@rebasepro/server-postgres 0.9.1-canary.58368ce → 0.9.1-canary.73476f2

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,7 +8,6 @@ import {
8
8
  getCollectionByPath,
9
9
  getTableForCollection,
10
10
  getPrimaryKeys,
11
- deriveRowAddress,
12
11
  parseIdValues,
13
12
  buildCompositeId
14
13
  } from "./collection-helpers";
@@ -152,36 +151,21 @@ export class FetchService {
152
151
  return null;
153
152
  }
154
153
 
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
154
  /**
173
155
  * Convert a db.query result row (with nested relation objects) to a flat row.
174
156
  * Handles:
157
+ * - Placing `id` at the top level as a string
175
158
  * - Type normalization (dates, numbers, NaN) via normalizeDbValues
176
159
  * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
177
160
  * - 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
161
  */
182
162
  private drizzleResultToRow<M extends Record<string, unknown>>(
183
163
  row: Record<string, unknown>,
184
- collection: CollectionConfig
164
+ collection: CollectionConfig,
165
+ _collectionPath: string,
166
+ idInfo: { fieldName: string; type: "string" | "number" },
167
+ _databaseId?: string,
168
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
185
169
  ): Record<string, unknown> {
186
170
  const resolvedRelations = resolveCollectionRelations(collection);
187
171
 
@@ -198,6 +182,8 @@ export class FetchService {
198
182
  if (relation.cardinality === "many" && Array.isArray(relData)) {
199
183
  const targetCollection = relation.target();
200
184
  const targetPath = targetCollection.slug;
185
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
186
+ const targetIdField = targetPks[0].fieldName;
201
187
 
202
188
  normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
203
189
  // Handle junction table flattening:
@@ -213,7 +199,7 @@ export class FetchService {
213
199
  }
214
200
  }
215
201
 
216
- const relId = this.relationTargetAddress(targetRow, targetCollection);
202
+ const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
217
203
  const targetValues = normalizeDbValues(targetRow, targetCollection);
218
204
 
219
205
  return createRelationRefWithData(relId, targetPath, {
@@ -225,9 +211,11 @@ export class FetchService {
225
211
  } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
226
212
  const targetCollection = relation.target();
227
213
  const targetPath = targetCollection.slug;
214
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
215
+ const targetIdField = targetPks[0].fieldName;
228
216
  const relObj = relData as Record<string, unknown>;
229
217
 
230
- const relId = this.relationTargetAddress(relObj, targetCollection);
218
+ const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
231
219
  const targetValues = normalizeDbValues(relObj, targetCollection);
232
220
 
233
221
  normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
@@ -238,10 +226,11 @@ export class FetchService {
238
226
  }
239
227
  }
240
228
 
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;
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
+ };
245
234
  }
246
235
 
247
236
  /**
@@ -285,6 +274,57 @@ export class FetchService {
285
274
  await Promise.all(promises);
286
275
  }
287
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
+
288
328
  /**
289
329
  * Resolves joinPath relations for raw REST rows and directly injects them.
290
330
  * Uses RelationService to query the database and maps results back to the flattened objects.
@@ -309,18 +349,14 @@ export class FetchService {
309
349
  if (joinPathRelations.length === 0) return;
310
350
 
311
351
  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;
318
352
 
319
353
  for (const [key, relation] of joinPathRelations) {
320
354
  try {
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);
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
+ });
324
360
 
325
361
  if (relation.cardinality === "one") {
326
362
  const resultMap = await this.relationService.batchFetchRelatedEntities(
@@ -330,11 +366,19 @@ export class FetchService {
330
366
  relation
331
367
  );
332
368
 
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;
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
+ }
338
382
  }
339
383
  } else if (relation.cardinality === "many") {
340
384
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
@@ -344,9 +388,14 @@ export class FetchService {
344
388
  relation
345
389
  );
346
390
 
347
- for (const row of addressable) {
348
- const relatedList = resultMap.get(String(parentIdOf(row))) || [];
349
- 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
+ }));
350
399
  }
351
400
  }
352
401
  } catch (e) {
@@ -356,39 +405,42 @@ export class FetchService {
356
405
  }
357
406
 
358
407
  /**
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.
408
+ * Convert a db.query result row to a flat REST-style object with populated relations.
366
409
  */
367
410
  private drizzleResultToRestRow(
368
411
  row: Record<string, unknown>,
369
- collection: CollectionConfig
412
+ collection: CollectionConfig,
413
+ idInfo: { fieldName: string; type: "string" | "number" },
414
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
370
415
  ): Record<string, unknown> {
371
- const flat: 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]) };
372
417
  const resolvedRelations = resolveCollectionRelations(collection);
373
418
 
374
419
  for (const [k, v] of Object.entries(row)) {
420
+ if (k === idInfo.fieldName) continue;
421
+
375
422
  const relation = findRelation(resolvedRelations, k);
376
423
  if (Array.isArray(v) && relation) {
377
- // Many relation — inline each nested row, unwrapping junction rows
424
+ // Many relation — flatten each nested row, handling junction tables
378
425
  flat[k] = v.map((item: Record<string, unknown>) => {
379
426
  if (this.isJunctionRelation(relation, collection)) {
380
427
  const nestedKey = Object.keys(item).find(
381
428
  nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
382
429
  );
383
430
  if (nestedKey) {
384
- return { ...(item[nestedKey] as Record<string, unknown>) };
431
+ const nested = item[nestedKey] as Record<string, unknown>;
432
+ return { ...nested,
433
+ id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
385
434
  }
386
435
  }
387
- return { ...item };
436
+ return { ...item,
437
+ id: String(item.id ?? item[Object.keys(item)[0]]) };
388
438
  });
389
439
  } 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>) };
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]]) };
392
444
  } else {
393
445
  flat[k] = v;
394
446
  }
@@ -564,7 +616,7 @@ export class FetchService {
564
616
 
565
617
  if (!row) return undefined;
566
618
 
567
- const flatRow = this.drizzleResultToRow<M>(row, collection);
619
+ const flatRow = this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
568
620
 
569
621
  // Post-fetch joinPath relations that Drizzle's `with` can't express
570
622
  await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
@@ -688,7 +740,7 @@ export class FetchService {
688
740
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
689
741
 
690
742
  const rows = (results as Record<string, unknown>[]).map(row =>
691
- this.drizzleResultToRow<M>(row, collection)
743
+ this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
692
744
  );
693
745
 
694
746
  return rows;
@@ -812,9 +864,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
812
864
  // relation type, avoiding the N+1 that plagued the old path.
813
865
  const parsedRows = await Promise.all(results.map(async (rawRow: Record<string, unknown>) => {
814
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]);
815
868
  return {
816
869
  rawRow,
817
- values
870
+ values,
871
+ id
818
872
  };
819
873
  }));
820
874
 
@@ -884,8 +938,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
884
938
  }
885
939
  }
886
940
 
887
- // Columns only — the address is the consumer's to derive.
888
- return parsedRows.map(item => item.values);
941
+ return parsedRows.map(item => ({
942
+ ...item.values,
943
+ id: item.id
944
+ }));
889
945
  }
890
946
 
891
947
  /**
@@ -974,12 +1030,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
974
1030
  relationKey,
975
1031
  options
976
1032
  );
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> }));
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
+ }));
983
1038
  }
984
1039
 
985
1040
  if (i + 1 < pathSegments.length) {
@@ -1180,7 +1235,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1180
1235
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1181
1236
 
1182
1237
  const restRows = (results as Record<string, unknown>[]).map(row =>
1183
- this.drizzleResultToRestRow(row, collection)
1238
+ this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
1184
1239
  );
1185
1240
 
1186
1241
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
@@ -1200,7 +1255,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1200
1255
  const rows = await this.fetchRowsWithConditionsRaw<M>(collectionPath, options);
1201
1256
 
1202
1257
  if (!include || include.length === 0) {
1203
- 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
+ }));
1204
1262
  }
1205
1263
 
1206
1264
  // Fallback relation loading via batch
@@ -1221,7 +1279,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1221
1279
  const eid = row[idInfo.fieldName] as string | number;
1222
1280
  const related = batchResults.get(String(eid));
1223
1281
  if (related) {
1224
- (row as Record<string, unknown>)[key] = { ...related.values };
1282
+ (row as Record<string, unknown>)[key] = { ...related.values,
1283
+ id: related.id };
1225
1284
  }
1226
1285
  }
1227
1286
  } catch (e) {
@@ -1245,7 +1304,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1245
1304
  }
1246
1305
  }
1247
1306
 
1248
- 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
+ }));
1249
1311
  }
1250
1312
 
1251
1313
  /**
@@ -1285,7 +1347,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1285
1347
 
1286
1348
  if (!row) return null;
1287
1349
 
1288
- const restRow = this.drizzleResultToRestRow(row, collection);
1350
+ const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
1289
1351
 
1290
1352
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1291
1353
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
@@ -1309,7 +1371,9 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1309
1371
 
1310
1372
  if (result.length === 0) return null;
1311
1373
 
1312
- 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]) };
1313
1377
 
1314
1378
  if (!include || include.length === 0) {
1315
1379
  return flatEntity;
@@ -1517,24 +1581,31 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1517
1581
 
1518
1582
  const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1519
1583
 
1520
- // Inline the nested Drizzle results, columns only — no synthesized id.
1584
+ // Flatten the nested Drizzle results into REST format
1521
1585
  return results.map((row: Record<string, unknown>) => {
1522
- 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]) };
1523
1587
  for (const [k, v] of Object.entries(row)) {
1588
+ if (k === idInfo.fieldName) continue;
1524
1589
  if (Array.isArray(v)) {
1525
- // Many relation — inline each nested row
1590
+ // Many relation — flatten each nested row
1526
1591
  flat[k] = v.map((item: Record<string, unknown>) => {
1527
- // Junction table rows may have the target nested, unwrap those
1592
+ // Junction table rows may have the target nested, flatten those
1528
1593
  const keys = Object.keys(item);
1594
+ // If it looks like a junction row (only FKs + nested objects), extract nested
1529
1595
  const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1530
1596
  if (nestedObj && keys.length <= 3) {
1531
- 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]]) };
1532
1600
  }
1533
- return { ...item };
1601
+ return { ...item,
1602
+ id: String(item.id ?? item[Object.keys(item)[0]]) };
1534
1603
  });
1535
1604
  } else if (typeof v === "object" && v !== null) {
1536
- // One-to-one relation — inline the target's columns
1537
- 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]]) };
1538
1609
  } else {
1539
1610
  flat[k] = v;
1540
1611
  }
@@ -1,5 +1,5 @@
1
- import { eq, and, sql, SQL } from "drizzle-orm";
2
- import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
1
+ import { eq, and } from "drizzle-orm";
2
+ import { AnyPgColumn } from "drizzle-orm/pg-core";
3
3
  // import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { CollectionConfig, Properties, Relation } from "@rebasepro/types";
5
5
  import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
@@ -16,7 +16,7 @@ import { RelationService } from "./RelationService";
16
16
  import { FetchService } from "./FetchService";
17
17
  import { DrizzleClient } from "../interfaces";
18
18
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
19
- import { ApiError, logger } from "@rebasepro/server";
19
+ import { logger } from "@rebasepro/server";
20
20
  import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage } from "../utils/pg-error-utils";
21
21
 
22
22
  /**
@@ -33,49 +33,6 @@ export class PersistService {
33
33
  }
34
34
 
35
35
 
36
- /**
37
- * Explain a write that matched no rows.
38
- *
39
- * Row-level security filters UPDATE and DELETE through the policy's USING
40
- * clause instead of raising: a denied write is reported by Postgres exactly
41
- * like a successful one that happened to match nothing. Left unchecked, a
42
- * caller cannot tell "denied" from "done" — the write returns 200/204 and
43
- * the row is untouched.
44
- *
45
- * Re-reading the target over the *same* RLS-scoped handle separates the two
46
- * cases. A visible row means the policy rejected the write (403); an
47
- * invisible one means there is nothing there to write for this caller (404,
48
- * matching what a GET would say). The re-read is bound by the caller's own
49
- * policies, so it discloses nothing a plain read wouldn't.
50
- *
51
- * Only reached when zero rows matched, so the happy path pays nothing.
52
- */
53
- private async explainZeroRowWrite(
54
- handle: DrizzleClient,
55
- table: PgTable,
56
- conditions: SQL[],
57
- collectionPath: string,
58
- id: string | number,
59
- operation: "update" | "delete"
60
- ): Promise<ApiError> {
61
- const visible = await handle
62
- .select({ present: sql<number>`1` })
63
- .from(table)
64
- .where(and(...conditions))
65
- .limit(1);
66
-
67
- if (visible.length > 0) {
68
- return ApiError.forbidden(
69
- `Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`,
70
- "WRITE_DENIED"
71
- );
72
- }
73
-
74
- return ApiError.notFound(
75
- `No row "${id}" in "${collectionPath}" to ${operation}.`
76
- );
77
- }
78
-
79
36
  /**
80
37
  * Delete an row by ID
81
38
  */
@@ -95,13 +52,9 @@ export class PersistService {
95
52
  conditions.push(eq(field, parsedIdObj[info.fieldName]));
96
53
  }
97
54
 
98
- const result = await this.db
55
+ await this.db
99
56
  .delete(table)
100
57
  .where(and(...conditions));
101
-
102
- if ((result.rowCount ?? 0) === 0) {
103
- throw await this.explainZeroRowWrite(this.db, table, conditions, collectionPath, id, "delete");
104
- }
105
58
  }
106
59
 
107
60
  /**
@@ -115,19 +68,12 @@ export class PersistService {
115
68
 
116
69
  /**
117
70
  * 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.
124
71
  */
125
72
  async save<M extends Record<string, unknown>>(
126
73
  collectionPath: string,
127
74
  values: Partial<M>,
128
75
  id?: string | number,
129
- databaseId?: string,
130
- options?: { upsert?: boolean }
76
+ databaseId?: string
131
77
  ): Promise<Record<string, unknown>> {
132
78
  // If saving under a nested relation path, resolve the parent and inject FK
133
79
  let effectiveCollectionPath = collectionPath;
@@ -262,7 +208,7 @@ export class PersistService {
262
208
  savedId = await this.db.transaction(async (tx) => {
263
209
  let currentId: string | number;
264
210
 
265
- if (id && !options?.upsert) {
211
+ if (id) {
266
212
  // Update existing row
267
213
  currentId = id; // `id` is already the formatted composite or singular string
268
214
  const idValues = parseIdValues(id, idInfoArray);
@@ -289,25 +235,11 @@ export class PersistService {
289
235
  conditions.push(eq(field, idValues[info.fieldName]));
290
236
  }
291
237
 
292
- const updateResult = await updateQuery.where(and(...conditions));
293
-
294
- // Throwing rolls the transaction back, so relation writes
295
- // already applied above do not survive a rejected update.
296
- if ((updateResult.rowCount ?? 0) === 0) {
297
- throw await this.explainZeroRowWrite(
298
- tx, table, conditions, effectiveCollectionPath, currentId, "update"
299
- );
300
- }
238
+ await updateQuery.where(and(...conditions));
301
239
  }
302
240
  } else {
303
241
  const dataForInsert = { ...(entityData as Record<string, unknown>) };
304
242
 
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
-
311
243
  // Strip empty primary keys so the database defaults (e.g. uuid_gen(), auto-increment) can trigger
312
244
  for (const info of idInfoArray) {
313
245
  if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === undefined) {
@@ -315,46 +247,13 @@ export class PersistService {
315
247
  }
316
248
  }
317
249
 
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
- }
250
+ const result = await tx
251
+ .insert(table)
252
+ .values(dataForInsert)
253
+ .returning(returningKeys);
341
254
 
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.
345
255
  const resultRow = result[0];
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
- }
256
+ currentId = buildCompositeId(resultRow, idInfoArray);
358
257
 
359
258
  // For inserts, apply joinPath after since the parent row didn't exist before
360
259
  if (joinPathRelationUpdates.length > 0) {
@@ -407,14 +306,6 @@ export class PersistService {
407
306
  * Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
408
307
  */
409
308
  private toUserFriendlyError(error: unknown, collectionSlug: string): Error {
410
- // Deliberate API errors already carry their own status, code and wording.
411
- // Re-wrapping one flattens it into a generic Error, and the status is lost
412
- // on the way out — a policy rejection would surface as a 500. Matched by
413
- // name as well as instance: a duplicated module copy breaks `instanceof`.
414
- if (error instanceof ApiError || (error as Error)?.name === "ApiError") {
415
- return error as Error;
416
- }
417
-
418
309
  const pgError = extractPgError(error);
419
310
 
420
311
  if (pgError) {