@rebasepro/server-postgres 0.19.1 → 0.19.2-canary.g08eed46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/{BranchService-ByLfM_Tt.js → BranchService-W3DMfcZZ.js} +2 -2
  2. package/dist/{BranchService-ByLfM_Tt.js.map → BranchService-W3DMfcZZ.js.map} +1 -1
  3. package/dist/PostgresBackendDriver.d.ts +81 -7
  4. package/dist/PostgresBootstrapper.d.ts +10 -0
  5. package/dist/{backup-cli-oM9WQVlV.js → backup-cli-DSpQyqcG.js} +2 -2
  6. package/dist/{backup-cli-oM9WQVlV.js.map → backup-cli-DSpQyqcG.js.map} +1 -1
  7. package/dist/{backup-service-Br3tqwsy.js → backup-service-DA7a6SUV.js} +2 -2
  8. package/dist/{backup-service-Br3tqwsy.js.map → backup-service-DA7a6SUV.js.map} +1 -1
  9. package/dist/{cli-errors-DxN4yzdH.js → cli-errors-C3g_kHBw.js} +137 -18
  10. package/dist/cli-errors-C3g_kHBw.js.map +1 -0
  11. package/dist/cli-errors.d.ts +19 -0
  12. package/dist/cli-helpers.d.ts +47 -0
  13. package/dist/cli.js +233 -19
  14. package/dist/cli.js.map +1 -1
  15. package/dist/{collection-index-BHgT_TKd.js → collection-index-DdnsxiJ_.js} +14 -4
  16. package/dist/{collection-index-BHgT_TKd.js.map → collection-index-DdnsxiJ_.js.map} +1 -1
  17. package/dist/collections/validate-relations.d.ts +7 -0
  18. package/dist/column-plan-helpers-CpILzHJS.js +145 -0
  19. package/dist/column-plan-helpers-CpILzHJS.js.map +1 -0
  20. package/dist/{doctor-D7HWmGzs.js → doctor-D5SrGJ5P.js} +175 -61
  21. package/dist/doctor-D5SrGJ5P.js.map +1 -0
  22. package/dist/{ensure-collection-policies-2c2NV8zX.js → ensure-collection-policies-CHO0moXQ.js} +5 -5
  23. package/dist/{ensure-collection-policies-2c2NV8zX.js.map → ensure-collection-policies-CHO0moXQ.js.map} +1 -1
  24. package/dist/{ensure-collection-tables-D4wJzM4y.js → ensure-collection-tables-Bkvehxdm.js} +318 -312
  25. package/dist/ensure-collection-tables-Bkvehxdm.js.map +1 -0
  26. package/dist/{ensure-tables-CIojK-Rx.js → ensure-tables-CYMtuuOd.js} +2 -2
  27. package/dist/{ensure-tables-CIojK-Rx.js.map → ensure-tables-CYMtuuOd.js.map} +1 -1
  28. package/dist/generate-drizzle-schema-logic-Dfyf_MRu.js +228 -0
  29. package/dist/generate-drizzle-schema-logic-Dfyf_MRu.js.map +1 -0
  30. package/dist/{generate-drizzle-schema-CwEgVyjO.js → generate-drizzle-schema-vSK-VdYT.js} +3 -3
  31. package/dist/generate-drizzle-schema-vSK-VdYT.js.map +1 -0
  32. package/dist/generate-postgres-ddl-logic-CR2xcS7e.js +475 -0
  33. package/dist/generate-postgres-ddl-logic-CR2xcS7e.js.map +1 -0
  34. package/dist/{generated-schema-staleness-DQdfJl2A.js → generated-schema-staleness-DRQe2BpC.js} +81 -3
  35. package/dist/{generated-schema-staleness-DQdfJl2A.js.map → generated-schema-staleness-DRQe2BpC.js.map} +1 -1
  36. package/dist/history/HistoryService.d.ts +23 -4
  37. package/dist/index.es.js +1991 -580
  38. package/dist/index.es.js.map +1 -1
  39. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -1
  40. package/dist/plan-schema-Hgl62S-w.js +1531 -0
  41. package/dist/plan-schema-Hgl62S-w.js.map +1 -0
  42. package/dist/{policy-drift-kNJ6X2Uj.js → policy-drift-B0GDRh7_.js} +2 -2
  43. package/dist/{policy-drift-kNJ6X2Uj.js.map → policy-drift-B0GDRh7_.js.map} +1 -1
  44. package/dist/{rls-bootstrap-sql-DLDnUjtx.js → rls-bootstrap-sql-BlzsOUtz.js} +111 -8
  45. package/dist/{rls-bootstrap-sql-DLDnUjtx.js.map → rls-bootstrap-sql-BlzsOUtz.js.map} +1 -1
  46. package/dist/{rls-enforcement-DeYEjYLt.js → rls-enforcement-BV12vBwQ.js} +2 -1
  47. package/dist/rls-enforcement-BV12vBwQ.js.map +1 -0
  48. package/dist/schema/catalogue-schema.d.ts +97 -0
  49. package/dist/schema/collection-index.d.ts +9 -0
  50. package/dist/schema/column-plan-helpers.d.ts +125 -0
  51. package/dist/schema/config-relations.d.ts +32 -0
  52. package/dist/schema/doctor-cli.js +4 -4
  53. package/dist/schema/doctor.d.ts +9 -0
  54. package/dist/schema/dynamic-tables.d.ts +27 -6
  55. package/dist/schema/ensure-collection-tables.d.ts +31 -268
  56. package/dist/schema/generate-drizzle-schema-logic.d.ts +26 -8
  57. package/dist/schema/generate-drizzle-schema.js +1 -1
  58. package/dist/schema/generate-postgres-ddl-logic.d.ts +84 -168
  59. package/dist/schema/generate-postgres-ddl.js +8 -2
  60. package/dist/schema/generate-postgres-ddl.js.map +1 -1
  61. package/dist/schema/generated-column-conflicts.d.ts +84 -0
  62. package/dist/schema/generated-schema-diff.d.ts +56 -0
  63. package/dist/schema/generated-schema-staleness.d.ts +33 -0
  64. package/dist/schema/introspect-db-logic.d.ts +2 -0
  65. package/dist/schema/plan/diff-plan.d.ts +220 -0
  66. package/dist/schema/plan/plan-schema.d.ts +84 -0
  67. package/dist/schema/plan/render-ddl.d.ts +62 -0
  68. package/dist/schema/plan/render-drizzle.d.ts +31 -0
  69. package/dist/schema/plan/types.d.ts +410 -0
  70. package/dist/schema/plan/updated-at-trigger.d.ts +22 -0
  71. package/dist/schema/relation-names.d.ts +28 -0
  72. package/dist/schema/search-column.d.ts +9 -0
  73. package/dist/security/rls-enforcement.d.ts +17 -0
  74. package/dist/services/FetchService.d.ts +176 -36
  75. package/dist/services/PersistService.d.ts +17 -4
  76. package/dist/services/RelationService.d.ts +11 -3
  77. package/dist/services/collection-helpers.d.ts +16 -2
  78. package/dist/services/dataService.d.ts +29 -2
  79. package/dist/services/field-op-sql.d.ts +16 -0
  80. package/dist/services/field-viewer.d.ts +19 -0
  81. package/dist/services/realtimeService.d.ts +35 -1
  82. package/dist/services/row-pipeline.d.ts +29 -5
  83. package/dist/services/soft-delete.d.ts +73 -0
  84. package/dist/utils/pg-error-utils.d.ts +41 -1
  85. package/dist/{vector-index-DkWxm2de.js → vector-index-CMJKb3C9.js} +3 -14
  86. package/dist/{vector-index-DkWxm2de.js.map → vector-index-CMJKb3C9.js.map} +1 -1
  87. package/package.json +18 -10
  88. package/dist/cli-errors-DxN4yzdH.js.map +0 -1
  89. package/dist/doctor-D7HWmGzs.js.map +0 -1
  90. package/dist/ensure-collection-tables-D4wJzM4y.js.map +0 -1
  91. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +0 -1
  92. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +0 -528
  93. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +0 -1
  94. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js +0 -1312
  95. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +0 -1
  96. package/dist/rls-enforcement-DeYEjYLt.js.map +0 -1
@@ -1,11 +1,12 @@
1
1
  import { SQL } from "drizzle-orm";
2
2
  import { PgTable } from "drizzle-orm/pg-core";
3
3
  import { FilterValues, OrderByTuple, LogicalCondition } from "@rebasepro/types";
4
- import type { VectorSearchParams } from "@rebasepro/types";
4
+ import type { IncludeSpec, VectorSearchParams } from "@rebasepro/types";
5
5
  import { RelationService } from "./RelationService.js";
6
6
  import { DrizzleClient } from "../interfaces.js";
7
7
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry.js";
8
8
  import { type NestedPathHop } from "./nested-path.js";
9
+ import { type WithDeleted } from "./soft-delete.js";
9
10
  /**
10
11
  * Service for handling all row read operations.
11
12
  * Handles fetching, searching, counting, and filtering rows.
@@ -99,6 +100,21 @@ export declare class FetchService {
99
100
  * one key of several still honours the ones that did resolve.
100
101
  */
101
102
  private resolveOrderKeys;
103
+ /**
104
+ * Whether this key's NULLs sort *after* its real values.
105
+ *
106
+ * The default is Postgres's own — `NULLS LAST` ascending, `NULLS FIRST`
107
+ * descending — and it was previously hardcoded in two places that had to
108
+ * agree: the `ORDER BY` and the keyset comparison behind cursor paging. A
109
+ * key that states a placement overrides it, in both, because they read the
110
+ * answer from here.
111
+ *
112
+ * The default is also the thing a "newest first" list gets wrong: every row
113
+ * with no date sorts to the very top, ahead of everything real, and the only
114
+ * way out used to be an `is-not-null` filter that dropped those rows
115
+ * entirely. `orderBy: [["published_at", "desc", "last"]]` is the fix.
116
+ */
117
+ private static nullsLast;
102
118
  /**
103
119
  * The full `ORDER BY`: the caller's keys, then the id.
104
120
  *
@@ -138,21 +154,95 @@ export declare class FetchService {
138
154
  */
139
155
  private getJunctionTargetRelationName;
140
156
  /**
141
- * Post-fetch joinPath relations for a single flat row.
142
- * joinPath relations cannot be expressed via Drizzle's `with` config,
143
- * so they must be loaded separately after the primary query.
157
+ * The relations one level of an include tree names, resolved.
158
+ *
159
+ * A name that is not a relation is a **400 `UNKNOWN_RELATION`**. It used to
160
+ * be dropped silently, which answers 200 with the field missing — and a
161
+ * missing relation field is indistinguishable from a row that genuinely has
162
+ * no related row, so a typo in an `include` looked like empty data.
144
163
  */
145
- private resolveJoinPathRelations;
164
+ private resolveIncludeLevel;
165
+ /**
166
+ * The extra `WHERE` an include's own `where`/`logical` puts on the target.
167
+ *
168
+ * Pushed into the batch query rather than applied to the rows it returns:
169
+ * filtering afterwards reads every related row of every parent to throw
170
+ * most of them away, and on a to-many relation that is the whole table.
171
+ */
172
+ private includeNarrowing;
146
173
  /**
147
- * Resolves joinPath relations for raw REST rows and directly injects them.
148
- * Uses RelationService to query the database and maps results back to the flattened objects.
174
+ * Sort, cap and project the rows of ONE to-many relation, per parent.
175
+ *
176
+ * `orderBy` and `limit` are applied here rather than in SQL because a limit
177
+ * on a batched relation load is *per parent*, and expressing that needs a
178
+ * LATERAL or a window function per relation kind — four of them, each with
179
+ * its own join shape. The rows were already narrowed by
180
+ * {@link includeNarrowing} in SQL, so what this sorts and cuts is the set
181
+ * the caller asked for and not the table.
182
+ */
183
+ private shapeRelatedRows;
184
+ /**
185
+ * Load an include tree onto `rows`, in place, one batched query per
186
+ * relation per level.
187
+ *
188
+ * Recurses into nested includes with the rows it just loaded, so
189
+ * `comments.author` is two queries for a page of posts rather than one per
190
+ * comment. The depth bound lives on the normalizer
191
+ * ({@link MAX_INCLUDE_DEPTH}); this asserts it again because the tree can
192
+ * also be built in-process, where nothing normalized it.
193
+ */
194
+ private loadIncludes;
195
+ /**
196
+ * The SELECT list for a read: the visible columns, narrowed to `fields`.
197
+ *
198
+ * `?fields=` used to be a trim of the *response* — every column read out of
199
+ * the database and most of them thrown away in JavaScript. It is a
200
+ * projection now, so asking for two columns of a wide row reads two
201
+ * columns; that is also what makes `distinct` mean anything.
202
+ *
203
+ * Two things survive a narrowing regardless:
204
+ *
205
+ * - the **primary key**, because a row nobody can address cannot be
206
+ * updated, deleted, or paged past — and `meta.nextCursor` is derived from
207
+ * it, so a projection without it would silently disable seeking. The one
208
+ * exception is `distinct`, below;
209
+ * - the exclusions. `excludeFromApi` and the generated search columns are
210
+ * removed *after* the narrowing, so naming one in `fields` does not
211
+ * un-hide it. That was the shape of the `?searchString=` leak: a path
212
+ * that skipped the projection returned a password hash the plain read
213
+ * correctly withheld.
214
+ *
215
+ * An unknown column name is a 400 rather than a silent omission: a caller
216
+ * who mistypes `?fields=titel` otherwise gets rows without titles and no
217
+ * hint why.
218
+ *
219
+ * **`distinct` drops the primary key.** Keeping it is what the refusal of
220
+ * `distinct` beside a search or vector query already describes: a value
221
+ * that differs on every row makes the whole row distinct by construction,
222
+ * so the query "would answer 200 having done nothing". A surrogate key does
223
+ * that more reliably than any score — `?fields=status&distinct=true` came
224
+ * back with one row per row, every one carrying its `id`. So a distinct
225
+ * read is a read of the named columns and nothing else. It addresses no
226
+ * rows, which is the honest shape for one: `cursorFor` finds no key on the
227
+ * row and issues no cursor, and the result is a set of values rather than a
228
+ * set of rows to update or delete.
229
+ */
230
+ private columnProjection;
231
+ /**
232
+ * The opaque cursor that continues a listing after `row`. See
233
+ * {@link RestFetchService.cursorFor}.
234
+ *
235
+ * Here rather than at the route because deriving it needs the collection's
236
+ * primary key, which may be named anything and span several columns — the
237
+ * driver's knowledge, not the HTTP layer's.
149
238
  */
150
- private resolveJoinPathRelationsBatchRest;
239
+ cursorFor(collectionPath: string, row: Record<string, unknown>, orderBy?: OrderByTuple[]): string | undefined;
151
240
  /**
152
- * Build db.query-compatible options from standard fetch options.
153
- * Handles filter, search, orderBy, limit, and cursor-based pagination.
241
+ * Post-fetch joinPath relations for a single flat row.
242
+ * joinPath relations cannot be expressed via Drizzle's `with` config,
243
+ * so they must be loaded separately after the primary query.
154
244
  */
155
- private buildDrizzleQueryOptions;
245
+ private resolveJoinPathRelations;
156
246
  /**
157
247
  * Extract cursor pagination conditions from startAfter options.
158
248
  *
@@ -195,7 +285,7 @@ export declare class FetchService {
195
285
  /**
196
286
  * Fetch a single row by ID
197
287
  */
198
- fetchOne<M extends Record<string, unknown>>(collectionPath: string, id: string | number, databaseId?: string): Promise<Record<string, unknown> | undefined>;
288
+ fetchOne<M extends Record<string, unknown>>(collectionPath: string, id: string | number, databaseId?: string, withDeleted?: WithDeleted): Promise<Record<string, unknown> | undefined>;
199
289
  /**
200
290
  * Unified method to fetch rows with optional search functionality
201
291
  */
@@ -213,6 +303,23 @@ export declare class FetchService {
213
303
  logical?: LogicalCondition;
214
304
  /** Narrow to the rows reachable from a parent through a relation. */
215
305
  relatedTo?: NestedPathHop;
306
+ /** See `FetchCollectionProps.withDeleted`. */
307
+ withDeleted?: WithDeleted;
308
+ /**
309
+ * Relations to load. **Absent means none** — the same as it means
310
+ * over REST.
311
+ *
312
+ * It used to mean *all of them*, unconditionally, and this is the
313
+ * method the realtime refetch goes through. So a subscription was
314
+ * pushed rows carrying every relation the collection declares while
315
+ * the identical `find()` returned rows carrying none, and a client
316
+ * that rendered both saw the row change shape when a write landed.
317
+ */
318
+ include?: IncludeSpec;
319
+ /** Columns to read — a projection pushed into the SELECT. */
320
+ fields?: string[];
321
+ /** `SELECT DISTINCT` over the projection. */
322
+ distinct?: boolean;
216
323
  }): Promise<Record<string, unknown>[]>;
217
324
  /**
218
325
  * Fallback path used when db.query is unavailable.
@@ -245,8 +352,15 @@ export declare class FetchService {
245
352
  offset?: number;
246
353
  startAfter?: Record<string, unknown>;
247
354
  searchString?: string;
355
+ searchExplain?: boolean;
248
356
  databaseId?: string;
249
357
  vectorSearch?: VectorSearchParams;
358
+ /** Relations to load. Absent means none — see `fetchRowsWithConditions`. */
359
+ include?: IncludeSpec;
360
+ /** Columns to read — a projection pushed into the SELECT. */
361
+ fields?: string[];
362
+ /** `SELECT DISTINCT` over the projection. */
363
+ distinct?: boolean;
250
364
  }): Promise<Record<string, unknown>[]>;
251
365
  /**
252
366
  * Search rows by text
@@ -285,6 +399,8 @@ export declare class FetchService {
285
399
  * request that was served three rows was told there were nine.
286
400
  */
287
401
  vectorSearch?: VectorSearchParams;
402
+ /** See `FetchCollectionProps.withDeleted`. */
403
+ withDeleted?: WithDeleted;
288
404
  }): Promise<number>;
289
405
  /**
290
406
  * `count`/`sum`/`avg`/`min`/`max`, optionally grouped.
@@ -312,6 +428,8 @@ export declare class FetchService {
312
428
  logical?: LogicalCondition;
313
429
  searchString?: string;
314
430
  limit?: number;
431
+ /** See `FetchCollectionProps.withDeleted`. */
432
+ withDeleted?: WithDeleted;
315
433
  }): Promise<Record<string, unknown>[]>;
316
434
  /**
317
435
  * Check if a field value is unique
@@ -322,12 +440,24 @@ export declare class FetchService {
322
440
  */
323
441
  getRelationService(): RelationService;
324
442
  /**
325
- * Fetch a collection of rows with optional relation includes.
326
- * When `include` is provided, only the specified relations are populated
327
- * with full row data (not just { id, path, __type }).
328
- * When `include` is absent, no relation queries are made (fast path).
443
+ * Fetch a collection of rows, with the relations `include` names.
444
+ *
445
+ * ## Why this no longer uses `db.query.findMany({ with })`
446
+ *
447
+ * It used to, whenever a `db.query` builder existed for the table — which
448
+ * is Drizzle's relational API, and which compiles a to-many relation into a
449
+ * lateral join. This file's own comment on the *other* read path says what
450
+ * that costs: "catastrophically slow for large collections (7s+ for 350
451
+ * rows)". So the admin fetch avoided it and REST took it, and the fast,
452
+ * batched loader that the admin used was unreachable from the HTTP API.
453
+ *
454
+ * There is one loader now, {@link loadIncludes}, and every read reaches it:
455
+ * one query per relation per level, never one per row. That also makes this
456
+ * method and the realtime refetch the same code, which is what stops
457
+ * `find()` and `listen()` returning different shapes for one query.
329
458
  *
330
- * @param include - Array of relation keys to populate, or ["*"] for all
459
+ * @param include - see {@link IncludeSpec}: names, dotted paths, `["*"]`,
460
+ * or the parametrised tree. An unknown name is a 400 `UNKNOWN_RELATION`.
331
461
  */
332
462
  fetchCollectionForRest<M extends Record<string, unknown>>(collectionPath: string, options?: {
333
463
  filter?: FilterValues<Extract<keyof M, string>>;
@@ -343,28 +473,38 @@ export declare class FetchService {
343
473
  vectorSearch?: VectorSearchParams;
344
474
  /** Narrow to the rows reachable from a parent through a relation. */
345
475
  relatedTo?: NestedPathHop;
346
- }, include?: string[]): Promise<Record<string, unknown>[]>;
476
+ /**
477
+ * See `FetchCollectionProps.withDeleted`. Applied by
478
+ * `fetchRowsWithConditionsRaw` — the one place this pipeline builds
479
+ * a WHERE — so it holds for the listing, the nested-path listing
480
+ * and the realtime refetch alike.
481
+ */
482
+ withDeleted?: WithDeleted;
483
+ /** Columns to read — a projection pushed into the SELECT. */
484
+ fields?: string[];
485
+ /** `SELECT DISTINCT` over the projection. */
486
+ distinct?: boolean;
487
+ /** Ask each row which declared search fields matched. */
488
+ searchExplain?: boolean;
489
+ }, include?: IncludeSpec): Promise<Record<string, unknown>[]>;
347
490
  /**
348
- * Fetch a single row with optional relation includes for REST API.
349
- */
350
- fetchOneForRest<M extends Record<string, unknown>>(collectionPath: string, id: string | number, include?: string[], databaseId?: string): Promise<Record<string, unknown> | null>;
491
+ * Fetch a single row, with the relations `include` names.
492
+ *
493
+ * The same two steps `fetchCollectionForRest` takes, for one row, through
494
+ * the same {@link loadIncludes}. It used to be a separate implementation on
495
+ * `db.query.findFirst({ with })` with a hand-written N+1 fallback beneath
496
+ * it, and the two disagreed: the primary path returned a to-one relation as
497
+ * the target's columns, the fallback merged an `id` over them, and a
498
+ * relation that resolved to nothing was *absent* on one path and `null` on
499
+ * the other. `find()[0]` and `findById()` now answer with the same row.
500
+ */
501
+ fetchOneForRest<M extends Record<string, unknown>>(collectionPath: string, id: string | number, include?: IncludeSpec, databaseId?: string, options?: {
502
+ fields?: string[];
503
+ /** See `FetchCollectionProps.withDeleted`. */
504
+ withDeleted?: WithDeleted;
505
+ }): Promise<Record<string, unknown> | null>;
351
506
  /**
352
507
  * Fetch raw rows without any relation processing (for REST fast path)
353
508
  */
354
509
  private fetchRowsWithConditionsRaw;
355
- /**
356
- * Check if the Drizzle instance has the relational query API available
357
- * for a given collection path.
358
- * Note: Primary path now uses inline `getQueryBuilder()` checks.
359
- */
360
- private hasDrizzleQueryAPI;
361
- /**
362
- * Fallback path used when db.query is unavailable.
363
- * The primary path uses db.query.findMany with `with` config, which
364
- * loads all relations in a single query.
365
- *
366
- * Batch fetch many-to-many related rows for multiple parent IDs.
367
- * Groups results by parent ID to avoid N+1.
368
- */
369
- private batchFetchManyRelatedRows;
370
510
  }
@@ -51,13 +51,21 @@ export declare class PersistService {
51
51
  * Save an row (create or update)
52
52
  *
53
53
  * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
54
- * UPDATE against the primary key rather than a plain UPDATE. That is one
55
- * statement, so it cannot lose a race the way a read-then-write can, and it
56
- * does not care whether the row already exists — which is what a re-runnable
57
- * import needs.
54
+ * UPDATE rather than a plain UPDATE. That is one statement, so it cannot
55
+ * lose a race the way a read-then-write can, and it does not care whether
56
+ * the row already exists — which is what a re-runnable import needs. The
57
+ * conflict is matched on the primary key unless `options.onConflict` names
58
+ * other columns; see {@link SaveProps.onConflict} for why that matters.
59
+ *
60
+ * `values` may carry field operations (`{ views: { $inc: 1 } }`). They are
61
+ * split out here rather than at the REST boundary because every request
62
+ * boundary — HTTP, the WebSocket, in-process `context.data` — reaches this
63
+ * one method, and a rule applied at one door is a rule the other doors do
64
+ * not have. What they compile to is `field-op-sql.ts`.
58
65
  */
59
66
  save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string, options?: {
60
67
  upsert?: boolean;
68
+ onConflict?: readonly string[];
61
69
  }): Promise<Record<string, unknown>>;
62
70
  /**
63
71
  * Get the RelationService instance for external use
@@ -76,6 +84,11 @@ export declare class PersistService {
76
84
  getFetchService(): FetchService;
77
85
  /**
78
86
  * Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
87
+ *
88
+ * `collection` is passed so the SQLSTATE can be turned into a *field*: the
89
+ * error carries a physical column and the caller only ever sees wire names,
90
+ * so `author_id` had to be translated to `authorId` by whoever read the
91
+ * message — which nothing does. See `pgFieldViolations`.
79
92
  */
80
93
  private toUserFriendlyError;
81
94
  }
@@ -1,3 +1,4 @@
1
+ import { SQL } from "drizzle-orm";
1
2
  import { DrizzleClient } from "../interfaces.js";
2
3
  import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
3
4
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry.js";
@@ -136,13 +137,20 @@ export declare class RelationService {
136
137
  */
137
138
  isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean>;
138
139
  /**
139
- * Batch fetch related rows for multiple parent rows to avoid N+1 queries
140
+ * Batch fetch related rows for multiple parent rows to avoid N+1 queries.
141
+ *
142
+ * `narrow` is an extra condition on the *target* table — what a per-include
143
+ * `where` compiles to. It is pushed into the query rather than applied to
144
+ * the rows that come back, because filtering afterwards reads every related
145
+ * row of every parent in order to discard most of them.
140
146
  */
141
- batchFetchRelatedEntities(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation): Promise<Map<string, RelatedRow<Record<string, unknown>>>>;
147
+ batchFetchRelatedEntities(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation, narrow?: SQL): Promise<Map<string, RelatedRow<Record<string, unknown>>>>;
142
148
  /**
143
149
  * Batch fetch many-cardinality related rows for multiple parent rows.
144
150
  * Returns a Map<parentId, RelatedRow[]> instead of Map<parentId, RelatedRow>.
145
151
  * Uses a single SQL query with IN clause to avoid N+1.
146
152
  */
147
- batchFetchRelatedEntitiesMany(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>>;
153
+ batchFetchRelatedEntitiesMany(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation,
154
+ /** An extra condition on the target — see {@link batchFetchRelatedEntities}. */
155
+ narrow?: SQL): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>>;
148
156
  }
@@ -38,6 +38,14 @@ export declare function getCollectionByPath(collectionPath: string, registry: Po
38
38
  /**
39
39
  * Reject a write naming something that is not a column of the table.
40
40
  *
41
+ * "The table" is the one read back from `information_schema` at boot — see
42
+ * `catalogue-schema.ts` — so this asks the database, not a file. It used to ask
43
+ * the generated module, which made the commonest cause of this error a stale
44
+ * `schema.generated.ts`: a property added to a collection was created in the
45
+ * database by boot-ensure and still answered `400` on its first save, because
46
+ * the module had not been regenerated. That cause no longer exists, and the
47
+ * remedy below no longer names it.
48
+ *
41
49
  * Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
42
50
  * from `Object.keys(tableColumns)`, so a key the table does not carry is not
43
51
  * rejected by anything — it is *left out of the statement*. The insert answers
@@ -81,10 +89,16 @@ export declare function getTableForCollection(collection: CollectionConfig, regi
81
89
  * The key columns a collection's rows are addressed by.
82
90
  *
83
91
  * Three tiers, in order: properties marked `isId`, the primary keys of the
84
- * drizzle schema, and finally a column literally named `id`. Only the first is
85
- * visible to the browser, which is why a key known only to drizzle is reported
92
+ * registered table, and finally a column literally named `id`. Only the first is
93
+ * visible to the browser, which is why a key known only to the table is reported
86
94
  * at boot — see {@link warnOnKeysTheAdminCannotResolve}.
87
95
  *
96
+ * The second tier reads the table the registry holds, which is built from
97
+ * `information_schema` — the database's own primary key, not a generated file's
98
+ * claim about it. Composite keys are declared as a table constraint rather than
99
+ * on each column (that is the only legal way to say it), so both shapes are
100
+ * asked for.
101
+ *
88
102
  * Returns `[]` when nothing resolves, rather than throwing. It used to open by
89
103
  * resolving the table, which throws when there is none — so the `isId` tier,
90
104
  * which needs no table at all, was unreachable for exactly the collections
@@ -1,6 +1,7 @@
1
- import { FilterValues, LogicalCondition, OrderByTuple } from "@rebasepro/types";
1
+ import { FilterValues, IncludeSpec, LogicalCondition, OrderByTuple } from "@rebasepro/types";
2
2
  import type { VectorSearchParams } from "@rebasepro/types";
3
3
  import { FetchService } from "./FetchService.js";
4
+ import type { WithDeleted } from "./soft-delete.js";
4
5
  import { PersistService } from "./PersistService.js";
5
6
  import { RelationService } from "./RelationService.js";
6
7
  import { DataRepository, DrizzleClient } from "../interfaces.js";
@@ -30,7 +31,9 @@ export declare class DataService implements DataRepository {
30
31
  /**
31
32
  * Fetch a single row by ID
32
33
  */
33
- fetchOne<M extends Record<string, unknown>>(collectionPath: string, id: string | number, databaseId?: string): Promise<Record<string, unknown> | undefined>;
34
+ fetchOne<M extends Record<string, unknown>>(collectionPath: string, id: string | number, databaseId?: string,
35
+ /** See `FetchCollectionProps.withDeleted`. */
36
+ withDeleted?: WithDeleted): Promise<Record<string, unknown> | undefined>;
34
37
  /**
35
38
  * Fetch a collection of rows with optional filtering, ordering, and pagination
36
39
  */
@@ -46,7 +49,28 @@ export declare class DataService implements DataRepository {
46
49
  searchString?: string;
47
50
  databaseId?: string;
48
51
  vectorSearch?: VectorSearchParams;
52
+ /** See `FetchCollectionProps.withDeleted`. */
53
+ withDeleted?: WithDeleted;
49
54
  }): Promise<Record<string, unknown>[]>;
55
+ /**
56
+ * The REST read pipeline: flat rows, with exactly the relations `include`
57
+ * names — see {@link FetchService.fetchCollectionForRest}.
58
+ *
59
+ * Exposed on the facade because it is what *every* consumer should be
60
+ * reading through. The realtime refetch used to go via `fetchCollection`
61
+ * above, which nests a relation under a `{ __type: "relation" }` envelope
62
+ * and loaded every relation regardless, so a subscription and a REST list
63
+ * answered the same query with two different row shapes.
64
+ */
65
+ fetchCollectionForRest<M extends Record<string, unknown>>(collectionPath: string, options?: Parameters<FetchService["fetchCollectionForRest"]>[1], include?: IncludeSpec): Promise<Record<string, unknown>[]>;
66
+ /** One row, through the same pipeline. See {@link FetchService.fetchOneForRest}. */
67
+ fetchOneForRest<M extends Record<string, unknown>>(collectionPath: string, id: string | number, include?: IncludeSpec, databaseId?: string, options?: {
68
+ fields?: string[];
69
+ /** See `FetchCollectionProps.withDeleted`. */
70
+ withDeleted?: WithDeleted;
71
+ }): Promise<Record<string, unknown> | null>;
72
+ /** See {@link FetchService.cursorFor}. */
73
+ cursorFor(collectionPath: string, row: Record<string, unknown>, orderBy?: OrderByTuple[]): string | undefined;
50
74
  /**
51
75
  * Search rows by text
52
76
  */
@@ -71,6 +95,8 @@ export declare class DataService implements DataRepository {
71
95
  databaseId?: string;
72
96
  /** Only the `threshold` narrows the count — see `FetchService.count`. */
73
97
  vectorSearch?: VectorSearchParams;
98
+ /** See `FetchCollectionProps.withDeleted`. */
99
+ withDeleted?: WithDeleted;
74
100
  }): Promise<number>;
75
101
  /**
76
102
  * Check if a field value is unique in a collection
@@ -93,6 +119,7 @@ export declare class DataService implements DataRepository {
93
119
  */
94
120
  save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string, options?: {
95
121
  upsert?: boolean;
122
+ onConflict?: readonly string[];
96
123
  }): Promise<Record<string, unknown>>;
97
124
  /**
98
125
  * Delete an row by ID
@@ -0,0 +1,16 @@
1
+ import { SQL } from "drizzle-orm";
2
+ import { PgTable } from "drizzle-orm/pg-core";
3
+ import type { ParsedFieldOp } from "@rebasepro/server";
4
+ /**
5
+ * The expression one operation compiles to, or a 400 saying why it cannot.
6
+ *
7
+ * `field` is the property key; it is already known to be a column of `table`
8
+ * (the caller checks that against the same table it builds the statement from).
9
+ */
10
+ export declare function compileFieldOp(table: PgTable, field: string, op: ParsedFieldOp, context: {
11
+ collectionPath: string;
12
+ }): SQL;
13
+ /** Compile a whole map of operations into a `SET`-ready object. */
14
+ export declare function compileFieldOps(table: PgTable, fieldOps: Record<string, ParsedFieldOp>, context: {
15
+ collectionPath: string;
16
+ }): Record<string, SQL>;
@@ -0,0 +1,19 @@
1
+ import type { FieldViewer } from "@rebasepro/common";
2
+ /**
3
+ * Run `fn` with `viewer` as the caller every row strip judges against.
4
+ *
5
+ * Nested calls shadow: a `dataAsAdmin` read inside a user request runs under the
6
+ * service identity for its duration and the user's viewer is restored after it,
7
+ * which is the behaviour both halves need and the reason this is not a variable.
8
+ */
9
+ export declare function withFieldViewer<T>(viewer: FieldViewer | undefined, fn: () => T): T;
10
+ /**
11
+ * The caller the current read is for, or `undefined` on the trusted server plane
12
+ * — an in-process `rebase.data` call on the base driver, a migration, the auth
13
+ * adapter reading a password hash to verify it.
14
+ *
15
+ * Absence cannot be reached from outside: every API ingress scopes its driver
16
+ * with `withAuth`, including an unauthenticated one (`roles: ["anon"]`), and
17
+ * that is what establishes the viewer.
18
+ */
19
+ export declare function currentFieldViewer(): FieldViewer | undefined;
@@ -1,6 +1,6 @@
1
1
  import { WebSocket } from "ws";
2
2
  import { EventEmitter } from "events";
3
- import { DataDriver, WebSocketMessage, LogicalCondition, OrderByTuple } from "@rebasepro/types";
3
+ import { DataDriver, IncludeSpec, WebSocketMessage, LogicalCondition, OrderByTuple } from "@rebasepro/types";
4
4
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
5
5
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces.js";
6
6
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry.js";
@@ -21,6 +21,15 @@ export interface SubscriptionAuthContext {
21
21
  * makes a realtime leak invisible from the surface people test.
22
22
  */
23
23
  isAnonymous?: boolean;
24
+ /**
25
+ * The custom claims on the socket's token, for the same reason
26
+ * `isAnonymous` is here: a refetch has to evaluate the same policies
27
+ * against the same principal the initial fetch did. A tenancy policy reads
28
+ * a claim, so a subscription that carried none would answer every frame
29
+ * from the tenant of nobody — which is no rows, silently, on a collection
30
+ * whose first page loaded fine.
31
+ */
32
+ claims?: Record<string, unknown>;
24
33
  }
25
34
  /** What a channel frame is asking to do. */
26
35
  export type ChannelAction = "join" | "broadcast" | "presence" | "history";
@@ -72,6 +81,20 @@ type StoredCollectionRequest = {
72
81
  searchString?: string;
73
82
  /** Ask each row which declared search field matched — populates `_matches`. */
74
83
  searchExplain?: boolean;
84
+ /**
85
+ * Relations to load, exactly as a REST list would.
86
+ *
87
+ * A subscription could not name any, and the refetch loaded **every**
88
+ * relation because it passed none — so `find()` returned a row with a
89
+ * foreign key and `listen()` returned the same row with a nested object
90
+ * where that key was. Same query, two shapes, and a client rendering both
91
+ * saw the row change the moment a write landed.
92
+ */
93
+ include?: IncludeSpec;
94
+ /** Columns to return — the same projection `?fields=` asks for. */
95
+ fields?: string[];
96
+ /** `SELECT DISTINCT` over the projection. */
97
+ distinct?: boolean;
75
98
  };
76
99
  /**
77
100
  * A registered subscription, plus the two counters that order its deliveries.
@@ -356,6 +379,17 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
356
379
  * with set_config calls so PostgreSQL RLS policies are enforced.
357
380
  */
358
381
  private fetchCollectionWithAuth;
382
+ /**
383
+ * The `meta` a `collection_update` frame carries beside its rows.
384
+ *
385
+ * Frames used to carry rows and primary keys and nothing else, so the
386
+ * client issued a `GET /count` **per push** to fill in a total it needed to
387
+ * render the same list it had just been handed — one extra round trip per
388
+ * write, per subscriber, forever. The refetch already knows the query; it
389
+ * counts once, here, under the same RLS transaction that read the rows, so
390
+ * the total describes the same set they came from.
391
+ */
392
+ private collectionMetaWithAuth;
359
393
  /**
360
394
  * Debounce an row refetch for a WebSocket subscription.
361
395
  */
@@ -29,17 +29,41 @@ export type RelationStyle = "ref" | "inline";
29
29
  * to unwrap that same level back out.
30
30
  */
31
31
  export declare function isJunctionRelation(relation: ResolvedRelation): boolean;
32
+ /**
33
+ * Apply {@link toRestScalar} across a row, leaving undeclared columns alone.
34
+ *
35
+ * Exported because the include loader attaches related rows itself, and a
36
+ * target rendered differently from its parent is the shape bug this whole file
37
+ * exists to prevent — a date was a string at the top level and a
38
+ * `{ __type: "date" }` envelope one level down, in the same response.
39
+ */
40
+ export declare function toRestValues(row: Record<string, unknown>, collection: CollectionConfig): Record<string, unknown>;
32
41
  /** Render one target row in the requested style. */
33
42
  /**
34
- * Drop every column the collection marked `excludeFromApi`.
43
+ * Drop every column this caller may not read.
35
44
  *
36
45
  * Password hashes and verification tokens have to be readable server-side but
37
46
  * must never reach a client — and "never" has to mean every exit from this
38
47
  * pipeline, including relation targets, or a secret leaks through whichever
39
48
  * path was overlooked. Keyed by both the property name and its column name,
40
49
  * since a row can arrive keyed either way depending on the caller.
50
+ *
51
+ * `excludeFromApi` is the case with no roles in it: `effectiveAccess` expands
52
+ * the flag to `read: []`, which no caller satisfies, so the flag needs no branch
53
+ * of its own here and no longer has one. `access.read: ["hr"]` is the same walk
54
+ * asking a different question of {@link currentFieldViewer}.
55
+ *
56
+ * The field is **deleted**, never nulled. A withheld value that arrives as
57
+ * `null` is indistinguishable from a stored `null`, which turns a permission
58
+ * boundary into a question a client can answer by counting nulls — and it makes
59
+ * an `update` that echoes the row back overwrite the real value with the null it
60
+ * was handed.
61
+ *
62
+ * `_matches` is filtered rather than deleted: it is the list of fields a text
63
+ * search hit, and a field the caller cannot read must not appear in it even
64
+ * though the array itself is theirs to see.
41
65
  */
42
- export declare function stripExcluded(row: Record<string, unknown>, collection: CollectionConfig): Record<string, unknown>;
66
+ export declare function stripUnreadable(row: Record<string, unknown>, collection: CollectionConfig): Record<string, unknown>;
43
67
  /**
44
68
  * The address a relation ref points at.
45
69
  *
@@ -65,9 +89,9 @@ export declare function toFlatRow(row: Record<string, unknown>, collection: Coll
65
89
  *
66
90
  * Values are the ones the database returned, except where that contradicts the
67
91
  * declared type: a `number` property is served as a number (see
68
- * {@link coerceDeclaredNumber}). Dates stay as the database returned them
69
- * JSON has its own opinions about dates that the admin's view-model does not
70
- * share.
92
+ * {@link coerceDeclaredNumber}) and a `date` as RFC 3339, which is what this
93
+ * server's own OpenAPI document says a date column is (see
94
+ * {@link toRestDate}).
71
95
  *
72
96
  * Keyed by the row rather than by the relation list — a REST fetch only loads
73
97
  * the relations `include` asked for, so the row is the authority on which are