@rebasepro/server-postgres 0.9.1-canary.f2f61da → 0.9.1-canary.fd3754b

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