@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.0

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 (92) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/backup/backup-service.d.ts +10 -1
  5. package/dist/backup/pg-tools.d.ts +47 -0
  6. package/dist/backup-service-CD8o_1Sl.js +8999 -0
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/connection-BuZ97wsr.js +250 -0
  10. package/dist/connection-BuZ97wsr.js.map +1 -0
  11. package/dist/connection.d.ts +42 -0
  12. package/dist/ensure-collection-policies-ViG8XiPn.js +57 -0
  13. package/dist/ensure-collection-policies-ViG8XiPn.js.map +1 -0
  14. package/dist/ensure-collection-tables-CBQdOETu.js +650 -0
  15. package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
  16. package/dist/index.es.js +1231 -10031
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/policy-CeA1JcxP.js +105 -0
  19. package/dist/policy-CeA1JcxP.js.map +1 -0
  20. package/dist/schema/auth-schema.d.ts +83 -144
  21. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  22. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  23. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  24. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  25. package/dist/schema/introspect-db-logic.d.ts +94 -5
  26. package/dist/schema/introspect-db-queries.d.ts +119 -0
  27. package/dist/schema/introspect-db-structure.d.ts +263 -0
  28. package/dist/schema/introspect-db-types.d.ts +11 -0
  29. package/dist/services/FetchService.d.ts +4 -1
  30. package/dist/services/RelationService.d.ts +24 -1
  31. package/dist/services/channel-bus/index.d.ts +1 -7
  32. package/dist/services/collection-helpers.d.ts +36 -2
  33. package/dist/services/dataService.d.ts +3 -1
  34. package/dist/services/row-pipeline.d.ts +1 -1
  35. package/dist/{src-BbFOPJ1S.js → src-DlPBctw_.js} +299 -173
  36. package/dist/src-DlPBctw_.js.map +1 -0
  37. package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
  38. package/dist/src-DoU9yPqq.js.map +1 -0
  39. package/dist/utils/connection-string.d.ts +29 -0
  40. package/dist/utils/drizzle-conditions.d.ts +5 -4
  41. package/dist/utils/pg-error-utils.d.ts +35 -0
  42. package/dist/websocket-B2LsrINK.js +530 -0
  43. package/dist/websocket-B2LsrINK.js.map +1 -0
  44. package/package.json +14 -14
  45. package/src/PostgresAdapter.ts +21 -2
  46. package/src/PostgresBackendDriver.ts +4 -0
  47. package/src/PostgresBootstrapper.ts +192 -33
  48. package/src/auth/ensure-tables.ts +164 -9
  49. package/src/auth/services.ts +24 -2
  50. package/src/backup/backup-cli.ts +41 -2
  51. package/src/backup/backup-service.ts +38 -5
  52. package/src/backup/pg-tools.ts +96 -3
  53. package/src/cli-helpers.ts +70 -0
  54. package/src/cli.ts +44 -26
  55. package/src/collections/validate-relations.ts +15 -0
  56. package/src/connection.ts +73 -0
  57. package/src/data-transformer.ts +9 -3
  58. package/src/databasePoolManager.ts +5 -2
  59. package/src/schema/auth-schema.ts +30 -19
  60. package/src/schema/ensure-collection-policies.ts +105 -0
  61. package/src/schema/ensure-collection-tables.test.ts +105 -9
  62. package/src/schema/ensure-collection-tables.ts +220 -32
  63. package/src/schema/generate-drizzle-schema-logic.ts +42 -6
  64. package/src/schema/generate-postgres-ddl-logic.ts +382 -19
  65. package/src/schema/introspect-db-constraints.ts +385 -0
  66. package/src/schema/introspect-db-inference.ts +18 -8
  67. package/src/schema/introspect-db-logic.ts +385 -71
  68. package/src/schema/introspect-db-queries.ts +326 -0
  69. package/src/schema/introspect-db-structure.ts +670 -0
  70. package/src/schema/introspect-db-types.ts +56 -0
  71. package/src/schema/introspect-db.ts +37 -80
  72. package/src/schema/introspect-runtime.test.ts +56 -8
  73. package/src/schema/introspect-runtime.ts +31 -9
  74. package/src/security/policy-drift.test.ts +11 -3
  75. package/src/services/BranchService.ts +66 -28
  76. package/src/services/FetchService.ts +90 -14
  77. package/src/services/PersistService.ts +20 -6
  78. package/src/services/RelationService.ts +249 -48
  79. package/src/services/channel-bus/index.ts +0 -9
  80. package/src/services/collection-helpers.ts +69 -3
  81. package/src/services/dataService.ts +3 -1
  82. package/src/services/realtimeService.ts +3 -3
  83. package/src/services/row-pipeline.ts +1 -1
  84. package/src/utils/connection-string.ts +58 -0
  85. package/src/utils/drizzle-conditions.ts +31 -6
  86. package/src/utils/pg-error-utils.ts +65 -0
  87. package/src/websocket.ts +18 -9
  88. package/dist/chunk-DSJWtz9O.js +0 -40
  89. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  90. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  91. package/dist/src-BbFOPJ1S.js.map +0 -1
  92. package/dist/src-Zqwaw3P5.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  import { and, eq, inArray, or, sql, SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { DrizzleClient } from "../interfaces";
4
- import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany } from "@rebasepro/types";
4
+ import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
5
5
  import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
6
6
  import { hasForeignKeyOnTarget, isManyToMany, type ResolvedVia } from "@rebasepro/types";
7
7
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
@@ -11,6 +11,8 @@ import {
11
11
  requirePrimaryKeys,
12
12
  parseIdValues,
13
13
  buildCompositeId,
14
+ joinsOnNaturalKey,
15
+ sourceKeyField,
14
16
  type PrimaryKeyInfo
15
17
  } from "./collection-helpers";
16
18
  import { parseDataFromServer } from "../data-transformer";
@@ -18,6 +20,39 @@ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionReg
18
20
  import { logger } from "@rebasepro/server";
19
21
  import type { NestedPathHop } from "./nested-path";
20
22
 
23
+ /**
24
+ * The ids in a to-many relation write, whatever shape the caller sent.
25
+ *
26
+ * A membership list is written as either the related rows (`[{ id: 1 }]`, what
27
+ * the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
28
+ * what anyone writing the API by hand sends). Only the first was read, via a
29
+ * blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
30
+ * on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
31
+ * and on a string-keyed one it did not surface at all — `String(undefined)`
32
+ * wrote a junction row pointing at the literal `"undefined"`, which no read
33
+ * would ever match. Both shapes are accepted here, in one place, because both
34
+ * call sites had the same assumption.
35
+ *
36
+ * An element that carries no key is refused rather than skipped: dropping it
37
+ * would silently write a shorter membership list than the caller asked for.
38
+ */
39
+ function relationTargetIds(value: unknown, relationName: string, collectionSlug: string): (string | number)[] {
40
+ if (!Array.isArray(value)) return [];
41
+
42
+ return value.map((element, index) => {
43
+ if (typeof element === "string" || typeof element === "number") return element;
44
+ if (element && typeof element === "object") {
45
+ const id = (element as { id?: unknown }).id;
46
+ if (typeof id === "string" || typeof id === "number") return id;
47
+ }
48
+ throw new Error(
49
+ `Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. ` +
50
+ "Pass either the related rows (`[{ id: … }]`) or their keys (`[1, 2]`), not " +
51
+ `${element === null ? "null" : typeof element}.`
52
+ );
53
+ });
54
+ }
55
+
21
56
  /**
22
57
  * Typed wrapper for Drizzle dynamic query innerJoin.
23
58
  * Drizzle's `$dynamic()` queries lose the `innerJoin` method from
@@ -135,18 +170,122 @@ export class RelationService {
135
170
  private assertSingleKeyAddressable(
136
171
  parentCollection: CollectionConfig,
137
172
  parentPks: PrimaryKeyInfo[],
138
- via: string
173
+ via: string,
174
+ relation?: ResolvedRelation
139
175
  ): void {
176
+ // A `sourceKey` names the single column the link actually points at, so
177
+ // the parent's key can be as wide as it likes — nothing here references
178
+ // it. That is the one way out of this error, and the message below now
179
+ // has to say so.
180
+ if (relation && hasForeignKeyOnTarget(relation) && relation.sourceKey) return;
181
+
140
182
  if (parentPks.length > 1) {
141
183
  throw new Error(
142
184
  `Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but ` +
143
185
  `'${parentCollection.slug}' is keyed on ${parentPks.map(k => `'${k.fieldName}'`).join(" + ")}. ` +
144
- `One column cannot reference a composite key — express this relation with \`joinPath\`, whose ` +
186
+ `One column cannot reference a composite key — give the relation a \`sourceKey\` naming ` +
187
+ `a single unique column to point at, or express it with \`joinPath\`, whose ` +
145
188
  `\`on.from\`/\`on.to\` take every key column.`
146
189
  );
147
190
  }
148
191
  }
149
192
 
193
+ /**
194
+ * What the target's foreign key holds, for each of these parent rows.
195
+ *
196
+ * Ordinarily the parent's id, and then this is free. When the relation
197
+ * declares a `sourceKey` the two are different values, and the mapping
198
+ * between them lives in the source table — so it costs one SELECT, issued
199
+ * once for the whole batch rather than per parent.
200
+ *
201
+ * Both directions come back because both are needed and deriving one from
202
+ * the other by hand is how a batch loader ends up attributing a child to the
203
+ * wrong parent: reads translate id → key to build the WHERE, and then
204
+ * translate key → id to attribute each row that comes back.
205
+ */
206
+ /**
207
+ * The value a related row's foreign key must hold to belong to this parent.
208
+ *
209
+ * `undefined` when the parent's source key is null — which is not an error
210
+ * here, only in the callers that were about to write it. Exposed for
211
+ * {@link PersistService}, which stamps this onto a child created under a
212
+ * nested path and would otherwise write the id and lose the row.
213
+ */
214
+ async parentKeyValue(
215
+ parentCollection: CollectionConfig,
216
+ relation: ResolvedHasOne | ResolvedHasMany,
217
+ parentId: string | number,
218
+ db: DrizzleClient = this.db
219
+ ): Promise<string | number | undefined> {
220
+ const { keyByParentId } = await this.resolveSourceKeys(parentCollection, relation, [parentId], db);
221
+ return keyByParentId.get(String(parentId));
222
+ }
223
+
224
+ private async resolveSourceKeys(
225
+ parentCollection: CollectionConfig,
226
+ relation: ResolvedHasOne | ResolvedHasMany,
227
+ parentIds: (string | number)[],
228
+ // Writes pass their transaction: reading the source key on the pool
229
+ // while the same transaction is holding an uncommitted change to it
230
+ // would translate the id against a stale value.
231
+ db: DrizzleClient = this.db
232
+ ): Promise<{ keyByParentId: Map<string, string | number>; parentIdByKey: Map<string, string | number> }> {
233
+ const keyByParentId = new Map<string, string | number>();
234
+ const parentIdByKey = new Map<string, string | number>();
235
+
236
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
237
+
238
+ if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
239
+ // The ordinary case, and it must stay free of a round-trip: the
240
+ // key IS the id. Parsed, not passed through — an id arrives as the
241
+ // string from a URL, and comparing "7" against an integer column is
242
+ // how this returns nothing at all.
243
+ for (const id of parentIds) {
244
+ const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
245
+ keyByParentId.set(String(id), value);
246
+ parentIdByKey.set(String(value), id);
247
+ }
248
+ return { keyByParentId, parentIdByKey };
249
+ }
250
+
251
+ const field = sourceKeyField(relation, parentCollection, this.registry);
252
+ const parentTable = getTableForCollection(parentCollection, this.registry);
253
+ const keyColumn = parentTable[field as keyof typeof parentTable] as AnyPgColumn;
254
+ if (!keyColumn) {
255
+ throw new Error(
256
+ `\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not a column on ` +
257
+ `'${parentCollection.slug}'. It names a column on the source table, not on the target.`
258
+ );
259
+ }
260
+
261
+ const rows = await db
262
+ .select()
263
+ .from(parentTable)
264
+ .where(this.parentKeyCondition(parentTable, parentPks, parentIds));
265
+
266
+ for (const row of rows as Array<Record<string, unknown>>) {
267
+ const keyValue = row[field] as string | number | null;
268
+ if (keyValue === null || keyValue === undefined) continue;
269
+ const parentId = buildCompositeId(row, parentPks);
270
+ keyByParentId.set(String(parentId), keyValue);
271
+ // A duplicate here means the source key is not unique, which makes
272
+ // "which parent does this child belong to" unanswerable. Refuse
273
+ // rather than pick the last one seen.
274
+ const existing = parentIdByKey.get(String(keyValue));
275
+ if (existing !== undefined && String(existing) !== String(parentId)) {
276
+ throw new Error(
277
+ `\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not unique on ` +
278
+ `'${parentCollection.slug}': rows '${existing}' and '${parentId}' both hold ` +
279
+ `'${keyValue}'. Add a unique constraint — a link that addresses more than one source ` +
280
+ `row cannot say which one a related row belongs to.`
281
+ );
282
+ }
283
+ parentIdByKey.set(String(keyValue), parentId);
284
+ }
285
+
286
+ return { keyByParentId, parentIdByKey };
287
+ }
288
+
150
289
  /**
151
290
  * Fetch rows related to a parent row through a specific relation
152
291
  */
@@ -259,7 +398,21 @@ export class RelationService {
259
398
  return rows;
260
399
  }
261
400
 
262
- // Handle other relation types
401
+ // Handle other relation types.
402
+ //
403
+ // The query builder compares the target's foreign key against a value,
404
+ // and for a link on a natural key that value is not the id in the URL.
405
+ // Resolved first, before anything is built, so a parent that reaches
406
+ // nothing costs one statement rather than two.
407
+ const matchValue = hasForeignKeyOnTarget(relation)
408
+ ? (await this.resolveSourceKeys(parentCollection, relation, [parentId]))
409
+ .keyByParentId.get(String(parentId))
410
+ : parsedParentId;
411
+
412
+ // A parent whose source key is null reaches nothing: NULL never equals
413
+ // a foreign key. Say so with an empty list rather than an `= NULL`.
414
+ if (matchValue === undefined) return [];
415
+
263
416
  let query = this.db.select().from(targetTable).$dynamic();
264
417
 
265
418
  // Build additional filter conditions
@@ -289,7 +442,7 @@ export class RelationService {
289
442
  query,
290
443
  query,
291
444
  relation,
292
- parsedParentId,
445
+ matchValue,
293
446
  targetTable,
294
447
  parentTable,
295
448
  parentIdCol,
@@ -367,6 +520,16 @@ export class RelationService {
367
520
  if (!parentTable) throw new Error("Parent table not found");
368
521
  const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
369
522
 
523
+ // Same translation the listing does, and it has to be: `isRelated`
524
+ // gates writes on this count, so a count built from a different value
525
+ // than the read would authorise rows the read never returned.
526
+ const matchValue = hasForeignKeyOnTarget(relation)
527
+ ? (await this.resolveSourceKeys(parentCollection, relation, [parentId]))
528
+ .keyByParentId.get(String(parentId))
529
+ : parsedParentId;
530
+
531
+ if (matchValue === undefined) return 0;
532
+
370
533
  // Start count with distinct to avoid duplicates from junction tables
371
534
  let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
372
535
 
@@ -374,7 +537,7 @@ export class RelationService {
374
537
  query = DrizzleConditionBuilder.buildRelationCountQuery(
375
538
  query,
376
539
  relation,
377
- parsedParentId,
540
+ matchValue,
378
541
  targetTable,
379
542
  parentTable,
380
543
  parentIdCol,
@@ -607,8 +770,22 @@ export class RelationService {
607
770
  this.assertSingleKeyAddressable(
608
771
  parentCollection,
609
772
  parentPks,
610
- hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
773
+ hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName,
774
+ relation
611
775
  );
776
+
777
+ // One lookup for the whole batch, both directions: the WHERE is built
778
+ // from the source-key values, and each row that comes back carries one
779
+ // of those values rather than a parent id.
780
+ const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation)
781
+ ? await this.resolveSourceKeys(parentCollection, relation, parentIds)
782
+ : { keyByParentId: new Map<string, string | number>(), parentIdByKey: new Map<string, string | number>() };
783
+
784
+ const matchValues = hasForeignKeyOnTarget(relation)
785
+ ? [...keyByParentId.values()]
786
+ : parsedParentIds;
787
+ if (matchValues.length === 0) return new Map();
788
+
612
789
  let query = this.db.select().from(targetTable).$dynamic();
613
790
 
614
791
  // Build the relation query with ALL parent IDs
@@ -616,7 +793,7 @@ export class RelationService {
616
793
  query,
617
794
  query,
618
795
  relation,
619
- parsedParentIds, // Pass array instead of single ID
796
+ matchValues, // Pass array instead of single ID
620
797
  targetTable,
621
798
  parentTable,
622
799
  parentIdCol,
@@ -628,28 +805,24 @@ export class RelationService {
628
805
  const results = await query;
629
806
  const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
630
807
 
631
- // Build a Set<string> for O(1) parent-ID lookups that is immune to
632
- // number-vs-string type mismatches (Drizzle may return either depending
633
- // on the column type and driver).
634
- const parentIdSet = new Set(parsedParentIds.map(String));
635
-
636
808
  // Map results back to parent rows
637
809
  for (const row of results as Array<Record<string, unknown>>) {
638
810
  const targetRow = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
639
811
 
640
- // Determine the parent ID this result belongs to based on the relation type
641
- let parentId: string | number | undefined;
642
-
643
812
  // The parent's key is on the target row, in the relation's own
644
813
  // column. There used to be a second branch here that guessed the
645
814
  // column by appending `_id` to `inverseRelationName`, reached only
646
815
  // when the foreign key had not been resolved — which cannot happen
647
816
  // now that resolution fills it in.
648
- if (hasForeignKeyOnTarget(relation)) {
649
- parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
650
- }
817
+ if (!hasForeignKeyOnTarget(relation)) continue;
818
+
819
+ // Keyed by string throughout: Drizzle returns a numeric column as a
820
+ // number or a string depending on the column type and the driver.
821
+ const foreignKeyValue = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
822
+ if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
651
823
 
652
- if (parentId !== undefined && parentIdSet.has(String(parentId))) {
824
+ const parentId = parentIdByKey.get(String(foreignKeyValue));
825
+ if (parentId !== undefined) {
653
826
  resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
654
827
  }
655
828
  }
@@ -778,15 +951,26 @@ export class RelationService {
778
951
  this.assertSingleKeyAddressable(
779
952
  parentCollection,
780
953
  parentPks,
781
- hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
954
+ hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName,
955
+ relation
782
956
  );
957
+
958
+ const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation)
959
+ ? await this.resolveSourceKeys(parentCollection, relation, parentIds)
960
+ : { keyByParentId: new Map<string, string | number>(), parentIdByKey: new Map<string, string | number>() };
961
+
962
+ const matchValues = hasForeignKeyOnTarget(relation)
963
+ ? [...keyByParentId.values()]
964
+ : parsedParentIds;
965
+ if (matchValues.length === 0) return new Map();
966
+
783
967
  let query = this.db.select().from(targetTable).$dynamic();
784
968
 
785
969
  query = applyDynamicRelationQuery(
786
970
  query,
787
971
  query,
788
972
  relation,
789
- parsedParentIds,
973
+ matchValues,
790
974
  targetTable,
791
975
  parentTable,
792
976
  parentIdCol,
@@ -798,23 +982,18 @@ export class RelationService {
798
982
  const results = await query;
799
983
  const resultMap = new Map<string, RelatedRow<Record<string, unknown>>[]>();
800
984
 
801
- // Build a Set<string> for O(1) parent-ID lookups that is immune to
802
- // number-vs-string type mismatches (Drizzle may return either depending
803
- // on the column type and driver).
804
- const parentIdSet = new Set(parsedParentIds.map(String));
805
-
806
985
  for (const row of results as Array<Record<string, unknown>>) {
807
986
  const targetRow = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
808
987
 
809
- let parentId: string | number | undefined;
810
-
811
988
  // Junction-backed relations returned earlier in this method, so
812
989
  // what reaches here names the parent with a column on the target.
813
- if (hasForeignKeyOnTarget(relation)) {
814
- parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
815
- }
990
+ if (!hasForeignKeyOnTarget(relation)) continue;
816
991
 
817
- if (parentId !== undefined && parentIdSet.has(String(parentId))) {
992
+ const foreignKeyValue = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
993
+ if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
994
+
995
+ const parentId = parentIdByKey.get(String(foreignKeyValue));
996
+ if (parentId !== undefined) {
818
997
  const key = String(parentId);
819
998
  const arr = resultMap.get(key) || [];
820
999
  arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
@@ -840,7 +1019,7 @@ export class RelationService {
840
1019
  const relation = findRelation(resolvedRelations, key);
841
1020
  if (!relation || relation.cardinality !== "many") continue;
842
1021
 
843
- const targetEntityIds = (value && Array.isArray(value)) ? value.map((rel: { id: string | number }) => rel.id) : [];
1022
+ const targetEntityIds = relationTargetIds(value, key, collection.slug);
844
1023
  const targetCollection = relation.target();
845
1024
 
846
1025
  // Use joinPath if available
@@ -961,10 +1140,19 @@ export class RelationService {
961
1140
  continue;
962
1141
  }
963
1142
 
964
- const parentPks = requirePrimaryKeys(collection, this.registry);
965
- const parentIdInfo = parentPks[0];
966
- const parsedParentIdObj = parseIdValues(id, parentPks);
967
- const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
1143
+ // What the children's foreign key must hold — the parent's id
1144
+ // unless the link declares a `sourceKey`, and then the value of
1145
+ // that column on this parent row.
1146
+ const parentKeyValue = (await this.resolveSourceKeys(collection, relation, [id], tx))
1147
+ .keyByParentId.get(String(id));
1148
+
1149
+ if (parentKeyValue === undefined) {
1150
+ throw new Error(
1151
+ `Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in ` +
1152
+ `\`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is ` +
1153
+ "nothing for the related rows to point at."
1154
+ );
1155
+ }
968
1156
 
969
1157
  // Clear existing links not in the new set
970
1158
  if (targetEntityIds.length > 0) {
@@ -972,19 +1160,19 @@ export class RelationService {
972
1160
  await tx
973
1161
  .update(targetTable)
974
1162
  .set({ [relation.foreignKeyOnTarget]: null })
975
- .where(and(eq(fkCol, parsedParentId), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
1163
+ .where(and(eq(fkCol, parentKeyValue), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
976
1164
 
977
1165
  // Set FK for the provided targets
978
1166
  await tx
979
1167
  .update(targetTable)
980
- .set({ [relation.foreignKeyOnTarget]: parsedParentId })
1168
+ .set({ [relation.foreignKeyOnTarget]: parentKeyValue })
981
1169
  .where(inArray(targetIdCol as AnyPgColumn, parsedTargetIds as unknown[]));
982
1170
  } else {
983
1171
  // If empty array provided, clear all existing links for this parent
984
1172
  await tx
985
1173
  .update(targetTable)
986
1174
  .set({ [relation.foreignKeyOnTarget]: null })
987
- .where(eq(fkCol, parsedParentId));
1175
+ .where(eq(fkCol, parentKeyValue));
988
1176
  }
989
1177
  } else {
990
1178
  logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
@@ -1062,14 +1250,25 @@ export class RelationService {
1062
1250
  continue;
1063
1251
  }
1064
1252
 
1065
- const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
1066
- const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
1253
+ // The value the target's foreign key holds: this row's id, or
1254
+ // the column named by `sourceKey` when the link joins on one.
1255
+ const sourceKeyValue = (await this.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx))
1256
+ .keyByParentId.get(String(sourceEntityId));
1257
+
1258
+ if (sourceKeyValue === undefined) {
1259
+ throw new Error(
1260
+ `Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row ` +
1261
+ `'${sourceEntityId}' has no value in \`sourceKey: ` +
1262
+ `"${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing ` +
1263
+ "for the related row to point at."
1264
+ );
1265
+ }
1067
1266
 
1068
1267
  if (newValue === null || newValue === undefined) {
1069
1268
  await tx
1070
1269
  .update(targetTable)
1071
1270
  .set({ [relation.foreignKeyOnTarget!]: null })
1072
- .where(eq(foreignKeyColumn, parsedSourceId));
1271
+ .where(eq(foreignKeyColumn, sourceKeyValue));
1073
1272
  } else {
1074
1273
  const parsedNewTargetIdObj = parseIdValues(newValue as string | number, targetPks);
1075
1274
  const parsedNewTargetId = parsedNewTargetIdObj[targetIdInfo.fieldName];
@@ -1079,12 +1278,12 @@ export class RelationService {
1079
1278
  await tx
1080
1279
  .update(targetTable)
1081
1280
  .set({ [relation.foreignKeyOnTarget!]: null })
1082
- .where(eq(foreignKeyColumn, parsedSourceId));
1281
+ .where(eq(foreignKeyColumn, sourceKeyValue));
1083
1282
 
1084
1283
  // Then, update the new target row to point to this source row
1085
1284
  await tx
1086
1285
  .update(targetTable)
1087
- .set({ [relation.foreignKeyOnTarget!]: parsedSourceId })
1286
+ .set({ [relation.foreignKeyOnTarget!]: sourceKeyValue })
1088
1287
  .where(eq(targetIdField, parsedNewTargetId));
1089
1288
  }
1090
1289
  } catch (e) {
@@ -1166,7 +1365,9 @@ export class RelationService {
1166
1365
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1167
1366
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1168
1367
  const targetIdInfo = targetPks[0];
1169
- const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel);
1368
+ // This path already read both shapes; the other two did not.
1369
+ // Same helper now, so the three cannot drift again.
1370
+ const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
1170
1371
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1171
1372
 
1172
1373
  const newLinks = parsedTargetIds.map(targetId => ({
@@ -1238,7 +1439,7 @@ export class RelationService {
1238
1439
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1239
1440
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1240
1441
  const targetIdInfo = targetPks[0];
1241
- const targetEntityIds = (newValue as Array<{ id: string | number }>).map((rel) => rel.id);
1442
+ const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
1242
1443
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1243
1444
 
1244
1445
  const newLinks = parsedTargetIds.map(targetId => ({
@@ -80,15 +80,6 @@ export function resolveChannelBusSetting(configured?: ChannelBusSetting): Channe
80
80
  return raw === "memory" ? { type: "memory" } : { type: "postgres" };
81
81
  }
82
82
 
83
- /**
84
- * @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
85
- * supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
86
- * config-only callers keep their exact types.
87
- */
88
- export function resolveChannelBusConfig(configured?: ChannelBusConfig): ChannelBusConfig {
89
- return resolveChannelBusSetting(configured) as ChannelBusConfig;
90
- }
91
-
92
83
  /**
93
84
  * Produce the bus a setting asks for.
94
85
  *
@@ -1,5 +1,5 @@
1
1
  import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
2
- import { CollectionConfig, Property } from "@rebasepro/types";
2
+ import { CollectionConfig, Property, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
3
3
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
4
4
  import { getTableName } from "@rebasepro/common";
5
5
  import { logger } from "@rebasepro/server";
@@ -7,9 +7,9 @@ import { logger } from "@rebasepro/server";
7
7
  // Row identity is derived on both sides of the wire — the driver parses an
8
8
  // incoming address into key columns, the admin derives one from a served row —
9
9
  // so the implementation lives in `common` and both agree by construction.
10
- export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
10
+ export { buildCompositeId, parseIdValues, isAddressableId, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
11
11
  export type { PrimaryKeyInfo } from "@rebasepro/common";
12
- import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys } from "@rebasepro/common";
12
+ import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys, isAddressableId } from "@rebasepro/common";
13
13
  import type { PrimaryKeyInfo } from "@rebasepro/common";
14
14
 
15
15
  /**
@@ -40,6 +40,33 @@ export function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta {
40
40
  };
41
41
  }
42
42
 
43
+ /**
44
+ * Whether an address could name a row in this table, judged by the columns.
45
+ *
46
+ * {@link getPrimaryKeys} lets a config's `isId: "uuid"` win over the schema, so
47
+ * its `isUUID` is a claim rather than a fact — right for deriving addresses,
48
+ * wrong for refusing a query. Here the Drizzle column type decides, because it
49
+ * is what Postgres will enforce: a `uuid` column meets `/c/products/new` with
50
+ * `22P02`, which aborts the surrounding transaction and turns every later
51
+ * statement into an unrelated-looking `25P02`.
52
+ */
53
+ export function idCanAddressTable(
54
+ id: string | number,
55
+ table: PgTable,
56
+ idInfoArray: PrimaryKeyInfo[]
57
+ ): boolean {
58
+ const columnBacked = idInfoArray.map(info => {
59
+ const col = table[info.fieldName as keyof typeof table] as AnyPgColumn | undefined;
60
+ const meta = col ? getColumnMeta(col) : undefined;
61
+ // No column to ask (a key the schema does not carry) leaves the id
62
+ // addressable: refusing on a guess would 404 rows that do exist.
63
+ if (!meta?.columnType) return info;
64
+ return { ...info,
65
+ isUUID: meta.columnType === "PgUUID" };
66
+ });
67
+ return isAddressableId(id, columnBacked);
68
+ }
69
+
43
70
  export function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig {
44
71
  const collection = registry.getCollectionByPath(collectionPath);
45
72
  if (!collection) {
@@ -149,6 +176,45 @@ export function requirePrimaryKeys(collection: CollectionConfig, registry: Postg
149
176
  return keys;
150
177
  }
151
178
 
179
+ /**
180
+ * The column on the *source* table that a `hasOne`/`hasMany` link points at.
181
+ *
182
+ * `sourceKey` is authored when the two sides join on a natural key — an
183
+ * external identity id, a SKU — and left off when they join on the row id,
184
+ * which is the overwhelming majority. That makes `undefined` the only optional
185
+ * field on a resolved relation, so it gets exactly one reader: this function.
186
+ * Every consumer that needs the column asks here, and none of them re-derives
187
+ * "or else the primary key" for itself. That is the whole point — the fallback
188
+ * chains this codebase removed from relation resolution were dangerous because
189
+ * they were *duplicated* and could disagree, not because they existed.
190
+ */
191
+ export function sourceKeyField(
192
+ relation: ResolvedHasOne | ResolvedHasMany,
193
+ sourceCollection: CollectionConfig,
194
+ registry: PostgresCollectionRegistry
195
+ ): string {
196
+ if (relation.sourceKey) return relation.sourceKey;
197
+ return requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
198
+ }
199
+
200
+ /**
201
+ * Whether this link joins on something other than the source's primary key.
202
+ *
203
+ * Callers that hold a parent *id* — which is most of them, since an id is what
204
+ * a URL carries — must translate it to the source key's value before it can be
205
+ * compared with the target's foreign key. Those that hold the parent *row*, or
206
+ * that build a correlated subquery over the source table, can read the column
207
+ * directly and skip the lookup.
208
+ */
209
+ export function joinsOnNaturalKey(
210
+ relation: ResolvedHasOne | ResolvedHasMany,
211
+ sourceCollection: CollectionConfig,
212
+ registry: PostgresCollectionRegistry
213
+ ): boolean {
214
+ if (!relation.sourceKey) return false;
215
+ return relation.sourceKey !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
216
+ }
217
+
152
218
  /**
153
219
  * Collections whose key the *browser* cannot resolve, and what it will do
154
220
  * instead.
@@ -1,5 +1,5 @@
1
1
  // import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
- import { FilterValues } from "@rebasepro/types";
2
+ import { FilterValues, LogicalCondition } from "@rebasepro/types";
3
3
  import type { VectorSearchParams } from "@rebasepro/types";
4
4
  import { FetchService } from "./FetchService";
5
5
  import { PersistService } from "./PersistService";
@@ -97,6 +97,8 @@ export class DataService implements DataRepository {
97
97
  collectionPath: string,
98
98
  options: {
99
99
  filter?: FilterValues<Extract<keyof M, string>>;
100
+ /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
101
+ logical?: LogicalCondition;
100
102
  searchString?: string;
101
103
  databaseId?: string;
102
104
  } = {}
@@ -4,7 +4,7 @@ import { Client as PgClient } from "pg";
4
4
  import { randomUUID } from "crypto";
5
5
  import { DataService } from "./dataService";
6
6
 
7
- import { FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
7
+ import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
8
8
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
9
  import { sql as drizzleSql } from "drizzle-orm";
10
10
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
@@ -751,7 +751,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
751
751
  // Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
752
752
  // Refetches are reads: apply the same GUCs + reader-role downgrade as the
753
753
  // driver's read path, so realtime cannot leak rows the initial fetch hid.
754
- const activeAuth = authContext || { uid: "anon",
754
+ const activeAuth = authContext || { uid: ANONYMOUS_USER_ID,
755
755
  roles: ["anon"] };
756
756
  return await this.db.transaction(async (tx) => {
757
757
  await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
@@ -931,7 +931,7 @@ roles: activeAuth.roles },
931
931
 
932
932
  // Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
933
933
  // Same read isolation as collection refetches: GUCs + reader-role downgrade.
934
- const activeAuth = authContext || { uid: "anon",
934
+ const activeAuth = authContext || { uid: ANONYMOUS_USER_ID,
935
935
  roles: ["anon"] };
936
936
  return await this.db.transaction(async (tx) => {
937
937
  await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
@@ -164,7 +164,7 @@ export function relationTargetAddress(
164
164
  * expects real types. The row's own address is *not* among the columns — it is
165
165
  * derived by the consumer from the collection's primary keys.
166
166
  */
167
- export function toCmsRow(
167
+ export function toFlatRow(
168
168
  row: Record<string, unknown>,
169
169
  collection: CollectionConfig,
170
170
  registry: PostgresCollectionRegistry