@rebasepro/server-postgres 0.9.1-canary.a57c262 → 0.9.1-canary.ad25bc0

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.
@@ -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,85 @@ 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
+ * - Type normalization (dates, numbers, NaN) via normalizeDbValues
158
+ * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
159
+ * - Flattening junction-table many-to-many results
160
+ *
161
+ * The row's own address is not among them: it is derived by the consumer
162
+ * from the collection's primary keys.
163
+ */
164
+ private drizzleResultToRow<M extends Record<string, unknown>>(
165
+ row: Record<string, unknown>,
166
+ collection: CollectionConfig
167
+ ): Record<string, unknown> {
168
+ const resolvedRelations = resolveCollectionRelations(collection);
169
+
170
+ // Normalize non-relation values (dates, numbers, etc.)
171
+ const normalizedValues = normalizeDbValues(row as M, collection) as Record<string, unknown>;
172
+
173
+ // Convert nested relation objects to CMS-style { id, path, __type: "relation" }
174
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
175
+ const drizzleRelName = relation.relationName || key;
176
+ const relData = row[drizzleRelName];
177
+
178
+ if (relData === undefined || relData === null) continue;
179
+
180
+ if (relation.cardinality === "many" && Array.isArray(relData)) {
181
+ const targetCollection = relation.target();
182
+ const targetPath = targetCollection.slug;
183
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
184
+ const targetIdField = targetPks[0].fieldName;
185
+
186
+ normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
187
+ // Handle junction table flattening:
188
+ // Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
189
+ let targetRow = item;
190
+ if (this.isJunctionRelation(relation, collection)) {
191
+ // Find the nested target object in the junction row
192
+ const nestedKey = Object.keys(item).find(
193
+ nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
194
+ );
195
+ if (nestedKey) {
196
+ targetRow = item[nestedKey] as Record<string, unknown>;
197
+ }
198
+ }
199
+
200
+ const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
201
+ const targetValues = normalizeDbValues(targetRow, targetCollection);
202
+
203
+ return createRelationRefWithData(relId, targetPath, {
204
+ id: relId,
205
+ path: targetPath,
206
+ values: targetValues
207
+ });
208
+ });
209
+ } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
210
+ const targetCollection = relation.target();
211
+ const targetPath = targetCollection.slug;
212
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
213
+ const targetIdField = targetPks[0].fieldName;
214
+ const relObj = relData as Record<string, unknown>;
215
+
216
+ const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
217
+ const targetValues = normalizeDbValues(relObj, targetCollection);
218
+
219
+ normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
220
+ id: relId,
221
+ path: targetPath,
222
+ values: targetValues
223
+ });
224
+ }
225
+ }
226
+
227
+ // A row is exactly its columns. The address is derived by the consumer
228
+ // from the collection's primary keys — writing it here would rename the
229
+ // key column (`sku` → `id`) and restringify it (`42` → `"42"`).
230
+ return normalizedValues;
231
+ }
232
+
146
233
  /**
147
234
  * Post-fetch joinPath relations for a single flat row.
148
235
  * joinPath relations cannot be expressed via Drizzle's `with` config,
@@ -184,6 +271,57 @@ export class FetchService {
184
271
  await Promise.all(promises);
185
272
  }
186
273
 
274
+ /**
275
+ * Post-fetch joinPath relations for a batch of flat rows.
276
+ * Uses batch fetching to avoid N+1 queries for list views.
277
+ */
278
+ private async resolveJoinPathRelationsBatch<M extends Record<string, unknown>>(
279
+ rows: Record<string, unknown>[],
280
+ collection: CollectionConfig,
281
+ collectionPath: string,
282
+ idInfo: { fieldName: string; type: "string" | "number" },
283
+ _databaseId?: string
284
+ ): Promise<void> {
285
+ if (rows.length === 0) return;
286
+
287
+ const resolvedRelations = resolveCollectionRelations(collection);
288
+
289
+ const joinPathRelations = Object.entries(resolvedRelations)
290
+ .filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
291
+
292
+ if (joinPathRelations.length === 0) return;
293
+
294
+ for (const [key, relation] of joinPathRelations) {
295
+ try {
296
+ const rowIds = rows.map(r => {
297
+ const parsed = parseIdValues(String(r.id), [idInfo]);
298
+ return parsed[idInfo.fieldName] as string | number;
299
+ });
300
+
301
+ const resultMap = await this.relationService.batchFetchRelatedEntities(
302
+ collectionPath,
303
+ rowIds,
304
+ key,
305
+ relation
306
+ );
307
+
308
+ for (const row of rows) {
309
+ const parsed = parseIdValues(String(row.id), [idInfo]);
310
+ const id = parsed[idInfo.fieldName] as string | number;
311
+ const relatedRow = resultMap.get(String(id));
312
+
313
+ if (relatedRow) {
314
+ if (relation.cardinality === "one") {
315
+ row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
316
+ }
317
+ }
318
+ }
319
+ } catch (e) {
320
+ logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
321
+ }
322
+ }
323
+ }
324
+
187
325
  /**
188
326
  * Resolves joinPath relations for raw REST rows and directly injects them.
189
327
  * Uses RelationService to query the database and maps results back to the flattened objects.
@@ -207,25 +345,15 @@ export class FetchService {
207
345
 
208
346
  if (joinPathRelations.length === 0) return;
209
347
 
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
- };
348
+ const idInfo = idInfoArray[0];
223
349
 
224
350
  for (const [key, relation] of joinPathRelations) {
225
351
  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);
352
+ // Determine the parent IDs based on the parsed string ID from the REST row
353
+ const rowIds = rows.map(r => {
354
+ const parsed = parseIdValues(String(r.id), idInfoArray);
355
+ return parsed[idInfo.fieldName] as string | number;
356
+ });
229
357
 
230
358
  if (relation.cardinality === "one") {
231
359
  const resultMap = await this.relationService.batchFetchRelatedEntities(
@@ -235,11 +363,19 @@ export class FetchService {
235
363
  relation
236
364
  );
237
365
 
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;
366
+ for (const row of rows) {
367
+ const parsed = parseIdValues(String(row.id), idInfoArray);
368
+ const id = parsed[idInfo.fieldName] as string | number;
369
+ const relatedRow = resultMap.get(String(id));
370
+
371
+ if (relatedRow) {
372
+ row[key] = {
373
+ ...relatedRow.values,
374
+ id: relatedRow.id
375
+ };
376
+ } else {
377
+ row[key] = null;
378
+ }
243
379
  }
244
380
  } else if (relation.cardinality === "many") {
245
381
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
@@ -249,9 +385,14 @@ export class FetchService {
249
385
  relation
250
386
  );
251
387
 
252
- for (const row of addressable) {
253
- const relatedList = resultMap.get(String(parentIdOf(row))) || [];
254
- row[key] = relatedList.map(e => ({ ...e.values }));
388
+ for (const row of rows) {
389
+ const parsed = parseIdValues(String(row.id), idInfoArray);
390
+ const id = parsed[idInfo.fieldName] as string | number;
391
+ const relatedList = resultMap.get(String(id)) || [];
392
+ row[key] = relatedList.map(e => ({
393
+ ...e.values,
394
+ id: e.id
395
+ }));
255
396
  }
256
397
  }
257
398
  } catch (e) {
@@ -260,6 +401,47 @@ export class FetchService {
260
401
  }
261
402
  }
262
403
 
404
+ /**
405
+ * Convert a db.query result row to a flat REST-style row with populated relations.
406
+ *
407
+ * Every column is copied through under its own name, with the value Postgres
408
+ * returned. This used to open with a synthesized `id` and then skip the key
409
+ * column, which renamed it (a `sku` primary key was served as `id`, and `sku`
410
+ * did not appear at all) and restringified it (`42` → `"42"`). Consumers that
411
+ * need an address derive it from the collection's primary keys.
412
+ */
413
+ private drizzleResultToRestRow(
414
+ row: Record<string, unknown>,
415
+ collection: CollectionConfig
416
+ ): Record<string, unknown> {
417
+ const flat: Record<string, unknown> = {};
418
+ const resolvedRelations = resolveCollectionRelations(collection);
419
+
420
+ for (const [k, v] of Object.entries(row)) {
421
+ const relation = findRelation(resolvedRelations, k);
422
+ if (Array.isArray(v) && relation) {
423
+ // Many relation — inline each nested row, unwrapping junction rows
424
+ flat[k] = v.map((item: Record<string, unknown>) => {
425
+ if (this.isJunctionRelation(relation, collection)) {
426
+ const nestedKey = Object.keys(item).find(
427
+ nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
428
+ );
429
+ if (nestedKey) {
430
+ return { ...(item[nestedKey] as Record<string, unknown>) };
431
+ }
432
+ }
433
+ return { ...item };
434
+ });
435
+ } else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
436
+ // One-to-one relation — inline the target's columns
437
+ flat[k] = { ...(v as Record<string, unknown>) };
438
+ } else {
439
+ flat[k] = v;
440
+ }
441
+ }
442
+ return flat;
443
+ }
444
+
263
445
  /**
264
446
  * Build db.query-compatible options from standard fetch options.
265
447
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
@@ -401,7 +583,7 @@ export class FetchService {
401
583
  ): Promise<Record<string, unknown> | undefined> {
402
584
  const collection = getCollectionByPath(collectionPath, this.registry);
403
585
  const table = getTableForCollection(collection, this.registry);
404
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
586
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
405
587
  const idInfo = idInfoArray[0];
406
588
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
407
589
 
@@ -428,7 +610,7 @@ export class FetchService {
428
610
 
429
611
  if (!row) return undefined;
430
612
 
431
- const flatRow = toCmsRow(row, collection, this.registry);
613
+ const flatRow = this.drizzleResultToRow<M>(row, collection);
432
614
 
433
615
  // Post-fetch joinPath relations that Drizzle's `with` can't express
434
616
  await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
@@ -520,7 +702,7 @@ export class FetchService {
520
702
  ): Promise<Record<string, unknown>[]> {
521
703
  const collection = getCollectionByPath(collectionPath, this.registry);
522
704
  const table = getTableForCollection(collection, this.registry);
523
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
705
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
524
706
  const idInfo = idInfoArray[0];
525
707
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
526
708
 
@@ -552,7 +734,7 @@ export class FetchService {
552
734
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
553
735
 
554
736
  const rows = (results as Record<string, unknown>[]).map(row =>
555
- toCmsRow(row, collection, this.registry)
737
+ this.drizzleResultToRow<M>(row, collection)
556
738
  );
557
739
 
558
740
  return rows;
@@ -652,10 +834,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
652
834
 
653
835
  /**
654
836
  * 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.
837
+ * The primary path uses drizzleResultToRow which handles relation
838
+ * mapping without N+1 queries.
659
839
  *
660
840
  * Process raw database results into flat rows with relations.
661
841
  */
@@ -840,12 +1020,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
840
1020
  relationKey,
841
1021
  options
842
1022
  );
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> }));
1023
+ // Convert RelatedRow[] from RelationService to flat rows
1024
+ return rows.map(row => ({
1025
+ ...row.values as Record<string, unknown>,
1026
+ id: row.id
1027
+ }));
849
1028
  }
850
1029
 
851
1030
  if (i + 1 < pathSegments.length) {
@@ -959,7 +1138,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
959
1138
 
960
1139
  const collection = getCollectionByPath(collectionPath, this.registry);
961
1140
  const table = getTableForCollection(collection, this.registry);
962
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1141
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
963
1142
  const idInfo = idInfoArray[0];
964
1143
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
965
1144
  const field = table[fieldName as keyof typeof table] as AnyPgColumn;
@@ -1019,7 +1198,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1019
1198
  ): Promise<Record<string, unknown>[]> {
1020
1199
  const collection = getCollectionByPath(collectionPath, this.registry);
1021
1200
  const table = getTableForCollection(collection, this.registry);
1022
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1201
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1023
1202
  const idInfo = idInfoArray[0];
1024
1203
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1025
1204
 
@@ -1046,7 +1225,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1046
1225
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1047
1226
 
1048
1227
  const restRows = (results as Record<string, unknown>[]).map(row =>
1049
- toRestRow(row, collection, this.registry)
1228
+ this.drizzleResultToRestRow(row, collection)
1050
1229
  );
1051
1230
 
1052
1231
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
@@ -1125,7 +1304,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1125
1304
  ): Promise<Record<string, unknown> | null> {
1126
1305
  const collection = getCollectionByPath(collectionPath, this.registry);
1127
1306
  const table = getTableForCollection(collection, this.registry);
1128
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1307
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1129
1308
  const idInfo = idInfoArray[0];
1130
1309
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1131
1310
 
@@ -1151,7 +1330,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1151
1330
 
1152
1331
  if (!row) return null;
1153
1332
 
1154
- const restRow = toRestRow(row, collection, this.registry);
1333
+ const restRow = this.drizzleResultToRestRow(row, collection);
1155
1334
 
1156
1335
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1157
1336
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
@@ -1233,7 +1412,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1233
1412
  ): Promise<Record<string, unknown>[]> {
1234
1413
  const collection = getCollectionByPath(collectionPath, this.registry);
1235
1414
  const table = getTableForCollection(collection, this.registry);
1236
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1415
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1237
1416
  const idInfo = idInfoArray[0];
1238
1417
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1239
1418