@rebasepro/server-postgres 0.10.1-canary.ed8caed → 0.10.1-canary.g4db1bb1

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 (42) 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-BVvtkkRm.js → ensure-collection-tables-DGMYK0fr.js} +5 -5
  5. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  6. package/dist/index.es.js +610 -313
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/services/RelationService.d.ts +9 -8
  9. package/dist/services/cdc/junction-tables.d.ts +38 -0
  10. package/dist/services/nested-path.d.ts +3 -3
  11. package/dist/services/realtimeService.d.ts +19 -0
  12. package/dist/services/row-pipeline.d.ts +2 -2
  13. package/dist/{src-lcfUP4xg.js → src-3VmUJ8Xn.js} +135 -247
  14. package/dist/src-3VmUJ8Xn.js.map +1 -0
  15. package/dist/{src-CBgtrPhJ.js → src-D5xBTl32.js} +12 -2
  16. package/dist/src-D5xBTl32.js.map +1 -0
  17. package/dist/utils/drizzle-conditions.d.ts +35 -23
  18. package/package.json +6 -6
  19. package/src/PostgresBootstrapper.ts +15 -2
  20. package/src/auth/ensure-tables.ts +23 -0
  21. package/src/auth/schema-version.ts +260 -0
  22. package/src/cli-errors.ts +1 -1
  23. package/src/cli-helpers.ts +4 -3
  24. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  25. package/src/collections/buildRegistry.ts +7 -0
  26. package/src/collections/validate-relations.ts +280 -0
  27. package/src/data-transformer.ts +28 -38
  28. package/src/schema/doctor.ts +8 -8
  29. package/src/schema/generate-drizzle-schema-logic.ts +49 -101
  30. package/src/schema/generate-postgres-ddl-logic.ts +9 -9
  31. package/src/schema/introspect-db-logic.ts +19 -23
  32. package/src/services/FetchService.ts +12 -12
  33. package/src/services/PersistService.ts +23 -27
  34. package/src/services/RelationService.ts +65 -83
  35. package/src/services/cdc/junction-tables.ts +91 -0
  36. package/src/services/nested-path.ts +24 -9
  37. package/src/services/realtimeService.ts +60 -0
  38. package/src/services/row-pipeline.ts +5 -6
  39. package/src/utils/drizzle-conditions.ts +177 -382
  40. package/dist/ensure-collection-tables-BVvtkkRm.js.map +0 -1
  41. package/dist/src-CBgtrPhJ.js.map +0 -1
  42. package/dist/src-lcfUP4xg.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- import { CollectionConfig, FilterValues, Relation, LogicalCondition } from "@rebasepro/types";
3
+ import { CollectionConfig, FilterValues, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
4
4
  import type { VectorSearchParams } from "@rebasepro/types";
5
5
  import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
6
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
@@ -73,7 +73,7 @@ export class FetchService {
73
73
  let orderByField = table[orderBy as keyof typeof table] as AnyPgColumn;
74
74
  if (!orderByField && collection) {
75
75
  const property = collection.properties[orderBy];
76
- if (property && property.type === "relation" && "relation" in property && property.relation?.direction === "owning") {
76
+ if (property && property.type === "relation" && "relation" in property && property.resolvedRelation?.kind === "belongsTo") {
77
77
  orderByField = table[`${orderBy}_id` as keyof typeof table] as AnyPgColumn;
78
78
  }
79
79
  }
@@ -106,7 +106,7 @@ export class FetchService {
106
106
  const drizzleRelName = relation.relationName || key;
107
107
 
108
108
  // Skip relations that use joinPath as they are not mapped in Drizzle schemas
109
- if (relation.joinPath && relation.joinPath.length > 0) {
109
+ if (relation.kind === "via") {
110
110
  continue;
111
111
  }
112
112
 
@@ -135,8 +135,8 @@ export class FetchService {
135
135
  * Get the Drizzle relation name on the junction table that points to the actual target row.
136
136
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
137
137
  */
138
- private getJunctionTargetRelationName(relation: Relation, _collection: CollectionConfig): string | null {
139
- if (relation.through) {
138
+ private getJunctionTargetRelationName(relation: ResolvedRelation, _collection: CollectionConfig): string | null {
139
+ if (isManyToMany(relation)) {
140
140
  // The junction relation on the junction table pointing to the target
141
141
  // uses the targetColumn name as the Drizzle relation name
142
142
  return relation.through.targetColumn.replace(/_id$/, "_id");
@@ -159,7 +159,7 @@ export class FetchService {
159
159
  const resolvedRelations = resolveCollectionRelations(collection);
160
160
 
161
161
  const promises = Object.entries(resolvedRelations)
162
- .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0)
162
+ .filter(([key, relation]) => relation.kind === "via")
163
163
  .map(async ([key, relation]) => {
164
164
  try {
165
165
  const relatedRows = await this.relationService.fetchRelatedEntities(
@@ -204,7 +204,7 @@ export class FetchService {
204
204
  !include || include.length === 0 || include[0] === "*" || include.includes(key);
205
205
 
206
206
  const joinPathRelations = Object.entries(resolvedRelations)
207
- .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
207
+ .filter(([key, relation]) => relation.kind === "via" && propertyKeys.has(key) && shouldInclude(key));
208
208
 
209
209
  if (joinPathRelations.length === 0) return;
210
210
 
@@ -495,7 +495,7 @@ idColumn };
495
495
  return flatRow;
496
496
  } catch (e) {
497
497
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
498
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
498
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
499
499
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
500
500
  }
501
501
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
@@ -620,7 +620,7 @@ idColumn };
620
620
  return rows;
621
621
  } catch (e) {
622
622
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
623
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
623
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
624
624
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
625
625
  }
626
626
  logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
@@ -1040,7 +1040,7 @@ relatedTo: hop }, include
1040
1040
  return restRows;
1041
1041
  } catch (e) {
1042
1042
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1043
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
1043
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1044
1044
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1045
1045
  }
1046
1046
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
@@ -1146,7 +1146,7 @@ relatedTo: hop }, include
1146
1146
  return restRow;
1147
1147
  } catch (e) {
1148
1148
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1149
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
1149
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1150
1150
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1151
1151
  }
1152
1152
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
@@ -1424,7 +1424,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1424
1424
  const relation = resolvedRelations[relationKey];
1425
1425
 
1426
1426
  if (!relation) {
1427
- logger.warn(`[batchFetchManyRelatedRows] Relation '${relationKey}' not found, skipping`);
1427
+ logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
1428
1428
  return new Map();
1429
1429
  }
1430
1430
 
@@ -1,7 +1,7 @@
1
1
  import { eq, and, sql, SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  // import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
- import { CollectionConfig, Properties, Relation } from "@rebasepro/types";
4
+ import { CollectionConfig, Properties, ResolvedRelation, type ResolvedManyToMany, isManyToMany } from "@rebasepro/types";
5
5
  import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
6
6
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
7
  import {
@@ -99,7 +99,7 @@ export class PersistService {
99
99
 
100
100
  if (isJunctionBackedRelation(hop.relation)) {
101
101
  // Shared target: drop the link, not the row.
102
- if (!hop.relation.through) {
102
+ if (!isManyToMany(hop.relation)) {
103
103
  throw ApiError.badRequest(
104
104
  `"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, ` +
105
105
  `so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" ` +
@@ -159,30 +159,26 @@ export class PersistService {
159
159
  * do, and first — stamped the parent's own foreign key onto the child row.
160
160
  */
161
161
  private resolveParentForeignKeyColumn(hop: NestedPathHop): string | undefined {
162
- const { relation, relationKey, targetCollection } = hop;
163
-
164
- if (relation.foreignKeyOnTarget) return relation.foreignKeyOnTarget;
165
-
166
- if (relation.joinPath && relation.joinPath.length === 1) {
167
- const joinStep = relation.joinPath[0];
168
- const targetTableName = getTableName(targetCollection);
169
- if (joinStep.table !== targetTableName) {
170
- logger.warn(`Join step for relation '${relationKey}' targets '${joinStep.table}', not the target table '${targetTableName}'.`);
171
- }
172
- return DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to)[0];
162
+ const { relation } = hop;
163
+
164
+ switch (relation.kind) {
165
+ case "hasOne":
166
+ case "hasMany":
167
+ return relation.foreignKeyOnTarget;
168
+
169
+ case "via":
170
+ // The link lives in an intermediate table, not in a column on
171
+ // the target — nothing to stamp.
172
+ return relation.joinPath.length === 1
173
+ ? DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0]
174
+ : undefined;
175
+
176
+ default:
177
+ // `belongsTo` names a column on the *parent*, and `manyToMany`
178
+ // is written as a junction row. Neither is a column here, and
179
+ // `assertWritableThrough` has already rejected the first.
180
+ return undefined;
173
181
  }
174
-
175
- if (relation.joinPath && relation.joinPath.length > 1) {
176
- // Multi-hop: the link lives in an intermediate table, not in a
177
- // column on the target. Nothing to stamp.
178
- return undefined;
179
- }
180
-
181
- throw ApiError.badRequest(
182
- `Relation '${relationKey}' on '${hop.parentCollection.slug}' cannot be written through: it declares no ` +
183
- "`foreignKeyOnTarget` (the column on " + `'${targetCollection.slug}'` + " that records the parent) and no `joinPath`.",
184
- "RELATION_NOT_WRITABLE"
185
- );
186
182
  }
187
183
 
188
184
  /**
@@ -204,7 +200,7 @@ export class PersistService {
204
200
  // If saving under a nested relation path, resolve the relation it ends in.
205
201
  let effectiveCollectionPath = collectionPath;
206
202
  const effectiveValues: Partial<M> = { ...values };
207
- let junctionTableInfo: { parentCollection: CollectionConfig; parentId: string | number; relation: Relation; relationKey: string; } | undefined;
203
+ let junctionTableInfo: { parentCollection: CollectionConfig; parentId: string | number; relation: ResolvedManyToMany; relationKey: string; } | undefined;
208
204
 
209
205
  const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
210
206
 
@@ -217,7 +213,7 @@ export class PersistService {
217
213
  return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
218
214
  };
219
215
 
220
- if (hop.relation.through) {
216
+ if (isManyToMany(hop.relation)) {
221
217
  // A junction path addresses set membership, so a write through it
222
218
  // asserts "this row is in this parent's set" — on create *and* on
223
219
  // update. The junction row is written after the main write below,
@@ -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,
@@ -181,7 +182,7 @@ export class RelationService {
181
182
  async fetchEntitiesUsingJoins<M extends Record<string, unknown>>(
182
183
  parentCollection: CollectionConfig,
183
184
  parentId: string | number,
184
- relation: Relation,
185
+ relation: ResolvedRelation,
185
186
  options: {
186
187
  filter?: FilterValues<Extract<keyof M, string>>;
187
188
  orderBy?: string;
@@ -206,7 +207,7 @@ export class RelationService {
206
207
  const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
207
208
 
208
209
  // Handle join path relations
209
- if (relation.joinPath && relation.joinPath.length > 0) {
210
+ if (relation.kind === "via") {
210
211
  let query = this.db.select().from(parentTable).$dynamic();
211
212
  let currentTable = parentTable;
212
213
 
@@ -349,7 +350,7 @@ export class RelationService {
349
350
  private async countRelatedRows(
350
351
  parentCollection: CollectionConfig,
351
352
  parentId: string | number,
352
- relation: Relation,
353
+ relation: ResolvedRelation,
353
354
  additionalFilters: SQL[]
354
355
  ): Promise<number> {
355
356
  const targetCollection = relation.target();
@@ -424,10 +425,10 @@ export class RelationService {
424
425
  hop: NestedPathHop,
425
426
  targetId: string | number
426
427
  ): Promise<void> {
427
- const through = hop.relation.through;
428
- if (!through) {
428
+ if (!isManyToMany(hop.relation)) {
429
429
  throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
430
430
  }
431
+ const through = hop.relation.through;
431
432
 
432
433
  const junctionTable = this.registry.getTable(through.table);
433
434
  if (!junctionTable) {
@@ -462,7 +463,7 @@ export class RelationService {
462
463
  parentCollectionPath: string,
463
464
  parentIds: (string | number)[],
464
465
  _relationKey: string,
465
- relation: Relation
466
+ relation: ResolvedRelation
466
467
  ): Promise<Map<string, RelatedRow<Record<string, unknown>>>> {
467
468
  if (parentIds.length === 0) return new Map();
468
469
 
@@ -483,7 +484,7 @@ export class RelationService {
483
484
  const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
484
485
 
485
486
  // Handle join path relations with batching
486
- if (relation.joinPath && relation.joinPath.length > 0) {
487
+ if (relation.kind === "via") {
487
488
  let query = this.db.select().from(parentTable).$dynamic();
488
489
  let currentTable = parentTable;
489
490
 
@@ -542,7 +543,7 @@ export class RelationService {
542
543
  // 1. Fetch FK values from the parent table in a single query
543
544
  // 2. Query the target table with unique FK values
544
545
  // 3. Map results back to parent rows via their FK values
545
- if (relation.direction === "owning" && relation.localKey) {
546
+ if (relation.kind === "belongsTo") {
546
547
  this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
547
548
  const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
548
549
  if (!localKeyCol) {
@@ -606,7 +607,7 @@ export class RelationService {
606
607
  this.assertSingleKeyAddressable(
607
608
  parentCollection,
608
609
  parentPks,
609
- relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`
610
+ hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
610
611
  );
611
612
  let query = this.db.select().from(targetTable).$dynamic();
612
613
 
@@ -639,11 +640,13 @@ export class RelationService {
639
640
  // Determine the parent ID this result belongs to based on the relation type
640
641
  let parentId: string | number | undefined;
641
642
 
642
- 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)) {
643
649
  parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
644
- } else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
645
- const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
646
- parentId = targetRow[inferredForeignKeyName] as string | number | undefined;
647
650
  }
648
651
 
649
652
  if (parentId !== undefined && parentIdSet.has(String(parentId))) {
@@ -663,7 +666,7 @@ export class RelationService {
663
666
  parentCollectionPath: string,
664
667
  parentIds: (string | number)[],
665
668
  _relationKey: string,
666
- relation: Relation
669
+ relation: ResolvedRelation
667
670
  ): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>> {
668
671
  if (parentIds.length === 0) return new Map();
669
672
 
@@ -683,7 +686,7 @@ export class RelationService {
683
686
  const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
684
687
 
685
688
  // Handle join path relations (many-to-many through junction tables)
686
- if (relation.joinPath && relation.joinPath.length > 0) {
689
+ if (relation.kind === "via") {
687
690
  let query = this.db.select().from(parentTable).$dynamic();
688
691
  let currentTable = parentTable;
689
692
 
@@ -725,7 +728,7 @@ export class RelationService {
725
728
  // Handle many-to-many owning relations with junction table (relation.through)
726
729
  // This is the standard path for posts→tags style relations where
727
730
  // sanitizeRelation populated the `through` config.
728
- if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
731
+ if (relation.kind === "manyToMany") {
729
732
  // The junction names its parent with one column, so the same
730
733
  // single-key limit applies as for a direct foreign key.
731
734
  this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
@@ -775,7 +778,7 @@ export class RelationService {
775
778
  this.assertSingleKeyAddressable(
776
779
  parentCollection,
777
780
  parentPks,
778
- relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`
781
+ hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
779
782
  );
780
783
  let query = this.db.select().from(targetTable).$dynamic();
781
784
 
@@ -805,16 +808,10 @@ export class RelationService {
805
808
 
806
809
  let parentId: string | number | undefined;
807
810
 
808
- if (relation.through && relation.direction === "inverse") {
809
- // Inverse many-to-many via junction table: the junction's targetColumn
810
- // references the parent (since from the inverse perspective, source/target are swapped).
811
- const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
812
- parentId = junctionData[relation.through.targetColumn] as string | number | undefined;
813
- } 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)) {
814
814
  parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
815
- } else if (relation.direction === "inverse" && relation.inverseRelationName) {
816
- const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
817
- parentId = targetRow[inferredForeignKeyName] as string | number | undefined;
818
815
  }
819
816
 
820
817
  if (parentId !== undefined && parentIdSet.has(String(parentId))) {
@@ -847,7 +844,7 @@ export class RelationService {
847
844
  const targetCollection = relation.target();
848
845
 
849
846
  // Use joinPath if available
850
- if (relation.joinPath && relation.joinPath.length > 0) {
847
+ if (relation.kind === "via") {
851
848
  const parentTableName = getTableName(collection);
852
849
  const targetTableName = getTableName(targetCollection);
853
850
 
@@ -913,7 +910,7 @@ export class RelationService {
913
910
  await tx.insert(junctionTable).values(newLinks);
914
911
  }
915
912
  }
916
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
913
+ } else if (relation.kind === "manyToMany") {
917
914
  // Handle many-to-many relations with junction table using 'through' property
918
915
  const junctionTable = this.registry.getTable(relation.through.table);
919
916
  if (!junctionTable) {
@@ -951,11 +948,7 @@ export class RelationService {
951
948
  await tx.insert(junctionTable).values(newLinks);
952
949
  }
953
950
  }
954
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") {
955
- // Inverse M2M relations should be saved from the owning side.
956
- // The owning collection manages the junction table rows.
957
- logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
958
- } else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
951
+ } else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
959
952
  // Handle one-to-many (inverse) by updating target FK to point to parent
960
953
  const targetTable = getTableForCollection(targetCollection, this.registry);
961
954
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
@@ -1008,7 +1001,7 @@ export class RelationService {
1008
1001
  sourceEntityId: string | number,
1009
1002
  inverseRelationUpdates: Array<{
1010
1003
  relationKey: string;
1011
- relation: Relation;
1004
+ relation: ResolvedRelation;
1012
1005
  newValue: unknown;
1013
1006
  }>
1014
1007
  ) {
@@ -1023,8 +1016,7 @@ export class RelationService {
1023
1016
  const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
1024
1017
  const sourceIdInfo = sourcePks[0];
1025
1018
 
1026
- // Handle inverse relations with joinPath
1027
- if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
1019
+ if (relation.kind === "via") {
1028
1020
  await this.updateInverseJoinPathRelation(
1029
1021
  tx,
1030
1022
  sourceCollection,
@@ -1036,42 +1028,31 @@ export class RelationService {
1036
1028
  continue;
1037
1029
  }
1038
1030
 
1039
- // Check if this is a many-to-many inverse relation
1040
- if (relation.cardinality === "many" && relation.direction === "inverse") {
1041
- const targetCollectionRelations = resolveCollectionRelations(targetCollection);
1042
- let junctionInfo: { table: string; sourceColumn: string; targetColumn: string } | null = null;
1043
-
1044
- for (const [relationKey, targetRelation] of Object.entries(targetCollectionRelations)) {
1045
- if (targetRelation.cardinality === "many" &&
1046
- targetRelation.direction === "owning" &&
1047
- targetRelation.through &&
1048
- (targetRelation.relationName === relation.inverseRelationName || relationKey === relation.inverseRelationName)) {
1049
- junctionInfo = {
1050
- table: targetRelation.through.table,
1051
- sourceColumn: targetRelation.through.targetColumn,
1052
- targetColumn: targetRelation.through.sourceColumn
1053
- };
1054
- 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
1055
1048
  }
1056
- }
1057
-
1058
- if (junctionInfo) {
1059
- await this.updateManyToManyInverseRelation(
1060
- tx,
1061
- sourceCollection,
1062
- sourceEntityId,
1063
- targetCollection,
1064
- relation,
1065
- newValue,
1066
- junctionInfo
1067
- );
1068
- continue;
1069
- }
1049
+ );
1050
+ continue;
1070
1051
  }
1071
1052
 
1072
- // Handle simple inverse relations
1073
- if (!relation.foreignKeyOnTarget) {
1074
- 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.`);
1075
1056
  continue;
1076
1057
  }
1077
1058
 
@@ -1120,14 +1101,10 @@ export class RelationService {
1120
1101
  sourceCollection: CollectionConfig,
1121
1102
  sourceEntityId: string | number,
1122
1103
  targetCollection: CollectionConfig,
1123
- relation: Relation,
1104
+ relation: ResolvedVia,
1124
1105
  newValue: unknown
1125
1106
  ) {
1126
1107
  try {
1127
- if (!relation.joinPath || relation.joinPath.length === 0) {
1128
- logger.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
1129
- return;
1130
- }
1131
1108
 
1132
1109
  const sourceTableName = getTableName(sourceCollection);
1133
1110
  const targetTableName = getTableName(targetCollection);
@@ -1230,7 +1207,7 @@ export class RelationService {
1230
1207
  sourceCollection: CollectionConfig,
1231
1208
  sourceEntityId: string | number,
1232
1209
  targetCollection: CollectionConfig,
1233
- relation: Relation,
1210
+ relation: ResolvedRelation,
1234
1211
  newValue: unknown,
1235
1212
  junctionInfo: { table: string; sourceColumn: string; targetColumn: string }
1236
1213
  ) {
@@ -1288,7 +1265,9 @@ export class RelationService {
1288
1265
  parentId: string | number,
1289
1266
  updates: Array<{
1290
1267
  relationKey: string;
1291
- relation: Relation;
1268
+ // Only a `via` relation has a join path to write through, and the
1269
+ // caller only collects those.
1270
+ relation: ResolvedVia;
1292
1271
  newTargetId: string | number | null;
1293
1272
  }>
1294
1273
  ) {
@@ -1366,7 +1345,7 @@ export class RelationService {
1366
1345
  */
1367
1346
  resolveJoinPathWriteMapping(
1368
1347
  parentCollection: CollectionConfig,
1369
- relation: Relation
1348
+ relation: ResolvedVia
1370
1349
  ): { targetFKColName: string; parentSourceColName: string } {
1371
1350
  if (!relation.joinPath || relation.joinPath.length === 0) {
1372
1351
  throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
@@ -1405,7 +1384,10 @@ parentSourceColName };
1405
1384
  junctionTableInfo: {
1406
1385
  parentCollection: CollectionConfig;
1407
1386
  parentId: string | number;
1408
- 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;
1409
1391
  relationKey: string;
1410
1392
  }
1411
1393
  ) {
@@ -1413,14 +1395,14 @@ parentSourceColName };
1413
1395
  const targetCollection = relation.target();
1414
1396
 
1415
1397
  try {
1416
- const junctionTable = this.registry.getTable(relation.through!.table);
1398
+ const junctionTable = this.registry.getTable(relation.through.table);
1417
1399
  if (!junctionTable) {
1418
- 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}'`);
1419
1401
  return;
1420
1402
  }
1421
1403
 
1422
- const sourceJunctionColumn = junctionTable[relation.through!.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
1423
- 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;
1424
1406
 
1425
1407
  if (!sourceJunctionColumn || !targetJunctionColumn) {
1426
1408
  logger.warn(`Junction columns not found for relation '${relationKey}'`);
@@ -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
+ }