@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.
- package/dist/PostgresBackendDriver.d.ts +2 -25
- package/dist/data-transformer.d.ts +2 -9
- package/dist/index.es.js +388 -1069
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/services/FetchService.d.ts +7 -21
- package/dist/services/PersistService.d.ts +1 -27
- package/dist/services/RelationService.d.ts +1 -0
- package/dist/services/collection-helpers.d.ts +14 -52
- package/dist/services/dataService.d.ts +1 -3
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +0 -7
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +8 -109
- package/src/PostgresBootstrapper.ts +23 -11
- package/src/data-transformer.ts +9 -11
- package/src/schema/auth-default-policies.ts +132 -0
- package/src/schema/generate-drizzle-schema-logic.ts +29 -24
- package/src/schema/generate-postgres-ddl-logic.ts +28 -76
- package/src/services/BranchService.ts +10 -42
- package/src/services/FetchService.ts +155 -84
- package/src/services/PersistService.ts +12 -121
- package/src/services/RelationService.ts +11 -10
- package/src/services/collection-helpers.ts +44 -129
- package/src/services/dataService.ts +2 -3
- package/src/services/index.ts +0 -1
- package/src/services/realtimeService.ts +19 -38
- package/src/websocket.ts +1 -4
- package/dist/collections/buildRegistry.d.ts +0 -27
- package/src/collections/buildRegistry.ts +0 -59
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
348
|
-
const
|
|
349
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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
|
|
391
|
-
|
|
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
|
-
|
|
888
|
-
|
|
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
|
-
//
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
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
|
-
//
|
|
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 —
|
|
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,
|
|
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
|
-
|
|
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
|
|
1537
|
-
|
|
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
|
|
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) {
|