@rebasepro/server-postgres 0.9.1-canary.73476f2 → 0.9.1-canary.7ba0e49

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 (43) hide show
  1. package/dist/PostgresBackendDriver.d.ts +25 -2
  2. package/dist/PostgresBootstrapper.d.ts +10 -0
  3. package/dist/auth/services.d.ts +16 -0
  4. package/dist/collections/buildRegistry.d.ts +27 -0
  5. package/dist/connection.d.ts +21 -0
  6. package/dist/data-transformer.d.ts +9 -2
  7. package/dist/index.es.js +1445 -571
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/schema/doctor.d.ts +1 -1
  10. package/dist/services/FetchService.d.ts +4 -24
  11. package/dist/services/PersistService.d.ts +27 -1
  12. package/dist/services/RelationService.d.ts +34 -1
  13. package/dist/services/collection-helpers.d.ts +79 -14
  14. package/dist/services/dataService.d.ts +3 -1
  15. package/dist/services/index.d.ts +1 -1
  16. package/dist/services/realtimeService.d.ts +7 -0
  17. package/dist/services/row-pipeline.d.ts +63 -0
  18. package/package.json +11 -9
  19. package/src/PostgresBackendDriver.ts +127 -13
  20. package/src/PostgresBootstrapper.ts +62 -25
  21. package/src/auth/ensure-tables.ts +73 -11
  22. package/src/auth/services.ts +49 -19
  23. package/src/collections/buildRegistry.ts +59 -0
  24. package/src/connection.ts +61 -1
  25. package/src/data-transformer.ts +11 -9
  26. package/src/databasePoolManager.ts +2 -0
  27. package/src/schema/doctor.ts +45 -20
  28. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  29. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  30. package/src/schema/introspect-db.ts +19 -2
  31. package/src/services/BranchService.ts +42 -10
  32. package/src/services/FetchService.ts +65 -270
  33. package/src/services/PersistService.ts +130 -14
  34. package/src/services/RelationService.ts +153 -94
  35. package/src/services/collection-helpers.ts +164 -47
  36. package/src/services/dataService.ts +3 -2
  37. package/src/services/index.ts +1 -0
  38. package/src/services/realtimeService.ts +40 -19
  39. package/src/services/row-pipeline.ts +215 -0
  40. package/src/utils/drizzle-conditions.ts +13 -0
  41. package/src/websocket.ts +4 -1
  42. package/dist/schema/auth-default-policies.d.ts +0 -10
  43. package/src/schema/auth-default-policies.ts +0 -132
@@ -7,15 +7,18 @@ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
7
  import {
8
8
  getCollectionByPath,
9
9
  getTableForCollection,
10
- getPrimaryKeys,
10
+ requirePrimaryKeys,
11
+ deriveRowAddress,
11
12
  parseIdValues,
12
- buildCompositeId
13
+ buildCompositeId,
14
+ COMPOSITE_ID_SEPARATOR
13
15
  } from "./collection-helpers";
14
16
  import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
15
17
  import { RelationService } from "./RelationService";
16
18
  import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
17
19
  import { DrizzleClient } from "../interfaces";
18
20
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
21
+ import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
19
22
  import { logger } from "@rebasepro/server";
20
23
 
21
24
  /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
@@ -109,7 +112,7 @@ export class FetchService {
109
112
  // Detect many-to-many junction tables:
110
113
  // If the relation goes through a junction table (relation.through exists or
111
114
  // the Drizzle schema maps to a junction table), we need two-level with.
112
- if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
115
+ if (relation.cardinality === "many" && isJunctionRelation(relation)) {
113
116
  // The Drizzle relation points to the junction table.
114
117
  // We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
115
118
  // The target FK name is the relation on the junction table that points to the actual target.
@@ -127,17 +130,6 @@ export class FetchService {
127
130
  return withConfig;
128
131
  }
129
132
 
130
- /**
131
- * Detect if a many-to-many relation uses a junction table in the Drizzle schema.
132
- */
133
- private isJunctionRelation(relation: Relation, _collection: CollectionConfig): boolean {
134
- // If `through` is defined, it's explicitly a junction relation
135
- if (relation.through) return true;
136
- // If joinPath has an intermediate table, it's likely junction-based
137
- if (relation.joinPath && relation.joinPath.length > 1) return true;
138
- return false;
139
- }
140
-
141
133
  /**
142
134
  * Get the Drizzle relation name on the junction table that points to the actual target row.
143
135
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
@@ -151,88 +143,6 @@ export class FetchService {
151
143
  return null;
152
144
  }
153
145
 
154
- /**
155
- * Convert a db.query result row (with nested relation objects) to a flat row.
156
- * Handles:
157
- * - Placing `id` at the top level as a string
158
- * - Type normalization (dates, numbers, NaN) via normalizeDbValues
159
- * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
160
- * - Flattening junction-table many-to-many results
161
- */
162
- private drizzleResultToRow<M extends Record<string, unknown>>(
163
- row: Record<string, unknown>,
164
- collection: CollectionConfig,
165
- _collectionPath: string,
166
- idInfo: { fieldName: string; type: "string" | "number" },
167
- _databaseId?: string,
168
- idInfoArray?: { fieldName: string; type: "string" | "number" }[]
169
- ): Record<string, unknown> {
170
- const resolvedRelations = resolveCollectionRelations(collection);
171
-
172
- // Normalize non-relation values (dates, numbers, etc.)
173
- const normalizedValues = normalizeDbValues(row as M, collection) as Record<string, unknown>;
174
-
175
- // Convert nested relation objects to CMS-style { id, path, __type: "relation" }
176
- for (const [key, relation] of Object.entries(resolvedRelations)) {
177
- const drizzleRelName = relation.relationName || key;
178
- const relData = row[drizzleRelName];
179
-
180
- if (relData === undefined || relData === null) continue;
181
-
182
- if (relation.cardinality === "many" && Array.isArray(relData)) {
183
- const targetCollection = relation.target();
184
- const targetPath = targetCollection.slug;
185
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
186
- const targetIdField = targetPks[0].fieldName;
187
-
188
- normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
189
- // Handle junction table flattening:
190
- // Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
191
- let targetRow = item;
192
- if (this.isJunctionRelation(relation, collection)) {
193
- // Find the nested target object in the junction row
194
- const nestedKey = Object.keys(item).find(
195
- nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
196
- );
197
- if (nestedKey) {
198
- targetRow = item[nestedKey] as Record<string, unknown>;
199
- }
200
- }
201
-
202
- const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
203
- const targetValues = normalizeDbValues(targetRow, targetCollection);
204
-
205
- return createRelationRefWithData(relId, targetPath, {
206
- id: relId,
207
- path: targetPath,
208
- values: targetValues
209
- });
210
- });
211
- } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
212
- const targetCollection = relation.target();
213
- const targetPath = targetCollection.slug;
214
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
215
- const targetIdField = targetPks[0].fieldName;
216
- const relObj = relData as Record<string, unknown>;
217
-
218
- const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
219
- const targetValues = normalizeDbValues(relObj, targetCollection);
220
-
221
- normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
222
- id: relId,
223
- path: targetPath,
224
- values: targetValues
225
- });
226
- }
227
- }
228
-
229
- return {
230
- ...normalizedValues,
231
- // Spread the canonical id last so it wins over a raw `id` column
232
- id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
233
- };
234
- }
235
-
236
146
  /**
237
147
  * Post-fetch joinPath relations for a single flat row.
238
148
  * joinPath relations cannot be expressed via Drizzle's `with` config,
@@ -274,57 +184,6 @@ export class FetchService {
274
184
  await Promise.all(promises);
275
185
  }
276
186
 
277
- /**
278
- * Post-fetch joinPath relations for a batch of flat rows.
279
- * Uses batch fetching to avoid N+1 queries for list views.
280
- */
281
- private async resolveJoinPathRelationsBatch<M extends Record<string, unknown>>(
282
- rows: Record<string, unknown>[],
283
- collection: CollectionConfig,
284
- collectionPath: string,
285
- idInfo: { fieldName: string; type: "string" | "number" },
286
- _databaseId?: string
287
- ): Promise<void> {
288
- if (rows.length === 0) return;
289
-
290
- const resolvedRelations = resolveCollectionRelations(collection);
291
-
292
- const joinPathRelations = Object.entries(resolvedRelations)
293
- .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
294
-
295
- if (joinPathRelations.length === 0) return;
296
-
297
- for (const [key, relation] of joinPathRelations) {
298
- try {
299
- const rowIds = rows.map(r => {
300
- const parsed = parseIdValues(String(r.id), [idInfo]);
301
- return parsed[idInfo.fieldName] as string | number;
302
- });
303
-
304
- const resultMap = await this.relationService.batchFetchRelatedEntities(
305
- collectionPath,
306
- rowIds,
307
- key,
308
- relation
309
- );
310
-
311
- for (const row of rows) {
312
- const parsed = parseIdValues(String(row.id), [idInfo]);
313
- const id = parsed[idInfo.fieldName] as string | number;
314
- const relatedRow = resultMap.get(String(id));
315
-
316
- if (relatedRow) {
317
- if (relation.cardinality === "one") {
318
- row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
319
- }
320
- }
321
- }
322
- } catch (e) {
323
- logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
324
- }
325
- }
326
- }
327
-
328
187
  /**
329
188
  * Resolves joinPath relations for raw REST rows and directly injects them.
330
189
  * Uses RelationService to query the database and maps results back to the flattened objects.
@@ -348,15 +207,25 @@ export class FetchService {
348
207
 
349
208
  if (joinPathRelations.length === 0) return;
350
209
 
351
- const idInfo = idInfoArray[0];
210
+ // These rows carry their key columns verbatim, so the parent's address
211
+ // is derived from them. It used to be parsed back out of a synthesized
212
+ // `id` — which no longer exists on a row, and threw (composite: parts
213
+ // mismatch; numeric: NaN) into the catch below, where a warning is all
214
+ // that separates "no relations" from "relations dropped".
215
+ //
216
+ // The whole key, because that is what the batch groups its results by:
217
+ // both sides derive the token the same way, so they agree by
218
+ // construction rather than by both happening to pick column zero.
219
+ const parentIdOf = (row: Record<string, unknown>): string | undefined => {
220
+ const address = buildCompositeId(row, idInfoArray);
221
+ return address && address.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "") ? address : undefined;
222
+ };
352
223
 
353
224
  for (const [key, relation] of joinPathRelations) {
354
225
  try {
355
- // Determine the parent IDs based on the parsed string ID from the REST row
356
- const rowIds = rows.map(r => {
357
- const parsed = parseIdValues(String(r.id), idInfoArray);
358
- return parsed[idInfo.fieldName] as string | number;
359
- });
226
+ const addressable = rows.filter(r => parentIdOf(r) !== undefined && parentIdOf(r) !== null);
227
+ if (addressable.length === 0) continue;
228
+ const rowIds = addressable.map(r => parentIdOf(r) as string | number);
360
229
 
361
230
  if (relation.cardinality === "one") {
362
231
  const resultMap = await this.relationService.batchFetchRelatedEntities(
@@ -366,19 +235,11 @@ export class FetchService {
366
235
  relation
367
236
  );
368
237
 
369
- for (const row of rows) {
370
- const parsed = parseIdValues(String(row.id), idInfoArray);
371
- const id = parsed[idInfo.fieldName] as string | number;
372
- const relatedRow = resultMap.get(String(id));
373
-
374
- if (relatedRow) {
375
- row[key] = {
376
- ...relatedRow.values,
377
- id: relatedRow.id
378
- };
379
- } else {
380
- row[key] = null;
381
- }
238
+ for (const row of addressable) {
239
+ const relatedRow = resultMap.get(String(parentIdOf(row)));
240
+ // Columns only: the target's address is the consumer's to
241
+ // derive, and merging it last overwrote a real `id` column.
242
+ row[key] = relatedRow ? { ...relatedRow.values } : null;
382
243
  }
383
244
  } else if (relation.cardinality === "many") {
384
245
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
@@ -388,14 +249,9 @@ export class FetchService {
388
249
  relation
389
250
  );
390
251
 
391
- for (const row of rows) {
392
- const parsed = parseIdValues(String(row.id), idInfoArray);
393
- const id = parsed[idInfo.fieldName] as string | number;
394
- const relatedList = resultMap.get(String(id)) || [];
395
- row[key] = relatedList.map(e => ({
396
- ...e.values,
397
- id: e.id
398
- }));
252
+ for (const row of addressable) {
253
+ const relatedList = resultMap.get(String(parentIdOf(row))) || [];
254
+ row[key] = relatedList.map(e => ({ ...e.values }));
399
255
  }
400
256
  }
401
257
  } catch (e) {
@@ -404,50 +260,6 @@ export class FetchService {
404
260
  }
405
261
  }
406
262
 
407
- /**
408
- * Convert a db.query result row to a flat REST-style object with populated relations.
409
- */
410
- private drizzleResultToRestRow(
411
- row: Record<string, unknown>,
412
- collection: CollectionConfig,
413
- idInfo: { fieldName: string; type: "string" | "number" },
414
- idInfoArray?: { fieldName: string; type: "string" | "number" }[]
415
- ): Record<string, unknown> {
416
- const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]) };
417
- const resolvedRelations = resolveCollectionRelations(collection);
418
-
419
- for (const [k, v] of Object.entries(row)) {
420
- if (k === idInfo.fieldName) continue;
421
-
422
- const relation = findRelation(resolvedRelations, k);
423
- if (Array.isArray(v) && relation) {
424
- // Many relation — flatten each nested row, handling junction tables
425
- flat[k] = v.map((item: Record<string, unknown>) => {
426
- if (this.isJunctionRelation(relation, collection)) {
427
- const nestedKey = Object.keys(item).find(
428
- nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
429
- );
430
- if (nestedKey) {
431
- const nested = item[nestedKey] as Record<string, unknown>;
432
- return { ...nested,
433
- id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
434
- }
435
- }
436
- return { ...item,
437
- id: String(item.id ?? item[Object.keys(item)[0]]) };
438
- });
439
- } else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
440
- // One-to-one relation — inline the object
441
- const relObj = v as Record<string, unknown>;
442
- flat[k] = { ...relObj,
443
- id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
444
- } else {
445
- flat[k] = v;
446
- }
447
- }
448
- return flat;
449
- }
450
-
451
263
  /**
452
264
  * Build db.query-compatible options from standard fetch options.
453
265
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
@@ -589,7 +401,7 @@ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
589
401
  ): Promise<Record<string, unknown> | undefined> {
590
402
  const collection = getCollectionByPath(collectionPath, this.registry);
591
403
  const table = getTableForCollection(collection, this.registry);
592
- const idInfoArray = getPrimaryKeys(collection, this.registry);
404
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
593
405
  const idInfo = idInfoArray[0];
594
406
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
595
407
 
@@ -616,7 +428,7 @@ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
616
428
 
617
429
  if (!row) return undefined;
618
430
 
619
- const flatRow = this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
431
+ const flatRow = toCmsRow(row, collection, this.registry);
620
432
 
621
433
  // Post-fetch joinPath relations that Drizzle's `with` can't express
622
434
  await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
@@ -708,7 +520,7 @@ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
708
520
  ): Promise<Record<string, unknown>[]> {
709
521
  const collection = getCollectionByPath(collectionPath, this.registry);
710
522
  const table = getTableForCollection(collection, this.registry);
711
- const idInfoArray = getPrimaryKeys(collection, this.registry);
523
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
712
524
  const idInfo = idInfoArray[0];
713
525
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
714
526
 
@@ -740,7 +552,7 @@ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
740
552
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
741
553
 
742
554
  const rows = (results as Record<string, unknown>[]).map(row =>
743
- this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
555
+ toCmsRow(row, collection, this.registry)
744
556
  );
745
557
 
746
558
  return rows;
@@ -840,8 +652,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
840
652
 
841
653
  /**
842
654
  * Fallback path used when db.query is unavailable.
843
- * The primary path uses drizzleResultToRow which handles relation
844
- * mapping without N+1 queries.
655
+ *
656
+ * The primary path runs the results through `toCmsRow`, which maps
657
+ * relations from what drizzle already nested — no query per row. This one
658
+ * has no nesting to read, so it resolves relations itself, in batches.
845
659
  *
846
660
  * Process raw database results into flat rows with relations.
847
661
  */
@@ -864,11 +678,9 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
864
678
  // relation type, avoiding the N+1 that plagued the old path.
865
679
  const parsedRows = await Promise.all(results.map(async (rawRow: Record<string, unknown>) => {
866
680
  const values = await parseDataFromServer(rawRow as M, collection) as Record<string, unknown>;
867
- const id = (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(rawRow as Record<string, unknown>, idInfoArray!) : String(rawRow[idInfo.fieldName]);
868
681
  return {
869
682
  rawRow,
870
- values,
871
- id
683
+ values
872
684
  };
873
685
  }));
874
686
 
@@ -938,10 +750,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
938
750
  }
939
751
  }
940
752
 
941
- return parsedRows.map(item => ({
942
- ...item.values,
943
- id: item.id
944
- }));
753
+ // Columns only — the address is the consumer's to derive.
754
+ return parsedRows.map(item => item.values);
945
755
  }
946
756
 
947
757
  /**
@@ -1030,11 +840,12 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1030
840
  relationKey,
1031
841
  options
1032
842
  );
1033
- // Convert RelatedRow[] from RelationService to flat rows
1034
- return rows.map(row => ({
1035
- ...row.values as Record<string, unknown>,
1036
- id: row.id
1037
- }));
843
+ // Flatten RelatedRow[] to rows: the target's columns, and only
844
+ // those. Merging `row.id` in last overwrote a real `id` column,
845
+ // and the address is derivable without it — a consumer resolves
846
+ // this path ("posts/1/comments") to the target collection and
847
+ // reads the key columns, which `values` carries.
848
+ return rows.map(row => ({ ...row.values as Record<string, unknown> }));
1038
849
  }
1039
850
 
1040
851
  if (i + 1 < pathSegments.length) {
@@ -1148,7 +959,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1148
959
 
1149
960
  const collection = getCollectionByPath(collectionPath, this.registry);
1150
961
  const table = getTableForCollection(collection, this.registry);
1151
- const idInfoArray = getPrimaryKeys(collection, this.registry);
962
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
1152
963
  const idInfo = idInfoArray[0];
1153
964
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1154
965
  const field = table[fieldName as keyof typeof table] as AnyPgColumn;
@@ -1208,7 +1019,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1208
1019
  ): Promise<Record<string, unknown>[]> {
1209
1020
  const collection = getCollectionByPath(collectionPath, this.registry);
1210
1021
  const table = getTableForCollection(collection, this.registry);
1211
- const idInfoArray = getPrimaryKeys(collection, this.registry);
1022
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
1212
1023
  const idInfo = idInfoArray[0];
1213
1024
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1214
1025
 
@@ -1235,7 +1046,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1235
1046
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1236
1047
 
1237
1048
  const restRows = (results as Record<string, unknown>[]).map(row =>
1238
- this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
1049
+ toRestRow(row, collection, this.registry)
1239
1050
  );
1240
1051
 
1241
1052
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
@@ -1255,10 +1066,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1255
1066
  const rows = await this.fetchRowsWithConditionsRaw<M>(collectionPath, options);
1256
1067
 
1257
1068
  if (!include || include.length === 0) {
1258
- return rows.map(row => ({
1259
- ...row,
1260
- id: (idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
1261
- }));
1069
+ return rows;
1262
1070
  }
1263
1071
 
1264
1072
  // Fallback relation loading via batch
@@ -1279,8 +1087,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1279
1087
  const eid = row[idInfo.fieldName] as string | number;
1280
1088
  const related = batchResults.get(String(eid));
1281
1089
  if (related) {
1282
- (row as Record<string, unknown>)[key] = { ...related.values,
1283
- id: related.id };
1090
+ (row as Record<string, unknown>)[key] = { ...related.values };
1284
1091
  }
1285
1092
  }
1286
1093
  } catch (e) {
@@ -1304,10 +1111,7 @@ id: related.id };
1304
1111
  }
1305
1112
  }
1306
1113
 
1307
- return rows.map(row => ({
1308
- ...row,
1309
- id: (idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
1310
- }));
1114
+ return rows;
1311
1115
  }
1312
1116
 
1313
1117
  /**
@@ -1321,7 +1125,7 @@ id: related.id };
1321
1125
  ): Promise<Record<string, unknown> | null> {
1322
1126
  const collection = getCollectionByPath(collectionPath, this.registry);
1323
1127
  const table = getTableForCollection(collection, this.registry);
1324
- const idInfoArray = getPrimaryKeys(collection, this.registry);
1128
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
1325
1129
  const idInfo = idInfoArray[0];
1326
1130
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1327
1131
 
@@ -1347,7 +1151,7 @@ id: related.id };
1347
1151
 
1348
1152
  if (!row) return null;
1349
1153
 
1350
- const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
1154
+ const restRow = toRestRow(row, collection, this.registry);
1351
1155
 
1352
1156
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1353
1157
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
@@ -1371,9 +1175,7 @@ id: related.id };
1371
1175
 
1372
1176
  if (result.length === 0) return null;
1373
1177
 
1374
- const raw = result[0] as Record<string, unknown>;
1375
- const flatEntity: Record<string, unknown> = { ...raw,
1376
- id: (idInfoArray.length > 1) ? buildCompositeId(raw as Record<string, unknown>, idInfoArray) : String(raw[idInfo.fieldName]) };
1178
+ const flatEntity: Record<string, unknown> = { ...(result[0] as Record<string, unknown>) };
1377
1179
 
1378
1180
  if (!include || include.length === 0) {
1379
1181
  return flatEntity;
@@ -1431,7 +1233,7 @@ id: (idInfoArray.length > 1) ? buildCompositeId(raw as Record<string, unknown>,
1431
1233
  ): Promise<Record<string, unknown>[]> {
1432
1234
  const collection = getCollectionByPath(collectionPath, this.registry);
1433
1235
  const table = getTableForCollection(collection, this.registry);
1434
- const idInfoArray = getPrimaryKeys(collection, this.registry);
1236
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
1435
1237
  const idInfo = idInfoArray[0];
1436
1238
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1437
1239
 
@@ -1581,31 +1383,24 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1581
1383
 
1582
1384
  const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1583
1385
 
1584
- // Flatten the nested Drizzle results into REST format
1386
+ // Inline the nested Drizzle results, columns only — no synthesized id.
1585
1387
  return results.map((row: Record<string, unknown>) => {
1586
- const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]) };
1388
+ const flat: Record<string, unknown> = {};
1587
1389
  for (const [k, v] of Object.entries(row)) {
1588
- if (k === idInfo.fieldName) continue;
1589
1390
  if (Array.isArray(v)) {
1590
- // Many relation — flatten each nested row
1391
+ // Many relation — inline each nested row
1591
1392
  flat[k] = v.map((item: Record<string, unknown>) => {
1592
- // Junction table rows may have the target nested, flatten those
1393
+ // Junction table rows may have the target nested, unwrap those
1593
1394
  const keys = Object.keys(item);
1594
- // If it looks like a junction row (only FKs + nested objects), extract nested
1595
1395
  const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1596
1396
  if (nestedObj && keys.length <= 3) {
1597
- const nested = item[nestedObj] as Record<string, unknown>;
1598
- return { ...nested,
1599
- id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
1397
+ return { ...(item[nestedObj] as Record<string, unknown>) };
1600
1398
  }
1601
- return { ...item,
1602
- id: String(item.id ?? item[Object.keys(item)[0]]) };
1399
+ return { ...item };
1603
1400
  });
1604
1401
  } else if (typeof v === "object" && v !== null) {
1605
- // One-to-one relation — inline the object
1606
- const relObj = v as Record<string, unknown>;
1607
- flat[k] = { ...relObj,
1608
- id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
1402
+ // One-to-one relation — inline the target's columns
1403
+ flat[k] = { ...(v as Record<string, unknown>) };
1609
1404
  } else {
1610
1405
  flat[k] = v;
1611
1406
  }