@rebasepro/server-postgres 0.15.0 → 0.16.0

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 (45) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +0 -11
  3. package/dist/auth/services.d.ts +2 -2
  4. package/dist/{auth-users-columns-JJ8ngvy5.js → auth-users-columns-CgyPWQ18.js} +3 -3
  5. package/dist/auth-users-columns-CgyPWQ18.js.map +1 -0
  6. package/dist/backup/backup-logic.d.ts +20 -0
  7. package/dist/backup/backup-service.d.ts +6 -0
  8. package/dist/backup/retention.d.ts +11 -0
  9. package/dist/{backup-service-czK-OAuG.js → backup-service-BZoixhVl.js} +56 -11
  10. package/dist/backup-service-BZoixhVl.js.map +1 -0
  11. package/dist/collections-schema-version-BMeu3cgv.js +81 -0
  12. package/dist/collections-schema-version-BMeu3cgv.js.map +1 -0
  13. package/dist/{ensure-collection-policies-D5PtQLyR.js → ensure-collection-policies-BVFb2olB.js} +4 -3
  14. package/dist/ensure-collection-policies-BVFb2olB.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BHUjQ-z4.js → ensure-collection-tables-BY1pHRD_.js} +2 -2
  16. package/dist/{ensure-collection-tables-BHUjQ-z4.js.map → ensure-collection-tables-BY1pHRD_.js.map} +1 -1
  17. package/dist/index.es.js +65 -21
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/{rls-enforcement-BDBfuTD4.js → rls-enforcement-Ch0T6OwW.js} +7 -7
  20. package/dist/rls-enforcement-Ch0T6OwW.js.map +1 -0
  21. package/dist/schema/collections-schema-version.d.ts +32 -0
  22. package/dist/security/policy-drift.d.ts +2 -2
  23. package/dist/security/rls-enforcement.d.ts +6 -6
  24. package/dist/services/realtimeService.d.ts +3 -1
  25. package/dist/src-BBFsDaeA.js.map +1 -1
  26. package/dist/websocket-BVgDVO-V.js.map +1 -1
  27. package/package.json +6 -6
  28. package/src/PostgresAdapter.ts +14 -0
  29. package/src/PostgresBackendDriver.ts +3 -3
  30. package/src/PostgresBootstrapper.ts +82 -12
  31. package/src/auth/services.ts +2 -2
  32. package/src/backup/backup-cli.ts +49 -12
  33. package/src/backup/backup-logic.ts +31 -0
  34. package/src/backup/backup-service.ts +42 -8
  35. package/src/backup/pg-tools.ts +12 -1
  36. package/src/backup/retention.ts +11 -0
  37. package/src/schema/collections-schema-version.ts +103 -0
  38. package/src/security/policy-drift.test.ts +25 -6
  39. package/src/security/policy-drift.ts +14 -6
  40. package/src/security/rls-enforcement.ts +7 -7
  41. package/src/services/realtimeService.ts +9 -1
  42. package/dist/auth-users-columns-JJ8ngvy5.js.map +0 -1
  43. package/dist/backup-service-czK-OAuG.js.map +0 -1
  44. package/dist/ensure-collection-policies-D5PtQLyR.js.map +0 -1
  45. package/dist/rls-enforcement-BDBfuTD4.js.map +0 -1
@@ -238,6 +238,16 @@ export function isScaffoldedLocalDatabase(connectionString: string | undefined):
238
238
  * });
239
239
  * ```
240
240
  */
241
+ /**
242
+ * Where the collections schema stamp lives.
243
+ *
244
+ * The runtime's own internal schema, always — unlike the auth stamp, which
245
+ * follows the users collection. `rebase` and `auth` sit outside
246
+ * `introspectionSchema` by construction, so nothing here is ever served as a
247
+ * collection.
248
+ */
249
+ const SCHEMA_META_SCHEMA = "rebase";
250
+
241
251
  export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): BackendBootstrapper {
242
252
  // Applied at construction rather than threaded through every read: the
243
253
  // condition builder's static methods are reached from call sites that
@@ -262,6 +272,19 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
262
272
  * builder, not to `execute(sql.raw(...))` — and every statement these hooks
263
273
  * emit is schema-qualified DDL, so neither depends on `search_path`.
264
274
  */
275
+ /**
276
+ * The drizzle handle itself, for the statements that want parameters.
277
+ *
278
+ * `provisioningQueryable` below hands back a `query(text)` shim because the
279
+ * DDL it serves is built as text. The schema stamp writes a *value*, so it
280
+ * wants the parameterised form — building that string by hand would be one
281
+ * more place a quoted literal has to be got right for no benefit.
282
+ */
283
+ const provisioningDb = (driverResult?: InitializedDriver) => {
284
+ const internals = driverResult?.internals as PostgresDriverInternals | undefined;
285
+ return internals?.db ?? (pgConfig.connection as PostgresDriverInternals["db"] | undefined);
286
+ };
287
+
265
288
  const provisioningQueryable = (driverResult?: InitializedDriver) => {
266
289
  const internals = driverResult?.internals as PostgresDriverInternals | undefined;
267
290
  const db = internals?.db ?? (pgConfig.connection as PostgresDriverInternals["db"] | undefined);
@@ -287,13 +310,20 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
287
310
  async initializeDriver(config: unknown): Promise<InitializedDriver> {
288
311
  // config is passed from coordinator, we merge it with our internal pgConfig if needed
289
312
  // Currently config from init.ts is `{ collections, collectionRegistry, mode }`
290
- const { collections, collectionRegistry, introspectCollections, baas, schemaProvisioning } = config as {
313
+ const { collections, collectionRegistry, introspectCollections, baas, schemaProvisioning, realtime } = config as {
291
314
  collections?: CollectionConfig[];
292
315
  collectionRegistry?: unknown;
293
316
  introspectCollections?: boolean;
294
317
  baas?: { unprotectedTables?: "exclude" | "serve" };
295
318
  schemaProvisioning?: { attempted: boolean; reason?: string };
319
+ realtime?: { subscribe: boolean; provision: boolean };
296
320
  };
321
+ // Absent means a caller that predates the field, and every one of
322
+ // those is a single process that both serves websockets and owns the
323
+ // schema. Defaulting to false here would silently disable realtime
324
+ // for them — the failure this whole area is prone to.
325
+ const realtimeSubscribes = realtime?.subscribe ?? true;
326
+ const realtimeProvisions = realtime?.provision ?? true;
297
327
  // Secure by default: a table with no RLS is not served.
298
328
  const unprotectedTables = baas?.unprotectedTables ?? "exclude";
299
329
 
@@ -570,7 +600,10 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
570
600
  // and leaves broadcast on its original fire-and-forget path, so
571
601
  // presence-only apps pay nothing for it.
572
602
  try {
573
- await realtimeService.configureChannelHistory(pgConfig.realtime?.channels);
603
+ await realtimeService.configureChannelHistory(
604
+ pgConfig.realtime?.channels,
605
+ { provision: realtimeProvisions }
606
+ );
574
607
  } catch (err) {
575
608
  logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
576
609
  }
@@ -622,7 +655,11 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
622
655
 
623
656
  // `auto` tries CDC but treats "can't" as a normal outcome (info log);
624
657
  // explicit trigger/wal was asked for, so a failure is worth a warning.
625
- const wantsCdc = cdcMode !== "off";
658
+ // A process that consumes nothing needs no capture *started*, and a
659
+ // process that does not own the schema installs no triggers. They
660
+ // come apart: the `api` in a split with an external migration Job
661
+ // subscribes without provisioning, and both answers are correct.
662
+ const wantsCdc = cdcMode !== "off" && (realtimeSubscribes || realtimeProvisions);
626
663
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
627
664
  let cdcEnabled = false;
628
665
  let provisionCdcForTables: PostgresDriverInternals["provisionCdcForTables"];
@@ -664,17 +701,29 @@ table: link.table });
664
701
  // Provisioning throws only when the connection can't create the
665
702
  // trigger function (insufficient privilege); enableCdc throws when
666
703
  // the LISTEN connection can't be established. Either → fall back.
667
- await provisionTriggerCdc(cdcRunSql, cdcTables);
668
- await realtimeService.enableCdc(directUrl);
704
+ if (realtimeProvisions) await provisionTriggerCdc(cdcRunSql, cdcTables);
705
+ if (realtimeSubscribes) await realtimeService.enableCdc(directUrl);
669
706
  cdcEnabled = true;
670
707
  // Boot steps that create their own tables (auth) run after
671
708
  // this one and use it to instrument what they just created.
672
- provisionCdcForTables = async (tables) => {
673
- await provisionTriggerCdc(cdcRunSql, tables);
674
- };
709
+ // Left undefined where this process installs nothing, so a
710
+ // later boot step cannot re-enter the DDL path by the side
711
+ // door — the callers already treat it as optional, because a
712
+ // driver without CDC never sets it either.
713
+ if (realtimeProvisions) {
714
+ provisionCdcForTables = async (tables) => {
715
+ await provisionTriggerCdc(cdcRunSql, tables);
716
+ };
717
+ }
718
+ // Say which half ran. "All writes now emit realtime events"
719
+ // is a claim about the database and stays true for a process
720
+ // that only installed the triggers; what changes is whether
721
+ // *this* process is listening, and an operator reading one
722
+ // pod's log should not have to infer that from its role.
675
723
  logger.info(
676
724
  `📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). ` +
677
- `All writes now emit realtime events regardless of origin.`
725
+ `All writes now emit realtime events regardless of origin.` +
726
+ (realtimeSubscribes ? "" : " This process installs the capture but does not consume it.")
678
727
  );
679
728
  } catch (err) {
680
729
  if (explicitCdc) {
@@ -691,7 +740,7 @@ table: link.table });
691
740
 
692
741
  // Legacy cross-instance realtime (app-level). Skipped when CDC is
693
742
  // active because CDC already spans instances.
694
- if (!cdcEnabled && directUrl) {
743
+ if (!cdcEnabled && directUrl && realtimeSubscribes) {
695
744
  try {
696
745
  await realtimeService.startListening(directUrl);
697
746
  } catch (err) {
@@ -981,8 +1030,8 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
981
1030
  *
982
1031
  * The companion to {@link ensureCollectionSchema}: that creates the
983
1032
  * tables, this makes them servable. Boot runs it *after* auth
984
- * initialization, because the generated policies call `auth.uid()` /
985
- * `auth.roles()`, and `CREATE POLICY` validates those functions exist.
1033
+ * initialization, because the generated policies call `rebase.uid()` /
1034
+ * `rebase.roles()`, and `CREATE POLICY` validates those functions exist.
986
1035
  *
987
1036
  * Runs through the same drizzle handle, one statement at a time (that
988
1037
  * handle speaks the extended query protocol, which rejects multi-command
@@ -1062,6 +1111,27 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
1062
1111
  return { applied: outcome.policiesApplied };
1063
1112
  },
1064
1113
 
1114
+ /**
1115
+ * Read what the last provisioning boot recorded, or `null`.
1116
+ *
1117
+ * The meta schema is `rebase` rather than the auth stamp's — see
1118
+ * `schema/collections-schema-version.ts` for why the two can differ.
1119
+ */
1120
+ async readCollectionsSchemaVersion(driverResult?: InitializedDriver): Promise<string | null> {
1121
+ const db = provisioningDb(driverResult);
1122
+ if (!db) return null;
1123
+ const { readCollectionsSchemaVersion } = await import("./schema/collections-schema-version");
1124
+ return readCollectionsSchemaVersion(db as never, SCHEMA_META_SCHEMA);
1125
+ },
1126
+
1127
+ /** Record what this process just applied. Only the provisioning process calls this. */
1128
+ async stampCollectionsSchemaVersion(version: string, driverResult?: InitializedDriver): Promise<void> {
1129
+ const db = provisioningDb(driverResult);
1130
+ if (!db) return;
1131
+ const { stampCollectionsSchemaVersion } = await import("./schema/collections-schema-version");
1132
+ await stampCollectionsSchemaVersion(db as never, SCHEMA_META_SCHEMA, version);
1133
+ },
1134
+
1065
1135
  getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
1066
1136
  const internals = driverResult.internals as PostgresDriverInternals;
1067
1137
  return internals.driver.admin;
@@ -112,7 +112,7 @@ export class UserService implements UserRepository {
112
112
  * Run a privileged auth write with an explicitly cleared RLS context.
113
113
  *
114
114
  * The auth services run on the base/owner connection, which by design
115
- * carries a NULL `app.uid` so the `auth.uid() IS NULL` server-escape
115
+ * carries a NULL `app.uid` so the `rebase.uid() IS NULL` server-escape
116
116
  * in the default policies applies. That NULL is normally guaranteed by
117
117
  * `set_config(..., is_local = true)` resetting at transaction end — but a
118
118
  * GUC that survives on a pooled connection (or a connection role that
@@ -120,7 +120,7 @@ export class UserService implements UserRepository {
120
120
  * turns the trusted write into an RLS-scoped one and denies it with
121
121
  * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
122
122
  * single chokepoint, makes the server context deterministic instead of
123
- * trusting whatever state the pool hands us. `auth.uid()` reads '' as
123
+ * trusting whatever state the pool hands us. `rebase.uid()` reads '' as
124
124
  * NULL via NULLIF, so '' is the server context.
125
125
  */
126
126
  private async withServerContext<T>(fn: (db: NodePgDatabase) => Promise<T>): Promise<T> {
@@ -25,6 +25,7 @@ import {
25
25
  applyGlobals,
26
26
  BackupToolError,
27
27
  createDump,
28
+ discardPartialDump,
28
29
  ensureDatabaseExists,
29
30
  listBackups,
30
31
  preflight,
@@ -285,18 +286,21 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
285
286
  process.exit(1);
286
287
  }
287
288
 
288
- // Create the target database when requested.
289
- if (args["--create-db"]) {
290
- if (!targetDb) {
291
- outError(chalk.red(" ✗ --create-db requires a resolvable target database name (use --target-db)."));
292
- process.exit(1);
293
- }
294
- const created = await ensureDatabaseExists(baseConnection, targetDb);
295
- print(chalk.gray(created ? ` ✓ Created database "${targetDb}".` : ` • Database "${targetDb}" already exists.`));
289
+ // `--create-db` needs a name before anything else can be decided.
290
+ if (args["--create-db"] && !targetDb) {
291
+ outError(chalk.red(" ✗ --create-db requires a resolvable target database name (use --target-db)."));
292
+ process.exit(1);
296
293
  }
297
294
 
298
295
  // Destructive-action gate. Restores overwrite data; never run without
299
296
  // an explicit yes (interactive confirmation or --yes).
297
+ //
298
+ // Ahead of `--create-db`, not after it. Creating the database first
299
+ // meant an aborted run had already changed the cluster, while printing
300
+ // "No changes were made" — and it left an empty database behind that a
301
+ // second, confirmed run then reported as "already exists". The gate is
302
+ // now the first thing that can stop the command, so its own message is
303
+ // true whichever way the answer goes.
300
304
  if (!args["--yes"]) {
301
305
  outWarn(chalk.yellow(
302
306
  ` ⚠️ This will restore into "${targetDb ?? "the target database"}" and may overwrite existing data.`
@@ -308,6 +312,12 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
308
312
  }
309
313
  }
310
314
 
315
+ // Create the target database when requested.
316
+ if (args["--create-db"]) {
317
+ const created = await ensureDatabaseExists(baseConnection, targetDb!);
318
+ print(chalk.gray(created ? ` ✓ Created database "${targetDb}".` : ` • Database "${targetDb}" already exists.`));
319
+ }
320
+
311
321
  // Recreate cluster roles before restoring so GRANT/RLS statements in
312
322
  // the dump apply. Best-effort and idempotent (see applyGlobals).
313
323
  if (globalsSql) {
@@ -377,7 +387,23 @@ export async function backupsCommand(rawArgs: string[]): Promise<void> {
377
387
  for (const b of backups) {
378
388
  const when = b.createdAt ? b.createdAt.toISOString() : "unknown date";
379
389
  const name = dest.kind === "local" ? path.basename(b.key) : b.key;
380
- print(` ${chalk.green("●")} ${chalk.bold(name)} ${chalk.gray(`— ${when}`)}`);
390
+ // An empty file is called out rather than listed as a peer of
391
+ // the real ones. Older failures could leave a 0-byte dump here
392
+ // (fixed at the source now), and retention protects the newest
393
+ // by date whatever they contain — so a corpse left in place can
394
+ // hold a `keepMinimum` slot against a backup that matters.
395
+ const empty = b.sizeBytes === 0;
396
+ const size = b.sizeBytes === undefined ? "" : ` — ${formatBytes(b.sizeBytes)}`;
397
+ if (empty) {
398
+ print(` ${chalk.red("○")} ${chalk.bold(name)} ${chalk.gray(`— ${when}`)}${chalk.red(" — EMPTY, not restorable")}`);
399
+ } else {
400
+ print(` ${chalk.green("●")} ${chalk.bold(name)} ${chalk.gray(`— ${when}${size}`)}`);
401
+ }
402
+ }
403
+ if (backups.some(b => b.sizeBytes === 0)) {
404
+ print("");
405
+ print(chalk.yellow(" ⚠ Empty files above are leftovers from a failed backup. Delete them:"));
406
+ print(chalk.gray(" they count as recent backups for retention but restore nothing."));
381
407
  }
382
408
  }
383
409
  print("");
@@ -387,13 +413,24 @@ export async function backupsCommand(rawArgs: string[]): Promise<void> {
387
413
  }
388
414
  }
389
415
 
390
- /** Verify a freshly written dump; abort the command if it looks corrupt. */
416
+ /**
417
+ * Verify a freshly written dump; abort the command if it looks corrupt.
418
+ *
419
+ * Discards the artifact on the way out. Refusing to *report* success was never
420
+ * enough on its own: the file stayed on disk, `rebase db backups list` showed
421
+ * it as an ordinary entry, and retention — which ranks by timestamp and never
422
+ * looks at size — would protect it as one of the `keepMinimum` newest while
423
+ * pruning a real backup underneath it. The roles sidecar goes too; the two are
424
+ * uploaded and pruned as a pair, so half a pair is not a backup either.
425
+ */
391
426
  async function assertDumpValid(localFile: string): Promise<void> {
392
427
  const check = await validateDump(localFile);
393
428
  if (!check.ok) {
429
+ discardPartialDump(globalsFileForDump(localFile));
430
+ discardPartialDump(localFile);
394
431
  throw new BackupToolError(
395
- `The backup failed validation and was not trusted: ${check.reason}`,
396
- "The dump may be corrupt or truncated. Investigate before relying on it."
432
+ `The backup failed validation and was discarded: ${check.reason}`,
433
+ "The dump was corrupt or truncated, so nothing was kept. Investigate before relying on this destination."
397
434
  );
398
435
  }
399
436
  }
@@ -60,3 +60,34 @@ export async function pruneWith(
60
60
  }
61
61
  }
62
62
  }
63
+
64
+ /**
65
+ * Remove a dump artifact abandoned by a failed tool run.
66
+ *
67
+ * `pg_dump` creates its `--file=` target before it finishes connecting, so any
68
+ * failure — a URL libpq rejects, a dropped connection, a full disk — leaves a
69
+ * 0-byte file behind. That corpse is not inert: `rebase db backups list` used
70
+ * to show it as an ordinary entry, and `selectBackupsToPrune` ranks by
71
+ * timestamp alone, so it occupies a protected `keepMinimum` slot and can push
72
+ * a real backup out of retention.
73
+ *
74
+ * Best-effort on purpose: the caller is already throwing, and a failed unlink
75
+ * must not replace the real diagnosis with an ENOENT/EPERM from the cleanup.
76
+ *
77
+ * Lives here rather than in `backup-service.ts` for the reason at the top of
78
+ * this file — that module's top-level `execa` import makes it unloadable under
79
+ * jest, so anything placed there cannot be unit-tested.
80
+ *
81
+ * `remove` deletes one file and throws if it cannot; `exists` reports presence.
82
+ */
83
+ export function discardPartialDumpWith(
84
+ exists: (file: string) => boolean,
85
+ remove: (file: string) => void,
86
+ file: string
87
+ ): void {
88
+ try {
89
+ if (exists(file)) remove(file);
90
+ } catch {
91
+ // Ignored: see above.
92
+ }
93
+ }
@@ -32,7 +32,20 @@ import {
32
32
  VersionCompatibility
33
33
  } from "./pg-tools";
34
34
  import { BackupObject, RetentionOptions, selectBackupsToPrune } from "./retention";
35
- import { applyGlobalsWith, pruneWith } from "./backup-logic";
35
+ import { applyGlobalsWith, discardPartialDumpWith, pruneWith } from "./backup-logic";
36
+
37
+ /**
38
+ * Remove a dump artifact abandoned by a failed tool run. See
39
+ * {@link discardPartialDumpWith} for why this exists and why the logic lives
40
+ * in the execa-free module.
41
+ */
42
+ export function discardPartialDump(file: string): void {
43
+ discardPartialDumpWith(
44
+ (f) => fs.existsSync(f),
45
+ (f) => fs.unlinkSync(f),
46
+ file
47
+ );
48
+ }
36
49
 
37
50
  export class BackupToolError extends Error {
38
51
  constructor(message: string, readonly hint?: string) {
@@ -190,6 +203,15 @@ export async function createDump(opts: {
190
203
  env: dumpEnv
191
204
  });
192
205
  } catch (error) {
206
+ // pg_dump creates its `--file=` target before it finishes connecting,
207
+ // so any failure here — a URL libpq rejects, a dropped connection, a
208
+ // full disk — leaves a 0-byte file behind that nothing else removed.
209
+ // That corpse is not inert: `rebase db backups list` shows it as an
210
+ // ordinary entry, and `selectBackupsToPrune` ranks by timestamp alone,
211
+ // so it occupies a protected `keepMinimum` slot and can push a real
212
+ // backup out of retention. A missing backup is honest; an empty file
213
+ // that reads as a backup is not.
214
+ discardPartialDump(localFile);
193
215
  // The RLS failure names a table and no cause. Replace it with the
194
216
  // cause and the two ways out; anything else is re-thrown untouched.
195
217
  const diagnosis = diagnoseRowSecurityDumpFailure(error);
@@ -214,11 +236,22 @@ export async function createDump(opts: {
214
236
  );
215
237
  }
216
238
  const globalsFile = globalsFileForDump(localFile);
217
- await execa(
218
- dumpallBin,
219
- buildPgDumpallGlobalsArgs({ connectionString: opts.connectionString, outFile: globalsFile }),
220
- { stdio: opts.inheritStdio ? "inherit" : "pipe", env: { ...(env as Record<string, string>) } }
221
- );
239
+ try {
240
+ await execa(
241
+ dumpallBin,
242
+ buildPgDumpallGlobalsArgs({ connectionString: opts.connectionString, outFile: globalsFile }),
243
+ { stdio: opts.inheritStdio ? "inherit" : "pipe", env: { ...(env as Record<string, string>) } }
244
+ );
245
+ } catch (error) {
246
+ // Same reasoning as the pg_dump catch above, and the dump goes with
247
+ // it: the pair is uploaded and pruned together, so a dump whose
248
+ // roles sidecar is missing restores without the roles its GRANT and
249
+ // RLS statements need — the exact failure the sidecar exists to
250
+ // prevent. Leaving half a pair on disk would look like a backup.
251
+ discardPartialDump(globalsFile);
252
+ discardPartialDump(localFile);
253
+ throw error;
254
+ }
222
255
  result.globalsFile = globalsFile;
223
256
  result.globalsSizeBytes = fs.existsSync(globalsFile) ? fs.statSync(globalsFile).size : 0;
224
257
  }
@@ -398,8 +431,9 @@ export async function listBackups(
398
431
  .filter((f) => f.endsWith(".dump"))
399
432
  .map((f) => {
400
433
  const full = path.join(dir, f);
401
- const createdAt = parseBackupTimestamp(f) ?? fs.statSync(full).mtime;
402
- return { key: full, createdAt };
434
+ const stats = fs.statSync(full);
435
+ const createdAt = parseBackupTimestamp(f) ?? stats.mtime;
436
+ return { key: full, createdAt, sizeBytes: stats.size };
403
437
  })
404
438
  .sort((a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0));
405
439
  }
@@ -373,7 +373,18 @@ export function globalsFileForDump(dumpPath: string): string {
373
373
  export function splitGlobalsStatements(sql: string): string[] {
374
374
  const withoutComments = sql
375
375
  .split("\n")
376
- .filter((line) => !line.trim().startsWith("--"))
376
+ // `--` comments, and psql meta-commands. pg_dumpall 15+ wraps its output
377
+ // in `\restrict <token>` / `\unrestrict <token>`, which are instructions
378
+ // to psql, not SQL. This replay sends statements over a connection
379
+ // instead, so they arrived at the server as `\restrict …` and came back
380
+ // as `syntax error at or near "\"` — reported to the user as two skipped
381
+ // globals per restore, which reads like roles failed to apply when
382
+ // nothing did. They carry no state worth replaying: dropping them is
383
+ // exactly what a SQL-level consumer should do.
384
+ .filter((line) => {
385
+ const trimmed = line.trim();
386
+ return !trimmed.startsWith("--") && !trimmed.startsWith("\\");
387
+ })
377
388
  .join("\n");
378
389
  return withoutComments
379
390
  .split(";")
@@ -12,6 +12,17 @@ export interface BackupObject {
12
12
  * timestamp encoded in the key by `buildBackupFilename`.
13
13
  */
14
14
  createdAt?: Date;
15
+ /**
16
+ * Size on disk, when the lister knows it (local destinations always do).
17
+ *
18
+ * Reported so an empty artifact cannot masquerade as a backup in
19
+ * `rebase db backups list`. Deliberately NOT consulted by
20
+ * {@link selectBackupsToPrune}: retention decides by age, and a rule that
21
+ * silently skipped small files would make a genuinely tiny dump immortal.
22
+ * Visibility is the fix here; the artifacts themselves are now removed at
23
+ * the point of failure by `discardPartialDump`.
24
+ */
25
+ sizeBytes?: number;
15
26
  }
16
27
 
17
28
  export interface RetentionOptions {
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The collections schema version, stamped into the database.
3
+ *
4
+ * Deliberately the same table shape, the same key/value convention and the same
5
+ * "unstamped is null, and null is not an error" rule as `auth/schema-version.ts`.
6
+ * They answer the same kind of question — "was this database provisioned by
7
+ * something that agrees with me" — and inventing a second convention for the
8
+ * second one would be a second thing to find, back up and reason about.
9
+ *
10
+ * It does *not* follow the auth stamp's schema. `resolveAuthSchema` tracks the
11
+ * users collection, so a project that puts its users in a custom schema moves
12
+ * the auth stamp with them — correct there, because that stamp describes those
13
+ * tables. This one describes the collections as a whole and belongs with the
14
+ * runtime's own internal state, which is always `rebase`. Usually they land in
15
+ * the same table anyway; when they do not, that is the reason.
16
+ *
17
+ * The value is opaque here on purpose. It is a hash produced by
18
+ * `computeSchemaVersion` in `@rebasepro/types`, and this module's only job is to
19
+ * put a string in and take the same string out. Comparing them, and deciding
20
+ * what a difference means, is `boot/schema-stamp.ts` in the runtime — where it
21
+ * can be tested without a database.
22
+ *
23
+ * Unlike the auth version, this is **not** ordered. The auth stamp is an integer
24
+ * whose comparison direction carries meaning (a database newer than the runtime
25
+ * is unrecoverable, older is the ordinary upgrade path). A schema hash has no
26
+ * order at all: it can say the two disagree and never which is ahead. That is a
27
+ * deliberate limit, and the reason the runtime's response to a mismatch is to
28
+ * describe it rather than to decide who is wrong.
29
+ */
30
+ import { sql } from "drizzle-orm";
31
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
32
+
33
+ /** Key under which the version is stored in the meta table. */
34
+ const VERSION_KEY = "collections_schema_version";
35
+
36
+ /**
37
+ * Anything that can run SQL for us.
38
+ *
39
+ * Narrower than `NodePgDatabase` so the provisioning handle — which is what the
40
+ * boot path actually has in hand — satisfies it without a cast at every call
41
+ * site.
42
+ */
43
+ export interface SchemaMetaQueryable {
44
+ execute(query: unknown): Promise<{ rows: Record<string, unknown>[] }>;
45
+ }
46
+
47
+ /**
48
+ * Read the stamped version, or `null` when this database has never been stamped.
49
+ *
50
+ * `to_regclass` rather than a plain `SELECT` so a missing schema or table is a
51
+ * `null` instead of a thrown 42P01 — the overwhelmingly common case on a fresh
52
+ * database is that neither exists yet, and that is not news.
53
+ */
54
+ export async function readCollectionsSchemaVersion(
55
+ db: SchemaMetaQueryable | NodePgDatabase,
56
+ metaSchema: string
57
+ ): Promise<string | null> {
58
+ const qualified = `"${metaSchema}"."schema_meta"`;
59
+ const exists = await (db as SchemaMetaQueryable).execute(
60
+ sql`SELECT to_regclass(${qualified}) IS NOT NULL AS present`
61
+ );
62
+ if (!(exists.rows[0] as { present: boolean } | undefined)?.present) return null;
63
+
64
+ const result = await (db as SchemaMetaQueryable).execute(sql`
65
+ SELECT value FROM ${sql.raw(qualified)} WHERE key = ${VERSION_KEY}
66
+ `);
67
+ const raw = (result.rows[0] as { value: string } | undefined)?.value;
68
+
69
+ // An empty string is not a version anybody computed, and treating it as one
70
+ // would make every process disagree with the database forever. Unstamped.
71
+ return raw === undefined || raw.trim() === "" ? null : raw;
72
+ }
73
+
74
+ /**
75
+ * Record the version this runtime just applied.
76
+ *
77
+ * Creates the meta table if the auth stamp has not already — the two are
78
+ * independent halves of one boot and either may run first, so neither can assume
79
+ * the table is there. `CREATE TABLE IF NOT EXISTS` is not atomic against a
80
+ * concurrent identical statement, which is exactly the race a split deployment
81
+ * arranges; it is tolerated here for the same reason it is elsewhere in the boot
82
+ * path, and the caller treats any failure as a warning rather than a fatal.
83
+ */
84
+ export async function stampCollectionsSchemaVersion(
85
+ db: SchemaMetaQueryable | NodePgDatabase,
86
+ metaSchema: string,
87
+ version: string
88
+ ): Promise<void> {
89
+ const qualified = `"${metaSchema}"."schema_meta"`;
90
+ await (db as SchemaMetaQueryable).execute(sql`CREATE SCHEMA IF NOT EXISTS ${sql.identifier(metaSchema)}`);
91
+ await (db as SchemaMetaQueryable).execute(sql`
92
+ CREATE TABLE IF NOT EXISTS ${sql.raw(qualified)} (
93
+ key TEXT PRIMARY KEY,
94
+ value TEXT NOT NULL,
95
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
96
+ )
97
+ `);
98
+ await (db as SchemaMetaQueryable).execute(sql`
99
+ INSERT INTO ${sql.raw(qualified)} (key, value)
100
+ VALUES (${VERSION_KEY}, ${version})
101
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
102
+ `);
103
+ }
@@ -244,15 +244,34 @@ describe("checkPolicyDrift", () => {
244
244
  expect(formatPolicyDrift(drift)).toContain("db push");
245
245
  });
246
246
 
247
+ it("flags the tautology under the post-1.0 schema name too", async () => {
248
+ // The helpers moved from `auth` to `rebase` in 1.0, and the compiler
249
+ // rewrites raw `securityRules` SQL on the way in — so `rebase.uid() IS
250
+ // NOT NULL` is the only spelling a current release can store. A checker
251
+ // anchored on the pre-1.0 name saw none of it.
252
+ const cols = [collection("posts")];
253
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
254
+ const live = expected.map((p) => liveRow(p, {
255
+ qual: p.hasUsing ? "(rebase.uid() IS NOT NULL)" : null
256
+ }));
257
+
258
+ const drift = await checkPolicyDrift(dbWith(live), cols);
259
+
260
+ expect(drift.insecure.length).toBeGreaterThan(0);
261
+ expect(drift.diverged).toHaveLength(0);
262
+ });
263
+
247
264
  it("clears the corrected expression, in either literal spelling", async () => {
248
265
  const cols = [collection("posts")];
249
266
  const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
250
- for (const guard of ["<> 'anonymous'::text", "<> 'anonymous'", "!= 'anonymous'"]) {
251
- const live = expected.map((p) => liveRow(p, {
252
- qual: p.hasUsing ? `((auth.uid() IS NOT NULL) AND ((auth.uid())::text ${guard}))` : null
253
- }));
254
- const drift = await checkPolicyDrift(dbWith(live), cols);
255
- expect(drift.insecure).toHaveLength(0);
267
+ for (const fn of ["auth.uid()", "rebase.uid()"]) {
268
+ for (const guard of ["<> 'anonymous'::text", "<> 'anonymous'", "!= 'anonymous'"]) {
269
+ const live = expected.map((p) => liveRow(p, {
270
+ qual: p.hasUsing ? `((${fn} IS NOT NULL) AND ((${fn})::text ${guard}))` : null
271
+ }));
272
+ const drift = await checkPolicyDrift(dbWith(live), cols);
273
+ expect(drift.insecure).toHaveLength(0);
274
+ }
256
275
  }
257
276
  });
258
277
 
@@ -13,7 +13,7 @@
13
13
  * function `db push` uses to write `drizzle/policies.sql`, so this compares
14
14
  * against exactly what would be applied rather than a reimplementation.
15
15
  */
16
- import type { CollectionConfig } from "@rebasepro/types";
16
+ import { RLS_UID_SQL, type CollectionConfig } from "@rebasepro/types";
17
17
 
18
18
  import { generatePostgresPoliciesDdl } from "../schema/generate-postgres-ddl-logic";
19
19
 
@@ -62,7 +62,7 @@ export interface PolicyDrift {
62
62
  diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];
63
63
  /**
64
64
  * A live policy whose expression is the known-permissive tautology
65
- * `auth.uid() IS NOT NULL` — true for anonymous visitors too, because the
65
+ * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the
66
66
  * user path coerces a blank id to the `'anonymous'` sentinel. This is what
67
67
  * `policy.authenticated()` used to compile to, so a database pushed before
68
68
  * that fix carries it, and neither the name, roles, command nor clause
@@ -243,18 +243,26 @@ async function readTablesWithRlsOff(
243
243
  }
244
244
 
245
245
  /**
246
- * The permissive tautology `auth.uid() IS NOT NULL`, without the
246
+ * The permissive tautology `rebase.uid() IS NOT NULL`, without the
247
247
  * `<> 'anonymous'` guard that makes it mean "signed in".
248
248
  *
249
249
  * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The
250
250
  * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the
251
251
  * corrected policy from the stale one, so its presence clears the text.
252
+ *
253
+ * Both schema spellings are matched. The helpers moved from `auth` to `rebase`
254
+ * in 1.0, and this check reads policies *as the database stored them*: a
255
+ * database pushed before the move still holds `auth.uid()` until it is
256
+ * recompiled, while everything written since — including raw `securityRules`
257
+ * SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.
258
+ * Matching only the pre-1.0 name made the check blind to every policy a current
259
+ * release could write, which is precisely the set still worth scanning.
252
260
  */
253
261
  function isPermissiveAuthTautology(clause: string | null | undefined): boolean {
254
262
  if (!clause) return false;
255
263
  const flat = clause.toLowerCase().replace(/\s+/g, " ");
256
- if (!/auth\.uid\(\)\s*is not null/.test(flat)) return false;
257
- // The fix appends `AND auth.uid() <> 'anonymous'`; Postgres may store the
264
+ if (!/\b(?:rebase|auth)\.uid\s*\(\s*\)\s*is not null/.test(flat)) return false;
265
+ // The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the
258
266
  // literal as `'anonymous'::text`. Either spelling means it is the corrected
259
267
  // policy, not the tautology.
260
268
  return !/<>\s*'anonymous'/.test(flat) && !/!=\s*'anonymous'/.test(flat);
@@ -315,7 +323,7 @@ export async function checkPolicyDrift(
315
323
  if (clause) {
316
324
  drift.insecure.push({
317
325
  policy: p,
318
- reason: `${clause} is \`auth.uid() IS NOT NULL\`, which is true for anonymous ` +
326
+ reason: `${clause} is \`${RLS_UID_SQL} IS NOT NULL\`, which is true for anonymous ` +
319
327
  `visitors too — this grants access to signed-out requests. It predates the ` +
320
328
  `\`policy.authenticated()\` fix; re-run \`rebase db push\` to tighten it.`
321
329
  });