@rebasepro/server-postgres 0.9.1-canary.682a9cf → 0.9.1-canary.73476f2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +2 -25
- package/dist/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 +561 -1454
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/services/FetchService.d.ts +24 -4
- package/dist/services/PersistService.d.ts +1 -27
- 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 +9 -11
- 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/doctor.ts +20 -45
- 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 +14 -130
- 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 -239
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { eq, and
|
|
2
|
-
import { AnyPgColumn
|
|
1
|
+
import { eq, and } from "drizzle-orm";
|
|
2
|
+
import { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
3
3
|
// import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { CollectionConfig, Properties, Relation } from "@rebasepro/types";
|
|
5
5
|
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
@@ -16,7 +16,7 @@ import { RelationService } from "./RelationService";
|
|
|
16
16
|
import { FetchService } from "./FetchService";
|
|
17
17
|
import { DrizzleClient } from "../interfaces";
|
|
18
18
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
19
|
-
import {
|
|
19
|
+
import { logger } from "@rebasepro/server";
|
|
20
20
|
import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage } from "../utils/pg-error-utils";
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -33,49 +33,6 @@ export class PersistService {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Explain a write that matched no rows.
|
|
38
|
-
*
|
|
39
|
-
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
40
|
-
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
41
|
-
* like a successful one that happened to match nothing. Left unchecked, a
|
|
42
|
-
* caller cannot tell "denied" from "done" — the write returns 200/204 and
|
|
43
|
-
* the row is untouched.
|
|
44
|
-
*
|
|
45
|
-
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
46
|
-
* cases. A visible row means the policy rejected the write (403); an
|
|
47
|
-
* invisible one means there is nothing there to write for this caller (404,
|
|
48
|
-
* matching what a GET would say). The re-read is bound by the caller's own
|
|
49
|
-
* policies, so it discloses nothing a plain read wouldn't.
|
|
50
|
-
*
|
|
51
|
-
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
52
|
-
*/
|
|
53
|
-
private async explainZeroRowWrite(
|
|
54
|
-
handle: DrizzleClient,
|
|
55
|
-
table: PgTable,
|
|
56
|
-
conditions: SQL[],
|
|
57
|
-
collectionPath: string,
|
|
58
|
-
id: string | number,
|
|
59
|
-
operation: "update" | "delete"
|
|
60
|
-
): Promise<ApiError> {
|
|
61
|
-
const visible = await handle
|
|
62
|
-
.select({ present: sql<number>`1` })
|
|
63
|
-
.from(table)
|
|
64
|
-
.where(and(...conditions))
|
|
65
|
-
.limit(1);
|
|
66
|
-
|
|
67
|
-
if (visible.length > 0) {
|
|
68
|
-
return ApiError.forbidden(
|
|
69
|
-
`Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`,
|
|
70
|
-
"WRITE_DENIED"
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return ApiError.notFound(
|
|
75
|
-
`No row "${id}" in "${collectionPath}" to ${operation}.`
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
36
|
/**
|
|
80
37
|
* Delete an row by ID
|
|
81
38
|
*/
|
|
@@ -95,13 +52,9 @@ export class PersistService {
|
|
|
95
52
|
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
96
53
|
}
|
|
97
54
|
|
|
98
|
-
|
|
55
|
+
await this.db
|
|
99
56
|
.delete(table)
|
|
100
57
|
.where(and(...conditions));
|
|
101
|
-
|
|
102
|
-
if ((result.rowCount ?? 0) === 0) {
|
|
103
|
-
throw await this.explainZeroRowWrite(this.db, table, conditions, collectionPath, id, "delete");
|
|
104
|
-
}
|
|
105
58
|
}
|
|
106
59
|
|
|
107
60
|
/**
|
|
@@ -115,19 +68,12 @@ export class PersistService {
|
|
|
115
68
|
|
|
116
69
|
/**
|
|
117
70
|
* Save an row (create or update)
|
|
118
|
-
*
|
|
119
|
-
* With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
|
|
120
|
-
* UPDATE against the primary key rather than a plain UPDATE. That is one
|
|
121
|
-
* statement, so it cannot lose a race the way a read-then-write can, and it
|
|
122
|
-
* does not care whether the row already exists — which is what a re-runnable
|
|
123
|
-
* import needs.
|
|
124
71
|
*/
|
|
125
72
|
async save<M extends Record<string, unknown>>(
|
|
126
73
|
collectionPath: string,
|
|
127
74
|
values: Partial<M>,
|
|
128
75
|
id?: string | number,
|
|
129
|
-
databaseId?: string
|
|
130
|
-
options?: { upsert?: boolean }
|
|
76
|
+
databaseId?: string
|
|
131
77
|
): Promise<Record<string, unknown>> {
|
|
132
78
|
// If saving under a nested relation path, resolve the parent and inject FK
|
|
133
79
|
let effectiveCollectionPath = collectionPath;
|
|
@@ -262,7 +208,7 @@ export class PersistService {
|
|
|
262
208
|
savedId = await this.db.transaction(async (tx) => {
|
|
263
209
|
let currentId: string | number;
|
|
264
210
|
|
|
265
|
-
if (id
|
|
211
|
+
if (id) {
|
|
266
212
|
// Update existing row
|
|
267
213
|
currentId = id; // `id` is already the formatted composite or singular string
|
|
268
214
|
const idValues = parseIdValues(id, idInfoArray);
|
|
@@ -289,25 +235,11 @@ export class PersistService {
|
|
|
289
235
|
conditions.push(eq(field, idValues[info.fieldName]));
|
|
290
236
|
}
|
|
291
237
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
// Throwing rolls the transaction back, so relation writes
|
|
295
|
-
// already applied above do not survive a rejected update.
|
|
296
|
-
if ((updateResult.rowCount ?? 0) === 0) {
|
|
297
|
-
throw await this.explainZeroRowWrite(
|
|
298
|
-
tx, table, conditions, effectiveCollectionPath, currentId, "update"
|
|
299
|
-
);
|
|
300
|
-
}
|
|
238
|
+
await updateQuery.where(and(...conditions));
|
|
301
239
|
}
|
|
302
240
|
} else {
|
|
303
241
|
const dataForInsert = { ...(entityData as Record<string, unknown>) };
|
|
304
242
|
|
|
305
|
-
// An explicit id given alongside upsert is the conflict target,
|
|
306
|
-
// so fold it into the row before the empty-key strip below.
|
|
307
|
-
if (id && options?.upsert) {
|
|
308
|
-
Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
|
|
309
|
-
}
|
|
310
|
-
|
|
311
243
|
// Strip empty primary keys so the database defaults (e.g. uuid_gen(), auto-increment) can trigger
|
|
312
244
|
for (const info of idInfoArray) {
|
|
313
245
|
if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === undefined) {
|
|
@@ -315,46 +247,13 @@ export class PersistService {
|
|
|
315
247
|
}
|
|
316
248
|
}
|
|
317
249
|
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// column present there is nothing to match on, so the row is a
|
|
323
|
-
// plain insert and a duplicate should still raise.
|
|
324
|
-
const hasFullKey = idInfoArray.length > 0
|
|
325
|
-
&& idInfoArray.every((info) => dataForInsert[info.fieldName] !== undefined);
|
|
326
|
-
|
|
327
|
-
let result;
|
|
328
|
-
if (options?.upsert && hasFullKey) {
|
|
329
|
-
const target = idInfoArray.map((info) => table[info.fieldName as keyof typeof table] as AnyPgColumn);
|
|
330
|
-
const set = { ...dataForInsert };
|
|
331
|
-
// Never reassign the key columns to themselves in the UPDATE
|
|
332
|
-
// branch; Postgres rejects that against the conflict target.
|
|
333
|
-
for (const info of idInfoArray) delete set[info.fieldName];
|
|
334
|
-
|
|
335
|
-
result = Object.keys(set).length > 0
|
|
336
|
-
? await insertQuery.onConflictDoUpdate({ target, set }).returning(returningKeys)
|
|
337
|
-
: await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
|
|
338
|
-
} else {
|
|
339
|
-
result = await insertQuery.returning(returningKeys);
|
|
340
|
-
}
|
|
250
|
+
const result = await tx
|
|
251
|
+
.insert(table)
|
|
252
|
+
.values(dataForInsert)
|
|
253
|
+
.returning(returningKeys);
|
|
341
254
|
|
|
342
|
-
// DO NOTHING returns no row when it skipped, and an upsert whose
|
|
343
|
-
// UPDATE branch is filtered by RLS returns none either. Fall back
|
|
344
|
-
// to the id we were given rather than reading undefined.
|
|
345
255
|
const resultRow = result[0];
|
|
346
|
-
|
|
347
|
-
if (id) {
|
|
348
|
-
currentId = id;
|
|
349
|
-
} else {
|
|
350
|
-
throw ApiError.forbidden(
|
|
351
|
-
`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`,
|
|
352
|
-
"WRITE_DENIED"
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
} else {
|
|
356
|
-
currentId = buildCompositeId(resultRow, idInfoArray);
|
|
357
|
-
}
|
|
256
|
+
currentId = buildCompositeId(resultRow, idInfoArray);
|
|
358
257
|
|
|
359
258
|
// For inserts, apply joinPath after since the parent row didn't exist before
|
|
360
259
|
if (joinPathRelationUpdates.length > 0) {
|
|
@@ -383,15 +282,8 @@ export class PersistService {
|
|
|
383
282
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
384
283
|
}
|
|
385
284
|
|
|
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);
|
|
285
|
+
// Fetch the updated/created row to return with proper relation objects
|
|
286
|
+
const finalEntity = await this.fetchService.fetchOne<M>(collection.slug, savedId, databaseId);
|
|
395
287
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
396
288
|
return finalEntity;
|
|
397
289
|
}
|
|
@@ -414,14 +306,6 @@ export class PersistService {
|
|
|
414
306
|
* Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
|
|
415
307
|
*/
|
|
416
308
|
private toUserFriendlyError(error: unknown, collectionSlug: string): Error {
|
|
417
|
-
// Deliberate API errors already carry their own status, code and wording.
|
|
418
|
-
// Re-wrapping one flattens it into a generic Error, and the status is lost
|
|
419
|
-
// on the way out — a policy rejection would surface as a 500. Matched by
|
|
420
|
-
// name as well as instance: a duplicated module copy breaks `instanceof`.
|
|
421
|
-
if (error instanceof ApiError || (error as Error)?.name === "ApiError") {
|
|
422
|
-
return error as Error;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
309
|
const pgError = extractPgError(error);
|
|
426
310
|
|
|
427
311
|
if (pgError) {
|