@rebasepro/server-postgres 0.9.1-canary.7dddf96 → 0.9.1-canary.97e305f

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, sql, SQL } from "drizzle-orm";
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
4
  import { CollectionConfig, FilterValues, Relation } from "@rebasepro/types";
@@ -7,9 +7,10 @@ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
7
  import {
8
8
  getCollectionByPath,
9
9
  getTableForCollection,
10
- getPrimaryKeys,
10
+ requirePrimaryKeys,
11
11
  parseIdValues,
12
- buildCompositeId
12
+ buildCompositeId,
13
+ type PrimaryKeyInfo
13
14
  } from "./collection-helpers";
14
15
  import { parseDataFromServer } from "../data-transformer";
15
16
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
@@ -56,6 +57,94 @@ export interface RelatedRow<M extends Record<string, unknown> = Record<string, u
56
57
  export class RelationService {
57
58
  constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) { }
58
59
 
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
+
59
148
  /**
60
149
  * Fetch rows related to a parent row through a specific relation
61
150
  */
@@ -104,10 +193,10 @@ export class RelationService {
104
193
  ): Promise<RelatedRow<M>[]> {
105
194
  const targetCollection = relation.target();
106
195
  const targetTable = getTableForCollection(targetCollection, this.registry);
107
- const idInfo = getPrimaryKeys(targetCollection, this.registry);
196
+ const idInfo = requirePrimaryKeys(targetCollection, this.registry);
108
197
  const idField = targetTable[idInfo[0].fieldName as keyof typeof targetTable] as AnyPgColumn;
109
198
 
110
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
199
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
111
200
  const parentIdInfo = parentPks[0];
112
201
  const parsedParentIdObj = parseIdValues(parentId, parentPks);
113
202
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -148,7 +237,7 @@ export class RelationService {
148
237
  }
149
238
 
150
239
  // Add where condition for the parent row
151
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
240
+ const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
152
241
  query = query.where(eq(parentIdField, parsedParentId));
153
242
 
154
243
  if (options.limit) {
@@ -162,15 +251,7 @@ export class RelationService {
162
251
  const rows: RelatedRow<M>[] = [];
163
252
  for (const row of results as Array<Record<string, unknown>>) {
164
253
  const targetRow = (row[targetTableName] as Record<string, unknown>) || row;
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
- });
254
+ rows.push(await this.toRelatedRow<M>(targetRow, targetCollection, idInfo, { resolveNested: true }));
174
255
  }
175
256
 
176
257
  return rows;
@@ -229,13 +310,7 @@ export class RelationService {
229
310
  const rows: RelatedRow<M>[] = [];
230
311
  for (const row of results) {
231
312
  const targetRow = row[getTableName(targetCollection)] || row;
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
- });
313
+ rows.push(await this.toRelatedRow<M>(targetRow as Record<string, unknown>, targetCollection, idInfo, { resolveNested: true }));
239
314
  }
240
315
 
241
316
  return rows;
@@ -260,11 +335,11 @@ export class RelationService {
260
335
 
261
336
  const targetCollection = relation.target();
262
337
  const targetTable = getTableForCollection(targetCollection, this.registry);
263
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
338
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
264
339
  const targetIdInfo = targetPks[0];
265
340
  const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
266
341
 
267
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
342
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
268
343
  const parentIdInfo = parentPks[0];
269
344
  const parsedParentIdObj = parseIdValues(parentId, parentPks);
270
345
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -309,11 +384,11 @@ export class RelationService {
309
384
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
310
385
  const targetCollection = relation.target();
311
386
  const targetTable = getTableForCollection(targetCollection, this.registry);
312
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
387
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
313
388
  const targetIdInfo = targetPks[0];
314
389
  const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
315
390
 
316
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
391
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
317
392
  const parentIdInfo = parentPks[0];
318
393
  const parentTable = this.registry.getTable(getTableName(parentCollection));
319
394
  if (!parentTable) throw new Error("Parent table not found");
@@ -354,27 +429,23 @@ export class RelationService {
354
429
  currentTable = joinTable;
355
430
  }
356
431
 
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));
432
+ // Match every parent at once, each by its whole key.
433
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
360
434
 
361
435
  const results = await query;
362
436
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
363
437
  const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
364
438
 
365
- // Group results by parent ID
439
+ // Group by the parent's address — the same token the caller looks
440
+ // results up by, derived the same way on both sides.
366
441
  for (const row of results as Array<Record<string, unknown>>) {
367
442
  const parentRow = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
368
443
  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);
372
444
 
373
- resultMap.set(String(parentId), {
374
- id: buildCompositeId(targetRow, targetPks),
375
- path: targetCollection.slug,
376
- values: parsedValues as Record<string, unknown>
377
- });
445
+ resultMap.set(
446
+ buildCompositeId(parentRow, parentPks),
447
+ await this.toRelatedRow(targetRow, targetCollection, targetPks)
448
+ );
378
449
  }
379
450
 
380
451
  return resultMap;
@@ -387,6 +458,7 @@ export class RelationService {
387
458
  // 2. Query the target table with unique FK values
388
459
  // 3. Map results back to parent rows via their FK values
389
460
  if (relation.direction === "owning" && relation.localKey) {
461
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
390
462
  const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
391
463
  if (!localKeyCol) {
392
464
  throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
@@ -436,19 +508,21 @@ export class RelationService {
436
508
  for (const [parentIdStr, fkValue] of parentToFk) {
437
509
  const targetRow = targetById.get(String(fkValue));
438
510
  if (targetRow) {
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
- });
511
+ resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
445
512
  }
446
513
  }
447
514
 
448
515
  return resultMap;
449
516
  }
450
517
 
451
- // Handle inverse relation types with batching
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
+ );
452
526
  let query = this.db.select().from(targetTable).$dynamic();
453
527
 
454
528
  // Build the relation query with ALL parent IDs
@@ -488,12 +562,7 @@ export class RelationService {
488
562
  }
489
563
 
490
564
  if (parentId !== undefined && parentIdSet.has(String(parentId))) {
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
- });
565
+ resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
497
566
  }
498
567
  }
499
568
 
@@ -516,11 +585,11 @@ export class RelationService {
516
585
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
517
586
  const targetCollection = relation.target();
518
587
  const targetTable = getTableForCollection(targetCollection, this.registry);
519
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
588
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
520
589
  const targetIdInfo = targetPks[0];
521
590
  const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
522
591
 
523
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
592
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
524
593
  const parentIdInfo = parentPks[0];
525
594
  const parentTable = this.registry.getTable(getTableName(parentCollection));
526
595
  if (!parentTable) throw new Error("Parent table not found");
@@ -550,8 +619,7 @@ export class RelationService {
550
619
  currentTable = joinTable;
551
620
  }
552
621
 
553
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
554
- query = query.where(inArray(parentIdField, parsedParentIds));
622
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
555
623
 
556
624
  const results = await query;
557
625
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
@@ -560,15 +628,9 @@ export class RelationService {
560
628
  for (const row of results as Array<Record<string, unknown>>) {
561
629
  const parentRow = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
562
630
  const targetRow = (row[targetTableName] || row) as Record<string, unknown>;
563
- const parentId = String(parentRow[parentIdInfo.fieldName]);
564
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
565
-
631
+ const parentId = buildCompositeId(parentRow, parentPks);
566
632
  const arr = resultMap.get(parentId) || [];
567
- arr.push({
568
- id: buildCompositeId(targetRow, targetPks),
569
- path: targetCollection.slug,
570
- values: parsedValues as Record<string, unknown>
571
- });
633
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
572
634
  resultMap.set(parentId, arr);
573
635
  }
574
636
 
@@ -579,6 +641,9 @@ export class RelationService {
579
641
  // This is the standard path for posts→tags style relations where
580
642
  // sanitizeRelation populated the `through` config.
581
643
  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}`);
582
647
  const junctionTable = this.registry.getTable(relation.through.table);
583
648
  if (!junctionTable) {
584
649
  logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
@@ -612,21 +677,21 @@ export class RelationService {
612
677
  const targetData = (row[targetTableName] || row) as Record<string, unknown>;
613
678
 
614
679
  const parentId = String(junctionData[relation.through.sourceColumn]);
615
- const parsedValues = await parseDataFromServer(targetData, targetCollection);
616
-
617
680
  const arr = resultMap.get(parentId) || [];
618
- arr.push({
619
- id: buildCompositeId(targetData, targetPks),
620
- path: targetCollection.slug,
621
- values: parsedValues as Record<string, unknown>
622
- });
681
+ arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
623
682
  resultMap.set(parentId, arr);
624
683
  }
625
684
 
626
685
  return resultMap;
627
686
  }
628
687
 
629
- // Handle FK-based relations (one-to-many inverse)
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
+ );
630
695
  let query = this.db.select().from(targetTable).$dynamic();
631
696
 
632
697
  query = applyDynamicRelationQuery(
@@ -668,14 +733,9 @@ export class RelationService {
668
733
  }
669
734
 
670
735
  if (parentId !== undefined && parentIdSet.has(String(parentId))) {
671
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
672
736
  const key = String(parentId);
673
737
  const arr = resultMap.get(key) || [];
674
- arr.push({
675
- id: buildCompositeId(targetRow, targetPks),
676
- path: targetCollection.slug,
677
- values: parsedValues as Record<string, unknown>
678
- });
738
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
679
739
  resultMap.set(key, arr);
680
740
  }
681
741
  }
@@ -746,7 +806,7 @@ export class RelationService {
746
806
  continue;
747
807
  }
748
808
 
749
- const parentPks = getPrimaryKeys(collection, this.registry);
809
+ const parentPks = requirePrimaryKeys(collection, this.registry);
750
810
  const parentIdInfo = parentPks[0];
751
811
  const parsedParentIdObj = parseIdValues(id, parentPks);
752
812
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -755,7 +815,7 @@ export class RelationService {
755
815
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
756
816
 
757
817
  if (targetEntityIds.length > 0) {
758
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
818
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
759
819
  const targetIdInfo = targetPks[0];
760
820
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
761
821
 
@@ -784,7 +844,7 @@ export class RelationService {
784
844
  continue;
785
845
  }
786
846
 
787
- const parentPks = getPrimaryKeys(collection, this.registry);
847
+ const parentPks = requirePrimaryKeys(collection, this.registry);
788
848
  const parentIdInfo = parentPks[0];
789
849
  const parsedParentIdObj = parseIdValues(id, parentPks);
790
850
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -793,7 +853,7 @@ export class RelationService {
793
853
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
794
854
 
795
855
  if (targetEntityIds.length > 0) {
796
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
856
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
797
857
  const targetIdInfo = targetPks[0];
798
858
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
799
859
 
@@ -813,7 +873,7 @@ export class RelationService {
813
873
  } else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
814
874
  // Handle one-to-many (inverse) by updating target FK to point to parent
815
875
  const targetTable = getTableForCollection(targetCollection, this.registry);
816
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
876
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
817
877
  const targetIdInfo = targetPks[0];
818
878
  const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
819
879
  const fkCol = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
@@ -823,7 +883,7 @@ export class RelationService {
823
883
  continue;
824
884
  }
825
885
 
826
- const parentPks = getPrimaryKeys(collection, this.registry);
886
+ const parentPks = requirePrimaryKeys(collection, this.registry);
827
887
  const parentIdInfo = parentPks[0];
828
888
  const parsedParentIdObj = parseIdValues(id, parentPks);
829
889
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -873,9 +933,9 @@ export class RelationService {
873
933
  try {
874
934
  const targetCollection = relation.target();
875
935
  const targetTable = getTableForCollection(targetCollection, this.registry);
876
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
936
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
877
937
  const targetIdInfo = targetPks[0];
878
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
938
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
879
939
  const sourceIdInfo = sourcePks[0];
880
940
 
881
941
  // Handle inverse relations with joinPath
@@ -1032,7 +1092,7 @@ export class RelationService {
1032
1092
  }
1033
1093
 
1034
1094
  // Perform the junction table update
1035
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
1095
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
1036
1096
  const sourceIdInfo = sourcePks[0];
1037
1097
  const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
1038
1098
  const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
@@ -1042,7 +1102,7 @@ export class RelationService {
1042
1102
 
1043
1103
  // Add new entries if newValue is provided
1044
1104
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1045
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
1105
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1046
1106
  const targetIdInfo = targetPks[0];
1047
1107
  const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel);
1048
1108
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
@@ -1057,7 +1117,7 @@ export class RelationService {
1057
1117
  }
1058
1118
  } else if (newValue && !Array.isArray(newValue)) {
1059
1119
  // Single value for one-to-one
1060
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
1120
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1061
1121
  const targetIdInfo = targetPks[0];
1062
1122
  const targetId = typeof newValue === "object" && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
1063
1123
  const parsedTargetIdObj = parseIdValues(targetId, targetPks);
@@ -1104,7 +1164,7 @@ export class RelationService {
1104
1164
  return;
1105
1165
  }
1106
1166
 
1107
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
1167
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
1108
1168
  const sourceIdInfo = sourcePks[0];
1109
1169
  const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
1110
1170
  const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
@@ -1114,7 +1174,7 @@ export class RelationService {
1114
1174
 
1115
1175
  // Add new entries if newValue is provided
1116
1176
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1117
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
1177
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1118
1178
  const targetIdInfo = targetPks[0];
1119
1179
  const targetEntityIds = (newValue as Array<{ id: string | number }>).map((rel) => rel.id);
1120
1180
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
@@ -1151,14 +1211,14 @@ export class RelationService {
1151
1211
  const { relation, newTargetId } = upd;
1152
1212
  const targetCollection = relation.target();
1153
1213
  const targetTable = getTableForCollection(targetCollection, this.registry);
1154
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
1214
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1155
1215
  const targetIdInfo = targetPks[0];
1156
1216
  const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
1157
1217
 
1158
1218
  // Determine mapping of columns
1159
1219
  const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
1160
1220
  const parentTable = getTableForCollection(parentCollection, this.registry);
1161
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
1221
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
1162
1222
  const parentIdInfo = parentPks[0];
1163
1223
  const parsedParentIdObj = parseIdValues(parentId, parentPks);
1164
1224
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
@@ -1283,7 +1343,7 @@ parentSourceColName };
1283
1343
  }
1284
1344
 
1285
1345
  // Parse the new row ID to the correct type
1286
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
1346
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1287
1347
  const targetIdInfo = targetPks[0];
1288
1348
  const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
1289
1349
  const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
@@ -58,10 +58,27 @@ 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
+ */
61
80
  export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[] {
62
- const table = getTableForCollection(collection, registry);
63
-
64
- // Fallback to explicitly defined isId properties
81
+ // Explicitly declared `isId` properties win, and need no table.
65
82
  if (collection.properties) {
66
83
  const idProps = Object.entries(collection.properties)
67
84
  .filter(([_, prop]) => "isId" in (prop as object) && Boolean((prop as { isId?: unknown }).isId))
@@ -76,6 +93,12 @@ export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresC
76
93
  }
77
94
  }
78
95
 
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
+
79
102
  // Otherwise infer from Drizzle schema
80
103
  const keys: PrimaryKeyInfo[] = [];
81
104
  for (const [key, colRaw] of Object.entries(table)) {
@@ -105,6 +128,27 @@ isUUID });
105
128
  return keys;
106
129
  }
107
130
 
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
+
108
152
  /**
109
153
  * Collections whose key the *browser* cannot resolve, and what it will do
110
154
  * instead.
@@ -138,14 +182,9 @@ export function findUnresolvableKeyCollections(
138
182
  // Declared `isId` is the tier both sides share: if it is there, they agree.
139
183
  if (getDeclaredPrimaryKeys(collection).length > 0) continue;
140
184
 
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
- }
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);
149
188
  if (keys.length === 0) continue;
150
189
 
151
190
  // A single key named `id` is the last tier on both sides: they agree
@@ -207,28 +246,21 @@ export function warnOnKeysTheAdminCannotResolve(
207
246
  * The address of a row: derived from the collection's primary keys, because a
208
247
  * row does not carry one — it is exactly its columns.
209
248
  *
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).
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).
217
253
  */
218
254
  export function deriveRowAddress(
219
255
  row: Record<string, unknown>,
220
256
  collection: CollectionConfig,
221
257
  registry: PostgresCollectionRegistry
222
258
  ): string {
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 */
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;
232
264
  }
233
265
  if (row.id !== undefined && row.id !== null) return String(row.id);
234
266
  return "";
@@ -963,8 +963,10 @@ roles: activeAuth.roles },
963
963
  const keys = getPrimaryKeys(collection, this.registry);
964
964
  return keys.length > 0 ? keys : undefined;
965
965
  } catch {
966
- // Unregistered table, or a relation path with no collection of its
967
- // own: the client keeps its pre-existing `id` behaviour.
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.
968
970
  return undefined;
969
971
  }
970
972
  }