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

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.
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,85 @@ 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, targetIdColumn, registry);
419
+ }
420
+ if (relation.through) {
421
+ const junctionTable = registry.getTable(relation.through.table);
422
+ if (!junctionTable) throw new Error(`Junction table not found: ${relation.through.table}`);
423
+ const sourceCol = junctionTable[relation.through.sourceColumn];
424
+ const targetCol = junctionTable[relation.through.targetColumn];
425
+ if (!sourceCol || !targetCol) throw new Error(`Junction columns '${relation.through.sourceColumn}'/'${relation.through.targetColumn}' not found in '${relation.through.table}'`);
426
+ return sql`EXISTS (SELECT 1 FROM ${junctionTable} WHERE ${targetCol} = ${targetIdColumn} AND ${sourceCol} = ${parentId})`;
427
+ }
428
+ if (relation.foreignKeyOnTarget) {
429
+ const fkColumn = targetTable[relation.foreignKeyOnTarget];
430
+ if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'. A many-to-many relation needs \`through\` instead.`);
431
+ return eq(fkColumn, parentId);
432
+ }
433
+ if (relation.localKey) {
434
+ const { table, idColumn } = parent();
435
+ return sql`${targetIdColumn} = (SELECT ${sql.identifier(relation.localKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
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, targetIdColumn, 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 lastPairs = pairs(last);
470
+ const correlation = lastPairs.length === 1 ? sql`${fromRef(inner.length, lastPairs[0].from)} = ${targetIdColumn}` : sql.join(lastPairs.map(({ from, to }) => sql`${fromRef(inner.length, from)} = ${sql.identifier(getColumnName(to))}`), sql` AND `);
471
+ const joinsSql = joins.length > 0 ? sql` ${sql.join(joins, sql` `)}` : sql``;
472
+ return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
473
+ }
395
474
  /**
396
475
  * Build filter conditions from FilterValues
397
476
  */
@@ -1551,6 +1630,18 @@ var RelationService = class {
1551
1630
  const available = Object.keys(resolvedRelations).join(", ") || "(none)";
1552
1631
  throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
1553
1632
  }
1633
+ return this.countRelatedRows(parentCollection, parentId, relation, []);
1634
+ }
1635
+ /**
1636
+ * Count the target rows a parent reaches through `relation`, narrowed by
1637
+ * `additionalFilters` (conditions on the target table).
1638
+ *
1639
+ * Shared by the public count and by {@link isRelated}, so "how many children
1640
+ * does this parent have" and "is this row one of them" are answered by the
1641
+ * same join — a membership test that reconstructed the join separately would
1642
+ * be free to disagree with the listing it is supposed to gate.
1643
+ */
1644
+ async countRelatedRows(parentCollection, parentId, relation, additionalFilters) {
1554
1645
  const targetCollection = relation.target();
1555
1646
  const targetTable = getTableForCollection(targetCollection, this.registry);
1556
1647
  const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
@@ -1561,11 +1652,54 @@ var RelationService = class {
1561
1652
  if (!parentTable) throw new Error("Parent table not found");
1562
1653
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1563
1654
  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, []);
1655
+ query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, parsedParentId, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
1565
1656
  const result = await query;
1566
1657
  return Number(result[0]?.count || 0);
1567
1658
  }
1568
1659
  /**
1660
+ * Whether `targetId` is actually reachable from the parent named in `hop`.
1661
+ *
1662
+ * A nested address like `authors/1/posts/43` used to resolve to the target
1663
+ * collection and then match on the primary key alone, so the parent segment
1664
+ * decided nothing: the row came back, and was updated or deleted, whoever it
1665
+ * belonged to. Reads, updates and deletes now all gate on this.
1666
+ */
1667
+ async isRelated(hop, targetId) {
1668
+ const targetTable = getTableForCollection(hop.targetCollection, this.registry);
1669
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
1670
+ const parsedTargetId = parseIdValues(targetId, targetPks);
1671
+ const identity = targetPks.map((pk) => {
1672
+ const column = targetTable[pk.fieldName];
1673
+ if (!column) throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
1674
+ return eq(column, parsedTargetId[pk.fieldName]);
1675
+ });
1676
+ return await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity) > 0;
1677
+ }
1678
+ /**
1679
+ * Remove the junction row linking a parent to `targetId`, leaving the target
1680
+ * row itself alone.
1681
+ *
1682
+ * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
1683
+ * target is shared, so deleting the row would remove the tag from every other
1684
+ * post that uses it. It used to do exactly that — resolve the path to the
1685
+ * `tags` table and delete by primary key.
1686
+ */
1687
+ async unlinkRelatedEntity(tx, hop, targetId) {
1688
+ const through = hop.relation.through;
1689
+ if (!through) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
1690
+ const junctionTable = this.registry.getTable(through.table);
1691
+ if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
1692
+ const sourceJunctionColumn = junctionTable[through.sourceColumn];
1693
+ const targetJunctionColumn = junctionTable[through.targetColumn];
1694
+ if (!sourceJunctionColumn || !targetJunctionColumn) throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
1695
+ const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
1696
+ const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
1697
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
1698
+ const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
1699
+ await tx.delete(junctionTable).where(and(eq(sourceJunctionColumn, parsedParentId), eq(targetJunctionColumn, parsedTargetId)));
1700
+ logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
1701
+ }
1702
+ /**
1569
1703
  * Batch fetch related rows for multiple parent rows to avoid N+1 queries
1570
1704
  */
1571
1705
  async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation) {
@@ -2102,8 +2236,8 @@ var RelationService = class {
2102
2236
  [sourceJunctionColumn.name]: parentId,
2103
2237
  [targetJunctionColumn.name]: parsedNewEntityId
2104
2238
  };
2105
- await tx.insert(junctionTable).values(junctionData);
2106
- logger.info(`Created junction table entry for many-to-many relation '${relationKey}': ${JSON.stringify(junctionData)}`);
2239
+ await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
2240
+ logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
2107
2241
  } catch (error) {
2108
2242
  logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
2109
2243
  throw error;
@@ -2255,6 +2389,79 @@ function toRestRow(row, collection, registry) {
2255
2389
  return stripExcluded(flat, collection);
2256
2390
  }
2257
2391
  //#endregion
2392
+ //#region src/services/nested-path.ts
2393
+ /**
2394
+ * True when `path` addresses rows through a relation rather than a root
2395
+ * collection.
2396
+ *
2397
+ * Any separator at all counts — a root collection slug never contains one — so
2398
+ * a malformed path like `collection/id` is a *broken* nested path and gets
2399
+ * reported as one by {@link resolveNestedPath}, rather than being looked up as
2400
+ * a root collection whose slug happens to contain a slash.
2401
+ */
2402
+ function isNestedPath(path) {
2403
+ return path.includes("/");
2404
+ }
2405
+ function splitPathSegments(path) {
2406
+ return path.split("/").filter((s) => s && s !== "undefined");
2407
+ }
2408
+ /**
2409
+ * Walk a nested collection path down to the relation it ends in.
2410
+ *
2411
+ * Returns `undefined` for a plain root-collection path so callers can keep the
2412
+ * root case on its existing code path. Throws when the path is malformed, or
2413
+ * when a segment names a relation that does not exist — the same errors the
2414
+ * individual walks used to raise, with the available names attached.
2415
+ */
2416
+ function resolveNestedPath(path, registry) {
2417
+ if (!isNestedPath(path)) return void 0;
2418
+ const segments = splitPathSegments(path);
2419
+ if (segments.length < 3 || segments.length % 2 === 0) throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
2420
+ let parentCollection = getCollectionByPath(segments[0], registry);
2421
+ let parentId = segments[1];
2422
+ for (let i = 2; i < segments.length; i += 2) {
2423
+ const relationKey = segments[i];
2424
+ const resolvedRelations = resolveCollectionRelations(parentCollection);
2425
+ const relation = findRelation(resolvedRelations, relationKey);
2426
+ if (!relation) {
2427
+ const available = Object.keys(resolvedRelations).join(", ") || "(none)";
2428
+ throw new Error(`Relation '${relationKey}' not found in collection '${parentCollection.slug}'. Available relations: [${available}]`);
2429
+ }
2430
+ const targetCollection = relation.target();
2431
+ if (i === segments.length - 1) return {
2432
+ parentCollection,
2433
+ parentId,
2434
+ relationKey,
2435
+ relation,
2436
+ targetCollection
2437
+ };
2438
+ parentCollection = targetCollection;
2439
+ parentId = segments[i + 1];
2440
+ }
2441
+ throw new Error(`Unable to resolve path: ${path}`);
2442
+ }
2443
+ /**
2444
+ * A relation reached through a junction table — many-to-many, or a multi-hop
2445
+ * `joinPath`. The target row is shared with other parents, so writing "through"
2446
+ * such a path addresses the *link*, not the row.
2447
+ */
2448
+ function isJunctionBackedRelation(relation) {
2449
+ return Boolean(relation.through) || Boolean(relation.joinPath && relation.joinPath.length > 1);
2450
+ }
2451
+ /**
2452
+ * Reject a nested write whose final segment is a to-one relation.
2453
+ *
2454
+ * There is no column on the target row that records a to-one parent — the
2455
+ * foreign key lives on the *parent* table. The write path used to fall through
2456
+ * to `relation.localKey` here and stamp the parent's own FK column onto the
2457
+ * target row, which either raised an opaque "column does not exist" or, when a
2458
+ * column of that name happened to exist on the target, silently wrote the wrong
2459
+ * one.
2460
+ */
2461
+ function assertWritableThrough(hop, path) {
2462
+ if (hop.relation.cardinality !== "many") throw ApiError.badRequest(`"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on '${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set '${hop.relationKey}' on the parent instead.`, "RELATION_NOT_WRITABLE");
2463
+ }
2464
+ //#endregion
2258
2465
  //#region src/services/FetchService.ts
2259
2466
  /**
2260
2467
  * Service for handling all row read operations.
@@ -2386,10 +2593,11 @@ var FetchService = class {
2386
2593
  * Build db.query-compatible options from standard fetch options.
2387
2594
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
2388
2595
  */
2389
- buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig) {
2596
+ buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
2390
2597
  const queryOpts = {};
2391
2598
  if (withConfig) queryOpts.with = withConfig;
2392
2599
  const allConditions = [];
2600
+ if (scopeCondition) allConditions.push(scopeCondition);
2393
2601
  if (options.searchString) {
2394
2602
  const collection = getCollectionByPath(collectionPath, this.registry);
2395
2603
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
@@ -2447,9 +2655,49 @@ var FetchService = class {
2447
2655
  return [];
2448
2656
  }
2449
2657
  /**
2658
+ * Compile "rows reachable from this parent" into a `WHERE` condition on the
2659
+ * target table, so a nested listing can run as an ordinary collection query.
2660
+ */
2661
+ buildRelationScope(hop) {
2662
+ const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
2663
+ const parentIdInfo = parentPks[0];
2664
+ const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
2665
+ const parent = () => {
2666
+ const table = getTableForCollection(hop.parentCollection, this.registry);
2667
+ const idColumn = table[parentIdInfo.fieldName];
2668
+ if (!idColumn) throw new Error(`ID field '${parentIdInfo.fieldName}' not found in table for collection '${hop.parentCollection.slug}'`);
2669
+ return {
2670
+ table,
2671
+ idColumn
2672
+ };
2673
+ };
2674
+ const targetTable = getTableForCollection(hop.targetCollection, this.registry);
2675
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
2676
+ const targetIdColumn = targetTable[targetPks[0].fieldName];
2677
+ if (!targetIdColumn) throw new Error(`ID field '${targetPks[0].fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
2678
+ return DrizzleConditionBuilder.buildRelationScopeCondition(hop.relation, parent, parsedParentId, targetTable, targetIdColumn, this.registry);
2679
+ }
2680
+ /**
2681
+ * Whether `id` is actually reachable at `collectionPath`.
2682
+ *
2683
+ * Trivially true for a root path. For a nested one it is a real question:
2684
+ * the path resolves to the target collection, and matching on the primary
2685
+ * key alone made the parent segment decorative — `authors/1/posts/43`
2686
+ * returned post 43 whoever wrote it, and the REST layer's delete then
2687
+ * deleted it. A row that is not under this parent is reported as absent,
2688
+ * which is what a caller addressing it through the parent should see.
2689
+ */
2690
+ async isAddressableUnder(collectionPath, id) {
2691
+ if (!isNestedPath(collectionPath)) return true;
2692
+ const hop = resolveNestedPath(collectionPath, this.registry);
2693
+ if (!hop) return true;
2694
+ return this.relationService.isRelated(hop, id);
2695
+ }
2696
+ /**
2450
2697
  * Fetch a single row by ID
2451
2698
  */
2452
2699
  async fetchOne(collectionPath, id, databaseId) {
2700
+ if (!await this.isAddressableUnder(collectionPath, id)) return void 0;
2453
2701
  const collection = getCollectionByPath(collectionPath, this.registry);
2454
2702
  const table = getTableForCollection(collection, this.registry);
2455
2703
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2506,6 +2754,7 @@ var FetchService = class {
2506
2754
  * Unified method to fetch rows with optional search functionality
2507
2755
  */
2508
2756
  async fetchRowsWithConditions(collectionPath, options = {}) {
2757
+ const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
2509
2758
  const collection = getCollectionByPath(collectionPath, this.registry);
2510
2759
  const table = getTableForCollection(collection, this.registry);
2511
2760
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2517,7 +2766,7 @@ var FetchService = class {
2517
2766
  const withConfig = this.buildWithConfig(collection);
2518
2767
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
2519
2768
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
2520
- const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
2769
+ const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0, scopeCondition);
2521
2770
  return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
2522
2771
  } catch (e) {
2523
2772
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
@@ -2533,6 +2782,7 @@ var FetchService = class {
2533
2782
  _distance: vectorMeta.distanceSelect
2534
2783
  }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
2535
2784
  const allConditions = [];
2785
+ if (scopeCondition) allConditions.push(scopeCondition);
2536
2786
  if (options.searchString) {
2537
2787
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
2538
2788
  if (searchConditions.length === 0) return [];
@@ -2637,7 +2887,11 @@ var FetchService = class {
2637
2887
  * Fetch a collection of rows
2638
2888
  */
2639
2889
  async fetchCollection(collectionPath, options = {}) {
2640
- if (collectionPath.includes("/")) return this.fetchCollectionFromPath(collectionPath, options);
2890
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
2891
+ if (hop) return this.fetchRowsWithConditions(hop.targetCollection.slug, {
2892
+ ...options,
2893
+ relatedTo: hop
2894
+ });
2641
2895
  return this.fetchRowsWithConditions(collectionPath, options);
2642
2896
  }
2643
2897
  /**
@@ -2650,43 +2904,23 @@ var FetchService = class {
2650
2904
  });
2651
2905
  }
2652
2906
  /**
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
2907
  * Count rows in a collection
2676
2908
  */
2677
2909
  async count(collectionPath, options = {}) {
2678
- if (collectionPath.includes("/")) return this.countEntitiesFromPath(collectionPath, options);
2679
- const collection = getCollectionByPath(collectionPath, this.registry);
2910
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
2911
+ const effectivePath = hop ? hop.targetCollection.slug : collectionPath;
2912
+ const collection = getCollectionByPath(effectivePath, this.registry);
2680
2913
  const table = getTableForCollection(collection, this.registry);
2681
2914
  let query = this.db.select({ count: count() }).from(table).$dynamic();
2682
2915
  const allConditions = [];
2916
+ if (hop) allConditions.push(this.buildRelationScope(hop));
2683
2917
  if (options.searchString) {
2684
2918
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
2685
2919
  if (searchConditions.length === 0) return 0;
2686
2920
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
2687
2921
  }
2688
2922
  if (options.filter) {
2689
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
2923
+ const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
2690
2924
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
2691
2925
  }
2692
2926
  if (allConditions.length > 0) {
@@ -2697,27 +2931,6 @@ var FetchService = class {
2697
2931
  return Number(result[0]?.count || 0);
2698
2932
  }
2699
2933
  /**
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
2934
  * Check if a field value is unique
2722
2935
  */
2723
2936
  async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
@@ -2749,6 +2962,14 @@ var FetchService = class {
2749
2962
  * @param include - Array of relation keys to populate, or ["*"] for all
2750
2963
  */
2751
2964
  async fetchCollectionForRest(collectionPath, options = {}, include) {
2965
+ if (isNestedPath(collectionPath)) {
2966
+ const hop = resolveNestedPath(collectionPath, this.registry);
2967
+ if (hop) return this.fetchCollectionForRest(hop.targetCollection.slug, {
2968
+ ...options,
2969
+ relatedTo: hop
2970
+ }, include);
2971
+ }
2972
+ const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
2752
2973
  const collection = getCollectionByPath(collectionPath, this.registry);
2753
2974
  const table = getTableForCollection(collection, this.registry);
2754
2975
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2758,7 +2979,7 @@ var FetchService = class {
2758
2979
  const qb = this.getQueryBuilder(tableName);
2759
2980
  if (qb && !options.searchString && !options.vectorSearch) try {
2760
2981
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
2761
- const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
2982
+ const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition);
2762
2983
  const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
2763
2984
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
2764
2985
  return restRows;
@@ -2806,6 +3027,7 @@ var FetchService = class {
2806
3027
  * Fetch a single row with optional relation includes for REST API.
2807
3028
  */
2808
3029
  async fetchOneForRest(collectionPath, id, include, databaseId) {
3030
+ if (!await this.isAddressableUnder(collectionPath, id)) return null;
2809
3031
  const collection = getCollectionByPath(collectionPath, this.registry);
2810
3032
  const table = getTableForCollection(collection, this.registry);
2811
3033
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -2874,6 +3096,7 @@ var FetchService = class {
2874
3096
  _distance: vectorMeta.distanceSelect
2875
3097
  }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
2876
3098
  const allConditions = [];
3099
+ if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
2877
3100
  if (options.searchString) {
2878
3101
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
2879
3102
  if (searchConditions.length === 0) return [];
@@ -3193,6 +3416,16 @@ var PersistService = class {
3193
3416
  * Delete an row by ID
3194
3417
  */
3195
3418
  async delete(collectionPath, id, _databaseId) {
3419
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
3420
+ if (hop) {
3421
+ assertWritableThrough(hop, collectionPath);
3422
+ if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
3423
+ if (isJunctionBackedRelation(hop.relation)) {
3424
+ if (!hop.relation.through) throw ApiError.badRequest(`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" directly if that is what you meant.`, "RELATION_NOT_UNLINKABLE");
3425
+ await this.relationService.unlinkRelatedEntity(this.db, hop, id);
3426
+ return;
3427
+ }
3428
+ }
3196
3429
  const collection = getCollectionByPath(collectionPath, this.registry);
3197
3430
  const table = getTableForCollection(collection, this.registry);
3198
3431
  const idInfoArray = getPrimaryKeys(collection, this.registry);
@@ -3213,6 +3446,29 @@ var PersistService = class {
3213
3446
  await this.db.delete(table);
3214
3447
  }
3215
3448
  /**
3449
+ * The column on the *target* table that records the parent, for a create
3450
+ * under a nested one-to-many path.
3451
+ *
3452
+ * Returns `undefined` when the link is not a column at all (a multi-hop
3453
+ * `joinPath`), so the caller writes the row without stamping anything.
3454
+ *
3455
+ * `relation.localKey` is deliberately not consulted: it names a column on
3456
+ * the *source* table. Falling back to it here — which is what this used to
3457
+ * do, and first — stamped the parent's own foreign key onto the child row.
3458
+ */
3459
+ resolveParentForeignKeyColumn(hop) {
3460
+ const { relation, relationKey, targetCollection } = hop;
3461
+ if (relation.foreignKeyOnTarget) return relation.foreignKeyOnTarget;
3462
+ if (relation.joinPath && relation.joinPath.length === 1) {
3463
+ const joinStep = relation.joinPath[0];
3464
+ const targetTableName = getTableName$1(targetCollection);
3465
+ if (joinStep.table !== targetTableName) logger.warn(`Join step for relation '${relationKey}' targets '${joinStep.table}', not the target table '${targetTableName}'.`);
3466
+ return DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to)[0];
3467
+ }
3468
+ if (relation.joinPath && relation.joinPath.length > 1) return;
3469
+ throw ApiError.badRequest(`Relation '${relationKey}' on '${hop.parentCollection.slug}' cannot be written through: it declares no \`foreignKeyOnTarget\` (the column on '${targetCollection.slug}' that records the parent) and no \`joinPath\`.`, "RELATION_NOT_WRITABLE");
3470
+ }
3471
+ /**
3216
3472
  * Save an row (create or update)
3217
3473
  *
3218
3474
  * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
@@ -3225,60 +3481,29 @@ var PersistService = class {
3225
3481
  let effectiveCollectionPath = collectionPath;
3226
3482
  const effectiveValues = { ...values };
3227
3483
  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
- }
3484
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
3485
+ if (hop) {
3486
+ assertWritableThrough(hop, collectionPath);
3487
+ effectiveCollectionPath = hop.targetCollection.slug;
3488
+ const parentIdForWrite = () => {
3489
+ const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
3490
+ return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
3491
+ };
3492
+ if (hop.relation.through) junctionTableInfo = {
3493
+ parentCollection: hop.parentCollection,
3494
+ parentId: parentIdForWrite(),
3495
+ relation: hop.relation,
3496
+ relationKey: hop.relationKey
3497
+ };
3498
+ else if (id !== void 0) {
3499
+ if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
3500
+ } else {
3501
+ const targetColumnName = this.resolveParentForeignKeyColumn(hop);
3502
+ if (targetColumnName) {
3503
+ const parsedParentId = parentIdForWrite();
3504
+ const existingValue = effectiveValues[targetColumnName];
3505
+ if (existingValue !== void 0 && existingValue !== null && existingValue !== parsedParentId) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
3506
+ effectiveValues[targetColumnName] = parsedParentId;
3282
3507
  }
3283
3508
  }
3284
3509
  }
@@ -3349,7 +3574,7 @@ var PersistService = class {
3349
3574
  }
3350
3575
  if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
3351
3576
  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);
3577
+ if (junctionTableInfo) await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
3353
3578
  return currentId;
3354
3579
  });
3355
3580
  } catch (error) {
@@ -5246,9 +5471,9 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5246
5471
  if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}("${colName}")`;
5247
5472
  else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid("${colName}")`;
5248
5473
  else if (stringProp.columnType === "uuid") columnDefinition = `uuid("${colName}")`;
5249
- else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) columnDefinition = `text("${colName}")`;
5250
5474
  else if (stringProp.columnType === "char") columnDefinition = `char("${colName}")`;
5251
- else columnDefinition = `varchar("${colName}")`;
5475
+ else if (stringProp.columnType === "varchar") columnDefinition = `varchar("${colName}")`;
5476
+ else columnDefinition = `text("${colName}")`;
5252
5477
  if (isIdProperty(propName, prop, collection)) columnDefinition += ".primaryKey()";
5253
5478
  if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
5254
5479
  if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
@@ -5334,7 +5559,7 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5334
5559
  const targetTableVar = getTableVarName(getTableName$1(targetCollection));
5335
5560
  const pkProp = getPrimaryKeyProp(targetCollection);
5336
5561
  const targetIdField = pkProp.name;
5337
- const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : pkProp.isUuid ? `uuid("${fkColumnName}")` : `varchar("${fkColumnName}")`;
5562
+ const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : pkProp.isUuid ? `uuid("${fkColumnName}")` : `text("${fkColumnName}")`;
5338
5563
  const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
5339
5564
  const required = prop.validation?.required;
5340
5565
  const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
@@ -5347,13 +5572,13 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5347
5572
  const refProp = prop;
5348
5573
  const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName$1(c) === refProp.path);
5349
5574
  if (!targetCollection) {
5350
- columnDefinition = `varchar("${colName}")`;
5575
+ columnDefinition = `text("${colName}")`;
5351
5576
  break;
5352
5577
  }
5353
5578
  const pkProp = getPrimaryKeyProp(targetCollection);
5354
5579
  const targetTableVar = getTableVarName(getTableName$1(targetCollection));
5355
5580
  const targetIdField = pkProp.name;
5356
- const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : pkProp.isUuid ? `uuid("${colName}")` : `varchar("${colName}")`;
5581
+ const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : pkProp.isUuid ? `uuid("${colName}")` : `text("${colName}")`;
5357
5582
  const required = prop.validation?.required;
5358
5583
  columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${`{ onDelete: "${required ? "cascade" : "set null"}" }`})`;
5359
5584
  if (required) columnDefinition += ".notNull()";
@@ -5529,8 +5754,8 @@ var generateSchema = async (collections, stripPolicies = false) => {
5529
5754
  const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
5530
5755
  const { sourceColumn, targetColumn } = relation.through;
5531
5756
  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";
5757
+ const sourceColType = isNumericId(sourceCollection) ? "integer" : getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "text";
5758
+ const targetColType = isNumericId(targetCollection) ? "integer" : getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "text";
5534
5759
  const sourceId = getPrimaryKeyName(sourceCollection);
5535
5760
  const targetId = getPrimaryKeyName(targetCollection);
5536
5761
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
@@ -5557,7 +5782,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5557
5782
  const columnString = getDrizzleColumn(propName, prop, collection, collections);
5558
5783
  if (columnString) columns.add(columnString);
5559
5784
  });
5560
- if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: varchar(\"id\").primaryKey()");
5785
+ if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: text(\"id\").primaryKey()");
5561
5786
  schemaContent += `${Array.from(columns).join(",\n")}`;
5562
5787
  const securityRules = getEffectiveSecurityRules(collection);
5563
5788
  if (!stripPolicies && securityRules.length > 0) {
@@ -20252,7 +20477,7 @@ function createPostgresBootstrapper(pgConfig) {
20252
20477
  */
20253
20478
  async ensureCollectionSchema(collections, driverResult, log) {
20254
20479
  const internals = driverResult.internals;
20255
- const { ensureCollectionTables } = await import("./ensure-collection-tables-CNlIONzj.js");
20480
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-BVvtkkRm.js");
20256
20481
  return { applied: (await ensureCollectionTables({ async query(text) {
20257
20482
  const result = await internals.db.execute(sql.raw(text));
20258
20483
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };