@rebasepro/server-postgres 0.9.1-canary.742f831 → 0.9.1-canary.7dddf96
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/PostgresBootstrapper.d.ts +0 -10
- package/dist/auth/services.d.ts +0 -16
- package/dist/connection.d.ts +0 -21
- package/dist/index.es.js +243 -455
- package/dist/index.es.js.map +1 -1
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/security/policy-drift.d.ts +0 -30
- package/dist/services/FetchService.d.ts +38 -4
- package/dist/services/RelationService.d.ts +0 -34
- package/dist/services/collection-helpers.d.ts +7 -34
- package/package.json +7 -10
- package/src/PostgresBackendDriver.ts +5 -18
- package/src/PostgresBootstrapper.ts +2 -51
- package/src/auth/ensure-tables.ts +11 -73
- package/src/auth/services.ts +19 -49
- package/src/cli.ts +0 -60
- package/src/connection.ts +1 -61
- package/src/databasePoolManager.ts +0 -2
- package/src/schema/doctor.ts +20 -45
- package/src/schema/introspect-db.ts +2 -19
- package/src/security/policy-drift.test.ts +1 -106
- package/src/security/policy-drift.ts +0 -56
- package/src/services/FetchService.ts +163 -29
- package/src/services/PersistService.ts +2 -9
- package/src/services/RelationService.ts +93 -153
- package/src/services/collection-helpers.ts +27 -59
- package/src/services/realtimeService.ts +2 -4
- package/src/utils/drizzle-conditions.ts +0 -13
- package/dist/services/row-pipeline.d.ts +0 -63
- package/src/services/row-pipeline.ts +0 -239
|
@@ -7,18 +7,16 @@ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
|
7
7
|
import {
|
|
8
8
|
getCollectionByPath,
|
|
9
9
|
getTableForCollection,
|
|
10
|
-
|
|
10
|
+
getPrimaryKeys,
|
|
11
11
|
deriveRowAddress,
|
|
12
12
|
parseIdValues,
|
|
13
|
-
buildCompositeId
|
|
14
|
-
COMPOSITE_ID_SEPARATOR
|
|
13
|
+
buildCompositeId
|
|
15
14
|
} from "./collection-helpers";
|
|
16
15
|
import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
|
|
17
16
|
import { RelationService } from "./RelationService";
|
|
18
17
|
import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
|
|
19
18
|
import { DrizzleClient } from "../interfaces";
|
|
20
19
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
21
|
-
import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
|
|
22
20
|
import { logger } from "@rebasepro/server";
|
|
23
21
|
|
|
24
22
|
/** Type-safe accessor for Drizzle's relational query API via dynamic table name */
|
|
@@ -112,7 +110,7 @@ export class FetchService {
|
|
|
112
110
|
// Detect many-to-many junction tables:
|
|
113
111
|
// If the relation goes through a junction table (relation.through exists or
|
|
114
112
|
// the Drizzle schema maps to a junction table), we need two-level with.
|
|
115
|
-
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
113
|
+
if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
|
|
116
114
|
// The Drizzle relation points to the junction table.
|
|
117
115
|
// We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
|
|
118
116
|
// The target FK name is the relation on the junction table that points to the actual target.
|
|
@@ -130,6 +128,17 @@ export class FetchService {
|
|
|
130
128
|
return withConfig;
|
|
131
129
|
}
|
|
132
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Detect if a many-to-many relation uses a junction table in the Drizzle schema.
|
|
133
|
+
*/
|
|
134
|
+
private isJunctionRelation(relation: Relation, _collection: CollectionConfig): boolean {
|
|
135
|
+
// If `through` is defined, it's explicitly a junction relation
|
|
136
|
+
if (relation.through) return true;
|
|
137
|
+
// If joinPath has an intermediate table, it's likely junction-based
|
|
138
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
133
142
|
/**
|
|
134
143
|
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
135
144
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
@@ -143,6 +152,98 @@ export class FetchService {
|
|
|
143
152
|
return null;
|
|
144
153
|
}
|
|
145
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
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
174
|
+
* Handles:
|
|
175
|
+
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
176
|
+
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
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.
|
|
181
|
+
*/
|
|
182
|
+
private drizzleResultToRow<M extends Record<string, unknown>>(
|
|
183
|
+
row: Record<string, unknown>,
|
|
184
|
+
collection: CollectionConfig
|
|
185
|
+
): Record<string, unknown> {
|
|
186
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
187
|
+
|
|
188
|
+
// Normalize non-relation values (dates, numbers, etc.)
|
|
189
|
+
const normalizedValues = normalizeDbValues(row as M, collection) as Record<string, unknown>;
|
|
190
|
+
|
|
191
|
+
// Convert nested relation objects to CMS-style { id, path, __type: "relation" }
|
|
192
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
193
|
+
const drizzleRelName = relation.relationName || key;
|
|
194
|
+
const relData = row[drizzleRelName];
|
|
195
|
+
|
|
196
|
+
if (relData === undefined || relData === null) continue;
|
|
197
|
+
|
|
198
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
199
|
+
const targetCollection = relation.target();
|
|
200
|
+
const targetPath = targetCollection.slug;
|
|
201
|
+
|
|
202
|
+
normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
|
|
203
|
+
// Handle junction table flattening:
|
|
204
|
+
// Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
|
|
205
|
+
let targetRow = item;
|
|
206
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
207
|
+
// Find the nested target object in the junction row
|
|
208
|
+
const nestedKey = Object.keys(item).find(
|
|
209
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
210
|
+
);
|
|
211
|
+
if (nestedKey) {
|
|
212
|
+
targetRow = item[nestedKey] as Record<string, unknown>;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const relId = this.relationTargetAddress(targetRow, targetCollection);
|
|
217
|
+
const targetValues = normalizeDbValues(targetRow, targetCollection);
|
|
218
|
+
|
|
219
|
+
return createRelationRefWithData(relId, targetPath, {
|
|
220
|
+
id: relId,
|
|
221
|
+
path: targetPath,
|
|
222
|
+
values: targetValues
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
226
|
+
const targetCollection = relation.target();
|
|
227
|
+
const targetPath = targetCollection.slug;
|
|
228
|
+
const relObj = relData as Record<string, unknown>;
|
|
229
|
+
|
|
230
|
+
const relId = this.relationTargetAddress(relObj, targetCollection);
|
|
231
|
+
const targetValues = normalizeDbValues(relObj, targetCollection);
|
|
232
|
+
|
|
233
|
+
normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
|
|
234
|
+
id: relId,
|
|
235
|
+
path: targetPath,
|
|
236
|
+
values: targetValues
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
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;
|
|
245
|
+
}
|
|
246
|
+
|
|
146
247
|
/**
|
|
147
248
|
* Post-fetch joinPath relations for a single flat row.
|
|
148
249
|
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
@@ -207,19 +308,13 @@ export class FetchService {
|
|
|
207
308
|
|
|
208
309
|
if (joinPathRelations.length === 0) return;
|
|
209
310
|
|
|
210
|
-
|
|
211
|
-
//
|
|
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
|
|
212
314
|
// `id` — which no longer exists on a row, and threw (composite: parts
|
|
213
315
|
// mismatch; numeric: NaN) into the catch below, where a warning is all
|
|
214
316
|
// 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
|
-
};
|
|
317
|
+
const parentIdOf = (row: Record<string, unknown>) => row[idInfo.fieldName] as string | number | undefined;
|
|
223
318
|
|
|
224
319
|
for (const [key, relation] of joinPathRelations) {
|
|
225
320
|
try {
|
|
@@ -260,6 +355,47 @@ export class FetchService {
|
|
|
260
355
|
}
|
|
261
356
|
}
|
|
262
357
|
|
|
358
|
+
/**
|
|
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.
|
|
366
|
+
*/
|
|
367
|
+
private drizzleResultToRestRow(
|
|
368
|
+
row: Record<string, unknown>,
|
|
369
|
+
collection: CollectionConfig
|
|
370
|
+
): Record<string, unknown> {
|
|
371
|
+
const flat: Record<string, unknown> = {};
|
|
372
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
373
|
+
|
|
374
|
+
for (const [k, v] of Object.entries(row)) {
|
|
375
|
+
const relation = findRelation(resolvedRelations, k);
|
|
376
|
+
if (Array.isArray(v) && relation) {
|
|
377
|
+
// Many relation — inline each nested row, unwrapping junction rows
|
|
378
|
+
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
379
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
380
|
+
const nestedKey = Object.keys(item).find(
|
|
381
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
382
|
+
);
|
|
383
|
+
if (nestedKey) {
|
|
384
|
+
return { ...(item[nestedKey] as Record<string, unknown>) };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return { ...item };
|
|
388
|
+
});
|
|
389
|
+
} else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
|
|
390
|
+
// One-to-one relation — inline the target's columns
|
|
391
|
+
flat[k] = { ...(v as Record<string, unknown>) };
|
|
392
|
+
} else {
|
|
393
|
+
flat[k] = v;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return flat;
|
|
397
|
+
}
|
|
398
|
+
|
|
263
399
|
/**
|
|
264
400
|
* Build db.query-compatible options from standard fetch options.
|
|
265
401
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
@@ -401,7 +537,7 @@ export class FetchService {
|
|
|
401
537
|
): Promise<Record<string, unknown> | undefined> {
|
|
402
538
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
403
539
|
const table = getTableForCollection(collection, this.registry);
|
|
404
|
-
const idInfoArray =
|
|
540
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
405
541
|
const idInfo = idInfoArray[0];
|
|
406
542
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
407
543
|
|
|
@@ -428,7 +564,7 @@ export class FetchService {
|
|
|
428
564
|
|
|
429
565
|
if (!row) return undefined;
|
|
430
566
|
|
|
431
|
-
const flatRow =
|
|
567
|
+
const flatRow = this.drizzleResultToRow<M>(row, collection);
|
|
432
568
|
|
|
433
569
|
// Post-fetch joinPath relations that Drizzle's `with` can't express
|
|
434
570
|
await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
@@ -520,7 +656,7 @@ export class FetchService {
|
|
|
520
656
|
): Promise<Record<string, unknown>[]> {
|
|
521
657
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
522
658
|
const table = getTableForCollection(collection, this.registry);
|
|
523
|
-
const idInfoArray =
|
|
659
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
524
660
|
const idInfo = idInfoArray[0];
|
|
525
661
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
526
662
|
|
|
@@ -552,7 +688,7 @@ export class FetchService {
|
|
|
552
688
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
553
689
|
|
|
554
690
|
const rows = (results as Record<string, unknown>[]).map(row =>
|
|
555
|
-
|
|
691
|
+
this.drizzleResultToRow<M>(row, collection)
|
|
556
692
|
);
|
|
557
693
|
|
|
558
694
|
return rows;
|
|
@@ -652,10 +788,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
652
788
|
|
|
653
789
|
/**
|
|
654
790
|
* 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.
|
|
791
|
+
* The primary path uses drizzleResultToRow which handles relation
|
|
792
|
+
* mapping without N+1 queries.
|
|
659
793
|
*
|
|
660
794
|
* Process raw database results into flat rows with relations.
|
|
661
795
|
*/
|
|
@@ -959,7 +1093,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
959
1093
|
|
|
960
1094
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
961
1095
|
const table = getTableForCollection(collection, this.registry);
|
|
962
|
-
const idInfoArray =
|
|
1096
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
963
1097
|
const idInfo = idInfoArray[0];
|
|
964
1098
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
965
1099
|
const field = table[fieldName as keyof typeof table] as AnyPgColumn;
|
|
@@ -1019,7 +1153,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1019
1153
|
): Promise<Record<string, unknown>[]> {
|
|
1020
1154
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1021
1155
|
const table = getTableForCollection(collection, this.registry);
|
|
1022
|
-
const idInfoArray =
|
|
1156
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1023
1157
|
const idInfo = idInfoArray[0];
|
|
1024
1158
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1025
1159
|
|
|
@@ -1046,7 +1180,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1046
1180
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
1047
1181
|
|
|
1048
1182
|
const restRows = (results as Record<string, unknown>[]).map(row =>
|
|
1049
|
-
|
|
1183
|
+
this.drizzleResultToRestRow(row, collection)
|
|
1050
1184
|
);
|
|
1051
1185
|
|
|
1052
1186
|
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
@@ -1125,7 +1259,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1125
1259
|
): Promise<Record<string, unknown> | null> {
|
|
1126
1260
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1127
1261
|
const table = getTableForCollection(collection, this.registry);
|
|
1128
|
-
const idInfoArray =
|
|
1262
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1129
1263
|
const idInfo = idInfoArray[0];
|
|
1130
1264
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1131
1265
|
|
|
@@ -1151,7 +1285,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1151
1285
|
|
|
1152
1286
|
if (!row) return null;
|
|
1153
1287
|
|
|
1154
|
-
const restRow =
|
|
1288
|
+
const restRow = this.drizzleResultToRestRow(row, collection);
|
|
1155
1289
|
|
|
1156
1290
|
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
1157
1291
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
@@ -1233,7 +1367,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1233
1367
|
): Promise<Record<string, unknown>[]> {
|
|
1234
1368
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1235
1369
|
const table = getTableForCollection(collection, this.registry);
|
|
1236
|
-
const idInfoArray =
|
|
1370
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1237
1371
|
const idInfo = idInfoArray[0];
|
|
1238
1372
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1239
1373
|
|
|
@@ -383,15 +383,8 @@ export class PersistService {
|
|
|
383
383
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
384
384
|
}
|
|
385
385
|
|
|
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);
|
|
386
|
+
// Fetch the updated/created row to return with proper relation objects
|
|
387
|
+
const finalEntity = await this.fetchService.fetchOne<M>(collection.slug, savedId, databaseId);
|
|
395
388
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
396
389
|
return finalEntity;
|
|
397
390
|
}
|