@rebasepro/server-postgresql 0.0.1-canary.4d4fb3e

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 (147) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +106 -0
  3. package/build-errors.txt +37 -0
  4. package/dist/common/src/collections/CollectionRegistry.d.ts +48 -0
  5. package/dist/common/src/collections/index.d.ts +1 -0
  6. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  7. package/dist/common/src/index.d.ts +3 -0
  8. package/dist/common/src/util/builders.d.ts +57 -0
  9. package/dist/common/src/util/callbacks.d.ts +6 -0
  10. package/dist/common/src/util/collections.d.ts +11 -0
  11. package/dist/common/src/util/common.d.ts +2 -0
  12. package/dist/common/src/util/conditions.d.ts +26 -0
  13. package/dist/common/src/util/entities.d.ts +36 -0
  14. package/dist/common/src/util/enums.d.ts +3 -0
  15. package/dist/common/src/util/index.d.ts +16 -0
  16. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  17. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  18. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  19. package/dist/common/src/util/paths.d.ts +14 -0
  20. package/dist/common/src/util/permissions.d.ts +5 -0
  21. package/dist/common/src/util/references.d.ts +2 -0
  22. package/dist/common/src/util/relations.d.ts +12 -0
  23. package/dist/common/src/util/resolutions.d.ts +72 -0
  24. package/dist/common/src/util/storage.d.ts +24 -0
  25. package/dist/index.es.js +10635 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +10643 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +112 -0
  30. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
  31. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
  32. package/dist/server-postgresql/src/auth/services.d.ts +188 -0
  33. package/dist/server-postgresql/src/cli.d.ts +1 -0
  34. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
  35. package/dist/server-postgresql/src/connection.d.ts +7 -0
  36. package/dist/server-postgresql/src/data-transformer.d.ts +36 -0
  37. package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
  38. package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
  39. package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
  40. package/dist/server-postgresql/src/index.d.ts +13 -0
  41. package/dist/server-postgresql/src/interfaces.d.ts +18 -0
  42. package/dist/server-postgresql/src/schema/auth-schema.d.ts +767 -0
  43. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
  44. package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
  45. package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
  46. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +195 -0
  47. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
  48. package/dist/server-postgresql/src/services/RelationService.d.ts +92 -0
  49. package/dist/server-postgresql/src/services/entity-helpers.d.ts +24 -0
  50. package/dist/server-postgresql/src/services/entityService.d.ts +102 -0
  51. package/dist/server-postgresql/src/services/index.d.ts +4 -0
  52. package/dist/server-postgresql/src/services/realtimeService.d.ts +186 -0
  53. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
  54. package/dist/server-postgresql/src/websocket.d.ts +5 -0
  55. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  56. package/dist/types/src/controllers/auth.d.ts +117 -0
  57. package/dist/types/src/controllers/client.d.ts +58 -0
  58. package/dist/types/src/controllers/collection_registry.d.ts +44 -0
  59. package/dist/types/src/controllers/customization_controller.d.ts +54 -0
  60. package/dist/types/src/controllers/data.d.ts +141 -0
  61. package/dist/types/src/controllers/data_driver.d.ts +168 -0
  62. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  63. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  64. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  65. package/dist/types/src/controllers/index.d.ts +17 -0
  66. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  67. package/dist/types/src/controllers/navigation.d.ts +213 -0
  68. package/dist/types/src/controllers/registry.d.ts +51 -0
  69. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  70. package/dist/types/src/controllers/side_entity_controller.d.ts +89 -0
  71. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  72. package/dist/types/src/controllers/storage.d.ts +173 -0
  73. package/dist/types/src/index.d.ts +4 -0
  74. package/dist/types/src/rebase_context.d.ts +101 -0
  75. package/dist/types/src/types/backend.d.ts +533 -0
  76. package/dist/types/src/types/builders.d.ts +14 -0
  77. package/dist/types/src/types/chips.d.ts +5 -0
  78. package/dist/types/src/types/collections.d.ts +812 -0
  79. package/dist/types/src/types/data_source.d.ts +64 -0
  80. package/dist/types/src/types/entities.d.ts +145 -0
  81. package/dist/types/src/types/entity_actions.d.ts +98 -0
  82. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  83. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  84. package/dist/types/src/types/entity_overrides.d.ts +9 -0
  85. package/dist/types/src/types/entity_views.d.ts +61 -0
  86. package/dist/types/src/types/export_import.d.ts +21 -0
  87. package/dist/types/src/types/index.d.ts +22 -0
  88. package/dist/types/src/types/locales.d.ts +4 -0
  89. package/dist/types/src/types/modify_collections.d.ts +5 -0
  90. package/dist/types/src/types/plugins.d.ts +225 -0
  91. package/dist/types/src/types/properties.d.ts +1091 -0
  92. package/dist/types/src/types/property_config.d.ts +70 -0
  93. package/dist/types/src/types/relations.d.ts +336 -0
  94. package/dist/types/src/types/slots.d.ts +228 -0
  95. package/dist/types/src/types/translations.d.ts +826 -0
  96. package/dist/types/src/types/user_management_delegate.d.ts +120 -0
  97. package/dist/types/src/types/websockets.d.ts +78 -0
  98. package/dist/types/src/users/index.d.ts +2 -0
  99. package/dist/types/src/users/roles.d.ts +22 -0
  100. package/dist/types/src/users/user.d.ts +46 -0
  101. package/jest-all.log +3128 -0
  102. package/jest.log +49 -0
  103. package/package.json +93 -0
  104. package/src/PostgresBackendDriver.ts +1024 -0
  105. package/src/PostgresBootstrapper.ts +232 -0
  106. package/src/auth/ensure-tables.ts +309 -0
  107. package/src/auth/services.ts +740 -0
  108. package/src/cli.ts +347 -0
  109. package/src/collections/PostgresCollectionRegistry.ts +96 -0
  110. package/src/connection.ts +62 -0
  111. package/src/data-transformer.ts +569 -0
  112. package/src/databasePoolManager.ts +84 -0
  113. package/src/history/HistoryService.ts +257 -0
  114. package/src/history/ensure-history-table.ts +45 -0
  115. package/src/index.ts +13 -0
  116. package/src/interfaces.ts +60 -0
  117. package/src/schema/auth-schema.ts +146 -0
  118. package/src/schema/generate-drizzle-schema-logic.ts +618 -0
  119. package/src/schema/generate-drizzle-schema.ts +151 -0
  120. package/src/services/BranchService.ts +237 -0
  121. package/src/services/EntityFetchService.ts +1447 -0
  122. package/src/services/EntityPersistService.ts +351 -0
  123. package/src/services/RelationService.ts +1012 -0
  124. package/src/services/entity-helpers.ts +121 -0
  125. package/src/services/entityService.ts +209 -0
  126. package/src/services/index.ts +13 -0
  127. package/src/services/realtimeService.ts +1005 -0
  128. package/src/utils/drizzle-conditions.ts +999 -0
  129. package/src/websocket.ts +487 -0
  130. package/test/auth-services.test.ts +569 -0
  131. package/test/branchService.test.ts +357 -0
  132. package/test/drizzle-conditions.test.ts +895 -0
  133. package/test/entityService.errors.test.ts +352 -0
  134. package/test/entityService.relations.test.ts +912 -0
  135. package/test/entityService.subcollection-search.test.ts +516 -0
  136. package/test/entityService.test.ts +977 -0
  137. package/test/generate-drizzle-schema.test.ts +795 -0
  138. package/test/historyService.test.ts +126 -0
  139. package/test/postgresDataDriver.test.ts +556 -0
  140. package/test/realtimeService.test.ts +276 -0
  141. package/test/relations.test.ts +662 -0
  142. package/test_drizzle_mock.js +3 -0
  143. package/test_find_changed.mjs +30 -0
  144. package/test_output.txt +3145 -0
  145. package/tsconfig.json +49 -0
  146. package/tsconfig.prod.json +20 -0
  147. package/vite.config.ts +82 -0
@@ -0,0 +1,1447 @@
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 { Entity, EntityCollection, FilterValues, Relation } from "@rebasepro/types";
4
+ import { resolveCollectionRelations } from "@rebasepro/common";
5
+ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
6
+ import {
7
+ getCollectionByPath,
8
+ getTableForCollection,
9
+ getPrimaryKeys,
10
+ parseIdValues,
11
+ buildCompositeId
12
+ } from "./entity-helpers";
13
+ import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
14
+ import { RelationService } from "./RelationService";
15
+ import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
16
+ import { DrizzleClient } from "../interfaces";
17
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
18
+
19
+ /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
20
+ type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
21
+
22
+ /**
23
+ * Service for handling all entity read operations.
24
+ * Handles fetching, searching, counting, and filtering entities.
25
+ */
26
+ export class EntityFetchService {
27
+ private relationService: RelationService;
28
+
29
+ constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) {
30
+ this.relationService = new RelationService(db, registry);
31
+ }
32
+
33
+ /**
34
+ * Get the relational query builder for a given table name.
35
+ * Safely narrows the DrizzleClient union type to access db.query[tableName].
36
+ */
37
+ private getQueryBuilder(tableName: string): RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined {
38
+ const query = (this.db as { query?: DbQueryAccessor }).query;
39
+ return query?.[tableName] as RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined;
40
+ }
41
+
42
+ /**
43
+ * Build filter conditions from FilterValues
44
+ * Delegates to DrizzleConditionBuilder.buildFilterConditions
45
+ */
46
+ buildFilterConditions<M extends Record<string, any>>(
47
+ filter: FilterValues<Extract<keyof M, string>>,
48
+ table: PgTable<any>,
49
+ collectionPath: string
50
+ ): SQL[] {
51
+ return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath);
52
+ }
53
+
54
+ // =============================================================
55
+ // DRIZZLE QUERY HELPERS
56
+ // =============================================================
57
+
58
+ /**
59
+ * Build the `with` config for Drizzle's relational query API.
60
+ * Converts collection relations to a Drizzle-compatible `with` object.
61
+ *
62
+ * When `include` is provided, only those relations are loaded.
63
+ * When `include` is absent, ALL relations are loaded (CMS path).
64
+ *
65
+ * Automatically detects many-to-many junction tables and nests
66
+ * the target relation so actual entity data is returned.
67
+ */
68
+ private buildWithConfig(
69
+ collection: EntityCollection,
70
+ include?: string[]
71
+ ): Record<string, boolean | { with: Record<string, boolean> }> {
72
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
73
+ const propertyKeys = new Set(Object.keys(collection.properties || {}));
74
+ const withConfig: Record<string, boolean | { with: Record<string, boolean> }> = {};
75
+
76
+ const shouldInclude = (key: string) =>
77
+ !include || include.length === 0 || include[0] === "*" || include.includes(key);
78
+
79
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
80
+ if (!shouldInclude(key)) continue;
81
+ // Only include relations that map to actual properties (or include all for REST)
82
+ if (!include && !propertyKeys.has(key)) continue;
83
+
84
+ const drizzleRelName = relation.relationName || key;
85
+
86
+ // Skip relations that use joinPath as they are not mapped in Drizzle schemas
87
+ if (relation.joinPath && relation.joinPath.length > 0) {
88
+ continue;
89
+ }
90
+
91
+ // Detect many-to-many junction tables:
92
+ // If the relation goes through a junction table (relation.through exists or
93
+ // the Drizzle schema maps to a junction table), we need two-level with.
94
+ if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
95
+ // The Drizzle relation points to the junction table.
96
+ // We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
97
+ // The target FK name is the relation on the junction table that points to the actual target.
98
+ const targetFkName = this.getJunctionTargetRelationName(relation, collection);
99
+ if (targetFkName) {
100
+ withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
101
+ } else {
102
+ withConfig[drizzleRelName] = true;
103
+ }
104
+ } else {
105
+ withConfig[drizzleRelName] = true;
106
+ }
107
+ }
108
+
109
+ return withConfig;
110
+ }
111
+
112
+ /**
113
+ * Detect if a many-to-many relation uses a junction table in the Drizzle schema.
114
+ */
115
+ private isJunctionRelation(relation: Relation, _collection: EntityCollection): boolean {
116
+ // If `through` is defined, it's explicitly a junction relation
117
+ if (relation.through) return true;
118
+ // If joinPath has an intermediate table, it's likely junction-based
119
+ if (relation.joinPath && relation.joinPath.length > 1) return true;
120
+ return false;
121
+ }
122
+
123
+ /**
124
+ * Get the Drizzle relation name on the junction table that points to the actual target entity.
125
+ * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
126
+ */
127
+ private getJunctionTargetRelationName(relation: Relation, _collection: EntityCollection): string | null {
128
+ if (relation.through) {
129
+ // The junction relation on the junction table pointing to the target
130
+ // uses the targetColumn name as the Drizzle relation name
131
+ return relation.through.targetColumn.replace(/_id$/, "_id");
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Convert a db.query result row (with nested relation objects) to an Entity<M>.
138
+ * Handles:
139
+ * - The { id, path, values } wrapping
140
+ * - Type normalization (dates, numbers, NaN) via normalizeDbValues
141
+ * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
142
+ * - Flattening junction-table many-to-many results
143
+ */
144
+ private drizzleResultToEntity<M extends Record<string, any>>(
145
+ row: Record<string, unknown>,
146
+ collection: EntityCollection,
147
+ collectionPath: string,
148
+ idInfo: { fieldName: string; type: "string" | "number" },
149
+ databaseId?: string,
150
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
151
+ ): Entity<M> {
152
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
153
+ const propertyKeys = new Set(Object.keys(collection.properties || {}));
154
+
155
+ // Normalize non-relation values (dates, numbers, etc.)
156
+ const normalizedValues = normalizeDbValues(row as M, collection);
157
+
158
+ // Convert nested relation objects to CMS-style { id, path, __type: "relation" }
159
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
160
+ if (!propertyKeys.has(key)) continue;
161
+ const drizzleRelName = relation.relationName || key;
162
+ const relData = row[drizzleRelName];
163
+
164
+ if (relData === undefined || relData === null) continue;
165
+
166
+ if (relation.cardinality === "many" && Array.isArray(relData)) {
167
+ const targetCollection = relation.target();
168
+ const targetPath = targetCollection.slug;
169
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
170
+ const targetIdField = targetPks[0].fieldName;
171
+
172
+ (normalizedValues as Record<string, unknown>)[key] = relData.map((item: Record<string, unknown>) => {
173
+ // Handle junction table flattening:
174
+ // Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
175
+ let targetEntity = item;
176
+ if (this.isJunctionRelation(relation, collection)) {
177
+ // Find the nested target object in the junction row
178
+ const nestedKey = Object.keys(item).find(
179
+ nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
180
+ );
181
+ if (nestedKey) {
182
+ targetEntity = item[nestedKey] as Record<string, unknown>;
183
+ }
184
+ }
185
+
186
+ const relId = String(targetEntity[targetIdField] ?? targetEntity.id ?? targetEntity[Object.keys(targetEntity)[0]]);
187
+ const targetValues = normalizeDbValues(targetEntity, targetCollection);
188
+
189
+ return {
190
+ id: relId,
191
+ path: targetPath,
192
+ __type: "relation" as const,
193
+ data: {
194
+ id: relId,
195
+ path: targetPath,
196
+ values: targetValues,
197
+ databaseId
198
+ }
199
+ };
200
+ });
201
+ } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
202
+ const targetCollection = relation.target();
203
+ const targetPath = targetCollection.slug;
204
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
205
+ const targetIdField = targetPks[0].fieldName;
206
+ const relObj = relData as Record<string, unknown>;
207
+
208
+ const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
209
+ const targetValues = normalizeDbValues(relObj, targetCollection);
210
+
211
+ (normalizedValues as Record<string, unknown>)[key] = {
212
+ id: relId,
213
+ path: targetPath,
214
+ __type: "relation" as const,
215
+ data: {
216
+ id: relId,
217
+ path: targetPath,
218
+ values: targetValues,
219
+ databaseId
220
+ }
221
+ };
222
+ }
223
+ }
224
+
225
+ return {
226
+ id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, any>, idInfoArray) : String(row[idInfo.fieldName]),
227
+ path: collectionPath,
228
+ values: normalizedValues as M,
229
+ databaseId
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Post-fetch joinPath relations for a single entity.
235
+ * joinPath relations cannot be expressed via Drizzle's `with` config,
236
+ * so they must be loaded separately after the primary query.
237
+ */
238
+ private async resolveJoinPathRelations<M extends Record<string, any>>(
239
+ entity: Entity<M>,
240
+ collection: EntityCollection,
241
+ collectionPath: string,
242
+ parsedId: string | number,
243
+ databaseId?: string
244
+ ): Promise<void> {
245
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
246
+ const propertyKeys = new Set(Object.keys(collection.properties || {}));
247
+
248
+ const promises = Object.entries(resolvedRelations)
249
+ .filter(([key, relation]) => propertyKeys.has(key) && relation.joinPath && relation.joinPath.length > 0)
250
+ .map(async ([key, relation]) => {
251
+ try {
252
+ const relatedEntities = await this.relationService.fetchRelatedEntities(
253
+ collectionPath,
254
+ parsedId,
255
+ key,
256
+ { limit: relation.cardinality === "one" ? 1 : undefined }
257
+ );
258
+
259
+ if (relation.cardinality === "one" && relatedEntities.length > 0) {
260
+ const e = relatedEntities[0];
261
+ (entity.values as Record<string, unknown>)[key] = {
262
+ id: e.id,
263
+ path: e.path,
264
+ __type: "relation" as const,
265
+ data: e
266
+ };
267
+ } else if (relation.cardinality === "many") {
268
+ (entity.values as Record<string, unknown>)[key] = relatedEntities.map(e => ({
269
+ id: e.id,
270
+ path: e.path,
271
+ __type: "relation" as const,
272
+ data: e
273
+ }));
274
+ }
275
+ } catch (e) {
276
+ console.warn(`Could not resolve joinPath relation '${key}':`, e);
277
+ }
278
+ });
279
+
280
+ await Promise.all(promises);
281
+ }
282
+
283
+ /**
284
+ * Post-fetch joinPath relations for a batch of entities.
285
+ * Uses batch fetching to avoid N+1 queries for list views.
286
+ */
287
+ private async resolveJoinPathRelationsBatch<M extends Record<string, any>>(
288
+ entities: Entity<M>[],
289
+ collection: EntityCollection,
290
+ collectionPath: string,
291
+ idInfo: { fieldName: string; type: "string" | "number" },
292
+ databaseId?: string
293
+ ): Promise<void> {
294
+ if (entities.length === 0) return;
295
+
296
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
297
+ const propertyKeys = new Set(Object.keys(collection.properties || {}));
298
+
299
+ const joinPathRelations = Object.entries(resolvedRelations)
300
+ .filter(([key, relation]) => propertyKeys.has(key) && relation.joinPath && relation.joinPath.length > 0);
301
+
302
+ if (joinPathRelations.length === 0) return;
303
+
304
+ for (const [key, relation] of joinPathRelations) {
305
+ try {
306
+ const entityIds = entities.map(e => {
307
+ const parsed = parseIdValues(e.id, [idInfo]);
308
+ return parsed[idInfo.fieldName] as string | number;
309
+ });
310
+
311
+ const resultMap = await this.relationService.batchFetchRelatedEntities(
312
+ collectionPath,
313
+ entityIds,
314
+ key,
315
+ relation
316
+ );
317
+
318
+ for (const entity of entities) {
319
+ const parsed = parseIdValues(entity.id, [idInfo]);
320
+ const entityId = parsed[idInfo.fieldName] as string | number;
321
+ const relatedEntity = resultMap.get(entityId);
322
+
323
+ if (relatedEntity) {
324
+ if (relation.cardinality === "one") {
325
+ (entity.values as Record<string, unknown>)[key] = {
326
+ id: relatedEntity.id,
327
+ path: relatedEntity.path,
328
+ __type: "relation" as const,
329
+ data: relatedEntity
330
+ };
331
+ }
332
+ }
333
+ }
334
+ } catch (e) {
335
+ console.warn(`Could not batch resolve joinPath relation '${key}':`, e);
336
+ }
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Convert a db.query result row to a flat REST-style object with populated relations.
342
+ */
343
+ private drizzleResultToRestRow(
344
+ row: Record<string, unknown>,
345
+ collection: EntityCollection,
346
+ idInfo: { fieldName: string; type: "string" | "number" },
347
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
348
+ ): Record<string, unknown> {
349
+ const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, any>, idInfoArray) : String(row[idInfo.fieldName]) };
350
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
351
+
352
+ for (const [k, v] of Object.entries(row)) {
353
+ if (k === idInfo.fieldName) continue;
354
+
355
+ const relation = resolvedRelations[k];
356
+ if (Array.isArray(v) && relation) {
357
+ // Many relation — flatten each nested entity, handling junction tables
358
+ flat[k] = v.map((item: Record<string, unknown>) => {
359
+ if (this.isJunctionRelation(relation, collection)) {
360
+ const nestedKey = Object.keys(item).find(
361
+ nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
362
+ );
363
+ if (nestedKey) {
364
+ const nested = item[nestedKey] as Record<string, unknown>;
365
+ return { id: String(nested.id ?? nested[Object.keys(nested)[0]]), ...nested };
366
+ }
367
+ }
368
+ return { id: String(item.id ?? item[Object.keys(item)[0]]), ...item };
369
+ });
370
+ } else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
371
+ // One-to-one relation — inline the object
372
+ const relObj = v as Record<string, unknown>;
373
+ flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]), ...relObj };
374
+ } else {
375
+ flat[k] = v;
376
+ }
377
+ }
378
+ return flat;
379
+ }
380
+
381
+ /**
382
+ * Build db.query-compatible options from standard fetch options.
383
+ * Handles filter, search, orderBy, limit, and cursor-based pagination.
384
+ */
385
+ private buildDrizzleQueryOptions<M extends Record<string, any>>(
386
+ table: PgTable<any>,
387
+ idField: AnyPgColumn,
388
+ idInfo: { fieldName: string; type: "string" | "number" },
389
+ options: {
390
+ filter?: FilterValues<Extract<keyof M, string>>;
391
+ orderBy?: string;
392
+ order?: "desc" | "asc";
393
+ limit?: number;
394
+ startAfter?: Record<string, unknown>;
395
+ searchString?: string;
396
+ },
397
+ collectionPath: string,
398
+ withConfig?: Record<string, any>
399
+ ): Record<string, unknown> {
400
+ const queryOpts: Record<string, unknown> = {};
401
+
402
+ if (withConfig) queryOpts.with = withConfig;
403
+
404
+ // Build where conditions
405
+ const allConditions: SQL[] = [];
406
+
407
+ if (options.searchString) {
408
+ const collection = getCollectionByPath(collectionPath, this.registry);
409
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
410
+ options.searchString, collection.properties, table
411
+ );
412
+ if (searchConditions.length === 0) {
413
+ // Return options that will produce empty results
414
+ queryOpts.where = and(eq(idField, -99999999)); // impossible condition
415
+ return queryOpts;
416
+ }
417
+ allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
418
+ }
419
+
420
+ if (options.filter) {
421
+ const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
422
+ if (filterConditions.length > 0) allConditions.push(...filterConditions);
423
+ }
424
+
425
+ // Cursor-based pagination (startAfter)
426
+ if (options.startAfter) {
427
+ const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options);
428
+ if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
429
+ }
430
+
431
+ if (allConditions.length > 0) {
432
+ queryOpts.where = and(...allConditions);
433
+ }
434
+
435
+ // OrderBy
436
+ const orderExpressions: unknown[] = [];
437
+ if (options.orderBy) {
438
+ const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
439
+ if (orderByField) {
440
+ orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
441
+ }
442
+ }
443
+ orderExpressions.push(desc(idField));
444
+ if (orderExpressions.length > 0) {
445
+ queryOpts.orderBy = orderExpressions;
446
+ }
447
+
448
+ // Limit
449
+ const limitValue = options.searchString ? (options.limit || 50) : options.limit;
450
+ if (limitValue) queryOpts.limit = limitValue;
451
+
452
+ return queryOpts;
453
+ }
454
+
455
+ /**
456
+ * Extract cursor pagination conditions from startAfter options.
457
+ */
458
+ private buildCursorConditions(
459
+ table: PgTable<any>,
460
+ idField: AnyPgColumn,
461
+ idInfo: { fieldName: string; type: "string" | "number" },
462
+ options: { orderBy?: string; order?: "desc" | "asc"; startAfter?: Record<string, unknown> }
463
+ ): SQL[] {
464
+ if (!options.startAfter) return [];
465
+ const cursor = options.startAfter;
466
+
467
+ if (options.orderBy) {
468
+ const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
469
+ if (orderByField) {
470
+ const startAfterOrderValue = (cursor.values as Record<string, unknown> | undefined)?.[options.orderBy] ?? cursor[options.orderBy];
471
+ const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
472
+
473
+ if (startAfterOrderValue !== undefined && startAfterId !== undefined) {
474
+ if (options.order === "asc") {
475
+ return [or(
476
+ gt(orderByField, startAfterOrderValue),
477
+ and(eq(orderByField, startAfterOrderValue), gt(idField, startAfterId))
478
+ )!];
479
+ } else {
480
+ return [or(
481
+ lt(orderByField, startAfterOrderValue),
482
+ and(eq(orderByField, startAfterOrderValue), lt(idField, startAfterId))
483
+ )!];
484
+ }
485
+ }
486
+ }
487
+ } else {
488
+ const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
489
+ if (startAfterId !== undefined && startAfterId !== null) {
490
+ const idInfoArray = [idInfo] as Array<{ fieldName: string; type: "string" | "number" }>;
491
+ const parsedStartAfterIdObj = parseIdValues(startAfterId as string | number, idInfoArray);
492
+ return [lt(idField, parsedStartAfterIdObj[idInfo.fieldName])];
493
+ }
494
+ }
495
+
496
+ return [];
497
+ }
498
+
499
+ /**
500
+ * Fetch a single entity by ID
501
+ */
502
+ async fetchEntity<M extends Record<string, any>>(
503
+ collectionPath: string,
504
+ entityId: string | number,
505
+ databaseId?: string
506
+ ): Promise<Entity<M> | undefined> {
507
+ const collection = getCollectionByPath(collectionPath, this.registry);
508
+ const table = getTableForCollection(collection, this.registry);
509
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
510
+ const idInfo = idInfoArray[0];
511
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
512
+
513
+ if (!idField) {
514
+ throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
515
+ }
516
+
517
+ const parsedIdObj = parseIdValues(entityId, idInfoArray);
518
+ const parsedId = parsedIdObj[idInfo.fieldName];
519
+
520
+ // Primary path: use db.query.findFirst with relation loading
521
+
522
+ const tableName = getTableName(table);
523
+
524
+ const qb = this.getQueryBuilder(tableName);
525
+ if (qb) {
526
+ try {
527
+ const withConfig = this.buildWithConfig(collection);
528
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
529
+ const row = await qb.findFirst({
530
+ where: eq(idField, parsedId),
531
+ with: withConfig
532
+ } as unknown as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
533
+
534
+ if (!row) return undefined;
535
+
536
+ const entity = this.drizzleResultToEntity<M>(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
537
+
538
+ // Post-fetch joinPath relations that Drizzle's `with` can't express
539
+ await this.resolveJoinPathRelations(entity, collection, collectionPath, parsedId, databaseId);
540
+
541
+ return entity;
542
+ } catch (e) {
543
+ console.warn(`[EntityFetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select:`, e);
544
+ }
545
+ }
546
+
547
+ // Fallback: db.select + N+1 relation loading
548
+ const result = await this.db
549
+ .select()
550
+ .from(table)
551
+ .where(eq(idField, parsedId))
552
+ .limit(1);
553
+
554
+ if (result.length === 0) return undefined;
555
+
556
+ const raw = result[0] as M;
557
+ const values = await parseDataFromServer(raw, collection, this.db, this.registry);
558
+
559
+ // Load relations based on cardinality (N+1 — only used in fallback)
560
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
561
+ const propertyKeys = new Set(Object.keys(collection.properties));
562
+
563
+ const relationPromises = Object.entries(resolvedRelations)
564
+ .filter(([key]) => propertyKeys.has(key))
565
+ .map(async ([key, relation]) => {
566
+ if (relation.cardinality === "many") {
567
+ const relatedEntities = await this.relationService.fetchRelatedEntities(
568
+ collectionPath,
569
+ parsedId,
570
+ key,
571
+ {}
572
+ );
573
+ (values as Record<string, unknown>)[key] = relatedEntities.map(e => ({
574
+ id: e.id,
575
+ path: e.path,
576
+ __type: "relation"
577
+ }));
578
+ } else if (relation.cardinality === "one") {
579
+ if ((values as Record<string, unknown>)[key] == null) {
580
+ try {
581
+ const relatedEntities = await this.relationService.fetchRelatedEntities(
582
+ collectionPath,
583
+ parsedId,
584
+ key,
585
+ { limit: 1 }
586
+ );
587
+ if (relatedEntities.length > 0) {
588
+ const e = relatedEntities[0];
589
+ (values as Record<string, unknown>)[key] = {
590
+ id: e.id,
591
+ path: e.path,
592
+ __type: "relation"
593
+ };
594
+ }
595
+ } catch (e) {
596
+ console.warn(`Could not resolve one-to-one relation property: ${key}`, e);
597
+ }
598
+ }
599
+ }
600
+ });
601
+
602
+ await Promise.all(relationPromises);
603
+
604
+ return {
605
+ id: entityId.toString(),
606
+ path: collectionPath,
607
+ values: values as M,
608
+ databaseId
609
+ };
610
+ }
611
+
612
+ /**
613
+ * Unified method to fetch entities with optional search functionality
614
+ */
615
+ async fetchEntitiesWithConditions<M extends Record<string, any>>(
616
+ collectionPath: string,
617
+ options: {
618
+ filter?: FilterValues<Extract<keyof M, string>>;
619
+ orderBy?: string;
620
+ order?: "desc" | "asc";
621
+ limit?: number;
622
+ startAfter?: Record<string, unknown>;
623
+ searchString?: string;
624
+ databaseId?: string;
625
+ } = {}
626
+ ): Promise<Entity<M>[]> {
627
+ const collection = getCollectionByPath(collectionPath, this.registry);
628
+ const table = getTableForCollection(collection, this.registry);
629
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
630
+ const idInfo = idInfoArray[0];
631
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
632
+
633
+ if (!idField) {
634
+ throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
635
+ }
636
+
637
+ // Primary path: use db.query.findMany with relation loading
638
+
639
+ const tableName = getTableName(table);
640
+
641
+ const qb = this.getQueryBuilder(tableName);
642
+ if (qb) {
643
+ try {
644
+ const withConfig = this.buildWithConfig(collection);
645
+ const queryOpts = this.buildDrizzleQueryOptions<M>(
646
+ table, idField, idInfo, options, collectionPath, withConfig
647
+ );
648
+
649
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
650
+ const results = await qb.findMany(queryOpts as unknown as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
651
+
652
+ const entities = (results as Record<string, unknown>[]).map(row =>
653
+ this.drizzleResultToEntity<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
654
+ );
655
+
656
+ // Post-fetch joinPath relations that Drizzle's `with` can't express
657
+ await this.resolveJoinPathRelationsBatch(entities, collection, collectionPath, idInfo, options.databaseId);
658
+
659
+ return entities;
660
+ } catch (e) {
661
+ console.warn(`[EntityFetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select:`, e);
662
+ }
663
+ }
664
+
665
+ // Fallback: db.select + processEntityResults (N+1 for relations)
666
+ let query = this.db.select().from(table).$dynamic();
667
+ const allConditions: SQL[] = [];
668
+
669
+ if (options.searchString) {
670
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
671
+ options.searchString, collection.properties, table
672
+ );
673
+ if (searchConditions.length === 0) return [];
674
+ allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
675
+ }
676
+
677
+ if (options.filter) {
678
+ const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
679
+ if (filterConditions.length > 0) allConditions.push(...filterConditions);
680
+ }
681
+
682
+ if (allConditions.length > 0) {
683
+ const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
684
+ if (finalCondition) query = query.where(finalCondition);
685
+ }
686
+
687
+ const orderExpressions = [];
688
+ if (options.orderBy) {
689
+ const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
690
+ if (orderByField) {
691
+ orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
692
+ }
693
+ }
694
+ orderExpressions.push(desc(idField));
695
+ if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
696
+
697
+ if (options.startAfter) {
698
+ const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options);
699
+ if (cursorConditions.length > 0) {
700
+ allConditions.push(...cursorConditions);
701
+ const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
702
+ if (finalCondition) query = query.where(finalCondition);
703
+ }
704
+ }
705
+
706
+ const limitValue = options.searchString ? (options.limit || 50) : options.limit;
707
+ if (limitValue) query = query.limit(limitValue);
708
+
709
+ const results = await query;
710
+
711
+ return this.processEntityResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
712
+ }
713
+
714
+ /**
715
+ * Fallback path used when db.query is unavailable.
716
+ * The primary path uses drizzleResultToEntity which handles relation
717
+ * mapping without N+1 queries.
718
+ *
719
+ * Process raw database results into Entity objects with relations.
720
+ */
721
+ private async processEntityResults<M extends Record<string, any>>(
722
+ results: Record<string, unknown>[],
723
+ collection: EntityCollection,
724
+ collectionPath: string,
725
+ idInfo: { fieldName: string; type: "string" | "number" },
726
+ databaseId?: string,
727
+ skipRelations: boolean = false,
728
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
729
+ ): Promise<Entity<M>[]> {
730
+ if (results.length === 0) return [];
731
+
732
+ // First pass: parse all entities
733
+ const entitiesWithValues = await Promise.all(results.map(async (entity: Record<string, unknown>) => {
734
+ const values = await parseDataFromServer(entity as M, collection, this.db, this.registry);
735
+ return {
736
+ entity,
737
+ values,
738
+ id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, any>, idInfoArray!) : String(entity[idInfo.fieldName]),
739
+ path: collectionPath
740
+ };
741
+ }));
742
+
743
+ if (!skipRelations) {
744
+ // Second pass: batch load missing one-to-one relations
745
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
746
+ const propertyKeys = new Set(Object.keys(collection.properties));
747
+
748
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
749
+ if (!propertyKeys.has(key) || relation.cardinality !== "one") continue;
750
+
751
+ const entitiesMissingRelation = entitiesWithValues.filter(item => {
752
+ const val = (item.values as Record<string, unknown>)[key];
753
+ if (val == null) return true;
754
+ if (typeof val === "object" && !Array.isArray(val) && (val as Record<string, unknown>).__type === "relation" && (val as Record<string, unknown>).data == null) return true;
755
+ return false;
756
+ });
757
+
758
+ if (entitiesMissingRelation.length === 0) continue;
759
+
760
+ try {
761
+ const entityIds = entitiesMissingRelation.map(item => item.entity[idInfo.fieldName] as string | number);
762
+ const relationResults = await this.relationService.batchFetchRelatedEntities(
763
+ collectionPath,
764
+ entityIds,
765
+ key,
766
+ relation
767
+ );
768
+
769
+ entitiesMissingRelation.forEach(item => {
770
+ const entityId = item.entity[idInfo.fieldName] as string | number;
771
+ const relatedEntity = relationResults.get(entityId);
772
+ if (relatedEntity) {
773
+ (item.values as Record<string, unknown>)[key] = {
774
+ id: relatedEntity.id,
775
+ path: relatedEntity.path,
776
+ __type: "relation",
777
+ data: relatedEntity
778
+ };
779
+ }
780
+ });
781
+ } catch (e) {
782
+ console.warn(`Could not batch load one-to-one relation property: ${key}`, e);
783
+ }
784
+ }
785
+
786
+ // Handle many relations
787
+ const manyRelationPromises = entitiesWithValues.map(async (item) => {
788
+ const manyRelationQueries = Object.entries(resolvedRelations)
789
+ .filter(([key, relation]) => propertyKeys.has(key) && relation.cardinality === "many")
790
+ .map(async ([key]) => {
791
+ try {
792
+ const relatedEntities = await this.relationService.fetchRelatedEntities(
793
+ collectionPath,
794
+ item.entity[idInfo.fieldName] as string | number,
795
+ key,
796
+ {}
797
+ );
798
+ (item.values as Record<string, unknown>)[key] = relatedEntities.map(e => ({
799
+ id: e.id,
800
+ path: e.path,
801
+ __type: "relation",
802
+ data: e
803
+ }));
804
+ } catch (e) {
805
+ console.warn(`Could not resolve many relation property: ${key}`, e);
806
+ }
807
+ });
808
+ await Promise.all(manyRelationQueries);
809
+ });
810
+
811
+ await Promise.all(manyRelationPromises);
812
+ }
813
+
814
+ return entitiesWithValues.map(item => ({
815
+ id: item.id,
816
+ path: item.path,
817
+ values: item.values as M,
818
+ databaseId
819
+ }));
820
+ }
821
+
822
+ /**
823
+ * Fetch a collection of entities
824
+ */
825
+ async fetchCollection<M extends Record<string, any>>(
826
+ collectionPath: string,
827
+ options: {
828
+ filter?: FilterValues<Extract<keyof M, string>>;
829
+ orderBy?: string;
830
+ order?: "desc" | "asc";
831
+ limit?: number;
832
+ startAfter?: Record<string, unknown>;
833
+ searchString?: string;
834
+ databaseId?: string;
835
+ } = {}
836
+ ): Promise<Entity<M>[]> {
837
+ // Handle multi-segment paths by resolving through relations
838
+ if (collectionPath.includes("/")) {
839
+ return this.fetchCollectionFromPath<M>(collectionPath, options);
840
+ }
841
+
842
+ return this.fetchEntitiesWithConditions<M>(collectionPath, options);
843
+ }
844
+
845
+ /**
846
+ * Search entities by text
847
+ */
848
+ async searchEntities<M extends Record<string, any>>(
849
+ collectionPath: string,
850
+ searchString: string,
851
+ options: {
852
+ filter?: FilterValues<Extract<keyof M, string>>;
853
+ orderBy?: string;
854
+ order?: "desc" | "asc";
855
+ limit?: number;
856
+ databaseId?: string;
857
+ } = {}
858
+ ): Promise<Entity<M>[]> {
859
+ return this.fetchEntitiesWithConditions<M>(collectionPath, {
860
+ ...options,
861
+ searchString
862
+ });
863
+ }
864
+
865
+ /**
866
+ * Fetch collection from multi-segment path
867
+ */
868
+ private async fetchCollectionFromPath<M extends Record<string, any>>(
869
+ path: string,
870
+ options: {
871
+ filter?: FilterValues<Extract<keyof M, string>>;
872
+ orderBy?: string;
873
+ order?: "desc" | "asc";
874
+ limit?: number;
875
+ startAfter?: Record<string, unknown>;
876
+ searchString?: string;
877
+ databaseId?: string;
878
+ } = {}
879
+ ): Promise<Entity<M>[]> {
880
+ const pathSegments = path.split("/").filter(p => p && p !== "undefined");
881
+
882
+ if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
883
+ throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
884
+ }
885
+
886
+ const rootCollectionPath = pathSegments[0];
887
+ let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
888
+ let currentEntityId: string | number = pathSegments[1];
889
+
890
+ for (let i = 2; i < pathSegments.length; i += 2) {
891
+ const relationKey = pathSegments[i];
892
+ const resolvedRelations = resolveCollectionRelations(currentCollection as import("@rebasepro/types").PostgresCollection<any, any>);
893
+ const relation = resolvedRelations[relationKey];
894
+
895
+ if (!relation) {
896
+ throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
897
+ }
898
+
899
+ if (i === pathSegments.length - 1) {
900
+ return this.relationService.fetchRelatedEntities<M>(
901
+ currentCollection.slug,
902
+ currentEntityId,
903
+ relationKey,
904
+ options
905
+ );
906
+ }
907
+
908
+ if (i + 1 < pathSegments.length) {
909
+ const nextEntityId = pathSegments[i + 1];
910
+ currentCollection = relation.target();
911
+ currentEntityId = nextEntityId;
912
+ }
913
+ }
914
+
915
+ throw new Error(`Unable to resolve path: ${path}`);
916
+ }
917
+
918
+ /**
919
+ * Count entities in a collection
920
+ */
921
+ async countEntities<M extends Record<string, any>>(
922
+ collectionPath: string,
923
+ options: {
924
+ filter?: FilterValues<Extract<keyof M, string>>;
925
+ databaseId?: string;
926
+ } = {}
927
+ ): Promise<number> {
928
+ if (collectionPath.includes("/")) {
929
+ return this.countEntitiesFromPath<M>(collectionPath, options);
930
+ }
931
+
932
+ const collection = getCollectionByPath(collectionPath, this.registry);
933
+ const table = getTableForCollection(collection, this.registry);
934
+
935
+ let query = this.db.select({ count: count() }).from(table).$dynamic();
936
+
937
+ if (options.filter) {
938
+ const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
939
+ if (filterConditions.length > 0) {
940
+ query = query.where(and(...filterConditions));
941
+ }
942
+ }
943
+
944
+ const result = await query;
945
+ return Number(result[0]?.count || 0);
946
+ }
947
+
948
+ /**
949
+ * Count entities from multi-segment path
950
+ */
951
+ private async countEntitiesFromPath<M extends Record<string, any>>(
952
+ path: string,
953
+ options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
954
+ ): Promise<number> {
955
+ const pathSegments = path.split("/").filter(p => p && p !== "undefined");
956
+
957
+ if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
958
+ throw new Error(`Invalid relation path: ${path}`);
959
+ }
960
+
961
+ const rootCollectionPath = pathSegments[0];
962
+ let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
963
+ let currentEntityId: string | number = pathSegments[1];
964
+
965
+ for (let i = 2; i < pathSegments.length; i += 2) {
966
+ const relationKey = pathSegments[i];
967
+ const resolvedRelations = resolveCollectionRelations(currentCollection as import("@rebasepro/types").PostgresCollection<any, any>);
968
+ const relation = resolvedRelations[relationKey];
969
+
970
+ if (!relation) {
971
+ throw new Error(`Relation '${relationKey}' not found`);
972
+ }
973
+
974
+ if (i === pathSegments.length - 1) {
975
+ return this.relationService.countRelatedEntities(
976
+ currentCollection.slug,
977
+ currentEntityId,
978
+ relationKey,
979
+ options
980
+ );
981
+ }
982
+
983
+ if (i + 1 < pathSegments.length) {
984
+ currentCollection = relation.target();
985
+ currentEntityId = pathSegments[i + 1];
986
+ }
987
+ }
988
+
989
+ throw new Error(`Unable to count for path: ${path}`);
990
+ }
991
+
992
+ /**
993
+ * Check if a field value is unique
994
+ */
995
+ async checkUniqueField(
996
+ collectionPath: string,
997
+ fieldName: string,
998
+ value: unknown,
999
+ excludeEntityId?: string,
1000
+ _databaseId?: string
1001
+ ): Promise<boolean> {
1002
+ if (value === undefined || value === null) return true;
1003
+
1004
+ const collection = getCollectionByPath(collectionPath, this.registry);
1005
+ const table = getTableForCollection(collection, this.registry);
1006
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1007
+ const idInfo = idInfoArray[0];
1008
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1009
+ const field = table[fieldName as keyof typeof table] as AnyPgColumn;
1010
+
1011
+ if (!field) return true;
1012
+
1013
+ const parsedExcludeId = excludeEntityId ? parseIdValues(excludeEntityId, idInfoArray)[idInfo.fieldName] : undefined;
1014
+ const conditions = DrizzleConditionBuilder.buildUniqueFieldCondition(
1015
+ field,
1016
+ value,
1017
+ idField,
1018
+ parsedExcludeId
1019
+ );
1020
+
1021
+ const result = await this.db
1022
+ .select({ count: count() })
1023
+ .from(table)
1024
+ .where(and(...conditions));
1025
+
1026
+ const countResult = Number(result[0]?.count || 0);
1027
+ return countResult === 0;
1028
+ }
1029
+
1030
+ /**
1031
+ * Get the RelationService instance for external use
1032
+ */
1033
+ getRelationService(): RelationService {
1034
+ return this.relationService;
1035
+ }
1036
+
1037
+ // =============================================================
1038
+ // REST API INCLUDE-AWARE METHODS
1039
+ // =============================================================
1040
+
1041
+ /**
1042
+ * Fetch a collection of entities with optional relation includes.
1043
+ * When `include` is provided, only the specified relations are populated
1044
+ * with full entity data (not just { id, path, __type }).
1045
+ * When `include` is absent, no relation queries are made (fast path).
1046
+ *
1047
+ * @param include - Array of relation keys to populate, or ["*"] for all
1048
+ */
1049
+ async fetchCollectionForRest<M extends Record<string, any>>(
1050
+ collectionPath: string,
1051
+ options: {
1052
+ filter?: FilterValues<Extract<keyof M, string>>;
1053
+ orderBy?: string;
1054
+ order?: "desc" | "asc";
1055
+ limit?: number;
1056
+ startAfter?: Record<string, unknown>;
1057
+ searchString?: string;
1058
+ databaseId?: string;
1059
+ } = {},
1060
+ include?: string[]
1061
+ ): Promise<Record<string, unknown>[]> {
1062
+ const collection = getCollectionByPath(collectionPath, this.registry);
1063
+ const table = getTableForCollection(collection, this.registry);
1064
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1065
+ const idInfo = idInfoArray[0];
1066
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1067
+
1068
+ // Primary path: use db.query.findMany
1069
+
1070
+ const tableName = getTableName(table);
1071
+
1072
+ const qb = this.getQueryBuilder(tableName);
1073
+ if (qb) {
1074
+ try {
1075
+ const withConfig = (include && include.length > 0)
1076
+ ? this.buildWithConfig(collection, include)
1077
+ : undefined;
1078
+
1079
+ const queryOpts = this.buildDrizzleQueryOptions<M>(
1080
+ table, idField, idInfo, options, collectionPath, withConfig
1081
+ );
1082
+
1083
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
1084
+ const results = await qb.findMany(queryOpts as unknown as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1085
+
1086
+ return (results as Record<string, unknown>[]).map(row =>
1087
+ this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
1088
+ );
1089
+ } catch (e) {
1090
+ console.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back:`, e);
1091
+ }
1092
+ }
1093
+
1094
+ // Fallback: fetch base entities without relations
1095
+ const entities = await this.fetchEntitiesWithConditionsRaw<M>(collectionPath, options);
1096
+
1097
+ if (!include || include.length === 0) {
1098
+ return entities.map(entity => ({
1099
+ id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, any>, idInfoArray) : String(entity[idInfo.fieldName]),
1100
+ ...entity
1101
+ }));
1102
+ }
1103
+
1104
+ // Fallback relation loading via batch
1105
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
1106
+ const propertyKeys = new Set(Object.keys(collection.properties || {}));
1107
+ const shouldInclude = (key: string) =>
1108
+ include[0] === "*" || include.includes(key);
1109
+
1110
+ const entityIds = entities.map(e => e[idInfo.fieldName] as string | number);
1111
+
1112
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
1113
+ if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "one") continue;
1114
+ try {
1115
+ const batchResults = await this.relationService.batchFetchRelatedEntities(
1116
+ collectionPath, entityIds, key, relation
1117
+ );
1118
+ for (const entity of entities) {
1119
+ const eid = entity[idInfo.fieldName] as string | number;
1120
+ const related = batchResults.get(eid);
1121
+ if (related) {
1122
+ (entity as Record<string, unknown>)[key] = { id: related.id, ...related.values };
1123
+ }
1124
+ }
1125
+ } catch (e) {
1126
+ console.warn(`[include] Failed to batch load one-to-one '${key}':`, e);
1127
+ }
1128
+ }
1129
+
1130
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
1131
+ if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "many") continue;
1132
+ try {
1133
+ const batchResults = await this.batchFetchManyRelatedEntities(
1134
+ collectionPath, entityIds, key
1135
+ );
1136
+ for (const entity of entities) {
1137
+ const eid = entity[idInfo.fieldName] as string | number;
1138
+ const relatedList = batchResults.get(String(eid)) || [];
1139
+ (entity as Record<string, unknown>)[key] = relatedList.map(e => ({
1140
+ id: e.id, ...e.values
1141
+ }));
1142
+ }
1143
+ } catch (e) {
1144
+ console.warn(`[include] Failed to batch load many '${key}':`, e);
1145
+ }
1146
+ }
1147
+
1148
+ return entities.map(entity => ({
1149
+ id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, any>, idInfoArray) : String(entity[idInfo.fieldName]),
1150
+ ...entity
1151
+ }));
1152
+ }
1153
+
1154
+ /**
1155
+ * Fetch a single entity with optional relation includes for REST API.
1156
+ */
1157
+ async fetchEntityForRest<M extends Record<string, any>>(
1158
+ collectionPath: string,
1159
+ entityId: string | number,
1160
+ include?: string[],
1161
+ databaseId?: string
1162
+ ): Promise<Record<string, unknown> | null> {
1163
+ const collection = getCollectionByPath(collectionPath, this.registry);
1164
+ const table = getTableForCollection(collection, this.registry);
1165
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1166
+ const idInfo = idInfoArray[0];
1167
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1168
+
1169
+ const parsedIdObj = parseIdValues(entityId, idInfoArray);
1170
+ const parsedId = parsedIdObj[idInfo.fieldName];
1171
+
1172
+ // Primary path: use db.query.findFirst
1173
+
1174
+ const tableName = getTableName(table);
1175
+
1176
+ const qb = this.getQueryBuilder(tableName);
1177
+ if (qb) {
1178
+ try {
1179
+ const withConfig = (include && include.length > 0)
1180
+ ? this.buildWithConfig(collection, include)
1181
+ : undefined;
1182
+
1183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
1184
+ const row = await qb.findFirst({
1185
+ where: eq(idField, parsedId),
1186
+ ...(withConfig ? { with: withConfig } : {})
1187
+ } as unknown as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
1188
+
1189
+ if (!row) return null;
1190
+
1191
+ return this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
1192
+ } catch (e) {
1193
+ console.warn(`[fetchEntityForRest] db.query.findFirst failed for ${collectionPath}, falling back:`, e);
1194
+ }
1195
+ }
1196
+
1197
+ // Fallback: db.select + N+1 relation loading
1198
+ const result = await this.db
1199
+ .select()
1200
+ .from(table)
1201
+ .where(eq(idField, parsedId))
1202
+ .limit(1);
1203
+
1204
+ if (result.length === 0) return null;
1205
+
1206
+ const raw = result[0] as Record<string, unknown>;
1207
+ const flatEntity: Record<string, unknown> = { id: (idInfoArray.length > 1) ? buildCompositeId(raw as Record<string, any>, idInfoArray) : String(raw[idInfo.fieldName]), ...raw };
1208
+
1209
+ if (!include || include.length === 0) {
1210
+ return flatEntity;
1211
+ }
1212
+
1213
+ // Fallback relation population
1214
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
1215
+ const propertyKeys = new Set(Object.keys(collection.properties || {}));
1216
+ const shouldInclude = (key: string) =>
1217
+ include[0] === "*" || include.includes(key);
1218
+
1219
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
1220
+ if (!propertyKeys.has(key) || !shouldInclude(key)) continue;
1221
+
1222
+ try {
1223
+ const relatedEntities = await this.relationService.fetchRelatedEntities(
1224
+ collectionPath, parsedId, key, {}
1225
+ );
1226
+
1227
+ if (relation.cardinality === "one") {
1228
+ if (relatedEntities.length > 0) {
1229
+ const e = relatedEntities[0];
1230
+ flatEntity[key] = { id: e.id, ...e.values };
1231
+ }
1232
+ } else {
1233
+ flatEntity[key] = relatedEntities.map(e => ({
1234
+ id: e.id, ...e.values
1235
+ }));
1236
+ }
1237
+ } catch (e) {
1238
+ console.warn(`[include] Failed to load relation '${key}':`, e);
1239
+ }
1240
+ }
1241
+
1242
+ return flatEntity;
1243
+ }
1244
+
1245
+ /**
1246
+ * Fetch raw rows without any relation processing (for REST fast path)
1247
+ */
1248
+ private async fetchEntitiesWithConditionsRaw<M extends Record<string, any>>(
1249
+ collectionPath: string,
1250
+ options: {
1251
+ filter?: FilterValues<Extract<keyof M, string>>;
1252
+ orderBy?: string;
1253
+ order?: "desc" | "asc";
1254
+ limit?: number;
1255
+ startAfter?: Record<string, unknown>;
1256
+ searchString?: string;
1257
+ } = {}
1258
+ ): Promise<Record<string, unknown>[]> {
1259
+ const collection = getCollectionByPath(collectionPath, this.registry);
1260
+ const table = getTableForCollection(collection, this.registry);
1261
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1262
+ const idInfo = idInfoArray[0];
1263
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1264
+
1265
+ let query = this.db.select().from(table).$dynamic();
1266
+ const allConditions: SQL[] = [];
1267
+
1268
+ if (options.searchString) {
1269
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1270
+ options.searchString, collection.properties, table
1271
+ );
1272
+ if (searchConditions.length === 0) return [];
1273
+ allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
1274
+ }
1275
+
1276
+ if (options.filter) {
1277
+ const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
1278
+ if (filterConditions.length > 0) allConditions.push(...filterConditions);
1279
+ }
1280
+
1281
+ if (allConditions.length > 0) {
1282
+ const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
1283
+ if (finalCondition) query = query.where(finalCondition);
1284
+ }
1285
+
1286
+ const orderExpressions = [];
1287
+ if (options.orderBy) {
1288
+ const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
1289
+ if (orderByField) {
1290
+ orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
1291
+ }
1292
+ }
1293
+ orderExpressions.push(desc(idField));
1294
+ if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
1295
+
1296
+ const limitValue = options.searchString ? (options.limit || 50) : options.limit;
1297
+ if (limitValue) query = query.limit(limitValue);
1298
+
1299
+ return await query as Record<string, unknown>[];
1300
+ }
1301
+
1302
+ /**
1303
+ * Check if the Drizzle instance has the relational query API available
1304
+ * for a given collection path.
1305
+ * Note: Primary path now uses inline `getQueryBuilder()` checks.
1306
+ */
1307
+ private hasDrizzleQueryAPI(collectionPath: string): boolean {
1308
+
1309
+ const qb = this.getQueryBuilder("__probe__");
1310
+ if (!qb) {
1311
+ // If getQueryBuilder returns undefined even for a probe, query API is not available
1312
+ return false;
1313
+ }
1314
+ const collection = getCollectionByPath(collectionPath, this.registry);
1315
+ const table = getTableForCollection(collection, this.registry);
1316
+ const tableName = getTableName(table);
1317
+ return !!this.getQueryBuilder(tableName);
1318
+ }
1319
+
1320
+ /**
1321
+ * Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
1322
+ * for efficient JOIN-based relation loading.
1323
+ * Returns null if the API is not available or the query fails.
1324
+ * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
1325
+ */
1326
+ private async fetchWithDrizzleQuery<M extends Record<string, any>>(
1327
+ collectionPath: string,
1328
+ collection: EntityCollection,
1329
+ options: {
1330
+ filter?: FilterValues<Extract<keyof M, string>>;
1331
+ orderBy?: string;
1332
+ order?: "desc" | "asc";
1333
+ limit?: number;
1334
+ },
1335
+ include: string[],
1336
+ idInfo: { fieldName: string; type: "string" | "number" },
1337
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
1338
+ ): Promise<Record<string, unknown>[] | null> {
1339
+ try {
1340
+
1341
+ const table = getTableForCollection(collection, this.registry);
1342
+ const tableName = getTableName(table);
1343
+ const queryTarget = this.getQueryBuilder(tableName);
1344
+
1345
+ if (!queryTarget?.findMany) return null;
1346
+
1347
+ // Build the `with` config from include array
1348
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
1349
+ const withConfig: Record<string, boolean> = {};
1350
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
1351
+ if (include[0] === "*" || include.includes(key)) {
1352
+ // Use the Drizzle relation name (from the schema)
1353
+ const drizzleRelName = relation.relationName || key;
1354
+ withConfig[drizzleRelName] = true;
1355
+ }
1356
+ }
1357
+
1358
+ // Build query options
1359
+ const queryOpts: Record<string, unknown> = { with: withConfig };
1360
+ if (options.limit) queryOpts.limit = options.limit;
1361
+
1362
+ // Build where clause
1363
+ if (options.filter) {
1364
+ const filterConditions = this.buildFilterConditions(
1365
+ options.filter, table, collectionPath
1366
+ );
1367
+ if (filterConditions.length > 0) {
1368
+ queryOpts.where = and(...filterConditions);
1369
+ }
1370
+ }
1371
+
1372
+ // Build orderBy
1373
+ if (options.orderBy) {
1374
+ const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
1375
+ if (orderByField) {
1376
+ queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
1377
+ }
1378
+ }
1379
+
1380
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- deprecated method, will be removed
1381
+ const results = await queryTarget.findMany(queryOpts as unknown as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1382
+
1383
+ // Flatten the nested Drizzle results into REST format
1384
+ return results.map((row: Record<string, unknown>) => {
1385
+ const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, any>, idInfoArray) : String(row[idInfo.fieldName]) };
1386
+ for (const [k, v] of Object.entries(row)) {
1387
+ if (k === idInfo.fieldName) continue;
1388
+ if (Array.isArray(v)) {
1389
+ // Many relation — flatten each nested entity
1390
+ flat[k] = v.map((item: Record<string, unknown>) => {
1391
+ // Junction table rows may have the target nested, flatten those
1392
+ const keys = Object.keys(item);
1393
+ // If it looks like a junction row (only FKs + nested objects), extract nested
1394
+ const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1395
+ if (nestedObj && keys.length <= 3) {
1396
+ const nested = item[nestedObj] as Record<string, unknown>;
1397
+ return { id: String(nested.id ?? nested[Object.keys(nested)[0]]), ...nested };
1398
+ }
1399
+ return { id: String(item.id ?? item[Object.keys(item)[0]]), ...item };
1400
+ });
1401
+ } else if (typeof v === "object" && v !== null) {
1402
+ // One-to-one relation — inline the object
1403
+ const relObj = v as Record<string, unknown>;
1404
+ flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]), ...relObj };
1405
+ } else {
1406
+ flat[k] = v;
1407
+ }
1408
+ }
1409
+ return flat;
1410
+ });
1411
+ } catch (e) {
1412
+ console.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back:`, e);
1413
+ return null;
1414
+ }
1415
+ }
1416
+
1417
+ /**
1418
+ * Fallback path used when db.query is unavailable.
1419
+ * The primary path uses db.query.findMany with `with` config, which
1420
+ * loads all relations in a single query.
1421
+ *
1422
+ * Batch fetch many-to-many related entities for multiple parent IDs.
1423
+ * Groups results by parent ID to avoid N+1.
1424
+ */
1425
+ private async batchFetchManyRelatedEntities(
1426
+ parentCollectionPath: string,
1427
+ parentIds: (string | number)[],
1428
+ relationKey: string
1429
+ ): Promise<Map<string, Entity[]>> {
1430
+ const resultMap = new Map<string, Entity[]>();
1431
+
1432
+ // Fetch for all parents using Promise.all (limited batch)
1433
+ const batchPromises = parentIds.map(async (parentId) => {
1434
+ try {
1435
+ const related = await this.relationService.fetchRelatedEntities(
1436
+ parentCollectionPath, parentId, relationKey, {}
1437
+ );
1438
+ resultMap.set(String(parentId), related);
1439
+ } catch (e) {
1440
+ resultMap.set(String(parentId), []);
1441
+ }
1442
+ });
1443
+
1444
+ await Promise.all(batchPromises);
1445
+ return resultMap;
1446
+ }
1447
+ }