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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/auth/schema-version.d.ts +106 -0
  2. package/dist/collections/validate-relations.d.ts +53 -0
  3. package/dist/data-transformer.d.ts +3 -3
  4. package/dist/{ensure-collection-tables-CNlIONzj.js → ensure-collection-tables-DGMYK0fr.js} +12 -12
  5. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  6. package/dist/index.es.js +913 -391
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/services/FetchService.d.ts +21 -8
  9. package/dist/services/PersistService.d.ts +12 -0
  10. package/dist/services/RelationService.d.ts +39 -8
  11. package/dist/services/cdc/junction-tables.d.ts +38 -0
  12. package/dist/services/nested-path.d.ts +59 -0
  13. package/dist/services/realtimeService.d.ts +19 -0
  14. package/dist/services/row-pipeline.d.ts +2 -2
  15. package/dist/{src-DmsRg8MR.js → src-3VmUJ8Xn.js} +214 -276
  16. package/dist/src-3VmUJ8Xn.js.map +1 -0
  17. package/dist/{src-B0v4IKaI.js → src-D5xBTl32.js} +19 -2
  18. package/dist/src-D5xBTl32.js.map +1 -0
  19. package/dist/utils/drizzle-conditions.d.ts +71 -18
  20. package/package.json +8 -9
  21. package/src/PostgresBootstrapper.ts +15 -2
  22. package/src/auth/ensure-tables.ts +23 -0
  23. package/src/auth/schema-version.ts +260 -0
  24. package/src/cli-errors.ts +1 -1
  25. package/src/cli-helpers.ts +4 -3
  26. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  27. package/src/collections/buildRegistry.ts +7 -0
  28. package/src/collections/validate-relations.ts +280 -0
  29. package/src/data-transformer.ts +28 -38
  30. package/src/schema/doctor.ts +14 -14
  31. package/src/schema/generate-drizzle-schema-logic.ts +62 -110
  32. package/src/schema/generate-postgres-ddl-logic.ts +28 -21
  33. package/src/schema/introspect-db-inference.ts +13 -13
  34. package/src/schema/introspect-db-logic.ts +25 -29
  35. package/src/services/FetchService.ts +116 -126
  36. package/src/services/PersistService.ts +126 -88
  37. package/src/services/RelationService.ts +157 -86
  38. package/src/services/cdc/junction-tables.ts +91 -0
  39. package/src/services/nested-path.ts +145 -0
  40. package/src/services/realtimeService.ts +60 -0
  41. package/src/services/row-pipeline.ts +5 -6
  42. package/src/utils/drizzle-conditions.ts +268 -330
  43. package/dist/ensure-collection-tables-CNlIONzj.js.map +0 -1
  44. package/dist/src-B0v4IKaI.js.map +0 -1
  45. package/dist/src-DmsRg8MR.js.map +0 -1
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-B0v4IKaI.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-DmsRg8MR.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";
@@ -392,6 +392,93 @@ function unwrapRelationFilterValue(value) {
392
392
  * const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;
393
393
  */
394
394
  var DrizzleConditionBuilder = class {
395
+ /**
396
+ * Express "reachable from this parent through this relation" as a plain
397
+ * `WHERE` condition on the target table.
398
+ *
399
+ * This is the primitive that lets a relation be a *filter* rather than an
400
+ * addressing scheme. A nested listing used to be served by its own query
401
+ * builder — `fetchEntitiesUsingJoins`, which grew joins the root pipeline
402
+ * did not have and lost the options the root pipeline did have (offset,
403
+ * filter, orderBy, include). Reduced to a condition, the same listing runs
404
+ * through the ordinary collection query, so it inherits all of them and
405
+ * there is one read path instead of two.
406
+ *
407
+ * The shapes:
408
+ * - inverse FK → `target.<fk> = :parentId`, a column comparison.
409
+ * - `through` → `EXISTS (SELECT 1 FROM junction …)`, correlated on the
410
+ * target's key, so the junction never multiplies rows the
411
+ * way an `INNER JOIN` would.
412
+ * - `joinPath` → the same `EXISTS`, with the path's steps joined inside
413
+ * it and the final step correlating to the outer row.
414
+ */
415
+ static buildRelationScopeCondition(relation, parent, parentId, targetTable, targetIdColumn, registry) {
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)}`);
441
+ }
442
+ }
443
+ /**
444
+ * `EXISTS` for an explicit `joinPath`.
445
+ *
446
+ * The path is declared source → target. The subquery replays every step but
447
+ * the last from inside, and turns the last one into the correlation with the
448
+ * outer target row — so the target table is never named twice and needs no
449
+ * alias. Each intermediate table is aliased positionally, which keeps a path
450
+ * that revisits a table (a self-referencing many-to-many) unambiguous.
451
+ */
452
+ static buildJoinPathScopeCondition(joinPath, parentTable, parentIdColumn, parentId, targetTable, registry) {
453
+ const sourceAlias = "__rel_src";
454
+ const aliasFor = (index) => `__rel_j${index}`;
455
+ const fromRef = (stepIndex, column) => sql`${sql.identifier(stepIndex === 0 ? sourceAlias : aliasFor(stepIndex - 1))}.${sql.identifier(getColumnName(column))}`;
456
+ const pairs = (step) => {
457
+ const from = Array.isArray(step.on.from) ? step.on.from : [step.on.from];
458
+ const to = Array.isArray(step.on.to) ? step.on.to : [step.on.to];
459
+ if (from.length !== to.length) throw new Error(`Join step on '${step.table}' has ${from.length} \`from\` columns and ${to.length} \`to\` columns`);
460
+ return from.map((f, i) => ({
461
+ from: f,
462
+ to: to[i]
463
+ }));
464
+ };
465
+ const inner = joinPath.slice(0, -1);
466
+ const last = joinPath[joinPath.length - 1];
467
+ const joins = inner.map((step, index) => {
468
+ const table = registry.getTable(step.table);
469
+ if (!table) throw new Error(`Join table not found: ${step.table}`);
470
+ const on = pairs(step).map(({ from, to }) => sql`${fromRef(index, from)} = ${sql.identifier(aliasFor(index))}.${sql.identifier(getColumnName(to))}`);
471
+ return sql`JOIN ${table} AS ${sql.identifier(aliasFor(index))} ON ${sql.join(on, sql` AND `)}`;
472
+ });
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 `);
479
+ const joinsSql = joins.length > 0 ? sql` ${sql.join(joins, sql` `)}` : sql``;
480
+ return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
481
+ }
395
482
  /**
396
483
  * Build filter conditions from FilterValues
397
484
  */
@@ -489,58 +576,43 @@ var DrizzleConditionBuilder = class {
489
576
  /**
490
577
  * Build relation-based conditions for different relation types
491
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
+ */
492
593
  static buildRelationConditions(relation, parentId, targetTable, parentTable, parentIdColumn, targetIdColumn, registry) {
493
- console.debug("🔍 [buildRelationConditions] Building conditions for relation:", {
494
- relationName: relation.relationName,
495
- cardinality: relation.cardinality,
496
- direction: relation.direction,
497
- hasThrough: !!relation.through,
498
- hasForeignKeyOnTarget: !!relation.foreignKeyOnTarget,
499
- inverseRelationName: relation.inverseRelationName,
500
- parentId
501
- });
502
594
  const joinConditions = [];
503
595
  const whereConditions = [];
504
- if (relation.joinPath && relation.joinPath.length > 0) {
505
- console.debug("🔍 [buildRelationConditions] Using joinPath logic");
506
- const { joins, finalCondition } = this.buildJoinPathConditions(relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry);
507
- joinConditions.push(...joins);
508
- whereConditions.push(finalCondition);
509
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
510
- console.debug("🔍 [buildRelationConditions] Using owning many-to-many with explicit through");
511
- const junctionResult = this.buildJunctionTableConditions(relation.through, targetIdColumn, parentId, registry);
512
- joinConditions.push(junctionResult.join);
513
- whereConditions.push(junctionResult.condition);
514
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") {
515
- console.debug("🔍 [buildRelationConditions] Using inverse many-to-many with explicit through");
516
- const junctionResult = this.buildInverseJunctionTableConditions(relation.through, targetIdColumn, parentId, registry);
517
- joinConditions.push(junctionResult.join);
518
- whereConditions.push(junctionResult.condition);
519
- } else if (relation.cardinality === "many" && relation.direction === "inverse" && !relation.through) {
520
- console.debug("🔍 [buildRelationConditions] Handling inverse many relationship without explicit through");
521
- const junctionInfo = this.findCorrespondingJunctionTable(relation, registry);
522
- if (junctionInfo) {
523
- console.debug("🔍 [buildRelationConditions] Found junction info for inverse many-to-many, building junction conditions");
524
- 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);
525
605
  joinConditions.push(junctionResult.join);
526
606
  whereConditions.push(junctionResult.condition);
527
- } else if (relation.foreignKeyOnTarget) {
528
- console.debug("🔍 [buildRelationConditions] No junction table found, treating as inverse one-to-many with foreign key on target");
529
- const simpleCondition = this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId);
530
- whereConditions.push(simpleCondition);
531
- } else {
532
- logger.error("🔍 [buildRelationConditions] Failed to find junction table info and no foreign key specified");
533
- 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;
534
608
  }
535
- } else {
536
- console.debug("🔍 [buildRelationConditions] Using simple relation logic - THIS IS WHERE THE ERROR MIGHT OCCUR");
537
- const simpleCondition = this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId);
538
- 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)}`);
539
615
  }
540
- console.debug("🔍 [buildRelationConditions] Final result:", {
541
- joinConditionsCount: joinConditions.length,
542
- whereConditionsCount: whereConditions.length
543
- });
544
616
  return {
545
617
  joinConditions,
546
618
  whereConditions
@@ -680,48 +752,24 @@ var DrizzleConditionBuilder = class {
680
752
  };
681
753
  }
682
754
  /**
683
- * Build conditions for inverse junction table (many-to-many) relations
684
- */
685
- static buildInverseJunctionTableConditions(through, targetIdColumn, parentId, registry) {
686
- const junctionTable = registry.getTable(through.table);
687
- if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
688
- const junctionSourceCol = junctionTable[through.sourceColumn];
689
- const junctionTargetCol = junctionTable[through.targetColumn];
690
- if (!junctionSourceCol) throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
691
- if (!junctionTargetCol) throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
692
- const condition = Array.isArray(parentId) ? inArray(junctionSourceCol, parentId) : eq(junctionSourceCol, parentId);
693
- return {
694
- join: {
695
- table: junctionTable,
696
- condition: eq(targetIdColumn, junctionTargetCol)
697
- },
698
- condition
699
- };
700
- }
701
- /**
702
- * 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.
703
762
  */
704
763
  static buildSimpleRelationCondition(relation, targetTable, parentTable, parentId) {
705
- if (relation.direction === "owning" && relation.localKey) {
706
- const targetIdCol = Object.values(targetTable).find((col) => col.primary);
707
- if (!targetIdCol) {
708
- const idCol = Object.values(targetTable).find((col) => col.name === "id");
709
- if (!idCol) throw new Error("No primary key or \"id\" column found in target table");
710
- return Array.isArray(parentId) ? inArray(idCol, parentId) : eq(idCol, parentId);
711
- }
712
- return Array.isArray(parentId) ? inArray(targetIdCol, parentId) : eq(targetIdCol, parentId);
713
- } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
714
- const foreignKeyCol = targetTable[relation.foreignKeyOnTarget];
715
- 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.`);
716
- return Array.isArray(parentId) ? inArray(foreignKeyCol, parentId) : eq(foreignKeyCol, parentId);
717
- } 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.`);
718
- else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
719
- const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
720
- const foreignKeyCol = targetTable[inferredForeignKeyName];
721
- 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.`);
722
- console.debug(`🔍 [DrizzleConditionBuilder] Auto-inferred foreign key '${inferredForeignKeyName}' for inverse relation '${relation.relationName}'`);
723
- return Array.isArray(parentId) ? inArray(foreignKeyCol, parentId) : eq(foreignKeyCol, parentId);
724
- } 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);
725
773
  }
726
774
  /**
727
775
  * Combine multiple conditions with AND operator
@@ -776,16 +824,23 @@ var DrizzleConditionBuilder = class {
776
824
  return query;
777
825
  }
778
826
  /**
779
- * 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.
780
832
  */
781
833
  static buildRelationCountQuery(baseCountQuery, relation, parentId, targetTable, parentTable, parentIdColumn, targetIdColumn, registry, additionalFilters) {
782
- if (relation.joinPath && relation.joinPath.length > 0) return this.buildJoinPathCountQuery(baseCountQuery, relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry, additionalFilters);
783
- else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") return this.buildJunctionCountQuery(baseCountQuery, relation.through, targetIdColumn, parentId, registry, additionalFilters);
784
- else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") return this.buildInverseJunctionCountQuery(baseCountQuery, relation.through, targetIdColumn, parentId, registry, additionalFilters);
785
- else {
786
- const allConditions = [this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId)];
787
- if (additionalFilters) allConditions.push(...additionalFilters);
788
- 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)}`);
789
844
  }
790
845
  }
791
846
  /**
@@ -826,20 +881,6 @@ var DrizzleConditionBuilder = class {
826
881
  return baseCountQuery.innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol)).where(and(...baseConditions));
827
882
  }
828
883
  /**
829
- * Build inverse junction table conditions for count queries
830
- */
831
- static buildInverseJunctionCountQuery(baseCountQuery, through, targetIdColumn, parentId, registry, additionalFilters) {
832
- const junctionTable = registry.getTable(through.table);
833
- if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
834
- const junctionSourceCol = junctionTable[through.sourceColumn];
835
- const junctionTargetCol = junctionTable[through.targetColumn];
836
- if (!junctionSourceCol) throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
837
- if (!junctionTargetCol) throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
838
- const baseConditions = [eq(junctionSourceCol, parentId)];
839
- if (additionalFilters && additionalFilters.length > 0) baseConditions.push(...additionalFilters);
840
- return baseCountQuery.innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol)).where(and(...baseConditions));
841
- }
842
- /**
843
884
  * Helper method to extract table names from columns
844
885
  */
845
886
  static getTableNamesFromColumns(columns) {
@@ -854,49 +895,6 @@ var DrizzleConditionBuilder = class {
854
895
  return [getColumnName(columns)];
855
896
  }
856
897
  /**
857
- * Find the corresponding junction table for an inverse many-to-many relation
858
- */
859
- static findCorrespondingJunctionTable(relation, registry) {
860
- try {
861
- console.debug(`🔍 [findCorrespondingJunctionTable] Looking for junction table for inverse relation '${relation.relationName}' with inverseRelationName '${relation.inverseRelationName}'`);
862
- if (!relation.inverseRelationName) {
863
- console.debug("🔍 [findCorrespondingJunctionTable] No inverseRelationName specified");
864
- return null;
865
- }
866
- const targetCollection = relation.target();
867
- console.debug(`🔍 [findCorrespondingJunctionTable] Target collection: ${targetCollection.slug}`);
868
- const targetCollectionRelations = resolveCollectionRelations(targetCollection);
869
- console.debug("🔍 [findCorrespondingJunctionTable] Target collection relations:", Object.keys(targetCollectionRelations));
870
- const correspondingRelation = targetCollectionRelations[relation.inverseRelationName];
871
- if (!correspondingRelation) {
872
- console.debug(`🔍 [findCorrespondingJunctionTable] No relation found with key '${relation.inverseRelationName}' on target collection`);
873
- return null;
874
- }
875
- console.debug("🔍 [findCorrespondingJunctionTable] Found relation:", {
876
- relationName: correspondingRelation.relationName,
877
- cardinality: correspondingRelation.cardinality,
878
- direction: correspondingRelation.direction,
879
- hasThrough: !!correspondingRelation.through
880
- });
881
- if (correspondingRelation.cardinality !== "many" || correspondingRelation.direction !== "owning" || !correspondingRelation.through) {
882
- console.debug("🔍 [findCorrespondingJunctionTable] Relation is not an owning many-to-many with junction table");
883
- return null;
884
- }
885
- console.debug("🔍 [findCorrespondingJunctionTable] Found matching owning relation with junction table!");
886
- const through = correspondingRelation.through;
887
- const result = {
888
- table: through.table,
889
- sourceColumn: through.targetColumn,
890
- targetColumn: through.sourceColumn
891
- };
892
- console.debug("🔍 [findCorrespondingJunctionTable] Returning junction info:", result);
893
- return result;
894
- } catch (error) {
895
- logger.error(`🔍 [findCorrespondingJunctionTable] Error finding corresponding junction table for relation '${relation.relationName}'`, { error });
896
- return null;
897
- }
898
- }
899
- /**
900
898
  * Build vector similarity search expressions for pgvector.
901
899
  *
902
900
  * Returns:
@@ -973,7 +971,7 @@ function serializeDataToServer(row, properties, collection, registry) {
973
971
  const joinPathRelationUpdates = [];
974
972
  const foreignKeys = /* @__PURE__ */ new Set();
975
973
  Object.values(resolvedRelations).forEach((relation) => {
976
- if (relation.localKey) foreignKeys.add(relation.localKey);
974
+ if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
977
975
  });
978
976
  for (const [key, value] of Object.entries(row)) {
979
977
  const property = properties[key];
@@ -985,11 +983,11 @@ function serializeDataToServer(row, properties, collection, registry) {
985
983
  if (property.type === "relation" && collection) {
986
984
  const relation = findRelation(resolvedRelations, key);
987
985
  if (relation) {
988
- if (relation.direction === "owning" && relation.localKey) {
986
+ if (relation.kind === "belongsTo") {
989
987
  const serializedValue = serializePropertyToServer(effectiveValue, property);
990
988
  if (serializedValue !== void 0) result[relation.localKey] = serializedValue;
991
989
  continue;
992
- } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
990
+ } else if (hasForeignKeyOnTarget(relation)) {
993
991
  const serializedValue = serializePropertyToServer(effectiveValue, property);
994
992
  inverseRelationUpdates.push({
995
993
  relationKey: key,
@@ -997,7 +995,7 @@ function serializeDataToServer(row, properties, collection, registry) {
997
995
  newValue: serializedValue
998
996
  });
999
997
  continue;
1000
- } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
998
+ } else if (relation.kind === "via") {
1001
999
  const serializedValue = serializePropertyToServer(effectiveValue, property);
1002
1000
  if (relation.cardinality === "one") joinPathRelationUpdates.push({
1003
1001
  relationKey: key,
@@ -1010,14 +1008,6 @@ function serializeDataToServer(row, properties, collection, registry) {
1010
1008
  newValue: serializedValue
1011
1009
  });
1012
1010
  continue;
1013
- } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
1014
- const serializedValue = serializePropertyToServer(effectiveValue, property);
1015
- joinPathRelationUpdates.push({
1016
- relationKey: key,
1017
- relation,
1018
- newTargetId: serializedValue
1019
- });
1020
- continue;
1021
1011
  }
1022
1012
  }
1023
1013
  }
@@ -1103,7 +1093,7 @@ async function parseDataFromServer(data, collection, db, registry) {
1103
1093
  for (const [propKey, property] of Object.entries(properties)) if (property.type === "relation" && !(propKey in result)) {
1104
1094
  const relation = findRelation(resolvedRelations, propKey);
1105
1095
  if (relation) {
1106
- if (relation.direction === "owning" && relation.localKey && relation.localKey in data) {
1096
+ if (relation.kind === "belongsTo" && relation.localKey in data) {
1107
1097
  const fkValue = data[relation.localKey];
1108
1098
  if (fkValue !== null && fkValue !== void 0) try {
1109
1099
  const targetCollection = relation.target();
@@ -1111,7 +1101,7 @@ async function parseDataFromServer(data, collection, db, registry) {
1111
1101
  } catch (e) {
1112
1102
  logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
1113
1103
  }
1114
- } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget && db && registry) try {
1104
+ } else if (hasForeignKeyOnTarget(relation) && db && registry) try {
1115
1105
  const targetCollection = relation.target();
1116
1106
  const targetTable = registry.getTable(getTableName$1(targetCollection));
1117
1107
  const currentId = buildCompositeId(data, getPrimaryKeys(collection, registry));
@@ -1132,7 +1122,7 @@ async function parseDataFromServer(data, collection, db, registry) {
1132
1122
  } catch (e) {
1133
1123
  logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
1134
1124
  }
1135
- else if (relation.direction === "inverse" && relation.joinPath && db && registry) try {
1125
+ else if (relation.kind === "via" && db && registry) try {
1136
1126
  const targetCollection = relation.target();
1137
1127
  const pks = getPrimaryKeys(collection, registry);
1138
1128
  const currentId = buildCompositeId(data, pks);
@@ -1249,11 +1239,10 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
1249
1239
  }
1250
1240
  case "relation":
1251
1241
  if (typeof value === "string" || typeof value === "number") {
1252
- let relationDef = property.relation;
1242
+ let relationDef = property.resolvedRelation;
1253
1243
  if (!relationDef && propertyKey) relationDef = findRelation(resolveCollectionRelations(collection), propertyKey);
1254
- if (!relationDef) relationDef = collection.relations?.find((rel) => rel.relationName === property.relationName);
1255
1244
  if (!relationDef) {
1256
- logger.warn(`Relation not defined in property for key: ${propertyKey || "unknown"}`);
1245
+ logger.warn(`ResolvedRelation not defined in property for key: ${propertyKey || "unknown"}`);
1257
1246
  return value;
1258
1247
  }
1259
1248
  try {
@@ -1353,7 +1342,7 @@ function normalizeScalarValues(data, properties, collection, resolvedRelations,
1353
1342
  const result = {};
1354
1343
  const internalFKColumns = /* @__PURE__ */ new Set();
1355
1344
  Object.values(resolvedRelations).forEach((relation) => {
1356
- if (relation.localKey && !properties[relation.localKey]) internalFKColumns.add(relation.localKey);
1345
+ if (relation.kind === "belongsTo" && !properties[relation.localKey]) internalFKColumns.add(relation.localKey);
1357
1346
  });
1358
1347
  for (const [key, value] of Object.entries(data)) {
1359
1348
  if (internalFKColumns.has(key)) {
@@ -1491,7 +1480,7 @@ var RelationService = class {
1491
1480
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
1492
1481
  if (!parentTable) throw new Error("Parent table not found");
1493
1482
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1494
- if (relation.joinPath && relation.joinPath.length > 0) {
1483
+ if (relation.kind === "via") {
1495
1484
  let query = this.db.select().from(parentTable).$dynamic();
1496
1485
  let currentTable = parentTable;
1497
1486
  for (const join of relation.joinPath) {
@@ -1551,6 +1540,18 @@ var RelationService = class {
1551
1540
  const available = Object.keys(resolvedRelations).join(", ") || "(none)";
1552
1541
  throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
1553
1542
  }
1543
+ return this.countRelatedRows(parentCollection, parentId, relation, []);
1544
+ }
1545
+ /**
1546
+ * Count the target rows a parent reaches through `relation`, narrowed by
1547
+ * `additionalFilters` (conditions on the target table).
1548
+ *
1549
+ * Shared by the public count and by {@link isRelated}, so "how many children
1550
+ * does this parent have" and "is this row one of them" are answered by the
1551
+ * same join — a membership test that reconstructed the join separately would
1552
+ * be free to disagree with the listing it is supposed to gate.
1553
+ */
1554
+ async countRelatedRows(parentCollection, parentId, relation, additionalFilters) {
1554
1555
  const targetCollection = relation.target();
1555
1556
  const targetTable = getTableForCollection(targetCollection, this.registry);
1556
1557
  const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
@@ -1561,11 +1562,54 @@ var RelationService = class {
1561
1562
  if (!parentTable) throw new Error("Parent table not found");
1562
1563
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1563
1564
  let query = this.db.select({ count: sql`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
1564
- query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, parsedParentId, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
1565
+ query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, parsedParentId, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
1565
1566
  const result = await query;
1566
1567
  return Number(result[0]?.count || 0);
1567
1568
  }
1568
1569
  /**
1570
+ * Whether `targetId` is actually reachable from the parent named in `hop`.
1571
+ *
1572
+ * A nested address like `authors/1/posts/43` used to resolve to the target
1573
+ * collection and then match on the primary key alone, so the parent segment
1574
+ * decided nothing: the row came back, and was updated or deleted, whoever it
1575
+ * belonged to. Reads, updates and deletes now all gate on this.
1576
+ */
1577
+ async isRelated(hop, targetId) {
1578
+ const targetTable = getTableForCollection(hop.targetCollection, this.registry);
1579
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
1580
+ const parsedTargetId = parseIdValues(targetId, targetPks);
1581
+ const identity = targetPks.map((pk) => {
1582
+ const column = targetTable[pk.fieldName];
1583
+ if (!column) throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
1584
+ return eq(column, parsedTargetId[pk.fieldName]);
1585
+ });
1586
+ return await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity) > 0;
1587
+ }
1588
+ /**
1589
+ * Remove the junction row linking a parent to `targetId`, leaving the target
1590
+ * row itself alone.
1591
+ *
1592
+ * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
1593
+ * target is shared, so deleting the row would remove the tag from every other
1594
+ * post that uses it. It used to do exactly that — resolve the path to the
1595
+ * `tags` table and delete by primary key.
1596
+ */
1597
+ async unlinkRelatedEntity(tx, hop, targetId) {
1598
+ if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
1599
+ const through = hop.relation.through;
1600
+ const junctionTable = this.registry.getTable(through.table);
1601
+ if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
1602
+ const sourceJunctionColumn = junctionTable[through.sourceColumn];
1603
+ const targetJunctionColumn = junctionTable[through.targetColumn];
1604
+ if (!sourceJunctionColumn || !targetJunctionColumn) throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
1605
+ const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
1606
+ const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
1607
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
1608
+ const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
1609
+ await tx.delete(junctionTable).where(and(eq(sourceJunctionColumn, parsedParentId), eq(targetJunctionColumn, parsedTargetId)));
1610
+ logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
1611
+ }
1612
+ /**
1569
1613
  * Batch fetch related rows for multiple parent rows to avoid N+1 queries
1570
1614
  */
1571
1615
  async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation) {
@@ -1582,7 +1626,7 @@ var RelationService = class {
1582
1626
  if (!parentTable) throw new Error("Parent table not found");
1583
1627
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1584
1628
  const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
1585
- if (relation.joinPath && relation.joinPath.length > 0) {
1629
+ if (relation.kind === "via") {
1586
1630
  let query = this.db.select().from(parentTable).$dynamic();
1587
1631
  let currentTable = parentTable;
1588
1632
  for (const join of relation.joinPath) {
@@ -1611,7 +1655,7 @@ var RelationService = class {
1611
1655
  }
1612
1656
  return resultMap;
1613
1657
  }
1614
- if (relation.direction === "owning" && relation.localKey) {
1658
+ if (relation.kind === "belongsTo") {
1615
1659
  this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
1616
1660
  const localKeyCol = parentTable[relation.localKey];
1617
1661
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
@@ -1645,7 +1689,7 @@ var RelationService = class {
1645
1689
  }
1646
1690
  return resultMap;
1647
1691
  }
1648
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
1692
+ this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
1649
1693
  let query = this.db.select().from(targetTable).$dynamic();
1650
1694
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
1651
1695
  const results = await query;
@@ -1654,8 +1698,7 @@ var RelationService = class {
1654
1698
  for (const row of results) {
1655
1699
  const targetRow = row[getTableName$1(targetCollection)] || row;
1656
1700
  let parentId;
1657
- if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
1658
- else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
1701
+ if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
1659
1702
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
1660
1703
  }
1661
1704
  return resultMap;
@@ -1678,7 +1721,7 @@ var RelationService = class {
1678
1721
  if (!parentTable) throw new Error("Parent table not found");
1679
1722
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1680
1723
  const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
1681
- if (relation.joinPath && relation.joinPath.length > 0) {
1724
+ if (relation.kind === "via") {
1682
1725
  let query = this.db.select().from(parentTable).$dynamic();
1683
1726
  let currentTable = parentTable;
1684
1727
  for (const join of relation.joinPath) {
@@ -1708,7 +1751,7 @@ var RelationService = class {
1708
1751
  }
1709
1752
  return resultMap;
1710
1753
  }
1711
- if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
1754
+ if (relation.kind === "manyToMany") {
1712
1755
  this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
1713
1756
  const junctionTable = this.registry.getTable(relation.through.table);
1714
1757
  if (!junctionTable) {
@@ -1734,7 +1777,7 @@ var RelationService = class {
1734
1777
  }
1735
1778
  return resultMap;
1736
1779
  }
1737
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
1780
+ this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
1738
1781
  let query = this.db.select().from(targetTable).$dynamic();
1739
1782
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
1740
1783
  const results = await query;
@@ -1743,9 +1786,7 @@ var RelationService = class {
1743
1786
  for (const row of results) {
1744
1787
  const targetRow = row[getTableName$1(targetCollection)] || row;
1745
1788
  let parentId;
1746
- if (relation.through && relation.direction === "inverse") parentId = (row[relation.through.table] || row)[relation.through.targetColumn];
1747
- else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
1748
- else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
1789
+ if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
1749
1790
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
1750
1791
  const key = String(parentId);
1751
1792
  const arr = resultMap.get(key) || [];
@@ -1765,7 +1806,7 @@ var RelationService = class {
1765
1806
  if (!relation || relation.cardinality !== "many") continue;
1766
1807
  const targetEntityIds = value && Array.isArray(value) ? value.map((rel) => rel.id) : [];
1767
1808
  const targetCollection = relation.target();
1768
- if (relation.joinPath && relation.joinPath.length > 0) {
1809
+ if (relation.kind === "via") {
1769
1810
  const parentTableName = getTableName$1(collection);
1770
1811
  const targetTableName = getTableName$1(targetCollection);
1771
1812
  let junctionTable = void 0;
@@ -1810,7 +1851,7 @@ var RelationService = class {
1810
1851
  }));
1811
1852
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
1812
1853
  }
1813
- } else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
1854
+ } else if (relation.kind === "manyToMany") {
1814
1855
  const junctionTable = this.registry.getTable(relation.through.table);
1815
1856
  if (!junctionTable) {
1816
1857
  logger.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
@@ -1835,8 +1876,7 @@ var RelationService = class {
1835
1876
  }));
1836
1877
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
1837
1878
  }
1838
- } 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.`);
1839
- else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
1879
+ } else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
1840
1880
  const targetTable = getTableForCollection(targetCollection, this.registry);
1841
1881
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1842
1882
  const targetIdInfo = targetPks[0];
@@ -1870,28 +1910,20 @@ var RelationService = class {
1870
1910
  const targetIdInfo = targetPks[0];
1871
1911
  const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
1872
1912
  const sourceIdInfo = sourcePks[0];
1873
- if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
1913
+ if (relation.kind === "via") {
1874
1914
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
1875
1915
  continue;
1876
1916
  }
1877
- if (relation.cardinality === "many" && relation.direction === "inverse") {
1878
- const targetCollectionRelations = resolveCollectionRelations(targetCollection);
1879
- let junctionInfo = null;
1880
- for (const [relationKey, targetRelation] of Object.entries(targetCollectionRelations)) if (targetRelation.cardinality === "many" && targetRelation.direction === "owning" && targetRelation.through && (targetRelation.relationName === relation.inverseRelationName || relationKey === relation.inverseRelationName)) {
1881
- junctionInfo = {
1882
- table: targetRelation.through.table,
1883
- sourceColumn: targetRelation.through.targetColumn,
1884
- targetColumn: targetRelation.through.sourceColumn
1885
- };
1886
- break;
1887
- }
1888
- if (junctionInfo) {
1889
- await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo);
1890
- continue;
1891
- }
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;
1892
1924
  }
1893
- if (!relation.foreignKeyOnTarget) {
1894
- 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.`);
1895
1927
  continue;
1896
1928
  }
1897
1929
  const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
@@ -1917,10 +1949,6 @@ var RelationService = class {
1917
1949
  */
1918
1950
  async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
1919
1951
  try {
1920
- if (!relation.joinPath || relation.joinPath.length === 0) {
1921
- logger.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
1922
- return;
1923
- }
1924
1952
  const sourceTableName = getTableName$1(sourceCollection);
1925
1953
  const targetTableName = getTableName$1(targetCollection);
1926
1954
  const intermediateTables = relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName);
@@ -2102,8 +2130,8 @@ var RelationService = class {
2102
2130
  [sourceJunctionColumn.name]: parentId,
2103
2131
  [targetJunctionColumn.name]: parsedNewEntityId
2104
2132
  };
2105
- await tx.insert(junctionTable).values(junctionData);
2106
- logger.info(`Created junction table entry for many-to-many relation '${relationKey}': ${JSON.stringify(junctionData)}`);
2133
+ await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
2134
+ logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
2107
2135
  } catch (error) {
2108
2136
  logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
2109
2137
  throw error;
@@ -2120,9 +2148,8 @@ var RelationService = class {
2120
2148
  * to unwrap that same level back out.
2121
2149
  */
2122
2150
  function isJunctionRelation(relation) {
2123
- if (relation.through) return true;
2124
- if (relation.joinPath && relation.joinPath.length > 1) return true;
2125
- return false;
2151
+ if (isManyToMany(relation)) return true;
2152
+ return relation.kind === "via" && relation.joinPath.length > 1;
2126
2153
  }
2127
2154
  /**
2128
2155
  * Reach the target row inside a junction row.
@@ -2255,6 +2282,82 @@ function toRestRow(row, collection, registry) {
2255
2282
  return stripExcluded(flat, collection);
2256
2283
  }
2257
2284
  //#endregion
2285
+ //#region src/services/nested-path.ts
2286
+ /**
2287
+ * True when `path` addresses rows through a relation rather than a root
2288
+ * collection.
2289
+ *
2290
+ * Any separator at all counts — a root collection slug never contains one — so
2291
+ * a malformed path like `collection/id` is a *broken* nested path and gets
2292
+ * reported as one by {@link resolveNestedPath}, rather than being looked up as
2293
+ * a root collection whose slug happens to contain a slash.
2294
+ */
2295
+ function isNestedPath(path) {
2296
+ return path.includes("/");
2297
+ }
2298
+ function splitPathSegments(path) {
2299
+ return path.split("/").filter((s) => s && s !== "undefined");
2300
+ }
2301
+ /**
2302
+ * Walk a nested collection path down to the relation it ends in.
2303
+ *
2304
+ * Returns `undefined` for a plain root-collection path so callers can keep the
2305
+ * root case on its existing code path. Throws when the path is malformed, or
2306
+ * when a segment names a relation that does not exist — the same errors the
2307
+ * individual walks used to raise, with the available names attached.
2308
+ */
2309
+ function resolveNestedPath(path, registry) {
2310
+ if (!isNestedPath(path)) return void 0;
2311
+ const segments = splitPathSegments(path);
2312
+ if (segments.length < 3 || segments.length % 2 === 0) throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
2313
+ let parentCollection = getCollectionByPath(segments[0], registry);
2314
+ let parentId = segments[1];
2315
+ for (let i = 2; i < segments.length; i += 2) {
2316
+ const relationKey = segments[i];
2317
+ const resolvedRelations = resolveCollectionRelations(parentCollection);
2318
+ const relation = findRelation(resolvedRelations, relationKey);
2319
+ if (!relation) {
2320
+ const available = Object.keys(resolvedRelations).join(", ") || "(none)";
2321
+ throw new Error(`Relation '${relationKey}' not found in collection '${parentCollection.slug}'. Available relations: [${available}]`);
2322
+ }
2323
+ const targetCollection = relation.target();
2324
+ if (i === segments.length - 1) return {
2325
+ parentCollection,
2326
+ parentId,
2327
+ relationKey,
2328
+ relation,
2329
+ targetCollection
2330
+ };
2331
+ parentCollection = targetCollection;
2332
+ parentId = segments[i + 1];
2333
+ }
2334
+ throw new Error(`Unable to resolve path: ${path}`);
2335
+ }
2336
+ /**
2337
+ * A relation reached through a junction table — many-to-many, or a multi-hop
2338
+ * `joinPath`. The target row is shared with other parents, so writing "through"
2339
+ * such a path addresses the *link*, not the row.
2340
+ */
2341
+ function isJunctionBackedRelation(relation) {
2342
+ return relation.shared;
2343
+ }
2344
+ /**
2345
+ * Reject a nested write whose final segment is a to-one relation.
2346
+ *
2347
+ * There is no column on the target row that records a to-one parent — the
2348
+ * foreign key lives on the *parent* table. The write path used to fall through
2349
+ * to `relation.localKey` here and stamp the parent's own FK column onto the
2350
+ * target row, which either raised an opaque "column does not exist" or, when a
2351
+ * column of that name happened to exist on the target, silently wrote the wrong
2352
+ * one.
2353
+ */
2354
+ function assertWritableThrough(hop, path) {
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");
2359
+ }
2360
+ //#endregion
2258
2361
  //#region src/services/FetchService.ts
2259
2362
  /**
2260
2363
  * Service for handling all row read operations.
@@ -2291,7 +2394,7 @@ var FetchService = class {
2291
2394
  let orderByField = table[orderBy];
2292
2395
  if (!orderByField && collection) {
2293
2396
  const property = collection.properties[orderBy];
2294
- 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`];
2295
2398
  }
2296
2399
  return orderByField;
2297
2400
  }
@@ -2312,7 +2415,7 @@ var FetchService = class {
2312
2415
  for (const [key, relation] of Object.entries(resolvedRelations)) {
2313
2416
  if (!shouldInclude(key)) continue;
2314
2417
  const drizzleRelName = relation.relationName || key;
2315
- if (relation.joinPath && relation.joinPath.length > 0) continue;
2418
+ if (relation.kind === "via") continue;
2316
2419
  if (relation.cardinality === "many" && isJunctionRelation(relation)) {
2317
2420
  const targetFkName = this.getJunctionTargetRelationName(relation, collection);
2318
2421
  if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
@@ -2326,7 +2429,7 @@ var FetchService = class {
2326
2429
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
2327
2430
  */
2328
2431
  getJunctionTargetRelationName(relation, _collection) {
2329
- if (relation.through) return relation.through.targetColumn.replace(/_id$/, "_id");
2432
+ if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
2330
2433
  return null;
2331
2434
  }
2332
2435
  /**
@@ -2336,7 +2439,7 @@ var FetchService = class {
2336
2439
  */
2337
2440
  async resolveJoinPathRelations(row, collection, collectionPath, parsedId, _databaseId) {
2338
2441
  const resolvedRelations = resolveCollectionRelations(collection);
2339
- 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]) => {
2340
2443
  try {
2341
2444
  const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: relation.cardinality === "one" ? 1 : void 0 });
2342
2445
  if (relation.cardinality === "one" && relatedRows.length > 0) {
@@ -2358,7 +2461,7 @@ var FetchService = class {
2358
2461
  const resolvedRelations = resolveCollectionRelations(collection);
2359
2462
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
2360
2463
  const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
2361
- 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));
2362
2465
  if (joinPathRelations.length === 0) return;
2363
2466
  const parentIdOf = (row) => {
2364
2467
  const address = buildCompositeId(row, idInfoArray);
@@ -2386,10 +2489,11 @@ var FetchService = class {
2386
2489
  * Build db.query-compatible options from standard fetch options.
2387
2490
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
2388
2491
  */
2389
- buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig) {
2492
+ buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
2390
2493
  const queryOpts = {};
2391
2494
  if (withConfig) queryOpts.with = withConfig;
2392
2495
  const allConditions = [];
2496
+ if (scopeCondition) allConditions.push(scopeCondition);
2393
2497
  if (options.searchString) {
2394
2498
  const collection = getCollectionByPath(collectionPath, this.registry);
2395
2499
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
@@ -2447,9 +2551,49 @@ var FetchService = class {
2447
2551
  return [];
2448
2552
  }
2449
2553
  /**
2554
+ * Compile "rows reachable from this parent" into a `WHERE` condition on the
2555
+ * target table, so a nested listing can run as an ordinary collection query.
2556
+ */
2557
+ buildRelationScope(hop) {
2558
+ const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
2559
+ const parentIdInfo = parentPks[0];
2560
+ const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
2561
+ const parent = () => {
2562
+ const table = getTableForCollection(hop.parentCollection, this.registry);
2563
+ const idColumn = table[parentIdInfo.fieldName];
2564
+ if (!idColumn) throw new Error(`ID field '${parentIdInfo.fieldName}' not found in table for collection '${hop.parentCollection.slug}'`);
2565
+ return {
2566
+ table,
2567
+ idColumn
2568
+ };
2569
+ };
2570
+ const targetTable = getTableForCollection(hop.targetCollection, this.registry);
2571
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
2572
+ const targetIdColumn = targetTable[targetPks[0].fieldName];
2573
+ if (!targetIdColumn) throw new Error(`ID field '${targetPks[0].fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
2574
+ return DrizzleConditionBuilder.buildRelationScopeCondition(hop.relation, parent, parsedParentId, targetTable, targetIdColumn, this.registry);
2575
+ }
2576
+ /**
2577
+ * Whether `id` is actually reachable at `collectionPath`.
2578
+ *
2579
+ * Trivially true for a root path. For a nested one it is a real question:
2580
+ * the path resolves to the target collection, and matching on the primary
2581
+ * key alone made the parent segment decorative — `authors/1/posts/43`
2582
+ * returned post 43 whoever wrote it, and the REST layer's delete then
2583
+ * deleted it. A row that is not under this parent is reported as absent,
2584
+ * which is what a caller addressing it through the parent should see.
2585
+ */
2586
+ async isAddressableUnder(collectionPath, id) {
2587
+ if (!isNestedPath(collectionPath)) return true;
2588
+ const hop = resolveNestedPath(collectionPath, this.registry);
2589
+ if (!hop) return true;
2590
+ return this.relationService.isRelated(hop, id);
2591
+ }
2592
+ /**
2450
2593
  * Fetch a single row by ID
2451
2594
  */
2452
2595
  async fetchOne(collectionPath, id, databaseId) {
2596
+ if (!await this.isAddressableUnder(collectionPath, id)) return void 0;
2453
2597
  const collection = getCollectionByPath(collectionPath, this.registry);
2454
2598
  const table = getTableForCollection(collection, this.registry);
2455
2599
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2471,7 +2615,7 @@ var FetchService = class {
2471
2615
  return flatRow;
2472
2616
  } catch (e) {
2473
2617
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2474
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2618
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2475
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.");
2476
2620
  }
2477
2621
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
@@ -2506,6 +2650,7 @@ var FetchService = class {
2506
2650
  * Unified method to fetch rows with optional search functionality
2507
2651
  */
2508
2652
  async fetchRowsWithConditions(collectionPath, options = {}) {
2653
+ const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
2509
2654
  const collection = getCollectionByPath(collectionPath, this.registry);
2510
2655
  const table = getTableForCollection(collection, this.registry);
2511
2656
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2517,11 +2662,11 @@ var FetchService = class {
2517
2662
  const withConfig = this.buildWithConfig(collection);
2518
2663
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
2519
2664
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
2520
- const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
2665
+ const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0, scopeCondition);
2521
2666
  return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
2522
2667
  } catch (e) {
2523
2668
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2524
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2669
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2525
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.");
2526
2671
  }
2527
2672
  logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
@@ -2533,6 +2678,7 @@ var FetchService = class {
2533
2678
  _distance: vectorMeta.distanceSelect
2534
2679
  }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
2535
2680
  const allConditions = [];
2681
+ if (scopeCondition) allConditions.push(scopeCondition);
2536
2682
  if (options.searchString) {
2537
2683
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
2538
2684
  if (searchConditions.length === 0) return [];
@@ -2637,7 +2783,11 @@ var FetchService = class {
2637
2783
  * Fetch a collection of rows
2638
2784
  */
2639
2785
  async fetchCollection(collectionPath, options = {}) {
2640
- if (collectionPath.includes("/")) return this.fetchCollectionFromPath(collectionPath, options);
2786
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
2787
+ if (hop) return this.fetchRowsWithConditions(hop.targetCollection.slug, {
2788
+ ...options,
2789
+ relatedTo: hop
2790
+ });
2641
2791
  return this.fetchRowsWithConditions(collectionPath, options);
2642
2792
  }
2643
2793
  /**
@@ -2650,43 +2800,23 @@ var FetchService = class {
2650
2800
  });
2651
2801
  }
2652
2802
  /**
2653
- * Fetch collection from multi-segment path
2654
- */
2655
- async fetchCollectionFromPath(path, options = {}) {
2656
- const pathSegments = path.split("/").filter((p) => p && p !== "undefined");
2657
- if (pathSegments.length < 3 || pathSegments.length % 2 === 0) throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
2658
- const rootCollectionPath = pathSegments[0];
2659
- let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
2660
- let currentId = pathSegments[1];
2661
- for (let i = 2; i < pathSegments.length; i += 2) {
2662
- const relationKey = pathSegments[i];
2663
- const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
2664
- if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
2665
- if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
2666
- if (i + 1 < pathSegments.length) {
2667
- const nextEntityId = pathSegments[i + 1];
2668
- currentCollection = relation.target();
2669
- currentId = nextEntityId;
2670
- }
2671
- }
2672
- throw new Error(`Unable to resolve path: ${path}`);
2673
- }
2674
- /**
2675
2803
  * Count rows in a collection
2676
2804
  */
2677
2805
  async count(collectionPath, options = {}) {
2678
- if (collectionPath.includes("/")) return this.countEntitiesFromPath(collectionPath, options);
2679
- const collection = getCollectionByPath(collectionPath, this.registry);
2806
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
2807
+ const effectivePath = hop ? hop.targetCollection.slug : collectionPath;
2808
+ const collection = getCollectionByPath(effectivePath, this.registry);
2680
2809
  const table = getTableForCollection(collection, this.registry);
2681
2810
  let query = this.db.select({ count: count() }).from(table).$dynamic();
2682
2811
  const allConditions = [];
2812
+ if (hop) allConditions.push(this.buildRelationScope(hop));
2683
2813
  if (options.searchString) {
2684
2814
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
2685
2815
  if (searchConditions.length === 0) return 0;
2686
2816
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
2687
2817
  }
2688
2818
  if (options.filter) {
2689
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
2819
+ const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
2690
2820
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
2691
2821
  }
2692
2822
  if (allConditions.length > 0) {
@@ -2697,27 +2827,6 @@ var FetchService = class {
2697
2827
  return Number(result[0]?.count || 0);
2698
2828
  }
2699
2829
  /**
2700
- * Count rows from multi-segment path
2701
- */
2702
- async countEntitiesFromPath(path, options = {}) {
2703
- const pathSegments = path.split("/").filter((p) => p && p !== "undefined");
2704
- if (pathSegments.length < 3 || pathSegments.length % 2 === 0) throw new Error(`Invalid relation path: ${path}`);
2705
- const rootCollectionPath = pathSegments[0];
2706
- let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
2707
- let currentId = pathSegments[1];
2708
- for (let i = 2; i < pathSegments.length; i += 2) {
2709
- const relationKey = pathSegments[i];
2710
- const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
2711
- if (!relation) throw new Error(`Relation '${relationKey}' not found`);
2712
- if (i === pathSegments.length - 1) return this.relationService.countRelatedEntities(currentCollection.slug, currentId, relationKey, options);
2713
- if (i + 1 < pathSegments.length) {
2714
- currentCollection = relation.target();
2715
- currentId = pathSegments[i + 1];
2716
- }
2717
- }
2718
- throw new Error(`Unable to count for path: ${path}`);
2719
- }
2720
- /**
2721
2830
  * Check if a field value is unique
2722
2831
  */
2723
2832
  async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
@@ -2749,6 +2858,14 @@ var FetchService = class {
2749
2858
  * @param include - Array of relation keys to populate, or ["*"] for all
2750
2859
  */
2751
2860
  async fetchCollectionForRest(collectionPath, options = {}, include) {
2861
+ if (isNestedPath(collectionPath)) {
2862
+ const hop = resolveNestedPath(collectionPath, this.registry);
2863
+ if (hop) return this.fetchCollectionForRest(hop.targetCollection.slug, {
2864
+ ...options,
2865
+ relatedTo: hop
2866
+ }, include);
2867
+ }
2868
+ const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
2752
2869
  const collection = getCollectionByPath(collectionPath, this.registry);
2753
2870
  const table = getTableForCollection(collection, this.registry);
2754
2871
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2758,13 +2875,13 @@ var FetchService = class {
2758
2875
  const qb = this.getQueryBuilder(tableName);
2759
2876
  if (qb && !options.searchString && !options.vectorSearch) try {
2760
2877
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
2761
- const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
2878
+ const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition);
2762
2879
  const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
2763
2880
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
2764
2881
  return restRows;
2765
2882
  } catch (e) {
2766
2883
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2767
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2884
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2768
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.");
2769
2886
  }
2770
2887
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
@@ -2806,6 +2923,7 @@ var FetchService = class {
2806
2923
  * Fetch a single row with optional relation includes for REST API.
2807
2924
  */
2808
2925
  async fetchOneForRest(collectionPath, id, include, databaseId) {
2926
+ if (!await this.isAddressableUnder(collectionPath, id)) return null;
2809
2927
  const collection = getCollectionByPath(collectionPath, this.registry);
2810
2928
  const table = getTableForCollection(collection, this.registry);
2811
2929
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2826,7 +2944,7 @@ var FetchService = class {
2826
2944
  return restRow;
2827
2945
  } catch (e) {
2828
2946
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2829
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
2947
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2830
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.");
2831
2949
  }
2832
2950
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
@@ -2874,6 +2992,7 @@ var FetchService = class {
2874
2992
  _distance: vectorMeta.distanceSelect
2875
2993
  }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
2876
2994
  const allConditions = [];
2995
+ if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
2877
2996
  if (options.searchString) {
2878
2997
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
2879
2998
  if (searchConditions.length === 0) return [];
@@ -2973,7 +3092,7 @@ var FetchService = class {
2973
3092
  if (parentIds.length === 0) return /* @__PURE__ */ new Map();
2974
3093
  const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
2975
3094
  if (!relation) {
2976
- logger.warn(`[batchFetchManyRelatedRows] Relation '${relationKey}' not found, skipping`);
3095
+ logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
2977
3096
  return /* @__PURE__ */ new Map();
2978
3097
  }
2979
3098
  const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
@@ -3193,6 +3312,16 @@ var PersistService = class {
3193
3312
  * Delete an row by ID
3194
3313
  */
3195
3314
  async delete(collectionPath, id, _databaseId) {
3315
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
3316
+ if (hop) {
3317
+ assertWritableThrough(hop, collectionPath);
3318
+ if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
3319
+ if (isJunctionBackedRelation(hop.relation)) {
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");
3321
+ await this.relationService.unlinkRelatedEntity(this.db, hop, id);
3322
+ return;
3323
+ }
3324
+ }
3196
3325
  const collection = getCollectionByPath(collectionPath, this.registry);
3197
3326
  const table = getTableForCollection(collection, this.registry);
3198
3327
  const idInfoArray = getPrimaryKeys(collection, this.registry);
@@ -3213,6 +3342,26 @@ var PersistService = class {
3213
3342
  await this.db.delete(table);
3214
3343
  }
3215
3344
  /**
3345
+ * The column on the *target* table that records the parent, for a create
3346
+ * under a nested one-to-many path.
3347
+ *
3348
+ * Returns `undefined` when the link is not a column at all (a multi-hop
3349
+ * `joinPath`), so the caller writes the row without stamping anything.
3350
+ *
3351
+ * `relation.localKey` is deliberately not consulted: it names a column on
3352
+ * the *source* table. Falling back to it here — which is what this used to
3353
+ * do, and first — stamped the parent's own foreign key onto the child row.
3354
+ */
3355
+ resolveParentForeignKeyColumn(hop) {
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;
3362
+ }
3363
+ }
3364
+ /**
3216
3365
  * Save an row (create or update)
3217
3366
  *
3218
3367
  * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
@@ -3225,60 +3374,29 @@ var PersistService = class {
3225
3374
  let effectiveCollectionPath = collectionPath;
3226
3375
  const effectiveValues = { ...values };
3227
3376
  let junctionTableInfo;
3228
- if (collectionPath.includes("/")) {
3229
- const segments = collectionPath.split("/").filter(Boolean);
3230
- if (segments.length >= 3 && segments.length % 2 === 1) {
3231
- const rootSegment = segments[0];
3232
- let currentCollection = getCollectionByPath(rootSegment, this.registry);
3233
- let currentId = segments[1];
3234
- for (let i = 2; i < segments.length; i += 2) {
3235
- const relationKey = segments[i];
3236
- const resolvedRelations = resolveCollectionRelations(currentCollection);
3237
- const relation = findRelation(resolvedRelations, relationKey);
3238
- if (!relation) {
3239
- const available = Object.keys(resolvedRelations).join(", ") || "(none)";
3240
- throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'. Available relations: [${available}]`);
3241
- }
3242
- if (i === segments.length - 1) {
3243
- const targetCollection = relation.target();
3244
- effectiveCollectionPath = targetCollection.slug;
3245
- if (relation.cardinality === "many" && relation.through) {
3246
- const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
3247
- const parentIdInfo = parentIdInfoArray[0];
3248
- const parsedParentId = parseIdValues(currentId, parentIdInfoArray)[parentIdInfo.fieldName];
3249
- junctionTableInfo = {
3250
- parentCollection: currentCollection,
3251
- parentId: parsedParentId,
3252
- relation,
3253
- relationKey
3254
- };
3255
- break;
3256
- }
3257
- let targetColumnName;
3258
- if (relation.localKey) targetColumnName = relation.localKey;
3259
- else if (relation.foreignKeyOnTarget) targetColumnName = relation.foreignKeyOnTarget;
3260
- else if (relation.joinPath && relation.joinPath.length === 1) {
3261
- const targetTableName = getTableName$1(targetCollection);
3262
- const relevantJoinStep = relation.joinPath.find((joinStep) => joinStep.table === targetTableName);
3263
- if (relevantJoinStep) targetColumnName = DrizzleConditionBuilder.getColumnNamesFromColumns(relevantJoinStep.on.to)[0];
3264
- else {
3265
- logger.warn(`Could not find specific join step for target table ${targetTableName} in relation '${relationKey}'.`);
3266
- targetColumnName = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0];
3267
- }
3268
- } else if (relation.joinPath && relation.joinPath.length > 1) break;
3269
- else throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
3270
- const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
3271
- const parentIdInfo = parentIdInfoArray[0];
3272
- const parsedParentId = parseIdValues(currentId, parentIdInfoArray)[parentIdInfo.fieldName];
3273
- const existingValue = effectiveValues[targetColumnName];
3274
- if (existingValue !== void 0 && existingValue !== null && existingValue !== parsedParentId) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
3275
- effectiveValues[targetColumnName] = parsedParentId;
3276
- break;
3277
- } else {
3278
- const nextEntityId = segments[i + 1];
3279
- currentCollection = relation.target();
3280
- currentId = nextEntityId;
3281
- }
3377
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
3378
+ if (hop) {
3379
+ assertWritableThrough(hop, collectionPath);
3380
+ effectiveCollectionPath = hop.targetCollection.slug;
3381
+ const parentIdForWrite = () => {
3382
+ const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
3383
+ return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
3384
+ };
3385
+ if (isManyToMany(hop.relation)) junctionTableInfo = {
3386
+ parentCollection: hop.parentCollection,
3387
+ parentId: parentIdForWrite(),
3388
+ relation: hop.relation,
3389
+ relationKey: hop.relationKey
3390
+ };
3391
+ else if (id !== void 0) {
3392
+ if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
3393
+ } else {
3394
+ const targetColumnName = this.resolveParentForeignKeyColumn(hop);
3395
+ if (targetColumnName) {
3396
+ const parsedParentId = parentIdForWrite();
3397
+ const existingValue = effectiveValues[targetColumnName];
3398
+ if (existingValue !== void 0 && existingValue !== null && existingValue !== parsedParentId) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
3399
+ effectiveValues[targetColumnName] = parsedParentId;
3282
3400
  }
3283
3401
  }
3284
3402
  }
@@ -3349,7 +3467,7 @@ var PersistService = class {
3349
3467
  }
3350
3468
  if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
3351
3469
  if (Object.keys(relationValues).length > 0) await this.relationService.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
3352
- if (junctionTableInfo && !id) await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
3470
+ if (junctionTableInfo) await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
3353
3471
  return currentId;
3354
3472
  });
3355
3473
  } catch (error) {
@@ -5246,9 +5364,9 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5246
5364
  if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}("${colName}")`;
5247
5365
  else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid("${colName}")`;
5248
5366
  else if (stringProp.columnType === "uuid") columnDefinition = `uuid("${colName}")`;
5249
- else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) columnDefinition = `text("${colName}")`;
5250
5367
  else if (stringProp.columnType === "char") columnDefinition = `char("${colName}")`;
5251
- else columnDefinition = `varchar("${colName}")`;
5368
+ else if (stringProp.columnType === "varchar") columnDefinition = `varchar("${colName}")`;
5369
+ else columnDefinition = `text("${colName}")`;
5252
5370
  if (isIdProperty(propName, prop, collection)) columnDefinition += ".primaryKey()";
5253
5371
  if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
5254
5372
  if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
@@ -5317,12 +5435,8 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5317
5435
  break;
5318
5436
  case "relation": {
5319
5437
  const refProp = prop;
5320
- const relation = findRelation(resolveCollectionRelations(collection), refProp.relationName ?? propName);
5321
- if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") return null;
5322
- if (!relation.localKey) {
5323
- logger.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
5324
- return null;
5325
- }
5438
+ const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
5439
+ if (!relation || relation.kind !== "belongsTo") return null;
5326
5440
  if (collection.properties[relation.localKey] && propName !== relation.localKey) return null;
5327
5441
  let targetCollection;
5328
5442
  try {
@@ -5334,7 +5448,7 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5334
5448
  const targetTableVar = getTableVarName(getTableName$1(targetCollection));
5335
5449
  const pkProp = getPrimaryKeyProp(targetCollection);
5336
5450
  const targetIdField = pkProp.name;
5337
- const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : pkProp.isUuid ? `uuid("${fkColumnName}")` : `varchar("${fkColumnName}")`;
5451
+ const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : pkProp.isUuid ? `uuid("${fkColumnName}")` : `text("${fkColumnName}")`;
5338
5452
  const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
5339
5453
  const required = prop.validation?.required;
5340
5454
  const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
@@ -5347,13 +5461,13 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5347
5461
  const refProp = prop;
5348
5462
  const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName$1(c) === refProp.path);
5349
5463
  if (!targetCollection) {
5350
- columnDefinition = `varchar("${colName}")`;
5464
+ columnDefinition = `text("${colName}")`;
5351
5465
  break;
5352
5466
  }
5353
5467
  const pkProp = getPrimaryKeyProp(targetCollection);
5354
5468
  const targetTableVar = getTableVarName(getTableName$1(targetCollection));
5355
5469
  const targetIdField = pkProp.name;
5356
- const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : pkProp.isUuid ? `uuid("${colName}")` : `varchar("${colName}")`;
5470
+ const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : pkProp.isUuid ? `uuid("${colName}")` : `text("${colName}")`;
5357
5471
  const required = prop.validation?.required;
5358
5472
  columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${`{ onDelete: "${required ? "cascade" : "set null"}" }`})`;
5359
5473
  if (required) columnDefinition += ".notNull()";
@@ -5416,33 +5530,17 @@ var generateSinglePolicyCode = (collection, rule, operation, policyName, resolve
5416
5530
  */
5417
5531
  var computeSharedRelationName = (rel, sourceCollection, _collections) => {
5418
5532
  const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
5419
- if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
5533
+ if (rel.kind === "belongsTo") {
5420
5534
  const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
5421
5535
  return `${getTableName$1(sourceCollection)}_${normalisedKey}`;
5422
5536
  }
5423
- if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) try {
5537
+ if (rel.kind === "hasMany" || rel.kind === "hasOne") try {
5424
5538
  const targetCollection = rel.target();
5425
5539
  const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
5426
5540
  return `${getTableName$1(targetCollection)}_${normalisedFK}`;
5427
5541
  } catch {
5428
5542
  return fallback;
5429
5543
  }
5430
- if (rel.direction === "inverse" && rel.cardinality === "one") {
5431
- if (rel.foreignKeyOnTarget) try {
5432
- const targetCollection = rel.target();
5433
- const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
5434
- return `${getTableName$1(targetCollection)}_${normalisedFK}`;
5435
- } catch {
5436
- return fallback;
5437
- }
5438
- try {
5439
- const targetCollection = rel.target();
5440
- const targetResolvedRelations = resolveCollectionRelations(targetCollection);
5441
- const correspondingRelation = Object.values(targetResolvedRelations).find((targetRel) => targetRel.direction === "owning" && targetRel.cardinality === "one" && targetRel.localKey && targetRel.target().slug === sourceCollection.slug);
5442
- if (correspondingRelation && correspondingRelation.localKey) return `${getTableName$1(targetCollection)}_${correspondingRelation.localKey}`;
5443
- } catch {}
5444
- return fallback;
5445
- }
5446
5544
  return fallback;
5447
5545
  };
5448
5546
  var generateSchema = async (collections, stripPolicies = false) => {
@@ -5507,7 +5605,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5507
5605
  const tableName = getTableName$1(collection);
5508
5606
  if (tableName) allTablesToGenerate.set(tableName, { collection });
5509
5607
  const resolvedRelations = resolveCollectionRelations(collection);
5510
- for (const relation of Object.values(resolvedRelations)) if (relation.through) {
5608
+ for (const relation of Object.values(resolvedRelations)) if (isManyToMany(relation)) {
5511
5609
  const junctionTableName = relation.through.table;
5512
5610
  if (!allTablesToGenerate.has(junctionTableName)) allTablesToGenerate.set(junctionTableName, {
5513
5611
  collection: {
@@ -5522,15 +5620,15 @@ var generateSchema = async (collections, stripPolicies = false) => {
5522
5620
  }
5523
5621
  for (const [tableName, { collection, isJunction, relation, sourceCollection }] of allTablesToGenerate.entries()) {
5524
5622
  const tableVarName = getTableVarName(tableName);
5525
- if (isJunction && relation && sourceCollection && relation.through) {
5623
+ if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
5526
5624
  const targetCollection = relation.target();
5527
5625
  const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : void 0) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : void 0);
5528
5626
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
5529
5627
  const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
5530
5628
  const { sourceColumn, targetColumn } = relation.through;
5531
5629
  const refOptions = `{ onDelete: \"${relation.onDelete ?? "cascade"}\" }`;
5532
- const sourceColType = isNumericId(sourceCollection) ? "integer" : getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "varchar";
5533
- const targetColType = isNumericId(targetCollection) ? "integer" : getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "varchar";
5630
+ const sourceColType = isNumericId(sourceCollection) ? "integer" : getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "text";
5631
+ const targetColType = isNumericId(targetCollection) ? "integer" : getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "text";
5534
5632
  const sourceId = getPrimaryKeyName(sourceCollection);
5535
5633
  const targetId = getPrimaryKeyName(targetCollection);
5536
5634
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
@@ -5557,7 +5655,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5557
5655
  const columnString = getDrizzleColumn(propName, prop, collection, collections);
5558
5656
  if (columnString) columns.add(columnString);
5559
5657
  });
5560
- if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: varchar(\"id\").primaryKey()");
5658
+ if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: text(\"id\").primaryKey()");
5561
5659
  schemaContent += `${Array.from(columns).join(",\n")}`;
5562
5660
  const securityRules = getEffectiveSecurityRules(collection);
5563
5661
  if (!stripPolicies && securityRules.length > 0) {
@@ -5576,7 +5674,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5576
5674
  const tableRelations = [];
5577
5675
  if (isJunction) {
5578
5676
  const relationInfo = Array.from(allTablesToGenerate.values()).find((v) => v.isJunction && getTableName$1(v.collection) === tableName);
5579
- if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation.through) {
5677
+ if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && isManyToMany(relationInfo.relation)) {
5580
5678
  const { relation, sourceCollection } = relationInfo;
5581
5679
  const targetCollection = relation.target();
5582
5680
  const sourceTableVar = getTableVarName(getTableName$1(sourceCollection));
@@ -5588,7 +5686,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5588
5686
  let inverseRelationName = null;
5589
5687
  try {
5590
5688
  const targetRelations = resolveCollectionRelations(targetCollection);
5591
- 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) {
5592
5690
  inverseRelationName = targetRel.relationName ?? null;
5593
5691
  break;
5594
5692
  }
@@ -5604,27 +5702,25 @@ var generateSchema = async (collections, stripPolicies = false) => {
5604
5702
  const target = rel.target();
5605
5703
  const targetTableVar = getTableVarName(getTableName$1(target));
5606
5704
  const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
5607
- const deduplicationKey = `${drizzleRelationName}::${rel.direction}`;
5705
+ const deduplicationKey = `${drizzleRelationName}::${rel.kind}`;
5608
5706
  if (emittedRelationNames.has(deduplicationKey)) continue;
5609
5707
  emittedRelationNames.add(deduplicationKey);
5610
- if (rel.cardinality === "one") {
5611
- 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 })`);
5612
- else if (rel.direction === "inverse") tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
5613
- } else if (rel.cardinality === "many") {
5614
- if (rel.direction === "inverse" && rel.foreignKeyOnTarget) tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
5615
- 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": {
5616
5719
  const junctionTableVar = getTableVarName(rel.through.table);
5617
5720
  tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
5618
- } else if (rel.direction === "inverse" && rel.inverseRelationName) try {
5619
- const targetResolvedRelations = resolveCollectionRelations(rel.target());
5620
- const correspondingRelation = Object.values(targetResolvedRelations).find((targetRel) => targetRel.direction === "owning" && targetRel.cardinality === "many" && targetRel.through && targetRel.relationName === rel.inverseRelationName);
5621
- if (correspondingRelation && correspondingRelation.through) {
5622
- const junctionTableVar = getTableVarName(correspondingRelation.through.table);
5623
- tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
5624
- } else logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
5625
- } catch (e) {
5626
- logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
5721
+ break;
5627
5722
  }
5723
+ case "via": break;
5628
5724
  }
5629
5725
  } catch (e) {
5630
5726
  logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
@@ -5632,10 +5728,10 @@ var generateSchema = async (collections, stripPolicies = false) => {
5632
5728
  for (const otherCollection of collections) {
5633
5729
  if (otherCollection.slug === collection.slug) continue;
5634
5730
  const otherRelations = resolveCollectionRelations(otherCollection);
5635
- 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 {
5636
5732
  if (otherRel.target().slug === collection.slug) {
5637
5733
  const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
5638
- const deduplicationKey = `${drizzleRelationName}::owning`;
5734
+ const deduplicationKey = `${drizzleRelationName}::belongsTo`;
5639
5735
  if (!emittedRelationNames.has(deduplicationKey)) {
5640
5736
  const otherTableVar = getTableVarName(getTableName$1(otherCollection));
5641
5737
  const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
@@ -5737,6 +5833,58 @@ var main = () => {
5737
5833
  };
5738
5834
  if (import.meta.url.endsWith(process.argv[1])) main();
5739
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
5740
5888
  //#region src/services/cdc/trigger-cdc.ts
5741
5889
  /**
5742
5890
  * Trigger-based Change Data Capture (CDC).
@@ -6861,6 +7009,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
6861
7009
  cdcListener;
6862
7010
  /** Whether database-level CDC is the active cross-instance change source. */
6863
7011
  cdcActive = false;
7012
+ /** Junction table → the child lists its rows belong to, built when CDC starts. */
7013
+ junctionLinkMap;
6864
7014
  /** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
6865
7015
  cdcTableMap;
6866
7016
  /**
@@ -7977,6 +8127,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7977
8127
  return;
7978
8128
  }
7979
8129
  this.cdcTableMap = this.buildCdcTableMap();
8130
+ this.junctionLinkMap = buildJunctionLinkMap(this.registry);
7980
8131
  this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
7981
8132
  try {
7982
8133
  await this.cdcListener.start();
@@ -7984,6 +8135,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7984
8135
  await this.cdcListener.stop().catch(() => {});
7985
8136
  this.cdcListener = void 0;
7986
8137
  this.cdcTableMap = void 0;
8138
+ this.junctionLinkMap = void 0;
7987
8139
  throw err;
7988
8140
  }
7989
8141
  this.cdcActive = true;
@@ -7997,6 +8149,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7997
8149
  this.cdcListener = void 0;
7998
8150
  }
7999
8151
  this.cdcTableMap = void 0;
8152
+ this.junctionLinkMap = void 0;
8000
8153
  this.recentAppEmits.clear();
8001
8154
  }
8002
8155
  /**
@@ -8033,6 +8186,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8033
8186
  async handleCdcEvent(event) {
8034
8187
  const collection = this.resolveCollectionForTable(event.schema, event.table);
8035
8188
  if (!collection) {
8189
+ if (await this.handleJunctionCdcEvent(event)) return;
8036
8190
  this.debugLog(`📡 [CDC] Ignoring change on unmapped table ${event.schema}.${event.table}`);
8037
8191
  return;
8038
8192
  }
@@ -8042,6 +8196,38 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8042
8196
  const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
8043
8197
  await this.notifyUpdate(path, id, row, databaseId, false, "cdc");
8044
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
+ }
8045
8231
  /** Compute the canonical (possibly composite) id string from a captured row. */
8046
8232
  extractIdFromCdcRow(collection, row) {
8047
8233
  return deriveRowAddress(row, collection, this.registry) || "*";
@@ -8761,8 +8947,8 @@ var PostgresCollectionRegistry = class extends CollectionRegistry {
8761
8947
  */
8762
8948
  getRelationKeysForCollection(collectionPath) {
8763
8949
  const collection = this.getCollectionByPath(collectionPath);
8764
- if (!collection || !getDataSourceCapabilities(collection.engine).supportsRelations || !collection.relations) return [];
8765
- return collection.relations.map((r) => r.relationName || r.localKey || "").filter(Boolean);
8950
+ if (!collection) return [];
8951
+ return Object.keys(resolveCollectionRelations(collection));
8766
8952
  }
8767
8953
  };
8768
8954
  //#endregion
@@ -17713,6 +17899,178 @@ function formatBytes(bytes) {
17713
17899
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
17714
17900
  }
17715
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
17716
18074
  //#region src/collections/buildRegistry.ts
17717
18075
  /**
17718
18076
  * Build the collection registry for a driver.
@@ -17737,8 +18095,163 @@ function buildCollectionRegistry(schema) {
17737
18095
  if (schema.enums) registry.registerEnums(schema.enums);
17738
18096
  if (schema.relations) registry.registerRelations(schema.relations);
17739
18097
  warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
18098
+ assertRelationsResolve(registry.getCollections(), registry);
17740
18099
  return registry;
17741
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
+ }
17742
18255
  //#endregion
17743
18256
  //#region src/auth/ensure-tables.ts
17744
18257
  /**
@@ -17750,6 +18263,7 @@ function buildCollectionRegistry(schema) {
17750
18263
  */
17751
18264
  async function ensureAuthTablesExist(db, collection) {
17752
18265
  logger.info("🔍 Checking auth tables...");
18266
+ await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
17753
18267
  try {
17754
18268
  let usersTableName = "\"rebase\".\"users\"";
17755
18269
  let userIdType = "TEXT";
@@ -18130,8 +18644,10 @@ async function ensureAuthTablesExist(db, collection) {
18130
18644
  } catch (rlsReconcileError) {
18131
18645
  logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
18132
18646
  }
18647
+ await stampAuthSchemaVersion(db, authSchema);
18133
18648
  logger.info("✅ Auth tables ready");
18134
18649
  } catch (error) {
18650
+ if (error instanceof AuthSchemaVersionError) throw error;
18135
18651
  logger.error("❌ Failed to create auth tables", { error });
18136
18652
  logger.warn("⚠️ Continuing without creating auth tables.");
18137
18653
  }
@@ -20026,7 +20542,7 @@ function createPostgresBootstrapper(pgConfig) {
20026
20542
  } catch {}
20027
20543
  const message = `
20028
20544
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
20029
- ❌ 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`;
20030
20546
  logger.error(message);
20031
20547
  throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
20032
20548
  }
@@ -20111,10 +20627,15 @@ function createPostgresBootstrapper(pgConfig) {
20111
20627
  const cdcRunSql = async (text) => {
20112
20628
  return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
20113
20629
  };
20114
- await provisionTriggerCdc(cdcRunSql, registry.getCollections().map((c) => ({
20630
+ const cdcTables = registry.getCollections().map((c) => ({
20115
20631
  schema: c.schema ?? "public",
20116
20632
  table: getTableName$1(c)
20117
- })).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);
20118
20639
  await realtimeService.enableCdc(directUrl);
20119
20640
  cdcEnabled = true;
20120
20641
  provisionCdcForTables = async (tables) => {
@@ -20224,7 +20745,8 @@ function createPostgresBootstrapper(pgConfig) {
20224
20745
  userService,
20225
20746
  roleService: userService,
20226
20747
  emailService,
20227
- authRepository
20748
+ authRepository,
20749
+ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
20228
20750
  };
20229
20751
  },
20230
20752
  async initializeHistory(config, driverResult) {
@@ -20252,7 +20774,7 @@ function createPostgresBootstrapper(pgConfig) {
20252
20774
  */
20253
20775
  async ensureCollectionSchema(collections, driverResult, log) {
20254
20776
  const internals = driverResult.internals;
20255
- const { ensureCollectionTables } = await import("./ensure-collection-tables-CNlIONzj.js");
20777
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-DGMYK0fr.js");
20256
20778
  return { applied: (await ensureCollectionTables({ async query(text) {
20257
20779
  const result = await internals.db.execute(sql.raw(text));
20258
20780
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };