@rebasepro/server-postgres 0.9.1-canary.73476f2 → 0.9.1-canary.7ba0e49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +25 -2
- package/dist/PostgresBootstrapper.d.ts +10 -0
- package/dist/auth/services.d.ts +16 -0
- 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 +1445 -571
- package/dist/index.es.js.map +1 -1
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/services/FetchService.d.ts +4 -24
- package/dist/services/PersistService.d.ts +27 -1
- package/dist/services/RelationService.d.ts +34 -1
- 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 +7 -0
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +11 -9
- package/src/PostgresBackendDriver.ts +127 -13
- package/src/PostgresBootstrapper.ts +62 -25
- package/src/auth/ensure-tables.ts +73 -11
- package/src/auth/services.ts +49 -19
- 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/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.ts +19 -2
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +65 -270
- package/src/services/PersistService.ts +130 -14
- package/src/services/RelationService.ts +153 -94
- 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 +40 -19
- package/src/services/row-pipeline.ts +215 -0
- package/src/utils/drizzle-conditions.ts +13 -0
- package/src/websocket.ts +4 -1
- package/dist/schema/auth-default-policies.d.ts +0 -10
- package/src/schema/auth-default-policies.ts +0 -132
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { eq, and } from "drizzle-orm";
|
|
2
|
-
import { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
1
|
+
import { eq, and, sql, SQL } from "drizzle-orm";
|
|
2
|
+
import { AnyPgColumn, PgTable } 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 { logger } from "@rebasepro/server";
|
|
19
|
+
import { ApiError, logger } from "@rebasepro/server";
|
|
20
20
|
import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage } from "../utils/pg-error-utils";
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -33,6 +33,49 @@ 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
|
+
|
|
36
79
|
/**
|
|
37
80
|
* Delete an row by ID
|
|
38
81
|
*/
|
|
@@ -52,9 +95,13 @@ export class PersistService {
|
|
|
52
95
|
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
53
96
|
}
|
|
54
97
|
|
|
55
|
-
await this.db
|
|
98
|
+
const result = await this.db
|
|
56
99
|
.delete(table)
|
|
57
100
|
.where(and(...conditions));
|
|
101
|
+
|
|
102
|
+
if ((result.rowCount ?? 0) === 0) {
|
|
103
|
+
throw await this.explainZeroRowWrite(this.db, table, conditions, collectionPath, id, "delete");
|
|
104
|
+
}
|
|
58
105
|
}
|
|
59
106
|
|
|
60
107
|
/**
|
|
@@ -68,12 +115,19 @@ export class PersistService {
|
|
|
68
115
|
|
|
69
116
|
/**
|
|
70
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.
|
|
71
124
|
*/
|
|
72
125
|
async save<M extends Record<string, unknown>>(
|
|
73
126
|
collectionPath: string,
|
|
74
127
|
values: Partial<M>,
|
|
75
128
|
id?: string | number,
|
|
76
|
-
databaseId?: string
|
|
129
|
+
databaseId?: string,
|
|
130
|
+
options?: { upsert?: boolean }
|
|
77
131
|
): Promise<Record<string, unknown>> {
|
|
78
132
|
// If saving under a nested relation path, resolve the parent and inject FK
|
|
79
133
|
let effectiveCollectionPath = collectionPath;
|
|
@@ -208,7 +262,7 @@ export class PersistService {
|
|
|
208
262
|
savedId = await this.db.transaction(async (tx) => {
|
|
209
263
|
let currentId: string | number;
|
|
210
264
|
|
|
211
|
-
if (id) {
|
|
265
|
+
if (id && !options?.upsert) {
|
|
212
266
|
// Update existing row
|
|
213
267
|
currentId = id; // `id` is already the formatted composite or singular string
|
|
214
268
|
const idValues = parseIdValues(id, idInfoArray);
|
|
@@ -235,11 +289,25 @@ export class PersistService {
|
|
|
235
289
|
conditions.push(eq(field, idValues[info.fieldName]));
|
|
236
290
|
}
|
|
237
291
|
|
|
238
|
-
await updateQuery.where(and(...conditions));
|
|
292
|
+
const updateResult = await updateQuery.where(and(...conditions));
|
|
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
|
+
}
|
|
239
301
|
}
|
|
240
302
|
} else {
|
|
241
303
|
const dataForInsert = { ...(entityData as Record<string, unknown>) };
|
|
242
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
|
+
|
|
243
311
|
// Strip empty primary keys so the database defaults (e.g. uuid_gen(), auto-increment) can trigger
|
|
244
312
|
for (const info of idInfoArray) {
|
|
245
313
|
if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === undefined) {
|
|
@@ -247,13 +315,46 @@ export class PersistService {
|
|
|
247
315
|
}
|
|
248
316
|
}
|
|
249
317
|
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
}
|
|
254
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.
|
|
255
345
|
const resultRow = result[0];
|
|
256
|
-
|
|
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
|
+
}
|
|
257
358
|
|
|
258
359
|
// For inserts, apply joinPath after since the parent row didn't exist before
|
|
259
360
|
if (joinPathRelationUpdates.length > 0) {
|
|
@@ -282,8 +383,15 @@ export class PersistService {
|
|
|
282
383
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
283
384
|
}
|
|
284
385
|
|
|
285
|
-
// Fetch the
|
|
286
|
-
|
|
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);
|
|
287
395
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
288
396
|
return finalEntity;
|
|
289
397
|
}
|
|
@@ -306,6 +414,14 @@ export class PersistService {
|
|
|
306
414
|
* Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
|
|
307
415
|
*/
|
|
308
416
|
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
|
+
|
|
309
425
|
const pgError = extractPgError(error);
|
|
310
426
|
|
|
311
427
|
if (pgError) {
|