@rebasepro/server-postgres 0.9.1-canary.d198c11 → 0.9.1-canary.e2fc7b6

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,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,85 +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
- * - 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
-
233
146
  /**
234
147
  * Post-fetch joinPath relations for a single flat row.
235
148
  * joinPath relations cannot be expressed via Drizzle's `with` config,
@@ -271,57 +184,6 @@ export class FetchService {
271
184
  await Promise.all(promises);
272
185
  }
273
186
 
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
-
325
187
  /**
326
188
  * Resolves joinPath relations for raw REST rows and directly injects them.
327
189
  * Uses RelationService to query the database and maps results back to the flattened objects.
@@ -345,15 +207,25 @@ export class FetchService {
345
207
 
346
208
  if (joinPathRelations.length === 0) return;
347
209
 
348
- 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
+ };
349
223
 
350
224
  for (const [key, relation] of joinPathRelations) {
351
225
  try {
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
- });
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);
357
229
 
358
230
  if (relation.cardinality === "one") {
359
231
  const resultMap = await this.relationService.batchFetchRelatedEntities(
@@ -363,19 +235,11 @@ export class FetchService {
363
235
  relation
364
236
  );
365
237
 
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
- }
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;
379
243
  }
380
244
  } else if (relation.cardinality === "many") {
381
245
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
@@ -385,14 +249,9 @@ export class FetchService {
385
249
  relation
386
250
  );
387
251
 
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
- }));
252
+ for (const row of addressable) {
253
+ const relatedList = resultMap.get(String(parentIdOf(row))) || [];
254
+ row[key] = relatedList.map(e => ({ ...e.values }));
396
255
  }
397
256
  }
398
257
  } catch (e) {
@@ -401,47 +260,6 @@ export class FetchService {
401
260
  }
402
261
  }
403
262
 
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
-
445
263
  /**
446
264
  * Build db.query-compatible options from standard fetch options.
447
265
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
@@ -583,7 +401,7 @@ export class FetchService {
583
401
  ): Promise<Record<string, unknown> | undefined> {
584
402
  const collection = getCollectionByPath(collectionPath, this.registry);
585
403
  const table = getTableForCollection(collection, this.registry);
586
- const idInfoArray = getPrimaryKeys(collection, this.registry);
404
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
587
405
  const idInfo = idInfoArray[0];
588
406
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
589
407
 
@@ -610,7 +428,7 @@ export class FetchService {
610
428
 
611
429
  if (!row) return undefined;
612
430
 
613
- const flatRow = this.drizzleResultToRow<M>(row, collection);
431
+ const flatRow = toCmsRow(row, collection, this.registry);
614
432
 
615
433
  // Post-fetch joinPath relations that Drizzle's `with` can't express
616
434
  await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
@@ -702,7 +520,7 @@ export class FetchService {
702
520
  ): Promise<Record<string, unknown>[]> {
703
521
  const collection = getCollectionByPath(collectionPath, this.registry);
704
522
  const table = getTableForCollection(collection, this.registry);
705
- const idInfoArray = getPrimaryKeys(collection, this.registry);
523
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
706
524
  const idInfo = idInfoArray[0];
707
525
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
708
526
 
@@ -734,7 +552,7 @@ export class FetchService {
734
552
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
735
553
 
736
554
  const rows = (results as Record<string, unknown>[]).map(row =>
737
- this.drizzleResultToRow<M>(row, collection)
555
+ toCmsRow(row, collection, this.registry)
738
556
  );
739
557
 
740
558
  return rows;
@@ -834,8 +652,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
834
652
 
835
653
  /**
836
654
  * Fallback path used when db.query is unavailable.
837
- * The primary path uses drizzleResultToRow which handles relation
838
- * 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.
839
659
  *
840
660
  * Process raw database results into flat rows with relations.
841
661
  */
@@ -1020,11 +840,12 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1020
840
  relationKey,
1021
841
  options
1022
842
  );
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
- }));
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> }));
1028
849
  }
1029
850
 
1030
851
  if (i + 1 < pathSegments.length) {
@@ -1138,7 +959,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1138
959
 
1139
960
  const collection = getCollectionByPath(collectionPath, this.registry);
1140
961
  const table = getTableForCollection(collection, this.registry);
1141
- const idInfoArray = getPrimaryKeys(collection, this.registry);
962
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
1142
963
  const idInfo = idInfoArray[0];
1143
964
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1144
965
  const field = table[fieldName as keyof typeof table] as AnyPgColumn;
@@ -1198,7 +1019,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1198
1019
  ): Promise<Record<string, unknown>[]> {
1199
1020
  const collection = getCollectionByPath(collectionPath, this.registry);
1200
1021
  const table = getTableForCollection(collection, this.registry);
1201
- const idInfoArray = getPrimaryKeys(collection, this.registry);
1022
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
1202
1023
  const idInfo = idInfoArray[0];
1203
1024
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1204
1025
 
@@ -1225,7 +1046,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1225
1046
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1226
1047
 
1227
1048
  const restRows = (results as Record<string, unknown>[]).map(row =>
1228
- this.drizzleResultToRestRow(row, collection)
1049
+ toRestRow(row, collection, this.registry)
1229
1050
  );
1230
1051
 
1231
1052
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
@@ -1304,7 +1125,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1304
1125
  ): Promise<Record<string, unknown> | null> {
1305
1126
  const collection = getCollectionByPath(collectionPath, this.registry);
1306
1127
  const table = getTableForCollection(collection, this.registry);
1307
- const idInfoArray = getPrimaryKeys(collection, this.registry);
1128
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
1308
1129
  const idInfo = idInfoArray[0];
1309
1130
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1310
1131
 
@@ -1330,7 +1151,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1330
1151
 
1331
1152
  if (!row) return null;
1332
1153
 
1333
- const restRow = this.drizzleResultToRestRow(row, collection);
1154
+ const restRow = toRestRow(row, collection, this.registry);
1334
1155
 
1335
1156
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1336
1157
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
@@ -1412,7 +1233,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1412
1233
  ): Promise<Record<string, unknown>[]> {
1413
1234
  const collection = getCollectionByPath(collectionPath, this.registry);
1414
1235
  const table = getTableForCollection(collection, this.registry);
1415
- const idInfoArray = getPrimaryKeys(collection, this.registry);
1236
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
1416
1237
  const idInfo = idInfoArray[0];
1417
1238
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1418
1239
 
@@ -383,8 +383,15 @@ export class PersistService {
383
383
  throw this.toUserFriendlyError(error, collection.slug);
384
384
  }
385
385
 
386
- // Fetch the updated/created row to return with proper relation objects
387
- const finalEntity = await this.fetchService.fetchOne<M>(collection.slug, savedId, databaseId);
386
+ // Fetch the saved row back through the same walk `GET /:id` serves, so a
387
+ // write's answer is the read that follows it. This used to be `fetchOne`
388
+ // — the admin view-model walk — whose `__type: "relation"` refs then
389
+ // leaked into REST responses, afterSave callbacks, history records and
390
+ // app-level realtime payloads, none of which see that shape from any
391
+ // read path. The admin does not need them here either: its rows arrive
392
+ // over the realtime subscription refetch, which still serves the
393
+ // view-model walk.
394
+ const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, undefined, databaseId);
388
395
  if (!finalEntity) throw new Error("Could not fetch row after save.");
389
396
  return finalEntity;
390
397
  }