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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/auth/schema-version.d.ts +106 -0
  2. package/dist/chunk-DSJWtz9O.js +40 -0
  3. package/dist/collections/validate-relations.d.ts +53 -0
  4. package/dist/data-transformer.d.ts +3 -3
  5. package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
  6. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  7. package/dist/index.es.js +1093 -4941
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/schema/ensure-collection-tables.d.ts +79 -0
  10. package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
  11. package/dist/services/FetchService.d.ts +21 -8
  12. package/dist/services/PersistService.d.ts +12 -0
  13. package/dist/services/RelationService.d.ts +39 -8
  14. package/dist/services/cdc/junction-tables.d.ts +38 -0
  15. package/dist/services/nested-path.d.ts +59 -0
  16. package/dist/services/realtimeService.d.ts +19 -0
  17. package/dist/services/row-pipeline.d.ts +2 -2
  18. package/dist/src-3VmUJ8Xn.js +3994 -0
  19. package/dist/src-3VmUJ8Xn.js.map +1 -0
  20. package/dist/src-D5xBTl32.js +346 -0
  21. package/dist/src-D5xBTl32.js.map +1 -0
  22. package/dist/utils/drizzle-conditions.d.ts +71 -18
  23. package/package.json +8 -9
  24. package/src/PostgresBootstrapper.ts +55 -2
  25. package/src/auth/ensure-tables.ts +23 -0
  26. package/src/auth/schema-version.ts +260 -0
  27. package/src/cli-errors.ts +1 -1
  28. package/src/cli-helpers.ts +4 -3
  29. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  30. package/src/collections/buildRegistry.ts +7 -0
  31. package/src/collections/validate-relations.ts +280 -0
  32. package/src/data-transformer.ts +28 -38
  33. package/src/schema/doctor.ts +14 -14
  34. package/src/schema/ensure-collection-tables.test.ts +156 -0
  35. package/src/schema/ensure-collection-tables.ts +297 -0
  36. package/src/schema/generate-drizzle-schema-logic.ts +62 -110
  37. package/src/schema/generate-postgres-ddl-logic.ts +31 -24
  38. package/src/schema/introspect-db-inference.ts +13 -13
  39. package/src/schema/introspect-db-logic.ts +25 -29
  40. package/src/services/FetchService.ts +116 -126
  41. package/src/services/PersistService.ts +126 -88
  42. package/src/services/RelationService.ts +157 -86
  43. package/src/services/cdc/junction-tables.ts +91 -0
  44. package/src/services/nested-path.ts +145 -0
  45. package/src/services/realtimeService.ts +60 -0
  46. package/src/services/row-pipeline.ts +5 -6
  47. package/src/utils/drizzle-conditions.ts +268 -330
@@ -569,11 +569,11 @@ export function generateCollectionFile(
569
569
  // Date auto-value heuristics
570
570
  if (finalPropType === "date") {
571
571
  if (colNameLower === "created_at" || colNameLower === "createdat") {
572
- extra += "\n autoValue: \"on_create\",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },";
572
+ extra += "\n autoValue: \"on_create\",\n admin: {\n readOnly: true,\n hideFromCollection: true\n },";
573
573
  } else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
574
- extra += "\n autoValue: \"on_update\",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },";
574
+ extra += "\n autoValue: \"on_update\",\n admin: {\n readOnly: true,\n hideFromCollection: true\n },";
575
575
  } else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
576
- extra += "\n autoValue: \"on_create\",\n ui: {\n readOnly: true\n },";
576
+ extra += "\n autoValue: \"on_create\",\n admin: {\n readOnly: true\n },";
577
577
  }
578
578
  }
579
579
 
@@ -604,13 +604,13 @@ export function generateCollectionFile(
604
604
  if (isMedia) {
605
605
  extra += `\n storage: {\n storagePath: "${tableName}/${col.column_name}"\n },`;
606
606
  } else if (isUrl) {
607
- extra += "\n ui: {\n url: true\n },";
607
+ extra += "\n url: true,";
608
608
  } else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
609
- extra += "\n multiline: true,";
609
+ extra += "\n admin: {\n multiline: true\n },";
610
610
  } else if (colNameLower === "content" || colNameLower === "body") {
611
611
  extra += "\n multiline: true,\n markdown: true,";
612
612
  } else if (col.data_type === "text") {
613
- extra += "\n multiline: true,";
613
+ extra += "\n admin: {\n multiline: true\n },";
614
614
  }
615
615
  }
616
616
 
@@ -700,11 +700,12 @@ export function generateCollectionFile(
700
700
  ${relName}: {
701
701
  name: "${relHumanName}",
702
702
  type: "relation",
703
- target: () => ${targetCollectionCamel},
704
- cardinality: "one",
705
- direction: "owning",
706
- localKey: "${fk.column_name}",
707
703
  // mapped from foreign key: ${fk.column_name} -> ${targetTableName}(${fk.foreign_column_name})
704
+ relation: {
705
+ kind: "belongsTo",
706
+ target: () => ${targetCollectionCamel},
707
+ localKey: "${fk.column_name}"
708
+ }
708
709
  },`);
709
710
  }
710
711
  }
@@ -718,15 +719,12 @@ export function generateCollectionFile(
718
719
  const targetCollectionCamel = toCollectionVarName(sourceTableName);
719
720
  imports.add(`import ${targetCollectionCamel} from "./${sourceTableName}";`);
720
721
 
721
- const inverseRelName = fk.column_name.replace(/_id$/, "");
722
-
723
722
  relationsOutput += `
724
723
  {
724
+ kind: "hasMany",
725
725
  relationName: "${sourceTableName}",
726
726
  target: () => ${targetCollectionCamel},
727
- cardinality: "many",
728
- direction: "inverse",
729
- inverseRelationName: "${inverseRelName}",
727
+ // the ${sourceTableName}.${fk.column_name} FK points back here
730
728
  foreignKeyOnTarget: "${fk.column_name}"
731
729
  },`;
732
730
  }
@@ -755,10 +753,9 @@ export function generateCollectionFile(
755
753
 
756
754
  relationsOutput += `
757
755
  {
756
+ kind: "manyToMany",
758
757
  relationName: "${relPropName}",
759
758
  target: () => ${tableName}Collection,
760
- cardinality: "many",
761
- direction: "owning",
762
759
  through: {
763
760
  table: "${jt}",
764
761
  sourceColumn: "${thisFk.column_name}",
@@ -776,24 +773,23 @@ export function generateCollectionFile(
776
773
  const targetCollectionCamel = toCollectionVarName(targetTableName);
777
774
  imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
778
775
 
779
- // Determine direction (alphabetically first table is owning)
780
- const direction = tableName < targetTableName ? "owning" : "inverse";
781
-
776
+ // Both sides of a many-to-many are `manyToMany`. There is no owning
777
+ // and inverse side to pick between any more, so this no longer
778
+ // guesses one from table-name ordering and no longer emits a
779
+ // half-configured relation on the losing side with a comment asking
780
+ // the reader to finish it by hand. Introspection already knows both
781
+ // junction columns; each side just names them from its own end.
782
782
  const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
783
783
 
784
- let throughCode = "";
785
- if (direction === "owning" && thisFk) {
786
- throughCode = `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n },`;
787
- } else if (direction === "inverse") {
788
- throughCode = "\n // Make sure the target collection configures the 'through' property.";
789
- }
784
+ const throughCode = thisFk
785
+ ? `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n }`
786
+ : "";
790
787
 
791
788
  relationsOutput += `
792
789
  {
790
+ kind: "manyToMany",
793
791
  relationName: "${targetTableName}",
794
- target: () => ${targetCollectionCamel},
795
- cardinality: "many",
796
- direction: "${direction}",${throughCode}
792
+ target: () => ${targetCollectionCamel},${throughCode}
797
793
  },`;
798
794
  }
799
795
  }
@@ -1,6 +1,6 @@
1
1
  import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- import { CollectionConfig, FilterValues, Relation, LogicalCondition } from "@rebasepro/types";
3
+ import { CollectionConfig, FilterValues, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
4
4
  import type { VectorSearchParams } from "@rebasepro/types";
5
5
  import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
6
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
@@ -19,6 +19,7 @@ import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query
19
19
  import { DrizzleClient } from "../interfaces";
20
20
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
21
21
  import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
22
+ import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
22
23
  import { logger } from "@rebasepro/server";
23
24
 
24
25
  /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
@@ -72,7 +73,7 @@ export class FetchService {
72
73
  let orderByField = table[orderBy as keyof typeof table] as AnyPgColumn;
73
74
  if (!orderByField && collection) {
74
75
  const property = collection.properties[orderBy];
75
- if (property && property.type === "relation" && "relation" in property && property.relation?.direction === "owning") {
76
+ if (property && property.type === "relation" && "relation" in property && property.resolvedRelation?.kind === "belongsTo") {
76
77
  orderByField = table[`${orderBy}_id` as keyof typeof table] as AnyPgColumn;
77
78
  }
78
79
  }
@@ -105,7 +106,7 @@ export class FetchService {
105
106
  const drizzleRelName = relation.relationName || key;
106
107
 
107
108
  // Skip relations that use joinPath as they are not mapped in Drizzle schemas
108
- if (relation.joinPath && relation.joinPath.length > 0) {
109
+ if (relation.kind === "via") {
109
110
  continue;
110
111
  }
111
112
 
@@ -134,8 +135,8 @@ export class FetchService {
134
135
  * Get the Drizzle relation name on the junction table that points to the actual target row.
135
136
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
136
137
  */
137
- private getJunctionTargetRelationName(relation: Relation, _collection: CollectionConfig): string | null {
138
- if (relation.through) {
138
+ private getJunctionTargetRelationName(relation: ResolvedRelation, _collection: CollectionConfig): string | null {
139
+ if (isManyToMany(relation)) {
139
140
  // The junction relation on the junction table pointing to the target
140
141
  // uses the targetColumn name as the Drizzle relation name
141
142
  return relation.through.targetColumn.replace(/_id$/, "_id");
@@ -158,7 +159,7 @@ export class FetchService {
158
159
  const resolvedRelations = resolveCollectionRelations(collection);
159
160
 
160
161
  const promises = Object.entries(resolvedRelations)
161
- .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0)
162
+ .filter(([key, relation]) => relation.kind === "via")
162
163
  .map(async ([key, relation]) => {
163
164
  try {
164
165
  const relatedRows = await this.relationService.fetchRelatedEntities(
@@ -203,7 +204,7 @@ export class FetchService {
203
204
  !include || include.length === 0 || include[0] === "*" || include.includes(key);
204
205
 
205
206
  const joinPathRelations = Object.entries(resolvedRelations)
206
- .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
207
+ .filter(([key, relation]) => relation.kind === "via" && propertyKeys.has(key) && shouldInclude(key));
207
208
 
208
209
  if (joinPathRelations.length === 0) return;
209
210
 
@@ -279,7 +280,8 @@ export class FetchService {
279
280
  logical?: LogicalCondition;
280
281
  },
281
282
  collectionPath: string,
282
- withConfig?: Record<string, unknown>
283
+ withConfig?: Record<string, unknown>,
284
+ scopeCondition?: SQL
283
285
  ): Record<string, unknown> {
284
286
  const queryOpts: Record<string, unknown> = {};
285
287
 
@@ -288,6 +290,8 @@ export class FetchService {
288
290
  // Build where conditions
289
291
  const allConditions: SQL[] = [];
290
292
 
293
+ if (scopeCondition) allConditions.push(scopeCondition);
294
+
291
295
  if (options.searchString) {
292
296
  const collection = getCollectionByPath(collectionPath, this.registry);
293
297
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
@@ -391,6 +395,59 @@ export class FetchService {
391
395
  return [];
392
396
  }
393
397
 
398
+ /**
399
+ * Compile "rows reachable from this parent" into a `WHERE` condition on the
400
+ * target table, so a nested listing can run as an ordinary collection query.
401
+ */
402
+ private buildRelationScope(hop: NestedPathHop): SQL {
403
+ const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
404
+ const parentIdInfo = parentPks[0];
405
+ const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
406
+
407
+ const parent = () => {
408
+ const table = getTableForCollection(hop.parentCollection, this.registry);
409
+ const idColumn = table[parentIdInfo.fieldName as keyof typeof table] as AnyPgColumn;
410
+ if (!idColumn) {
411
+ throw new Error(`ID field '${parentIdInfo.fieldName}' not found in table for collection '${hop.parentCollection.slug}'`);
412
+ }
413
+ return { table,
414
+ idColumn };
415
+ };
416
+
417
+ const targetTable = getTableForCollection(hop.targetCollection, this.registry);
418
+ const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
419
+ const targetIdColumn = targetTable[targetPks[0].fieldName as keyof typeof targetTable] as AnyPgColumn;
420
+ if (!targetIdColumn) {
421
+ throw new Error(`ID field '${targetPks[0].fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
422
+ }
423
+
424
+ return DrizzleConditionBuilder.buildRelationScopeCondition(
425
+ hop.relation,
426
+ parent,
427
+ parsedParentId as string | number,
428
+ targetTable,
429
+ targetIdColumn,
430
+ this.registry
431
+ );
432
+ }
433
+
434
+ /**
435
+ * Whether `id` is actually reachable at `collectionPath`.
436
+ *
437
+ * Trivially true for a root path. For a nested one it is a real question:
438
+ * the path resolves to the target collection, and matching on the primary
439
+ * key alone made the parent segment decorative — `authors/1/posts/43`
440
+ * returned post 43 whoever wrote it, and the REST layer's delete then
441
+ * deleted it. A row that is not under this parent is reported as absent,
442
+ * which is what a caller addressing it through the parent should see.
443
+ */
444
+ private async isAddressableUnder(collectionPath: string, id: string | number): Promise<boolean> {
445
+ if (!isNestedPath(collectionPath)) return true;
446
+ const hop = resolveNestedPath(collectionPath, this.registry);
447
+ if (!hop) return true;
448
+ return this.relationService.isRelated(hop, id);
449
+ }
450
+
394
451
  /**
395
452
  * Fetch a single row by ID
396
453
  */
@@ -399,6 +456,8 @@ export class FetchService {
399
456
  id: string | number,
400
457
  databaseId?: string
401
458
  ): Promise<Record<string, unknown> | undefined> {
459
+ if (!await this.isAddressableUnder(collectionPath, id)) return undefined;
460
+
402
461
  const collection = getCollectionByPath(collectionPath, this.registry);
403
462
  const table = getTableForCollection(collection, this.registry);
404
463
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -436,7 +495,7 @@ export class FetchService {
436
495
  return flatRow;
437
496
  } catch (e) {
438
497
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
439
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
498
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
440
499
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
441
500
  }
442
501
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
@@ -516,8 +575,11 @@ export class FetchService {
516
575
  databaseId?: string;
517
576
  vectorSearch?: VectorSearchParams;
518
577
  logical?: LogicalCondition;
578
+ /** Narrow to the rows reachable from a parent through a relation. */
579
+ relatedTo?: NestedPathHop;
519
580
  } = {}
520
581
  ): Promise<Record<string, unknown>[]> {
582
+ const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : undefined;
521
583
  const collection = getCollectionByPath(collectionPath, this.registry);
522
584
  const table = getTableForCollection(collection, this.registry);
523
585
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -545,7 +607,7 @@ export class FetchService {
545
607
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) {
546
608
  try {
547
609
  const queryOpts = this.buildDrizzleQueryOptions<M>(
548
- table, idField, idInfo, options, collectionPath, undefined
610
+ table, idField, idInfo, options, collectionPath, undefined, scopeCondition
549
611
  );
550
612
 
551
613
 
@@ -558,7 +620,7 @@ export class FetchService {
558
620
  return rows;
559
621
  } catch (e) {
560
622
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
561
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
623
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
562
624
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
563
625
  }
564
626
  logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
@@ -578,6 +640,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
578
640
  : this.db.select().from(table).$dynamic();
579
641
  const allConditions: SQL[] = [];
580
642
 
643
+ if (scopeCondition) allConditions.push(scopeCondition);
644
+
581
645
  if (options.searchString) {
582
646
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
583
647
  options.searchString, collection.properties, table
@@ -771,9 +835,13 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
771
835
  vectorSearch?: VectorSearchParams;
772
836
  } = {}
773
837
  ): Promise<Record<string, unknown>[]> {
774
- // Handle multi-segment paths by resolving through relations
775
- if (collectionPath.includes("/")) {
776
- return this.fetchCollectionFromPath<M>(collectionPath, options);
838
+ // A nested path is the target collection narrowed by a relation — the
839
+ // same query, one condition heavier. It used to be a separate builder
840
+ // that honoured `limit` and nothing else.
841
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
842
+ if (hop) {
843
+ return this.fetchRowsWithConditions<M>(hop.targetCollection.slug, { ...options,
844
+ relatedTo: hop });
777
845
  }
778
846
 
779
847
  return this.fetchRowsWithConditions<M>(collectionPath, options);
@@ -799,65 +867,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
799
867
  });
800
868
  }
801
869
 
802
- /**
803
- * Fetch collection from multi-segment path
804
- */
805
- private async fetchCollectionFromPath<M extends Record<string, unknown>>(
806
- path: string,
807
- options: {
808
- filter?: FilterValues<Extract<keyof M, string>>;
809
- orderBy?: string;
810
- order?: "desc" | "asc";
811
- limit?: number;
812
- startAfter?: Record<string, unknown>;
813
- searchString?: string;
814
- databaseId?: string;
815
- } = {}
816
- ): Promise<Record<string, unknown>[]> {
817
- const pathSegments = path.split("/").filter(p => p && p !== "undefined");
818
-
819
- if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
820
- throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
821
- }
822
-
823
- const rootCollectionPath = pathSegments[0];
824
- let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
825
- let currentId: string | number = pathSegments[1];
826
-
827
- for (let i = 2; i < pathSegments.length; i += 2) {
828
- const relationKey = pathSegments[i];
829
- const resolvedRelations = resolveCollectionRelations(currentCollection);
830
- const relation = findRelation(resolvedRelations, relationKey);
831
-
832
- if (!relation) {
833
- throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
834
- }
835
-
836
- if (i === pathSegments.length - 1) {
837
- const rows = await this.relationService.fetchRelatedEntities<M>(
838
- currentCollection.slug,
839
- currentId,
840
- relationKey,
841
- options
842
- );
843
- // Flatten RelatedRow[] to rows: the target's columns, and only
844
- // those. Merging `row.id` in last overwrote a real `id` column,
845
- // and the address is derivable without it — a consumer resolves
846
- // this path ("posts/1/comments") to the target collection and
847
- // reads the key columns, which `values` carries.
848
- return rows.map(row => ({ ...row.values as Record<string, unknown> }));
849
- }
850
-
851
- if (i + 1 < pathSegments.length) {
852
- const nextEntityId = pathSegments[i + 1];
853
- currentCollection = relation.target();
854
- currentId = nextEntityId;
855
- }
856
- }
857
-
858
- throw new Error(`Unable to resolve path: ${path}`);
859
- }
860
-
861
870
  /**
862
871
  * Count rows in a collection
863
872
  */
@@ -869,16 +878,20 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
869
878
  databaseId?: string;
870
879
  } = {}
871
880
  ): Promise<number> {
872
- if (collectionPath.includes("/")) {
873
- return this.countEntitiesFromPath<M>(collectionPath, options);
874
- }
881
+ // Same narrowing as the listing — and, unlike the count it replaces,
882
+ // the same `filter` and `searchString` too, so `total` describes the
883
+ // rows that were actually served.
884
+ const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
885
+ const effectivePath = hop ? hop.targetCollection.slug : collectionPath;
875
886
 
876
- const collection = getCollectionByPath(collectionPath, this.registry);
887
+ const collection = getCollectionByPath(effectivePath, this.registry);
877
888
  const table = getTableForCollection(collection, this.registry);
878
889
 
879
890
  let query = this.db.select({ count: count() }).from(table).$dynamic();
880
891
  const allConditions: SQL[] = [];
881
892
 
893
+ if (hop) allConditions.push(this.buildRelationScope(hop));
894
+
882
895
  if (options.searchString) {
883
896
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
884
897
  options.searchString, collection.properties, table
@@ -888,7 +901,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
888
901
  }
889
902
 
890
903
  if (options.filter) {
891
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
904
+ const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
892
905
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
893
906
  }
894
907
 
@@ -901,50 +914,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
901
914
  return Number(result[0]?.count || 0);
902
915
  }
903
916
 
904
- /**
905
- * Count rows from multi-segment path
906
- */
907
- private async countEntitiesFromPath<M extends Record<string, unknown>>(
908
- path: string,
909
- options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
910
- ): Promise<number> {
911
- const pathSegments = path.split("/").filter(p => p && p !== "undefined");
912
-
913
- if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
914
- throw new Error(`Invalid relation path: ${path}`);
915
- }
916
-
917
- const rootCollectionPath = pathSegments[0];
918
- let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
919
- let currentId: string | number = pathSegments[1];
920
-
921
- for (let i = 2; i < pathSegments.length; i += 2) {
922
- const relationKey = pathSegments[i];
923
- const resolvedRelations = resolveCollectionRelations(currentCollection);
924
- const relation = findRelation(resolvedRelations, relationKey);
925
-
926
- if (!relation) {
927
- throw new Error(`Relation '${relationKey}' not found`);
928
- }
929
-
930
- if (i === pathSegments.length - 1) {
931
- return this.relationService.countRelatedEntities(
932
- currentCollection.slug,
933
- currentId,
934
- relationKey,
935
- options
936
- );
937
- }
938
-
939
- if (i + 1 < pathSegments.length) {
940
- currentCollection = relation.target();
941
- currentId = pathSegments[i + 1];
942
- }
943
- }
944
-
945
- throw new Error(`Unable to count for path: ${path}`);
946
- }
947
-
948
917
  /**
949
918
  * Check if a field value is unique
950
919
  */
@@ -1014,9 +983,25 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1014
983
  searchString?: string;
1015
984
  databaseId?: string;
1016
985
  vectorSearch?: VectorSearchParams;
986
+ /** Narrow to the rows reachable from a parent through a relation. */
987
+ relatedTo?: NestedPathHop;
1017
988
  } = {},
1018
989
  include?: string[]
1019
990
  ): Promise<Record<string, unknown>[]> {
991
+ // Resolve a nested path here rather than at the route, so `include`,
992
+ // `offset` and the rest reach a child listing by the same route they
993
+ // reach a root one.
994
+ if (isNestedPath(collectionPath)) {
995
+ const hop = resolveNestedPath(collectionPath, this.registry);
996
+ if (hop) {
997
+ return this.fetchCollectionForRest<M>(
998
+ hop.targetCollection.slug, { ...options,
999
+ relatedTo: hop }, include
1000
+ );
1001
+ }
1002
+ }
1003
+
1004
+ const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : undefined;
1020
1005
  const collection = getCollectionByPath(collectionPath, this.registry);
1021
1006
  const table = getTableForCollection(collection, this.registry);
1022
1007
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -1039,7 +1024,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1039
1024
  : undefined;
1040
1025
 
1041
1026
  const queryOpts = this.buildDrizzleQueryOptions<M>(
1042
- table, idField, idInfo, options, collectionPath, withConfig
1027
+ table, idField, idInfo, options, collectionPath, withConfig, scopeCondition
1043
1028
  );
1044
1029
 
1045
1030
 
@@ -1055,7 +1040,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1055
1040
  return restRows;
1056
1041
  } catch (e) {
1057
1042
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1058
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
1043
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1059
1044
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1060
1045
  }
1061
1046
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
@@ -1123,6 +1108,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1123
1108
  include?: string[],
1124
1109
  databaseId?: string
1125
1110
  ): Promise<Record<string, unknown> | null> {
1111
+ if (!await this.isAddressableUnder(collectionPath, id)) return null;
1112
+
1126
1113
  const collection = getCollectionByPath(collectionPath, this.registry);
1127
1114
  const table = getTableForCollection(collection, this.registry);
1128
1115
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
@@ -1159,7 +1146,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1159
1146
  return restRow;
1160
1147
  } catch (e) {
1161
1148
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1162
- logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
1149
+ logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1163
1150
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1164
1151
  }
1165
1152
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
@@ -1229,6 +1216,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1229
1216
  startAfter?: Record<string, unknown>;
1230
1217
  searchString?: string;
1231
1218
  vectorSearch?: VectorSearchParams;
1219
+ relatedTo?: NestedPathHop;
1232
1220
  } = {}
1233
1221
  ): Promise<Record<string, unknown>[]> {
1234
1222
  const collection = getCollectionByPath(collectionPath, this.registry);
@@ -1248,6 +1236,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1248
1236
  : this.db.select().from(table).$dynamic();
1249
1237
  const allConditions: SQL[] = [];
1250
1238
 
1239
+ if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
1240
+
1251
1241
  if (options.searchString) {
1252
1242
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1253
1243
  options.searchString, collection.properties, table
@@ -1434,7 +1424,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1434
1424
  const relation = resolvedRelations[relationKey];
1435
1425
 
1436
1426
  if (!relation) {
1437
- logger.warn(`[batchFetchManyRelatedRows] Relation '${relationKey}' not found, skipping`);
1427
+ logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
1438
1428
  return new Map();
1439
1429
  }
1440
1430