@rebasepro/server-postgres 0.10.1-canary.262a5be → 0.10.1-canary.3d09338

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 (31) hide show
  1. package/dist/{ensure-collection-tables-CNlIONzj.js → ensure-collection-tables-BVvtkkRm.js} +10 -10
  2. package/dist/ensure-collection-tables-BVvtkkRm.js.map +1 -0
  3. package/dist/index.es.js +446 -122
  4. package/dist/index.es.js.map +1 -1
  5. package/dist/services/FetchService.d.ts +21 -8
  6. package/dist/services/PersistService.d.ts +12 -0
  7. package/dist/services/RelationService.d.ts +30 -0
  8. package/dist/services/cdc/junction-tables.d.ts +38 -0
  9. package/dist/services/nested-path.d.ts +59 -0
  10. package/dist/services/realtimeService.d.ts +19 -0
  11. package/dist/{src-B0v4IKaI.js → src-CBgtrPhJ.js} +8 -1
  12. package/dist/{src-B0v4IKaI.js.map → src-CBgtrPhJ.js.map} +1 -1
  13. package/dist/{src-DmsRg8MR.js → src-lcfUP4xg.js} +126 -76
  14. package/dist/src-lcfUP4xg.js.map +1 -0
  15. package/dist/utils/drizzle-conditions.d.ts +41 -0
  16. package/package.json +8 -9
  17. package/src/PostgresBootstrapper.ts +10 -1
  18. package/src/schema/doctor.ts +6 -6
  19. package/src/schema/generate-drizzle-schema-logic.ts +13 -9
  20. package/src/schema/generate-postgres-ddl-logic.ts +19 -12
  21. package/src/schema/introspect-db-inference.ts +13 -13
  22. package/src/schema/introspect-db-logic.ts +6 -6
  23. package/src/services/FetchService.ts +104 -114
  24. package/src/services/PersistService.ts +127 -85
  25. package/src/services/RelationService.ts +95 -6
  26. package/src/services/cdc/junction-tables.ts +91 -0
  27. package/src/services/nested-path.ts +130 -0
  28. package/src/services/realtimeService.ts +60 -0
  29. package/src/utils/drizzle-conditions.ts +154 -0
  30. package/dist/ensure-collection-tables-CNlIONzj.js.map +0 -1
  31. 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, i as isSQLAdmin, l as isPostgresCollectionConfig, m as Vector, n as getDataSourceCapabilities, o as ANONYMOUS_USER_ID, r as isChannelBusInstance } from "./src-CBgtrPhJ.js";
6
+ import { C as createRelationRefWithData, D as mergeDeep, E as getPolicyNamesForRule, O as camelCase, S as createRelationRef, T as updateDateAutoValues, _ as getTableVarName, a as getJunctionCollectionConfig, b as getDeclaredPrimaryKeys, c as getEffectiveSecurityRules, d as securityRuleToConditions, f as findAnonymousGrants, g as getTableName$1, h as getEnumVarName, i as CollectionRegistry, k as toSnakeCase, l as buildPropertyCallbacks, m as getColumnName, n as detectJunctionTables, o as getJunctionSecurityRules, p as findRelation, r as buildSdkData, s as resolveJunctionSpecs, t as classifyTable, u as policyToPostgres, v as resolveCollectionRelations, w as normalizeToEntityRelation, x as parseIdValues, y as buildCompositeId } from "./src-lcfUP4xg.js";
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,89 @@ 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
+ if (relation.joinPath && relation.joinPath.length > 0) {
417
+ const { table, idColumn } = parent();
418
+ return this.buildJoinPathScopeCondition(relation.joinPath, table, idColumn, parentId, targetTable, registry);
419
+ }
420
+ if (relation.through) {
421
+ const junctionTable = registry.getTable(relation.through.table);
422
+ if (!junctionTable) throw new Error(`Junction table not found: ${relation.through.table}`);
423
+ const sourceCol = junctionTable[relation.through.sourceColumn];
424
+ const targetCol = junctionTable[relation.through.targetColumn];
425
+ if (!sourceCol || !targetCol) throw new Error(`Junction columns '${relation.through.sourceColumn}'/'${relation.through.targetColumn}' not found in '${relation.through.table}'`);
426
+ return sql`EXISTS (SELECT 1 FROM ${junctionTable} WHERE ${targetCol} = ${targetIdColumn} AND ${sourceCol} = ${parentId})`;
427
+ }
428
+ if (relation.foreignKeyOnTarget) {
429
+ const fkColumn = targetTable[relation.foreignKeyOnTarget];
430
+ if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'. A many-to-many relation needs \`through\` instead.`);
431
+ return eq(fkColumn, parentId);
432
+ }
433
+ if (relation.localKey) {
434
+ const { table, idColumn } = parent();
435
+ return sql`${targetIdColumn} = (SELECT ${sql.identifier(relation.localKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
436
+ }
437
+ throw new Error(`Relation '${relation.relationName}' declares no \`foreignKeyOnTarget\`, \`through\`, \`joinPath\` or \`localKey\`, so there is no way to tell which target rows belong to a parent.`);
438
+ }
439
+ /**
440
+ * `EXISTS` for an explicit `joinPath`.
441
+ *
442
+ * The path is declared source → target. The subquery replays every step but
443
+ * the last from inside, and turns the last one into the correlation with the
444
+ * outer target row — so the target table is never named twice and needs no
445
+ * alias. Each intermediate table is aliased positionally, which keeps a path
446
+ * that revisits a table (a self-referencing many-to-many) unambiguous.
447
+ */
448
+ static buildJoinPathScopeCondition(joinPath, parentTable, parentIdColumn, parentId, targetTable, registry) {
449
+ const sourceAlias = "__rel_src";
450
+ const aliasFor = (index) => `__rel_j${index}`;
451
+ const fromRef = (stepIndex, column) => sql`${sql.identifier(stepIndex === 0 ? sourceAlias : aliasFor(stepIndex - 1))}.${sql.identifier(getColumnName(column))}`;
452
+ const pairs = (step) => {
453
+ const from = Array.isArray(step.on.from) ? step.on.from : [step.on.from];
454
+ const to = Array.isArray(step.on.to) ? step.on.to : [step.on.to];
455
+ if (from.length !== to.length) throw new Error(`Join step on '${step.table}' has ${from.length} \`from\` columns and ${to.length} \`to\` columns`);
456
+ return from.map((f, i) => ({
457
+ from: f,
458
+ to: to[i]
459
+ }));
460
+ };
461
+ const inner = joinPath.slice(0, -1);
462
+ const last = joinPath[joinPath.length - 1];
463
+ const joins = inner.map((step, index) => {
464
+ const table = registry.getTable(step.table);
465
+ if (!table) throw new Error(`Join table not found: ${step.table}`);
466
+ const on = pairs(step).map(({ from, to }) => sql`${fromRef(index, from)} = ${sql.identifier(aliasFor(index))}.${sql.identifier(getColumnName(to))}`);
467
+ return sql`JOIN ${table} AS ${sql.identifier(aliasFor(index))} ON ${sql.join(on, sql` AND `)}`;
468
+ });
469
+ const targetColumn = (name) => {
470
+ const column = targetTable[getColumnName(name)];
471
+ if (!column) throw new Error(`Join step column '${name}' not found in the target table of this joinPath`);
472
+ return column;
473
+ };
474
+ const correlation = sql.join(pairs(last).map(({ from, to }) => sql`${fromRef(inner.length, from)} = ${targetColumn(to)}`), sql` AND `);
475
+ const joinsSql = joins.length > 0 ? sql` ${sql.join(joins, sql` `)}` : sql``;
476
+ return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
477
+ }
395
478
  /**
396
479
  * Build filter conditions from FilterValues
397
480
  */
@@ -1551,6 +1634,18 @@ var RelationService = class {
1551
1634
  const available = Object.keys(resolvedRelations).join(", ") || "(none)";
1552
1635
  throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
1553
1636
  }
1637
+ return this.countRelatedRows(parentCollection, parentId, relation, []);
1638
+ }
1639
+ /**
1640
+ * Count the target rows a parent reaches through `relation`, narrowed by
1641
+ * `additionalFilters` (conditions on the target table).
1642
+ *
1643
+ * Shared by the public count and by {@link isRelated}, so "how many children
1644
+ * does this parent have" and "is this row one of them" are answered by the
1645
+ * same join — a membership test that reconstructed the join separately would
1646
+ * be free to disagree with the listing it is supposed to gate.
1647
+ */
1648
+ async countRelatedRows(parentCollection, parentId, relation, additionalFilters) {
1554
1649
  const targetCollection = relation.target();
1555
1650
  const targetTable = getTableForCollection(targetCollection, this.registry);
1556
1651
  const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
@@ -1561,11 +1656,54 @@ var RelationService = class {
1561
1656
  if (!parentTable) throw new Error("Parent table not found");
1562
1657
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1563
1658
  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, []);
1659
+ query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, parsedParentId, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
1565
1660
  const result = await query;
1566
1661
  return Number(result[0]?.count || 0);
1567
1662
  }
1568
1663
  /**
1664
+ * Whether `targetId` is actually reachable from the parent named in `hop`.
1665
+ *
1666
+ * A nested address like `authors/1/posts/43` used to resolve to the target
1667
+ * collection and then match on the primary key alone, so the parent segment
1668
+ * decided nothing: the row came back, and was updated or deleted, whoever it
1669
+ * belonged to. Reads, updates and deletes now all gate on this.
1670
+ */
1671
+ async isRelated(hop, targetId) {
1672
+ const targetTable = getTableForCollection(hop.targetCollection, this.registry);
1673
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
1674
+ const parsedTargetId = parseIdValues(targetId, targetPks);
1675
+ const identity = targetPks.map((pk) => {
1676
+ const column = targetTable[pk.fieldName];
1677
+ if (!column) throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
1678
+ return eq(column, parsedTargetId[pk.fieldName]);
1679
+ });
1680
+ return await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity) > 0;
1681
+ }
1682
+ /**
1683
+ * Remove the junction row linking a parent to `targetId`, leaving the target
1684
+ * row itself alone.
1685
+ *
1686
+ * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
1687
+ * target is shared, so deleting the row would remove the tag from every other
1688
+ * post that uses it. It used to do exactly that — resolve the path to the
1689
+ * `tags` table and delete by primary key.
1690
+ */
1691
+ async unlinkRelatedEntity(tx, hop, targetId) {
1692
+ const through = hop.relation.through;
1693
+ if (!through) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
1694
+ const junctionTable = this.registry.getTable(through.table);
1695
+ if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
1696
+ const sourceJunctionColumn = junctionTable[through.sourceColumn];
1697
+ const targetJunctionColumn = junctionTable[through.targetColumn];
1698
+ if (!sourceJunctionColumn || !targetJunctionColumn) throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
1699
+ const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
1700
+ const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
1701
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
1702
+ const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
1703
+ await tx.delete(junctionTable).where(and(eq(sourceJunctionColumn, parsedParentId), eq(targetJunctionColumn, parsedTargetId)));
1704
+ logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
1705
+ }
1706
+ /**
1569
1707
  * Batch fetch related rows for multiple parent rows to avoid N+1 queries
1570
1708
  */
1571
1709
  async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation) {
@@ -2102,8 +2240,8 @@ var RelationService = class {
2102
2240
  [sourceJunctionColumn.name]: parentId,
2103
2241
  [targetJunctionColumn.name]: parsedNewEntityId
2104
2242
  };
2105
- await tx.insert(junctionTable).values(junctionData);
2106
- logger.info(`Created junction table entry for many-to-many relation '${relationKey}': ${JSON.stringify(junctionData)}`);
2243
+ await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
2244
+ logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
2107
2245
  } catch (error) {
2108
2246
  logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
2109
2247
  throw error;
@@ -2255,6 +2393,79 @@ function toRestRow(row, collection, registry) {
2255
2393
  return stripExcluded(flat, collection);
2256
2394
  }
2257
2395
  //#endregion
2396
+ //#region src/services/nested-path.ts
2397
+ /**
2398
+ * True when `path` addresses rows through a relation rather than a root
2399
+ * collection.
2400
+ *
2401
+ * Any separator at all counts — a root collection slug never contains one — so
2402
+ * a malformed path like `collection/id` is a *broken* nested path and gets
2403
+ * reported as one by {@link resolveNestedPath}, rather than being looked up as
2404
+ * a root collection whose slug happens to contain a slash.
2405
+ */
2406
+ function isNestedPath(path) {
2407
+ return path.includes("/");
2408
+ }
2409
+ function splitPathSegments(path) {
2410
+ return path.split("/").filter((s) => s && s !== "undefined");
2411
+ }
2412
+ /**
2413
+ * Walk a nested collection path down to the relation it ends in.
2414
+ *
2415
+ * Returns `undefined` for a plain root-collection path so callers can keep the
2416
+ * root case on its existing code path. Throws when the path is malformed, or
2417
+ * when a segment names a relation that does not exist — the same errors the
2418
+ * individual walks used to raise, with the available names attached.
2419
+ */
2420
+ function resolveNestedPath(path, registry) {
2421
+ if (!isNestedPath(path)) return void 0;
2422
+ const segments = splitPathSegments(path);
2423
+ if (segments.length < 3 || segments.length % 2 === 0) throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
2424
+ let parentCollection = getCollectionByPath(segments[0], registry);
2425
+ let parentId = segments[1];
2426
+ for (let i = 2; i < segments.length; i += 2) {
2427
+ const relationKey = segments[i];
2428
+ const resolvedRelations = resolveCollectionRelations(parentCollection);
2429
+ const relation = findRelation(resolvedRelations, relationKey);
2430
+ if (!relation) {
2431
+ const available = Object.keys(resolvedRelations).join(", ") || "(none)";
2432
+ throw new Error(`Relation '${relationKey}' not found in collection '${parentCollection.slug}'. Available relations: [${available}]`);
2433
+ }
2434
+ const targetCollection = relation.target();
2435
+ if (i === segments.length - 1) return {
2436
+ parentCollection,
2437
+ parentId,
2438
+ relationKey,
2439
+ relation,
2440
+ targetCollection
2441
+ };
2442
+ parentCollection = targetCollection;
2443
+ parentId = segments[i + 1];
2444
+ }
2445
+ throw new Error(`Unable to resolve path: ${path}`);
2446
+ }
2447
+ /**
2448
+ * A relation reached through a junction table — many-to-many, or a multi-hop
2449
+ * `joinPath`. The target row is shared with other parents, so writing "through"
2450
+ * such a path addresses the *link*, not the row.
2451
+ */
2452
+ function isJunctionBackedRelation(relation) {
2453
+ return Boolean(relation.through) || Boolean(relation.joinPath && relation.joinPath.length > 1);
2454
+ }
2455
+ /**
2456
+ * Reject a nested write whose final segment is a to-one relation.
2457
+ *
2458
+ * There is no column on the target row that records a to-one parent — the
2459
+ * foreign key lives on the *parent* table. The write path used to fall through
2460
+ * to `relation.localKey` here and stamp the parent's own FK column onto the
2461
+ * target row, which either raised an opaque "column does not exist" or, when a
2462
+ * column of that name happened to exist on the target, silently wrote the wrong
2463
+ * one.
2464
+ */
2465
+ function assertWritableThrough(hop, path) {
2466
+ if (hop.relation.cardinality !== "many") throw ApiError.badRequest(`"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on '${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set '${hop.relationKey}' on the parent instead.`, "RELATION_NOT_WRITABLE");
2467
+ }
2468
+ //#endregion
2258
2469
  //#region src/services/FetchService.ts
2259
2470
  /**
2260
2471
  * Service for handling all row read operations.
@@ -2386,10 +2597,11 @@ var FetchService = class {
2386
2597
  * Build db.query-compatible options from standard fetch options.
2387
2598
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
2388
2599
  */
2389
- buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig) {
2600
+ buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
2390
2601
  const queryOpts = {};
2391
2602
  if (withConfig) queryOpts.with = withConfig;
2392
2603
  const allConditions = [];
2604
+ if (scopeCondition) allConditions.push(scopeCondition);
2393
2605
  if (options.searchString) {
2394
2606
  const collection = getCollectionByPath(collectionPath, this.registry);
2395
2607
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
@@ -2447,9 +2659,49 @@ var FetchService = class {
2447
2659
  return [];
2448
2660
  }
2449
2661
  /**
2662
+ * Compile "rows reachable from this parent" into a `WHERE` condition on the
2663
+ * target table, so a nested listing can run as an ordinary collection query.
2664
+ */
2665
+ buildRelationScope(hop) {
2666
+ const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
2667
+ const parentIdInfo = parentPks[0];
2668
+ const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
2669
+ const parent = () => {
2670
+ const table = getTableForCollection(hop.parentCollection, this.registry);
2671
+ const idColumn = table[parentIdInfo.fieldName];
2672
+ if (!idColumn) throw new Error(`ID field '${parentIdInfo.fieldName}' not found in table for collection '${hop.parentCollection.slug}'`);
2673
+ return {
2674
+ table,
2675
+ idColumn
2676
+ };
2677
+ };
2678
+ const targetTable = getTableForCollection(hop.targetCollection, this.registry);
2679
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
2680
+ const targetIdColumn = targetTable[targetPks[0].fieldName];
2681
+ if (!targetIdColumn) throw new Error(`ID field '${targetPks[0].fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
2682
+ return DrizzleConditionBuilder.buildRelationScopeCondition(hop.relation, parent, parsedParentId, targetTable, targetIdColumn, this.registry);
2683
+ }
2684
+ /**
2685
+ * Whether `id` is actually reachable at `collectionPath`.
2686
+ *
2687
+ * Trivially true for a root path. For a nested one it is a real question:
2688
+ * the path resolves to the target collection, and matching on the primary
2689
+ * key alone made the parent segment decorative — `authors/1/posts/43`
2690
+ * returned post 43 whoever wrote it, and the REST layer's delete then
2691
+ * deleted it. A row that is not under this parent is reported as absent,
2692
+ * which is what a caller addressing it through the parent should see.
2693
+ */
2694
+ async isAddressableUnder(collectionPath, id) {
2695
+ if (!isNestedPath(collectionPath)) return true;
2696
+ const hop = resolveNestedPath(collectionPath, this.registry);
2697
+ if (!hop) return true;
2698
+ return this.relationService.isRelated(hop, id);
2699
+ }
2700
+ /**
2450
2701
  * Fetch a single row by ID
2451
2702
  */
2452
2703
  async fetchOne(collectionPath, id, databaseId) {
2704
+ if (!await this.isAddressableUnder(collectionPath, id)) return void 0;
2453
2705
  const collection = getCollectionByPath(collectionPath, this.registry);
2454
2706
  const table = getTableForCollection(collection, this.registry);
2455
2707
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2506,6 +2758,7 @@ var FetchService = class {
2506
2758
  * Unified method to fetch rows with optional search functionality
2507
2759
  */
2508
2760
  async fetchRowsWithConditions(collectionPath, options = {}) {
2761
+ const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
2509
2762
  const collection = getCollectionByPath(collectionPath, this.registry);
2510
2763
  const table = getTableForCollection(collection, this.registry);
2511
2764
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2517,7 +2770,7 @@ var FetchService = class {
2517
2770
  const withConfig = this.buildWithConfig(collection);
2518
2771
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
2519
2772
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
2520
- const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
2773
+ const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0, scopeCondition);
2521
2774
  return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
2522
2775
  } catch (e) {
2523
2776
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
@@ -2533,6 +2786,7 @@ var FetchService = class {
2533
2786
  _distance: vectorMeta.distanceSelect
2534
2787
  }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
2535
2788
  const allConditions = [];
2789
+ if (scopeCondition) allConditions.push(scopeCondition);
2536
2790
  if (options.searchString) {
2537
2791
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
2538
2792
  if (searchConditions.length === 0) return [];
@@ -2637,7 +2891,11 @@ var FetchService = class {
2637
2891
  * Fetch a collection of rows
2638
2892
  */
2639
2893
  async fetchCollection(collectionPath, options = {}) {
2640
- if (collectionPath.includes("/")) return this.fetchCollectionFromPath(collectionPath, options);
2894
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
2895
+ if (hop) return this.fetchRowsWithConditions(hop.targetCollection.slug, {
2896
+ ...options,
2897
+ relatedTo: hop
2898
+ });
2641
2899
  return this.fetchRowsWithConditions(collectionPath, options);
2642
2900
  }
2643
2901
  /**
@@ -2650,43 +2908,23 @@ var FetchService = class {
2650
2908
  });
2651
2909
  }
2652
2910
  /**
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
2911
  * Count rows in a collection
2676
2912
  */
2677
2913
  async count(collectionPath, options = {}) {
2678
- if (collectionPath.includes("/")) return this.countEntitiesFromPath(collectionPath, options);
2679
- const collection = getCollectionByPath(collectionPath, this.registry);
2914
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
2915
+ const effectivePath = hop ? hop.targetCollection.slug : collectionPath;
2916
+ const collection = getCollectionByPath(effectivePath, this.registry);
2680
2917
  const table = getTableForCollection(collection, this.registry);
2681
2918
  let query = this.db.select({ count: count() }).from(table).$dynamic();
2682
2919
  const allConditions = [];
2920
+ if (hop) allConditions.push(this.buildRelationScope(hop));
2683
2921
  if (options.searchString) {
2684
2922
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
2685
2923
  if (searchConditions.length === 0) return 0;
2686
2924
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
2687
2925
  }
2688
2926
  if (options.filter) {
2689
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
2927
+ const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
2690
2928
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
2691
2929
  }
2692
2930
  if (allConditions.length > 0) {
@@ -2697,27 +2935,6 @@ var FetchService = class {
2697
2935
  return Number(result[0]?.count || 0);
2698
2936
  }
2699
2937
  /**
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
2938
  * Check if a field value is unique
2722
2939
  */
2723
2940
  async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
@@ -2749,6 +2966,14 @@ var FetchService = class {
2749
2966
  * @param include - Array of relation keys to populate, or ["*"] for all
2750
2967
  */
2751
2968
  async fetchCollectionForRest(collectionPath, options = {}, include) {
2969
+ if (isNestedPath(collectionPath)) {
2970
+ const hop = resolveNestedPath(collectionPath, this.registry);
2971
+ if (hop) return this.fetchCollectionForRest(hop.targetCollection.slug, {
2972
+ ...options,
2973
+ relatedTo: hop
2974
+ }, include);
2975
+ }
2976
+ const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
2752
2977
  const collection = getCollectionByPath(collectionPath, this.registry);
2753
2978
  const table = getTableForCollection(collection, this.registry);
2754
2979
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2758,7 +2983,7 @@ var FetchService = class {
2758
2983
  const qb = this.getQueryBuilder(tableName);
2759
2984
  if (qb && !options.searchString && !options.vectorSearch) try {
2760
2985
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
2761
- const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
2986
+ const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition);
2762
2987
  const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
2763
2988
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
2764
2989
  return restRows;
@@ -2806,6 +3031,7 @@ var FetchService = class {
2806
3031
  * Fetch a single row with optional relation includes for REST API.
2807
3032
  */
2808
3033
  async fetchOneForRest(collectionPath, id, include, databaseId) {
3034
+ if (!await this.isAddressableUnder(collectionPath, id)) return null;
2809
3035
  const collection = getCollectionByPath(collectionPath, this.registry);
2810
3036
  const table = getTableForCollection(collection, this.registry);
2811
3037
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2874,6 +3100,7 @@ var FetchService = class {
2874
3100
  _distance: vectorMeta.distanceSelect
2875
3101
  }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
2876
3102
  const allConditions = [];
3103
+ if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
2877
3104
  if (options.searchString) {
2878
3105
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
2879
3106
  if (searchConditions.length === 0) return [];
@@ -3193,6 +3420,16 @@ var PersistService = class {
3193
3420
  * Delete an row by ID
3194
3421
  */
3195
3422
  async delete(collectionPath, id, _databaseId) {
3423
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
3424
+ if (hop) {
3425
+ assertWritableThrough(hop, collectionPath);
3426
+ if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
3427
+ if (isJunctionBackedRelation(hop.relation)) {
3428
+ if (!hop.relation.through) throw ApiError.badRequest(`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" directly if that is what you meant.`, "RELATION_NOT_UNLINKABLE");
3429
+ await this.relationService.unlinkRelatedEntity(this.db, hop, id);
3430
+ return;
3431
+ }
3432
+ }
3196
3433
  const collection = getCollectionByPath(collectionPath, this.registry);
3197
3434
  const table = getTableForCollection(collection, this.registry);
3198
3435
  const idInfoArray = getPrimaryKeys(collection, this.registry);
@@ -3213,6 +3450,29 @@ var PersistService = class {
3213
3450
  await this.db.delete(table);
3214
3451
  }
3215
3452
  /**
3453
+ * The column on the *target* table that records the parent, for a create
3454
+ * under a nested one-to-many path.
3455
+ *
3456
+ * Returns `undefined` when the link is not a column at all (a multi-hop
3457
+ * `joinPath`), so the caller writes the row without stamping anything.
3458
+ *
3459
+ * `relation.localKey` is deliberately not consulted: it names a column on
3460
+ * the *source* table. Falling back to it here — which is what this used to
3461
+ * do, and first — stamped the parent's own foreign key onto the child row.
3462
+ */
3463
+ resolveParentForeignKeyColumn(hop) {
3464
+ const { relation, relationKey, targetCollection } = hop;
3465
+ if (relation.foreignKeyOnTarget) return relation.foreignKeyOnTarget;
3466
+ if (relation.joinPath && relation.joinPath.length === 1) {
3467
+ const joinStep = relation.joinPath[0];
3468
+ const targetTableName = getTableName$1(targetCollection);
3469
+ if (joinStep.table !== targetTableName) logger.warn(`Join step for relation '${relationKey}' targets '${joinStep.table}', not the target table '${targetTableName}'.`);
3470
+ return DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to)[0];
3471
+ }
3472
+ if (relation.joinPath && relation.joinPath.length > 1) return;
3473
+ throw ApiError.badRequest(`Relation '${relationKey}' on '${hop.parentCollection.slug}' cannot be written through: it declares no \`foreignKeyOnTarget\` (the column on '${targetCollection.slug}' that records the parent) and no \`joinPath\`.`, "RELATION_NOT_WRITABLE");
3474
+ }
3475
+ /**
3216
3476
  * Save an row (create or update)
3217
3477
  *
3218
3478
  * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
@@ -3225,60 +3485,29 @@ var PersistService = class {
3225
3485
  let effectiveCollectionPath = collectionPath;
3226
3486
  const effectiveValues = { ...values };
3227
3487
  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
- }
3488
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
3489
+ if (hop) {
3490
+ assertWritableThrough(hop, collectionPath);
3491
+ effectiveCollectionPath = hop.targetCollection.slug;
3492
+ const parentIdForWrite = () => {
3493
+ const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
3494
+ return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
3495
+ };
3496
+ if (hop.relation.through) junctionTableInfo = {
3497
+ parentCollection: hop.parentCollection,
3498
+ parentId: parentIdForWrite(),
3499
+ relation: hop.relation,
3500
+ relationKey: hop.relationKey
3501
+ };
3502
+ else if (id !== void 0) {
3503
+ if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
3504
+ } else {
3505
+ const targetColumnName = this.resolveParentForeignKeyColumn(hop);
3506
+ if (targetColumnName) {
3507
+ const parsedParentId = parentIdForWrite();
3508
+ const existingValue = effectiveValues[targetColumnName];
3509
+ if (existingValue !== void 0 && existingValue !== null && existingValue !== parsedParentId) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
3510
+ effectiveValues[targetColumnName] = parsedParentId;
3282
3511
  }
3283
3512
  }
3284
3513
  }
@@ -3349,7 +3578,7 @@ var PersistService = class {
3349
3578
  }
3350
3579
  if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
3351
3580
  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);
3581
+ if (junctionTableInfo) await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
3353
3582
  return currentId;
3354
3583
  });
3355
3584
  } catch (error) {
@@ -5246,9 +5475,9 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5246
5475
  if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}("${colName}")`;
5247
5476
  else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid("${colName}")`;
5248
5477
  else if (stringProp.columnType === "uuid") columnDefinition = `uuid("${colName}")`;
5249
- else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) columnDefinition = `text("${colName}")`;
5250
5478
  else if (stringProp.columnType === "char") columnDefinition = `char("${colName}")`;
5251
- else columnDefinition = `varchar("${colName}")`;
5479
+ else if (stringProp.columnType === "varchar") columnDefinition = `varchar("${colName}")`;
5480
+ else columnDefinition = `text("${colName}")`;
5252
5481
  if (isIdProperty(propName, prop, collection)) columnDefinition += ".primaryKey()";
5253
5482
  if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
5254
5483
  if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
@@ -5334,7 +5563,7 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5334
5563
  const targetTableVar = getTableVarName(getTableName$1(targetCollection));
5335
5564
  const pkProp = getPrimaryKeyProp(targetCollection);
5336
5565
  const targetIdField = pkProp.name;
5337
- const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : pkProp.isUuid ? `uuid("${fkColumnName}")` : `varchar("${fkColumnName}")`;
5566
+ const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : pkProp.isUuid ? `uuid("${fkColumnName}")` : `text("${fkColumnName}")`;
5338
5567
  const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
5339
5568
  const required = prop.validation?.required;
5340
5569
  const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
@@ -5347,13 +5576,13 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5347
5576
  const refProp = prop;
5348
5577
  const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName$1(c) === refProp.path);
5349
5578
  if (!targetCollection) {
5350
- columnDefinition = `varchar("${colName}")`;
5579
+ columnDefinition = `text("${colName}")`;
5351
5580
  break;
5352
5581
  }
5353
5582
  const pkProp = getPrimaryKeyProp(targetCollection);
5354
5583
  const targetTableVar = getTableVarName(getTableName$1(targetCollection));
5355
5584
  const targetIdField = pkProp.name;
5356
- const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : pkProp.isUuid ? `uuid("${colName}")` : `varchar("${colName}")`;
5585
+ const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : pkProp.isUuid ? `uuid("${colName}")` : `text("${colName}")`;
5357
5586
  const required = prop.validation?.required;
5358
5587
  columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${`{ onDelete: "${required ? "cascade" : "set null"}" }`})`;
5359
5588
  if (required) columnDefinition += ".notNull()";
@@ -5529,8 +5758,8 @@ var generateSchema = async (collections, stripPolicies = false) => {
5529
5758
  const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
5530
5759
  const { sourceColumn, targetColumn } = relation.through;
5531
5760
  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";
5761
+ const sourceColType = isNumericId(sourceCollection) ? "integer" : getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "text";
5762
+ const targetColType = isNumericId(targetCollection) ? "integer" : getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "text";
5534
5763
  const sourceId = getPrimaryKeyName(sourceCollection);
5535
5764
  const targetId = getPrimaryKeyName(targetCollection);
5536
5765
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
@@ -5557,7 +5786,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5557
5786
  const columnString = getDrizzleColumn(propName, prop, collection, collections);
5558
5787
  if (columnString) columns.add(columnString);
5559
5788
  });
5560
- if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: varchar(\"id\").primaryKey()");
5789
+ if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: text(\"id\").primaryKey()");
5561
5790
  schemaContent += `${Array.from(columns).join(",\n")}`;
5562
5791
  const securityRules = getEffectiveSecurityRules(collection);
5563
5792
  if (!stripPolicies && securityRules.length > 0) {
@@ -5737,6 +5966,58 @@ var main = () => {
5737
5966
  };
5738
5967
  if (import.meta.url.endsWith(process.argv[1])) main();
5739
5968
  //#endregion
5969
+ //#region src/services/cdc/junction-tables.ts
5970
+ /**
5971
+ * Every junction table reachable from a registered collection, once per
5972
+ * relation that uses it.
5973
+ *
5974
+ * A junction is listed once per *direction* when both sides declare it, because
5975
+ * each direction addresses a different child list: `posts/1/tags` and
5976
+ * `tags/t/posts` both change when one link is written.
5977
+ */
5978
+ function collectJunctionLinks(registry) {
5979
+ const links = [];
5980
+ const seen = /* @__PURE__ */ new Set();
5981
+ for (const collection of registry.getCollections()) {
5982
+ let relations;
5983
+ try {
5984
+ relations = resolveCollectionRelations(collection);
5985
+ } catch {
5986
+ continue;
5987
+ }
5988
+ for (const [relationKey, relation] of Object.entries(relations)) {
5989
+ const through = relation?.through;
5990
+ if (!through?.table) continue;
5991
+ const key = `${collection.slug}::${relationKey}::${through.table}`;
5992
+ if (seen.has(key)) continue;
5993
+ seen.add(key);
5994
+ links.push({
5995
+ schema: collection.schema ?? "public",
5996
+ table: through.table,
5997
+ parentCollection: collection,
5998
+ relationKey,
5999
+ sourceColumn: through.sourceColumn,
6000
+ targetColumn: through.targetColumn
6001
+ });
6002
+ }
6003
+ }
6004
+ return links;
6005
+ }
6006
+ /**
6007
+ * Index {@link collectJunctionLinks} by table, under both the qualified and the
6008
+ * bare name — a change event carries whatever the trigger reports, and a
6009
+ * collection need not declare a schema.
6010
+ */
6011
+ function buildJunctionLinkMap(registry) {
6012
+ const map = /* @__PURE__ */ new Map();
6013
+ for (const link of collectJunctionLinks(registry)) for (const key of [`${link.schema}.${link.table}`, link.table]) {
6014
+ const existing = map.get(key);
6015
+ if (existing) existing.push(link);
6016
+ else map.set(key, [link]);
6017
+ }
6018
+ return map;
6019
+ }
6020
+ //#endregion
5740
6021
  //#region src/services/cdc/trigger-cdc.ts
5741
6022
  /**
5742
6023
  * Trigger-based Change Data Capture (CDC).
@@ -6861,6 +7142,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
6861
7142
  cdcListener;
6862
7143
  /** Whether database-level CDC is the active cross-instance change source. */
6863
7144
  cdcActive = false;
7145
+ /** Junction table → the child lists its rows belong to, built when CDC starts. */
7146
+ junctionLinkMap;
6864
7147
  /** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
6865
7148
  cdcTableMap;
6866
7149
  /**
@@ -7977,6 +8260,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7977
8260
  return;
7978
8261
  }
7979
8262
  this.cdcTableMap = this.buildCdcTableMap();
8263
+ this.junctionLinkMap = buildJunctionLinkMap(this.registry);
7980
8264
  this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
7981
8265
  try {
7982
8266
  await this.cdcListener.start();
@@ -7984,6 +8268,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7984
8268
  await this.cdcListener.stop().catch(() => {});
7985
8269
  this.cdcListener = void 0;
7986
8270
  this.cdcTableMap = void 0;
8271
+ this.junctionLinkMap = void 0;
7987
8272
  throw err;
7988
8273
  }
7989
8274
  this.cdcActive = true;
@@ -7997,6 +8282,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7997
8282
  this.cdcListener = void 0;
7998
8283
  }
7999
8284
  this.cdcTableMap = void 0;
8285
+ this.junctionLinkMap = void 0;
8000
8286
  this.recentAppEmits.clear();
8001
8287
  }
8002
8288
  /**
@@ -8033,6 +8319,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8033
8319
  async handleCdcEvent(event) {
8034
8320
  const collection = this.resolveCollectionForTable(event.schema, event.table);
8035
8321
  if (!collection) {
8322
+ if (await this.handleJunctionCdcEvent(event)) return;
8036
8323
  this.debugLog(`📡 [CDC] Ignoring change on unmapped table ${event.schema}.${event.table}`);
8037
8324
  return;
8038
8325
  }
@@ -8042,6 +8329,38 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8042
8329
  const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
8043
8330
  await this.notifyUpdate(path, id, row, databaseId, false, "cdc");
8044
8331
  }
8332
+ /**
8333
+ * Deliver a change on a many-to-many junction table as a change to the child
8334
+ * lists it belongs to.
8335
+ *
8336
+ * Linking a tag to a post writes only `posts_tags`. That table backs no
8337
+ * collection, so change capture dropped the event as unmapped and the
8338
+ * subscribers of `posts/1/tags` never heard about it — every other write in
8339
+ * the system was realtime, and this one silently was not. The junction row
8340
+ * carries both ids, so it names its own paths exactly.
8341
+ *
8342
+ * Notifies the nested path rather than either endpoint collection, because
8343
+ * invalidation walks *parent* paths and never child ones: telling `tags` it
8344
+ * changed would not reach a subscription on `posts/1/tags`.
8345
+ *
8346
+ * Returns whether the table was recognised as a junction.
8347
+ */
8348
+ async handleJunctionCdcEvent(event) {
8349
+ const links = this.junctionLinkMap?.get(`${event.schema}.${event.table}`) ?? this.junctionLinkMap?.get(event.table);
8350
+ if (!links?.length) return false;
8351
+ for (const link of links) {
8352
+ const sourceId = event.row?.[link.sourceColumn];
8353
+ const targetId = event.row?.[link.targetColumn];
8354
+ if (sourceId === void 0 || sourceId === null || targetId === void 0 || targetId === null) {
8355
+ this.debugLog(`📡 [CDC] Junction row on ${event.table} is missing '${link.sourceColumn}'/'${link.targetColumn}' — skipping.`);
8356
+ continue;
8357
+ }
8358
+ const path = `${link.parentCollection.slug}/${String(sourceId)}/${link.relationKey}`;
8359
+ const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
8360
+ await this.notifyUpdate(path, String(targetId), row, link.parentCollection.databaseId, false, "cdc");
8361
+ }
8362
+ return true;
8363
+ }
8045
8364
  /** Compute the canonical (possibly composite) id string from a captured row. */
8046
8365
  extractIdFromCdcRow(collection, row) {
8047
8366
  return deriveRowAddress(row, collection, this.registry) || "*";
@@ -20026,7 +20345,7 @@ function createPostgresBootstrapper(pgConfig) {
20026
20345
  } catch {}
20027
20346
  const message = `
20028
20347
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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`;
20348
+ ❌ 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
20349
  logger.error(message);
20031
20350
  throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
20032
20351
  }
@@ -20111,10 +20430,15 @@ function createPostgresBootstrapper(pgConfig) {
20111
20430
  const cdcRunSql = async (text) => {
20112
20431
  return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
20113
20432
  };
20114
- await provisionTriggerCdc(cdcRunSql, registry.getCollections().map((c) => ({
20433
+ const cdcTables = registry.getCollections().map((c) => ({
20115
20434
  schema: c.schema ?? "public",
20116
20435
  table: getTableName$1(c)
20117
- })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
20436
+ })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table));
20437
+ for (const link of collectJunctionLinks(registry)) cdcTables.push({
20438
+ schema: link.schema,
20439
+ table: link.table
20440
+ });
20441
+ await provisionTriggerCdc(cdcRunSql, cdcTables);
20118
20442
  await realtimeService.enableCdc(directUrl);
20119
20443
  cdcEnabled = true;
20120
20444
  provisionCdcForTables = async (tables) => {
@@ -20252,7 +20576,7 @@ function createPostgresBootstrapper(pgConfig) {
20252
20576
  */
20253
20577
  async ensureCollectionSchema(collections, driverResult, log) {
20254
20578
  const internals = driverResult.internals;
20255
- const { ensureCollectionTables } = await import("./ensure-collection-tables-CNlIONzj.js");
20579
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-BVvtkkRm.js");
20256
20580
  return { applied: (await ensureCollectionTables({ async query(text) {
20257
20581
  const result = await internals.db.execute(sql.raw(text));
20258
20582
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };