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

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
package/dist/index.es.js CHANGED
@@ -2,8 +2,8 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { i as __toESM, n as __exportAll, r as __require, t as __commonJSMin } from "./chunk-DSJWtz9O.js";
5
- import { a as isSchemaAdmin, i as isSQLAdmin, l as isPostgresCollectionConfig, m as Vector, n as getDataSourceCapabilities, o as ANONYMOUS_USER_ID, r as isChannelBusInstance } from "./src-CBgtrPhJ.js";
6
- import { C as createRelationRefWithData, D as mergeDeep, E as getPolicyNamesForRule, O as camelCase, S as createRelationRef, T as updateDateAutoValues, _ as getTableVarName, a as getJunctionCollectionConfig, b as getDeclaredPrimaryKeys, c as getEffectiveSecurityRules, d as securityRuleToConditions, f as findAnonymousGrants, g as getTableName$1, h as getEnumVarName, i as CollectionRegistry, k as toSnakeCase, l as buildPropertyCallbacks, m as getColumnName, n as detectJunctionTables, o as getJunctionSecurityRules, p as findRelation, r as buildSdkData, s as resolveJunctionSpecs, t as classifyTable, u as policyToPostgres, v as resolveCollectionRelations, w as normalizeToEntityRelation, x as parseIdValues, y as buildCompositeId } from "./src-lcfUP4xg.js";
5
+ import { a as isSchemaAdmin, c as hasForeignKeyOnTarget, d as isPostgresCollectionConfig, g as Vector, i as isSQLAdmin, l as isManyToMany, o as ANONYMOUS_USER_ID, r as isChannelBusInstance } from "./src-D5xBTl32.js";
6
+ import { C as createRelationRefWithData, D as mergeDeep, E as getPolicyNamesForRule, O as camelCase, S as createRelationRef, T as updateDateAutoValues, _ as getTableVarName, a as getJunctionCollectionConfig, b as getDeclaredPrimaryKeys, c as getEffectiveSecurityRules, d as securityRuleToConditions, f as findAnonymousGrants, g as getTableName$1, h as getEnumVarName, i as CollectionRegistry, k as toSnakeCase, l as buildPropertyCallbacks, m as getColumnName, n as detectJunctionTables, o as getJunctionSecurityRules, p as findRelation, r as buildSdkData, s as resolveJunctionSpecs, t as classifyTable, u as policyToPostgres, v as resolveCollectionRelations, w as normalizeToEntityRelation, x as parseIdValues, y as buildCompositeId } from "./src-3VmUJ8Xn.js";
7
7
  import { Client, Pool } from "pg";
8
8
  import { drizzle } from "drizzle-orm/node-postgres";
9
9
  import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
@@ -413,28 +413,32 @@ var DrizzleConditionBuilder = class {
413
413
  * it and the final step correlating to the outer row.
414
414
  */
415
415
  static buildRelationScopeCondition(relation, parent, parentId, targetTable, targetIdColumn, registry) {
416
- if (relation.joinPath && relation.joinPath.length > 0) {
417
- const { table, idColumn } = parent();
418
- return this.buildJoinPathScopeCondition(relation.joinPath, table, idColumn, parentId, targetIdColumn, registry);
419
- }
420
- if (relation.through) {
421
- const junctionTable = registry.getTable(relation.through.table);
422
- if (!junctionTable) throw new Error(`Junction table not found: ${relation.through.table}`);
423
- const sourceCol = junctionTable[relation.through.sourceColumn];
424
- const targetCol = junctionTable[relation.through.targetColumn];
425
- if (!sourceCol || !targetCol) throw new Error(`Junction columns '${relation.through.sourceColumn}'/'${relation.through.targetColumn}' not found in '${relation.through.table}'`);
426
- return sql`EXISTS (SELECT 1 FROM ${junctionTable} WHERE ${targetCol} = ${targetIdColumn} AND ${sourceCol} = ${parentId})`;
427
- }
428
- if (relation.foreignKeyOnTarget) {
429
- const fkColumn = targetTable[relation.foreignKeyOnTarget];
430
- if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'. A many-to-many relation needs \`through\` instead.`);
431
- return eq(fkColumn, parentId);
432
- }
433
- if (relation.localKey) {
434
- const { table, idColumn } = parent();
435
- return sql`${targetIdColumn} = (SELECT ${sql.identifier(relation.localKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
416
+ switch (relation.kind) {
417
+ case "via": {
418
+ const { table, idColumn } = parent();
419
+ return this.buildJoinPathScopeCondition(relation.joinPath, table, idColumn, parentId, targetTable, registry);
420
+ }
421
+ case "manyToMany": {
422
+ const { table: junctionName, sourceColumn, targetColumn } = relation.through;
423
+ const junctionTable = registry.getTable(junctionName);
424
+ if (!junctionTable) throw new Error(`Junction table not found: ${junctionName}`);
425
+ const sourceCol = junctionTable[sourceColumn];
426
+ const targetCol = junctionTable[targetColumn];
427
+ if (!sourceCol || !targetCol) throw new Error(`Junction columns '${sourceColumn}'/'${targetColumn}' not found in '${junctionName}'`);
428
+ return sql`EXISTS (SELECT 1 FROM ${junctionTable} WHERE ${targetCol} = ${targetIdColumn} AND ${sourceCol} = ${parentId})`;
429
+ }
430
+ case "hasOne":
431
+ case "hasMany": {
432
+ const fkColumn = targetTable[relation.foreignKeyOnTarget];
433
+ if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
434
+ return eq(fkColumn, parentId);
435
+ }
436
+ case "belongsTo": {
437
+ const { table, idColumn } = parent();
438
+ return sql`${targetIdColumn} = (SELECT ${sql.identifier(relation.localKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
439
+ }
440
+ default: throw new Error(`Unknown relation kind: ${JSON.stringify(relation)}`);
436
441
  }
437
- throw new Error(`Relation '${relation.relationName}' declares no \`foreignKeyOnTarget\`, \`through\`, \`joinPath\` or \`localKey\`, so there is no way to tell which target rows belong to a parent.`);
438
442
  }
439
443
  /**
440
444
  * `EXISTS` for an explicit `joinPath`.
@@ -445,7 +449,7 @@ var DrizzleConditionBuilder = class {
445
449
  * alias. Each intermediate table is aliased positionally, which keeps a path
446
450
  * that revisits a table (a self-referencing many-to-many) unambiguous.
447
451
  */
448
- static buildJoinPathScopeCondition(joinPath, parentTable, parentIdColumn, parentId, targetIdColumn, registry) {
452
+ static buildJoinPathScopeCondition(joinPath, parentTable, parentIdColumn, parentId, targetTable, registry) {
449
453
  const sourceAlias = "__rel_src";
450
454
  const aliasFor = (index) => `__rel_j${index}`;
451
455
  const fromRef = (stepIndex, column) => sql`${sql.identifier(stepIndex === 0 ? sourceAlias : aliasFor(stepIndex - 1))}.${sql.identifier(getColumnName(column))}`;
@@ -466,8 +470,12 @@ var DrizzleConditionBuilder = class {
466
470
  const on = pairs(step).map(({ from, to }) => sql`${fromRef(index, from)} = ${sql.identifier(aliasFor(index))}.${sql.identifier(getColumnName(to))}`);
467
471
  return sql`JOIN ${table} AS ${sql.identifier(aliasFor(index))} ON ${sql.join(on, sql` AND `)}`;
468
472
  });
469
- const lastPairs = pairs(last);
470
- const correlation = lastPairs.length === 1 ? sql`${fromRef(inner.length, lastPairs[0].from)} = ${targetIdColumn}` : sql.join(lastPairs.map(({ from, to }) => sql`${fromRef(inner.length, from)} = ${sql.identifier(getColumnName(to))}`), sql` AND `);
473
+ const targetColumn = (name) => {
474
+ const column = targetTable[getColumnName(name)];
475
+ if (!column) throw new Error(`Join step column '${name}' not found in the target table of this joinPath`);
476
+ return column;
477
+ };
478
+ const correlation = sql.join(pairs(last).map(({ from, to }) => sql`${fromRef(inner.length, from)} = ${targetColumn(to)}`), sql` AND `);
471
479
  const joinsSql = joins.length > 0 ? sql` ${sql.join(joins, sql` `)}` : sql``;
472
480
  return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
473
481
  }
@@ -568,58 +576,43 @@ var DrizzleConditionBuilder = class {
568
576
  /**
569
577
  * Build relation-based conditions for different relation types
570
578
  */
579
+ /**
580
+ * Joins and where-conditions that reach a relation's target rows.
581
+ *
582
+ * One case per kind. This used to be a chain of six `else if`s over
583
+ * `cardinality`/`direction`/`through`, ending in
584
+ * `findCorrespondingJunctionTable` — a search through the *target's* own
585
+ * relations to work out whether an "inverse many" was a one-to-many or the
586
+ * far side of a junction. That search is gone: the kind says which it is.
587
+ *
588
+ * The owning/inverse split for junctions is gone too. Both variants built
589
+ * the identical condition — `through` is always written from the declaring
590
+ * side's point of view — so the second was a distinction without a
591
+ * difference and one of the places the two could drift apart.
592
+ */
571
593
  static buildRelationConditions(relation, parentId, targetTable, parentTable, parentIdColumn, targetIdColumn, registry) {
572
- console.debug("🔍 [buildRelationConditions] Building conditions for relation:", {
573
- relationName: relation.relationName,
574
- cardinality: relation.cardinality,
575
- direction: relation.direction,
576
- hasThrough: !!relation.through,
577
- hasForeignKeyOnTarget: !!relation.foreignKeyOnTarget,
578
- inverseRelationName: relation.inverseRelationName,
579
- parentId
580
- });
581
594
  const joinConditions = [];
582
595
  const whereConditions = [];
583
- if (relation.joinPath && relation.joinPath.length > 0) {
584
- console.debug("🔍 [buildRelationConditions] Using joinPath logic");
585
- const { joins, finalCondition } = this.buildJoinPathConditions(relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry);
586
- joinConditions.push(...joins);
587
- whereConditions.push(finalCondition);
588
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
589
- console.debug("🔍 [buildRelationConditions] Using owning many-to-many with explicit through");
590
- const junctionResult = this.buildJunctionTableConditions(relation.through, targetIdColumn, parentId, registry);
591
- joinConditions.push(junctionResult.join);
592
- whereConditions.push(junctionResult.condition);
593
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") {
594
- console.debug("🔍 [buildRelationConditions] Using inverse many-to-many with explicit through");
595
- const junctionResult = this.buildInverseJunctionTableConditions(relation.through, targetIdColumn, parentId, registry);
596
- joinConditions.push(junctionResult.join);
597
- whereConditions.push(junctionResult.condition);
598
- } else if (relation.cardinality === "many" && relation.direction === "inverse" && !relation.through) {
599
- console.debug("🔍 [buildRelationConditions] Handling inverse many relationship without explicit through");
600
- const junctionInfo = this.findCorrespondingJunctionTable(relation, registry);
601
- if (junctionInfo) {
602
- console.debug("🔍 [buildRelationConditions] Found junction info for inverse many-to-many, building junction conditions");
603
- const junctionResult = this.buildInverseJunctionTableConditions(junctionInfo, targetIdColumn, parentId, registry);
596
+ switch (relation.kind) {
597
+ case "via": {
598
+ const { joins, finalCondition } = this.buildJoinPathConditions(relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry);
599
+ joinConditions.push(...joins);
600
+ whereConditions.push(finalCondition);
601
+ break;
602
+ }
603
+ case "manyToMany": {
604
+ const junctionResult = this.buildJunctionTableConditions(relation.through, targetIdColumn, parentId, registry);
604
605
  joinConditions.push(junctionResult.join);
605
606
  whereConditions.push(junctionResult.condition);
606
- } else if (relation.foreignKeyOnTarget) {
607
- console.debug("🔍 [buildRelationConditions] No junction table found, treating as inverse one-to-many with foreign key on target");
608
- const simpleCondition = this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId);
609
- whereConditions.push(simpleCondition);
610
- } else {
611
- logger.error("🔍 [buildRelationConditions] Failed to find junction table info and no foreign key specified");
612
- throw new Error(`Cannot resolve inverse many relation '${relation.relationName}'. Either specify 'through' property, ensure corresponding owning relation exists with junction table configuration, or specify 'foreignKeyOnTarget' for one-to-many relationships.`);
607
+ break;
613
608
  }
614
- } else {
615
- console.debug("🔍 [buildRelationConditions] Using simple relation logic - THIS IS WHERE THE ERROR MIGHT OCCUR");
616
- const simpleCondition = this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId);
617
- whereConditions.push(simpleCondition);
609
+ case "hasOne":
610
+ case "hasMany":
611
+ case "belongsTo":
612
+ whereConditions.push(this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId));
613
+ break;
614
+ default: throw new Error(`Unknown relation kind: ${JSON.stringify(relation)}`);
618
615
  }
619
- console.debug("🔍 [buildRelationConditions] Final result:", {
620
- joinConditionsCount: joinConditions.length,
621
- whereConditionsCount: whereConditions.length
622
- });
623
616
  return {
624
617
  joinConditions,
625
618
  whereConditions
@@ -759,48 +752,24 @@ var DrizzleConditionBuilder = class {
759
752
  };
760
753
  }
761
754
  /**
762
- * Build conditions for inverse junction table (many-to-many) relations
763
- */
764
- static buildInverseJunctionTableConditions(through, targetIdColumn, parentId, registry) {
765
- const junctionTable = registry.getTable(through.table);
766
- if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
767
- const junctionSourceCol = junctionTable[through.sourceColumn];
768
- const junctionTargetCol = junctionTable[through.targetColumn];
769
- if (!junctionSourceCol) throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
770
- if (!junctionTargetCol) throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
771
- const condition = Array.isArray(parentId) ? inArray(junctionSourceCol, parentId) : eq(junctionSourceCol, parentId);
772
- return {
773
- join: {
774
- table: junctionTable,
775
- condition: eq(targetIdColumn, junctionTargetCol)
776
- },
777
- condition
778
- };
779
- }
780
- /**
781
- * Build conditions for simple relations (owning/inverse without join paths)
755
+ * The condition for a relation whose link is a single column.
756
+ *
757
+ * Two cases. It had five: two of them existed only to throw ("should not be
758
+ * called directly", "lacks proper configuration"), and one guessed a column
759
+ * name by appending `_id` to `inverseRelationName` when no foreign key had
760
+ * been resolved. All three were reachable only because the old type let a
761
+ * relation arrive here under-specified. It cannot now.
782
762
  */
783
763
  static buildSimpleRelationCondition(relation, targetTable, parentTable, parentId) {
784
- if (relation.direction === "owning" && relation.localKey) {
785
- const targetIdCol = Object.values(targetTable).find((col) => col.primary);
786
- if (!targetIdCol) {
787
- const idCol = Object.values(targetTable).find((col) => col.name === "id");
788
- if (!idCol) throw new Error("No primary key or \"id\" column found in target table");
789
- return Array.isArray(parentId) ? inArray(idCol, parentId) : eq(idCol, parentId);
790
- }
791
- return Array.isArray(parentId) ? inArray(targetIdCol, parentId) : eq(targetIdCol, parentId);
792
- } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
793
- const foreignKeyCol = targetTable[relation.foreignKeyOnTarget];
794
- if (!foreignKeyCol) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table. This might be a many-to-many relationship that requires a junction table. Consider using 'through' property or ensure the corresponding owning relation exists with junction table configuration.`);
795
- return Array.isArray(parentId) ? inArray(foreignKeyCol, parentId) : eq(foreignKeyCol, parentId);
796
- } else if (relation.direction === "inverse" && relation.cardinality === "many" && relation.inverseRelationName) throw new Error(`Inverse many-to-many relation '${relation.relationName}' requires a junction table. Either specify 'through' property or ensure the corresponding owning relation exists with junction table configuration.`);
797
- else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
798
- const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
799
- const foreignKeyCol = targetTable[inferredForeignKeyName];
800
- if (!foreignKeyCol) throw new Error(`Auto-inferred foreign key column '${inferredForeignKeyName}' not found in target table for inverse relation '${relation.relationName}'. Please specify 'foreignKeyOnTarget' explicitly.`);
801
- console.debug(`🔍 [DrizzleConditionBuilder] Auto-inferred foreign key '${inferredForeignKeyName}' for inverse relation '${relation.relationName}'`);
802
- return Array.isArray(parentId) ? inArray(foreignKeyCol, parentId) : eq(foreignKeyCol, parentId);
803
- } else throw new Error(`Relation '${relation.relationName}' lacks proper configuration. For many-to-many relations, use 'through' property. For simple relations, use 'localKey' or 'foreignKeyOnTarget'.`);
764
+ const match = (column) => Array.isArray(parentId) ? inArray(column, parentId) : eq(column, parentId);
765
+ if (relation.kind === "belongsTo") {
766
+ const targetIdCol = Object.values(targetTable).find((col) => col.primary) ?? Object.values(targetTable).find((col) => col.name === "id");
767
+ if (!targetIdCol) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}'.`);
768
+ return match(targetIdCol);
769
+ }
770
+ const foreignKeyCol = targetTable[relation.foreignKeyOnTarget];
771
+ if (!foreignKeyCol) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'. A link through a junction is \`kind: "manyToMany"\`.`);
772
+ return match(foreignKeyCol);
804
773
  }
805
774
  /**
806
775
  * Combine multiple conditions with AND operator
@@ -855,16 +824,23 @@ var DrizzleConditionBuilder = class {
855
824
  return query;
856
825
  }
857
826
  /**
858
- * Build count query for relations with proper joins and conditions
827
+ * A count over a relation's target rows.
828
+ *
829
+ * The junction case counts `distinct` because the caller's query joins the
830
+ * junction; the owning/inverse pair that used to sit here built the same
831
+ * query twice.
859
832
  */
860
833
  static buildRelationCountQuery(baseCountQuery, relation, parentId, targetTable, parentTable, parentIdColumn, targetIdColumn, registry, additionalFilters) {
861
- if (relation.joinPath && relation.joinPath.length > 0) return this.buildJoinPathCountQuery(baseCountQuery, relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry, additionalFilters);
862
- else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") return this.buildJunctionCountQuery(baseCountQuery, relation.through, targetIdColumn, parentId, registry, additionalFilters);
863
- else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") return this.buildInverseJunctionCountQuery(baseCountQuery, relation.through, targetIdColumn, parentId, registry, additionalFilters);
864
- else {
865
- const allConditions = [this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId)];
866
- if (additionalFilters) allConditions.push(...additionalFilters);
867
- return baseCountQuery.where(and(...allConditions));
834
+ switch (relation.kind) {
835
+ case "via": return this.buildJoinPathCountQuery(baseCountQuery, relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry, additionalFilters);
836
+ case "manyToMany": return this.buildJunctionCountQuery(baseCountQuery, relation.through, targetIdColumn, parentId, registry, additionalFilters);
837
+ case "belongsTo":
838
+ case "hasOne":
839
+ case "hasMany": {
840
+ const allConditions = [this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId), ...additionalFilters ?? []];
841
+ return baseCountQuery.where(and(...allConditions));
842
+ }
843
+ default: throw new Error(`Unknown relation kind: ${JSON.stringify(relation)}`);
868
844
  }
869
845
  }
870
846
  /**
@@ -905,20 +881,6 @@ var DrizzleConditionBuilder = class {
905
881
  return baseCountQuery.innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol)).where(and(...baseConditions));
906
882
  }
907
883
  /**
908
- * Build inverse junction table conditions for count queries
909
- */
910
- static buildInverseJunctionCountQuery(baseCountQuery, through, targetIdColumn, parentId, registry, additionalFilters) {
911
- const junctionTable = registry.getTable(through.table);
912
- if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
913
- const junctionSourceCol = junctionTable[through.sourceColumn];
914
- const junctionTargetCol = junctionTable[through.targetColumn];
915
- if (!junctionSourceCol) throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
916
- if (!junctionTargetCol) throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
917
- const baseConditions = [eq(junctionSourceCol, parentId)];
918
- if (additionalFilters && additionalFilters.length > 0) baseConditions.push(...additionalFilters);
919
- return baseCountQuery.innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol)).where(and(...baseConditions));
920
- }
921
- /**
922
884
  * Helper method to extract table names from columns
923
885
  */
924
886
  static getTableNamesFromColumns(columns) {
@@ -933,49 +895,6 @@ var DrizzleConditionBuilder = class {
933
895
  return [getColumnName(columns)];
934
896
  }
935
897
  /**
936
- * Find the corresponding junction table for an inverse many-to-many relation
937
- */
938
- static findCorrespondingJunctionTable(relation, registry) {
939
- try {
940
- console.debug(`🔍 [findCorrespondingJunctionTable] Looking for junction table for inverse relation '${relation.relationName}' with inverseRelationName '${relation.inverseRelationName}'`);
941
- if (!relation.inverseRelationName) {
942
- console.debug("🔍 [findCorrespondingJunctionTable] No inverseRelationName specified");
943
- return null;
944
- }
945
- const targetCollection = relation.target();
946
- console.debug(`🔍 [findCorrespondingJunctionTable] Target collection: ${targetCollection.slug}`);
947
- const targetCollectionRelations = resolveCollectionRelations(targetCollection);
948
- console.debug("🔍 [findCorrespondingJunctionTable] Target collection relations:", Object.keys(targetCollectionRelations));
949
- const correspondingRelation = targetCollectionRelations[relation.inverseRelationName];
950
- if (!correspondingRelation) {
951
- console.debug(`🔍 [findCorrespondingJunctionTable] No relation found with key '${relation.inverseRelationName}' on target collection`);
952
- return null;
953
- }
954
- console.debug("🔍 [findCorrespondingJunctionTable] Found relation:", {
955
- relationName: correspondingRelation.relationName,
956
- cardinality: correspondingRelation.cardinality,
957
- direction: correspondingRelation.direction,
958
- hasThrough: !!correspondingRelation.through
959
- });
960
- if (correspondingRelation.cardinality !== "many" || correspondingRelation.direction !== "owning" || !correspondingRelation.through) {
961
- console.debug("🔍 [findCorrespondingJunctionTable] Relation is not an owning many-to-many with junction table");
962
- return null;
963
- }
964
- console.debug("🔍 [findCorrespondingJunctionTable] Found matching owning relation with junction table!");
965
- const through = correspondingRelation.through;
966
- const result = {
967
- table: through.table,
968
- sourceColumn: through.targetColumn,
969
- targetColumn: through.sourceColumn
970
- };
971
- console.debug("🔍 [findCorrespondingJunctionTable] Returning junction info:", result);
972
- return result;
973
- } catch (error) {
974
- logger.error(`🔍 [findCorrespondingJunctionTable] Error finding corresponding junction table for relation '${relation.relationName}'`, { error });
975
- return null;
976
- }
977
- }
978
- /**
979
898
  * Build vector similarity search expressions for pgvector.
980
899
  *
981
900
  * Returns:
@@ -1052,7 +971,7 @@ function serializeDataToServer(row, properties, collection, registry) {
1052
971
  const joinPathRelationUpdates = [];
1053
972
  const foreignKeys = /* @__PURE__ */ new Set();
1054
973
  Object.values(resolvedRelations).forEach((relation) => {
1055
- if (relation.localKey) foreignKeys.add(relation.localKey);
974
+ if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
1056
975
  });
1057
976
  for (const [key, value] of Object.entries(row)) {
1058
977
  const property = properties[key];
@@ -1064,11 +983,11 @@ function serializeDataToServer(row, properties, collection, registry) {
1064
983
  if (property.type === "relation" && collection) {
1065
984
  const relation = findRelation(resolvedRelations, key);
1066
985
  if (relation) {
1067
- if (relation.direction === "owning" && relation.localKey) {
986
+ if (relation.kind === "belongsTo") {
1068
987
  const serializedValue = serializePropertyToServer(effectiveValue, property);
1069
988
  if (serializedValue !== void 0) result[relation.localKey] = serializedValue;
1070
989
  continue;
1071
- } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
990
+ } else if (hasForeignKeyOnTarget(relation)) {
1072
991
  const serializedValue = serializePropertyToServer(effectiveValue, property);
1073
992
  inverseRelationUpdates.push({
1074
993
  relationKey: key,
@@ -1076,7 +995,7 @@ function serializeDataToServer(row, properties, collection, registry) {
1076
995
  newValue: serializedValue
1077
996
  });
1078
997
  continue;
1079
- } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
998
+ } else if (relation.kind === "via") {
1080
999
  const serializedValue = serializePropertyToServer(effectiveValue, property);
1081
1000
  if (relation.cardinality === "one") joinPathRelationUpdates.push({
1082
1001
  relationKey: key,
@@ -1089,14 +1008,6 @@ function serializeDataToServer(row, properties, collection, registry) {
1089
1008
  newValue: serializedValue
1090
1009
  });
1091
1010
  continue;
1092
- } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
1093
- const serializedValue = serializePropertyToServer(effectiveValue, property);
1094
- joinPathRelationUpdates.push({
1095
- relationKey: key,
1096
- relation,
1097
- newTargetId: serializedValue
1098
- });
1099
- continue;
1100
1011
  }
1101
1012
  }
1102
1013
  }
@@ -1182,7 +1093,7 @@ async function parseDataFromServer(data, collection, db, registry) {
1182
1093
  for (const [propKey, property] of Object.entries(properties)) if (property.type === "relation" && !(propKey in result)) {
1183
1094
  const relation = findRelation(resolvedRelations, propKey);
1184
1095
  if (relation) {
1185
- if (relation.direction === "owning" && relation.localKey && relation.localKey in data) {
1096
+ if (relation.kind === "belongsTo" && relation.localKey in data) {
1186
1097
  const fkValue = data[relation.localKey];
1187
1098
  if (fkValue !== null && fkValue !== void 0) try {
1188
1099
  const targetCollection = relation.target();
@@ -1190,7 +1101,7 @@ async function parseDataFromServer(data, collection, db, registry) {
1190
1101
  } catch (e) {
1191
1102
  logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
1192
1103
  }
1193
- } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget && db && registry) try {
1104
+ } else if (hasForeignKeyOnTarget(relation) && db && registry) try {
1194
1105
  const targetCollection = relation.target();
1195
1106
  const targetTable = registry.getTable(getTableName$1(targetCollection));
1196
1107
  const currentId = buildCompositeId(data, getPrimaryKeys(collection, registry));
@@ -1211,7 +1122,7 @@ async function parseDataFromServer(data, collection, db, registry) {
1211
1122
  } catch (e) {
1212
1123
  logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
1213
1124
  }
1214
- else if (relation.direction === "inverse" && relation.joinPath && db && registry) try {
1125
+ else if (relation.kind === "via" && db && registry) try {
1215
1126
  const targetCollection = relation.target();
1216
1127
  const pks = getPrimaryKeys(collection, registry);
1217
1128
  const currentId = buildCompositeId(data, pks);
@@ -1328,11 +1239,10 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
1328
1239
  }
1329
1240
  case "relation":
1330
1241
  if (typeof value === "string" || typeof value === "number") {
1331
- let relationDef = property.relation;
1242
+ let relationDef = property.resolvedRelation;
1332
1243
  if (!relationDef && propertyKey) relationDef = findRelation(resolveCollectionRelations(collection), propertyKey);
1333
- if (!relationDef) relationDef = collection.relations?.find((rel) => rel.relationName === property.relationName);
1334
1244
  if (!relationDef) {
1335
- logger.warn(`Relation not defined in property for key: ${propertyKey || "unknown"}`);
1245
+ logger.warn(`ResolvedRelation not defined in property for key: ${propertyKey || "unknown"}`);
1336
1246
  return value;
1337
1247
  }
1338
1248
  try {
@@ -1432,7 +1342,7 @@ function normalizeScalarValues(data, properties, collection, resolvedRelations,
1432
1342
  const result = {};
1433
1343
  const internalFKColumns = /* @__PURE__ */ new Set();
1434
1344
  Object.values(resolvedRelations).forEach((relation) => {
1435
- if (relation.localKey && !properties[relation.localKey]) internalFKColumns.add(relation.localKey);
1345
+ if (relation.kind === "belongsTo" && !properties[relation.localKey]) internalFKColumns.add(relation.localKey);
1436
1346
  });
1437
1347
  for (const [key, value] of Object.entries(data)) {
1438
1348
  if (internalFKColumns.has(key)) {
@@ -1570,7 +1480,7 @@ var RelationService = class {
1570
1480
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
1571
1481
  if (!parentTable) throw new Error("Parent table not found");
1572
1482
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1573
- if (relation.joinPath && relation.joinPath.length > 0) {
1483
+ if (relation.kind === "via") {
1574
1484
  let query = this.db.select().from(parentTable).$dynamic();
1575
1485
  let currentTable = parentTable;
1576
1486
  for (const join of relation.joinPath) {
@@ -1685,8 +1595,8 @@ var RelationService = class {
1685
1595
  * `tags` table and delete by primary key.
1686
1596
  */
1687
1597
  async unlinkRelatedEntity(tx, hop, targetId) {
1598
+ if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
1688
1599
  const through = hop.relation.through;
1689
- if (!through) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
1690
1600
  const junctionTable = this.registry.getTable(through.table);
1691
1601
  if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
1692
1602
  const sourceJunctionColumn = junctionTable[through.sourceColumn];
@@ -1716,7 +1626,7 @@ var RelationService = class {
1716
1626
  if (!parentTable) throw new Error("Parent table not found");
1717
1627
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1718
1628
  const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
1719
- if (relation.joinPath && relation.joinPath.length > 0) {
1629
+ if (relation.kind === "via") {
1720
1630
  let query = this.db.select().from(parentTable).$dynamic();
1721
1631
  let currentTable = parentTable;
1722
1632
  for (const join of relation.joinPath) {
@@ -1745,7 +1655,7 @@ var RelationService = class {
1745
1655
  }
1746
1656
  return resultMap;
1747
1657
  }
1748
- if (relation.direction === "owning" && relation.localKey) {
1658
+ if (relation.kind === "belongsTo") {
1749
1659
  this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
1750
1660
  const localKeyCol = parentTable[relation.localKey];
1751
1661
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
@@ -1779,7 +1689,7 @@ var RelationService = class {
1779
1689
  }
1780
1690
  return resultMap;
1781
1691
  }
1782
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
1692
+ this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
1783
1693
  let query = this.db.select().from(targetTable).$dynamic();
1784
1694
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
1785
1695
  const results = await query;
@@ -1788,8 +1698,7 @@ var RelationService = class {
1788
1698
  for (const row of results) {
1789
1699
  const targetRow = row[getTableName$1(targetCollection)] || row;
1790
1700
  let parentId;
1791
- if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
1792
- else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
1701
+ if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
1793
1702
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
1794
1703
  }
1795
1704
  return resultMap;
@@ -1812,7 +1721,7 @@ var RelationService = class {
1812
1721
  if (!parentTable) throw new Error("Parent table not found");
1813
1722
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1814
1723
  const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
1815
- if (relation.joinPath && relation.joinPath.length > 0) {
1724
+ if (relation.kind === "via") {
1816
1725
  let query = this.db.select().from(parentTable).$dynamic();
1817
1726
  let currentTable = parentTable;
1818
1727
  for (const join of relation.joinPath) {
@@ -1842,7 +1751,7 @@ var RelationService = class {
1842
1751
  }
1843
1752
  return resultMap;
1844
1753
  }
1845
- if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
1754
+ if (relation.kind === "manyToMany") {
1846
1755
  this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
1847
1756
  const junctionTable = this.registry.getTable(relation.through.table);
1848
1757
  if (!junctionTable) {
@@ -1868,7 +1777,7 @@ var RelationService = class {
1868
1777
  }
1869
1778
  return resultMap;
1870
1779
  }
1871
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
1780
+ this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
1872
1781
  let query = this.db.select().from(targetTable).$dynamic();
1873
1782
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
1874
1783
  const results = await query;
@@ -1877,9 +1786,7 @@ var RelationService = class {
1877
1786
  for (const row of results) {
1878
1787
  const targetRow = row[getTableName$1(targetCollection)] || row;
1879
1788
  let parentId;
1880
- if (relation.through && relation.direction === "inverse") parentId = (row[relation.through.table] || row)[relation.through.targetColumn];
1881
- else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
1882
- else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
1789
+ if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
1883
1790
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
1884
1791
  const key = String(parentId);
1885
1792
  const arr = resultMap.get(key) || [];
@@ -1899,7 +1806,7 @@ var RelationService = class {
1899
1806
  if (!relation || relation.cardinality !== "many") continue;
1900
1807
  const targetEntityIds = value && Array.isArray(value) ? value.map((rel) => rel.id) : [];
1901
1808
  const targetCollection = relation.target();
1902
- if (relation.joinPath && relation.joinPath.length > 0) {
1809
+ if (relation.kind === "via") {
1903
1810
  const parentTableName = getTableName$1(collection);
1904
1811
  const targetTableName = getTableName$1(targetCollection);
1905
1812
  let junctionTable = void 0;
@@ -1944,7 +1851,7 @@ var RelationService = class {
1944
1851
  }));
1945
1852
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
1946
1853
  }
1947
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
1854
+ } else if (relation.kind === "manyToMany") {
1948
1855
  const junctionTable = this.registry.getTable(relation.through.table);
1949
1856
  if (!junctionTable) {
1950
1857
  logger.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
@@ -1969,8 +1876,7 @@ var RelationService = class {
1969
1876
  }));
1970
1877
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
1971
1878
  }
1972
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
1973
- else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
1879
+ } else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
1974
1880
  const targetTable = getTableForCollection(targetCollection, this.registry);
1975
1881
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1976
1882
  const targetIdInfo = targetPks[0];
@@ -2004,28 +1910,20 @@ var RelationService = class {
2004
1910
  const targetIdInfo = targetPks[0];
2005
1911
  const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
2006
1912
  const sourceIdInfo = sourcePks[0];
2007
- if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
1913
+ if (relation.kind === "via") {
2008
1914
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
2009
1915
  continue;
2010
1916
  }
2011
- if (relation.cardinality === "many" && relation.direction === "inverse") {
2012
- const targetCollectionRelations = resolveCollectionRelations(targetCollection);
2013
- let junctionInfo = null;
2014
- for (const [relationKey, targetRelation] of Object.entries(targetCollectionRelations)) if (targetRelation.cardinality === "many" && targetRelation.direction === "owning" && targetRelation.through && (targetRelation.relationName === relation.inverseRelationName || relationKey === relation.inverseRelationName)) {
2015
- junctionInfo = {
2016
- table: targetRelation.through.table,
2017
- sourceColumn: targetRelation.through.targetColumn,
2018
- targetColumn: targetRelation.through.sourceColumn
2019
- };
2020
- break;
2021
- }
2022
- if (junctionInfo) {
2023
- await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo);
2024
- continue;
2025
- }
1917
+ if (isManyToMany(relation)) {
1918
+ await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, {
1919
+ table: relation.through.table,
1920
+ sourceColumn: relation.through.sourceColumn,
1921
+ targetColumn: relation.through.targetColumn
1922
+ });
1923
+ continue;
2026
1924
  }
2027
- if (!relation.foreignKeyOnTarget) {
2028
- logger.warn(`Inverse relation '${relation.relationName}' is missing foreignKeyOnTarget property. Skipping.`);
1925
+ if (!hasForeignKeyOnTarget(relation)) {
1926
+ logger.warn(`Relation '${relation.relationName}' has no column on the target to write. Skipping.`);
2029
1927
  continue;
2030
1928
  }
2031
1929
  const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
@@ -2051,10 +1949,6 @@ var RelationService = class {
2051
1949
  */
2052
1950
  async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
2053
1951
  try {
2054
- if (!relation.joinPath || relation.joinPath.length === 0) {
2055
- logger.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
2056
- return;
2057
- }
2058
1952
  const sourceTableName = getTableName$1(sourceCollection);
2059
1953
  const targetTableName = getTableName$1(targetCollection);
2060
1954
  const intermediateTables = relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName);
@@ -2254,9 +2148,8 @@ var RelationService = class {
2254
2148
  * to unwrap that same level back out.
2255
2149
  */
2256
2150
  function isJunctionRelation(relation) {
2257
- if (relation.through) return true;
2258
- if (relation.joinPath && relation.joinPath.length > 1) return true;
2259
- return false;
2151
+ if (isManyToMany(relation)) return true;
2152
+ return relation.kind === "via" && relation.joinPath.length > 1;
2260
2153
  }
2261
2154
  /**
2262
2155
  * Reach the target row inside a junction row.
@@ -2446,7 +2339,7 @@ function resolveNestedPath(path, registry) {
2446
2339
  * such a path addresses the *link*, not the row.
2447
2340
  */
2448
2341
  function isJunctionBackedRelation(relation) {
2449
- return Boolean(relation.through) || Boolean(relation.joinPath && relation.joinPath.length > 1);
2342
+ return relation.shared;
2450
2343
  }
2451
2344
  /**
2452
2345
  * Reject a nested write whose final segment is a to-one relation.
@@ -2459,7 +2352,10 @@ function isJunctionBackedRelation(relation) {
2459
2352
  * one.
2460
2353
  */
2461
2354
  function assertWritableThrough(hop, path) {
2462
- if (hop.relation.cardinality !== "many") throw ApiError.badRequest(`"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on '${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set '${hop.relationKey}' on the parent instead.`, "RELATION_NOT_WRITABLE");
2355
+ const { relation } = hop;
2356
+ if (relation.writable && relation.cardinality === "many") return;
2357
+ if (!relation.writable) throw ApiError.badRequest(`"${path}" ends in '${hop.relationKey}', a \`via\` relation. Rebase will not infer how to write through an arbitrary join chain — write the row at "${hop.targetCollection.slug}" directly.`, "RELATION_NOT_WRITABLE");
2358
+ throw ApiError.badRequest(`"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on '${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set '${hop.relationKey}' on the parent instead.`, "RELATION_NOT_WRITABLE");
2463
2359
  }
2464
2360
  //#endregion
2465
2361
  //#region src/services/FetchService.ts
@@ -2498,7 +2394,7 @@ var FetchService = class {
2498
2394
  let orderByField = table[orderBy];
2499
2395
  if (!orderByField && collection) {
2500
2396
  const property = collection.properties[orderBy];
2501
- if (property && property.type === "relation" && "relation" in property && property.relation?.direction === "owning") orderByField = table[`${orderBy}_id`];
2397
+ if (property && property.type === "relation" && "relation" in property && property.resolvedRelation?.kind === "belongsTo") orderByField = table[`${orderBy}_id`];
2502
2398
  }
2503
2399
  return orderByField;
2504
2400
  }
@@ -2519,7 +2415,7 @@ var FetchService = class {
2519
2415
  for (const [key, relation] of Object.entries(resolvedRelations)) {
2520
2416
  if (!shouldInclude(key)) continue;
2521
2417
  const drizzleRelName = relation.relationName || key;
2522
- if (relation.joinPath && relation.joinPath.length > 0) continue;
2418
+ if (relation.kind === "via") continue;
2523
2419
  if (relation.cardinality === "many" && isJunctionRelation(relation)) {
2524
2420
  const targetFkName = this.getJunctionTargetRelationName(relation, collection);
2525
2421
  if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
@@ -2533,7 +2429,7 @@ var FetchService = class {
2533
2429
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
2534
2430
  */
2535
2431
  getJunctionTargetRelationName(relation, _collection) {
2536
- if (relation.through) return relation.through.targetColumn.replace(/_id$/, "_id");
2432
+ if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
2537
2433
  return null;
2538
2434
  }
2539
2435
  /**
@@ -2543,7 +2439,7 @@ var FetchService = class {
2543
2439
  */
2544
2440
  async resolveJoinPathRelations(row, collection, collectionPath, parsedId, _databaseId) {
2545
2441
  const resolvedRelations = resolveCollectionRelations(collection);
2546
- const promises = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0).map(async ([key, relation]) => {
2442
+ const promises = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via").map(async ([key, relation]) => {
2547
2443
  try {
2548
2444
  const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: relation.cardinality === "one" ? 1 : void 0 });
2549
2445
  if (relation.cardinality === "one" && relatedRows.length > 0) {
@@ -2565,7 +2461,7 @@ var FetchService = class {
2565
2461
  const resolvedRelations = resolveCollectionRelations(collection);
2566
2462
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
2567
2463
  const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
2568
- const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
2464
+ const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via" && propertyKeys.has(key) && shouldInclude(key));
2569
2465
  if (joinPathRelations.length === 0) return;
2570
2466
  const parentIdOf = (row) => {
2571
2467
  const address = buildCompositeId(row, idInfoArray);
@@ -2719,7 +2615,7 @@ var FetchService = class {
2719
2615
  return flatRow;
2720
2616
  } catch (e) {
2721
2617
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2722
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2618
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2723
2619
  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.");
2724
2620
  }
2725
2621
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
@@ -2770,7 +2666,7 @@ var FetchService = class {
2770
2666
  return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
2771
2667
  } catch (e) {
2772
2668
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2773
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2669
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2774
2670
  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.");
2775
2671
  }
2776
2672
  logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
@@ -2985,7 +2881,7 @@ var FetchService = class {
2985
2881
  return restRows;
2986
2882
  } catch (e) {
2987
2883
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2988
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2884
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2989
2885
  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.");
2990
2886
  }
2991
2887
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
@@ -3048,7 +2944,7 @@ var FetchService = class {
3048
2944
  return restRow;
3049
2945
  } catch (e) {
3050
2946
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
3051
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2947
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
3052
2948
  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.");
3053
2949
  }
3054
2950
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
@@ -3196,7 +3092,7 @@ var FetchService = class {
3196
3092
  if (parentIds.length === 0) return /* @__PURE__ */ new Map();
3197
3093
  const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
3198
3094
  if (!relation) {
3199
- logger.warn(`[batchFetchManyRelatedRows] Relation '${relationKey}' not found, skipping`);
3095
+ logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
3200
3096
  return /* @__PURE__ */ new Map();
3201
3097
  }
3202
3098
  const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
@@ -3421,7 +3317,7 @@ var PersistService = class {
3421
3317
  assertWritableThrough(hop, collectionPath);
3422
3318
  if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
3423
3319
  if (isJunctionBackedRelation(hop.relation)) {
3424
- if (!hop.relation.through) throw ApiError.badRequest(`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" directly if that is what you meant.`, "RELATION_NOT_UNLINKABLE");
3320
+ if (!isManyToMany(hop.relation)) throw ApiError.badRequest(`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" directly if that is what you meant.`, "RELATION_NOT_UNLINKABLE");
3425
3321
  await this.relationService.unlinkRelatedEntity(this.db, hop, id);
3426
3322
  return;
3427
3323
  }
@@ -3457,16 +3353,13 @@ var PersistService = class {
3457
3353
  * do, and first — stamped the parent's own foreign key onto the child row.
3458
3354
  */
3459
3355
  resolveParentForeignKeyColumn(hop) {
3460
- const { relation, relationKey, targetCollection } = hop;
3461
- if (relation.foreignKeyOnTarget) return relation.foreignKeyOnTarget;
3462
- if (relation.joinPath && relation.joinPath.length === 1) {
3463
- const joinStep = relation.joinPath[0];
3464
- const targetTableName = getTableName$1(targetCollection);
3465
- if (joinStep.table !== targetTableName) logger.warn(`Join step for relation '${relationKey}' targets '${joinStep.table}', not the target table '${targetTableName}'.`);
3466
- return DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to)[0];
3356
+ const { relation } = hop;
3357
+ switch (relation.kind) {
3358
+ case "hasOne":
3359
+ case "hasMany": return relation.foreignKeyOnTarget;
3360
+ case "via": return relation.joinPath.length === 1 ? DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0] : void 0;
3361
+ default: return;
3467
3362
  }
3468
- if (relation.joinPath && relation.joinPath.length > 1) return;
3469
- throw ApiError.badRequest(`Relation '${relationKey}' on '${hop.parentCollection.slug}' cannot be written through: it declares no \`foreignKeyOnTarget\` (the column on '${targetCollection.slug}' that records the parent) and no \`joinPath\`.`, "RELATION_NOT_WRITABLE");
3470
3363
  }
3471
3364
  /**
3472
3365
  * Save an row (create or update)
@@ -3489,7 +3382,7 @@ var PersistService = class {
3489
3382
  const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
3490
3383
  return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
3491
3384
  };
3492
- if (hop.relation.through) junctionTableInfo = {
3385
+ if (isManyToMany(hop.relation)) junctionTableInfo = {
3493
3386
  parentCollection: hop.parentCollection,
3494
3387
  parentId: parentIdForWrite(),
3495
3388
  relation: hop.relation,
@@ -5542,12 +5435,8 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5542
5435
  break;
5543
5436
  case "relation": {
5544
5437
  const refProp = prop;
5545
- const relation = findRelation(resolveCollectionRelations(collection), refProp.relationName ?? propName);
5546
- if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") return null;
5547
- if (!relation.localKey) {
5548
- logger.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
5549
- return null;
5550
- }
5438
+ const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
5439
+ if (!relation || relation.kind !== "belongsTo") return null;
5551
5440
  if (collection.properties[relation.localKey] && propName !== relation.localKey) return null;
5552
5441
  let targetCollection;
5553
5442
  try {
@@ -5641,33 +5530,17 @@ var generateSinglePolicyCode = (collection, rule, operation, policyName, resolve
5641
5530
  */
5642
5531
  var computeSharedRelationName = (rel, sourceCollection, _collections) => {
5643
5532
  const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
5644
- if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
5533
+ if (rel.kind === "belongsTo") {
5645
5534
  const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
5646
5535
  return `${getTableName$1(sourceCollection)}_${normalisedKey}`;
5647
5536
  }
5648
- if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) try {
5537
+ if (rel.kind === "hasMany" || rel.kind === "hasOne") try {
5649
5538
  const targetCollection = rel.target();
5650
5539
  const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
5651
5540
  return `${getTableName$1(targetCollection)}_${normalisedFK}`;
5652
5541
  } catch {
5653
5542
  return fallback;
5654
5543
  }
5655
- if (rel.direction === "inverse" && rel.cardinality === "one") {
5656
- if (rel.foreignKeyOnTarget) try {
5657
- const targetCollection = rel.target();
5658
- const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
5659
- return `${getTableName$1(targetCollection)}_${normalisedFK}`;
5660
- } catch {
5661
- return fallback;
5662
- }
5663
- try {
5664
- const targetCollection = rel.target();
5665
- const targetResolvedRelations = resolveCollectionRelations(targetCollection);
5666
- const correspondingRelation = Object.values(targetResolvedRelations).find((targetRel) => targetRel.direction === "owning" && targetRel.cardinality === "one" && targetRel.localKey && targetRel.target().slug === sourceCollection.slug);
5667
- if (correspondingRelation && correspondingRelation.localKey) return `${getTableName$1(targetCollection)}_${correspondingRelation.localKey}`;
5668
- } catch {}
5669
- return fallback;
5670
- }
5671
5544
  return fallback;
5672
5545
  };
5673
5546
  var generateSchema = async (collections, stripPolicies = false) => {
@@ -5732,7 +5605,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5732
5605
  const tableName = getTableName$1(collection);
5733
5606
  if (tableName) allTablesToGenerate.set(tableName, { collection });
5734
5607
  const resolvedRelations = resolveCollectionRelations(collection);
5735
- for (const relation of Object.values(resolvedRelations)) if (relation.through) {
5608
+ for (const relation of Object.values(resolvedRelations)) if (isManyToMany(relation)) {
5736
5609
  const junctionTableName = relation.through.table;
5737
5610
  if (!allTablesToGenerate.has(junctionTableName)) allTablesToGenerate.set(junctionTableName, {
5738
5611
  collection: {
@@ -5747,7 +5620,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5747
5620
  }
5748
5621
  for (const [tableName, { collection, isJunction, relation, sourceCollection }] of allTablesToGenerate.entries()) {
5749
5622
  const tableVarName = getTableVarName(tableName);
5750
- if (isJunction && relation && sourceCollection && relation.through) {
5623
+ if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
5751
5624
  const targetCollection = relation.target();
5752
5625
  const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : void 0) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : void 0);
5753
5626
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -5801,7 +5674,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5801
5674
  const tableRelations = [];
5802
5675
  if (isJunction) {
5803
5676
  const relationInfo = Array.from(allTablesToGenerate.values()).find((v) => v.isJunction && getTableName$1(v.collection) === tableName);
5804
- if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation.through) {
5677
+ if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && isManyToMany(relationInfo.relation)) {
5805
5678
  const { relation, sourceCollection } = relationInfo;
5806
5679
  const targetCollection = relation.target();
5807
5680
  const sourceTableVar = getTableVarName(getTableName$1(sourceCollection));
@@ -5813,7 +5686,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5813
5686
  let inverseRelationName = null;
5814
5687
  try {
5815
5688
  const targetRelations = resolveCollectionRelations(targetCollection);
5816
- for (const [, targetRel] of Object.entries(targetRelations)) if (targetRel.direction === "inverse" && targetRel.cardinality === "many" && targetRel.inverseRelationName === owningRelationName) {
5689
+ for (const [, targetRel] of Object.entries(targetRelations)) if (targetRel.kind !== "belongsTo" && targetRel.cardinality === "many" && targetRel.relationName === owningRelationName) {
5817
5690
  inverseRelationName = targetRel.relationName ?? null;
5818
5691
  break;
5819
5692
  }
@@ -5829,27 +5702,25 @@ var generateSchema = async (collections, stripPolicies = false) => {
5829
5702
  const target = rel.target();
5830
5703
  const targetTableVar = getTableVarName(getTableName$1(target));
5831
5704
  const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
5832
- const deduplicationKey = `${drizzleRelationName}::${rel.direction}`;
5705
+ const deduplicationKey = `${drizzleRelationName}::${rel.kind}`;
5833
5706
  if (emittedRelationNames.has(deduplicationKey)) continue;
5834
5707
  emittedRelationNames.add(deduplicationKey);
5835
- if (rel.cardinality === "one") {
5836
- if (rel.direction === "owning" && rel.localKey) tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
5837
- else if (rel.direction === "inverse") tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
5838
- } else if (rel.cardinality === "many") {
5839
- if (rel.direction === "inverse" && rel.foreignKeyOnTarget) tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
5840
- else if (rel.through) {
5708
+ switch (rel.kind) {
5709
+ case "belongsTo":
5710
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
5711
+ break;
5712
+ case "hasOne":
5713
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
5714
+ break;
5715
+ case "hasMany":
5716
+ tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
5717
+ break;
5718
+ case "manyToMany": {
5841
5719
  const junctionTableVar = getTableVarName(rel.through.table);
5842
5720
  tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
5843
- } else if (rel.direction === "inverse" && rel.inverseRelationName) try {
5844
- const targetResolvedRelations = resolveCollectionRelations(rel.target());
5845
- const correspondingRelation = Object.values(targetResolvedRelations).find((targetRel) => targetRel.direction === "owning" && targetRel.cardinality === "many" && targetRel.through && targetRel.relationName === rel.inverseRelationName);
5846
- if (correspondingRelation && correspondingRelation.through) {
5847
- const junctionTableVar = getTableVarName(correspondingRelation.through.table);
5848
- tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
5849
- } else logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
5850
- } catch (e) {
5851
- logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
5721
+ break;
5852
5722
  }
5723
+ case "via": break;
5853
5724
  }
5854
5725
  } catch (e) {
5855
5726
  logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
@@ -5857,10 +5728,10 @@ var generateSchema = async (collections, stripPolicies = false) => {
5857
5728
  for (const otherCollection of collections) {
5858
5729
  if (otherCollection.slug === collection.slug) continue;
5859
5730
  const otherRelations = resolveCollectionRelations(otherCollection);
5860
- for (const [otherKey, otherRel] of Object.entries(otherRelations)) if (otherRel.direction === "inverse" && otherRel.foreignKeyOnTarget) try {
5731
+ for (const [otherKey, otherRel] of Object.entries(otherRelations)) if (hasForeignKeyOnTarget(otherRel)) try {
5861
5732
  if (otherRel.target().slug === collection.slug) {
5862
5733
  const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
5863
- const deduplicationKey = `${drizzleRelationName}::owning`;
5734
+ const deduplicationKey = `${drizzleRelationName}::belongsTo`;
5864
5735
  if (!emittedRelationNames.has(deduplicationKey)) {
5865
5736
  const otherTableVar = getTableVarName(getTableName$1(otherCollection));
5866
5737
  const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
@@ -5962,6 +5833,58 @@ var main = () => {
5962
5833
  };
5963
5834
  if (import.meta.url.endsWith(process.argv[1])) main();
5964
5835
  //#endregion
5836
+ //#region src/services/cdc/junction-tables.ts
5837
+ /**
5838
+ * Every junction table reachable from a registered collection, once per
5839
+ * relation that uses it.
5840
+ *
5841
+ * A junction is listed once per *direction* when both sides declare it, because
5842
+ * each direction addresses a different child list: `posts/1/tags` and
5843
+ * `tags/t/posts` both change when one link is written.
5844
+ */
5845
+ function collectJunctionLinks(registry) {
5846
+ const links = [];
5847
+ const seen = /* @__PURE__ */ new Set();
5848
+ for (const collection of registry.getCollections()) {
5849
+ let relations;
5850
+ try {
5851
+ relations = resolveCollectionRelations(collection);
5852
+ } catch {
5853
+ continue;
5854
+ }
5855
+ for (const [relationKey, relation] of Object.entries(relations)) {
5856
+ if (!isManyToMany(relation)) continue;
5857
+ const through = relation.through;
5858
+ const key = `${collection.slug}::${relationKey}::${through.table}`;
5859
+ if (seen.has(key)) continue;
5860
+ seen.add(key);
5861
+ links.push({
5862
+ schema: collection.schema ?? "public",
5863
+ table: through.table,
5864
+ parentCollection: collection,
5865
+ relationKey,
5866
+ sourceColumn: through.sourceColumn,
5867
+ targetColumn: through.targetColumn
5868
+ });
5869
+ }
5870
+ }
5871
+ return links;
5872
+ }
5873
+ /**
5874
+ * Index {@link collectJunctionLinks} by table, under both the qualified and the
5875
+ * bare name — a change event carries whatever the trigger reports, and a
5876
+ * collection need not declare a schema.
5877
+ */
5878
+ function buildJunctionLinkMap(registry) {
5879
+ const map = /* @__PURE__ */ new Map();
5880
+ for (const link of collectJunctionLinks(registry)) for (const key of [`${link.schema}.${link.table}`, link.table]) {
5881
+ const existing = map.get(key);
5882
+ if (existing) existing.push(link);
5883
+ else map.set(key, [link]);
5884
+ }
5885
+ return map;
5886
+ }
5887
+ //#endregion
5965
5888
  //#region src/services/cdc/trigger-cdc.ts
5966
5889
  /**
5967
5890
  * Trigger-based Change Data Capture (CDC).
@@ -7086,6 +7009,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7086
7009
  cdcListener;
7087
7010
  /** Whether database-level CDC is the active cross-instance change source. */
7088
7011
  cdcActive = false;
7012
+ /** Junction table → the child lists its rows belong to, built when CDC starts. */
7013
+ junctionLinkMap;
7089
7014
  /** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
7090
7015
  cdcTableMap;
7091
7016
  /**
@@ -8202,6 +8127,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8202
8127
  return;
8203
8128
  }
8204
8129
  this.cdcTableMap = this.buildCdcTableMap();
8130
+ this.junctionLinkMap = buildJunctionLinkMap(this.registry);
8205
8131
  this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
8206
8132
  try {
8207
8133
  await this.cdcListener.start();
@@ -8209,6 +8135,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8209
8135
  await this.cdcListener.stop().catch(() => {});
8210
8136
  this.cdcListener = void 0;
8211
8137
  this.cdcTableMap = void 0;
8138
+ this.junctionLinkMap = void 0;
8212
8139
  throw err;
8213
8140
  }
8214
8141
  this.cdcActive = true;
@@ -8222,6 +8149,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8222
8149
  this.cdcListener = void 0;
8223
8150
  }
8224
8151
  this.cdcTableMap = void 0;
8152
+ this.junctionLinkMap = void 0;
8225
8153
  this.recentAppEmits.clear();
8226
8154
  }
8227
8155
  /**
@@ -8258,6 +8186,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8258
8186
  async handleCdcEvent(event) {
8259
8187
  const collection = this.resolveCollectionForTable(event.schema, event.table);
8260
8188
  if (!collection) {
8189
+ if (await this.handleJunctionCdcEvent(event)) return;
8261
8190
  this.debugLog(`📡 [CDC] Ignoring change on unmapped table ${event.schema}.${event.table}`);
8262
8191
  return;
8263
8192
  }
@@ -8267,6 +8196,38 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8267
8196
  const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
8268
8197
  await this.notifyUpdate(path, id, row, databaseId, false, "cdc");
8269
8198
  }
8199
+ /**
8200
+ * Deliver a change on a many-to-many junction table as a change to the child
8201
+ * lists it belongs to.
8202
+ *
8203
+ * Linking a tag to a post writes only `posts_tags`. That table backs no
8204
+ * collection, so change capture dropped the event as unmapped and the
8205
+ * subscribers of `posts/1/tags` never heard about it — every other write in
8206
+ * the system was realtime, and this one silently was not. The junction row
8207
+ * carries both ids, so it names its own paths exactly.
8208
+ *
8209
+ * Notifies the nested path rather than either endpoint collection, because
8210
+ * invalidation walks *parent* paths and never child ones: telling `tags` it
8211
+ * changed would not reach a subscription on `posts/1/tags`.
8212
+ *
8213
+ * Returns whether the table was recognised as a junction.
8214
+ */
8215
+ async handleJunctionCdcEvent(event) {
8216
+ const links = this.junctionLinkMap?.get(`${event.schema}.${event.table}`) ?? this.junctionLinkMap?.get(event.table);
8217
+ if (!links?.length) return false;
8218
+ for (const link of links) {
8219
+ const sourceId = event.row?.[link.sourceColumn];
8220
+ const targetId = event.row?.[link.targetColumn];
8221
+ if (sourceId === void 0 || sourceId === null || targetId === void 0 || targetId === null) {
8222
+ this.debugLog(`📡 [CDC] Junction row on ${event.table} is missing '${link.sourceColumn}'/'${link.targetColumn}' — skipping.`);
8223
+ continue;
8224
+ }
8225
+ const path = `${link.parentCollection.slug}/${String(sourceId)}/${link.relationKey}`;
8226
+ const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
8227
+ await this.notifyUpdate(path, String(targetId), row, link.parentCollection.databaseId, false, "cdc");
8228
+ }
8229
+ return true;
8230
+ }
8270
8231
  /** Compute the canonical (possibly composite) id string from a captured row. */
8271
8232
  extractIdFromCdcRow(collection, row) {
8272
8233
  return deriveRowAddress(row, collection, this.registry) || "*";
@@ -8986,8 +8947,8 @@ var PostgresCollectionRegistry = class extends CollectionRegistry {
8986
8947
  */
8987
8948
  getRelationKeysForCollection(collectionPath) {
8988
8949
  const collection = this.getCollectionByPath(collectionPath);
8989
- if (!collection || !getDataSourceCapabilities(collection.engine).supportsRelations || !collection.relations) return [];
8990
- return collection.relations.map((r) => r.relationName || r.localKey || "").filter(Boolean);
8950
+ if (!collection) return [];
8951
+ return Object.keys(resolveCollectionRelations(collection));
8991
8952
  }
8992
8953
  };
8993
8954
  //#endregion
@@ -17938,6 +17899,178 @@ function formatBytes(bytes) {
17938
17899
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
17939
17900
  }
17940
17901
  //#endregion
17902
+ //#region src/collections/validate-relations.ts
17903
+ /**
17904
+ * Every name a column answers to: the key in the drizzle schema and the real
17905
+ * column name in Postgres. A relation may legitimately be written with either,
17906
+ * and reporting a working relation as broken is worse than not checking.
17907
+ */
17908
+ function columnNames(table) {
17909
+ const names = /* @__PURE__ */ new Set();
17910
+ for (const [key, col] of Object.entries(getTableColumns(table))) {
17911
+ names.add(key);
17912
+ const dbName = col?.name;
17913
+ if (dbName) names.add(dbName);
17914
+ }
17915
+ return names;
17916
+ }
17917
+ var quote = (xs) => Array.from(xs).map((s) => `\`${s}\``).join(", ");
17918
+ /** `on.from` / `on.to` accept a single column or a composite tuple. */
17919
+ var asColumns = (value) => Array.isArray(value) ? value : [value];
17920
+ /**
17921
+ * Relations whose names do not resolve against the registered schema.
17922
+ *
17923
+ * Fails open wherever it cannot see enough to be sure — an unregistered source
17924
+ * table, a target belonging to another backend — because a false alarm here
17925
+ * costs more than a missed one: it would block boot on a working app.
17926
+ */
17927
+ function findRelationDefects(collections, registry) {
17928
+ const defects = [];
17929
+ const registeredSlugs = new Set(registry.getCollections().map((c) => c.slug));
17930
+ for (const collection of collections) {
17931
+ const sourceTableName = getTableName$1(collection);
17932
+ const sourceTable = registry.getTable(sourceTableName);
17933
+ if (!sourceTable) continue;
17934
+ const sourceColumns = columnNames(sourceTable);
17935
+ const relations = resolveCollectionRelations(collection);
17936
+ for (const relation of Object.values(relations)) {
17937
+ const at = {
17938
+ collection: collection.slug,
17939
+ relationName: relation.relationName,
17940
+ kind: relation.kind
17941
+ };
17942
+ let targetCollection;
17943
+ try {
17944
+ targetCollection = relation.target();
17945
+ } catch (e) {
17946
+ defects.push({
17947
+ ...at,
17948
+ problem: `its \`target()\` threw: ${e instanceof Error ? e.message : String(e)}`,
17949
+ fix: "a target thunk usually throws because of a circular import — make sure it is `() => otherCollection` and not evaluated at module load"
17950
+ });
17951
+ continue;
17952
+ }
17953
+ if (!registeredSlugs.has(targetCollection.slug)) continue;
17954
+ const targetTableName = getTableName$1(targetCollection);
17955
+ const targetTable = registry.getTable(targetTableName);
17956
+ if (!targetTable) {
17957
+ defects.push({
17958
+ ...at,
17959
+ problem: `it points at collection \`${targetCollection.slug}\`, which has no table \`${targetTableName}\` in the schema`,
17960
+ fix: `create the \`${targetTableName}\` table, or correct \`table\` on the \`${targetCollection.slug}\` collection`
17961
+ });
17962
+ continue;
17963
+ }
17964
+ const targetColumns = columnNames(targetTable);
17965
+ switch (relation.kind) {
17966
+ case "belongsTo":
17967
+ if (!sourceColumns.has(relation.localKey)) defects.push({
17968
+ ...at,
17969
+ problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
17970
+ fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
17971
+ });
17972
+ break;
17973
+ case "hasOne":
17974
+ case "hasMany":
17975
+ if (!targetColumns.has(relation.foreignKeyOnTarget)) defects.push({
17976
+ ...at,
17977
+ problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
17978
+ fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
17979
+ });
17980
+ break;
17981
+ case "manyToMany": {
17982
+ const { table, sourceColumn, targetColumn } = relation.through;
17983
+ const junction = registry.getTable(table);
17984
+ if (!junction) {
17985
+ defects.push({
17986
+ ...at,
17987
+ problem: `its junction table \`${table}\` does not exist`,
17988
+ fix: `create \`${table}\`, or name the real one with \`through: { table: "..." }\`. Note that an omitted \`through.table\` is derived from the two table names sorted and joined, so renaming a table changes it`
17989
+ });
17990
+ break;
17991
+ }
17992
+ const junctionColumns = columnNames(junction);
17993
+ for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]]) if (!junctionColumns.has(column)) defects.push({
17994
+ ...at,
17995
+ problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
17996
+ fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` + (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
17997
+ });
17998
+ break;
17999
+ }
18000
+ case "via": {
18001
+ if (relation.joinPath.length === 0) {
18002
+ defects.push({
18003
+ ...at,
18004
+ problem: "its `joinPath` is empty, so it joins nothing",
18005
+ fix: "add at least one step, ending at the target's table"
18006
+ });
18007
+ break;
18008
+ }
18009
+ let prevName = sourceTableName;
18010
+ let prevColumns = sourceColumns;
18011
+ let broken = false;
18012
+ for (const [i, step] of relation.joinPath.entries()) {
18013
+ const stepTable = registry.getTable(step.table);
18014
+ if (!stepTable) {
18015
+ defects.push({
18016
+ ...at,
18017
+ problem: `step ${i + 1} of its \`joinPath\` joins \`${step.table}\`, which is not a table in the schema`,
18018
+ fix: `correct \`joinPath[${i}].table\``
18019
+ });
18020
+ broken = true;
18021
+ break;
18022
+ }
18023
+ const stepColumns = columnNames(stepTable);
18024
+ for (const column of asColumns(step.on.from)) if (!prevColumns.has(column)) defects.push({
18025
+ ...at,
18026
+ problem: `step ${i + 1} joins \`${prevName}.${column}\` → \`${step.table}\`, but \`${column}\` is not a column on \`${prevName}\``,
18027
+ fix: `\`joinPath[${i}].on.from\` names columns on ${i === 0 ? "this collection's table" : `the previous step's table (\`${prevName}\`)`}: ${quote(prevColumns)}`
18028
+ });
18029
+ for (const column of asColumns(step.on.to)) if (!stepColumns.has(column)) defects.push({
18030
+ ...at,
18031
+ problem: `step ${i + 1} joins into \`${step.table}.${column}\`, but \`${column}\` is not a column on \`${step.table}\``,
18032
+ fix: `\`joinPath[${i}].on.to\` names columns on \`${step.table}\`: ${quote(stepColumns)}`
18033
+ });
18034
+ if (asColumns(step.on.from).length !== asColumns(step.on.to).length) defects.push({
18035
+ ...at,
18036
+ problem: `step ${i + 1} compares ${asColumns(step.on.from).length} column(s) against ${asColumns(step.on.to).length}`,
18037
+ fix: `\`from\` and \`to\` must name the same number of columns in \`joinPath[${i}]\``
18038
+ });
18039
+ prevName = step.table;
18040
+ prevColumns = stepColumns;
18041
+ }
18042
+ if (!broken && prevName !== targetTableName) defects.push({
18043
+ ...at,
18044
+ problem: `its \`joinPath\` ends at \`${prevName}\`, but it targets \`${targetCollection.slug}\` (table \`${targetTableName}\`)`,
18045
+ fix: `make the last step join \`${targetTableName}\`, or point \`target\` at the collection backed by \`${prevName}\``
18046
+ });
18047
+ break;
18048
+ }
18049
+ default: throw new Error(`Unhandled relation kind: ${JSON.stringify(relation)}`);
18050
+ }
18051
+ }
18052
+ }
18053
+ return defects;
18054
+ }
18055
+ /**
18056
+ * Fail boot on any relation that cannot resolve, listing all of them at once.
18057
+ *
18058
+ * Deliberately fatal rather than a warning. Every one of these produces an
18059
+ * empty result at query time and nothing else — an empty tab, an empty
18060
+ * `include`, a subcollection that looks like it has no rows. A server that
18061
+ * refuses to start is recoverable in a minute; a relation that quietly answers
18062
+ * "nothing" is the kind of bug found in production, weeks later, by a user
18063
+ * asking where their data went.
18064
+ */
18065
+ function assertRelationsResolve(collections, registry) {
18066
+ const defects = findRelationDefects(collections, registry);
18067
+ if (defects.length === 0) return;
18068
+ const lines = defects.map((d) => ` • ${d.collection}.${d.relationName} (${d.kind})\n ${d.problem}\n fix: ${d.fix}`);
18069
+ throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database schema.\n\nEach of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
18070
+
18071
+ ` + lines.join("\n\n") + "\n");
18072
+ }
18073
+ //#endregion
17941
18074
  //#region src/collections/buildRegistry.ts
17942
18075
  /**
17943
18076
  * Build the collection registry for a driver.
@@ -17962,8 +18095,163 @@ function buildCollectionRegistry(schema) {
17962
18095
  if (schema.enums) registry.registerEnums(schema.enums);
17963
18096
  if (schema.relations) registry.registerRelations(schema.relations);
17964
18097
  warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
18098
+ assertRelationsResolve(registry.getCollections(), registry);
17965
18099
  return registry;
17966
18100
  }
18101
+ /** Key under which the version is stored in the auth schema's meta table. */
18102
+ var VERSION_KEY = "auth_schema_version";
18103
+ /**
18104
+ * Columns `refresh_tokens` must have for the current runtime's auth write path
18105
+ * to work. Checked by the health probe so a database that drifted *below* this
18106
+ * runtime is reported as unhealthy rather than discovered one failed login at a
18107
+ * time. Kept in step with the migration in `ensureAuthTablesExist`.
18108
+ */
18109
+ var REQUIRED_REFRESH_TOKEN_COLUMNS = [
18110
+ "session_id",
18111
+ "revoked",
18112
+ "rotated_at",
18113
+ "session_started_at"
18114
+ ];
18115
+ /**
18116
+ * A constraint whose *presence* means the database is still at version 1, in a
18117
+ * shape this runtime's rotation logic cannot write to: it makes two live tokens
18118
+ * of one rotating session collide.
18119
+ */
18120
+ var RETIRED_REFRESH_TOKEN_CONSTRAINT = "unique_device_session";
18121
+ /**
18122
+ * Thrown when the database was migrated by a runtime newer than this one.
18123
+ *
18124
+ * Distinct class rather than a bare `Error` because `ensureAuthTablesExist`
18125
+ * wraps its migrations in a catch that deliberately swallows failures and
18126
+ * continues — every other problem there is better survived than crashed on.
18127
+ * This one is not, so the catch rethrows on this type specifically.
18128
+ */
18129
+ var AuthSchemaVersionError = class extends Error {
18130
+ databaseVersion;
18131
+ runtimeVersion;
18132
+ constructor(databaseVersion, runtimeVersion) {
18133
+ super(`Auth schema version mismatch: the database is at version ${databaseVersion}, but this runtime understands version ${runtimeVersion}.\n\nA newer version of the framework has already migrated this database. Running this older runtime against it would boot cleanly and then fail every login and token refresh, because the auth schema it expects no longer exists.
18134
+
18135
+ Refusing to start. Deploy a framework version at or above the one that migrated this database, or restore the database from a backup taken before the upgrade.`);
18136
+ this.name = "AuthSchemaVersionError";
18137
+ this.databaseVersion = databaseVersion;
18138
+ this.runtimeVersion = runtimeVersion;
18139
+ }
18140
+ };
18141
+ /**
18142
+ * The schema the auth tables live in, derived exactly as `ensureAuthTablesExist`
18143
+ * derives it. Shared so the two cannot drift: a stamp written to one schema and
18144
+ * read from another would read as "never stamped" forever.
18145
+ */
18146
+ function resolveAuthSchema(collection) {
18147
+ if (!collection) return "rebase";
18148
+ const usersSchema = "schema" in collection && typeof collection.schema === "string" ? collection.schema : "public";
18149
+ return usersSchema === "public" ? "rebase" : usersSchema;
18150
+ }
18151
+ /**
18152
+ * Read the stamped version, or `null` when the database has never been stamped.
18153
+ *
18154
+ * `null` is not an error and must not be treated as one: every database
18155
+ * provisioned before this file existed is unstamped, and so is every fresh one.
18156
+ * Uses `to_regclass` rather than selecting straight from the table so a missing
18157
+ * schema or table is a `null` rather than a thrown 42P01.
18158
+ */
18159
+ async function readAuthSchemaVersion(db, authSchema) {
18160
+ const qualified = `"${authSchema}"."schema_meta"`;
18161
+ if (!(await db.execute(sql`SELECT to_regclass(${qualified}) IS NOT NULL AS present`)).rows[0]?.present) return null;
18162
+ const raw = (await db.execute(sql`
18163
+ SELECT value FROM ${sql.raw(qualified)} WHERE key = ${VERSION_KEY}
18164
+ `)).rows[0]?.value;
18165
+ if (raw === void 0) return null;
18166
+ const parsed = Number.parseInt(raw, 10);
18167
+ return Number.isFinite(parsed) ? parsed : null;
18168
+ }
18169
+ /**
18170
+ * Refuse to run against a database a newer runtime has already migrated.
18171
+ *
18172
+ * Deliberately one-directional. A database *older* than this runtime is the
18173
+ * normal upgrade path — the migrations in `ensureAuthTablesExist` are about to
18174
+ * bring it forward, so it is not an error. Only the reverse is unrecoverable.
18175
+ */
18176
+ async function assertAuthSchemaCompatible(db, authSchema) {
18177
+ const databaseVersion = await readAuthSchemaVersion(db, authSchema);
18178
+ if (databaseVersion !== null && databaseVersion > 2) throw new AuthSchemaVersionError(databaseVersion, 2);
18179
+ }
18180
+ /**
18181
+ * Record that this runtime's migrations have been applied.
18182
+ *
18183
+ * Called at the end of `ensureAuthTablesExist`, so a boot that failed partway
18184
+ * through leaves the older stamp in place and the next boot migrates again.
18185
+ */
18186
+ async function stampAuthSchemaVersion(db, authSchema) {
18187
+ const qualified = `"${authSchema}"."schema_meta"`;
18188
+ await db.execute(sql`
18189
+ CREATE TABLE IF NOT EXISTS ${sql.raw(qualified)} (
18190
+ key TEXT PRIMARY KEY,
18191
+ value TEXT NOT NULL,
18192
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
18193
+ )
18194
+ `);
18195
+ await db.execute(sql`
18196
+ INSERT INTO ${sql.raw(qualified)} (key, value)
18197
+ VALUES (${VERSION_KEY}, ${String(2)})
18198
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
18199
+ `);
18200
+ }
18201
+ /**
18202
+ * Check that the auth schema is one this runtime can actually write to.
18203
+ *
18204
+ * Two independent checks, because either alone has a blind spot:
18205
+ *
18206
+ * - The **stamp** catches a runtime older than the database. It is the precise
18207
+ * signal, but it is blind on every database provisioned before stamping
18208
+ * existed — which today is all of them.
18209
+ * - The **structure** catches a database older than the runtime, and works on
18210
+ * unstamped databases. It is what makes this useful immediately rather than
18211
+ * one upgrade cycle from now.
18212
+ *
18213
+ * Never throws: a probe that fails to run reports unhealthy with the reason, so
18214
+ * a broken check surfaces as a degraded health response rather than a 500 from
18215
+ * the health endpoint itself.
18216
+ */
18217
+ async function probeAuthSchema(db, authSchema) {
18218
+ const problems = [];
18219
+ let databaseVersion = null;
18220
+ try {
18221
+ databaseVersion = await readAuthSchemaVersion(db, authSchema);
18222
+ if (databaseVersion !== null && databaseVersion > 2) problems.push(`database is at auth schema version ${databaseVersion}, this runtime understands 2 — it was migrated by a newer framework version`);
18223
+ const refreshTokens = `"${authSchema}"."refresh_tokens"`;
18224
+ if (!(await db.execute(sql`SELECT to_regclass(${refreshTokens}) IS NOT NULL AS present`)).rows[0]?.present) return {
18225
+ healthy: problems.length === 0,
18226
+ databaseVersion,
18227
+ runtimeVersion: 2,
18228
+ problems
18229
+ };
18230
+ const columns = await db.execute(sql`
18231
+ SELECT column_name FROM information_schema.columns
18232
+ WHERE table_schema = ${authSchema} AND table_name = 'refresh_tokens'
18233
+ `);
18234
+ const found = new Set(columns.rows.map((row) => row.column_name));
18235
+ const missing = REQUIRED_REFRESH_TOKEN_COLUMNS.filter((column) => !found.has(column));
18236
+ if (missing.length > 0) problems.push(`refresh_tokens is missing ${missing.join(", ")} — the auth migrations have not been applied to this database, so token rotation will fail`);
18237
+ if ((await db.execute(sql`
18238
+ SELECT 1 FROM pg_constraint c
18239
+ JOIN pg_class t ON t.oid = c.conrelid
18240
+ JOIN pg_namespace n ON n.oid = t.relnamespace
18241
+ WHERE n.nspname = ${authSchema}
18242
+ AND t.relname = 'refresh_tokens'
18243
+ AND c.conname = ${RETIRED_REFRESH_TOKEN_CONSTRAINT}
18244
+ `)).rows.length > 0) problems.push(`refresh_tokens still carries ${RETIRED_REFRESH_TOKEN_CONSTRAINT} — concurrent token rotation for one session will fail on it`);
18245
+ } catch (error) {
18246
+ problems.push(`auth schema probe failed: ${error instanceof Error ? error.message : String(error)}`);
18247
+ }
18248
+ return {
18249
+ healthy: problems.length === 0,
18250
+ databaseVersion,
18251
+ runtimeVersion: 2,
18252
+ problems
18253
+ };
18254
+ }
17967
18255
  //#endregion
17968
18256
  //#region src/auth/ensure-tables.ts
17969
18257
  /**
@@ -17975,6 +18263,7 @@ function buildCollectionRegistry(schema) {
17975
18263
  */
17976
18264
  async function ensureAuthTablesExist(db, collection) {
17977
18265
  logger.info("🔍 Checking auth tables...");
18266
+ await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
17978
18267
  try {
17979
18268
  let usersTableName = "\"rebase\".\"users\"";
17980
18269
  let userIdType = "TEXT";
@@ -18355,8 +18644,10 @@ async function ensureAuthTablesExist(db, collection) {
18355
18644
  } catch (rlsReconcileError) {
18356
18645
  logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
18357
18646
  }
18647
+ await stampAuthSchemaVersion(db, authSchema);
18358
18648
  logger.info("✅ Auth tables ready");
18359
18649
  } catch (error) {
18650
+ if (error instanceof AuthSchemaVersionError) throw error;
18360
18651
  logger.error("❌ Failed to create auth tables", { error });
18361
18652
  logger.warn("⚠️ Continuing without creating auth tables.");
18362
18653
  }
@@ -20251,7 +20542,7 @@ function createPostgresBootstrapper(pgConfig) {
20251
20542
  } catch {}
20252
20543
  const message = `
20253
20544
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
20254
- ❌ Cannot connect to PostgreSQL at ${hostInfo}\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n The database server is not running or is not accepting\n connections. Common fixes:\n\n • brew services start postgresql@18\n • docker compose up -d postgres\n • Verify DATABASE_URL in your .env file\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
20545
+ ❌ Cannot connect to PostgreSQL at ${hostInfo}\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n The database server is not running or is not accepting\n connections. Common fixes:\n\n • docker compose up -d db (the service a Rebase scaffold ships)\n • brew services start postgresql@18\n • Verify DATABASE_URL in your .env file\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
20255
20546
  logger.error(message);
20256
20547
  throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
20257
20548
  }
@@ -20336,10 +20627,15 @@ function createPostgresBootstrapper(pgConfig) {
20336
20627
  const cdcRunSql = async (text) => {
20337
20628
  return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
20338
20629
  };
20339
- await provisionTriggerCdc(cdcRunSql, registry.getCollections().map((c) => ({
20630
+ const cdcTables = registry.getCollections().map((c) => ({
20340
20631
  schema: c.schema ?? "public",
20341
20632
  table: getTableName$1(c)
20342
- })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
20633
+ })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table));
20634
+ for (const link of collectJunctionLinks(registry)) cdcTables.push({
20635
+ schema: link.schema,
20636
+ table: link.table
20637
+ });
20638
+ await provisionTriggerCdc(cdcRunSql, cdcTables);
20343
20639
  await realtimeService.enableCdc(directUrl);
20344
20640
  cdcEnabled = true;
20345
20641
  provisionCdcForTables = async (tables) => {
@@ -20449,7 +20745,8 @@ function createPostgresBootstrapper(pgConfig) {
20449
20745
  userService,
20450
20746
  roleService: userService,
20451
20747
  emailService,
20452
- authRepository
20748
+ authRepository,
20749
+ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
20453
20750
  };
20454
20751
  },
20455
20752
  async initializeHistory(config, driverResult) {
@@ -20477,7 +20774,7 @@ function createPostgresBootstrapper(pgConfig) {
20477
20774
  */
20478
20775
  async ensureCollectionSchema(collections, driverResult, log) {
20479
20776
  const internals = driverResult.internals;
20480
- const { ensureCollectionTables } = await import("./ensure-collection-tables-BVvtkkRm.js");
20777
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-DGMYK0fr.js");
20481
20778
  return { applied: (await ensureCollectionTables({ async query(text) {
20482
20779
  const result = await internals.db.execute(sql.raw(text));
20483
20780
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };