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

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.
@@ -154,16 +154,18 @@ export class FetchService {
154
154
  /**
155
155
  * Convert a db.query result row (with nested relation objects) to a flat row.
156
156
  * Handles:
157
+ * - Placing `id` at the top level as a string
157
158
  * - Type normalization (dates, numbers, NaN) via normalizeDbValues
158
159
  * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
159
160
  * - 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
161
  */
164
162
  private drizzleResultToRow<M extends Record<string, unknown>>(
165
163
  row: Record<string, unknown>,
166
- 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" }[]
167
169
  ): Record<string, unknown> {
168
170
  const resolvedRelations = resolveCollectionRelations(collection);
169
171
 
@@ -224,10 +226,11 @@ export class FetchService {
224
226
  }
225
227
  }
226
228
 
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;
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
+ };
231
234
  }
232
235
 
233
236
  /**
@@ -402,39 +405,42 @@ export class FetchService {
402
405
  }
403
406
 
404
407
  /**
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.
408
+ * Convert a db.query result row to a flat REST-style object with populated relations.
412
409
  */
413
410
  private drizzleResultToRestRow(
414
411
  row: Record<string, unknown>,
415
- collection: CollectionConfig
412
+ collection: CollectionConfig,
413
+ idInfo: { fieldName: string; type: "string" | "number" },
414
+ idInfoArray?: { fieldName: string; type: "string" | "number" }[]
416
415
  ): Record<string, unknown> {
417
- 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]) };
418
417
  const resolvedRelations = resolveCollectionRelations(collection);
419
418
 
420
419
  for (const [k, v] of Object.entries(row)) {
420
+ if (k === idInfo.fieldName) continue;
421
+
421
422
  const relation = findRelation(resolvedRelations, k);
422
423
  if (Array.isArray(v) && relation) {
423
- // Many relation — inline each nested row, unwrapping junction rows
424
+ // Many relation — flatten each nested row, handling junction tables
424
425
  flat[k] = v.map((item: Record<string, unknown>) => {
425
426
  if (this.isJunctionRelation(relation, collection)) {
426
427
  const nestedKey = Object.keys(item).find(
427
428
  nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
428
429
  );
429
430
  if (nestedKey) {
430
- 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]]) };
431
434
  }
432
435
  }
433
- return { ...item };
436
+ return { ...item,
437
+ id: String(item.id ?? item[Object.keys(item)[0]]) };
434
438
  });
435
439
  } 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>) };
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]]) };
438
444
  } else {
439
445
  flat[k] = v;
440
446
  }
@@ -610,7 +616,7 @@ export class FetchService {
610
616
 
611
617
  if (!row) return undefined;
612
618
 
613
- const flatRow = this.drizzleResultToRow<M>(row, collection);
619
+ const flatRow = this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
614
620
 
615
621
  // Post-fetch joinPath relations that Drizzle's `with` can't express
616
622
  await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
@@ -734,7 +740,7 @@ export class FetchService {
734
740
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
735
741
 
736
742
  const rows = (results as Record<string, unknown>[]).map(row =>
737
- this.drizzleResultToRow<M>(row, collection)
743
+ this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
738
744
  );
739
745
 
740
746
  return rows;
@@ -858,9 +864,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
858
864
  // relation type, avoiding the N+1 that plagued the old path.
859
865
  const parsedRows = await Promise.all(results.map(async (rawRow: Record<string, unknown>) => {
860
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]);
861
868
  return {
862
869
  rawRow,
863
- values
870
+ values,
871
+ id
864
872
  };
865
873
  }));
866
874
 
@@ -930,8 +938,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
930
938
  }
931
939
  }
932
940
 
933
- // Columns only — the address is the consumer's to derive.
934
- return parsedRows.map(item => item.values);
941
+ return parsedRows.map(item => ({
942
+ ...item.values,
943
+ id: item.id
944
+ }));
935
945
  }
936
946
 
937
947
  /**
@@ -1225,7 +1235,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1225
1235
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1226
1236
 
1227
1237
  const restRows = (results as Record<string, unknown>[]).map(row =>
1228
- this.drizzleResultToRestRow(row, collection)
1238
+ this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
1229
1239
  );
1230
1240
 
1231
1241
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
@@ -1245,7 +1255,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1245
1255
  const rows = await this.fetchRowsWithConditionsRaw<M>(collectionPath, options);
1246
1256
 
1247
1257
  if (!include || include.length === 0) {
1248
- 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
+ }));
1249
1262
  }
1250
1263
 
1251
1264
  // Fallback relation loading via batch
@@ -1266,7 +1279,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1266
1279
  const eid = row[idInfo.fieldName] as string | number;
1267
1280
  const related = batchResults.get(String(eid));
1268
1281
  if (related) {
1269
- (row as Record<string, unknown>)[key] = { ...related.values };
1282
+ (row as Record<string, unknown>)[key] = { ...related.values,
1283
+ id: related.id };
1270
1284
  }
1271
1285
  }
1272
1286
  } catch (e) {
@@ -1290,7 +1304,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1290
1304
  }
1291
1305
  }
1292
1306
 
1293
- 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
+ }));
1294
1311
  }
1295
1312
 
1296
1313
  /**
@@ -1330,7 +1347,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1330
1347
 
1331
1348
  if (!row) return null;
1332
1349
 
1333
- const restRow = this.drizzleResultToRestRow(row, collection);
1350
+ const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
1334
1351
 
1335
1352
  // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1336
1353
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
@@ -1354,7 +1371,9 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1354
1371
 
1355
1372
  if (result.length === 0) return null;
1356
1373
 
1357
- 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]) };
1358
1377
 
1359
1378
  if (!include || include.length === 0) {
1360
1379
  return flatEntity;
@@ -1562,24 +1581,31 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1562
1581
 
1563
1582
  const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1564
1583
 
1565
- // Inline the nested Drizzle results, columns only — no synthesized id.
1584
+ // Flatten the nested Drizzle results into REST format
1566
1585
  return results.map((row: Record<string, unknown>) => {
1567
- 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]) };
1568
1587
  for (const [k, v] of Object.entries(row)) {
1588
+ if (k === idInfo.fieldName) continue;
1569
1589
  if (Array.isArray(v)) {
1570
- // Many relation — inline each nested row
1590
+ // Many relation — flatten each nested row
1571
1591
  flat[k] = v.map((item: Record<string, unknown>) => {
1572
- // Junction table rows may have the target nested, unwrap those
1592
+ // Junction table rows may have the target nested, flatten those
1573
1593
  const keys = Object.keys(item);
1594
+ // If it looks like a junction row (only FKs + nested objects), extract nested
1574
1595
  const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1575
1596
  if (nestedObj && keys.length <= 3) {
1576
- 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]]) };
1577
1600
  }
1578
- return { ...item };
1601
+ return { ...item,
1602
+ id: String(item.id ?? item[Object.keys(item)[0]]) };
1579
1603
  });
1580
1604
  } else if (typeof v === "object" && v !== null) {
1581
- // One-to-one relation — inline the target's columns
1582
- 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]]) };
1583
1609
  } else {
1584
1610
  flat[k] = v;
1585
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) {
@@ -3,13 +3,6 @@ import { CollectionConfig, Property } from "@rebasepro/types";
3
3
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
4
4
  import { getTableName } from "@rebasepro/common";
5
5
 
6
- // Row identity is derived on both sides of the wire — the driver parses an
7
- // incoming address into key columns, the admin derives one from a served row —
8
- // so the implementation lives in `common` and both agree by construction.
9
- export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
10
- export type { PrimaryKeyInfo } from "@rebasepro/common";
11
- import type { PrimaryKeyInfo } from "@rebasepro/common";
12
-
13
6
  /**
14
7
  * Shared helper functions for row operations.
15
8
  * These are used by FetchService, PersistService, and RelationService.
@@ -56,7 +49,7 @@ export function getTableForCollection(collection: CollectionConfig, registry: Po
56
49
  return table;
57
50
  }
58
51
 
59
- export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[] {
52
+ export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): { fieldName: string; type: "string" | "number"; isUUID?: boolean }[] {
60
53
  const table = getTableForCollection(collection, registry);
61
54
 
62
55
  // Fallback to explicitly defined isId properties
@@ -75,7 +68,7 @@ export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresC
75
68
  }
76
69
 
77
70
  // Otherwise infer from Drizzle schema
78
- const keys: PrimaryKeyInfo[] = [];
71
+ const keys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[] = [];
79
72
  for (const [key, colRaw] of Object.entries(table)) {
80
73
  const col = colRaw as AnyPgColumn;
81
74
  if (col && typeof col === "object" && "primary" in col && col.primary) {
@@ -103,3 +96,56 @@ isUUID });
103
96
  return keys;
104
97
  }
105
98
 
99
+ export function parseIdValues(idValue: string | number, primaryKeys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[]): Record<string, string | number> {
100
+ const result: Record<string, string | number> = {};
101
+
102
+ if (primaryKeys.length === 0) {
103
+ return result;
104
+ }
105
+
106
+ if (primaryKeys.length === 1) {
107
+ const pk = primaryKeys[0];
108
+ if (pk.type === "number" && !pk.isUUID) {
109
+ const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
110
+ if (isNaN(parsed)) {
111
+ throw new Error(`Invalid numeric ID: ${idValue}`);
112
+ }
113
+ result[pk.fieldName] = parsed;
114
+ } else {
115
+ result[pk.fieldName] = String(idValue);
116
+ }
117
+ return result;
118
+ }
119
+
120
+ // Composite key - split by :::
121
+ const parts = String(idValue).split(":::");
122
+ if (parts.length !== primaryKeys.length) {
123
+ throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
124
+ }
125
+
126
+ for (let i = 0; i < primaryKeys.length; i++) {
127
+ const pk = primaryKeys[i];
128
+ const val = parts[i];
129
+ if (pk.type === "number" && !pk.isUUID) {
130
+ const parsed = parseInt(val, 10);
131
+ if (isNaN(parsed)) {
132
+ throw new Error(`Invalid numeric ID component: ${val}`);
133
+ }
134
+ result[pk.fieldName] = parsed;
135
+ } else {
136
+ result[pk.fieldName] = val;
137
+ }
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ export function buildCompositeId(values: Record<string, unknown>, primaryKeys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[]): string {
144
+ if (primaryKeys.length === 0) {
145
+ return "";
146
+ }
147
+ if (primaryKeys.length === 1) {
148
+ return String(values[primaryKeys[0].fieldName] ?? "");
149
+ }
150
+ return primaryKeys.map(pk => String(values[pk.fieldName] ?? "")).join(":::");
151
+ }
@@ -154,10 +154,9 @@ export class DataService implements DataRepository {
154
154
  collectionPath: string,
155
155
  values: Partial<M>,
156
156
  id?: string | number,
157
- databaseId?: string,
158
- options?: { upsert?: boolean }
157
+ databaseId?: string
159
158
  ): Promise<Record<string, unknown>> {
160
- return this.persistService.save<M>(collectionPath, values, id, databaseId, options);
159
+ return this.persistService.save<M>(collectionPath, values, id, databaseId);
161
160
  }
162
161
 
163
162
  /**
package/src/websocket.ts CHANGED
@@ -620,12 +620,9 @@ roles: ["anon"] };
620
620
  if (error instanceof Error) {
621
621
  logger.error("Stack trace", { detail: error.stack });
622
622
  }
623
- // Unwrap the cause chain: a Drizzle failure reports itself as
624
- // "Failed query: <sql> params:", which tells the user nothing and
625
- // echoes the statement back at them. The reason is in the cause.
626
623
  const errorMessage = process.env.NODE_ENV === "production"
627
624
  ? "An unexpected error occurred"
628
- : extractErrorMessage(error);
625
+ : (error instanceof Error ? error.message : "An unexpected error occurred");
629
626
  const errorResponse = {
630
627
  type: "ERROR",
631
628
  requestId,