@rebasepro/server-postgres 0.9.1-canary.1d2d8b5 → 0.9.1-canary.58368ce

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.
@@ -8,6 +8,7 @@ import {
8
8
  getCollectionByPath,
9
9
  getTableForCollection,
10
10
  getPrimaryKeys,
11
+ deriveRowAddress,
11
12
  parseIdValues,
12
13
  buildCompositeId
13
14
  } from "./collection-helpers";
@@ -151,21 +152,36 @@ export class FetchService {
151
152
  return null;
152
153
  }
153
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
+
154
172
  /**
155
173
  * Convert a db.query result row (with nested relation objects) to a flat row.
156
174
  * Handles:
157
- * - Placing `id` at the top level as a string
158
175
  * - Type normalization (dates, numbers, NaN) via normalizeDbValues
159
176
  * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
160
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.
161
181
  */
162
182
  private drizzleResultToRow<M extends Record<string, unknown>>(
163
183
  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" }[]
184
+ collection: CollectionConfig
169
185
  ): Record<string, unknown> {
170
186
  const resolvedRelations = resolveCollectionRelations(collection);
171
187
 
@@ -182,8 +198,6 @@ export class FetchService {
182
198
  if (relation.cardinality === "many" && Array.isArray(relData)) {
183
199
  const targetCollection = relation.target();
184
200
  const targetPath = targetCollection.slug;
185
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
186
- const targetIdField = targetPks[0].fieldName;
187
201
 
188
202
  normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
189
203
  // Handle junction table flattening:
@@ -199,7 +213,7 @@ export class FetchService {
199
213
  }
200
214
  }
201
215
 
202
- const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
216
+ const relId = this.relationTargetAddress(targetRow, targetCollection);
203
217
  const targetValues = normalizeDbValues(targetRow, targetCollection);
204
218
 
205
219
  return createRelationRefWithData(relId, targetPath, {
@@ -211,11 +225,9 @@ export class FetchService {
211
225
  } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
212
226
  const targetCollection = relation.target();
213
227
  const targetPath = targetCollection.slug;
214
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
215
- const targetIdField = targetPks[0].fieldName;
216
228
  const relObj = relData as Record<string, unknown>;
217
229
 
218
- const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
230
+ const relId = this.relationTargetAddress(relObj, targetCollection);
219
231
  const targetValues = normalizeDbValues(relObj, targetCollection);
220
232
 
221
233
  normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
@@ -226,11 +238,10 @@ export class FetchService {
226
238
  }
227
239
  }
228
240
 
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
- };
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;
234
245
  }
235
246
 
236
247
  /**
@@ -274,57 +285,6 @@ export class FetchService {
274
285
  await Promise.all(promises);
275
286
  }
276
287
 
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
288
  /**
329
289
  * Resolves joinPath relations for raw REST rows and directly injects them.
330
290
  * Uses RelationService to query the database and maps results back to the flattened objects.
@@ -349,14 +309,18 @@ export class FetchService {
349
309
  if (joinPathRelations.length === 0) return;
350
310
 
351
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
314
+ // `id` — which no longer exists on a row, and threw (composite: parts
315
+ // mismatch; numeric: NaN) into the catch below, where a warning is all
316
+ // that separates "no relations" from "relations dropped".
317
+ const parentIdOf = (row: Record<string, unknown>) => row[idInfo.fieldName] as string | number | undefined;
352
318
 
353
319
  for (const [key, relation] of joinPathRelations) {
354
320
  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
- });
321
+ const addressable = rows.filter(r => parentIdOf(r) !== undefined && parentIdOf(r) !== null);
322
+ if (addressable.length === 0) continue;
323
+ const rowIds = addressable.map(r => parentIdOf(r) as string | number);
360
324
 
361
325
  if (relation.cardinality === "one") {
362
326
  const resultMap = await this.relationService.batchFetchRelatedEntities(
@@ -366,19 +330,11 @@ export class FetchService {
366
330
  relation
367
331
  );
368
332
 
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
- }
333
+ for (const row of addressable) {
334
+ const relatedRow = resultMap.get(String(parentIdOf(row)));
335
+ // Columns only: the target's address is the consumer's to
336
+ // derive, and merging it last overwrote a real `id` column.
337
+ row[key] = relatedRow ? { ...relatedRow.values } : null;
382
338
  }
383
339
  } else if (relation.cardinality === "many") {
384
340
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
@@ -388,14 +344,9 @@ export class FetchService {
388
344
  relation
389
345
  );
390
346
 
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
- }));
347
+ for (const row of addressable) {
348
+ const relatedList = resultMap.get(String(parentIdOf(row))) || [];
349
+ row[key] = relatedList.map(e => ({ ...e.values }));
399
350
  }
400
351
  }
401
352
  } catch (e) {
@@ -405,42 +356,39 @@ export class FetchService {
405
356
  }
406
357
 
407
358
  /**
408
- * Convert a db.query result row to a flat REST-style object with populated relations.
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.
409
366
  */
410
367
  private drizzleResultToRestRow(
411
368
  row: Record<string, unknown>,
412
- collection: CollectionConfig,
413
- idInfo: { fieldName: string; type: "string" | "number" },
414
- idInfoArray?: { fieldName: string; type: "string" | "number" }[]
369
+ collection: CollectionConfig
415
370
  ): 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]) };
371
+ const flat: Record<string, unknown> = {};
417
372
  const resolvedRelations = resolveCollectionRelations(collection);
418
373
 
419
374
  for (const [k, v] of Object.entries(row)) {
420
- if (k === idInfo.fieldName) continue;
421
-
422
375
  const relation = findRelation(resolvedRelations, k);
423
376
  if (Array.isArray(v) && relation) {
424
- // Many relation — flatten each nested row, handling junction tables
377
+ // Many relation — inline each nested row, unwrapping junction rows
425
378
  flat[k] = v.map((item: Record<string, unknown>) => {
426
379
  if (this.isJunctionRelation(relation, collection)) {
427
380
  const nestedKey = Object.keys(item).find(
428
381
  nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
429
382
  );
430
383
  if (nestedKey) {
431
- const nested = item[nestedKey] as Record<string, unknown>;
432
- return { ...nested,
433
- id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
384
+ return { ...(item[nestedKey] as Record<string, unknown>) };
434
385
  }
435
386
  }
436
- return { ...item,
437
- id: String(item.id ?? item[Object.keys(item)[0]]) };
387
+ return { ...item };
438
388
  });
439
389
  } 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]]) };
390
+ // One-to-one relation — inline the target's columns
391
+ flat[k] = { ...(v as Record<string, unknown>) };
444
392
  } else {
445
393
  flat[k] = v;
446
394
  }
@@ -616,7 +564,7 @@ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
616
564
 
617
565
  if (!row) return undefined;
618
566
 
619
- const flatRow = this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
567
+ const flatRow = this.drizzleResultToRow<M>(row, collection);
620
568
 
621
569
  // Post-fetch joinPath relations that Drizzle's `with` can't express
622
570
  await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
@@ -740,7 +688,7 @@ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
740
688
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
741
689
 
742
690
  const rows = (results as Record<string, unknown>[]).map(row =>
743
- this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
691
+ this.drizzleResultToRow<M>(row, collection)
744
692
  );
745
693
 
746
694
  return rows;
@@ -864,11 +812,9 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
864
812
  // relation type, avoiding the N+1 that plagued the old path.
865
813
  const parsedRows = await Promise.all(results.map(async (rawRow: Record<string, unknown>) => {
866
814
  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
815
  return {
869
816
  rawRow,
870
- values,
871
- id
817
+ values
872
818
  };
873
819
  }));
874
820
 
@@ -938,10 +884,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
938
884
  }
939
885
  }
940
886
 
941
- return parsedRows.map(item => ({
942
- ...item.values,
943
- id: item.id
944
- }));
887
+ // Columns only — the address is the consumer's to derive.
888
+ return parsedRows.map(item => item.values);
945
889
  }
946
890
 
947
891
  /**
@@ -1030,11 +974,12 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1030
974
  relationKey,
1031
975
  options
1032
976
  );
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
- }));
977
+ // Flatten RelatedRow[] to rows: the target's columns, and only
978
+ // those. Merging `row.id` in last overwrote a real `id` column,
979
+ // and the address is derivable without it — a consumer resolves
980
+ // this path ("posts/1/comments") to the target collection and
981
+ // reads the key columns, which `values` carries.
982
+ return rows.map(row => ({ ...row.values as Record<string, unknown> }));
1038
983
  }
1039
984
 
1040
985
  if (i + 1 < pathSegments.length) {
@@ -1235,7 +1180,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1235
1180
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1236
1181
 
1237
1182
  const restRows = (results as Record<string, unknown>[]).map(row =>
1238
- this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
1183
+ this.drizzleResultToRestRow(row, collection)
1239
1184
  );
1240
1185
 
1241
1186
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
@@ -1255,10 +1200,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1255
1200
  const rows = await this.fetchRowsWithConditionsRaw<M>(collectionPath, options);
1256
1201
 
1257
1202
  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
- }));
1203
+ return rows;
1262
1204
  }
1263
1205
 
1264
1206
  // Fallback relation loading via batch
@@ -1279,8 +1221,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1279
1221
  const eid = row[idInfo.fieldName] as string | number;
1280
1222
  const related = batchResults.get(String(eid));
1281
1223
  if (related) {
1282
- (row as Record<string, unknown>)[key] = { ...related.values,
1283
- id: related.id };
1224
+ (row as Record<string, unknown>)[key] = { ...related.values };
1284
1225
  }
1285
1226
  }
1286
1227
  } catch (e) {
@@ -1304,10 +1245,7 @@ id: related.id };
1304
1245
  }
1305
1246
  }
1306
1247
 
1307
- return rows.map(row => ({
1308
- ...row,
1309
- id: (idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
1310
- }));
1248
+ return rows;
1311
1249
  }
1312
1250
 
1313
1251
  /**
@@ -1347,7 +1285,7 @@ id: related.id };
1347
1285
 
1348
1286
  if (!row) return null;
1349
1287
 
1350
- const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
1288
+ const restRow = this.drizzleResultToRestRow(row, collection);
1351
1289
 
1352
1290
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1353
1291
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
@@ -1371,9 +1309,7 @@ id: related.id };
1371
1309
 
1372
1310
  if (result.length === 0) return null;
1373
1311
 
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]) };
1312
+ const flatEntity: Record<string, unknown> = { ...(result[0] as Record<string, unknown>) };
1377
1313
 
1378
1314
  if (!include || include.length === 0) {
1379
1315
  return flatEntity;
@@ -1581,31 +1517,24 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1581
1517
 
1582
1518
  const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1583
1519
 
1584
- // Flatten the nested Drizzle results into REST format
1520
+ // Inline the nested Drizzle results, columns only — no synthesized id.
1585
1521
  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]) };
1522
+ const flat: Record<string, unknown> = {};
1587
1523
  for (const [k, v] of Object.entries(row)) {
1588
- if (k === idInfo.fieldName) continue;
1589
1524
  if (Array.isArray(v)) {
1590
- // Many relation — flatten each nested row
1525
+ // Many relation — inline each nested row
1591
1526
  flat[k] = v.map((item: Record<string, unknown>) => {
1592
- // Junction table rows may have the target nested, flatten those
1527
+ // Junction table rows may have the target nested, unwrap those
1593
1528
  const keys = Object.keys(item);
1594
- // If it looks like a junction row (only FKs + nested objects), extract nested
1595
1529
  const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1596
1530
  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]]) };
1531
+ return { ...(item[nestedObj] as Record<string, unknown>) };
1600
1532
  }
1601
- return { ...item,
1602
- id: String(item.id ?? item[Object.keys(item)[0]]) };
1533
+ return { ...item };
1603
1534
  });
1604
1535
  } 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]]) };
1536
+ // One-to-one relation — inline the target's columns
1537
+ flat[k] = { ...(v as Record<string, unknown>) };
1609
1538
  } else {
1610
1539
  flat[k] = v;
1611
1540
  }
@@ -115,12 +115,19 @@ export class PersistService {
115
115
 
116
116
  /**
117
117
  * Save an row (create or update)
118
+ *
119
+ * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
120
+ * UPDATE against the primary key rather than a plain UPDATE. That is one
121
+ * statement, so it cannot lose a race the way a read-then-write can, and it
122
+ * does not care whether the row already exists — which is what a re-runnable
123
+ * import needs.
118
124
  */
119
125
  async save<M extends Record<string, unknown>>(
120
126
  collectionPath: string,
121
127
  values: Partial<M>,
122
128
  id?: string | number,
123
- databaseId?: string
129
+ databaseId?: string,
130
+ options?: { upsert?: boolean }
124
131
  ): Promise<Record<string, unknown>> {
125
132
  // If saving under a nested relation path, resolve the parent and inject FK
126
133
  let effectiveCollectionPath = collectionPath;
@@ -255,7 +262,7 @@ export class PersistService {
255
262
  savedId = await this.db.transaction(async (tx) => {
256
263
  let currentId: string | number;
257
264
 
258
- if (id) {
265
+ if (id && !options?.upsert) {
259
266
  // Update existing row
260
267
  currentId = id; // `id` is already the formatted composite or singular string
261
268
  const idValues = parseIdValues(id, idInfoArray);
@@ -295,6 +302,12 @@ export class PersistService {
295
302
  } else {
296
303
  const dataForInsert = { ...(entityData as Record<string, unknown>) };
297
304
 
305
+ // An explicit id given alongside upsert is the conflict target,
306
+ // so fold it into the row before the empty-key strip below.
307
+ if (id && options?.upsert) {
308
+ Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
309
+ }
310
+
298
311
  // Strip empty primary keys so the database defaults (e.g. uuid_gen(), auto-increment) can trigger
299
312
  for (const info of idInfoArray) {
300
313
  if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === undefined) {
@@ -302,13 +315,46 @@ export class PersistService {
302
315
  }
303
316
  }
304
317
 
305
- const result = await tx
306
- .insert(table)
307
- .values(dataForInsert)
308
- .returning(returningKeys);
318
+ const insertQuery = tx.insert(table).values(dataForInsert);
319
+
320
+ // ON CONFLICT needs a real conflict target, and the only one
321
+ // guaranteed to exist is the primary key. Without every key
322
+ // column present there is nothing to match on, so the row is a
323
+ // plain insert and a duplicate should still raise.
324
+ const hasFullKey = idInfoArray.length > 0
325
+ && idInfoArray.every((info) => dataForInsert[info.fieldName] !== undefined);
326
+
327
+ let result;
328
+ if (options?.upsert && hasFullKey) {
329
+ const target = idInfoArray.map((info) => table[info.fieldName as keyof typeof table] as AnyPgColumn);
330
+ const set = { ...dataForInsert };
331
+ // Never reassign the key columns to themselves in the UPDATE
332
+ // branch; Postgres rejects that against the conflict target.
333
+ for (const info of idInfoArray) delete set[info.fieldName];
334
+
335
+ result = Object.keys(set).length > 0
336
+ ? await insertQuery.onConflictDoUpdate({ target, set }).returning(returningKeys)
337
+ : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
338
+ } else {
339
+ result = await insertQuery.returning(returningKeys);
340
+ }
309
341
 
342
+ // DO NOTHING returns no row when it skipped, and an upsert whose
343
+ // UPDATE branch is filtered by RLS returns none either. Fall back
344
+ // to the id we were given rather than reading undefined.
310
345
  const resultRow = result[0];
311
- currentId = buildCompositeId(resultRow, idInfoArray);
346
+ if (!resultRow) {
347
+ if (id) {
348
+ currentId = id;
349
+ } else {
350
+ throw ApiError.forbidden(
351
+ `Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`,
352
+ "WRITE_DENIED"
353
+ );
354
+ }
355
+ } else {
356
+ currentId = buildCompositeId(resultRow, idInfoArray);
357
+ }
312
358
 
313
359
  // For inserts, apply joinPath after since the parent row didn't exist before
314
360
  if (joinPathRelationUpdates.length > 0) {
@@ -162,11 +162,12 @@ export class RelationService {
162
162
  const rows: RelatedRow<M>[] = [];
163
163
  for (const row of results as Array<Record<string, unknown>>) {
164
164
  const targetRow = (row[targetTableName] as Record<string, unknown>) || row;
165
- const id = targetRow[idInfo[0].fieldName as string];
166
165
  const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
167
166
 
168
167
  rows.push({
169
- id: id?.toString() || "",
168
+ // The whole key: the first column of a composite one names
169
+ // every row that shares it.
170
+ id: buildCompositeId(targetRow, idInfo),
170
171
  path: targetCollection.slug,
171
172
  values: parsedValues as M
172
173
  });
@@ -228,11 +229,10 @@ export class RelationService {
228
229
  const rows: RelatedRow<M>[] = [];
229
230
  for (const row of results) {
230
231
  const targetRow = row[getTableName(targetCollection)] || row;
231
- const id = targetRow[idInfo[0].fieldName];
232
232
  const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
233
233
 
234
234
  rows.push({
235
- id: id?.toString() || "",
235
+ id: buildCompositeId(targetRow as Record<string, unknown>, idInfo),
236
236
  path: targetCollection.slug,
237
237
  values: parsedValues as M
238
238
  });
@@ -371,7 +371,7 @@ export class RelationService {
371
371
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
372
372
 
373
373
  resultMap.set(String(parentId), {
374
- id: String(targetRow[targetIdInfo.fieldName]),
374
+ id: buildCompositeId(targetRow, targetPks),
375
375
  path: targetCollection.slug,
376
376
  values: parsedValues as Record<string, unknown>
377
377
  });
@@ -438,7 +438,7 @@ export class RelationService {
438
438
  if (targetRow) {
439
439
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
440
440
  resultMap.set(parentIdStr, {
441
- id: String(targetRow[targetIdInfo.fieldName]),
441
+ id: buildCompositeId(targetRow, targetPks),
442
442
  path: targetCollection.slug,
443
443
  values: parsedValues as Record<string, unknown>
444
444
  });
@@ -490,7 +490,7 @@ export class RelationService {
490
490
  if (parentId !== undefined && parentIdSet.has(String(parentId))) {
491
491
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
492
492
  resultMap.set(String(parentId), {
493
- id: String(targetRow[targetIdInfo.fieldName]),
493
+ id: buildCompositeId(targetRow, targetPks),
494
494
  path: targetCollection.slug,
495
495
  values: parsedValues as Record<string, unknown>
496
496
  });
@@ -565,7 +565,7 @@ export class RelationService {
565
565
 
566
566
  const arr = resultMap.get(parentId) || [];
567
567
  arr.push({
568
- id: String(targetRow[targetIdInfo.fieldName]),
568
+ id: buildCompositeId(targetRow, targetPks),
569
569
  path: targetCollection.slug,
570
570
  values: parsedValues as Record<string, unknown>
571
571
  });
@@ -616,7 +616,7 @@ export class RelationService {
616
616
 
617
617
  const arr = resultMap.get(parentId) || [];
618
618
  arr.push({
619
- id: String(targetData[targetIdInfo.fieldName]),
619
+ id: buildCompositeId(targetData, targetPks),
620
620
  path: targetCollection.slug,
621
621
  values: parsedValues as Record<string, unknown>
622
622
  });
@@ -672,7 +672,7 @@ export class RelationService {
672
672
  const key = String(parentId);
673
673
  const arr = resultMap.get(key) || [];
674
674
  arr.push({
675
- id: String(targetRow[targetIdInfo.fieldName]),
675
+ id: buildCompositeId(targetRow, targetPks),
676
676
  path: targetCollection.slug,
677
677
  values: parsedValues as Record<string, unknown>
678
678
  });
@@ -865,7 +865,6 @@ export class RelationService {
865
865
  relationKey: string;
866
866
  relation: Relation;
867
867
  newValue: unknown;
868
- currentId?: string | number;
869
868
  }>
870
869
  ) {
871
870
  for (const update of inverseRelationUpdates) {