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

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 (136) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index.es.js +1254 -591
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +1254 -591
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +17 -29
  10. package/dist/server-postgresql/src/auth/services.d.ts +7 -3
  11. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +1 -1
  12. package/dist/server-postgresql/src/connection.d.ts +34 -1
  13. package/dist/server-postgresql/src/data-transformer.d.ts +26 -4
  14. package/dist/server-postgresql/src/databasePoolManager.d.ts +2 -2
  15. package/dist/server-postgresql/src/schema/auth-schema.d.ts +139 -38
  16. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  17. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  18. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +1 -1
  19. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  20. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +22 -8
  21. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +1 -1
  22. package/dist/server-postgresql/src/services/RelationService.d.ts +11 -5
  23. package/dist/server-postgresql/src/services/entity-helpers.d.ts +16 -2
  24. package/dist/server-postgresql/src/services/entityService.d.ts +8 -6
  25. package/dist/server-postgresql/src/services/realtimeService.d.ts +2 -0
  26. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +2 -2
  27. package/dist/types/src/controllers/auth.d.ts +2 -0
  28. package/dist/types/src/controllers/client.d.ts +119 -7
  29. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  30. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  31. package/dist/types/src/controllers/data.d.ts +34 -7
  32. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  33. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  34. package/dist/types/src/controllers/email.d.ts +34 -0
  35. package/dist/types/src/controllers/index.d.ts +1 -0
  36. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  37. package/dist/types/src/controllers/navigation.d.ts +5 -5
  38. package/dist/types/src/controllers/registry.d.ts +6 -3
  39. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  40. package/dist/types/src/controllers/storage.d.ts +24 -26
  41. package/dist/types/src/rebase_context.d.ts +8 -4
  42. package/dist/types/src/types/backend.d.ts +4 -1
  43. package/dist/types/src/types/builders.d.ts +5 -4
  44. package/dist/types/src/types/chips.d.ts +1 -1
  45. package/dist/types/src/types/collections.d.ts +169 -125
  46. package/dist/types/src/types/cron.d.ts +102 -0
  47. package/dist/types/src/types/data_source.d.ts +1 -1
  48. package/dist/types/src/types/entity_actions.d.ts +8 -8
  49. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  50. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  51. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  52. package/dist/types/src/types/entity_views.d.ts +8 -8
  53. package/dist/types/src/types/export_import.d.ts +3 -3
  54. package/dist/types/src/types/index.d.ts +1 -0
  55. package/dist/types/src/types/plugins.d.ts +72 -18
  56. package/dist/types/src/types/properties.d.ts +118 -33
  57. package/dist/types/src/types/relations.d.ts +1 -1
  58. package/dist/types/src/types/slots.d.ts +30 -6
  59. package/dist/types/src/types/translations.d.ts +44 -0
  60. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  61. package/drizzle-test/0000_woozy_junta.sql +6 -0
  62. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  63. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  64. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  65. package/drizzle-test/meta/0000_snapshot.json +47 -0
  66. package/drizzle-test/meta/0001_snapshot.json +48 -0
  67. package/drizzle-test/meta/0002_snapshot.json +38 -0
  68. package/drizzle-test/meta/0003_snapshot.json +48 -0
  69. package/drizzle-test/meta/_journal.json +34 -0
  70. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  71. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  72. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  73. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  74. package/drizzle-test-out/meta/_journal.json +20 -0
  75. package/drizzle.test.config.ts +10 -0
  76. package/package.json +88 -89
  77. package/scratch.ts +41 -0
  78. package/src/PostgresBackendDriver.ts +63 -79
  79. package/src/PostgresBootstrapper.ts +7 -8
  80. package/src/auth/ensure-tables.ts +158 -86
  81. package/src/auth/services.ts +109 -50
  82. package/src/cli.ts +259 -16
  83. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  84. package/src/connection.ts +70 -48
  85. package/src/data-transformer.ts +155 -116
  86. package/src/databasePoolManager.ts +6 -5
  87. package/src/history/HistoryService.ts +3 -12
  88. package/src/interfaces.ts +3 -3
  89. package/src/schema/auth-schema.ts +26 -3
  90. package/src/schema/doctor-cli.ts +47 -0
  91. package/src/schema/doctor.ts +595 -0
  92. package/src/schema/generate-drizzle-schema-logic.ts +204 -57
  93. package/src/schema/generate-drizzle-schema.ts +6 -6
  94. package/src/schema/test-schema.ts +11 -0
  95. package/src/services/BranchService.ts +5 -5
  96. package/src/services/EntityFetchService.ts +317 -188
  97. package/src/services/EntityPersistService.ts +15 -17
  98. package/src/services/RelationService.ts +299 -37
  99. package/src/services/entity-helpers.ts +39 -13
  100. package/src/services/entityService.ts +11 -9
  101. package/src/services/realtimeService.ts +58 -29
  102. package/src/utils/drizzle-conditions.ts +25 -24
  103. package/src/websocket.ts +52 -21
  104. package/test/auth-services.test.ts +131 -39
  105. package/test/batch-many-to-many-regression.test.ts +573 -0
  106. package/test/branchService.test.ts +22 -12
  107. package/test/data-transformer-hardening.test.ts +417 -0
  108. package/test/data-transformer.test.ts +175 -0
  109. package/test/doctor.test.ts +182 -0
  110. package/test/entityService.errors.test.ts +31 -16
  111. package/test/entityService.relations.test.ts +155 -59
  112. package/test/entityService.subcollection-search.test.ts +107 -57
  113. package/test/entityService.test.ts +105 -47
  114. package/test/generate-drizzle-schema.test.ts +262 -69
  115. package/test/historyService.test.ts +31 -16
  116. package/test/n-plus-one-regression.test.ts +314 -0
  117. package/test/postgresDataDriver.test.ts +260 -168
  118. package/test/realtimeService.test.ts +70 -39
  119. package/test/relation-pipeline-gaps.test.ts +637 -0
  120. package/test/relations.test.ts +492 -39
  121. package/test-drizzle-bug.ts +18 -0
  122. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  123. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  124. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  125. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  126. package/test-drizzle-out/meta/_journal.json +20 -0
  127. package/test-drizzle-prompt.sh +2 -0
  128. package/test-policy-prompt.sh +3 -0
  129. package/test-programmatic.ts +30 -0
  130. package/test-programmatic2.ts +59 -0
  131. package/test-schema-no-policies.ts +12 -0
  132. package/test_drizzle_mock.js +2 -2
  133. package/test_find_changed.mjs +3 -1
  134. package/test_hash.js +14 -0
  135. package/tsconfig.json +1 -1
  136. package/vite.config.ts +5 -5
@@ -1,7 +1,7 @@
1
1
  import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { Entity, EntityCollection, FilterValues, Relation } from "@rebasepro/types";
4
- import { resolveCollectionRelations } from "@rebasepro/common";
4
+ import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
5
5
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
6
6
  import {
7
7
  getCollectionByPath,
@@ -43,7 +43,7 @@ export class EntityFetchService {
43
43
  * Build filter conditions from FilterValues
44
44
  * Delegates to DrizzleConditionBuilder.buildFilterConditions
45
45
  */
46
- buildFilterConditions<M extends Record<string, any>>(
46
+ buildFilterConditions<M extends Record<string, unknown>>(
47
47
  filter: FilterValues<Extract<keyof M, string>>,
48
48
  table: PgTable<any>,
49
49
  collectionPath: string
@@ -55,6 +55,25 @@ export class EntityFetchService {
55
55
  // DRIZZLE QUERY HELPERS
56
56
  // =============================================================
57
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
+
58
77
  /**
59
78
  * Build the `with` config for Drizzle's relational query API.
60
79
  * Converts collection relations to a Drizzle-compatible `with` object.
@@ -69,8 +88,7 @@ export class EntityFetchService {
69
88
  collection: EntityCollection,
70
89
  include?: string[]
71
90
  ): 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 || {}));
91
+ const resolvedRelations = resolveCollectionRelations(collection);
74
92
  const withConfig: Record<string, boolean | { with: Record<string, boolean> }> = {};
75
93
 
76
94
  const shouldInclude = (key: string) =>
@@ -78,8 +96,6 @@ export class EntityFetchService {
78
96
 
79
97
  for (const [key, relation] of Object.entries(resolvedRelations)) {
80
98
  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
99
 
84
100
  const drizzleRelName = relation.relationName || key;
85
101
 
@@ -141,7 +157,7 @@ export class EntityFetchService {
141
157
  * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
142
158
  * - Flattening junction-table many-to-many results
143
159
  */
144
- private drizzleResultToEntity<M extends Record<string, any>>(
160
+ private drizzleResultToEntity<M extends Record<string, unknown>>(
145
161
  row: Record<string, unknown>,
146
162
  collection: EntityCollection,
147
163
  collectionPath: string,
@@ -149,15 +165,13 @@ export class EntityFetchService {
149
165
  databaseId?: string,
150
166
  idInfoArray?: { fieldName: string; type: "string" | "number" }[]
151
167
  ): Entity<M> {
152
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
153
- const propertyKeys = new Set(Object.keys(collection.properties || {}));
168
+ const resolvedRelations = resolveCollectionRelations(collection);
154
169
 
155
170
  // Normalize non-relation values (dates, numbers, etc.)
156
171
  const normalizedValues = normalizeDbValues(row as M, collection);
157
172
 
158
173
  // Convert nested relation objects to CMS-style { id, path, __type: "relation" }
159
174
  for (const [key, relation] of Object.entries(resolvedRelations)) {
160
- if (!propertyKeys.has(key)) continue;
161
175
  const drizzleRelName = relation.relationName || key;
162
176
  const relData = row[drizzleRelName];
163
177
 
@@ -186,17 +200,12 @@ export class EntityFetchService {
186
200
  const relId = String(targetEntity[targetIdField] ?? targetEntity.id ?? targetEntity[Object.keys(targetEntity)[0]]);
187
201
  const targetValues = normalizeDbValues(targetEntity, targetCollection);
188
202
 
189
- return {
203
+ return createRelationRefWithData(relId, targetPath, {
190
204
  id: relId,
191
205
  path: targetPath,
192
- __type: "relation" as const,
193
- data: {
194
- id: relId,
195
- path: targetPath,
196
- values: targetValues,
197
- databaseId
198
- }
199
- };
206
+ values: targetValues,
207
+ databaseId
208
+ });
200
209
  });
201
210
  } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
202
211
  const targetCollection = relation.target();
@@ -208,22 +217,17 @@ export class EntityFetchService {
208
217
  const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
209
218
  const targetValues = normalizeDbValues(relObj, targetCollection);
210
219
 
211
- (normalizedValues as Record<string, unknown>)[key] = {
220
+ (normalizedValues as Record<string, unknown>)[key] = createRelationRefWithData(relId, targetPath, {
212
221
  id: relId,
213
222
  path: targetPath,
214
- __type: "relation" as const,
215
- data: {
216
- id: relId,
217
- path: targetPath,
218
- values: targetValues,
219
- databaseId
220
- }
221
- };
223
+ values: targetValues,
224
+ databaseId
225
+ });
222
226
  }
223
227
  }
224
228
 
225
229
  return {
226
- id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, any>, idInfoArray) : String(row[idInfo.fieldName]),
230
+ id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]),
227
231
  path: collectionPath,
228
232
  values: normalizedValues as M,
229
233
  databaseId
@@ -235,18 +239,17 @@ export class EntityFetchService {
235
239
  * joinPath relations cannot be expressed via Drizzle's `with` config,
236
240
  * so they must be loaded separately after the primary query.
237
241
  */
238
- private async resolveJoinPathRelations<M extends Record<string, any>>(
242
+ private async resolveJoinPathRelations<M extends Record<string, unknown>>(
239
243
  entity: Entity<M>,
240
244
  collection: EntityCollection,
241
245
  collectionPath: string,
242
246
  parsedId: string | number,
243
247
  databaseId?: string
244
248
  ): Promise<void> {
245
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
246
- const propertyKeys = new Set(Object.keys(collection.properties || {}));
249
+ const resolvedRelations = resolveCollectionRelations(collection);
247
250
 
248
251
  const promises = Object.entries(resolvedRelations)
249
- .filter(([key, relation]) => propertyKeys.has(key) && relation.joinPath && relation.joinPath.length > 0)
252
+ .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0)
250
253
  .map(async ([key, relation]) => {
251
254
  try {
252
255
  const relatedEntities = await this.relationService.fetchRelatedEntities(
@@ -258,19 +261,11 @@ export class EntityFetchService {
258
261
 
259
262
  if (relation.cardinality === "one" && relatedEntities.length > 0) {
260
263
  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
- };
264
+ (entity.values as Record<string, unknown>)[key] = createRelationRefWithData(e.id, e.path, e);
267
265
  } 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
- }));
266
+ (entity.values as Record<string, unknown>)[key] = relatedEntities.map(e =>
267
+ createRelationRefWithData(e.id, e.path, e)
268
+ );
274
269
  }
275
270
  } catch (e) {
276
271
  console.warn(`Could not resolve joinPath relation '${key}':`, e);
@@ -284,7 +279,7 @@ export class EntityFetchService {
284
279
  * Post-fetch joinPath relations for a batch of entities.
285
280
  * Uses batch fetching to avoid N+1 queries for list views.
286
281
  */
287
- private async resolveJoinPathRelationsBatch<M extends Record<string, any>>(
282
+ private async resolveJoinPathRelationsBatch<M extends Record<string, unknown>>(
288
283
  entities: Entity<M>[],
289
284
  collection: EntityCollection,
290
285
  collectionPath: string,
@@ -293,11 +288,10 @@ export class EntityFetchService {
293
288
  ): Promise<void> {
294
289
  if (entities.length === 0) return;
295
290
 
296
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
297
- const propertyKeys = new Set(Object.keys(collection.properties || {}));
291
+ const resolvedRelations = resolveCollectionRelations(collection);
298
292
 
299
293
  const joinPathRelations = Object.entries(resolvedRelations)
300
- .filter(([key, relation]) => propertyKeys.has(key) && relation.joinPath && relation.joinPath.length > 0);
294
+ .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
301
295
 
302
296
  if (joinPathRelations.length === 0) return;
303
297
 
@@ -318,21 +312,94 @@ export class EntityFetchService {
318
312
  for (const entity of entities) {
319
313
  const parsed = parseIdValues(entity.id, [idInfo]);
320
314
  const entityId = parsed[idInfo.fieldName] as string | number;
321
- const relatedEntity = resultMap.get(entityId);
315
+ const relatedEntity = resultMap.get(String(entityId));
322
316
 
323
317
  if (relatedEntity) {
324
318
  if (relation.cardinality === "one") {
325
- (entity.values as Record<string, unknown>)[key] = {
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] = {
326
377
  id: relatedEntity.id,
327
- path: relatedEntity.path,
328
- __type: "relation" as const,
329
- data: relatedEntity
378
+ ...relatedEntity.values
330
379
  };
380
+ } else {
381
+ row[key] = null;
331
382
  }
332
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
+ }
333
400
  }
334
401
  } catch (e) {
335
- console.warn(`Could not batch resolve joinPath relation '${key}':`, e);
402
+ console.warn(`Could not batch resolve joinPath relation '${key}' for REST:`, e);
336
403
  }
337
404
  }
338
405
  }
@@ -346,13 +413,13 @@ export class EntityFetchService {
346
413
  idInfo: { fieldName: string; type: "string" | "number" },
347
414
  idInfoArray?: { fieldName: string; type: "string" | "number" }[]
348
415
  ): 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>);
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);
351
418
 
352
419
  for (const [k, v] of Object.entries(row)) {
353
420
  if (k === idInfo.fieldName) continue;
354
421
 
355
- const relation = resolvedRelations[k];
422
+ const relation = findRelation(resolvedRelations, k);
356
423
  if (Array.isArray(v) && relation) {
357
424
  // Many relation — flatten each nested entity, handling junction tables
358
425
  flat[k] = v.map((item: Record<string, unknown>) => {
@@ -362,15 +429,18 @@ export class EntityFetchService {
362
429
  );
363
430
  if (nestedKey) {
364
431
  const nested = item[nestedKey] as Record<string, unknown>;
365
- return { id: String(nested.id ?? nested[Object.keys(nested)[0]]), ...nested };
432
+ return { id: String(nested.id ?? nested[Object.keys(nested)[0]]),
433
+ ...nested };
366
434
  }
367
435
  }
368
- return { id: String(item.id ?? item[Object.keys(item)[0]]), ...item };
436
+ return { id: String(item.id ?? item[Object.keys(item)[0]]),
437
+ ...item };
369
438
  });
370
439
  } else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
371
440
  // One-to-one relation — inline the object
372
441
  const relObj = v as Record<string, unknown>;
373
- flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]), ...relObj };
442
+ flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]),
443
+ ...relObj };
374
444
  } else {
375
445
  flat[k] = v;
376
446
  }
@@ -382,7 +452,7 @@ export class EntityFetchService {
382
452
  * Build db.query-compatible options from standard fetch options.
383
453
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
384
454
  */
385
- private buildDrizzleQueryOptions<M extends Record<string, any>>(
455
+ private buildDrizzleQueryOptions<M extends Record<string, unknown>>(
386
456
  table: PgTable<any>,
387
457
  idField: AnyPgColumn,
388
458
  idInfo: { fieldName: string; type: "string" | "number" },
@@ -391,11 +461,12 @@ export class EntityFetchService {
391
461
  orderBy?: string;
392
462
  order?: "desc" | "asc";
393
463
  limit?: number;
464
+ offset?: number;
394
465
  startAfter?: Record<string, unknown>;
395
466
  searchString?: string;
396
467
  },
397
468
  collectionPath: string,
398
- withConfig?: Record<string, any>
469
+ withConfig?: Record<string, unknown>
399
470
  ): Record<string, unknown> {
400
471
  const queryOpts: Record<string, unknown> = {};
401
472
 
@@ -424,7 +495,7 @@ export class EntityFetchService {
424
495
 
425
496
  // Cursor-based pagination (startAfter)
426
497
  if (options.startAfter) {
427
- const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options);
498
+ const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
428
499
  if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
429
500
  }
430
501
 
@@ -435,7 +506,8 @@ export class EntityFetchService {
435
506
  // OrderBy
436
507
  const orderExpressions: unknown[] = [];
437
508
  if (options.orderBy) {
438
- const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
509
+ const collection = getCollectionByPath(collectionPath, this.registry);
510
+ const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
439
511
  if (orderByField) {
440
512
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
441
513
  }
@@ -449,6 +521,9 @@ export class EntityFetchService {
449
521
  const limitValue = options.searchString ? (options.limit || 50) : options.limit;
450
522
  if (limitValue) queryOpts.limit = limitValue;
451
523
 
524
+ // Offset (numeric pagination)
525
+ if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
526
+
452
527
  return queryOpts;
453
528
  }
454
529
 
@@ -459,13 +534,15 @@ export class EntityFetchService {
459
534
  table: PgTable<any>,
460
535
  idField: AnyPgColumn,
461
536
  idInfo: { fieldName: string; type: "string" | "number" },
462
- options: { orderBy?: string; order?: "desc" | "asc"; startAfter?: Record<string, unknown> }
537
+ options: { orderBy?: string; order?: "desc" | "asc"; startAfter?: Record<string, unknown> },
538
+ collectionPath?: string
463
539
  ): SQL[] {
464
540
  if (!options.startAfter) return [];
465
541
  const cursor = options.startAfter;
466
542
 
467
543
  if (options.orderBy) {
468
- const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
544
+ const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
545
+ const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
469
546
  if (orderByField) {
470
547
  const startAfterOrderValue = (cursor.values as Record<string, unknown> | undefined)?.[options.orderBy] ?? cursor[options.orderBy];
471
548
  const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
@@ -499,7 +576,7 @@ export class EntityFetchService {
499
576
  /**
500
577
  * Fetch a single entity by ID
501
578
  */
502
- async fetchEntity<M extends Record<string, any>>(
579
+ async fetchEntity<M extends Record<string, unknown>>(
503
580
  collectionPath: string,
504
581
  entityId: string | number,
505
582
  databaseId?: string
@@ -518,14 +595,14 @@ export class EntityFetchService {
518
595
  const parsedId = parsedIdObj[idInfo.fieldName];
519
596
 
520
597
  // Primary path: use db.query.findFirst with relation loading
521
-
598
+
522
599
  const tableName = getTableName(table);
523
600
 
524
601
  const qb = this.getQueryBuilder(tableName);
525
602
  if (qb) {
526
603
  try {
527
604
  const withConfig = this.buildWithConfig(collection);
528
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
605
+
529
606
  const row = await qb.findFirst({
530
607
  where: eq(idField, parsedId),
531
608
  with: withConfig
@@ -557,7 +634,7 @@ export class EntityFetchService {
557
634
  const values = await parseDataFromServer(raw, collection, this.db, this.registry);
558
635
 
559
636
  // Load relations based on cardinality (N+1 — only used in fallback)
560
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
637
+ const resolvedRelations = resolveCollectionRelations(collection);
561
638
  const propertyKeys = new Set(Object.keys(collection.properties));
562
639
 
563
640
  const relationPromises = Object.entries(resolvedRelations)
@@ -570,11 +647,9 @@ export class EntityFetchService {
570
647
  key,
571
648
  {}
572
649
  );
573
- (values as Record<string, unknown>)[key] = relatedEntities.map(e => ({
574
- id: e.id,
575
- path: e.path,
576
- __type: "relation"
577
- }));
650
+ (values as Record<string, unknown>)[key] = relatedEntities.map(e =>
651
+ createRelationRef(e.id, e.path)
652
+ );
578
653
  } else if (relation.cardinality === "one") {
579
654
  if ((values as Record<string, unknown>)[key] == null) {
580
655
  try {
@@ -586,11 +661,7 @@ export class EntityFetchService {
586
661
  );
587
662
  if (relatedEntities.length > 0) {
588
663
  const e = relatedEntities[0];
589
- (values as Record<string, unknown>)[key] = {
590
- id: e.id,
591
- path: e.path,
592
- __type: "relation"
593
- };
664
+ (values as Record<string, unknown>)[key] = createRelationRef(e.id, e.path);
594
665
  }
595
666
  } catch (e) {
596
667
  console.warn(`Could not resolve one-to-one relation property: ${key}`, e);
@@ -612,13 +683,14 @@ export class EntityFetchService {
612
683
  /**
613
684
  * Unified method to fetch entities with optional search functionality
614
685
  */
615
- async fetchEntitiesWithConditions<M extends Record<string, any>>(
686
+ async fetchEntitiesWithConditions<M extends Record<string, unknown>>(
616
687
  collectionPath: string,
617
688
  options: {
618
689
  filter?: FilterValues<Extract<keyof M, string>>;
619
690
  orderBy?: string;
620
691
  order?: "desc" | "asc";
621
692
  limit?: number;
693
+ offset?: number;
622
694
  startAfter?: Record<string, unknown>;
623
695
  searchString?: string;
624
696
  databaseId?: string;
@@ -635,27 +707,30 @@ export class EntityFetchService {
635
707
  }
636
708
 
637
709
  // Primary path: use db.query.findMany with relation loading
638
-
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
+
639
715
  const tableName = getTableName(table);
640
716
 
641
717
  const qb = this.getQueryBuilder(tableName);
642
- if (qb) {
718
+ const withConfig = this.buildWithConfig(collection);
719
+ const hasRelations = withConfig && Object.keys(withConfig).length > 0;
720
+
721
+ if (qb && !options.searchString && !hasRelations) {
643
722
  try {
644
- const withConfig = this.buildWithConfig(collection);
645
723
  const queryOpts = this.buildDrizzleQueryOptions<M>(
646
- table, idField, idInfo, options, collectionPath, withConfig
724
+ table, idField, idInfo, options, collectionPath, undefined
647
725
  );
648
726
 
649
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
727
+
650
728
  const results = await qb.findMany(queryOpts as unknown as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
651
729
 
652
730
  const entities = (results as Record<string, unknown>[]).map(row =>
653
731
  this.drizzleResultToEntity<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
654
732
  );
655
733
 
656
- // Post-fetch joinPath relations that Drizzle's `with` can't express
657
- await this.resolveJoinPathRelationsBatch(entities, collection, collectionPath, idInfo, options.databaseId);
658
-
659
734
  return entities;
660
735
  } catch (e) {
661
736
  console.warn(`[EntityFetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select:`, e);
@@ -686,7 +761,7 @@ export class EntityFetchService {
686
761
 
687
762
  const orderExpressions = [];
688
763
  if (options.orderBy) {
689
- const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
764
+ const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
690
765
  if (orderByField) {
691
766
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
692
767
  }
@@ -695,7 +770,7 @@ export class EntityFetchService {
695
770
  if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
696
771
 
697
772
  if (options.startAfter) {
698
- const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options);
773
+ const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
699
774
  if (cursorConditions.length > 0) {
700
775
  allConditions.push(...cursorConditions);
701
776
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
@@ -706,6 +781,9 @@ export class EntityFetchService {
706
781
  const limitValue = options.searchString ? (options.limit || 50) : options.limit;
707
782
  if (limitValue) query = query.limit(limitValue);
708
783
 
784
+ // Offset (numeric pagination)
785
+ if (options.offset && options.offset > 0) query = query.offset(options.offset);
786
+
709
787
  const results = await query;
710
788
 
711
789
  return this.processEntityResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
@@ -718,31 +796,36 @@ export class EntityFetchService {
718
796
  *
719
797
  * Process raw database results into Entity objects with relations.
720
798
  */
721
- private async processEntityResults<M extends Record<string, any>>(
799
+ private async processEntityResults<M extends Record<string, unknown>>(
722
800
  results: Record<string, unknown>[],
723
801
  collection: EntityCollection,
724
802
  collectionPath: string,
725
803
  idInfo: { fieldName: string; type: "string" | "number" },
726
804
  databaseId?: string,
727
- skipRelations: boolean = false,
805
+ skipRelations = false,
728
806
  idInfoArray?: { fieldName: string; type: "string" | "number" }[]
729
807
  ): Promise<Entity<M>[]> {
730
808
  if (results.length === 0) return [];
731
809
 
732
- // First pass: parse all entities
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.
733
816
  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);
817
+ const values = await parseDataFromServer(entity as M, collection);
735
818
  return {
736
819
  entity,
737
820
  values,
738
- id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, any>, idInfoArray!) : String(entity[idInfo.fieldName]),
821
+ id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, unknown>, idInfoArray!) : String(entity[idInfo.fieldName]),
739
822
  path: collectionPath
740
823
  };
741
824
  }));
742
825
 
743
826
  if (!skipRelations) {
744
827
  // Second pass: batch load missing one-to-one relations
745
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
828
+ const resolvedRelations = resolveCollectionRelations(collection);
746
829
  const propertyKeys = new Set(Object.keys(collection.properties));
747
830
 
748
831
  for (const [key, relation] of Object.entries(resolvedRelations)) {
@@ -768,14 +851,9 @@ export class EntityFetchService {
768
851
 
769
852
  entitiesMissingRelation.forEach(item => {
770
853
  const entityId = item.entity[idInfo.fieldName] as string | number;
771
- const relatedEntity = relationResults.get(entityId);
854
+ const relatedEntity = relationResults.get(String(entityId));
772
855
  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
- };
856
+ (item.values as Record<string, unknown>)[key] = createRelationRefWithData(relatedEntity.id, relatedEntity.path, relatedEntity);
779
857
  }
780
858
  });
781
859
  } catch (e) {
@@ -783,32 +861,32 @@ export class EntityFetchService {
783
861
  }
784
862
  }
785
863
 
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
- });
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
+ );
810
878
 
811
- await Promise.all(manyRelationPromises);
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
+ }
812
890
  }
813
891
 
814
892
  return entitiesWithValues.map(item => ({
@@ -822,13 +900,14 @@ export class EntityFetchService {
822
900
  /**
823
901
  * Fetch a collection of entities
824
902
  */
825
- async fetchCollection<M extends Record<string, any>>(
903
+ async fetchCollection<M extends Record<string, unknown>>(
826
904
  collectionPath: string,
827
905
  options: {
828
906
  filter?: FilterValues<Extract<keyof M, string>>;
829
907
  orderBy?: string;
830
908
  order?: "desc" | "asc";
831
909
  limit?: number;
910
+ offset?: number;
832
911
  startAfter?: Record<string, unknown>;
833
912
  searchString?: string;
834
913
  databaseId?: string;
@@ -845,7 +924,7 @@ export class EntityFetchService {
845
924
  /**
846
925
  * Search entities by text
847
926
  */
848
- async searchEntities<M extends Record<string, any>>(
927
+ async searchEntities<M extends Record<string, unknown>>(
849
928
  collectionPath: string,
850
929
  searchString: string,
851
930
  options: {
@@ -865,7 +944,7 @@ export class EntityFetchService {
865
944
  /**
866
945
  * Fetch collection from multi-segment path
867
946
  */
868
- private async fetchCollectionFromPath<M extends Record<string, any>>(
947
+ private async fetchCollectionFromPath<M extends Record<string, unknown>>(
869
948
  path: string,
870
949
  options: {
871
950
  filter?: FilterValues<Extract<keyof M, string>>;
@@ -889,20 +968,27 @@ export class EntityFetchService {
889
968
 
890
969
  for (let i = 2; i < pathSegments.length; i += 2) {
891
970
  const relationKey = pathSegments[i];
892
- const resolvedRelations = resolveCollectionRelations(currentCollection as import("@rebasepro/types").PostgresCollection<any, any>);
893
- const relation = resolvedRelations[relationKey];
971
+ const resolvedRelations = resolveCollectionRelations(currentCollection);
972
+ const relation = findRelation(resolvedRelations, relationKey);
894
973
 
895
974
  if (!relation) {
896
975
  throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
897
976
  }
898
977
 
899
978
  if (i === pathSegments.length - 1) {
900
- return this.relationService.fetchRelatedEntities<M>(
979
+ const entities = await this.relationService.fetchRelatedEntities<M>(
901
980
  currentCollection.slug,
902
981
  currentEntityId,
903
982
  relationKey,
904
983
  options
905
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;
906
992
  }
907
993
 
908
994
  if (i + 1 < pathSegments.length) {
@@ -918,10 +1004,11 @@ export class EntityFetchService {
918
1004
  /**
919
1005
  * Count entities in a collection
920
1006
  */
921
- async countEntities<M extends Record<string, any>>(
1007
+ async countEntities<M extends Record<string, unknown>>(
922
1008
  collectionPath: string,
923
1009
  options: {
924
1010
  filter?: FilterValues<Extract<keyof M, string>>;
1011
+ searchString?: string;
925
1012
  databaseId?: string;
926
1013
  } = {}
927
1014
  ): Promise<number> {
@@ -933,12 +1020,24 @@ export class EntityFetchService {
933
1020
  const table = getTableForCollection(collection, this.registry);
934
1021
 
935
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
+ }
936
1032
 
937
1033
  if (options.filter) {
938
1034
  const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
939
- if (filterConditions.length > 0) {
940
- query = query.where(and(...filterConditions));
941
- }
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);
942
1041
  }
943
1042
 
944
1043
  const result = await query;
@@ -948,7 +1047,7 @@ export class EntityFetchService {
948
1047
  /**
949
1048
  * Count entities from multi-segment path
950
1049
  */
951
- private async countEntitiesFromPath<M extends Record<string, any>>(
1050
+ private async countEntitiesFromPath<M extends Record<string, unknown>>(
952
1051
  path: string,
953
1052
  options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
954
1053
  ): Promise<number> {
@@ -964,8 +1063,8 @@ export class EntityFetchService {
964
1063
 
965
1064
  for (let i = 2; i < pathSegments.length; i += 2) {
966
1065
  const relationKey = pathSegments[i];
967
- const resolvedRelations = resolveCollectionRelations(currentCollection as import("@rebasepro/types").PostgresCollection<any, any>);
968
- const relation = resolvedRelations[relationKey];
1066
+ const resolvedRelations = resolveCollectionRelations(currentCollection);
1067
+ const relation = findRelation(resolvedRelations, relationKey);
969
1068
 
970
1069
  if (!relation) {
971
1070
  throw new Error(`Relation '${relationKey}' not found`);
@@ -1046,13 +1145,14 @@ export class EntityFetchService {
1046
1145
  *
1047
1146
  * @param include - Array of relation keys to populate, or ["*"] for all
1048
1147
  */
1049
- async fetchCollectionForRest<M extends Record<string, any>>(
1148
+ async fetchCollectionForRest<M extends Record<string, unknown>>(
1050
1149
  collectionPath: string,
1051
1150
  options: {
1052
1151
  filter?: FilterValues<Extract<keyof M, string>>;
1053
1152
  orderBy?: string;
1054
1153
  order?: "desc" | "asc";
1055
1154
  limit?: number;
1155
+ offset?: number;
1056
1156
  startAfter?: Record<string, unknown>;
1057
1157
  searchString?: string;
1058
1158
  databaseId?: string;
@@ -1066,11 +1166,14 @@ export class EntityFetchService {
1066
1166
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1067
1167
 
1068
1168
  // Primary path: use db.query.findMany
1069
-
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
+
1070
1173
  const tableName = getTableName(table);
1071
1174
 
1072
1175
  const qb = this.getQueryBuilder(tableName);
1073
- if (qb) {
1176
+ if (qb && !options.searchString) {
1074
1177
  try {
1075
1178
  const withConfig = (include && include.length > 0)
1076
1179
  ? this.buildWithConfig(collection, include)
@@ -1080,12 +1183,17 @@ export class EntityFetchService {
1080
1183
  table, idField, idInfo, options, collectionPath, withConfig
1081
1184
  );
1082
1185
 
1083
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
1186
+
1084
1187
  const results = await qb.findMany(queryOpts as unknown as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1085
1188
 
1086
- return (results as Record<string, unknown>[]).map(row =>
1189
+ const restRows = (results as Record<string, unknown>[]).map(row =>
1087
1190
  this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
1088
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;
1089
1197
  } catch (e) {
1090
1198
  console.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back:`, e);
1091
1199
  }
@@ -1096,13 +1204,13 @@ export class EntityFetchService {
1096
1204
 
1097
1205
  if (!include || include.length === 0) {
1098
1206
  return entities.map(entity => ({
1099
- id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, any>, idInfoArray) : String(entity[idInfo.fieldName]),
1207
+ id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, unknown>, idInfoArray) : String(entity[idInfo.fieldName]),
1100
1208
  ...entity
1101
1209
  }));
1102
1210
  }
1103
1211
 
1104
1212
  // Fallback relation loading via batch
1105
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
1213
+ const resolvedRelations = resolveCollectionRelations(collection);
1106
1214
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
1107
1215
  const shouldInclude = (key: string) =>
1108
1216
  include[0] === "*" || include.includes(key);
@@ -1117,9 +1225,10 @@ export class EntityFetchService {
1117
1225
  );
1118
1226
  for (const entity of entities) {
1119
1227
  const eid = entity[idInfo.fieldName] as string | number;
1120
- const related = batchResults.get(eid);
1228
+ const related = batchResults.get(String(eid));
1121
1229
  if (related) {
1122
- (entity as Record<string, unknown>)[key] = { id: related.id, ...related.values };
1230
+ (entity as Record<string, unknown>)[key] = { id: related.id,
1231
+ ...related.values };
1123
1232
  }
1124
1233
  }
1125
1234
  } catch (e) {
@@ -1137,7 +1246,8 @@ export class EntityFetchService {
1137
1246
  const eid = entity[idInfo.fieldName] as string | number;
1138
1247
  const relatedList = batchResults.get(String(eid)) || [];
1139
1248
  (entity as Record<string, unknown>)[key] = relatedList.map(e => ({
1140
- id: e.id, ...e.values
1249
+ id: e.id,
1250
+ ...e.values
1141
1251
  }));
1142
1252
  }
1143
1253
  } catch (e) {
@@ -1146,7 +1256,7 @@ export class EntityFetchService {
1146
1256
  }
1147
1257
 
1148
1258
  return entities.map(entity => ({
1149
- id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, any>, idInfoArray) : String(entity[idInfo.fieldName]),
1259
+ id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, unknown>, idInfoArray) : String(entity[idInfo.fieldName]),
1150
1260
  ...entity
1151
1261
  }));
1152
1262
  }
@@ -1154,7 +1264,7 @@ export class EntityFetchService {
1154
1264
  /**
1155
1265
  * Fetch a single entity with optional relation includes for REST API.
1156
1266
  */
1157
- async fetchEntityForRest<M extends Record<string, any>>(
1267
+ async fetchEntityForRest<M extends Record<string, unknown>>(
1158
1268
  collectionPath: string,
1159
1269
  entityId: string | number,
1160
1270
  include?: string[],
@@ -1170,7 +1280,7 @@ export class EntityFetchService {
1170
1280
  const parsedId = parsedIdObj[idInfo.fieldName];
1171
1281
 
1172
1282
  // Primary path: use db.query.findFirst
1173
-
1283
+
1174
1284
  const tableName = getTableName(table);
1175
1285
 
1176
1286
  const qb = this.getQueryBuilder(tableName);
@@ -1180,7 +1290,7 @@ export class EntityFetchService {
1180
1290
  ? this.buildWithConfig(collection, include)
1181
1291
  : undefined;
1182
1292
 
1183
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
1293
+
1184
1294
  const row = await qb.findFirst({
1185
1295
  where: eq(idField, parsedId),
1186
1296
  ...(withConfig ? { with: withConfig } : {})
@@ -1188,7 +1298,12 @@ export class EntityFetchService {
1188
1298
 
1189
1299
  if (!row) return null;
1190
1300
 
1191
- return this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
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;
1192
1307
  } catch (e) {
1193
1308
  console.warn(`[fetchEntityForRest] db.query.findFirst failed for ${collectionPath}, falling back:`, e);
1194
1309
  }
@@ -1204,14 +1319,15 @@ export class EntityFetchService {
1204
1319
  if (result.length === 0) return null;
1205
1320
 
1206
1321
  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 };
1322
+ const flatEntity: Record<string, unknown> = { id: (idInfoArray.length > 1) ? buildCompositeId(raw as Record<string, unknown>, idInfoArray) : String(raw[idInfo.fieldName]),
1323
+ ...raw };
1208
1324
 
1209
1325
  if (!include || include.length === 0) {
1210
1326
  return flatEntity;
1211
1327
  }
1212
1328
 
1213
1329
  // Fallback relation population
1214
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
1330
+ const resolvedRelations = resolveCollectionRelations(collection);
1215
1331
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
1216
1332
  const shouldInclude = (key: string) =>
1217
1333
  include[0] === "*" || include.includes(key);
@@ -1227,11 +1343,13 @@ export class EntityFetchService {
1227
1343
  if (relation.cardinality === "one") {
1228
1344
  if (relatedEntities.length > 0) {
1229
1345
  const e = relatedEntities[0];
1230
- flatEntity[key] = { id: e.id, ...e.values };
1346
+ flatEntity[key] = { id: e.id,
1347
+ ...e.values };
1231
1348
  }
1232
1349
  } else {
1233
1350
  flatEntity[key] = relatedEntities.map(e => ({
1234
- id: e.id, ...e.values
1351
+ id: e.id,
1352
+ ...e.values
1235
1353
  }));
1236
1354
  }
1237
1355
  } catch (e) {
@@ -1245,13 +1363,14 @@ export class EntityFetchService {
1245
1363
  /**
1246
1364
  * Fetch raw rows without any relation processing (for REST fast path)
1247
1365
  */
1248
- private async fetchEntitiesWithConditionsRaw<M extends Record<string, any>>(
1366
+ private async fetchEntitiesWithConditionsRaw<M extends Record<string, unknown>>(
1249
1367
  collectionPath: string,
1250
1368
  options: {
1251
1369
  filter?: FilterValues<Extract<keyof M, string>>;
1252
1370
  orderBy?: string;
1253
1371
  order?: "desc" | "asc";
1254
1372
  limit?: number;
1373
+ offset?: number;
1255
1374
  startAfter?: Record<string, unknown>;
1256
1375
  searchString?: string;
1257
1376
  } = {}
@@ -1285,7 +1404,7 @@ export class EntityFetchService {
1285
1404
 
1286
1405
  const orderExpressions = [];
1287
1406
  if (options.orderBy) {
1288
- const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
1407
+ const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1289
1408
  if (orderByField) {
1290
1409
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
1291
1410
  }
@@ -1296,6 +1415,9 @@ export class EntityFetchService {
1296
1415
  const limitValue = options.searchString ? (options.limit || 50) : options.limit;
1297
1416
  if (limitValue) query = query.limit(limitValue);
1298
1417
 
1418
+ // Offset (numeric pagination)
1419
+ if (options.offset && options.offset > 0) query = query.offset(options.offset);
1420
+
1299
1421
  return await query as Record<string, unknown>[];
1300
1422
  }
1301
1423
 
@@ -1305,7 +1427,7 @@ export class EntityFetchService {
1305
1427
  * Note: Primary path now uses inline `getQueryBuilder()` checks.
1306
1428
  */
1307
1429
  private hasDrizzleQueryAPI(collectionPath: string): boolean {
1308
-
1430
+
1309
1431
  const qb = this.getQueryBuilder("__probe__");
1310
1432
  if (!qb) {
1311
1433
  // If getQueryBuilder returns undefined even for a probe, query API is not available
@@ -1323,7 +1445,7 @@ export class EntityFetchService {
1323
1445
  * Returns null if the API is not available or the query fails.
1324
1446
  * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
1325
1447
  */
1326
- private async fetchWithDrizzleQuery<M extends Record<string, any>>(
1448
+ private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
1327
1449
  collectionPath: string,
1328
1450
  collection: EntityCollection,
1329
1451
  options: {
@@ -1337,7 +1459,7 @@ export class EntityFetchService {
1337
1459
  idInfoArray?: { fieldName: string; type: "string" | "number" }[]
1338
1460
  ): Promise<Record<string, unknown>[] | null> {
1339
1461
  try {
1340
-
1462
+
1341
1463
  const table = getTableForCollection(collection, this.registry);
1342
1464
  const tableName = getTableName(table);
1343
1465
  const queryTarget = this.getQueryBuilder(tableName);
@@ -1345,7 +1467,7 @@ export class EntityFetchService {
1345
1467
  if (!queryTarget?.findMany) return null;
1346
1468
 
1347
1469
  // Build the `with` config from include array
1348
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
1470
+ const resolvedRelations = resolveCollectionRelations(collection);
1349
1471
  const withConfig: Record<string, boolean> = {};
1350
1472
  for (const [key, relation] of Object.entries(resolvedRelations)) {
1351
1473
  if (include[0] === "*" || include.includes(key)) {
@@ -1371,18 +1493,18 @@ export class EntityFetchService {
1371
1493
 
1372
1494
  // Build orderBy
1373
1495
  if (options.orderBy) {
1374
- const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
1496
+ const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1375
1497
  if (orderByField) {
1376
1498
  queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
1377
1499
  }
1378
1500
  }
1379
1501
 
1380
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- deprecated method, will be removed
1502
+
1381
1503
  const results = await queryTarget.findMany(queryOpts as unknown as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1382
1504
 
1383
1505
  // Flatten the nested Drizzle results into REST format
1384
1506
  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]) };
1507
+ const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]) };
1386
1508
  for (const [k, v] of Object.entries(row)) {
1387
1509
  if (k === idInfo.fieldName) continue;
1388
1510
  if (Array.isArray(v)) {
@@ -1394,14 +1516,17 @@ export class EntityFetchService {
1394
1516
  const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1395
1517
  if (nestedObj && keys.length <= 3) {
1396
1518
  const nested = item[nestedObj] as Record<string, unknown>;
1397
- return { id: String(nested.id ?? nested[Object.keys(nested)[0]]), ...nested };
1519
+ return { id: String(nested.id ?? nested[Object.keys(nested)[0]]),
1520
+ ...nested };
1398
1521
  }
1399
- return { id: String(item.id ?? item[Object.keys(item)[0]]), ...item };
1522
+ return { id: String(item.id ?? item[Object.keys(item)[0]]),
1523
+ ...item };
1400
1524
  });
1401
1525
  } else if (typeof v === "object" && v !== null) {
1402
1526
  // One-to-one relation — inline the object
1403
1527
  const relObj = v as Record<string, unknown>;
1404
- flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]), ...relObj };
1528
+ flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]),
1529
+ ...relObj };
1405
1530
  } else {
1406
1531
  flat[k] = v;
1407
1532
  }
@@ -1427,21 +1552,25 @@ export class EntityFetchService {
1427
1552
  parentIds: (string | number)[],
1428
1553
  relationKey: string
1429
1554
  ): Promise<Map<string, Entity[]>> {
1430
- const resultMap = new Map<string, Entity[]>();
1555
+ if (parentIds.length === 0) return new Map();
1431
1556
 
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
- });
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
+ }
1443
1566
 
1444
- await Promise.all(batchPromises);
1445
- return resultMap;
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
+ );
1446
1575
  }
1447
1576
  }