@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.
- package/dist/PostgresBackendDriver.d.ts +2 -25
- package/dist/index.es.js +275 -656
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/services/FetchService.d.ts +2 -10
- package/dist/services/PersistService.d.ts +1 -27
- package/dist/services/collection-helpers.d.ts +15 -4
- package/dist/services/dataService.d.ts +1 -3
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +2 -88
- package/src/schema/auth-default-policies.ts +132 -0
- package/src/schema/generate-drizzle-schema-logic.ts +26 -4
- package/src/schema/generate-postgres-ddl-logic.ts +26 -14
- package/src/services/BranchService.ts +10 -42
- package/src/services/FetchService.ts +67 -41
- package/src/services/PersistService.ts +12 -121
- package/src/services/collection-helpers.ts +55 -9
- package/src/services/dataService.ts +2 -3
- package/src/websocket.ts +1 -4
|
@@ -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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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
|
|
437
|
-
|
|
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
|
-
|
|
934
|
-
|
|
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
|
|
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
|
-
//
|
|
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 —
|
|
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,
|
|
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
|
-
|
|
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
|
|
1582
|
-
|
|
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
|
|
2
|
-
import { AnyPgColumn
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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):
|
|
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:
|
|
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
|
|
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
|
-
:
|
|
625
|
+
: (error instanceof Error ? error.message : "An unexpected error occurred");
|
|
629
626
|
const errorResponse = {
|
|
630
627
|
type: "ERROR",
|
|
631
628
|
requestId,
|