@rebasepro/server-postgres 0.0.1-canary.4829d6e

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 (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. package/src/websocket.ts +640 -0
@@ -0,0 +1,1661 @@
1
+ import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
+ import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
+ import { CollectionConfig, FilterValues, Relation, LogicalCondition } from "@rebasepro/types";
4
+ import type { VectorSearchParams } from "@rebasepro/types";
5
+ import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
+ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
+ import {
8
+ getCollectionByPath,
9
+ getTableForCollection,
10
+ getPrimaryKeys,
11
+ parseIdValues,
12
+ buildCompositeId
13
+ } from "./collection-helpers";
14
+ import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
15
+ import { RelationService } from "./RelationService";
16
+ import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
17
+ import { DrizzleClient } from "../interfaces";
18
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
19
+ import { logger } from "@rebasepro/server";
20
+
21
+ /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
22
+ type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
23
+
24
+ /**
25
+ * Service for handling all row read operations.
26
+ * Handles fetching, searching, counting, and filtering rows.
27
+ */
28
+ export class FetchService {
29
+ private relationService: RelationService;
30
+
31
+ constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) {
32
+ this.relationService = new RelationService(db, registry);
33
+ }
34
+
35
+ /**
36
+ * Get the relational query builder for a given table name.
37
+ * Safely narrows the DrizzleClient union type to access db.query[tableName].
38
+ */
39
+ private getQueryBuilder(tableName: string): RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined {
40
+ const query = (this.db as { query?: DbQueryAccessor }).query;
41
+ return query?.[tableName] as RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined;
42
+ }
43
+
44
+ /**
45
+ * Build filter conditions from FilterValues
46
+ * Delegates to DrizzleConditionBuilder.buildFilterConditions
47
+ */
48
+ buildFilterConditions<M extends Record<string, unknown>>(
49
+ filter: FilterValues<Extract<keyof M, string>>,
50
+ table: PgTable<any>,
51
+ collectionPath: string
52
+ ): SQL[] {
53
+ return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath);
54
+ }
55
+
56
+ // =============================================================
57
+ // DRIZZLE QUERY HELPERS
58
+ // =============================================================
59
+
60
+ /**
61
+ * Resolves the correct Drizzle column for sorting.
62
+ * Automatically maps owning relation property keys to their underlying foreign key column.
63
+ */
64
+ private resolveOrderByField(
65
+ table: PgTable<any>,
66
+ orderBy: string,
67
+ collection?: CollectionConfig
68
+ ): AnyPgColumn | undefined {
69
+ let orderByField = table[orderBy as keyof typeof table] as AnyPgColumn;
70
+ if (!orderByField && collection) {
71
+ const property = collection.properties[orderBy];
72
+ if (property && property.type === "relation" && "relation" in property && property.relation?.direction === "owning") {
73
+ orderByField = table[`${orderBy}_id` as keyof typeof table] as AnyPgColumn;
74
+ }
75
+ }
76
+ return orderByField;
77
+ }
78
+
79
+ /**
80
+ * Build the `with` config for Drizzle's relational query API.
81
+ * Converts collection relations to a Drizzle-compatible `with` object.
82
+ *
83
+ * When `include` is provided, only those relations are loaded.
84
+ * When `include` is absent, ALL relations are loaded (CMS path).
85
+ *
86
+ * Automatically detects many-to-many junction tables and nests
87
+ * the target relation so actual row data is returned.
88
+ */
89
+ private buildWithConfig(
90
+ collection: CollectionConfig,
91
+ include?: string[]
92
+ ): Record<string, boolean | { with: Record<string, boolean> }> {
93
+ const resolvedRelations = resolveCollectionRelations(collection);
94
+ const withConfig: Record<string, boolean | { with: Record<string, boolean> }> = {};
95
+
96
+ const shouldInclude = (key: string) =>
97
+ !include || include.length === 0 || include[0] === "*" || include.includes(key);
98
+
99
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
100
+ if (!shouldInclude(key)) continue;
101
+
102
+ const drizzleRelName = relation.relationName || key;
103
+
104
+ // Skip relations that use joinPath as they are not mapped in Drizzle schemas
105
+ if (relation.joinPath && relation.joinPath.length > 0) {
106
+ continue;
107
+ }
108
+
109
+ // Detect many-to-many junction tables:
110
+ // If the relation goes through a junction table (relation.through exists or
111
+ // the Drizzle schema maps to a junction table), we need two-level with.
112
+ if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
113
+ // The Drizzle relation points to the junction table.
114
+ // We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
115
+ // The target FK name is the relation on the junction table that points to the actual target.
116
+ const targetFkName = this.getJunctionTargetRelationName(relation, collection);
117
+ if (targetFkName) {
118
+ withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
119
+ } else {
120
+ withConfig[drizzleRelName] = true;
121
+ }
122
+ } else {
123
+ withConfig[drizzleRelName] = true;
124
+ }
125
+ }
126
+
127
+ return withConfig;
128
+ }
129
+
130
+ /**
131
+ * Detect if a many-to-many relation uses a junction table in the Drizzle schema.
132
+ */
133
+ private isJunctionRelation(relation: Relation, _collection: CollectionConfig): boolean {
134
+ // If `through` is defined, it's explicitly a junction relation
135
+ if (relation.through) return true;
136
+ // If joinPath has an intermediate table, it's likely junction-based
137
+ if (relation.joinPath && relation.joinPath.length > 1) return true;
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * Get the Drizzle relation name on the junction table that points to the actual target row.
143
+ * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
144
+ */
145
+ private getJunctionTargetRelationName(relation: Relation, _collection: CollectionConfig): string | null {
146
+ if (relation.through) {
147
+ // The junction relation on the junction table pointing to the target
148
+ // uses the targetColumn name as the Drizzle relation name
149
+ return relation.through.targetColumn.replace(/_id$/, "_id");
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Convert a db.query result row (with nested relation objects) to a flat row.
156
+ * Handles:
157
+ * - Placing `id` at the top level as a string
158
+ * - Type normalization (dates, numbers, NaN) via normalizeDbValues
159
+ * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
160
+ * - Flattening junction-table many-to-many results
161
+ */
162
+ private drizzleResultToRow<M extends Record<string, unknown>>(
163
+ row: Record<string, unknown>,
164
+ collection: CollectionConfig,
165
+ _collectionPath: string,
166
+ idInfo: { fieldName: string; type: "string" | "number" },
167
+ _databaseId?: string,
168
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
169
+ ): Record<string, unknown> {
170
+ const resolvedRelations = resolveCollectionRelations(collection);
171
+
172
+ // Normalize non-relation values (dates, numbers, etc.)
173
+ const normalizedValues = normalizeDbValues(row as M, collection) as Record<string, unknown>;
174
+
175
+ // Convert nested relation objects to CMS-style { id, path, __type: "relation" }
176
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
177
+ const drizzleRelName = relation.relationName || key;
178
+ const relData = row[drizzleRelName];
179
+
180
+ if (relData === undefined || relData === null) continue;
181
+
182
+ if (relation.cardinality === "many" && Array.isArray(relData)) {
183
+ const targetCollection = relation.target();
184
+ const targetPath = targetCollection.slug;
185
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
186
+ const targetIdField = targetPks[0].fieldName;
187
+
188
+ normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
189
+ // Handle junction table flattening:
190
+ // Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
191
+ let targetRow = item;
192
+ if (this.isJunctionRelation(relation, collection)) {
193
+ // Find the nested target object in the junction row
194
+ const nestedKey = Object.keys(item).find(
195
+ nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
196
+ );
197
+ if (nestedKey) {
198
+ targetRow = item[nestedKey] as Record<string, unknown>;
199
+ }
200
+ }
201
+
202
+ const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
203
+ const targetValues = normalizeDbValues(targetRow, targetCollection);
204
+
205
+ return createRelationRefWithData(relId, targetPath, {
206
+ id: relId,
207
+ path: targetPath,
208
+ values: targetValues
209
+ });
210
+ });
211
+ } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
212
+ const targetCollection = relation.target();
213
+ const targetPath = targetCollection.slug;
214
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
215
+ const targetIdField = targetPks[0].fieldName;
216
+ const relObj = relData as Record<string, unknown>;
217
+
218
+ const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
219
+ const targetValues = normalizeDbValues(relObj, targetCollection);
220
+
221
+ normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
222
+ id: relId,
223
+ path: targetPath,
224
+ values: targetValues
225
+ });
226
+ }
227
+ }
228
+
229
+ return {
230
+ ...normalizedValues,
231
+ // Spread the canonical id last so it wins over a raw `id` column
232
+ id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Post-fetch joinPath relations for a single flat row.
238
+ * joinPath relations cannot be expressed via Drizzle's `with` config,
239
+ * so they must be loaded separately after the primary query.
240
+ */
241
+ private async resolveJoinPathRelations<M extends Record<string, unknown>>(
242
+ row: Record<string, unknown>,
243
+ collection: CollectionConfig,
244
+ collectionPath: string,
245
+ parsedId: string | number,
246
+ _databaseId?: string
247
+ ): Promise<void> {
248
+ const resolvedRelations = resolveCollectionRelations(collection);
249
+
250
+ const promises = Object.entries(resolvedRelations)
251
+ .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0)
252
+ .map(async ([key, relation]) => {
253
+ try {
254
+ const relatedRows = await this.relationService.fetchRelatedEntities(
255
+ collectionPath,
256
+ parsedId,
257
+ key,
258
+ { limit: relation.cardinality === "one" ? 1 : undefined }
259
+ );
260
+
261
+ if (relation.cardinality === "one" && relatedRows.length > 0) {
262
+ const e = relatedRows[0];
263
+ row[key] = createRelationRefWithData(e.id, e.path, e);
264
+ } else if (relation.cardinality === "many") {
265
+ row[key] = relatedRows.map(e =>
266
+ createRelationRefWithData(e.id, e.path, e)
267
+ );
268
+ }
269
+ } catch (e) {
270
+ logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
271
+ }
272
+ });
273
+
274
+ await Promise.all(promises);
275
+ }
276
+
277
+ /**
278
+ * Post-fetch joinPath relations for a batch of flat rows.
279
+ * Uses batch fetching to avoid N+1 queries for list views.
280
+ */
281
+ private async resolveJoinPathRelationsBatch<M extends Record<string, unknown>>(
282
+ rows: Record<string, unknown>[],
283
+ collection: CollectionConfig,
284
+ collectionPath: string,
285
+ idInfo: { fieldName: string; type: "string" | "number" },
286
+ _databaseId?: string
287
+ ): Promise<void> {
288
+ if (rows.length === 0) return;
289
+
290
+ const resolvedRelations = resolveCollectionRelations(collection);
291
+
292
+ const joinPathRelations = Object.entries(resolvedRelations)
293
+ .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
294
+
295
+ if (joinPathRelations.length === 0) return;
296
+
297
+ for (const [key, relation] of joinPathRelations) {
298
+ try {
299
+ const rowIds = rows.map(r => {
300
+ const parsed = parseIdValues(String(r.id), [idInfo]);
301
+ return parsed[idInfo.fieldName] as string | number;
302
+ });
303
+
304
+ const resultMap = await this.relationService.batchFetchRelatedEntities(
305
+ collectionPath,
306
+ rowIds,
307
+ key,
308
+ relation
309
+ );
310
+
311
+ for (const row of rows) {
312
+ const parsed = parseIdValues(String(row.id), [idInfo]);
313
+ const id = parsed[idInfo.fieldName] as string | number;
314
+ const relatedRow = resultMap.get(String(id));
315
+
316
+ if (relatedRow) {
317
+ if (relation.cardinality === "one") {
318
+ row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
319
+ }
320
+ }
321
+ }
322
+ } catch (e) {
323
+ logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Resolves joinPath relations for raw REST rows and directly injects them.
330
+ * Uses RelationService to query the database and maps results back to the flattened objects.
331
+ */
332
+ private async resolveJoinPathRelationsBatchRest(
333
+ rows: Record<string, unknown>[],
334
+ collection: CollectionConfig,
335
+ collectionPath: string,
336
+ idInfoArray: { fieldName: string; type: "string" | "number" }[],
337
+ include?: string[]
338
+ ): Promise<void> {
339
+ if (rows.length === 0) return;
340
+
341
+ const resolvedRelations = resolveCollectionRelations(collection);
342
+ const propertyKeys = new Set(Object.keys(collection.properties || {}));
343
+ const shouldInclude = (key: string) =>
344
+ !include || include.length === 0 || include[0] === "*" || include.includes(key);
345
+
346
+ const joinPathRelations = Object.entries(resolvedRelations)
347
+ .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
348
+
349
+ if (joinPathRelations.length === 0) return;
350
+
351
+ const idInfo = idInfoArray[0];
352
+
353
+ for (const [key, relation] of joinPathRelations) {
354
+ try {
355
+ // Determine the parent IDs based on the parsed string ID from the REST row
356
+ const rowIds = rows.map(r => {
357
+ const parsed = parseIdValues(String(r.id), idInfoArray);
358
+ return parsed[idInfo.fieldName] as string | number;
359
+ });
360
+
361
+ if (relation.cardinality === "one") {
362
+ const resultMap = await this.relationService.batchFetchRelatedEntities(
363
+ collectionPath,
364
+ rowIds,
365
+ key,
366
+ relation
367
+ );
368
+
369
+ for (const row of rows) {
370
+ const parsed = parseIdValues(String(row.id), idInfoArray);
371
+ const id = parsed[idInfo.fieldName] as string | number;
372
+ const relatedRow = resultMap.get(String(id));
373
+
374
+ if (relatedRow) {
375
+ row[key] = {
376
+ ...relatedRow.values,
377
+ id: relatedRow.id
378
+ };
379
+ } else {
380
+ row[key] = null;
381
+ }
382
+ }
383
+ } else if (relation.cardinality === "many") {
384
+ const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
385
+ collectionPath,
386
+ rowIds,
387
+ key,
388
+ relation
389
+ );
390
+
391
+ for (const row of rows) {
392
+ const parsed = parseIdValues(String(row.id), idInfoArray);
393
+ const id = parsed[idInfo.fieldName] as string | number;
394
+ const relatedList = resultMap.get(String(id)) || [];
395
+ row[key] = relatedList.map(e => ({
396
+ ...e.values,
397
+ id: e.id
398
+ }));
399
+ }
400
+ }
401
+ } catch (e) {
402
+ logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
403
+ }
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Convert a db.query result row to a flat REST-style object with populated relations.
409
+ */
410
+ private drizzleResultToRestRow(
411
+ row: Record<string, unknown>,
412
+ collection: CollectionConfig,
413
+ idInfo: { fieldName: string; type: "string" | "number" },
414
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
415
+ ): Record<string, unknown> {
416
+ const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]) };
417
+ const resolvedRelations = resolveCollectionRelations(collection);
418
+
419
+ for (const [k, v] of Object.entries(row)) {
420
+ if (k === idInfo.fieldName) continue;
421
+
422
+ const relation = findRelation(resolvedRelations, k);
423
+ if (Array.isArray(v) && relation) {
424
+ // Many relation — flatten each nested row, handling junction tables
425
+ flat[k] = v.map((item: Record<string, unknown>) => {
426
+ if (this.isJunctionRelation(relation, collection)) {
427
+ const nestedKey = Object.keys(item).find(
428
+ nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
429
+ );
430
+ if (nestedKey) {
431
+ const nested = item[nestedKey] as Record<string, unknown>;
432
+ return { ...nested,
433
+ id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
434
+ }
435
+ }
436
+ return { ...item,
437
+ id: String(item.id ?? item[Object.keys(item)[0]]) };
438
+ });
439
+ } else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
440
+ // One-to-one relation — inline the object
441
+ const relObj = v as Record<string, unknown>;
442
+ flat[k] = { ...relObj,
443
+ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
444
+ } else {
445
+ flat[k] = v;
446
+ }
447
+ }
448
+ return flat;
449
+ }
450
+
451
+ /**
452
+ * Build db.query-compatible options from standard fetch options.
453
+ * Handles filter, search, orderBy, limit, and cursor-based pagination.
454
+ */
455
+ private buildDrizzleQueryOptions<M extends Record<string, unknown>>(
456
+ table: PgTable<any>,
457
+ idField: AnyPgColumn,
458
+ idInfo: { fieldName: string; type: "string" | "number" },
459
+ options: {
460
+ filter?: FilterValues<Extract<keyof M, string>>;
461
+ orderBy?: string;
462
+ order?: "desc" | "asc";
463
+ limit?: number;
464
+ offset?: number;
465
+ startAfter?: Record<string, unknown>;
466
+ searchString?: string;
467
+ logical?: LogicalCondition;
468
+ },
469
+ collectionPath: string,
470
+ withConfig?: Record<string, unknown>
471
+ ): Record<string, unknown> {
472
+ const queryOpts: Record<string, unknown> = {};
473
+
474
+ if (withConfig) queryOpts.with = withConfig;
475
+
476
+ // Build where conditions
477
+ const allConditions: SQL[] = [];
478
+
479
+ if (options.searchString) {
480
+ const collection = getCollectionByPath(collectionPath, this.registry);
481
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
482
+ options.searchString, collection.properties, table
483
+ );
484
+ if (searchConditions.length === 0) {
485
+ // Return options that will produce empty results
486
+ queryOpts.where = and(eq(idField, -99999999)); // impossible condition
487
+ return queryOpts;
488
+ }
489
+ allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
490
+ }
491
+
492
+ if (options.filter) {
493
+ const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
494
+ if (filterConditions.length > 0) allConditions.push(...filterConditions);
495
+ }
496
+
497
+ if (options.logical) {
498
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
499
+ if (logicalCondition) allConditions.push(logicalCondition);
500
+ }
501
+
502
+ // Cursor-based pagination (startAfter)
503
+ if (options.startAfter) {
504
+ const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
505
+ if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
506
+ }
507
+
508
+ if (allConditions.length > 0) {
509
+ queryOpts.where = and(...allConditions);
510
+ }
511
+
512
+ // OrderBy
513
+ const orderExpressions: unknown[] = [];
514
+ if (options.orderBy) {
515
+ const collection = getCollectionByPath(collectionPath, this.registry);
516
+ const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
517
+ if (orderByField) {
518
+ orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
519
+ }
520
+ }
521
+ orderExpressions.push(desc(idField));
522
+ if (orderExpressions.length > 0) {
523
+ queryOpts.orderBy = orderExpressions;
524
+ }
525
+
526
+ // Limit
527
+ const limitValue = options.searchString ? (options.limit || 50) : options.limit;
528
+ if (limitValue) queryOpts.limit = limitValue;
529
+
530
+ // Offset (numeric pagination)
531
+ if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
532
+
533
+ return queryOpts;
534
+ }
535
+
536
+ /**
537
+ * Extract cursor pagination conditions from startAfter options.
538
+ */
539
+ private buildCursorConditions(
540
+ table: PgTable<any>,
541
+ idField: AnyPgColumn,
542
+ idInfo: { fieldName: string; type: "string" | "number" },
543
+ options: { orderBy?: string; order?: "desc" | "asc"; startAfter?: Record<string, unknown> },
544
+ collectionPath?: string
545
+ ): SQL[] {
546
+ if (!options.startAfter) return [];
547
+ const cursor = options.startAfter;
548
+
549
+ if (options.orderBy) {
550
+ const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
551
+ const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
552
+ if (orderByField) {
553
+ const startAfterOrderValue = (cursor.values as Record<string, unknown> | undefined)?.[options.orderBy] ?? cursor[options.orderBy];
554
+ const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
555
+
556
+ if (startAfterOrderValue !== undefined && startAfterId !== undefined) {
557
+ if (options.order === "asc") {
558
+ return [or(
559
+ gt(orderByField, startAfterOrderValue),
560
+ and(eq(orderByField, startAfterOrderValue), gt(idField, startAfterId))
561
+ )!];
562
+ } else {
563
+ return [or(
564
+ lt(orderByField, startAfterOrderValue),
565
+ and(eq(orderByField, startAfterOrderValue), lt(idField, startAfterId))
566
+ )!];
567
+ }
568
+ }
569
+ }
570
+ } else {
571
+ const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
572
+ if (startAfterId !== undefined && startAfterId !== null) {
573
+ const idInfoArray = [idInfo] as Array<{ fieldName: string; type: "string" | "number" }>;
574
+ const parsedStartAfterIdObj = parseIdValues(startAfterId as string | number, idInfoArray);
575
+ return [lt(idField, parsedStartAfterIdObj[idInfo.fieldName])];
576
+ }
577
+ }
578
+
579
+ return [];
580
+ }
581
+
582
+ /**
583
+ * Fetch a single row by ID
584
+ */
585
+ async fetchOne<M extends Record<string, unknown>>(
586
+ collectionPath: string,
587
+ id: string | number,
588
+ databaseId?: string
589
+ ): Promise<Record<string, unknown> | undefined> {
590
+ const collection = getCollectionByPath(collectionPath, this.registry);
591
+ const table = getTableForCollection(collection, this.registry);
592
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
593
+ const idInfo = idInfoArray[0];
594
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
595
+
596
+ if (!idField) {
597
+ throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
598
+ }
599
+
600
+ const parsedIdObj = parseIdValues(id, idInfoArray);
601
+ const parsedId = parsedIdObj[idInfo.fieldName];
602
+
603
+ // Primary path: use db.query.findFirst with relation loading
604
+
605
+ const tableName = getTableName(table);
606
+
607
+ const qb = this.getQueryBuilder(tableName);
608
+ if (qb) {
609
+ try {
610
+ const withConfig = this.buildWithConfig(collection);
611
+
612
+ const row = await qb.findFirst({
613
+ where: eq(idField, parsedId),
614
+ with: withConfig
615
+ } as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
616
+
617
+ if (!row) return undefined;
618
+
619
+ const flatRow = this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
620
+
621
+ // Post-fetch joinPath relations that Drizzle's `with` can't express
622
+ await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
623
+
624
+ return flatRow;
625
+ } catch (e) {
626
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
627
+ logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
628
+ 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.");
629
+ }
630
+ logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
631
+ }
632
+ }
633
+
634
+ // Fallback: db.select + N+1 relation loading
635
+ const result = await this.db
636
+ .select()
637
+ .from(table)
638
+ .where(eq(idField, parsedId))
639
+ .limit(1);
640
+
641
+ if (result.length === 0) return undefined;
642
+
643
+ const raw = result[0] as M;
644
+ const values = await parseDataFromServer(raw, collection, this.db, this.registry) as Record<string, unknown>;
645
+
646
+ // Load relations based on cardinality (N+1 — only used in fallback)
647
+ const resolvedRelations = resolveCollectionRelations(collection);
648
+ const propertyKeys = new Set(Object.keys(collection.properties));
649
+
650
+ const relationPromises = Object.entries(resolvedRelations)
651
+ .filter(([key]) => propertyKeys.has(key))
652
+ .map(async ([key, relation]) => {
653
+ if (relation.cardinality === "many") {
654
+ const relatedRows = await this.relationService.fetchRelatedEntities(
655
+ collectionPath,
656
+ parsedId,
657
+ key,
658
+ {}
659
+ );
660
+ values[key] = relatedRows.map(e =>
661
+ createRelationRef(e.id, e.path)
662
+ );
663
+ } else if (relation.cardinality === "one") {
664
+ if (values[key] == null) {
665
+ try {
666
+ const relatedRows = await this.relationService.fetchRelatedEntities(
667
+ collectionPath,
668
+ parsedId,
669
+ key,
670
+ { limit: 1 }
671
+ );
672
+ if (relatedRows.length > 0) {
673
+ const e = relatedRows[0];
674
+ values[key] = createRelationRef(e.id, e.path);
675
+ }
676
+ } catch (e) {
677
+ logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
678
+ }
679
+ }
680
+ }
681
+ });
682
+
683
+ await Promise.all(relationPromises);
684
+
685
+ return {
686
+ ...values,
687
+ id: id.toString()
688
+ };
689
+ }
690
+
691
+ /**
692
+ * Unified method to fetch rows with optional search functionality
693
+ */
694
+ async fetchRowsWithConditions<M extends Record<string, unknown>>(
695
+ collectionPath: string,
696
+ options: {
697
+ filter?: FilterValues<Extract<keyof M, string>>;
698
+ orderBy?: string;
699
+ order?: "desc" | "asc";
700
+ limit?: number;
701
+ offset?: number;
702
+ startAfter?: Record<string, unknown>;
703
+ searchString?: string;
704
+ databaseId?: string;
705
+ vectorSearch?: VectorSearchParams;
706
+ logical?: LogicalCondition;
707
+ } = {}
708
+ ): Promise<Record<string, unknown>[]> {
709
+ const collection = getCollectionByPath(collectionPath, this.registry);
710
+ const table = getTableForCollection(collection, this.registry);
711
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
712
+ const idInfo = idInfoArray[0];
713
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
714
+
715
+ if (!idField) {
716
+ throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
717
+ }
718
+
719
+ // Primary path: use db.query.findMany with relation loading
720
+ // Skip when searchString is present (same reason as fetchCollectionForRest)
721
+ // Skip when collection has relations — lateral JOINs are catastrophically
722
+ // slow for large collections (7s+ for 350 rows). The db.select fallback
723
+ // path uses batch relation resolution which is 50x faster.
724
+
725
+ const tableName = getTableName(table);
726
+
727
+ const qb = this.getQueryBuilder(tableName);
728
+ const withConfig = this.buildWithConfig(collection);
729
+ const hasRelations = withConfig && Object.keys(withConfig).length > 0;
730
+
731
+ // Skip db.query path when vectorSearch is present — it doesn't support
732
+ // custom SELECT expressions needed for the _distance column.
733
+ if (qb && !options.searchString && !hasRelations && !options.vectorSearch) {
734
+ try {
735
+ const queryOpts = this.buildDrizzleQueryOptions<M>(
736
+ table, idField, idInfo, options, collectionPath, undefined
737
+ );
738
+
739
+
740
+ const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
741
+
742
+ const rows = (results as Record<string, unknown>[]).map(row =>
743
+ this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
744
+ );
745
+
746
+ return rows;
747
+ } catch (e) {
748
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
749
+ logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
750
+ 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.");
751
+ }
752
+ logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
753
+ }
754
+ }
755
+
756
+ // Fallback: db.select + processRowResults (N+1 for relations)
757
+ // When vectorSearch is present, add _distance to the SELECT.
758
+ let vectorMeta: { orderBy: SQL; filter?: SQL; distanceSelect: SQL } | undefined;
759
+ if (options.vectorSearch) {
760
+ vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
761
+ }
762
+
763
+ let query = vectorMeta
764
+ ? this.db.select({ table_row: table,
765
+ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
766
+ : this.db.select().from(table).$dynamic();
767
+ const allConditions: SQL[] = [];
768
+
769
+ if (options.searchString) {
770
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
771
+ options.searchString, collection.properties, table
772
+ );
773
+ if (searchConditions.length === 0) return [];
774
+ allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
775
+ }
776
+
777
+ if (options.filter) {
778
+ const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
779
+ if (filterConditions.length > 0) allConditions.push(...filterConditions);
780
+ }
781
+
782
+ if (options.logical) {
783
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
784
+ if (logicalCondition) allConditions.push(logicalCondition);
785
+ }
786
+
787
+ // Vector distance threshold filter
788
+ if (vectorMeta?.filter) {
789
+ allConditions.push(vectorMeta.filter);
790
+ }
791
+
792
+ if (allConditions.length > 0) {
793
+ const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
794
+ if (finalCondition) query = query.where(finalCondition);
795
+ }
796
+
797
+ const orderExpressions = [];
798
+ // Vector search overrides ORDER BY with distance (ascending = closest first)
799
+ if (vectorMeta) {
800
+ orderExpressions.push(asc(vectorMeta.orderBy));
801
+ } else if (options.orderBy) {
802
+ const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
803
+ if (orderByField) {
804
+ orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
805
+ }
806
+ }
807
+ orderExpressions.push(desc(idField));
808
+ if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
809
+
810
+ if (options.startAfter) {
811
+ const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
812
+ if (cursorConditions.length > 0) {
813
+ allConditions.push(...cursorConditions);
814
+ const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
815
+ if (finalCondition) query = query.where(finalCondition);
816
+ }
817
+ }
818
+
819
+ const limitValue = options.vectorSearch
820
+ ? (options.limit || 10)
821
+ : options.searchString ? (options.limit || 50) : options.limit;
822
+ if (limitValue) query = query.limit(limitValue);
823
+
824
+ // Offset (numeric pagination)
825
+ if (options.offset && options.offset > 0) query = query.offset(options.offset);
826
+
827
+ const rawResults = await query;
828
+
829
+ // When vector search is active, unwrap the nested select shape and
830
+ // attach _distance to each row's values.
831
+ const results = vectorMeta
832
+ ? (rawResults as { table_row: Record<string, unknown>; _distance: unknown }[]).map(r => ({
833
+ ...r.table_row,
834
+ _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
835
+ }))
836
+ : rawResults as Record<string, unknown>[];
837
+
838
+ return this.processRowResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
839
+ }
840
+
841
+ /**
842
+ * Fallback path used when db.query is unavailable.
843
+ * The primary path uses drizzleResultToRow which handles relation
844
+ * mapping without N+1 queries.
845
+ *
846
+ * Process raw database results into flat rows with relations.
847
+ */
848
+ private async processRowResults<M extends Record<string, unknown>>(
849
+ results: Record<string, unknown>[],
850
+ collection: CollectionConfig,
851
+ collectionPath: string,
852
+ idInfo: { fieldName: string; type: "string" | "number" },
853
+ _databaseId?: string,
854
+ skipRelations = false,
855
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
856
+ ): Promise<Record<string, unknown>[]> {
857
+ if (results.length === 0) return [];
858
+
859
+ // First pass: parse all rows WITHOUT per-row relation queries.
860
+ // We deliberately omit db/registry so parseDataFromServer only does type
861
+ // coercion (dates, numbers, FK→relation stubs for owning relations) and
862
+ // does NOT issue individual SQL queries for inverse relations. The second
863
+ // pass below batch-loads all inverse/many relations in O(1) queries per
864
+ // relation type, avoiding the N+1 that plagued the old path.
865
+ const parsedRows = await Promise.all(results.map(async (rawRow: Record<string, unknown>) => {
866
+ const values = await parseDataFromServer(rawRow as M, collection) as Record<string, unknown>;
867
+ const id = (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(rawRow as Record<string, unknown>, idInfoArray!) : String(rawRow[idInfo.fieldName]);
868
+ return {
869
+ rawRow,
870
+ values,
871
+ id
872
+ };
873
+ }));
874
+
875
+ if (!skipRelations) {
876
+ // Second pass: batch load missing one-to-one relations
877
+ const resolvedRelations = resolveCollectionRelations(collection);
878
+ const propertyKeys = new Set(Object.keys(collection.properties));
879
+
880
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
881
+ if (!propertyKeys.has(key) || relation.cardinality !== "one") continue;
882
+
883
+ const rowsMissingRelation = parsedRows.filter(item => {
884
+ const val = item.values[key];
885
+ if (val == null) return true;
886
+ if (typeof val === "object" && !Array.isArray(val) && (val as Record<string, unknown>).__type === "relation" && (val as Record<string, unknown>).data == null) return true;
887
+ return false;
888
+ });
889
+
890
+ if (rowsMissingRelation.length === 0) continue;
891
+
892
+ try {
893
+ const rowIds = rowsMissingRelation.map(item => item.rawRow[idInfo.fieldName] as string | number);
894
+ const relationResults = await this.relationService.batchFetchRelatedEntities(
895
+ collectionPath,
896
+ rowIds,
897
+ key,
898
+ relation
899
+ );
900
+
901
+ rowsMissingRelation.forEach(item => {
902
+ const id = item.rawRow[idInfo.fieldName] as string | number;
903
+ const relatedRow = relationResults.get(String(id));
904
+ if (relatedRow) {
905
+ item.values[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
906
+ }
907
+ });
908
+ } catch (e) {
909
+ logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
910
+ }
911
+ }
912
+
913
+ // Batch load many-cardinality relations (1 query per relation type
914
+ // instead of N queries per row)
915
+ const manyRelations = Object.entries(resolvedRelations)
916
+ .filter(([key, relation]) => propertyKeys.has(key) && relation.cardinality === "many");
917
+
918
+ for (const [key, relation] of manyRelations) {
919
+ try {
920
+ const rowIds = parsedRows.map(item => item.rawRow[idInfo.fieldName] as string | number);
921
+ const relationResults = await this.relationService.batchFetchRelatedEntitiesMany(
922
+ collectionPath,
923
+ rowIds,
924
+ key,
925
+ relation
926
+ );
927
+
928
+ parsedRows.forEach(item => {
929
+ const id = String(item.rawRow[idInfo.fieldName]);
930
+ const relatedRows = relationResults.get(id) || [];
931
+ item.values[key] = relatedRows.map(e =>
932
+ createRelationRefWithData(e.id, e.path, e)
933
+ );
934
+ });
935
+ } catch (e) {
936
+ logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
937
+ }
938
+ }
939
+ }
940
+
941
+ return parsedRows.map(item => ({
942
+ ...item.values,
943
+ id: item.id
944
+ }));
945
+ }
946
+
947
+ /**
948
+ * Fetch a collection of rows
949
+ */
950
+ async fetchCollection<M extends Record<string, unknown>>(
951
+ collectionPath: string,
952
+ options: {
953
+ filter?: FilterValues<Extract<keyof M, string>>;
954
+ orderBy?: string;
955
+ order?: "desc" | "asc";
956
+ limit?: number;
957
+ offset?: number;
958
+ startAfter?: Record<string, unknown>;
959
+ searchString?: string;
960
+ databaseId?: string;
961
+ vectorSearch?: VectorSearchParams;
962
+ } = {}
963
+ ): Promise<Record<string, unknown>[]> {
964
+ // Handle multi-segment paths by resolving through relations
965
+ if (collectionPath.includes("/")) {
966
+ return this.fetchCollectionFromPath<M>(collectionPath, options);
967
+ }
968
+
969
+ return this.fetchRowsWithConditions<M>(collectionPath, options);
970
+ }
971
+
972
+ /**
973
+ * Search rows by text
974
+ */
975
+ async searchRows<M extends Record<string, unknown>>(
976
+ collectionPath: string,
977
+ searchString: string,
978
+ options: {
979
+ filter?: FilterValues<Extract<keyof M, string>>;
980
+ orderBy?: string;
981
+ order?: "desc" | "asc";
982
+ limit?: number;
983
+ databaseId?: string;
984
+ } = {}
985
+ ): Promise<Record<string, unknown>[]> {
986
+ return this.fetchRowsWithConditions<M>(collectionPath, {
987
+ ...options,
988
+ searchString
989
+ });
990
+ }
991
+
992
+ /**
993
+ * Fetch collection from multi-segment path
994
+ */
995
+ private async fetchCollectionFromPath<M extends Record<string, unknown>>(
996
+ path: string,
997
+ options: {
998
+ filter?: FilterValues<Extract<keyof M, string>>;
999
+ orderBy?: string;
1000
+ order?: "desc" | "asc";
1001
+ limit?: number;
1002
+ startAfter?: Record<string, unknown>;
1003
+ searchString?: string;
1004
+ databaseId?: string;
1005
+ } = {}
1006
+ ): Promise<Record<string, unknown>[]> {
1007
+ const pathSegments = path.split("/").filter(p => p && p !== "undefined");
1008
+
1009
+ if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
1010
+ throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
1011
+ }
1012
+
1013
+ const rootCollectionPath = pathSegments[0];
1014
+ let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
1015
+ let currentId: string | number = pathSegments[1];
1016
+
1017
+ for (let i = 2; i < pathSegments.length; i += 2) {
1018
+ const relationKey = pathSegments[i];
1019
+ const resolvedRelations = resolveCollectionRelations(currentCollection);
1020
+ const relation = findRelation(resolvedRelations, relationKey);
1021
+
1022
+ if (!relation) {
1023
+ throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
1024
+ }
1025
+
1026
+ if (i === pathSegments.length - 1) {
1027
+ const rows = await this.relationService.fetchRelatedEntities<M>(
1028
+ currentCollection.slug,
1029
+ currentId,
1030
+ relationKey,
1031
+ options
1032
+ );
1033
+ // Convert RelatedRow[] from RelationService to flat rows
1034
+ return rows.map(row => ({
1035
+ ...row.values as Record<string, unknown>,
1036
+ id: row.id
1037
+ }));
1038
+ }
1039
+
1040
+ if (i + 1 < pathSegments.length) {
1041
+ const nextEntityId = pathSegments[i + 1];
1042
+ currentCollection = relation.target();
1043
+ currentId = nextEntityId;
1044
+ }
1045
+ }
1046
+
1047
+ throw new Error(`Unable to resolve path: ${path}`);
1048
+ }
1049
+
1050
+ /**
1051
+ * Count rows in a collection
1052
+ */
1053
+ async count<M extends Record<string, unknown>>(
1054
+ collectionPath: string,
1055
+ options: {
1056
+ filter?: FilterValues<Extract<keyof M, string>>;
1057
+ searchString?: string;
1058
+ databaseId?: string;
1059
+ } = {}
1060
+ ): Promise<number> {
1061
+ if (collectionPath.includes("/")) {
1062
+ return this.countEntitiesFromPath<M>(collectionPath, options);
1063
+ }
1064
+
1065
+ const collection = getCollectionByPath(collectionPath, this.registry);
1066
+ const table = getTableForCollection(collection, this.registry);
1067
+
1068
+ let query = this.db.select({ count: count() }).from(table).$dynamic();
1069
+ const allConditions: SQL[] = [];
1070
+
1071
+ if (options.searchString) {
1072
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1073
+ options.searchString, collection.properties, table
1074
+ );
1075
+ if (searchConditions.length === 0) return 0;
1076
+ allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
1077
+ }
1078
+
1079
+ if (options.filter) {
1080
+ const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
1081
+ if (filterConditions.length > 0) allConditions.push(...filterConditions);
1082
+ }
1083
+
1084
+ if (allConditions.length > 0) {
1085
+ const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
1086
+ if (finalCondition) query = query.where(finalCondition);
1087
+ }
1088
+
1089
+ const result = await query;
1090
+ return Number(result[0]?.count || 0);
1091
+ }
1092
+
1093
+ /**
1094
+ * Count rows from multi-segment path
1095
+ */
1096
+ private async countEntitiesFromPath<M extends Record<string, unknown>>(
1097
+ path: string,
1098
+ options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
1099
+ ): Promise<number> {
1100
+ const pathSegments = path.split("/").filter(p => p && p !== "undefined");
1101
+
1102
+ if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
1103
+ throw new Error(`Invalid relation path: ${path}`);
1104
+ }
1105
+
1106
+ const rootCollectionPath = pathSegments[0];
1107
+ let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
1108
+ let currentId: string | number = pathSegments[1];
1109
+
1110
+ for (let i = 2; i < pathSegments.length; i += 2) {
1111
+ const relationKey = pathSegments[i];
1112
+ const resolvedRelations = resolveCollectionRelations(currentCollection);
1113
+ const relation = findRelation(resolvedRelations, relationKey);
1114
+
1115
+ if (!relation) {
1116
+ throw new Error(`Relation '${relationKey}' not found`);
1117
+ }
1118
+
1119
+ if (i === pathSegments.length - 1) {
1120
+ return this.relationService.countRelatedEntities(
1121
+ currentCollection.slug,
1122
+ currentId,
1123
+ relationKey,
1124
+ options
1125
+ );
1126
+ }
1127
+
1128
+ if (i + 1 < pathSegments.length) {
1129
+ currentCollection = relation.target();
1130
+ currentId = pathSegments[i + 1];
1131
+ }
1132
+ }
1133
+
1134
+ throw new Error(`Unable to count for path: ${path}`);
1135
+ }
1136
+
1137
+ /**
1138
+ * Check if a field value is unique
1139
+ */
1140
+ async checkUniqueField(
1141
+ collectionPath: string,
1142
+ fieldName: string,
1143
+ value: unknown,
1144
+ excludeEntityId?: string,
1145
+ _databaseId?: string
1146
+ ): Promise<boolean> {
1147
+ if (value === undefined || value === null) return true;
1148
+
1149
+ const collection = getCollectionByPath(collectionPath, this.registry);
1150
+ const table = getTableForCollection(collection, this.registry);
1151
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1152
+ const idInfo = idInfoArray[0];
1153
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1154
+ const field = table[fieldName as keyof typeof table] as AnyPgColumn;
1155
+
1156
+ if (!field) return true;
1157
+
1158
+ const parsedExcludeId = excludeEntityId ? parseIdValues(excludeEntityId, idInfoArray)[idInfo.fieldName] : undefined;
1159
+ const conditions = DrizzleConditionBuilder.buildUniqueFieldCondition(
1160
+ field,
1161
+ value,
1162
+ idField,
1163
+ parsedExcludeId
1164
+ );
1165
+
1166
+ const result = await this.db
1167
+ .select({ count: count() })
1168
+ .from(table)
1169
+ .where(and(...conditions));
1170
+
1171
+ const countResult = Number(result[0]?.count || 0);
1172
+ return countResult === 0;
1173
+ }
1174
+
1175
+ /**
1176
+ * Get the RelationService instance for external use
1177
+ */
1178
+ getRelationService(): RelationService {
1179
+ return this.relationService;
1180
+ }
1181
+
1182
+ // =============================================================
1183
+ // REST API INCLUDE-AWARE METHODS
1184
+ // =============================================================
1185
+
1186
+ /**
1187
+ * Fetch a collection of rows with optional relation includes.
1188
+ * When `include` is provided, only the specified relations are populated
1189
+ * with full row data (not just { id, path, __type }).
1190
+ * When `include` is absent, no relation queries are made (fast path).
1191
+ *
1192
+ * @param include - Array of relation keys to populate, or ["*"] for all
1193
+ */
1194
+ async fetchCollectionForRest<M extends Record<string, unknown>>(
1195
+ collectionPath: string,
1196
+ options: {
1197
+ filter?: FilterValues<Extract<keyof M, string>>;
1198
+ orderBy?: string;
1199
+ order?: "desc" | "asc";
1200
+ limit?: number;
1201
+ offset?: number;
1202
+ startAfter?: Record<string, unknown>;
1203
+ searchString?: string;
1204
+ databaseId?: string;
1205
+ vectorSearch?: VectorSearchParams;
1206
+ } = {},
1207
+ include?: string[]
1208
+ ): Promise<Record<string, unknown>[]> {
1209
+ const collection = getCollectionByPath(collectionPath, this.registry);
1210
+ const table = getTableForCollection(collection, this.registry);
1211
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1212
+ const idInfo = idInfoArray[0];
1213
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1214
+
1215
+ // Primary path: use db.query.findMany
1216
+ // NOTE: Skip db.query path when searchString is present because
1217
+ // Drizzle's relational query API doesn't properly apply raw SQL
1218
+ // ILIKE conditions — the fallback db.select path handles them correctly.
1219
+
1220
+ const tableName = getTableName(table);
1221
+
1222
+ const qb = this.getQueryBuilder(tableName);
1223
+ // Skip db.query path when vectorSearch is present — needs custom SELECT
1224
+ if (qb && !options.searchString && !options.vectorSearch) {
1225
+ try {
1226
+ const withConfig = (include && include.length > 0)
1227
+ ? this.buildWithConfig(collection, include)
1228
+ : undefined;
1229
+
1230
+ const queryOpts = this.buildDrizzleQueryOptions<M>(
1231
+ table, idField, idInfo, options, collectionPath, withConfig
1232
+ );
1233
+
1234
+
1235
+ const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1236
+
1237
+ const restRows = (results as Record<string, unknown>[]).map(row =>
1238
+ this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
1239
+ );
1240
+
1241
+ // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1242
+ await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
1243
+
1244
+ return restRows;
1245
+ } catch (e) {
1246
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1247
+ logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
1248
+ 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.");
1249
+ }
1250
+ logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
1251
+ }
1252
+ }
1253
+
1254
+ // Fallback: fetch base rows without relations
1255
+ const rows = await this.fetchRowsWithConditionsRaw<M>(collectionPath, options);
1256
+
1257
+ if (!include || include.length === 0) {
1258
+ return rows.map(row => ({
1259
+ ...row,
1260
+ id: (idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
1261
+ }));
1262
+ }
1263
+
1264
+ // Fallback relation loading via batch
1265
+ const resolvedRelations = resolveCollectionRelations(collection);
1266
+ const propertyKeys = new Set(Object.keys(collection.properties || {}));
1267
+ const shouldInclude = (key: string) =>
1268
+ include[0] === "*" || include.includes(key);
1269
+
1270
+ const rowIds = rows.map(e => e[idInfo.fieldName] as string | number);
1271
+
1272
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
1273
+ if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "one") continue;
1274
+ try {
1275
+ const batchResults = await this.relationService.batchFetchRelatedEntities(
1276
+ collectionPath, rowIds, key, relation
1277
+ );
1278
+ for (const row of rows) {
1279
+ const eid = row[idInfo.fieldName] as string | number;
1280
+ const related = batchResults.get(String(eid));
1281
+ if (related) {
1282
+ (row as Record<string, unknown>)[key] = { ...related.values,
1283
+ id: related.id };
1284
+ }
1285
+ }
1286
+ } catch (e) {
1287
+ logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
1288
+ }
1289
+ }
1290
+
1291
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
1292
+ if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "many") continue;
1293
+ try {
1294
+ const batchResults = await this.batchFetchManyRelatedRows(
1295
+ collectionPath, rowIds, key
1296
+ );
1297
+ for (const row of rows) {
1298
+ const eid = row[idInfo.fieldName] as string | number;
1299
+ const relatedList = batchResults.get(String(eid)) || [];
1300
+ (row as Record<string, unknown>)[key] = relatedList;
1301
+ }
1302
+ } catch (e) {
1303
+ logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
1304
+ }
1305
+ }
1306
+
1307
+ return rows.map(row => ({
1308
+ ...row,
1309
+ id: (idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
1310
+ }));
1311
+ }
1312
+
1313
+ /**
1314
+ * Fetch a single row with optional relation includes for REST API.
1315
+ */
1316
+ async fetchOneForRest<M extends Record<string, unknown>>(
1317
+ collectionPath: string,
1318
+ id: string | number,
1319
+ include?: string[],
1320
+ databaseId?: string
1321
+ ): Promise<Record<string, unknown> | null> {
1322
+ const collection = getCollectionByPath(collectionPath, this.registry);
1323
+ const table = getTableForCollection(collection, this.registry);
1324
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1325
+ const idInfo = idInfoArray[0];
1326
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1327
+
1328
+ const parsedIdObj = parseIdValues(id, idInfoArray);
1329
+ const parsedId = parsedIdObj[idInfo.fieldName];
1330
+
1331
+ // Primary path: use db.query.findFirst
1332
+
1333
+ const tableName = getTableName(table);
1334
+
1335
+ const qb = this.getQueryBuilder(tableName);
1336
+ if (qb) {
1337
+ try {
1338
+ const withConfig = (include && include.length > 0)
1339
+ ? this.buildWithConfig(collection, include)
1340
+ : undefined;
1341
+
1342
+
1343
+ const row = await qb.findFirst({
1344
+ where: eq(idField, parsedId),
1345
+ ...(withConfig ? { with: withConfig } : {})
1346
+ } as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
1347
+
1348
+ if (!row) return null;
1349
+
1350
+ const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
1351
+
1352
+ // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1353
+ await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
1354
+
1355
+ return restRow;
1356
+ } catch (e) {
1357
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1358
+ logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
1359
+ 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.");
1360
+ }
1361
+ logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
1362
+ }
1363
+ }
1364
+
1365
+ // Fallback: db.select + N+1 relation loading
1366
+ const result = await this.db
1367
+ .select()
1368
+ .from(table)
1369
+ .where(eq(idField, parsedId))
1370
+ .limit(1);
1371
+
1372
+ if (result.length === 0) return null;
1373
+
1374
+ const raw = result[0] as Record<string, unknown>;
1375
+ const flatEntity: Record<string, unknown> = { ...raw,
1376
+ id: (idInfoArray.length > 1) ? buildCompositeId(raw as Record<string, unknown>, idInfoArray) : String(raw[idInfo.fieldName]) };
1377
+
1378
+ if (!include || include.length === 0) {
1379
+ return flatEntity;
1380
+ }
1381
+
1382
+ // Fallback relation population
1383
+ const resolvedRelations = resolveCollectionRelations(collection);
1384
+ const propertyKeys = new Set(Object.keys(collection.properties || {}));
1385
+ const shouldInclude = (key: string) =>
1386
+ include[0] === "*" || include.includes(key);
1387
+
1388
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
1389
+ if (!propertyKeys.has(key) || !shouldInclude(key)) continue;
1390
+
1391
+ try {
1392
+ const relatedRows = await this.relationService.fetchRelatedEntities(
1393
+ collectionPath, parsedId, key, {}
1394
+ );
1395
+
1396
+ if (relation.cardinality === "one") {
1397
+ if (relatedRows.length > 0) {
1398
+ const e = relatedRows[0];
1399
+ flatEntity[key] = { id: e.id,
1400
+ ...e.values };
1401
+ }
1402
+ } else {
1403
+ flatEntity[key] = relatedRows.map(e => ({
1404
+ id: e.id,
1405
+ ...e.values
1406
+ }));
1407
+ }
1408
+ } catch (e) {
1409
+ logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
1410
+ }
1411
+ }
1412
+
1413
+ return flatEntity;
1414
+ }
1415
+
1416
+ /**
1417
+ * Fetch raw rows without any relation processing (for REST fast path)
1418
+ */
1419
+ private async fetchRowsWithConditionsRaw<M extends Record<string, unknown>>(
1420
+ collectionPath: string,
1421
+ options: {
1422
+ filter?: FilterValues<Extract<keyof M, string>>;
1423
+ orderBy?: string;
1424
+ order?: "desc" | "asc";
1425
+ limit?: number;
1426
+ offset?: number;
1427
+ startAfter?: Record<string, unknown>;
1428
+ searchString?: string;
1429
+ vectorSearch?: VectorSearchParams;
1430
+ } = {}
1431
+ ): Promise<Record<string, unknown>[]> {
1432
+ const collection = getCollectionByPath(collectionPath, this.registry);
1433
+ const table = getTableForCollection(collection, this.registry);
1434
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1435
+ const idInfo = idInfoArray[0];
1436
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1437
+
1438
+ let vectorMeta: { orderBy: SQL; filter?: SQL; distanceSelect: SQL } | undefined;
1439
+ if (options.vectorSearch) {
1440
+ vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
1441
+ }
1442
+
1443
+ let query = vectorMeta
1444
+ ? this.db.select({ table_row: table,
1445
+ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1446
+ : this.db.select().from(table).$dynamic();
1447
+ const allConditions: SQL[] = [];
1448
+
1449
+ if (options.searchString) {
1450
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1451
+ options.searchString, collection.properties, table
1452
+ );
1453
+ if (searchConditions.length === 0) return [];
1454
+ allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
1455
+ }
1456
+
1457
+ if (options.filter) {
1458
+ const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
1459
+ if (filterConditions.length > 0) allConditions.push(...filterConditions);
1460
+ }
1461
+
1462
+ if (vectorMeta?.filter) {
1463
+ allConditions.push(vectorMeta.filter);
1464
+ }
1465
+
1466
+ if (allConditions.length > 0) {
1467
+ const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
1468
+ if (finalCondition) query = query.where(finalCondition);
1469
+ }
1470
+
1471
+ const orderExpressions = [];
1472
+ if (vectorMeta) {
1473
+ orderExpressions.push(asc(vectorMeta.orderBy));
1474
+ } else if (options.orderBy) {
1475
+ const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1476
+ if (orderByField) {
1477
+ orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
1478
+ }
1479
+ }
1480
+ orderExpressions.push(desc(idField));
1481
+ if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
1482
+
1483
+ const limitValue = options.vectorSearch
1484
+ ? (options.limit || 10)
1485
+ : options.searchString ? (options.limit || 50) : options.limit;
1486
+ if (limitValue) query = query.limit(limitValue);
1487
+
1488
+ // Offset (numeric pagination)
1489
+ if (options.offset && options.offset > 0) query = query.offset(options.offset);
1490
+
1491
+ const rawResults = await query;
1492
+
1493
+ if (vectorMeta) {
1494
+ return (rawResults as { table_row: Record<string, unknown>; _distance: unknown }[]).map(r => ({
1495
+ ...r.table_row,
1496
+ _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
1497
+ }));
1498
+ }
1499
+
1500
+ return rawResults as Record<string, unknown>[];
1501
+ }
1502
+
1503
+ /**
1504
+ * Check if the Drizzle instance has the relational query API available
1505
+ * for a given collection path.
1506
+ * Note: Primary path now uses inline `getQueryBuilder()` checks.
1507
+ */
1508
+ private hasDrizzleQueryAPI(collectionPath: string): boolean {
1509
+
1510
+ const qb = this.getQueryBuilder("__probe__");
1511
+ if (!qb) {
1512
+ // If getQueryBuilder returns undefined even for a probe, query API is not available
1513
+ return false;
1514
+ }
1515
+ const collection = getCollectionByPath(collectionPath, this.registry);
1516
+ const table = getTableForCollection(collection, this.registry);
1517
+ const tableName = getTableName(table);
1518
+ return !!this.getQueryBuilder(tableName);
1519
+ }
1520
+
1521
+ /**
1522
+ * Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
1523
+ * for efficient JOIN-based relation loading.
1524
+ * Returns null if the API is not available or the query fails.
1525
+ * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
1526
+ */
1527
+ private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
1528
+ collectionPath: string,
1529
+ collection: CollectionConfig,
1530
+ options: {
1531
+ filter?: FilterValues<Extract<keyof M, string>>;
1532
+ orderBy?: string;
1533
+ order?: "desc" | "asc";
1534
+ limit?: number;
1535
+ },
1536
+ include: string[],
1537
+ idInfo: { fieldName: string; type: "string" | "number" },
1538
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
1539
+ ): Promise<Record<string, unknown>[] | null> {
1540
+ try {
1541
+
1542
+ const table = getTableForCollection(collection, this.registry);
1543
+ const tableName = getTableName(table);
1544
+ const queryTarget = this.getQueryBuilder(tableName);
1545
+
1546
+ if (!queryTarget?.findMany) return null;
1547
+
1548
+ // Build the `with` config from include array
1549
+ const resolvedRelations = resolveCollectionRelations(collection);
1550
+ const withConfig: Record<string, boolean> = {};
1551
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
1552
+ if (include[0] === "*" || include.includes(key)) {
1553
+ // Use the Drizzle relation name (from the schema)
1554
+ const drizzleRelName = relation.relationName || key;
1555
+ withConfig[drizzleRelName] = true;
1556
+ }
1557
+ }
1558
+
1559
+ // Build query options
1560
+ const queryOpts: Record<string, unknown> = { with: withConfig };
1561
+ if (options.limit) queryOpts.limit = options.limit;
1562
+
1563
+ // Build where clause
1564
+ if (options.filter) {
1565
+ const filterConditions = this.buildFilterConditions(
1566
+ options.filter, table, collectionPath
1567
+ );
1568
+ if (filterConditions.length > 0) {
1569
+ queryOpts.where = and(...filterConditions);
1570
+ }
1571
+ }
1572
+
1573
+ // Build orderBy
1574
+ if (options.orderBy) {
1575
+ const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1576
+ if (orderByField) {
1577
+ queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
1578
+ }
1579
+ }
1580
+
1581
+
1582
+ const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1583
+
1584
+ // Flatten the nested Drizzle results into REST format
1585
+ return results.map((row: Record<string, unknown>) => {
1586
+ const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]) };
1587
+ for (const [k, v] of Object.entries(row)) {
1588
+ if (k === idInfo.fieldName) continue;
1589
+ if (Array.isArray(v)) {
1590
+ // Many relation — flatten each nested row
1591
+ flat[k] = v.map((item: Record<string, unknown>) => {
1592
+ // Junction table rows may have the target nested, flatten those
1593
+ const keys = Object.keys(item);
1594
+ // If it looks like a junction row (only FKs + nested objects), extract nested
1595
+ const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1596
+ if (nestedObj && keys.length <= 3) {
1597
+ const nested = item[nestedObj] as Record<string, unknown>;
1598
+ return { ...nested,
1599
+ id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
1600
+ }
1601
+ return { ...item,
1602
+ id: String(item.id ?? item[Object.keys(item)[0]]) };
1603
+ });
1604
+ } else if (typeof v === "object" && v !== null) {
1605
+ // One-to-one relation — inline the object
1606
+ const relObj = v as Record<string, unknown>;
1607
+ flat[k] = { ...relObj,
1608
+ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
1609
+ } else {
1610
+ flat[k] = v;
1611
+ }
1612
+ }
1613
+ return flat;
1614
+ });
1615
+ } catch (e) {
1616
+ logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
1617
+ return null;
1618
+ }
1619
+ }
1620
+
1621
+ /**
1622
+ * Fallback path used when db.query is unavailable.
1623
+ * The primary path uses db.query.findMany with `with` config, which
1624
+ * loads all relations in a single query.
1625
+ *
1626
+ * Batch fetch many-to-many related rows for multiple parent IDs.
1627
+ * Groups results by parent ID to avoid N+1.
1628
+ */
1629
+ private async batchFetchManyRelatedRows(
1630
+ parentCollectionPath: string,
1631
+ parentIds: (string | number)[],
1632
+ relationKey: string
1633
+ ): Promise<Map<string, Record<string, unknown>[]>> {
1634
+ if (parentIds.length === 0) return new Map();
1635
+
1636
+ // Resolve the relation definition so we can use the true batch method
1637
+ const collection = getCollectionByPath(parentCollectionPath, this.registry);
1638
+ const resolvedRelations = resolveCollectionRelations(collection);
1639
+ const relation = resolvedRelations[relationKey];
1640
+
1641
+ if (!relation) {
1642
+ logger.warn(`[batchFetchManyRelatedRows] Relation '${relationKey}' not found, skipping`);
1643
+ return new Map();
1644
+ }
1645
+
1646
+ // Delegate to RelationService.batchFetchRelatedEntitiesMany which
1647
+ // uses a single SQL query with IN(...) — O(1) instead of O(N).
1648
+ // RelationService returns RelatedRow shapes (id + path + values) — flatten to plain rows here.
1649
+ const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(
1650
+ parentCollectionPath,
1651
+ parentIds,
1652
+ relationKey,
1653
+ relation
1654
+ );
1655
+ const flatMap = new Map<string, Record<string, unknown>[]>();
1656
+ for (const [key, rows] of entityMap) {
1657
+ flatMap.set(key, rows.map(e => ({ ...e.values, id: e.id })));
1658
+ }
1659
+ return flatMap;
1660
+ }
1661
+ }