@rebasepro/server-postgres 0.10.1-canary.d8d45b2 → 0.10.1-canary.ed78a2c

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 (45) hide show
  1. package/dist/auth/schema-version.d.ts +106 -0
  2. package/dist/collections/validate-relations.d.ts +53 -0
  3. package/dist/data-transformer.d.ts +3 -3
  4. package/dist/{ensure-collection-tables-CNlIONzj.js → ensure-collection-tables-DGMYK0fr.js} +12 -12
  5. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  6. package/dist/index.es.js +913 -391
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/services/FetchService.d.ts +21 -8
  9. package/dist/services/PersistService.d.ts +12 -0
  10. package/dist/services/RelationService.d.ts +39 -8
  11. package/dist/services/cdc/junction-tables.d.ts +38 -0
  12. package/dist/services/nested-path.d.ts +59 -0
  13. package/dist/services/realtimeService.d.ts +19 -0
  14. package/dist/services/row-pipeline.d.ts +2 -2
  15. package/dist/{src-DmsRg8MR.js → src-3VmUJ8Xn.js} +214 -276
  16. package/dist/src-3VmUJ8Xn.js.map +1 -0
  17. package/dist/{src-B0v4IKaI.js → src-D5xBTl32.js} +19 -2
  18. package/dist/src-D5xBTl32.js.map +1 -0
  19. package/dist/utils/drizzle-conditions.d.ts +71 -18
  20. package/package.json +8 -9
  21. package/src/PostgresBootstrapper.ts +15 -2
  22. package/src/auth/ensure-tables.ts +23 -0
  23. package/src/auth/schema-version.ts +260 -0
  24. package/src/cli-errors.ts +1 -1
  25. package/src/cli-helpers.ts +4 -3
  26. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  27. package/src/collections/buildRegistry.ts +7 -0
  28. package/src/collections/validate-relations.ts +280 -0
  29. package/src/data-transformer.ts +28 -38
  30. package/src/schema/doctor.ts +14 -14
  31. package/src/schema/generate-drizzle-schema-logic.ts +62 -110
  32. package/src/schema/generate-postgres-ddl-logic.ts +28 -21
  33. package/src/schema/introspect-db-inference.ts +13 -13
  34. package/src/schema/introspect-db-logic.ts +25 -29
  35. package/src/services/FetchService.ts +116 -126
  36. package/src/services/PersistService.ts +126 -88
  37. package/src/services/RelationService.ts +157 -86
  38. package/src/services/cdc/junction-tables.ts +91 -0
  39. package/src/services/nested-path.ts +145 -0
  40. package/src/services/realtimeService.ts +60 -0
  41. package/src/services/row-pipeline.ts +5 -6
  42. package/src/utils/drizzle-conditions.ts +268 -330
  43. package/dist/ensure-collection-tables-CNlIONzj.js.map +0 -1
  44. package/dist/src-B0v4IKaI.js.map +0 -1
  45. package/dist/src-DmsRg8MR.js.map +0 -1
@@ -1,8 +1,9 @@
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, Relation } from "@rebasepro/types";
4
+ import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany } from "@rebasepro/types";
5
5
  import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
6
+ import { hasForeignKeyOnTarget, isManyToMany, type ResolvedVia } from "@rebasepro/types";
6
7
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
8
  import {
8
9
  getCollectionByPath,
@@ -15,6 +16,7 @@ import {
15
16
  import { parseDataFromServer } from "../data-transformer";
16
17
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
17
18
  import { logger } from "@rebasepro/server";
19
+ import type { NestedPathHop } from "./nested-path";
18
20
 
19
21
  /**
20
22
  * Typed wrapper for Drizzle dynamic query innerJoin.
@@ -180,7 +182,7 @@ export class RelationService {
180
182
  async fetchEntitiesUsingJoins<M extends Record<string, unknown>>(
181
183
  parentCollection: CollectionConfig,
182
184
  parentId: string | number,
183
- relation: Relation,
185
+ relation: ResolvedRelation,
184
186
  options: {
185
187
  filter?: FilterValues<Extract<keyof M, string>>;
186
188
  orderBy?: string;
@@ -205,7 +207,7 @@ export class RelationService {
205
207
  const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
206
208
 
207
209
  // Handle join path relations
208
- if (relation.joinPath && relation.joinPath.length > 0) {
210
+ if (relation.kind === "via") {
209
211
  let query = this.db.select().from(parentTable).$dynamic();
210
212
  let currentTable = parentTable;
211
213
 
@@ -333,6 +335,24 @@ export class RelationService {
333
335
  throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
334
336
  }
335
337
 
338
+ return this.countRelatedRows(parentCollection, parentId, relation, []);
339
+ }
340
+
341
+ /**
342
+ * Count the target rows a parent reaches through `relation`, narrowed by
343
+ * `additionalFilters` (conditions on the target table).
344
+ *
345
+ * Shared by the public count and by {@link isRelated}, so "how many children
346
+ * does this parent have" and "is this row one of them" are answered by the
347
+ * same join — a membership test that reconstructed the join separately would
348
+ * be free to disagree with the listing it is supposed to gate.
349
+ */
350
+ private async countRelatedRows(
351
+ parentCollection: CollectionConfig,
352
+ parentId: string | number,
353
+ relation: ResolvedRelation,
354
+ additionalFilters: SQL[]
355
+ ): Promise<number> {
336
356
  const targetCollection = relation.target();
337
357
  const targetTable = getTableForCollection(targetCollection, this.registry);
338
358
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
@@ -350,9 +370,6 @@ export class RelationService {
350
370
  // Start count with distinct to avoid duplicates from junction tables
351
371
  let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
352
372
 
353
- // Build additional filter conditions
354
- const additionalFilters: SQL[] = [];
355
-
356
373
  // Use unified count query builder from DrizzleConditionBuilder
357
374
  query = DrizzleConditionBuilder.buildRelationCountQuery(
358
375
  query,
@@ -370,6 +387,75 @@ export class RelationService {
370
387
  return Number(result[0]?.count || 0);
371
388
  }
372
389
 
390
+ /**
391
+ * Whether `targetId` is actually reachable from the parent named in `hop`.
392
+ *
393
+ * A nested address like `authors/1/posts/43` used to resolve to the target
394
+ * collection and then match on the primary key alone, so the parent segment
395
+ * decided nothing: the row came back, and was updated or deleted, whoever it
396
+ * belonged to. Reads, updates and deletes now all gate on this.
397
+ */
398
+ async isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean> {
399
+ const targetTable = getTableForCollection(hop.targetCollection, this.registry);
400
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
401
+ const parsedTargetId = parseIdValues(targetId, targetPks);
402
+
403
+ const identity: SQL[] = targetPks.map(pk => {
404
+ const column = targetTable[pk.fieldName as keyof typeof targetTable] as AnyPgColumn;
405
+ if (!column) {
406
+ throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
407
+ }
408
+ return eq(column, parsedTargetId[pk.fieldName]);
409
+ });
410
+
411
+ return (await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity)) > 0;
412
+ }
413
+
414
+ /**
415
+ * Remove the junction row linking a parent to `targetId`, leaving the target
416
+ * row itself alone.
417
+ *
418
+ * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
419
+ * target is shared, so deleting the row would remove the tag from every other
420
+ * post that uses it. It used to do exactly that — resolve the path to the
421
+ * `tags` table and delete by primary key.
422
+ */
423
+ async unlinkRelatedEntity(
424
+ tx: DrizzleClient,
425
+ hop: NestedPathHop,
426
+ targetId: string | number
427
+ ): Promise<void> {
428
+ if (!isManyToMany(hop.relation)) {
429
+ throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
430
+ }
431
+ const through = hop.relation.through;
432
+
433
+ const junctionTable = this.registry.getTable(through.table);
434
+ if (!junctionTable) {
435
+ throw new Error(`Junction table not found: ${through.table}`);
436
+ }
437
+
438
+ const sourceJunctionColumn = junctionTable[through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
439
+ const targetJunctionColumn = junctionTable[through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
440
+
441
+ if (!sourceJunctionColumn || !targetJunctionColumn) {
442
+ throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
443
+ }
444
+
445
+ const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
446
+ const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
447
+
448
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
449
+ const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
450
+
451
+ await tx.delete(junctionTable).where(and(
452
+ eq(sourceJunctionColumn, parsedParentId),
453
+ eq(targetJunctionColumn, parsedTargetId)
454
+ ));
455
+
456
+ logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
457
+ }
458
+
373
459
  /**
374
460
  * Batch fetch related rows for multiple parent rows to avoid N+1 queries
375
461
  */
@@ -377,7 +463,7 @@ export class RelationService {
377
463
  parentCollectionPath: string,
378
464
  parentIds: (string | number)[],
379
465
  _relationKey: string,
380
- relation: Relation
466
+ relation: ResolvedRelation
381
467
  ): Promise<Map<string, RelatedRow<Record<string, unknown>>>> {
382
468
  if (parentIds.length === 0) return new Map();
383
469
 
@@ -398,7 +484,7 @@ export class RelationService {
398
484
  const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
399
485
 
400
486
  // Handle join path relations with batching
401
- if (relation.joinPath && relation.joinPath.length > 0) {
487
+ if (relation.kind === "via") {
402
488
  let query = this.db.select().from(parentTable).$dynamic();
403
489
  let currentTable = parentTable;
404
490
 
@@ -457,7 +543,7 @@ export class RelationService {
457
543
  // 1. Fetch FK values from the parent table in a single query
458
544
  // 2. Query the target table with unique FK values
459
545
  // 3. Map results back to parent rows via their FK values
460
- if (relation.direction === "owning" && relation.localKey) {
546
+ if (relation.kind === "belongsTo") {
461
547
  this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
462
548
  const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
463
549
  if (!localKeyCol) {
@@ -521,7 +607,7 @@ export class RelationService {
521
607
  this.assertSingleKeyAddressable(
522
608
  parentCollection,
523
609
  parentPks,
524
- relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`
610
+ hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
525
611
  );
526
612
  let query = this.db.select().from(targetTable).$dynamic();
527
613
 
@@ -554,11 +640,13 @@ export class RelationService {
554
640
  // Determine the parent ID this result belongs to based on the relation type
555
641
  let parentId: string | number | undefined;
556
642
 
557
- if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
643
+ // The parent's key is on the target row, in the relation's own
644
+ // column. There used to be a second branch here that guessed the
645
+ // column by appending `_id` to `inverseRelationName`, reached only
646
+ // when the foreign key had not been resolved — which cannot happen
647
+ // now that resolution fills it in.
648
+ if (hasForeignKeyOnTarget(relation)) {
558
649
  parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
559
- } else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
560
- const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
561
- parentId = targetRow[inferredForeignKeyName] as string | number | undefined;
562
650
  }
563
651
 
564
652
  if (parentId !== undefined && parentIdSet.has(String(parentId))) {
@@ -578,7 +666,7 @@ export class RelationService {
578
666
  parentCollectionPath: string,
579
667
  parentIds: (string | number)[],
580
668
  _relationKey: string,
581
- relation: Relation
669
+ relation: ResolvedRelation
582
670
  ): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>> {
583
671
  if (parentIds.length === 0) return new Map();
584
672
 
@@ -598,7 +686,7 @@ export class RelationService {
598
686
  const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
599
687
 
600
688
  // Handle join path relations (many-to-many through junction tables)
601
- if (relation.joinPath && relation.joinPath.length > 0) {
689
+ if (relation.kind === "via") {
602
690
  let query = this.db.select().from(parentTable).$dynamic();
603
691
  let currentTable = parentTable;
604
692
 
@@ -640,7 +728,7 @@ export class RelationService {
640
728
  // Handle many-to-many owning relations with junction table (relation.through)
641
729
  // This is the standard path for posts→tags style relations where
642
730
  // sanitizeRelation populated the `through` config.
643
- if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
731
+ if (relation.kind === "manyToMany") {
644
732
  // The junction names its parent with one column, so the same
645
733
  // single-key limit applies as for a direct foreign key.
646
734
  this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
@@ -690,7 +778,7 @@ export class RelationService {
690
778
  this.assertSingleKeyAddressable(
691
779
  parentCollection,
692
780
  parentPks,
693
- relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`
781
+ hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
694
782
  );
695
783
  let query = this.db.select().from(targetTable).$dynamic();
696
784
 
@@ -720,16 +808,10 @@ export class RelationService {
720
808
 
721
809
  let parentId: string | number | undefined;
722
810
 
723
- if (relation.through && relation.direction === "inverse") {
724
- // Inverse many-to-many via junction table: the junction's targetColumn
725
- // references the parent (since from the inverse perspective, source/target are swapped).
726
- const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
727
- parentId = junctionData[relation.through.targetColumn] as string | number | undefined;
728
- } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
811
+ // Junction-backed relations returned earlier in this method, so
812
+ // what reaches here names the parent with a column on the target.
813
+ if (hasForeignKeyOnTarget(relation)) {
729
814
  parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
730
- } else if (relation.direction === "inverse" && relation.inverseRelationName) {
731
- const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
732
- parentId = targetRow[inferredForeignKeyName] as string | number | undefined;
733
815
  }
734
816
 
735
817
  if (parentId !== undefined && parentIdSet.has(String(parentId))) {
@@ -762,7 +844,7 @@ export class RelationService {
762
844
  const targetCollection = relation.target();
763
845
 
764
846
  // Use joinPath if available
765
- if (relation.joinPath && relation.joinPath.length > 0) {
847
+ if (relation.kind === "via") {
766
848
  const parentTableName = getTableName(collection);
767
849
  const targetTableName = getTableName(targetCollection);
768
850
 
@@ -828,7 +910,7 @@ export class RelationService {
828
910
  await tx.insert(junctionTable).values(newLinks);
829
911
  }
830
912
  }
831
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
913
+ } else if (relation.kind === "manyToMany") {
832
914
  // Handle many-to-many relations with junction table using 'through' property
833
915
  const junctionTable = this.registry.getTable(relation.through.table);
834
916
  if (!junctionTable) {
@@ -866,11 +948,7 @@ export class RelationService {
866
948
  await tx.insert(junctionTable).values(newLinks);
867
949
  }
868
950
  }
869
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") {
870
- // Inverse M2M relations should be saved from the owning side.
871
- // The owning collection manages the junction table rows.
872
- logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
873
- } else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
951
+ } else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
874
952
  // Handle one-to-many (inverse) by updating target FK to point to parent
875
953
  const targetTable = getTableForCollection(targetCollection, this.registry);
876
954
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
@@ -923,7 +1001,7 @@ export class RelationService {
923
1001
  sourceEntityId: string | number,
924
1002
  inverseRelationUpdates: Array<{
925
1003
  relationKey: string;
926
- relation: Relation;
1004
+ relation: ResolvedRelation;
927
1005
  newValue: unknown;
928
1006
  }>
929
1007
  ) {
@@ -938,8 +1016,7 @@ export class RelationService {
938
1016
  const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
939
1017
  const sourceIdInfo = sourcePks[0];
940
1018
 
941
- // Handle inverse relations with joinPath
942
- if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
1019
+ if (relation.kind === "via") {
943
1020
  await this.updateInverseJoinPathRelation(
944
1021
  tx,
945
1022
  sourceCollection,
@@ -951,42 +1028,31 @@ export class RelationService {
951
1028
  continue;
952
1029
  }
953
1030
 
954
- // Check if this is a many-to-many inverse relation
955
- if (relation.cardinality === "many" && relation.direction === "inverse") {
956
- const targetCollectionRelations = resolveCollectionRelations(targetCollection);
957
- let junctionInfo: { table: string; sourceColumn: string; targetColumn: string } | null = null;
958
-
959
- for (const [relationKey, targetRelation] of Object.entries(targetCollectionRelations)) {
960
- if (targetRelation.cardinality === "many" &&
961
- targetRelation.direction === "owning" &&
962
- targetRelation.through &&
963
- (targetRelation.relationName === relation.inverseRelationName || relationKey === relation.inverseRelationName)) {
964
- junctionInfo = {
965
- table: targetRelation.through.table,
966
- sourceColumn: targetRelation.through.targetColumn,
967
- targetColumn: targetRelation.through.sourceColumn
968
- };
969
- break;
1031
+ // A many-to-many names its own junction. This used to walk the
1032
+ // *target's* relations looking for an owning side whose
1033
+ // `relationName` matched `inverseRelationName`, and swap its
1034
+ // source/target columns — a search that silently did nothing
1035
+ // when the far side was declared differently than expected.
1036
+ if (isManyToMany(relation)) {
1037
+ await this.updateManyToManyInverseRelation(
1038
+ tx,
1039
+ sourceCollection,
1040
+ sourceEntityId,
1041
+ targetCollection,
1042
+ relation,
1043
+ newValue,
1044
+ {
1045
+ table: relation.through.table,
1046
+ sourceColumn: relation.through.sourceColumn,
1047
+ targetColumn: relation.through.targetColumn
970
1048
  }
971
- }
972
-
973
- if (junctionInfo) {
974
- await this.updateManyToManyInverseRelation(
975
- tx,
976
- sourceCollection,
977
- sourceEntityId,
978
- targetCollection,
979
- relation,
980
- newValue,
981
- junctionInfo
982
- );
983
- continue;
984
- }
1049
+ );
1050
+ continue;
985
1051
  }
986
1052
 
987
- // Handle simple inverse relations
988
- if (!relation.foreignKeyOnTarget) {
989
- logger.warn(`Inverse relation '${relation.relationName}' is missing foreignKeyOnTarget property. Skipping.`);
1053
+ // What is left names the parent with a column on the target.
1054
+ if (!hasForeignKeyOnTarget(relation)) {
1055
+ logger.warn(`Relation '${relation.relationName}' has no column on the target to write. Skipping.`);
990
1056
  continue;
991
1057
  }
992
1058
 
@@ -1035,14 +1101,10 @@ export class RelationService {
1035
1101
  sourceCollection: CollectionConfig,
1036
1102
  sourceEntityId: string | number,
1037
1103
  targetCollection: CollectionConfig,
1038
- relation: Relation,
1104
+ relation: ResolvedVia,
1039
1105
  newValue: unknown
1040
1106
  ) {
1041
1107
  try {
1042
- if (!relation.joinPath || relation.joinPath.length === 0) {
1043
- logger.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
1044
- return;
1045
- }
1046
1108
 
1047
1109
  const sourceTableName = getTableName(sourceCollection);
1048
1110
  const targetTableName = getTableName(targetCollection);
@@ -1145,7 +1207,7 @@ export class RelationService {
1145
1207
  sourceCollection: CollectionConfig,
1146
1208
  sourceEntityId: string | number,
1147
1209
  targetCollection: CollectionConfig,
1148
- relation: Relation,
1210
+ relation: ResolvedRelation,
1149
1211
  newValue: unknown,
1150
1212
  junctionInfo: { table: string; sourceColumn: string; targetColumn: string }
1151
1213
  ) {
@@ -1203,7 +1265,9 @@ export class RelationService {
1203
1265
  parentId: string | number,
1204
1266
  updates: Array<{
1205
1267
  relationKey: string;
1206
- relation: Relation;
1268
+ // Only a `via` relation has a join path to write through, and the
1269
+ // caller only collects those.
1270
+ relation: ResolvedVia;
1207
1271
  newTargetId: string | number | null;
1208
1272
  }>
1209
1273
  ) {
@@ -1281,7 +1345,7 @@ export class RelationService {
1281
1345
  */
1282
1346
  resolveJoinPathWriteMapping(
1283
1347
  parentCollection: CollectionConfig,
1284
- relation: Relation
1348
+ relation: ResolvedVia
1285
1349
  ): { targetFKColName: string; parentSourceColName: string } {
1286
1350
  if (!relation.joinPath || relation.joinPath.length === 0) {
1287
1351
  throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
@@ -1320,7 +1384,10 @@ parentSourceColName };
1320
1384
  junctionTableInfo: {
1321
1385
  parentCollection: CollectionConfig;
1322
1386
  parentId: string | number;
1323
- relation: Relation;
1387
+ // Only a junction-backed relation has a link to write, and the
1388
+ // caller only builds this for one — stated here so the body needs
1389
+ // no narrowing.
1390
+ relation: ResolvedManyToMany;
1324
1391
  relationKey: string;
1325
1392
  }
1326
1393
  ) {
@@ -1328,14 +1395,14 @@ parentSourceColName };
1328
1395
  const targetCollection = relation.target();
1329
1396
 
1330
1397
  try {
1331
- const junctionTable = this.registry.getTable(relation.through!.table);
1398
+ const junctionTable = this.registry.getTable(relation.through.table);
1332
1399
  if (!junctionTable) {
1333
- logger.warn(`Junction table '${relation.through!.table}' not found for relation '${relationKey}'`);
1400
+ logger.warn(`Junction table '${relation.through.table}' not found for relation '${relationKey}'`);
1334
1401
  return;
1335
1402
  }
1336
1403
 
1337
- const sourceJunctionColumn = junctionTable[relation.through!.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
1338
- const targetJunctionColumn = junctionTable[relation.through!.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
1404
+ const sourceJunctionColumn = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
1405
+ const targetJunctionColumn = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
1339
1406
 
1340
1407
  if (!sourceJunctionColumn || !targetJunctionColumn) {
1341
1408
  logger.warn(`Junction columns not found for relation '${relationKey}'`);
@@ -1348,15 +1415,19 @@ parentSourceColName };
1348
1415
  const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
1349
1416
  const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
1350
1417
 
1351
- // Create the junction table entry linking parent to the new row
1418
+ // Create the junction table entry linking parent to the target row.
1352
1419
  const junctionData = {
1353
1420
  [sourceJunctionColumn.name]: parentId,
1354
1421
  [targetJunctionColumn.name]: parsedNewEntityId
1355
1422
  };
1356
1423
 
1357
- await tx.insert(junctionTable).values(junctionData);
1424
+ // Idempotent: a link either exists or it does not, so asking for one
1425
+ // twice is not an error. This is what lets `PUT parent/id/child/childId`
1426
+ // mean "this row belongs to this parent's set" — the only way to
1427
+ // attach an *existing* row, which previously had none.
1428
+ await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
1358
1429
 
1359
- logger.info(`Created junction table entry for many-to-many relation '${relationKey}': ${JSON.stringify(junctionData)}`);
1430
+ logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
1360
1431
  } catch (error) {
1361
1432
  logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error: error });
1362
1433
  throw error;
@@ -0,0 +1,91 @@
1
+ import { CollectionConfig, ResolvedRelation, isManyToMany } from "@rebasepro/types";
2
+ import { resolveCollectionRelations } from "@rebasepro/common";
3
+
4
+ import { PostgresCollectionRegistry } from "../../collections/PostgresCollectionRegistry";
5
+
6
+ /**
7
+ * One end of a many-to-many, as seen from the junction table.
8
+ *
9
+ * A junction table is not a collection, so nothing in the registry maps it to
10
+ * one — which is why a change to it was invisible to change capture. But its
11
+ * rows are exactly the contents of a parent's child list, so a write to it is a
12
+ * change to `<parentSlug>/<sourceId>/<relationKey>` and to nothing else.
13
+ */
14
+ export interface JunctionLink {
15
+ schema: string;
16
+ /** The junction table itself, e.g. `posts_tags`. */
17
+ table: string;
18
+ /** The collection whose relation this is, e.g. `posts`. */
19
+ parentCollection: CollectionConfig;
20
+ /** The relation's key — the path segment a child list is addressed by. */
21
+ relationKey: string;
22
+ /** Junction column holding the parent's id. */
23
+ sourceColumn: string;
24
+ /** Junction column holding the target's id. */
25
+ targetColumn: string;
26
+ }
27
+
28
+ /**
29
+ * Every junction table reachable from a registered collection, once per
30
+ * relation that uses it.
31
+ *
32
+ * A junction is listed once per *direction* when both sides declare it, because
33
+ * each direction addresses a different child list: `posts/1/tags` and
34
+ * `tags/t/posts` both change when one link is written.
35
+ */
36
+ export function collectJunctionLinks(registry: PostgresCollectionRegistry): JunctionLink[] {
37
+ const links: JunctionLink[] = [];
38
+ const seen = new Set<string>();
39
+
40
+ for (const collection of registry.getCollections()) {
41
+ let relations: Record<string, ResolvedRelation>;
42
+ try {
43
+ relations = resolveCollectionRelations(collection);
44
+ } catch {
45
+ // A collection whose relations cannot be resolved (an unresolvable
46
+ // target, typically mid-migration) simply contributes none.
47
+ continue;
48
+ }
49
+
50
+ for (const [relationKey, relation] of Object.entries(relations)) {
51
+ if (!isManyToMany(relation)) continue;
52
+ const through = relation.through;
53
+
54
+ // Same relation registered under both its canonical name and the
55
+ // declaring property key would otherwise notify the same path twice.
56
+ const key = `${collection.slug}::${relationKey}::${through.table}`;
57
+ if (seen.has(key)) continue;
58
+ seen.add(key);
59
+
60
+ links.push({
61
+ schema: (collection as { schema?: string }).schema ?? "public",
62
+ table: through.table,
63
+ parentCollection: collection,
64
+ relationKey,
65
+ sourceColumn: through.sourceColumn,
66
+ targetColumn: through.targetColumn
67
+ });
68
+ }
69
+ }
70
+
71
+ return links;
72
+ }
73
+
74
+ /**
75
+ * Index {@link collectJunctionLinks} by table, under both the qualified and the
76
+ * bare name — a change event carries whatever the trigger reports, and a
77
+ * collection need not declare a schema.
78
+ */
79
+ export function buildJunctionLinkMap(registry: PostgresCollectionRegistry): Map<string, JunctionLink[]> {
80
+ const map = new Map<string, JunctionLink[]>();
81
+
82
+ for (const link of collectJunctionLinks(registry)) {
83
+ for (const key of [`${link.schema}.${link.table}`, link.table]) {
84
+ const existing = map.get(key);
85
+ if (existing) existing.push(link);
86
+ else map.set(key, [link]);
87
+ }
88
+ }
89
+
90
+ return map;
91
+ }