@rebasepro/server-postgresql 0.0.1-canary.09e5ec5

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