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