@rebasepro/server-postgres 0.10.1-canary.3d09338 → 0.10.1-canary.60301c3

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 (39) hide show
  1. package/dist/auth/schema-version.d.ts +100 -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 +507 -309
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/services/RelationService.d.ts +9 -8
  9. package/dist/services/nested-path.d.ts +3 -3
  10. package/dist/services/row-pipeline.d.ts +2 -2
  11. package/dist/{src-lcfUP4xg.js → src-3VmUJ8Xn.js} +135 -247
  12. package/dist/src-3VmUJ8Xn.js.map +1 -0
  13. package/dist/{src-CBgtrPhJ.js → src-D5xBTl32.js} +12 -2
  14. package/dist/src-D5xBTl32.js.map +1 -0
  15. package/dist/utils/drizzle-conditions.d.ts +35 -23
  16. package/package.json +6 -6
  17. package/src/PostgresBootstrapper.ts +5 -1
  18. package/src/auth/ensure-tables.ts +23 -0
  19. package/src/auth/schema-version.ts +254 -0
  20. package/src/cli-errors.ts +1 -1
  21. package/src/cli-helpers.ts +4 -3
  22. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  23. package/src/collections/buildRegistry.ts +7 -0
  24. package/src/collections/validate-relations.ts +280 -0
  25. package/src/data-transformer.ts +28 -38
  26. package/src/schema/doctor.ts +8 -8
  27. package/src/schema/generate-drizzle-schema-logic.ts +49 -101
  28. package/src/schema/generate-postgres-ddl-logic.ts +9 -9
  29. package/src/schema/introspect-db-logic.ts +19 -23
  30. package/src/services/FetchService.ts +12 -12
  31. package/src/services/PersistService.ts +23 -27
  32. package/src/services/RelationService.ts +65 -83
  33. package/src/services/cdc/junction-tables.ts +4 -4
  34. package/src/services/nested-path.ts +24 -9
  35. package/src/services/row-pipeline.ts +5 -6
  36. package/src/utils/drizzle-conditions.ts +156 -372
  37. package/dist/ensure-collection-tables-BVvtkkRm.js.map +0 -1
  38. package/dist/src-CBgtrPhJ.js.map +0 -1
  39. 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, targetTable, 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`.
@@ -572,58 +576,43 @@ var DrizzleConditionBuilder = class {
572
576
  /**
573
577
  * Build relation-based conditions for different relation types
574
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
+ */
575
593
  static buildRelationConditions(relation, parentId, targetTable, parentTable, parentIdColumn, targetIdColumn, registry) {
576
- console.debug("🔍 [buildRelationConditions] Building conditions for relation:", {
577
- relationName: relation.relationName,
578
- cardinality: relation.cardinality,
579
- direction: relation.direction,
580
- hasThrough: !!relation.through,
581
- hasForeignKeyOnTarget: !!relation.foreignKeyOnTarget,
582
- inverseRelationName: relation.inverseRelationName,
583
- parentId
584
- });
585
594
  const joinConditions = [];
586
595
  const whereConditions = [];
587
- if (relation.joinPath && relation.joinPath.length > 0) {
588
- console.debug("🔍 [buildRelationConditions] Using joinPath logic");
589
- const { joins, finalCondition } = this.buildJoinPathConditions(relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry);
590
- joinConditions.push(...joins);
591
- whereConditions.push(finalCondition);
592
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
593
- console.debug("🔍 [buildRelationConditions] Using owning many-to-many with explicit through");
594
- const junctionResult = this.buildJunctionTableConditions(relation.through, targetIdColumn, parentId, registry);
595
- joinConditions.push(junctionResult.join);
596
- whereConditions.push(junctionResult.condition);
597
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") {
598
- console.debug("🔍 [buildRelationConditions] Using inverse many-to-many with explicit through");
599
- const junctionResult = this.buildInverseJunctionTableConditions(relation.through, targetIdColumn, parentId, registry);
600
- joinConditions.push(junctionResult.join);
601
- whereConditions.push(junctionResult.condition);
602
- } else if (relation.cardinality === "many" && relation.direction === "inverse" && !relation.through) {
603
- console.debug("🔍 [buildRelationConditions] Handling inverse many relationship without explicit through");
604
- const junctionInfo = this.findCorrespondingJunctionTable(relation, registry);
605
- if (junctionInfo) {
606
- console.debug("🔍 [buildRelationConditions] Found junction info for inverse many-to-many, building junction conditions");
607
- 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);
608
605
  joinConditions.push(junctionResult.join);
609
606
  whereConditions.push(junctionResult.condition);
610
- } else if (relation.foreignKeyOnTarget) {
611
- console.debug("🔍 [buildRelationConditions] No junction table found, treating as inverse one-to-many with foreign key on target");
612
- const simpleCondition = this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId);
613
- whereConditions.push(simpleCondition);
614
- } else {
615
- logger.error("🔍 [buildRelationConditions] Failed to find junction table info and no foreign key specified");
616
- 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;
617
608
  }
618
- } else {
619
- console.debug("🔍 [buildRelationConditions] Using simple relation logic - THIS IS WHERE THE ERROR MIGHT OCCUR");
620
- const simpleCondition = this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId);
621
- 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)}`);
622
615
  }
623
- console.debug("🔍 [buildRelationConditions] Final result:", {
624
- joinConditionsCount: joinConditions.length,
625
- whereConditionsCount: whereConditions.length
626
- });
627
616
  return {
628
617
  joinConditions,
629
618
  whereConditions
@@ -763,48 +752,24 @@ var DrizzleConditionBuilder = class {
763
752
  };
764
753
  }
765
754
  /**
766
- * Build conditions for inverse junction table (many-to-many) relations
767
- */
768
- static buildInverseJunctionTableConditions(through, targetIdColumn, parentId, registry) {
769
- const junctionTable = registry.getTable(through.table);
770
- if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
771
- const junctionSourceCol = junctionTable[through.sourceColumn];
772
- const junctionTargetCol = junctionTable[through.targetColumn];
773
- if (!junctionSourceCol) throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
774
- if (!junctionTargetCol) throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
775
- const condition = Array.isArray(parentId) ? inArray(junctionSourceCol, parentId) : eq(junctionSourceCol, parentId);
776
- return {
777
- join: {
778
- table: junctionTable,
779
- condition: eq(targetIdColumn, junctionTargetCol)
780
- },
781
- condition
782
- };
783
- }
784
- /**
785
- * 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.
786
762
  */
787
763
  static buildSimpleRelationCondition(relation, targetTable, parentTable, parentId) {
788
- if (relation.direction === "owning" && relation.localKey) {
789
- const targetIdCol = Object.values(targetTable).find((col) => col.primary);
790
- if (!targetIdCol) {
791
- const idCol = Object.values(targetTable).find((col) => col.name === "id");
792
- if (!idCol) throw new Error("No primary key or \"id\" column found in target table");
793
- return Array.isArray(parentId) ? inArray(idCol, parentId) : eq(idCol, parentId);
794
- }
795
- return Array.isArray(parentId) ? inArray(targetIdCol, parentId) : eq(targetIdCol, parentId);
796
- } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
797
- const foreignKeyCol = targetTable[relation.foreignKeyOnTarget];
798
- 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.`);
799
- return Array.isArray(parentId) ? inArray(foreignKeyCol, parentId) : eq(foreignKeyCol, parentId);
800
- } 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.`);
801
- else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
802
- const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
803
- const foreignKeyCol = targetTable[inferredForeignKeyName];
804
- 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.`);
805
- console.debug(`🔍 [DrizzleConditionBuilder] Auto-inferred foreign key '${inferredForeignKeyName}' for inverse relation '${relation.relationName}'`);
806
- return Array.isArray(parentId) ? inArray(foreignKeyCol, parentId) : eq(foreignKeyCol, parentId);
807
- } 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);
808
773
  }
809
774
  /**
810
775
  * Combine multiple conditions with AND operator
@@ -859,16 +824,23 @@ var DrizzleConditionBuilder = class {
859
824
  return query;
860
825
  }
861
826
  /**
862
- * 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.
863
832
  */
864
833
  static buildRelationCountQuery(baseCountQuery, relation, parentId, targetTable, parentTable, parentIdColumn, targetIdColumn, registry, additionalFilters) {
865
- if (relation.joinPath && relation.joinPath.length > 0) return this.buildJoinPathCountQuery(baseCountQuery, relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry, additionalFilters);
866
- else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") return this.buildJunctionCountQuery(baseCountQuery, relation.through, targetIdColumn, parentId, registry, additionalFilters);
867
- else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") return this.buildInverseJunctionCountQuery(baseCountQuery, relation.through, targetIdColumn, parentId, registry, additionalFilters);
868
- else {
869
- const allConditions = [this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId)];
870
- if (additionalFilters) allConditions.push(...additionalFilters);
871
- 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)}`);
872
844
  }
873
845
  }
874
846
  /**
@@ -909,20 +881,6 @@ var DrizzleConditionBuilder = class {
909
881
  return baseCountQuery.innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol)).where(and(...baseConditions));
910
882
  }
911
883
  /**
912
- * Build inverse junction table conditions for count queries
913
- */
914
- static buildInverseJunctionCountQuery(baseCountQuery, through, targetIdColumn, parentId, registry, additionalFilters) {
915
- const junctionTable = registry.getTable(through.table);
916
- if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
917
- const junctionSourceCol = junctionTable[through.sourceColumn];
918
- const junctionTargetCol = junctionTable[through.targetColumn];
919
- if (!junctionSourceCol) throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
920
- if (!junctionTargetCol) throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
921
- const baseConditions = [eq(junctionSourceCol, parentId)];
922
- if (additionalFilters && additionalFilters.length > 0) baseConditions.push(...additionalFilters);
923
- return baseCountQuery.innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol)).where(and(...baseConditions));
924
- }
925
- /**
926
884
  * Helper method to extract table names from columns
927
885
  */
928
886
  static getTableNamesFromColumns(columns) {
@@ -937,49 +895,6 @@ var DrizzleConditionBuilder = class {
937
895
  return [getColumnName(columns)];
938
896
  }
939
897
  /**
940
- * Find the corresponding junction table for an inverse many-to-many relation
941
- */
942
- static findCorrespondingJunctionTable(relation, registry) {
943
- try {
944
- console.debug(`🔍 [findCorrespondingJunctionTable] Looking for junction table for inverse relation '${relation.relationName}' with inverseRelationName '${relation.inverseRelationName}'`);
945
- if (!relation.inverseRelationName) {
946
- console.debug("🔍 [findCorrespondingJunctionTable] No inverseRelationName specified");
947
- return null;
948
- }
949
- const targetCollection = relation.target();
950
- console.debug(`🔍 [findCorrespondingJunctionTable] Target collection: ${targetCollection.slug}`);
951
- const targetCollectionRelations = resolveCollectionRelations(targetCollection);
952
- console.debug("🔍 [findCorrespondingJunctionTable] Target collection relations:", Object.keys(targetCollectionRelations));
953
- const correspondingRelation = targetCollectionRelations[relation.inverseRelationName];
954
- if (!correspondingRelation) {
955
- console.debug(`🔍 [findCorrespondingJunctionTable] No relation found with key '${relation.inverseRelationName}' on target collection`);
956
- return null;
957
- }
958
- console.debug("🔍 [findCorrespondingJunctionTable] Found relation:", {
959
- relationName: correspondingRelation.relationName,
960
- cardinality: correspondingRelation.cardinality,
961
- direction: correspondingRelation.direction,
962
- hasThrough: !!correspondingRelation.through
963
- });
964
- if (correspondingRelation.cardinality !== "many" || correspondingRelation.direction !== "owning" || !correspondingRelation.through) {
965
- console.debug("🔍 [findCorrespondingJunctionTable] Relation is not an owning many-to-many with junction table");
966
- return null;
967
- }
968
- console.debug("🔍 [findCorrespondingJunctionTable] Found matching owning relation with junction table!");
969
- const through = correspondingRelation.through;
970
- const result = {
971
- table: through.table,
972
- sourceColumn: through.targetColumn,
973
- targetColumn: through.sourceColumn
974
- };
975
- console.debug("🔍 [findCorrespondingJunctionTable] Returning junction info:", result);
976
- return result;
977
- } catch (error) {
978
- logger.error(`🔍 [findCorrespondingJunctionTable] Error finding corresponding junction table for relation '${relation.relationName}'`, { error });
979
- return null;
980
- }
981
- }
982
- /**
983
898
  * Build vector similarity search expressions for pgvector.
984
899
  *
985
900
  * Returns:
@@ -1056,7 +971,7 @@ function serializeDataToServer(row, properties, collection, registry) {
1056
971
  const joinPathRelationUpdates = [];
1057
972
  const foreignKeys = /* @__PURE__ */ new Set();
1058
973
  Object.values(resolvedRelations).forEach((relation) => {
1059
- if (relation.localKey) foreignKeys.add(relation.localKey);
974
+ if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
1060
975
  });
1061
976
  for (const [key, value] of Object.entries(row)) {
1062
977
  const property = properties[key];
@@ -1068,11 +983,11 @@ function serializeDataToServer(row, properties, collection, registry) {
1068
983
  if (property.type === "relation" && collection) {
1069
984
  const relation = findRelation(resolvedRelations, key);
1070
985
  if (relation) {
1071
- if (relation.direction === "owning" && relation.localKey) {
986
+ if (relation.kind === "belongsTo") {
1072
987
  const serializedValue = serializePropertyToServer(effectiveValue, property);
1073
988
  if (serializedValue !== void 0) result[relation.localKey] = serializedValue;
1074
989
  continue;
1075
- } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
990
+ } else if (hasForeignKeyOnTarget(relation)) {
1076
991
  const serializedValue = serializePropertyToServer(effectiveValue, property);
1077
992
  inverseRelationUpdates.push({
1078
993
  relationKey: key,
@@ -1080,7 +995,7 @@ function serializeDataToServer(row, properties, collection, registry) {
1080
995
  newValue: serializedValue
1081
996
  });
1082
997
  continue;
1083
- } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
998
+ } else if (relation.kind === "via") {
1084
999
  const serializedValue = serializePropertyToServer(effectiveValue, property);
1085
1000
  if (relation.cardinality === "one") joinPathRelationUpdates.push({
1086
1001
  relationKey: key,
@@ -1093,14 +1008,6 @@ function serializeDataToServer(row, properties, collection, registry) {
1093
1008
  newValue: serializedValue
1094
1009
  });
1095
1010
  continue;
1096
- } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
1097
- const serializedValue = serializePropertyToServer(effectiveValue, property);
1098
- joinPathRelationUpdates.push({
1099
- relationKey: key,
1100
- relation,
1101
- newTargetId: serializedValue
1102
- });
1103
- continue;
1104
1011
  }
1105
1012
  }
1106
1013
  }
@@ -1186,7 +1093,7 @@ async function parseDataFromServer(data, collection, db, registry) {
1186
1093
  for (const [propKey, property] of Object.entries(properties)) if (property.type === "relation" && !(propKey in result)) {
1187
1094
  const relation = findRelation(resolvedRelations, propKey);
1188
1095
  if (relation) {
1189
- if (relation.direction === "owning" && relation.localKey && relation.localKey in data) {
1096
+ if (relation.kind === "belongsTo" && relation.localKey in data) {
1190
1097
  const fkValue = data[relation.localKey];
1191
1098
  if (fkValue !== null && fkValue !== void 0) try {
1192
1099
  const targetCollection = relation.target();
@@ -1194,7 +1101,7 @@ async function parseDataFromServer(data, collection, db, registry) {
1194
1101
  } catch (e) {
1195
1102
  logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
1196
1103
  }
1197
- } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget && db && registry) try {
1104
+ } else if (hasForeignKeyOnTarget(relation) && db && registry) try {
1198
1105
  const targetCollection = relation.target();
1199
1106
  const targetTable = registry.getTable(getTableName$1(targetCollection));
1200
1107
  const currentId = buildCompositeId(data, getPrimaryKeys(collection, registry));
@@ -1215,7 +1122,7 @@ async function parseDataFromServer(data, collection, db, registry) {
1215
1122
  } catch (e) {
1216
1123
  logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
1217
1124
  }
1218
- else if (relation.direction === "inverse" && relation.joinPath && db && registry) try {
1125
+ else if (relation.kind === "via" && db && registry) try {
1219
1126
  const targetCollection = relation.target();
1220
1127
  const pks = getPrimaryKeys(collection, registry);
1221
1128
  const currentId = buildCompositeId(data, pks);
@@ -1332,11 +1239,10 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
1332
1239
  }
1333
1240
  case "relation":
1334
1241
  if (typeof value === "string" || typeof value === "number") {
1335
- let relationDef = property.relation;
1242
+ let relationDef = property.resolvedRelation;
1336
1243
  if (!relationDef && propertyKey) relationDef = findRelation(resolveCollectionRelations(collection), propertyKey);
1337
- if (!relationDef) relationDef = collection.relations?.find((rel) => rel.relationName === property.relationName);
1338
1244
  if (!relationDef) {
1339
- logger.warn(`Relation not defined in property for key: ${propertyKey || "unknown"}`);
1245
+ logger.warn(`ResolvedRelation not defined in property for key: ${propertyKey || "unknown"}`);
1340
1246
  return value;
1341
1247
  }
1342
1248
  try {
@@ -1436,7 +1342,7 @@ function normalizeScalarValues(data, properties, collection, resolvedRelations,
1436
1342
  const result = {};
1437
1343
  const internalFKColumns = /* @__PURE__ */ new Set();
1438
1344
  Object.values(resolvedRelations).forEach((relation) => {
1439
- if (relation.localKey && !properties[relation.localKey]) internalFKColumns.add(relation.localKey);
1345
+ if (relation.kind === "belongsTo" && !properties[relation.localKey]) internalFKColumns.add(relation.localKey);
1440
1346
  });
1441
1347
  for (const [key, value] of Object.entries(data)) {
1442
1348
  if (internalFKColumns.has(key)) {
@@ -1574,7 +1480,7 @@ var RelationService = class {
1574
1480
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
1575
1481
  if (!parentTable) throw new Error("Parent table not found");
1576
1482
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1577
- if (relation.joinPath && relation.joinPath.length > 0) {
1483
+ if (relation.kind === "via") {
1578
1484
  let query = this.db.select().from(parentTable).$dynamic();
1579
1485
  let currentTable = parentTable;
1580
1486
  for (const join of relation.joinPath) {
@@ -1689,8 +1595,8 @@ var RelationService = class {
1689
1595
  * `tags` table and delete by primary key.
1690
1596
  */
1691
1597
  async unlinkRelatedEntity(tx, hop, targetId) {
1598
+ if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
1692
1599
  const through = hop.relation.through;
1693
- if (!through) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
1694
1600
  const junctionTable = this.registry.getTable(through.table);
1695
1601
  if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
1696
1602
  const sourceJunctionColumn = junctionTable[through.sourceColumn];
@@ -1720,7 +1626,7 @@ var RelationService = class {
1720
1626
  if (!parentTable) throw new Error("Parent table not found");
1721
1627
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1722
1628
  const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
1723
- if (relation.joinPath && relation.joinPath.length > 0) {
1629
+ if (relation.kind === "via") {
1724
1630
  let query = this.db.select().from(parentTable).$dynamic();
1725
1631
  let currentTable = parentTable;
1726
1632
  for (const join of relation.joinPath) {
@@ -1749,7 +1655,7 @@ var RelationService = class {
1749
1655
  }
1750
1656
  return resultMap;
1751
1657
  }
1752
- if (relation.direction === "owning" && relation.localKey) {
1658
+ if (relation.kind === "belongsTo") {
1753
1659
  this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
1754
1660
  const localKeyCol = parentTable[relation.localKey];
1755
1661
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
@@ -1783,7 +1689,7 @@ var RelationService = class {
1783
1689
  }
1784
1690
  return resultMap;
1785
1691
  }
1786
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
1692
+ this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
1787
1693
  let query = this.db.select().from(targetTable).$dynamic();
1788
1694
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
1789
1695
  const results = await query;
@@ -1792,8 +1698,7 @@ var RelationService = class {
1792
1698
  for (const row of results) {
1793
1699
  const targetRow = row[getTableName$1(targetCollection)] || row;
1794
1700
  let parentId;
1795
- if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
1796
- else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
1701
+ if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
1797
1702
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
1798
1703
  }
1799
1704
  return resultMap;
@@ -1816,7 +1721,7 @@ var RelationService = class {
1816
1721
  if (!parentTable) throw new Error("Parent table not found");
1817
1722
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1818
1723
  const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
1819
- if (relation.joinPath && relation.joinPath.length > 0) {
1724
+ if (relation.kind === "via") {
1820
1725
  let query = this.db.select().from(parentTable).$dynamic();
1821
1726
  let currentTable = parentTable;
1822
1727
  for (const join of relation.joinPath) {
@@ -1846,7 +1751,7 @@ var RelationService = class {
1846
1751
  }
1847
1752
  return resultMap;
1848
1753
  }
1849
- if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
1754
+ if (relation.kind === "manyToMany") {
1850
1755
  this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
1851
1756
  const junctionTable = this.registry.getTable(relation.through.table);
1852
1757
  if (!junctionTable) {
@@ -1872,7 +1777,7 @@ var RelationService = class {
1872
1777
  }
1873
1778
  return resultMap;
1874
1779
  }
1875
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
1780
+ this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
1876
1781
  let query = this.db.select().from(targetTable).$dynamic();
1877
1782
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
1878
1783
  const results = await query;
@@ -1881,9 +1786,7 @@ var RelationService = class {
1881
1786
  for (const row of results) {
1882
1787
  const targetRow = row[getTableName$1(targetCollection)] || row;
1883
1788
  let parentId;
1884
- if (relation.through && relation.direction === "inverse") parentId = (row[relation.through.table] || row)[relation.through.targetColumn];
1885
- else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
1886
- else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
1789
+ if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
1887
1790
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
1888
1791
  const key = String(parentId);
1889
1792
  const arr = resultMap.get(key) || [];
@@ -1903,7 +1806,7 @@ var RelationService = class {
1903
1806
  if (!relation || relation.cardinality !== "many") continue;
1904
1807
  const targetEntityIds = value && Array.isArray(value) ? value.map((rel) => rel.id) : [];
1905
1808
  const targetCollection = relation.target();
1906
- if (relation.joinPath && relation.joinPath.length > 0) {
1809
+ if (relation.kind === "via") {
1907
1810
  const parentTableName = getTableName$1(collection);
1908
1811
  const targetTableName = getTableName$1(targetCollection);
1909
1812
  let junctionTable = void 0;
@@ -1948,7 +1851,7 @@ var RelationService = class {
1948
1851
  }));
1949
1852
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
1950
1853
  }
1951
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
1854
+ } else if (relation.kind === "manyToMany") {
1952
1855
  const junctionTable = this.registry.getTable(relation.through.table);
1953
1856
  if (!junctionTable) {
1954
1857
  logger.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
@@ -1973,8 +1876,7 @@ var RelationService = class {
1973
1876
  }));
1974
1877
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
1975
1878
  }
1976
- } 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.`);
1977
- else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
1879
+ } else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
1978
1880
  const targetTable = getTableForCollection(targetCollection, this.registry);
1979
1881
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1980
1882
  const targetIdInfo = targetPks[0];
@@ -2008,28 +1910,20 @@ var RelationService = class {
2008
1910
  const targetIdInfo = targetPks[0];
2009
1911
  const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
2010
1912
  const sourceIdInfo = sourcePks[0];
2011
- if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
1913
+ if (relation.kind === "via") {
2012
1914
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
2013
1915
  continue;
2014
1916
  }
2015
- if (relation.cardinality === "many" && relation.direction === "inverse") {
2016
- const targetCollectionRelations = resolveCollectionRelations(targetCollection);
2017
- let junctionInfo = null;
2018
- for (const [relationKey, targetRelation] of Object.entries(targetCollectionRelations)) if (targetRelation.cardinality === "many" && targetRelation.direction === "owning" && targetRelation.through && (targetRelation.relationName === relation.inverseRelationName || relationKey === relation.inverseRelationName)) {
2019
- junctionInfo = {
2020
- table: targetRelation.through.table,
2021
- sourceColumn: targetRelation.through.targetColumn,
2022
- targetColumn: targetRelation.through.sourceColumn
2023
- };
2024
- break;
2025
- }
2026
- if (junctionInfo) {
2027
- await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo);
2028
- continue;
2029
- }
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;
2030
1924
  }
2031
- if (!relation.foreignKeyOnTarget) {
2032
- 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.`);
2033
1927
  continue;
2034
1928
  }
2035
1929
  const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
@@ -2055,10 +1949,6 @@ var RelationService = class {
2055
1949
  */
2056
1950
  async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
2057
1951
  try {
2058
- if (!relation.joinPath || relation.joinPath.length === 0) {
2059
- logger.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
2060
- return;
2061
- }
2062
1952
  const sourceTableName = getTableName$1(sourceCollection);
2063
1953
  const targetTableName = getTableName$1(targetCollection);
2064
1954
  const intermediateTables = relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName);
@@ -2258,9 +2148,8 @@ var RelationService = class {
2258
2148
  * to unwrap that same level back out.
2259
2149
  */
2260
2150
  function isJunctionRelation(relation) {
2261
- if (relation.through) return true;
2262
- if (relation.joinPath && relation.joinPath.length > 1) return true;
2263
- return false;
2151
+ if (isManyToMany(relation)) return true;
2152
+ return relation.kind === "via" && relation.joinPath.length > 1;
2264
2153
  }
2265
2154
  /**
2266
2155
  * Reach the target row inside a junction row.
@@ -2450,7 +2339,7 @@ function resolveNestedPath(path, registry) {
2450
2339
  * such a path addresses the *link*, not the row.
2451
2340
  */
2452
2341
  function isJunctionBackedRelation(relation) {
2453
- return Boolean(relation.through) || Boolean(relation.joinPath && relation.joinPath.length > 1);
2342
+ return relation.shared;
2454
2343
  }
2455
2344
  /**
2456
2345
  * Reject a nested write whose final segment is a to-one relation.
@@ -2463,7 +2352,10 @@ function isJunctionBackedRelation(relation) {
2463
2352
  * one.
2464
2353
  */
2465
2354
  function assertWritableThrough(hop, path) {
2466
- 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");
2467
2359
  }
2468
2360
  //#endregion
2469
2361
  //#region src/services/FetchService.ts
@@ -2502,7 +2394,7 @@ var FetchService = class {
2502
2394
  let orderByField = table[orderBy];
2503
2395
  if (!orderByField && collection) {
2504
2396
  const property = collection.properties[orderBy];
2505
- 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`];
2506
2398
  }
2507
2399
  return orderByField;
2508
2400
  }
@@ -2523,7 +2415,7 @@ var FetchService = class {
2523
2415
  for (const [key, relation] of Object.entries(resolvedRelations)) {
2524
2416
  if (!shouldInclude(key)) continue;
2525
2417
  const drizzleRelName = relation.relationName || key;
2526
- if (relation.joinPath && relation.joinPath.length > 0) continue;
2418
+ if (relation.kind === "via") continue;
2527
2419
  if (relation.cardinality === "many" && isJunctionRelation(relation)) {
2528
2420
  const targetFkName = this.getJunctionTargetRelationName(relation, collection);
2529
2421
  if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
@@ -2537,7 +2429,7 @@ var FetchService = class {
2537
2429
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
2538
2430
  */
2539
2431
  getJunctionTargetRelationName(relation, _collection) {
2540
- if (relation.through) return relation.through.targetColumn.replace(/_id$/, "_id");
2432
+ if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
2541
2433
  return null;
2542
2434
  }
2543
2435
  /**
@@ -2547,7 +2439,7 @@ var FetchService = class {
2547
2439
  */
2548
2440
  async resolveJoinPathRelations(row, collection, collectionPath, parsedId, _databaseId) {
2549
2441
  const resolvedRelations = resolveCollectionRelations(collection);
2550
- 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]) => {
2551
2443
  try {
2552
2444
  const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: relation.cardinality === "one" ? 1 : void 0 });
2553
2445
  if (relation.cardinality === "one" && relatedRows.length > 0) {
@@ -2569,7 +2461,7 @@ var FetchService = class {
2569
2461
  const resolvedRelations = resolveCollectionRelations(collection);
2570
2462
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
2571
2463
  const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
2572
- 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));
2573
2465
  if (joinPathRelations.length === 0) return;
2574
2466
  const parentIdOf = (row) => {
2575
2467
  const address = buildCompositeId(row, idInfoArray);
@@ -2723,7 +2615,7 @@ var FetchService = class {
2723
2615
  return flatRow;
2724
2616
  } catch (e) {
2725
2617
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2726
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2618
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2727
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.");
2728
2620
  }
2729
2621
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
@@ -2774,7 +2666,7 @@ var FetchService = class {
2774
2666
  return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
2775
2667
  } catch (e) {
2776
2668
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2777
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2669
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2778
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.");
2779
2671
  }
2780
2672
  logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
@@ -2989,7 +2881,7 @@ var FetchService = class {
2989
2881
  return restRows;
2990
2882
  } catch (e) {
2991
2883
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2992
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2884
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2993
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.");
2994
2886
  }
2995
2887
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
@@ -3052,7 +2944,7 @@ var FetchService = class {
3052
2944
  return restRow;
3053
2945
  } catch (e) {
3054
2946
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
3055
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2947
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
3056
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.");
3057
2949
  }
3058
2950
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
@@ -3200,7 +3092,7 @@ var FetchService = class {
3200
3092
  if (parentIds.length === 0) return /* @__PURE__ */ new Map();
3201
3093
  const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
3202
3094
  if (!relation) {
3203
- logger.warn(`[batchFetchManyRelatedRows] Relation '${relationKey}' not found, skipping`);
3095
+ logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
3204
3096
  return /* @__PURE__ */ new Map();
3205
3097
  }
3206
3098
  const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
@@ -3425,7 +3317,7 @@ var PersistService = class {
3425
3317
  assertWritableThrough(hop, collectionPath);
3426
3318
  if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
3427
3319
  if (isJunctionBackedRelation(hop.relation)) {
3428
- 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");
3429
3321
  await this.relationService.unlinkRelatedEntity(this.db, hop, id);
3430
3322
  return;
3431
3323
  }
@@ -3461,16 +3353,13 @@ var PersistService = class {
3461
3353
  * do, and first — stamped the parent's own foreign key onto the child row.
3462
3354
  */
3463
3355
  resolveParentForeignKeyColumn(hop) {
3464
- const { relation, relationKey, targetCollection } = hop;
3465
- if (relation.foreignKeyOnTarget) return relation.foreignKeyOnTarget;
3466
- if (relation.joinPath && relation.joinPath.length === 1) {
3467
- const joinStep = relation.joinPath[0];
3468
- const targetTableName = getTableName$1(targetCollection);
3469
- if (joinStep.table !== targetTableName) logger.warn(`Join step for relation '${relationKey}' targets '${joinStep.table}', not the target table '${targetTableName}'.`);
3470
- 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;
3471
3362
  }
3472
- if (relation.joinPath && relation.joinPath.length > 1) return;
3473
- 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");
3474
3363
  }
3475
3364
  /**
3476
3365
  * Save an row (create or update)
@@ -3493,7 +3382,7 @@ var PersistService = class {
3493
3382
  const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
3494
3383
  return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
3495
3384
  };
3496
- if (hop.relation.through) junctionTableInfo = {
3385
+ if (isManyToMany(hop.relation)) junctionTableInfo = {
3497
3386
  parentCollection: hop.parentCollection,
3498
3387
  parentId: parentIdForWrite(),
3499
3388
  relation: hop.relation,
@@ -5546,12 +5435,8 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5546
5435
  break;
5547
5436
  case "relation": {
5548
5437
  const refProp = prop;
5549
- const relation = findRelation(resolveCollectionRelations(collection), refProp.relationName ?? propName);
5550
- if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") return null;
5551
- if (!relation.localKey) {
5552
- logger.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
5553
- return null;
5554
- }
5438
+ const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
5439
+ if (!relation || relation.kind !== "belongsTo") return null;
5555
5440
  if (collection.properties[relation.localKey] && propName !== relation.localKey) return null;
5556
5441
  let targetCollection;
5557
5442
  try {
@@ -5645,33 +5530,17 @@ var generateSinglePolicyCode = (collection, rule, operation, policyName, resolve
5645
5530
  */
5646
5531
  var computeSharedRelationName = (rel, sourceCollection, _collections) => {
5647
5532
  const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
5648
- if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
5533
+ if (rel.kind === "belongsTo") {
5649
5534
  const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
5650
5535
  return `${getTableName$1(sourceCollection)}_${normalisedKey}`;
5651
5536
  }
5652
- if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) try {
5537
+ if (rel.kind === "hasMany" || rel.kind === "hasOne") try {
5653
5538
  const targetCollection = rel.target();
5654
5539
  const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
5655
5540
  return `${getTableName$1(targetCollection)}_${normalisedFK}`;
5656
5541
  } catch {
5657
5542
  return fallback;
5658
5543
  }
5659
- if (rel.direction === "inverse" && rel.cardinality === "one") {
5660
- if (rel.foreignKeyOnTarget) try {
5661
- const targetCollection = rel.target();
5662
- const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
5663
- return `${getTableName$1(targetCollection)}_${normalisedFK}`;
5664
- } catch {
5665
- return fallback;
5666
- }
5667
- try {
5668
- const targetCollection = rel.target();
5669
- const targetResolvedRelations = resolveCollectionRelations(targetCollection);
5670
- const correspondingRelation = Object.values(targetResolvedRelations).find((targetRel) => targetRel.direction === "owning" && targetRel.cardinality === "one" && targetRel.localKey && targetRel.target().slug === sourceCollection.slug);
5671
- if (correspondingRelation && correspondingRelation.localKey) return `${getTableName$1(targetCollection)}_${correspondingRelation.localKey}`;
5672
- } catch {}
5673
- return fallback;
5674
- }
5675
5544
  return fallback;
5676
5545
  };
5677
5546
  var generateSchema = async (collections, stripPolicies = false) => {
@@ -5736,7 +5605,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5736
5605
  const tableName = getTableName$1(collection);
5737
5606
  if (tableName) allTablesToGenerate.set(tableName, { collection });
5738
5607
  const resolvedRelations = resolveCollectionRelations(collection);
5739
- for (const relation of Object.values(resolvedRelations)) if (relation.through) {
5608
+ for (const relation of Object.values(resolvedRelations)) if (isManyToMany(relation)) {
5740
5609
  const junctionTableName = relation.through.table;
5741
5610
  if (!allTablesToGenerate.has(junctionTableName)) allTablesToGenerate.set(junctionTableName, {
5742
5611
  collection: {
@@ -5751,7 +5620,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5751
5620
  }
5752
5621
  for (const [tableName, { collection, isJunction, relation, sourceCollection }] of allTablesToGenerate.entries()) {
5753
5622
  const tableVarName = getTableVarName(tableName);
5754
- if (isJunction && relation && sourceCollection && relation.through) {
5623
+ if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
5755
5624
  const targetCollection = relation.target();
5756
5625
  const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : void 0) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : void 0);
5757
5626
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -5805,7 +5674,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5805
5674
  const tableRelations = [];
5806
5675
  if (isJunction) {
5807
5676
  const relationInfo = Array.from(allTablesToGenerate.values()).find((v) => v.isJunction && getTableName$1(v.collection) === tableName);
5808
- if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation.through) {
5677
+ if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && isManyToMany(relationInfo.relation)) {
5809
5678
  const { relation, sourceCollection } = relationInfo;
5810
5679
  const targetCollection = relation.target();
5811
5680
  const sourceTableVar = getTableVarName(getTableName$1(sourceCollection));
@@ -5817,7 +5686,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5817
5686
  let inverseRelationName = null;
5818
5687
  try {
5819
5688
  const targetRelations = resolveCollectionRelations(targetCollection);
5820
- 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) {
5821
5690
  inverseRelationName = targetRel.relationName ?? null;
5822
5691
  break;
5823
5692
  }
@@ -5833,27 +5702,25 @@ var generateSchema = async (collections, stripPolicies = false) => {
5833
5702
  const target = rel.target();
5834
5703
  const targetTableVar = getTableVarName(getTableName$1(target));
5835
5704
  const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
5836
- const deduplicationKey = `${drizzleRelationName}::${rel.direction}`;
5705
+ const deduplicationKey = `${drizzleRelationName}::${rel.kind}`;
5837
5706
  if (emittedRelationNames.has(deduplicationKey)) continue;
5838
5707
  emittedRelationNames.add(deduplicationKey);
5839
- if (rel.cardinality === "one") {
5840
- 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 })`);
5841
- else if (rel.direction === "inverse") tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
5842
- } else if (rel.cardinality === "many") {
5843
- if (rel.direction === "inverse" && rel.foreignKeyOnTarget) tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
5844
- 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": {
5845
5719
  const junctionTableVar = getTableVarName(rel.through.table);
5846
5720
  tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
5847
- } else if (rel.direction === "inverse" && rel.inverseRelationName) try {
5848
- const targetResolvedRelations = resolveCollectionRelations(rel.target());
5849
- const correspondingRelation = Object.values(targetResolvedRelations).find((targetRel) => targetRel.direction === "owning" && targetRel.cardinality === "many" && targetRel.through && targetRel.relationName === rel.inverseRelationName);
5850
- if (correspondingRelation && correspondingRelation.through) {
5851
- const junctionTableVar = getTableVarName(correspondingRelation.through.table);
5852
- tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
5853
- } else logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
5854
- } catch (e) {
5855
- logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
5721
+ break;
5856
5722
  }
5723
+ case "via": break;
5857
5724
  }
5858
5725
  } catch (e) {
5859
5726
  logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
@@ -5861,10 +5728,10 @@ var generateSchema = async (collections, stripPolicies = false) => {
5861
5728
  for (const otherCollection of collections) {
5862
5729
  if (otherCollection.slug === collection.slug) continue;
5863
5730
  const otherRelations = resolveCollectionRelations(otherCollection);
5864
- 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 {
5865
5732
  if (otherRel.target().slug === collection.slug) {
5866
5733
  const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
5867
- const deduplicationKey = `${drizzleRelationName}::owning`;
5734
+ const deduplicationKey = `${drizzleRelationName}::belongsTo`;
5868
5735
  if (!emittedRelationNames.has(deduplicationKey)) {
5869
5736
  const otherTableVar = getTableVarName(getTableName$1(otherCollection));
5870
5737
  const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
@@ -5986,8 +5853,8 @@ function collectJunctionLinks(registry) {
5986
5853
  continue;
5987
5854
  }
5988
5855
  for (const [relationKey, relation] of Object.entries(relations)) {
5989
- const through = relation?.through;
5990
- if (!through?.table) continue;
5856
+ if (!isManyToMany(relation)) continue;
5857
+ const through = relation.through;
5991
5858
  const key = `${collection.slug}::${relationKey}::${through.table}`;
5992
5859
  if (seen.has(key)) continue;
5993
5860
  seen.add(key);
@@ -9080,8 +8947,8 @@ var PostgresCollectionRegistry = class extends CollectionRegistry {
9080
8947
  */
9081
8948
  getRelationKeysForCollection(collectionPath) {
9082
8949
  const collection = this.getCollectionByPath(collectionPath);
9083
- if (!collection || !getDataSourceCapabilities(collection.engine).supportsRelations || !collection.relations) return [];
9084
- return collection.relations.map((r) => r.relationName || r.localKey || "").filter(Boolean);
8950
+ if (!collection) return [];
8951
+ return Object.keys(resolveCollectionRelations(collection));
9085
8952
  }
9086
8953
  };
9087
8954
  //#endregion
@@ -18032,6 +17899,178 @@ function formatBytes(bytes) {
18032
17899
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
18033
17900
  }
18034
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
18035
18074
  //#region src/collections/buildRegistry.ts
18036
18075
  /**
18037
18076
  * Build the collection registry for a driver.
@@ -18056,8 +18095,163 @@ function buildCollectionRegistry(schema) {
18056
18095
  if (schema.enums) registry.registerEnums(schema.enums);
18057
18096
  if (schema.relations) registry.registerRelations(schema.relations);
18058
18097
  warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
18098
+ assertRelationsResolve(registry.getCollections(), registry);
18059
18099
  return registry;
18060
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
+ }
18061
18255
  //#endregion
18062
18256
  //#region src/auth/ensure-tables.ts
18063
18257
  /**
@@ -18069,6 +18263,7 @@ function buildCollectionRegistry(schema) {
18069
18263
  */
18070
18264
  async function ensureAuthTablesExist(db, collection) {
18071
18265
  logger.info("🔍 Checking auth tables...");
18266
+ await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
18072
18267
  try {
18073
18268
  let usersTableName = "\"rebase\".\"users\"";
18074
18269
  let userIdType = "TEXT";
@@ -18449,8 +18644,10 @@ async function ensureAuthTablesExist(db, collection) {
18449
18644
  } catch (rlsReconcileError) {
18450
18645
  logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
18451
18646
  }
18647
+ await stampAuthSchemaVersion(db, authSchema);
18452
18648
  logger.info("✅ Auth tables ready");
18453
18649
  } catch (error) {
18650
+ if (error instanceof AuthSchemaVersionError) throw error;
18454
18651
  logger.error("❌ Failed to create auth tables", { error });
18455
18652
  logger.warn("⚠️ Continuing without creating auth tables.");
18456
18653
  }
@@ -20548,7 +20745,8 @@ function createPostgresBootstrapper(pgConfig) {
20548
20745
  userService,
20549
20746
  roleService: userService,
20550
20747
  emailService,
20551
- authRepository
20748
+ authRepository,
20749
+ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
20552
20750
  };
20553
20751
  },
20554
20752
  async initializeHistory(config, driverResult) {
@@ -20576,7 +20774,7 @@ function createPostgresBootstrapper(pgConfig) {
20576
20774
  */
20577
20775
  async ensureCollectionSchema(collections, driverResult, log) {
20578
20776
  const internals = driverResult.internals;
20579
- const { ensureCollectionTables } = await import("./ensure-collection-tables-BVvtkkRm.js");
20777
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-DGMYK0fr.js");
20580
20778
  return { applied: (await ensureCollectionTables({ async query(text) {
20581
20779
  const result = await internals.db.execute(sql.raw(text));
20582
20780
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };