@rebasepro/server-postgres 0.9.1-canary.16c42e9 → 0.9.1-canary.1d2d8b5
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/PostgresBootstrapper.d.ts +0 -10
- package/dist/auth/services.d.ts +0 -16
- package/dist/connection.d.ts +0 -21
- package/dist/data-transformer.d.ts +2 -9
- package/dist/index.es.js +548 -1310
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/services/FetchService.d.ts +24 -4
- package/dist/services/PersistService.d.ts +1 -9
- package/dist/services/RelationService.d.ts +1 -34
- package/dist/services/collection-helpers.d.ts +14 -79
- 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 -10
- package/src/PostgresBackendDriver.ts +13 -127
- package/src/PostgresBootstrapper.ts +25 -62
- package/src/auth/ensure-tables.ts +11 -73
- package/src/auth/services.ts +19 -49
- package/src/connection.ts +1 -61
- package/src/data-transformer.ts +9 -11
- package/src/databasePoolManager.ts +0 -2
- 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/schema/introspect-db.ts +2 -19
- package/src/services/BranchService.ts +10 -42
- package/src/services/FetchService.ts +270 -65
- package/src/services/PersistService.ts +9 -62
- package/src/services/RelationService.ts +94 -153
- package/src/services/collection-helpers.ts +47 -164
- package/src/services/dataService.ts +2 -3
- package/src/services/index.ts +0 -1
- package/src/services/realtimeService.ts +19 -40
- package/src/utils/drizzle-conditions.ts +0 -13
- package/src/websocket.ts +1 -4
- package/dist/collections/buildRegistry.d.ts +0 -27
- package/dist/services/row-pipeline.d.ts +0 -63
- package/src/collections/buildRegistry.ts +0 -59
- package/src/services/row-pipeline.ts +0 -215
|
@@ -7,18 +7,15 @@ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
|
7
7
|
import {
|
|
8
8
|
getCollectionByPath,
|
|
9
9
|
getTableForCollection,
|
|
10
|
-
|
|
11
|
-
deriveRowAddress,
|
|
10
|
+
getPrimaryKeys,
|
|
12
11
|
parseIdValues,
|
|
13
|
-
buildCompositeId
|
|
14
|
-
COMPOSITE_ID_SEPARATOR
|
|
12
|
+
buildCompositeId
|
|
15
13
|
} from "./collection-helpers";
|
|
16
14
|
import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
|
|
17
15
|
import { RelationService } from "./RelationService";
|
|
18
16
|
import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
|
|
19
17
|
import { DrizzleClient } from "../interfaces";
|
|
20
18
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
21
|
-
import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
|
|
22
19
|
import { logger } from "@rebasepro/server";
|
|
23
20
|
|
|
24
21
|
/** Type-safe accessor for Drizzle's relational query API via dynamic table name */
|
|
@@ -112,7 +109,7 @@ export class FetchService {
|
|
|
112
109
|
// Detect many-to-many junction tables:
|
|
113
110
|
// If the relation goes through a junction table (relation.through exists or
|
|
114
111
|
// the Drizzle schema maps to a junction table), we need two-level with.
|
|
115
|
-
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
112
|
+
if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
|
|
116
113
|
// The Drizzle relation points to the junction table.
|
|
117
114
|
// We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
|
|
118
115
|
// The target FK name is the relation on the junction table that points to the actual target.
|
|
@@ -130,6 +127,17 @@ export class FetchService {
|
|
|
130
127
|
return withConfig;
|
|
131
128
|
}
|
|
132
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Detect if a many-to-many relation uses a junction table in the Drizzle schema.
|
|
132
|
+
*/
|
|
133
|
+
private isJunctionRelation(relation: Relation, _collection: CollectionConfig): boolean {
|
|
134
|
+
// If `through` is defined, it's explicitly a junction relation
|
|
135
|
+
if (relation.through) return true;
|
|
136
|
+
// If joinPath has an intermediate table, it's likely junction-based
|
|
137
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
133
141
|
/**
|
|
134
142
|
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
135
143
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
@@ -143,6 +151,88 @@ export class FetchService {
|
|
|
143
151
|
return null;
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
156
|
+
* Handles:
|
|
157
|
+
* - Placing `id` at the top level as a string
|
|
158
|
+
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
159
|
+
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
160
|
+
* - Flattening junction-table many-to-many results
|
|
161
|
+
*/
|
|
162
|
+
private drizzleResultToRow<M extends Record<string, unknown>>(
|
|
163
|
+
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" }[]
|
|
169
|
+
): Record<string, unknown> {
|
|
170
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
171
|
+
|
|
172
|
+
// Normalize non-relation values (dates, numbers, etc.)
|
|
173
|
+
const normalizedValues = normalizeDbValues(row as M, collection) as Record<string, unknown>;
|
|
174
|
+
|
|
175
|
+
// Convert nested relation objects to CMS-style { id, path, __type: "relation" }
|
|
176
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
177
|
+
const drizzleRelName = relation.relationName || key;
|
|
178
|
+
const relData = row[drizzleRelName];
|
|
179
|
+
|
|
180
|
+
if (relData === undefined || relData === null) continue;
|
|
181
|
+
|
|
182
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
183
|
+
const targetCollection = relation.target();
|
|
184
|
+
const targetPath = targetCollection.slug;
|
|
185
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
186
|
+
const targetIdField = targetPks[0].fieldName;
|
|
187
|
+
|
|
188
|
+
normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
|
|
189
|
+
// Handle junction table flattening:
|
|
190
|
+
// Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
|
|
191
|
+
let targetRow = item;
|
|
192
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
193
|
+
// Find the nested target object in the junction row
|
|
194
|
+
const nestedKey = Object.keys(item).find(
|
|
195
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
196
|
+
);
|
|
197
|
+
if (nestedKey) {
|
|
198
|
+
targetRow = item[nestedKey] as Record<string, unknown>;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
|
|
203
|
+
const targetValues = normalizeDbValues(targetRow, targetCollection);
|
|
204
|
+
|
|
205
|
+
return createRelationRefWithData(relId, targetPath, {
|
|
206
|
+
id: relId,
|
|
207
|
+
path: targetPath,
|
|
208
|
+
values: targetValues
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
212
|
+
const targetCollection = relation.target();
|
|
213
|
+
const targetPath = targetCollection.slug;
|
|
214
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
215
|
+
const targetIdField = targetPks[0].fieldName;
|
|
216
|
+
const relObj = relData as Record<string, unknown>;
|
|
217
|
+
|
|
218
|
+
const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
|
|
219
|
+
const targetValues = normalizeDbValues(relObj, targetCollection);
|
|
220
|
+
|
|
221
|
+
normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
|
|
222
|
+
id: relId,
|
|
223
|
+
path: targetPath,
|
|
224
|
+
values: targetValues
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
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
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
146
236
|
/**
|
|
147
237
|
* Post-fetch joinPath relations for a single flat row.
|
|
148
238
|
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
@@ -184,6 +274,57 @@ export class FetchService {
|
|
|
184
274
|
await Promise.all(promises);
|
|
185
275
|
}
|
|
186
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
|
+
|
|
187
328
|
/**
|
|
188
329
|
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
189
330
|
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
@@ -207,25 +348,15 @@ export class FetchService {
|
|
|
207
348
|
|
|
208
349
|
if (joinPathRelations.length === 0) return;
|
|
209
350
|
|
|
210
|
-
|
|
211
|
-
// is derived from them. It used to be parsed back out of a synthesized
|
|
212
|
-
// `id` — which no longer exists on a row, and threw (composite: parts
|
|
213
|
-
// mismatch; numeric: NaN) into the catch below, where a warning is all
|
|
214
|
-
// that separates "no relations" from "relations dropped".
|
|
215
|
-
//
|
|
216
|
-
// The whole key, because that is what the batch groups its results by:
|
|
217
|
-
// both sides derive the token the same way, so they agree by
|
|
218
|
-
// construction rather than by both happening to pick column zero.
|
|
219
|
-
const parentIdOf = (row: Record<string, unknown>): string | undefined => {
|
|
220
|
-
const address = buildCompositeId(row, idInfoArray);
|
|
221
|
-
return address && address.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "") ? address : undefined;
|
|
222
|
-
};
|
|
351
|
+
const idInfo = idInfoArray[0];
|
|
223
352
|
|
|
224
353
|
for (const [key, relation] of joinPathRelations) {
|
|
225
354
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
});
|
|
229
360
|
|
|
230
361
|
if (relation.cardinality === "one") {
|
|
231
362
|
const resultMap = await this.relationService.batchFetchRelatedEntities(
|
|
@@ -235,11 +366,19 @@ export class FetchService {
|
|
|
235
366
|
relation
|
|
236
367
|
);
|
|
237
368
|
|
|
238
|
-
for (const row of
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
}
|
|
243
382
|
}
|
|
244
383
|
} else if (relation.cardinality === "many") {
|
|
245
384
|
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
|
|
@@ -249,9 +388,14 @@ export class FetchService {
|
|
|
249
388
|
relation
|
|
250
389
|
);
|
|
251
390
|
|
|
252
|
-
for (const row of
|
|
253
|
-
const
|
|
254
|
-
|
|
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
|
+
}));
|
|
255
399
|
}
|
|
256
400
|
}
|
|
257
401
|
} catch (e) {
|
|
@@ -260,6 +404,50 @@ export class FetchService {
|
|
|
260
404
|
}
|
|
261
405
|
}
|
|
262
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Convert a db.query result row to a flat REST-style object with populated relations.
|
|
409
|
+
*/
|
|
410
|
+
private drizzleResultToRestRow(
|
|
411
|
+
row: Record<string, unknown>,
|
|
412
|
+
collection: CollectionConfig,
|
|
413
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
414
|
+
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
415
|
+
): 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]) };
|
|
417
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
418
|
+
|
|
419
|
+
for (const [k, v] of Object.entries(row)) {
|
|
420
|
+
if (k === idInfo.fieldName) continue;
|
|
421
|
+
|
|
422
|
+
const relation = findRelation(resolvedRelations, k);
|
|
423
|
+
if (Array.isArray(v) && relation) {
|
|
424
|
+
// Many relation — flatten each nested row, handling junction tables
|
|
425
|
+
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
426
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
427
|
+
const nestedKey = Object.keys(item).find(
|
|
428
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
429
|
+
);
|
|
430
|
+
if (nestedKey) {
|
|
431
|
+
const nested = item[nestedKey] as Record<string, unknown>;
|
|
432
|
+
return { ...nested,
|
|
433
|
+
id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { ...item,
|
|
437
|
+
id: String(item.id ?? item[Object.keys(item)[0]]) };
|
|
438
|
+
});
|
|
439
|
+
} else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
|
|
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]]) };
|
|
444
|
+
} else {
|
|
445
|
+
flat[k] = v;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return flat;
|
|
449
|
+
}
|
|
450
|
+
|
|
263
451
|
/**
|
|
264
452
|
* Build db.query-compatible options from standard fetch options.
|
|
265
453
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
@@ -401,7 +589,7 @@ export class FetchService {
|
|
|
401
589
|
): Promise<Record<string, unknown> | undefined> {
|
|
402
590
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
403
591
|
const table = getTableForCollection(collection, this.registry);
|
|
404
|
-
const idInfoArray =
|
|
592
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
405
593
|
const idInfo = idInfoArray[0];
|
|
406
594
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
407
595
|
|
|
@@ -428,7 +616,7 @@ export class FetchService {
|
|
|
428
616
|
|
|
429
617
|
if (!row) return undefined;
|
|
430
618
|
|
|
431
|
-
const flatRow =
|
|
619
|
+
const flatRow = this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
|
|
432
620
|
|
|
433
621
|
// Post-fetch joinPath relations that Drizzle's `with` can't express
|
|
434
622
|
await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
@@ -520,7 +708,7 @@ export class FetchService {
|
|
|
520
708
|
): Promise<Record<string, unknown>[]> {
|
|
521
709
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
522
710
|
const table = getTableForCollection(collection, this.registry);
|
|
523
|
-
const idInfoArray =
|
|
711
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
524
712
|
const idInfo = idInfoArray[0];
|
|
525
713
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
526
714
|
|
|
@@ -552,7 +740,7 @@ export class FetchService {
|
|
|
552
740
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
553
741
|
|
|
554
742
|
const rows = (results as Record<string, unknown>[]).map(row =>
|
|
555
|
-
|
|
743
|
+
this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
|
|
556
744
|
);
|
|
557
745
|
|
|
558
746
|
return rows;
|
|
@@ -652,10 +840,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
652
840
|
|
|
653
841
|
/**
|
|
654
842
|
* Fallback path used when db.query is unavailable.
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* relations from what drizzle already nested — no query per row. This one
|
|
658
|
-
* has no nesting to read, so it resolves relations itself, in batches.
|
|
843
|
+
* The primary path uses drizzleResultToRow which handles relation
|
|
844
|
+
* mapping without N+1 queries.
|
|
659
845
|
*
|
|
660
846
|
* Process raw database results into flat rows with relations.
|
|
661
847
|
*/
|
|
@@ -678,9 +864,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
678
864
|
// relation type, avoiding the N+1 that plagued the old path.
|
|
679
865
|
const parsedRows = await Promise.all(results.map(async (rawRow: Record<string, unknown>) => {
|
|
680
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]);
|
|
681
868
|
return {
|
|
682
869
|
rawRow,
|
|
683
|
-
values
|
|
870
|
+
values,
|
|
871
|
+
id
|
|
684
872
|
};
|
|
685
873
|
}));
|
|
686
874
|
|
|
@@ -750,8 +938,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
750
938
|
}
|
|
751
939
|
}
|
|
752
940
|
|
|
753
|
-
|
|
754
|
-
|
|
941
|
+
return parsedRows.map(item => ({
|
|
942
|
+
...item.values,
|
|
943
|
+
id: item.id
|
|
944
|
+
}));
|
|
755
945
|
}
|
|
756
946
|
|
|
757
947
|
/**
|
|
@@ -840,12 +1030,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
840
1030
|
relationKey,
|
|
841
1031
|
options
|
|
842
1032
|
);
|
|
843
|
-
//
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
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
|
+
}));
|
|
849
1038
|
}
|
|
850
1039
|
|
|
851
1040
|
if (i + 1 < pathSegments.length) {
|
|
@@ -959,7 +1148,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
959
1148
|
|
|
960
1149
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
961
1150
|
const table = getTableForCollection(collection, this.registry);
|
|
962
|
-
const idInfoArray =
|
|
1151
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
963
1152
|
const idInfo = idInfoArray[0];
|
|
964
1153
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
965
1154
|
const field = table[fieldName as keyof typeof table] as AnyPgColumn;
|
|
@@ -1019,7 +1208,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1019
1208
|
): Promise<Record<string, unknown>[]> {
|
|
1020
1209
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1021
1210
|
const table = getTableForCollection(collection, this.registry);
|
|
1022
|
-
const idInfoArray =
|
|
1211
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1023
1212
|
const idInfo = idInfoArray[0];
|
|
1024
1213
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1025
1214
|
|
|
@@ -1046,7 +1235,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1046
1235
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
1047
1236
|
|
|
1048
1237
|
const restRows = (results as Record<string, unknown>[]).map(row =>
|
|
1049
|
-
|
|
1238
|
+
this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
|
|
1050
1239
|
);
|
|
1051
1240
|
|
|
1052
1241
|
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
@@ -1066,7 +1255,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1066
1255
|
const rows = await this.fetchRowsWithConditionsRaw<M>(collectionPath, options);
|
|
1067
1256
|
|
|
1068
1257
|
if (!include || include.length === 0) {
|
|
1069
|
-
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
|
+
}));
|
|
1070
1262
|
}
|
|
1071
1263
|
|
|
1072
1264
|
// Fallback relation loading via batch
|
|
@@ -1087,7 +1279,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1087
1279
|
const eid = row[idInfo.fieldName] as string | number;
|
|
1088
1280
|
const related = batchResults.get(String(eid));
|
|
1089
1281
|
if (related) {
|
|
1090
|
-
(row as Record<string, unknown>)[key] = { ...related.values
|
|
1282
|
+
(row as Record<string, unknown>)[key] = { ...related.values,
|
|
1283
|
+
id: related.id };
|
|
1091
1284
|
}
|
|
1092
1285
|
}
|
|
1093
1286
|
} catch (e) {
|
|
@@ -1111,7 +1304,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1111
1304
|
}
|
|
1112
1305
|
}
|
|
1113
1306
|
|
|
1114
|
-
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
|
+
}));
|
|
1115
1311
|
}
|
|
1116
1312
|
|
|
1117
1313
|
/**
|
|
@@ -1125,7 +1321,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1125
1321
|
): Promise<Record<string, unknown> | null> {
|
|
1126
1322
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1127
1323
|
const table = getTableForCollection(collection, this.registry);
|
|
1128
|
-
const idInfoArray =
|
|
1324
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1129
1325
|
const idInfo = idInfoArray[0];
|
|
1130
1326
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1131
1327
|
|
|
@@ -1151,7 +1347,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1151
1347
|
|
|
1152
1348
|
if (!row) return null;
|
|
1153
1349
|
|
|
1154
|
-
const restRow =
|
|
1350
|
+
const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
|
|
1155
1351
|
|
|
1156
1352
|
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
1157
1353
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
@@ -1175,7 +1371,9 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1175
1371
|
|
|
1176
1372
|
if (result.length === 0) return null;
|
|
1177
1373
|
|
|
1178
|
-
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]) };
|
|
1179
1377
|
|
|
1180
1378
|
if (!include || include.length === 0) {
|
|
1181
1379
|
return flatEntity;
|
|
@@ -1233,7 +1431,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1233
1431
|
): Promise<Record<string, unknown>[]> {
|
|
1234
1432
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1235
1433
|
const table = getTableForCollection(collection, this.registry);
|
|
1236
|
-
const idInfoArray =
|
|
1434
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1237
1435
|
const idInfo = idInfoArray[0];
|
|
1238
1436
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1239
1437
|
|
|
@@ -1383,24 +1581,31 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1383
1581
|
|
|
1384
1582
|
const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
|
|
1385
1583
|
|
|
1386
|
-
//
|
|
1584
|
+
// Flatten the nested Drizzle results into REST format
|
|
1387
1585
|
return results.map((row: Record<string, unknown>) => {
|
|
1388
|
-
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]) };
|
|
1389
1587
|
for (const [k, v] of Object.entries(row)) {
|
|
1588
|
+
if (k === idInfo.fieldName) continue;
|
|
1390
1589
|
if (Array.isArray(v)) {
|
|
1391
|
-
// Many relation —
|
|
1590
|
+
// Many relation — flatten each nested row
|
|
1392
1591
|
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
1393
|
-
// Junction table rows may have the target nested,
|
|
1592
|
+
// Junction table rows may have the target nested, flatten those
|
|
1394
1593
|
const keys = Object.keys(item);
|
|
1594
|
+
// If it looks like a junction row (only FKs + nested objects), extract nested
|
|
1395
1595
|
const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
1396
1596
|
if (nestedObj && keys.length <= 3) {
|
|
1397
|
-
|
|
1597
|
+
const nested = item[nestedObj] as Record<string, unknown>;
|
|
1598
|
+
return { ...nested,
|
|
1599
|
+
id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
|
|
1398
1600
|
}
|
|
1399
|
-
return { ...item
|
|
1601
|
+
return { ...item,
|
|
1602
|
+
id: String(item.id ?? item[Object.keys(item)[0]]) };
|
|
1400
1603
|
});
|
|
1401
1604
|
} else if (typeof v === "object" && v !== null) {
|
|
1402
|
-
// One-to-one relation — inline the
|
|
1403
|
-
|
|
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]]) };
|
|
1404
1609
|
} else {
|
|
1405
1610
|
flat[k] = v;
|
|
1406
1611
|
}
|
|
@@ -115,19 +115,12 @@ 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.
|
|
124
118
|
*/
|
|
125
119
|
async save<M extends Record<string, unknown>>(
|
|
126
120
|
collectionPath: string,
|
|
127
121
|
values: Partial<M>,
|
|
128
122
|
id?: string | number,
|
|
129
|
-
databaseId?: string
|
|
130
|
-
options?: { upsert?: boolean }
|
|
123
|
+
databaseId?: string
|
|
131
124
|
): Promise<Record<string, unknown>> {
|
|
132
125
|
// If saving under a nested relation path, resolve the parent and inject FK
|
|
133
126
|
let effectiveCollectionPath = collectionPath;
|
|
@@ -262,7 +255,7 @@ export class PersistService {
|
|
|
262
255
|
savedId = await this.db.transaction(async (tx) => {
|
|
263
256
|
let currentId: string | number;
|
|
264
257
|
|
|
265
|
-
if (id
|
|
258
|
+
if (id) {
|
|
266
259
|
// Update existing row
|
|
267
260
|
currentId = id; // `id` is already the formatted composite or singular string
|
|
268
261
|
const idValues = parseIdValues(id, idInfoArray);
|
|
@@ -302,12 +295,6 @@ export class PersistService {
|
|
|
302
295
|
} else {
|
|
303
296
|
const dataForInsert = { ...(entityData as Record<string, unknown>) };
|
|
304
297
|
|
|
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
298
|
// Strip empty primary keys so the database defaults (e.g. uuid_gen(), auto-increment) can trigger
|
|
312
299
|
for (const info of idInfoArray) {
|
|
313
300
|
if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === undefined) {
|
|
@@ -315,46 +302,13 @@ export class PersistService {
|
|
|
315
302
|
}
|
|
316
303
|
}
|
|
317
304
|
|
|
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
|
-
}
|
|
305
|
+
const result = await tx
|
|
306
|
+
.insert(table)
|
|
307
|
+
.values(dataForInsert)
|
|
308
|
+
.returning(returningKeys);
|
|
341
309
|
|
|
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
310
|
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
|
-
}
|
|
311
|
+
currentId = buildCompositeId(resultRow, idInfoArray);
|
|
358
312
|
|
|
359
313
|
// For inserts, apply joinPath after since the parent row didn't exist before
|
|
360
314
|
if (joinPathRelationUpdates.length > 0) {
|
|
@@ -383,15 +337,8 @@ export class PersistService {
|
|
|
383
337
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
384
338
|
}
|
|
385
339
|
|
|
386
|
-
// Fetch the
|
|
387
|
-
|
|
388
|
-
// — the admin view-model walk — whose `__type: "relation"` refs then
|
|
389
|
-
// leaked into REST responses, afterSave callbacks, history records and
|
|
390
|
-
// app-level realtime payloads, none of which see that shape from any
|
|
391
|
-
// read path. The admin does not need them here either: its rows arrive
|
|
392
|
-
// over the realtime subscription refetch, which still serves the
|
|
393
|
-
// view-model walk.
|
|
394
|
-
const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, undefined, databaseId);
|
|
340
|
+
// Fetch the updated/created row to return with proper relation objects
|
|
341
|
+
const finalEntity = await this.fetchService.fetchOne<M>(collection.slug, savedId, databaseId);
|
|
395
342
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
396
343
|
return finalEntity;
|
|
397
344
|
}
|