@rebasepro/server-postgres 0.9.1-canary.f0ac103 → 0.9.1-canary.fd3754b

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.
Files changed (51) hide show
  1. package/dist/PostgresBackendDriver.d.ts +2 -25
  2. package/dist/PostgresBootstrapper.d.ts +0 -10
  3. package/dist/auth/services.d.ts +0 -16
  4. package/dist/chunk-DSJWtz9O.js +40 -0
  5. package/dist/connection.d.ts +0 -21
  6. package/dist/data-transformer.d.ts +2 -9
  7. package/dist/index.es.js +2604 -2007
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/schema/auth-default-policies.d.ts +10 -0
  10. package/dist/security/policy-drift.d.ts +5 -16
  11. package/dist/security/rls-enforcement.d.ts +2 -27
  12. package/dist/services/FetchService.d.ts +24 -4
  13. package/dist/services/PersistService.d.ts +1 -27
  14. package/dist/services/RelationService.d.ts +1 -34
  15. package/dist/services/collection-helpers.d.ts +14 -79
  16. package/dist/services/dataService.d.ts +1 -3
  17. package/dist/services/index.d.ts +1 -1
  18. package/dist/services/realtimeService.d.ts +0 -7
  19. package/dist/src-Eh-CZosp.js +595 -0
  20. package/dist/src-Eh-CZosp.js.map +1 -0
  21. package/package.json +17 -15
  22. package/src/PostgresBackendDriver.ts +13 -127
  23. package/src/PostgresBootstrapper.ts +26 -68
  24. package/src/auth/ensure-tables.ts +11 -73
  25. package/src/auth/services.ts +19 -49
  26. package/src/cli-helpers.ts +20 -2
  27. package/src/connection.ts +1 -61
  28. package/src/data-transformer.ts +9 -11
  29. package/src/databasePoolManager.ts +0 -2
  30. package/src/schema/auth-default-policies.ts +125 -0
  31. package/src/schema/doctor-cli.ts +1 -5
  32. package/src/schema/generate-drizzle-schema-logic.ts +29 -24
  33. package/src/schema/generate-postgres-ddl-logic.ts +28 -76
  34. package/src/schema/introspect-db.ts +2 -19
  35. package/src/security/policy-drift.test.ts +14 -48
  36. package/src/security/policy-drift.ts +10 -72
  37. package/src/security/rls-enforcement.ts +3 -64
  38. package/src/services/BranchService.ts +10 -42
  39. package/src/services/FetchService.ts +270 -65
  40. package/src/services/PersistService.ts +14 -130
  41. package/src/services/RelationService.ts +94 -153
  42. package/src/services/collection-helpers.ts +47 -164
  43. package/src/services/dataService.ts +2 -3
  44. package/src/services/index.ts +0 -1
  45. package/src/services/realtimeService.ts +19 -40
  46. package/src/utils/drizzle-conditions.ts +0 -13
  47. package/src/websocket.ts +1 -4
  48. package/dist/collections/buildRegistry.d.ts +0 -27
  49. package/dist/services/row-pipeline.d.ts +0 -63
  50. package/src/collections/buildRegistry.ts +0 -59
  51. package/src/services/row-pipeline.ts +0 -215
@@ -1,5 +1,5 @@
1
- import { eq, and, sql, SQL } from "drizzle-orm";
2
- import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
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 { ApiError, logger } from "@rebasepro/server";
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
- const result = await this.db
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 && !options?.upsert) {
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
- 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
- }
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 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
- }
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
- 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
- }
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 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);
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) {