@rebasepro/server-postgres 0.9.1-canary.742f831 → 0.9.1-canary.7dddf96

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,16 @@ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
7
  import {
8
8
  getCollectionByPath,
9
9
  getTableForCollection,
10
- requirePrimaryKeys,
10
+ getPrimaryKeys,
11
11
  deriveRowAddress,
12
12
  parseIdValues,
13
- buildCompositeId,
14
- COMPOSITE_ID_SEPARATOR
13
+ buildCompositeId
15
14
  } from "./collection-helpers";
16
15
  import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
17
16
  import { RelationService } from "./RelationService";
18
17
  import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
19
18
  import { DrizzleClient } from "../interfaces";
20
19
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
21
- import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
22
20
  import { logger } from "@rebasepro/server";
23
21
 
24
22
  /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
@@ -112,7 +110,7 @@ export class FetchService {
112
110
  // Detect many-to-many junction tables:
113
111
  // If the relation goes through a junction table (relation.through exists or
114
112
  // the Drizzle schema maps to a junction table), we need two-level with.
115
- if (relation.cardinality === "many" && isJunctionRelation(relation)) {
113
+ if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
116
114
  // The Drizzle relation points to the junction table.
117
115
  // We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
118
116
  // The target FK name is the relation on the junction table that points to the actual target.
@@ -130,6 +128,17 @@ export class FetchService {
130
128
  return withConfig;
131
129
  }
132
130
 
131
+ /**
132
+ * Detect if a many-to-many relation uses a junction table in the Drizzle schema.
133
+ */
134
+ private isJunctionRelation(relation: Relation, _collection: CollectionConfig): boolean {
135
+ // If `through` is defined, it's explicitly a junction relation
136
+ if (relation.through) return true;
137
+ // If joinPath has an intermediate table, it's likely junction-based
138
+ if (relation.joinPath && relation.joinPath.length > 1) return true;
139
+ return false;
140
+ }
141
+
133
142
  /**
134
143
  * Get the Drizzle relation name on the junction table that points to the actual target row.
135
144
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
@@ -143,6 +152,98 @@ export class FetchService {
143
152
  return null;
144
153
  }
145
154
 
155
+ /**
156
+ * The address a relation ref points at.
157
+ *
158
+ * The whole key, not its first column: a composite-keyed target addressed
159
+ * by `tenant_id` alone points at every row that shares it. And a target
160
+ * whose key cannot be resolved at all used to throw here — reading
161
+ * `targetPks[0]` of an empty array — taking down the parent's fetch over a
162
+ * relation it may not even have asked for; the first column is a guess, but
163
+ * a ref that resolves to nothing beats no rows at all.
164
+ */
165
+ private relationTargetAddress(targetRow: Record<string, unknown>, targetCollection: CollectionConfig): string {
166
+ const address = deriveRowAddress(targetRow, targetCollection, this.registry);
167
+ if (address) return address;
168
+ const firstColumn = targetRow[Object.keys(targetRow)[0]];
169
+ return String(firstColumn ?? "");
170
+ }
171
+
172
+ /**
173
+ * Convert a db.query result row (with nested relation objects) to a flat row.
174
+ * Handles:
175
+ * - Type normalization (dates, numbers, NaN) via normalizeDbValues
176
+ * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
177
+ * - Flattening junction-table many-to-many results
178
+ *
179
+ * The row's own address is not among them: it is derived by the consumer
180
+ * from the collection's primary keys.
181
+ */
182
+ private drizzleResultToRow<M extends Record<string, unknown>>(
183
+ row: Record<string, unknown>,
184
+ collection: CollectionConfig
185
+ ): Record<string, unknown> {
186
+ const resolvedRelations = resolveCollectionRelations(collection);
187
+
188
+ // Normalize non-relation values (dates, numbers, etc.)
189
+ const normalizedValues = normalizeDbValues(row as M, collection) as Record<string, unknown>;
190
+
191
+ // Convert nested relation objects to CMS-style { id, path, __type: "relation" }
192
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
193
+ const drizzleRelName = relation.relationName || key;
194
+ const relData = row[drizzleRelName];
195
+
196
+ if (relData === undefined || relData === null) continue;
197
+
198
+ if (relation.cardinality === "many" && Array.isArray(relData)) {
199
+ const targetCollection = relation.target();
200
+ const targetPath = targetCollection.slug;
201
+
202
+ normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
203
+ // Handle junction table flattening:
204
+ // Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
205
+ let targetRow = item;
206
+ if (this.isJunctionRelation(relation, collection)) {
207
+ // Find the nested target object in the junction row
208
+ const nestedKey = Object.keys(item).find(
209
+ nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
210
+ );
211
+ if (nestedKey) {
212
+ targetRow = item[nestedKey] as Record<string, unknown>;
213
+ }
214
+ }
215
+
216
+ const relId = this.relationTargetAddress(targetRow, targetCollection);
217
+ const targetValues = normalizeDbValues(targetRow, targetCollection);
218
+
219
+ return createRelationRefWithData(relId, targetPath, {
220
+ id: relId,
221
+ path: targetPath,
222
+ values: targetValues
223
+ });
224
+ });
225
+ } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
226
+ const targetCollection = relation.target();
227
+ const targetPath = targetCollection.slug;
228
+ const relObj = relData as Record<string, unknown>;
229
+
230
+ const relId = this.relationTargetAddress(relObj, targetCollection);
231
+ const targetValues = normalizeDbValues(relObj, targetCollection);
232
+
233
+ normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
234
+ id: relId,
235
+ path: targetPath,
236
+ values: targetValues
237
+ });
238
+ }
239
+ }
240
+
241
+ // A row is exactly its columns. The address is derived by the consumer
242
+ // from the collection's primary keys — writing it here would rename the
243
+ // key column (`sku` → `id`) and restringify it (`42` → `"42"`).
244
+ return normalizedValues;
245
+ }
246
+
146
247
  /**
147
248
  * Post-fetch joinPath relations for a single flat row.
148
249
  * joinPath relations cannot be expressed via Drizzle's `with` config,
@@ -207,19 +308,13 @@ export class FetchService {
207
308
 
208
309
  if (joinPathRelations.length === 0) return;
209
310
 
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
311
+ const idInfo = idInfoArray[0];
312
+ // These rows carry their key columns verbatim, so the parent id is read
313
+ // straight off them. It used to be parsed back out of a synthesized
212
314
  // `id` — which no longer exists on a row, and threw (composite: parts
213
315
  // mismatch; numeric: NaN) into the catch below, where a warning is all
214
316
  // 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
- };
317
+ const parentIdOf = (row: Record<string, unknown>) => row[idInfo.fieldName] as string | number | undefined;
223
318
 
224
319
  for (const [key, relation] of joinPathRelations) {
225
320
  try {
@@ -260,6 +355,47 @@ export class FetchService {
260
355
  }
261
356
  }
262
357
 
358
+ /**
359
+ * Convert a db.query result row to a flat REST-style row with populated relations.
360
+ *
361
+ * Every column is copied through under its own name, with the value Postgres
362
+ * returned. This used to open with a synthesized `id` and then skip the key
363
+ * column, which renamed it (a `sku` primary key was served as `id`, and `sku`
364
+ * did not appear at all) and restringified it (`42` → `"42"`). Consumers that
365
+ * need an address derive it from the collection's primary keys.
366
+ */
367
+ private drizzleResultToRestRow(
368
+ row: Record<string, unknown>,
369
+ collection: CollectionConfig
370
+ ): Record<string, unknown> {
371
+ const flat: Record<string, unknown> = {};
372
+ const resolvedRelations = resolveCollectionRelations(collection);
373
+
374
+ for (const [k, v] of Object.entries(row)) {
375
+ const relation = findRelation(resolvedRelations, k);
376
+ if (Array.isArray(v) && relation) {
377
+ // Many relation — inline each nested row, unwrapping junction rows
378
+ flat[k] = v.map((item: Record<string, unknown>) => {
379
+ if (this.isJunctionRelation(relation, collection)) {
380
+ const nestedKey = Object.keys(item).find(
381
+ nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
382
+ );
383
+ if (nestedKey) {
384
+ return { ...(item[nestedKey] as Record<string, unknown>) };
385
+ }
386
+ }
387
+ return { ...item };
388
+ });
389
+ } else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
390
+ // One-to-one relation — inline the target's columns
391
+ flat[k] = { ...(v as Record<string, unknown>) };
392
+ } else {
393
+ flat[k] = v;
394
+ }
395
+ }
396
+ return flat;
397
+ }
398
+
263
399
  /**
264
400
  * Build db.query-compatible options from standard fetch options.
265
401
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
@@ -401,7 +537,7 @@ export class FetchService {
401
537
  ): Promise<Record<string, unknown> | undefined> {
402
538
  const collection = getCollectionByPath(collectionPath, this.registry);
403
539
  const table = getTableForCollection(collection, this.registry);
404
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
540
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
405
541
  const idInfo = idInfoArray[0];
406
542
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
407
543
 
@@ -428,7 +564,7 @@ export class FetchService {
428
564
 
429
565
  if (!row) return undefined;
430
566
 
431
- const flatRow = toCmsRow(row, collection, this.registry);
567
+ const flatRow = this.drizzleResultToRow<M>(row, collection);
432
568
 
433
569
  // Post-fetch joinPath relations that Drizzle's `with` can't express
434
570
  await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
@@ -520,7 +656,7 @@ export class FetchService {
520
656
  ): Promise<Record<string, unknown>[]> {
521
657
  const collection = getCollectionByPath(collectionPath, this.registry);
522
658
  const table = getTableForCollection(collection, this.registry);
523
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
659
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
524
660
  const idInfo = idInfoArray[0];
525
661
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
526
662
 
@@ -552,7 +688,7 @@ export class FetchService {
552
688
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
553
689
 
554
690
  const rows = (results as Record<string, unknown>[]).map(row =>
555
- toCmsRow(row, collection, this.registry)
691
+ this.drizzleResultToRow<M>(row, collection)
556
692
  );
557
693
 
558
694
  return rows;
@@ -652,10 +788,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
652
788
 
653
789
  /**
654
790
  * 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.
791
+ * The primary path uses drizzleResultToRow which handles relation
792
+ * mapping without N+1 queries.
659
793
  *
660
794
  * Process raw database results into flat rows with relations.
661
795
  */
@@ -959,7 +1093,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
959
1093
 
960
1094
  const collection = getCollectionByPath(collectionPath, this.registry);
961
1095
  const table = getTableForCollection(collection, this.registry);
962
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1096
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
963
1097
  const idInfo = idInfoArray[0];
964
1098
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
965
1099
  const field = table[fieldName as keyof typeof table] as AnyPgColumn;
@@ -1019,7 +1153,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1019
1153
  ): Promise<Record<string, unknown>[]> {
1020
1154
  const collection = getCollectionByPath(collectionPath, this.registry);
1021
1155
  const table = getTableForCollection(collection, this.registry);
1022
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1156
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1023
1157
  const idInfo = idInfoArray[0];
1024
1158
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1025
1159
 
@@ -1046,7 +1180,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1046
1180
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1047
1181
 
1048
1182
  const restRows = (results as Record<string, unknown>[]).map(row =>
1049
- toRestRow(row, collection, this.registry)
1183
+ this.drizzleResultToRestRow(row, collection)
1050
1184
  );
1051
1185
 
1052
1186
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
@@ -1125,7 +1259,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1125
1259
  ): Promise<Record<string, unknown> | null> {
1126
1260
  const collection = getCollectionByPath(collectionPath, this.registry);
1127
1261
  const table = getTableForCollection(collection, this.registry);
1128
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1262
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1129
1263
  const idInfo = idInfoArray[0];
1130
1264
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1131
1265
 
@@ -1151,7 +1285,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1151
1285
 
1152
1286
  if (!row) return null;
1153
1287
 
1154
- const restRow = toRestRow(row, collection, this.registry);
1288
+ const restRow = this.drizzleResultToRestRow(row, collection);
1155
1289
 
1156
1290
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1157
1291
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
@@ -1233,7 +1367,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1233
1367
  ): Promise<Record<string, unknown>[]> {
1234
1368
  const collection = getCollectionByPath(collectionPath, this.registry);
1235
1369
  const table = getTableForCollection(collection, this.registry);
1236
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1370
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
1237
1371
  const idInfo = idInfoArray[0];
1238
1372
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1239
1373
 
@@ -383,15 +383,8 @@ export class PersistService {
383
383
  throw this.toUserFriendlyError(error, collection.slug);
384
384
  }
385
385
 
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);
386
+ // Fetch the updated/created row to return with proper relation objects
387
+ const finalEntity = await this.fetchService.fetchOne<M>(collection.slug, savedId, databaseId);
395
388
  if (!finalEntity) throw new Error("Could not fetch row after save.");
396
389
  return finalEntity;
397
390
  }