@rebasepro/server-postgres 0.9.1-canary.1d2d8b5 → 0.9.1-canary.26fe4b2
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/README.md +21 -0
- package/dist/PostgresBackendDriver.d.ts +43 -2
- package/dist/PostgresBootstrapper.d.ts +17 -1
- package/dist/auth/services.d.ts +68 -52
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/connection.d.ts +21 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +2060 -762
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
- package/dist/schema/auth-schema.d.ts +24 -24
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/schema/introspect-db-logic.d.ts +0 -5
- package/dist/schema/introspect-db-naming.d.ts +10 -0
- package/dist/security/policy-drift.d.ts +30 -0
- package/dist/security/rls-enforcement.d.ts +2 -2
- package/dist/services/FetchService.d.ts +4 -24
- package/dist/services/PersistService.d.ts +9 -1
- package/dist/services/RelationService.d.ts +34 -1
- package/dist/services/channel-history.d.ts +118 -0
- package/dist/services/collection-helpers.d.ts +79 -14
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +76 -2
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +12 -33
- package/src/PostgresBackendDriver.ts +183 -18
- package/src/PostgresBootstrapper.ts +80 -26
- package/src/auth/ensure-tables.ts +170 -28
- package/src/auth/services.ts +181 -150
- package/src/cli.ts +60 -0
- package/src/collections/buildRegistry.ts +59 -0
- package/src/connection.ts +61 -1
- package/src/data-transformer.ts +11 -9
- package/src/databasePoolManager.ts +2 -0
- package/src/schema/auth-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +13 -13
- package/src/schema/doctor.ts +45 -20
- package/src/schema/generate-drizzle-schema-logic.ts +24 -29
- package/src/schema/generate-postgres-ddl-logic.ts +76 -28
- package/src/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +1 -10
- package/src/schema/introspect-db-naming.ts +15 -0
- package/src/schema/introspect-db.ts +19 -2
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/security/policy-drift.test.ts +106 -1
- package/src/security/policy-drift.ts +56 -0
- package/src/security/rls-enforcement.ts +11 -5
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +65 -270
- package/src/services/PersistService.ts +62 -9
- package/src/services/RelationService.ts +153 -94
- package/src/services/channel-history.ts +343 -0
- package/src/services/collection-helpers.ts +164 -47
- package/src/services/dataService.ts +3 -2
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +238 -29
- package/src/services/row-pipeline.ts +239 -0
- package/src/utils/drizzle-conditions.ts +13 -0
- package/src/websocket.ts +34 -12
- package/dist/schema/auth-default-policies.d.ts +0 -10
- package/src/schema/auth-default-policies.ts +0 -132
|
@@ -7,15 +7,18 @@ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
|
7
7
|
import {
|
|
8
8
|
getCollectionByPath,
|
|
9
9
|
getTableForCollection,
|
|
10
|
-
|
|
10
|
+
requirePrimaryKeys,
|
|
11
|
+
deriveRowAddress,
|
|
11
12
|
parseIdValues,
|
|
12
|
-
buildCompositeId
|
|
13
|
+
buildCompositeId,
|
|
14
|
+
COMPOSITE_ID_SEPARATOR
|
|
13
15
|
} from "./collection-helpers";
|
|
14
16
|
import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
|
|
15
17
|
import { RelationService } from "./RelationService";
|
|
16
18
|
import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
|
|
17
19
|
import { DrizzleClient } from "../interfaces";
|
|
18
20
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
21
|
+
import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
|
|
19
22
|
import { logger } from "@rebasepro/server";
|
|
20
23
|
|
|
21
24
|
/** Type-safe accessor for Drizzle's relational query API via dynamic table name */
|
|
@@ -109,7 +112,7 @@ export class FetchService {
|
|
|
109
112
|
// Detect many-to-many junction tables:
|
|
110
113
|
// If the relation goes through a junction table (relation.through exists or
|
|
111
114
|
// the Drizzle schema maps to a junction table), we need two-level with.
|
|
112
|
-
if (relation.cardinality === "many" &&
|
|
115
|
+
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
113
116
|
// The Drizzle relation points to the junction table.
|
|
114
117
|
// We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
|
|
115
118
|
// The target FK name is the relation on the junction table that points to the actual target.
|
|
@@ -127,17 +130,6 @@ export class FetchService {
|
|
|
127
130
|
return withConfig;
|
|
128
131
|
}
|
|
129
132
|
|
|
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
|
-
|
|
141
133
|
/**
|
|
142
134
|
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
143
135
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
@@ -151,88 +143,6 @@ export class FetchService {
|
|
|
151
143
|
return null;
|
|
152
144
|
}
|
|
153
145
|
|
|
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
|
-
|
|
236
146
|
/**
|
|
237
147
|
* Post-fetch joinPath relations for a single flat row.
|
|
238
148
|
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
@@ -274,57 +184,6 @@ export class FetchService {
|
|
|
274
184
|
await Promise.all(promises);
|
|
275
185
|
}
|
|
276
186
|
|
|
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
187
|
/**
|
|
329
188
|
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
330
189
|
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
@@ -348,15 +207,25 @@ export class FetchService {
|
|
|
348
207
|
|
|
349
208
|
if (joinPathRelations.length === 0) return;
|
|
350
209
|
|
|
351
|
-
|
|
210
|
+
// These rows carry their key columns verbatim, so the parent's address
|
|
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
|
+
};
|
|
352
223
|
|
|
353
224
|
for (const [key, relation] of joinPathRelations) {
|
|
354
225
|
try {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return parsed[idInfo.fieldName] as string | number;
|
|
359
|
-
});
|
|
226
|
+
const addressable = rows.filter(r => parentIdOf(r) !== undefined && parentIdOf(r) !== null);
|
|
227
|
+
if (addressable.length === 0) continue;
|
|
228
|
+
const rowIds = addressable.map(r => parentIdOf(r) as string | number);
|
|
360
229
|
|
|
361
230
|
if (relation.cardinality === "one") {
|
|
362
231
|
const resultMap = await this.relationService.batchFetchRelatedEntities(
|
|
@@ -366,19 +235,11 @@ export class FetchService {
|
|
|
366
235
|
relation
|
|
367
236
|
);
|
|
368
237
|
|
|
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
|
-
}
|
|
238
|
+
for (const row of addressable) {
|
|
239
|
+
const relatedRow = resultMap.get(String(parentIdOf(row)));
|
|
240
|
+
// Columns only: the target's address is the consumer's to
|
|
241
|
+
// derive, and merging it last overwrote a real `id` column.
|
|
242
|
+
row[key] = relatedRow ? { ...relatedRow.values } : null;
|
|
382
243
|
}
|
|
383
244
|
} else if (relation.cardinality === "many") {
|
|
384
245
|
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
|
|
@@ -388,14 +249,9 @@ export class FetchService {
|
|
|
388
249
|
relation
|
|
389
250
|
);
|
|
390
251
|
|
|
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
|
-
}));
|
|
252
|
+
for (const row of addressable) {
|
|
253
|
+
const relatedList = resultMap.get(String(parentIdOf(row))) || [];
|
|
254
|
+
row[key] = relatedList.map(e => ({ ...e.values }));
|
|
399
255
|
}
|
|
400
256
|
}
|
|
401
257
|
} catch (e) {
|
|
@@ -404,50 +260,6 @@ export class FetchService {
|
|
|
404
260
|
}
|
|
405
261
|
}
|
|
406
262
|
|
|
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
|
-
|
|
451
263
|
/**
|
|
452
264
|
* Build db.query-compatible options from standard fetch options.
|
|
453
265
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
@@ -589,7 +401,7 @@ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
|
|
|
589
401
|
): Promise<Record<string, unknown> | undefined> {
|
|
590
402
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
591
403
|
const table = getTableForCollection(collection, this.registry);
|
|
592
|
-
const idInfoArray =
|
|
404
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
593
405
|
const idInfo = idInfoArray[0];
|
|
594
406
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
595
407
|
|
|
@@ -616,7 +428,7 @@ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
|
|
|
616
428
|
|
|
617
429
|
if (!row) return undefined;
|
|
618
430
|
|
|
619
|
-
const flatRow =
|
|
431
|
+
const flatRow = toCmsRow(row, collection, this.registry);
|
|
620
432
|
|
|
621
433
|
// Post-fetch joinPath relations that Drizzle's `with` can't express
|
|
622
434
|
await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
@@ -708,7 +520,7 @@ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
|
|
|
708
520
|
): Promise<Record<string, unknown>[]> {
|
|
709
521
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
710
522
|
const table = getTableForCollection(collection, this.registry);
|
|
711
|
-
const idInfoArray =
|
|
523
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
712
524
|
const idInfo = idInfoArray[0];
|
|
713
525
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
714
526
|
|
|
@@ -740,7 +552,7 @@ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
|
|
|
740
552
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
741
553
|
|
|
742
554
|
const rows = (results as Record<string, unknown>[]).map(row =>
|
|
743
|
-
|
|
555
|
+
toCmsRow(row, collection, this.registry)
|
|
744
556
|
);
|
|
745
557
|
|
|
746
558
|
return rows;
|
|
@@ -840,8 +652,10 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
840
652
|
|
|
841
653
|
/**
|
|
842
654
|
* Fallback path used when db.query is unavailable.
|
|
843
|
-
*
|
|
844
|
-
*
|
|
655
|
+
*
|
|
656
|
+
* The primary path runs the results through `toCmsRow`, which maps
|
|
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.
|
|
845
659
|
*
|
|
846
660
|
* Process raw database results into flat rows with relations.
|
|
847
661
|
*/
|
|
@@ -864,11 +678,9 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
864
678
|
// relation type, avoiding the N+1 that plagued the old path.
|
|
865
679
|
const parsedRows = await Promise.all(results.map(async (rawRow: Record<string, unknown>) => {
|
|
866
680
|
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
681
|
return {
|
|
869
682
|
rawRow,
|
|
870
|
-
values
|
|
871
|
-
id
|
|
683
|
+
values
|
|
872
684
|
};
|
|
873
685
|
}));
|
|
874
686
|
|
|
@@ -938,10 +750,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
938
750
|
}
|
|
939
751
|
}
|
|
940
752
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
id: item.id
|
|
944
|
-
}));
|
|
753
|
+
// Columns only — the address is the consumer's to derive.
|
|
754
|
+
return parsedRows.map(item => item.values);
|
|
945
755
|
}
|
|
946
756
|
|
|
947
757
|
/**
|
|
@@ -1030,11 +840,12 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1030
840
|
relationKey,
|
|
1031
841
|
options
|
|
1032
842
|
);
|
|
1033
|
-
//
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
843
|
+
// Flatten RelatedRow[] to rows: the target's columns, and only
|
|
844
|
+
// those. Merging `row.id` in last overwrote a real `id` column,
|
|
845
|
+
// and the address is derivable without it — a consumer resolves
|
|
846
|
+
// this path ("posts/1/comments") to the target collection and
|
|
847
|
+
// reads the key columns, which `values` carries.
|
|
848
|
+
return rows.map(row => ({ ...row.values as Record<string, unknown> }));
|
|
1038
849
|
}
|
|
1039
850
|
|
|
1040
851
|
if (i + 1 < pathSegments.length) {
|
|
@@ -1148,7 +959,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1148
959
|
|
|
1149
960
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1150
961
|
const table = getTableForCollection(collection, this.registry);
|
|
1151
|
-
const idInfoArray =
|
|
962
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
1152
963
|
const idInfo = idInfoArray[0];
|
|
1153
964
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1154
965
|
const field = table[fieldName as keyof typeof table] as AnyPgColumn;
|
|
@@ -1208,7 +1019,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1208
1019
|
): Promise<Record<string, unknown>[]> {
|
|
1209
1020
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1210
1021
|
const table = getTableForCollection(collection, this.registry);
|
|
1211
|
-
const idInfoArray =
|
|
1022
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
1212
1023
|
const idInfo = idInfoArray[0];
|
|
1213
1024
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1214
1025
|
|
|
@@ -1235,7 +1046,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1235
1046
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
1236
1047
|
|
|
1237
1048
|
const restRows = (results as Record<string, unknown>[]).map(row =>
|
|
1238
|
-
|
|
1049
|
+
toRestRow(row, collection, this.registry)
|
|
1239
1050
|
);
|
|
1240
1051
|
|
|
1241
1052
|
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
@@ -1255,10 +1066,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1255
1066
|
const rows = await this.fetchRowsWithConditionsRaw<M>(collectionPath, options);
|
|
1256
1067
|
|
|
1257
1068
|
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
|
-
}));
|
|
1069
|
+
return rows;
|
|
1262
1070
|
}
|
|
1263
1071
|
|
|
1264
1072
|
// Fallback relation loading via batch
|
|
@@ -1279,8 +1087,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1279
1087
|
const eid = row[idInfo.fieldName] as string | number;
|
|
1280
1088
|
const related = batchResults.get(String(eid));
|
|
1281
1089
|
if (related) {
|
|
1282
|
-
(row as Record<string, unknown>)[key] = { ...related.values
|
|
1283
|
-
id: related.id };
|
|
1090
|
+
(row as Record<string, unknown>)[key] = { ...related.values };
|
|
1284
1091
|
}
|
|
1285
1092
|
}
|
|
1286
1093
|
} catch (e) {
|
|
@@ -1304,10 +1111,7 @@ id: related.id };
|
|
|
1304
1111
|
}
|
|
1305
1112
|
}
|
|
1306
1113
|
|
|
1307
|
-
return rows
|
|
1308
|
-
...row,
|
|
1309
|
-
id: (idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
|
|
1310
|
-
}));
|
|
1114
|
+
return rows;
|
|
1311
1115
|
}
|
|
1312
1116
|
|
|
1313
1117
|
/**
|
|
@@ -1321,7 +1125,7 @@ id: related.id };
|
|
|
1321
1125
|
): Promise<Record<string, unknown> | null> {
|
|
1322
1126
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1323
1127
|
const table = getTableForCollection(collection, this.registry);
|
|
1324
|
-
const idInfoArray =
|
|
1128
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
1325
1129
|
const idInfo = idInfoArray[0];
|
|
1326
1130
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1327
1131
|
|
|
@@ -1347,7 +1151,7 @@ id: related.id };
|
|
|
1347
1151
|
|
|
1348
1152
|
if (!row) return null;
|
|
1349
1153
|
|
|
1350
|
-
const restRow =
|
|
1154
|
+
const restRow = toRestRow(row, collection, this.registry);
|
|
1351
1155
|
|
|
1352
1156
|
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
1353
1157
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
@@ -1371,9 +1175,7 @@ id: related.id };
|
|
|
1371
1175
|
|
|
1372
1176
|
if (result.length === 0) return null;
|
|
1373
1177
|
|
|
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]) };
|
|
1178
|
+
const flatEntity: Record<string, unknown> = { ...(result[0] as Record<string, unknown>) };
|
|
1377
1179
|
|
|
1378
1180
|
if (!include || include.length === 0) {
|
|
1379
1181
|
return flatEntity;
|
|
@@ -1431,7 +1233,7 @@ id: (idInfoArray.length > 1) ? buildCompositeId(raw as Record<string, unknown>,
|
|
|
1431
1233
|
): Promise<Record<string, unknown>[]> {
|
|
1432
1234
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1433
1235
|
const table = getTableForCollection(collection, this.registry);
|
|
1434
|
-
const idInfoArray =
|
|
1236
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
1435
1237
|
const idInfo = idInfoArray[0];
|
|
1436
1238
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1437
1239
|
|
|
@@ -1581,31 +1383,24 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1581
1383
|
|
|
1582
1384
|
const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
|
|
1583
1385
|
|
|
1584
|
-
//
|
|
1386
|
+
// Inline the nested Drizzle results, columns only — no synthesized id.
|
|
1585
1387
|
return results.map((row: Record<string, unknown>) => {
|
|
1586
|
-
const flat: Record<string, unknown> = {
|
|
1388
|
+
const flat: Record<string, unknown> = {};
|
|
1587
1389
|
for (const [k, v] of Object.entries(row)) {
|
|
1588
|
-
if (k === idInfo.fieldName) continue;
|
|
1589
1390
|
if (Array.isArray(v)) {
|
|
1590
|
-
// Many relation —
|
|
1391
|
+
// Many relation — inline each nested row
|
|
1591
1392
|
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
1592
|
-
// Junction table rows may have the target nested,
|
|
1393
|
+
// Junction table rows may have the target nested, unwrap those
|
|
1593
1394
|
const keys = Object.keys(item);
|
|
1594
|
-
// If it looks like a junction row (only FKs + nested objects), extract nested
|
|
1595
1395
|
const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
1596
1396
|
if (nestedObj && keys.length <= 3) {
|
|
1597
|
-
|
|
1598
|
-
return { ...nested,
|
|
1599
|
-
id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
|
|
1397
|
+
return { ...(item[nestedObj] as Record<string, unknown>) };
|
|
1600
1398
|
}
|
|
1601
|
-
return { ...item
|
|
1602
|
-
id: String(item.id ?? item[Object.keys(item)[0]]) };
|
|
1399
|
+
return { ...item };
|
|
1603
1400
|
});
|
|
1604
1401
|
} 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]]) };
|
|
1402
|
+
// One-to-one relation — inline the target's columns
|
|
1403
|
+
flat[k] = { ...(v as Record<string, unknown>) };
|
|
1609
1404
|
} else {
|
|
1610
1405
|
flat[k] = v;
|
|
1611
1406
|
}
|
|
@@ -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) {
|
|
@@ -337,8 +383,15 @@ export class PersistService {
|
|
|
337
383
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
338
384
|
}
|
|
339
385
|
|
|
340
|
-
// Fetch the
|
|
341
|
-
|
|
386
|
+
// Fetch the saved row back through the same walk `GET /:id` serves, so a
|
|
387
|
+
// write's answer is the read that follows it. This used to be `fetchOne`
|
|
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);
|
|
342
395
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
343
396
|
return finalEntity;
|
|
344
397
|
}
|