@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.
Files changed (43) hide show
  1. package/dist/PostgresBackendDriver.d.ts +25 -2
  2. package/dist/PostgresBootstrapper.d.ts +10 -0
  3. package/dist/auth/services.d.ts +16 -0
  4. package/dist/collections/buildRegistry.d.ts +27 -0
  5. package/dist/connection.d.ts +21 -0
  6. package/dist/data-transformer.d.ts +9 -2
  7. package/dist/index.es.js +1445 -571
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/schema/doctor.d.ts +1 -1
  10. package/dist/services/FetchService.d.ts +4 -24
  11. package/dist/services/PersistService.d.ts +27 -1
  12. package/dist/services/RelationService.d.ts +34 -1
  13. package/dist/services/collection-helpers.d.ts +79 -14
  14. package/dist/services/dataService.d.ts +3 -1
  15. package/dist/services/index.d.ts +1 -1
  16. package/dist/services/realtimeService.d.ts +7 -0
  17. package/dist/services/row-pipeline.d.ts +63 -0
  18. package/package.json +11 -9
  19. package/src/PostgresBackendDriver.ts +127 -13
  20. package/src/PostgresBootstrapper.ts +62 -25
  21. package/src/auth/ensure-tables.ts +73 -11
  22. package/src/auth/services.ts +49 -19
  23. package/src/collections/buildRegistry.ts +59 -0
  24. package/src/connection.ts +61 -1
  25. package/src/data-transformer.ts +11 -9
  26. package/src/databasePoolManager.ts +2 -0
  27. package/src/schema/doctor.ts +45 -20
  28. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  29. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  30. package/src/schema/introspect-db.ts +19 -2
  31. package/src/services/BranchService.ts +42 -10
  32. package/src/services/FetchService.ts +65 -270
  33. package/src/services/PersistService.ts +130 -14
  34. package/src/services/RelationService.ts +153 -94
  35. package/src/services/collection-helpers.ts +164 -47
  36. package/src/services/dataService.ts +3 -2
  37. package/src/services/index.ts +1 -0
  38. package/src/services/realtimeService.ts +40 -19
  39. package/src/services/row-pipeline.ts +215 -0
  40. package/src/utils/drizzle-conditions.ts +13 -0
  41. package/src/websocket.ts +4 -1
  42. package/dist/schema/auth-default-policies.d.ts +0 -10
  43. 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 result = await tx
251
- .insert(table)
252
- .values(dataForInsert)
253
- .returning(returningKeys);
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
- currentId = buildCompositeId(resultRow, idInfoArray);
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 updated/created row to return with proper relation objects
286
- const finalEntity = await this.fetchService.fetchOne<M>(collection.slug, savedId, databaseId);
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) {