@rebasepro/server-postgres 0.10.1-canary.7609565 → 0.10.1-canary.7801eed
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/auth/schema-version.d.ts +106 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +1093 -4941
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +39 -8
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/services/realtimeService.d.ts +19 -0
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/src-3VmUJ8Xn.js +3994 -0
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/src-D5xBTl32.js +346 -0
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +55 -2
- package/src/auth/ensure-tables.ts +23 -0
- package/src/auth/schema-version.ts +260 -0
- package/src/cli-errors.ts +1 -1
- package/src/cli-helpers.ts +4 -3
- package/src/collections/PostgresCollectionRegistry.ts +9 -4
- package/src/collections/buildRegistry.ts +7 -0
- package/src/collections/validate-relations.ts +280 -0
- package/src/data-transformer.ts +28 -38
- package/src/schema/doctor.ts +14 -14
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +31 -24
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +25 -29
- package/src/services/FetchService.ts +116 -126
- package/src/services/PersistService.ts +126 -88
- package/src/services/RelationService.ts +157 -86
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +145 -0
- package/src/services/realtimeService.ts +60 -0
- package/src/services/row-pipeline.ts +5 -6
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
780
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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:
|
|
138
|
-
if (relation
|
|
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.
|
|
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.
|
|
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]
|
|
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]
|
|
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
|
-
//
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
873
|
-
|
|
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(
|
|
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,
|
|
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]
|
|
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]
|
|
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]
|
|
1427
|
+
logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
|
|
1438
1428
|
return new Map();
|
|
1439
1429
|
}
|
|
1440
1430
|
|