@rebasepro/server-postgres 0.9.1-canary.742f831 → 0.9.1-canary.7dddf96

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.
@@ -1,4 +1,4 @@
1
- import { and, eq, inArray, or, sql, SQL } from "drizzle-orm";
1
+ import { and, eq, inArray, sql, SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { DrizzleClient } from "../interfaces";
4
4
  import { CollectionConfig, FilterValues, Relation } from "@rebasepro/types";
@@ -7,10 +7,9 @@ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
7
  import {
8
8
  getCollectionByPath,
9
9
  getTableForCollection,
10
- requirePrimaryKeys,
10
+ getPrimaryKeys,
11
11
  parseIdValues,
12
- buildCompositeId,
13
- type PrimaryKeyInfo
12
+ buildCompositeId
14
13
  } from "./collection-helpers";
15
14
  import { parseDataFromServer } from "../data-transformer";
16
15
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
@@ -57,94 +56,6 @@ export interface RelatedRow<M extends Record<string, unknown> = Record<string, u
57
56
  export class RelationService {
58
57
  constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) { }
59
58
 
60
- /**
61
- * One target row, as the {@link RelatedRow} everything here returns.
62
- *
63
- * Eight sites built this by hand, which is how the address came to be the
64
- * target's first key column in all eight — one edit, eight places to miss.
65
- *
66
- * `resolveNested` is the one thing they did not agree on, and the
67
- * disagreement was invisible: the single-parent fetches pass `db` and
68
- * `registry` to `parseDataFromServer`, so the target's *own* relations get
69
- * resolved too, while the batch paths deliberately do not — a query per
70
- * target row is the N+1 the batching exists to avoid. Naming the parameter
71
- * makes that a decision rather than a difference between two call sites
72
- * nobody was comparing.
73
- */
74
- private async toRelatedRow<M extends Record<string, unknown>>(
75
- targetRow: Record<string, unknown>,
76
- targetCollection: CollectionConfig,
77
- targetPks: PrimaryKeyInfo[],
78
- options?: { resolveNested?: boolean }
79
- ): Promise<RelatedRow<M>> {
80
- const values = options?.resolveNested
81
- ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry)
82
- : await parseDataFromServer(targetRow, targetCollection);
83
-
84
- return {
85
- // The whole key: a composite target addressed by its first column
86
- // names every row that shares it.
87
- id: buildCompositeId(targetRow, targetPks),
88
- path: targetCollection.slug,
89
- values: values as M
90
- };
91
- }
92
-
93
- /**
94
- * A WHERE matching any of `parentIds`, by the whole key.
95
- *
96
- * A single key is an `IN (…)`. A composite one cannot be: matching
97
- * `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
98
- * share their first column each receive the other's relations. It becomes
99
- * an OR of ANDs — one exact address per parent — which Postgres indexes the
100
- * same way it would a multi-column key lookup.
101
- */
102
- private parentKeyCondition(
103
- parentTable: PgTable,
104
- parentPks: PrimaryKeyInfo[],
105
- parentIds: (string | number)[]
106
- ): SQL {
107
- const columnFor = (fieldName: string) => {
108
- const col = parentTable[fieldName as keyof typeof parentTable] as AnyPgColumn;
109
- if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
110
- return col;
111
- };
112
-
113
- if (parentPks.length === 1) {
114
- const values = parentIds.map(id => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
115
- return inArray(columnFor(parentPks[0].fieldName), values);
116
- }
117
-
118
- const perParent = parentIds.map(id => {
119
- const values = parseIdValues(id, parentPks);
120
- return and(...parentPks.map(pk => eq(columnFor(pk.fieldName), values[pk.fieldName])));
121
- });
122
- return or(...perParent) as SQL;
123
- }
124
-
125
- /**
126
- * Reject a relation that cannot express a composite-keyed parent.
127
- *
128
- * `localKey` and `foreignKeyOnTarget` are single column names: one column
129
- * cannot reference a two-column key, so such a relation has no correct
130
- * reading. Left alone it would silently match on the first key column and
131
- * hand a tenant's rows to its neighbour — say so instead.
132
- */
133
- private assertSingleKeyAddressable(
134
- parentCollection: CollectionConfig,
135
- parentPks: PrimaryKeyInfo[],
136
- via: string
137
- ): void {
138
- if (parentPks.length > 1) {
139
- throw new Error(
140
- `Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but ` +
141
- `'${parentCollection.slug}' is keyed on ${parentPks.map(k => `'${k.fieldName}'`).join(" + ")}. ` +
142
- `One column cannot reference a composite key — express this relation with \`joinPath\`, whose ` +
143
- `\`on.from\`/\`on.to\` take every key column.`
144
- );
145
- }
146
- }
147
-
148
59
  /**
149
60
  * Fetch rows related to a parent row through a specific relation
150
61
  */
@@ -193,10 +104,10 @@ export class RelationService {
193
104
  ): Promise<RelatedRow<M>[]> {
194
105
  const targetCollection = relation.target();
195
106
  const targetTable = getTableForCollection(targetCollection, this.registry);
196
- const idInfo = requirePrimaryKeys(targetCollection, this.registry);
107
+ const idInfo = getPrimaryKeys(targetCollection, this.registry);
197
108
  const idField = targetTable[idInfo[0].fieldName as keyof typeof targetTable] as AnyPgColumn;
198
109
 
199
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
110
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
200
111
  const parentIdInfo = parentPks[0];
201
112
  const parsedParentIdObj = parseIdValues(parentId, parentPks);
202
113
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -237,7 +148,7 @@ export class RelationService {
237
148
  }
238
149
 
239
150
  // Add where condition for the parent row
240
- const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
151
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
241
152
  query = query.where(eq(parentIdField, parsedParentId));
242
153
 
243
154
  if (options.limit) {
@@ -251,7 +162,15 @@ export class RelationService {
251
162
  const rows: RelatedRow<M>[] = [];
252
163
  for (const row of results as Array<Record<string, unknown>>) {
253
164
  const targetRow = (row[targetTableName] as Record<string, unknown>) || row;
254
- rows.push(await this.toRelatedRow<M>(targetRow, targetCollection, idInfo, { resolveNested: true }));
165
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
166
+
167
+ rows.push({
168
+ // The whole key: the first column of a composite one names
169
+ // every row that shares it.
170
+ id: buildCompositeId(targetRow, idInfo),
171
+ path: targetCollection.slug,
172
+ values: parsedValues as M
173
+ });
255
174
  }
256
175
 
257
176
  return rows;
@@ -310,7 +229,13 @@ export class RelationService {
310
229
  const rows: RelatedRow<M>[] = [];
311
230
  for (const row of results) {
312
231
  const targetRow = row[getTableName(targetCollection)] || row;
313
- rows.push(await this.toRelatedRow<M>(targetRow as Record<string, unknown>, targetCollection, idInfo, { resolveNested: true }));
232
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
233
+
234
+ rows.push({
235
+ id: buildCompositeId(targetRow as Record<string, unknown>, idInfo),
236
+ path: targetCollection.slug,
237
+ values: parsedValues as M
238
+ });
314
239
  }
315
240
 
316
241
  return rows;
@@ -335,11 +260,11 @@ export class RelationService {
335
260
 
336
261
  const targetCollection = relation.target();
337
262
  const targetTable = getTableForCollection(targetCollection, this.registry);
338
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
263
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
339
264
  const targetIdInfo = targetPks[0];
340
265
  const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
341
266
 
342
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
267
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
343
268
  const parentIdInfo = parentPks[0];
344
269
  const parsedParentIdObj = parseIdValues(parentId, parentPks);
345
270
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -384,11 +309,11 @@ export class RelationService {
384
309
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
385
310
  const targetCollection = relation.target();
386
311
  const targetTable = getTableForCollection(targetCollection, this.registry);
387
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
312
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
388
313
  const targetIdInfo = targetPks[0];
389
314
  const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
390
315
 
391
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
316
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
392
317
  const parentIdInfo = parentPks[0];
393
318
  const parentTable = this.registry.getTable(getTableName(parentCollection));
394
319
  if (!parentTable) throw new Error("Parent table not found");
@@ -429,23 +354,27 @@ export class RelationService {
429
354
  currentTable = joinTable;
430
355
  }
431
356
 
432
- // Match every parent at once, each by its whole key.
433
- query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
357
+ // Add where condition for ALL parent rows at once
358
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
359
+ query = query.where(inArray(parentIdField, parsedParentIds));
434
360
 
435
361
  const results = await query;
436
362
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
437
363
  const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
438
364
 
439
- // Group by the parent's address — the same token the caller looks
440
- // results up by, derived the same way on both sides.
365
+ // Group results by parent ID
441
366
  for (const row of results as Array<Record<string, unknown>>) {
442
367
  const parentRow = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
443
368
  const targetRow = (row[targetTableName] || row) as Record<string, unknown>;
369
+ const parentId = parentRow[parentIdInfo.fieldName] as string | number;
370
+
371
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
444
372
 
445
- resultMap.set(
446
- buildCompositeId(parentRow, parentPks),
447
- await this.toRelatedRow(targetRow, targetCollection, targetPks)
448
- );
373
+ resultMap.set(String(parentId), {
374
+ id: buildCompositeId(targetRow, targetPks),
375
+ path: targetCollection.slug,
376
+ values: parsedValues as Record<string, unknown>
377
+ });
449
378
  }
450
379
 
451
380
  return resultMap;
@@ -458,7 +387,6 @@ export class RelationService {
458
387
  // 2. Query the target table with unique FK values
459
388
  // 3. Map results back to parent rows via their FK values
460
389
  if (relation.direction === "owning" && relation.localKey) {
461
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
462
390
  const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
463
391
  if (!localKeyCol) {
464
392
  throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
@@ -508,21 +436,19 @@ export class RelationService {
508
436
  for (const [parentIdStr, fkValue] of parentToFk) {
509
437
  const targetRow = targetById.get(String(fkValue));
510
438
  if (targetRow) {
511
- resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
439
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
440
+ resultMap.set(parentIdStr, {
441
+ id: buildCompositeId(targetRow, targetPks),
442
+ path: targetCollection.slug,
443
+ values: parsedValues as Record<string, unknown>
444
+ });
512
445
  }
513
446
  }
514
447
 
515
448
  return resultMap;
516
449
  }
517
450
 
518
- // Handle inverse relation types with batching. The parent is named by a
519
- // single FK column on the target, so a composite-keyed parent has no
520
- // correct reading here either.
521
- this.assertSingleKeyAddressable(
522
- parentCollection,
523
- parentPks,
524
- relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`
525
- );
451
+ // Handle inverse relation types with batching
526
452
  let query = this.db.select().from(targetTable).$dynamic();
527
453
 
528
454
  // Build the relation query with ALL parent IDs
@@ -562,7 +488,12 @@ export class RelationService {
562
488
  }
563
489
 
564
490
  if (parentId !== undefined && parentIdSet.has(String(parentId))) {
565
- resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
491
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
492
+ resultMap.set(String(parentId), {
493
+ id: buildCompositeId(targetRow, targetPks),
494
+ path: targetCollection.slug,
495
+ values: parsedValues as Record<string, unknown>
496
+ });
566
497
  }
567
498
  }
568
499
 
@@ -585,11 +516,11 @@ export class RelationService {
585
516
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
586
517
  const targetCollection = relation.target();
587
518
  const targetTable = getTableForCollection(targetCollection, this.registry);
588
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
519
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
589
520
  const targetIdInfo = targetPks[0];
590
521
  const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
591
522
 
592
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
523
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
593
524
  const parentIdInfo = parentPks[0];
594
525
  const parentTable = this.registry.getTable(getTableName(parentCollection));
595
526
  if (!parentTable) throw new Error("Parent table not found");
@@ -619,7 +550,8 @@ export class RelationService {
619
550
  currentTable = joinTable;
620
551
  }
621
552
 
622
- query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
553
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
554
+ query = query.where(inArray(parentIdField, parsedParentIds));
623
555
 
624
556
  const results = await query;
625
557
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
@@ -628,9 +560,15 @@ export class RelationService {
628
560
  for (const row of results as Array<Record<string, unknown>>) {
629
561
  const parentRow = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
630
562
  const targetRow = (row[targetTableName] || row) as Record<string, unknown>;
631
- const parentId = buildCompositeId(parentRow, parentPks);
563
+ const parentId = String(parentRow[parentIdInfo.fieldName]);
564
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
565
+
632
566
  const arr = resultMap.get(parentId) || [];
633
- arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
567
+ arr.push({
568
+ id: buildCompositeId(targetRow, targetPks),
569
+ path: targetCollection.slug,
570
+ values: parsedValues as Record<string, unknown>
571
+ });
634
572
  resultMap.set(parentId, arr);
635
573
  }
636
574
 
@@ -641,9 +579,6 @@ export class RelationService {
641
579
  // This is the standard path for posts→tags style relations where
642
580
  // sanitizeRelation populated the `through` config.
643
581
  if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
644
- // The junction names its parent with one column, so the same
645
- // single-key limit applies as for a direct foreign key.
646
- this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
647
582
  const junctionTable = this.registry.getTable(relation.through.table);
648
583
  if (!junctionTable) {
649
584
  logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
@@ -677,21 +612,21 @@ export class RelationService {
677
612
  const targetData = (row[targetTableName] || row) as Record<string, unknown>;
678
613
 
679
614
  const parentId = String(junctionData[relation.through.sourceColumn]);
615
+ const parsedValues = await parseDataFromServer(targetData, targetCollection);
616
+
680
617
  const arr = resultMap.get(parentId) || [];
681
- arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
618
+ arr.push({
619
+ id: buildCompositeId(targetData, targetPks),
620
+ path: targetCollection.slug,
621
+ values: parsedValues as Record<string, unknown>
622
+ });
682
623
  resultMap.set(parentId, arr);
683
624
  }
684
625
 
685
626
  return resultMap;
686
627
  }
687
628
 
688
- // Handle FK-based relations (one-to-many inverse). One column on the
689
- // target names the parent, so a composite-keyed parent cannot be named.
690
- this.assertSingleKeyAddressable(
691
- parentCollection,
692
- parentPks,
693
- relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`
694
- );
629
+ // Handle FK-based relations (one-to-many inverse)
695
630
  let query = this.db.select().from(targetTable).$dynamic();
696
631
 
697
632
  query = applyDynamicRelationQuery(
@@ -733,9 +668,14 @@ export class RelationService {
733
668
  }
734
669
 
735
670
  if (parentId !== undefined && parentIdSet.has(String(parentId))) {
671
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
736
672
  const key = String(parentId);
737
673
  const arr = resultMap.get(key) || [];
738
- arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
674
+ arr.push({
675
+ id: buildCompositeId(targetRow, targetPks),
676
+ path: targetCollection.slug,
677
+ values: parsedValues as Record<string, unknown>
678
+ });
739
679
  resultMap.set(key, arr);
740
680
  }
741
681
  }
@@ -806,7 +746,7 @@ export class RelationService {
806
746
  continue;
807
747
  }
808
748
 
809
- const parentPks = requirePrimaryKeys(collection, this.registry);
749
+ const parentPks = getPrimaryKeys(collection, this.registry);
810
750
  const parentIdInfo = parentPks[0];
811
751
  const parsedParentIdObj = parseIdValues(id, parentPks);
812
752
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -815,7 +755,7 @@ export class RelationService {
815
755
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
816
756
 
817
757
  if (targetEntityIds.length > 0) {
818
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
758
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
819
759
  const targetIdInfo = targetPks[0];
820
760
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
821
761
 
@@ -844,7 +784,7 @@ export class RelationService {
844
784
  continue;
845
785
  }
846
786
 
847
- const parentPks = requirePrimaryKeys(collection, this.registry);
787
+ const parentPks = getPrimaryKeys(collection, this.registry);
848
788
  const parentIdInfo = parentPks[0];
849
789
  const parsedParentIdObj = parseIdValues(id, parentPks);
850
790
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -853,7 +793,7 @@ export class RelationService {
853
793
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
854
794
 
855
795
  if (targetEntityIds.length > 0) {
856
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
796
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
857
797
  const targetIdInfo = targetPks[0];
858
798
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
859
799
 
@@ -873,7 +813,7 @@ export class RelationService {
873
813
  } else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
874
814
  // Handle one-to-many (inverse) by updating target FK to point to parent
875
815
  const targetTable = getTableForCollection(targetCollection, this.registry);
876
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
816
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
877
817
  const targetIdInfo = targetPks[0];
878
818
  const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
879
819
  const fkCol = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
@@ -883,7 +823,7 @@ export class RelationService {
883
823
  continue;
884
824
  }
885
825
 
886
- const parentPks = requirePrimaryKeys(collection, this.registry);
826
+ const parentPks = getPrimaryKeys(collection, this.registry);
887
827
  const parentIdInfo = parentPks[0];
888
828
  const parsedParentIdObj = parseIdValues(id, parentPks);
889
829
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -933,9 +873,9 @@ export class RelationService {
933
873
  try {
934
874
  const targetCollection = relation.target();
935
875
  const targetTable = getTableForCollection(targetCollection, this.registry);
936
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
876
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
937
877
  const targetIdInfo = targetPks[0];
938
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
878
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
939
879
  const sourceIdInfo = sourcePks[0];
940
880
 
941
881
  // Handle inverse relations with joinPath
@@ -1092,7 +1032,7 @@ export class RelationService {
1092
1032
  }
1093
1033
 
1094
1034
  // Perform the junction table update
1095
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
1035
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
1096
1036
  const sourceIdInfo = sourcePks[0];
1097
1037
  const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
1098
1038
  const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
@@ -1102,7 +1042,7 @@ export class RelationService {
1102
1042
 
1103
1043
  // Add new entries if newValue is provided
1104
1044
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1105
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1045
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
1106
1046
  const targetIdInfo = targetPks[0];
1107
1047
  const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel);
1108
1048
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
@@ -1117,7 +1057,7 @@ export class RelationService {
1117
1057
  }
1118
1058
  } else if (newValue && !Array.isArray(newValue)) {
1119
1059
  // Single value for one-to-one
1120
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1060
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
1121
1061
  const targetIdInfo = targetPks[0];
1122
1062
  const targetId = typeof newValue === "object" && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
1123
1063
  const parsedTargetIdObj = parseIdValues(targetId, targetPks);
@@ -1164,7 +1104,7 @@ export class RelationService {
1164
1104
  return;
1165
1105
  }
1166
1106
 
1167
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
1107
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
1168
1108
  const sourceIdInfo = sourcePks[0];
1169
1109
  const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
1170
1110
  const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
@@ -1174,7 +1114,7 @@ export class RelationService {
1174
1114
 
1175
1115
  // Add new entries if newValue is provided
1176
1116
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1177
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1117
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
1178
1118
  const targetIdInfo = targetPks[0];
1179
1119
  const targetEntityIds = (newValue as Array<{ id: string | number }>).map((rel) => rel.id);
1180
1120
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
@@ -1211,14 +1151,14 @@ export class RelationService {
1211
1151
  const { relation, newTargetId } = upd;
1212
1152
  const targetCollection = relation.target();
1213
1153
  const targetTable = getTableForCollection(targetCollection, this.registry);
1214
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1154
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
1215
1155
  const targetIdInfo = targetPks[0];
1216
1156
  const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
1217
1157
 
1218
1158
  // Determine mapping of columns
1219
1159
  const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
1220
1160
  const parentTable = getTableForCollection(parentCollection, this.registry);
1221
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
1161
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
1222
1162
  const parentIdInfo = parentPks[0];
1223
1163
  const parsedParentIdObj = parseIdValues(parentId, parentPks);
1224
1164
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -1343,7 +1283,7 @@ parentSourceColName };
1343
1283
  }
1344
1284
 
1345
1285
  // Parse the new row ID to the correct type
1346
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1286
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
1347
1287
  const targetIdInfo = targetPks[0];
1348
1288
  const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
1349
1289
  const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
@@ -58,27 +58,10 @@ export function getTableForCollection(collection: CollectionConfig, registry: Po
58
58
  return table;
59
59
  }
60
60
 
61
- /**
62
- * The key columns a collection's rows are addressed by.
63
- *
64
- * Three tiers, in order: properties marked `isId`, the primary keys of the
65
- * drizzle schema, and finally a column literally named `id`. Only the first is
66
- * visible to the browser, which is why a key known only to drizzle is reported
67
- * at boot — see {@link warnOnKeysTheAdminCannotResolve}.
68
- *
69
- * Returns `[]` when nothing resolves, rather than throwing. It used to open by
70
- * resolving the table, which throws when there is none — so the `isId` tier,
71
- * which needs no table at all, was unreachable for exactly the collections
72
- * most likely to have no table registered. Every caller that wanted "no keys"
73
- * to mean "no keys" had to spell that out in a try/catch.
74
- *
75
- * Callers that cannot proceed without a key must say so themselves, naming the
76
- * collection: an empty array here means "this collection has no address", which
77
- * is a different answer in a notification (broadcast a wildcard) than in a save
78
- * (fail).
79
- */
80
61
  export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[] {
81
- // Explicitly declared `isId` properties win, and need no table.
62
+ const table = getTableForCollection(collection, registry);
63
+
64
+ // Fallback to explicitly defined isId properties
82
65
  if (collection.properties) {
83
66
  const idProps = Object.entries(collection.properties)
84
67
  .filter(([_, prop]) => "isId" in (prop as object) && Boolean((prop as { isId?: unknown }).isId))
@@ -93,12 +76,6 @@ export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresC
93
76
  }
94
77
  }
95
78
 
96
- // The remaining tiers read the drizzle schema, so they need the table. A
97
- // collection without one — another engine's, or simply unregistered — has
98
- // nothing more to offer.
99
- const table = registry.getTable(getTableName(collection));
100
- if (!table) return [];
101
-
102
79
  // Otherwise infer from Drizzle schema
103
80
  const keys: PrimaryKeyInfo[] = [];
104
81
  for (const [key, colRaw] of Object.entries(table)) {
@@ -128,27 +105,6 @@ isUUID });
128
105
  return keys;
129
106
  }
130
107
 
131
- /**
132
- * The key columns, for callers that cannot do their job without one.
133
- *
134
- * {@link getPrimaryKeys} answers "what keys, if any" and returns `[]` for a
135
- * collection with no address. Most of this driver, though, is building a WHERE
136
- * clause and has no meaning without a key — for those, an empty array is not an
137
- * answer, and indexing `[0]` into it produces `Cannot read properties of
138
- * undefined` three frames from where the real problem is. This says what is
139
- * wrong and which collection it is wrong about.
140
- */
141
- export function requirePrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[] {
142
- const keys = getPrimaryKeys(collection, registry);
143
- if (keys.length === 0) {
144
- throw new Error(
145
- `Collection '${collection.slug}' has no primary key, so its rows cannot be addressed. ` +
146
- `Mark the key property with \`isId\` in its config, or register a table whose schema declares one.`
147
- );
148
- }
149
- return keys;
150
- }
151
-
152
108
  /**
153
109
  * Collections whose key the *browser* cannot resolve, and what it will do
154
110
  * instead.
@@ -182,9 +138,14 @@ export function findUnresolvableKeyCollections(
182
138
  // Declared `isId` is the tier both sides share: if it is there, they agree.
183
139
  if (getDeclaredPrimaryKeys(collection).length > 0) continue;
184
140
 
185
- // No registered table resolves to no keys, and a collection this cannot
186
- // resolve a key for is one it has nothing to say about.
187
- const keys = getPrimaryKeys(collection, registry);
141
+ let keys: PrimaryKeyInfo[];
142
+ try {
143
+ keys = getPrimaryKeys(collection, registry);
144
+ } catch {
145
+ // No registered table — nothing resolved here either, so there is no
146
+ // disagreement to report.
147
+ continue;
148
+ }
188
149
  if (keys.length === 0) continue;
189
150
 
190
151
  // A single key named `id` is the last tier on both sides: they agree
@@ -246,21 +207,28 @@ export function warnOnKeysTheAdminCannotResolve(
246
207
  * The address of a row: derived from the collection's primary keys, because a
247
208
  * row does not carry one — it is exactly its columns.
248
209
  *
249
- * Falls back to a literal `id` column, for a row that reached us from somewhere
250
- * other than this driver. Returns `""` when there is no key and no `id` —
251
- * callers decide what that means, since "unaddressable" is a different answer
252
- * in a notification (broadcast a wildcard) than in a save (fail).
210
+ * Falls back to a literal `id` column, which covers the two cases where the
211
+ * keys cannot be resolved: a row that reached us from somewhere other than the
212
+ * postgres driver, and a collection whose table the registry cannot look up
213
+ * (`getPrimaryKeys` throws for an unregistered table rather than returning
214
+ * nothing). Returns `""` when there is no key and no `id` — callers decide what
215
+ * that means, since "unaddressable" is a different answer in a notification
216
+ * (broadcast a wildcard) than in a save (fail).
253
217
  */
254
218
  export function deriveRowAddress(
255
219
  row: Record<string, unknown>,
256
220
  collection: CollectionConfig,
257
221
  registry: PostgresCollectionRegistry
258
222
  ): string {
259
- const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
260
- // An all-empty composite is indistinguishable from a missing key, and
261
- // `buildCompositeId` returns "" for no keys at all.
262
- if (composite && composite.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "")) {
263
- return composite;
223
+ try {
224
+ const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
225
+ // An all-empty composite is indistinguishable from a missing key, and
226
+ // `buildCompositeId` returns "" for no keys at all.
227
+ if (composite && composite.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "")) {
228
+ return composite;
229
+ }
230
+ } catch {
231
+ /* unresolvable table or keys — fall through to the literal column */
264
232
  }
265
233
  if (row.id !== undefined && row.id !== null) return String(row.id);
266
234
  return "";
@@ -963,10 +963,8 @@ roles: activeAuth.roles },
963
963
  const keys = getPrimaryKeys(collection, this.registry);
964
964
  return keys.length > 0 ? keys : undefined;
965
965
  } catch {
966
- // `getCollectionByPath` throws on a path it cannot walk and this
967
- // is called for parent paths too, which include entity paths like
968
- // `posts/1` that name no collection. Telling the subscriber nothing
969
- // is right here; letting it throw would drop the notification.
966
+ // Unregistered table, or a relation path with no collection of its
967
+ // own: the client keeps its pre-existing `id` behaviour.
970
968
  return undefined;
971
969
  }
972
970
  }