@rebasepro/server-postgres 0.17.3 → 0.18.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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,520 +0,0 @@
1
- /**
2
- * CLI handlers for `rebase db backup`, `rebase db restore`, and
3
- * `rebase db backups list`. Kept out of the main `cli.ts` dispatcher so the
4
- * backup surface stays self-contained.
5
- */
6
- import arg from "arg";
7
- import path from "path";
8
- import fs from "fs";
9
- import os from "os";
10
- import readline from "readline";
11
- import chalk from "chalk";
12
- // Aliased: `out` is already a local in two of the commands below (the `--out`
13
- // backup destination).
14
- import { out as print, outWarn, outError } from "../cli-output";
15
- import type { StorageController } from "@rebasepro/server";
16
- import {
17
- BackupDestination,
18
- globalsFileForDump,
19
- parseBackupDestination,
20
- parseDbNameFromUrl,
21
- resolveConnectionString,
22
- withDatabaseName
23
- } from "./pg-tools";
24
- import {
25
- applyGlobals,
26
- BackupToolError,
27
- createDump,
28
- discardPartialDump,
29
- ensureDatabaseExists,
30
- listBackups,
31
- preflight,
32
- restoreDump,
33
- uploadBackup,
34
- validateDump
35
- } from "./backup-service";
36
-
37
- function formatBytes(bytes: number): string {
38
- if (bytes < 1024) return `${bytes} B`;
39
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
40
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
41
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
42
- }
43
-
44
- /**
45
- * Build a StorageController for an object-storage destination from the same
46
- * `S3_*` env vars the backend uses. Returns `null` for local destinations.
47
- */
48
- async function resolveStorageForDestination(
49
- dest: BackupDestination,
50
- env: Record<string, string | undefined>
51
- ): Promise<StorageController | null> {
52
- if (dest.kind === "local") return null;
53
- if (dest.kind === "gcs") {
54
- const { GCSStorageController } = await import("@rebasepro/server");
55
- return new GCSStorageController({ type: "gcs", bucket: dest.bucket });
56
- }
57
- // s3 (also covers R2/MinIO/Hetzner/GCS-interop via S3_ENDPOINT)
58
- if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {
59
- throw new BackupToolError(
60
- "S3 destination requires S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY in the environment.",
61
- "Set the same S3_* variables your backend uses for storage."
62
- );
63
- }
64
- const { S3StorageController } = await import("@rebasepro/server");
65
- return new S3StorageController({
66
- type: "s3",
67
- bucket: dest.bucket,
68
- region: env.S3_REGION || "auto",
69
- accessKeyId: env.S3_ACCESS_KEY_ID,
70
- secretAccessKey: env.S3_SECRET_ACCESS_KEY,
71
- endpoint: env.S3_ENDPOINT,
72
- forcePathStyle: env.S3_FORCE_PATH_STYLE === "true"
73
- });
74
- }
75
-
76
- function requireConnection(): string {
77
- const conn = resolveConnectionString(process.env);
78
- if (!conn) {
79
- outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
80
- process.exit(1);
81
- }
82
- return conn;
83
- }
84
-
85
- async function promptConfirm(question: string): Promise<boolean> {
86
- // Non-interactive shells (CI, pipes) can't answer — treat as "no".
87
- if (!process.stdin.isTTY) return false;
88
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
89
- try {
90
- const answer: string = await new Promise((resolve) => rl.question(question, resolve));
91
- return /^y(es)?$/i.test(answer.trim());
92
- } finally {
93
- rl.close();
94
- }
95
- }
96
-
97
- // ─────────────────────────────────────────────────────────────────────────
98
- // rebase db backup
99
- // ─────────────────────────────────────────────────────────────────────────
100
- export async function backupCommand(rawArgs: string[]): Promise<void> {
101
- const args = arg(
102
- {
103
- "--out": String,
104
- "--exclude-schema": [String],
105
- "--no-owner": Boolean,
106
- "--enable-row-security": Boolean,
107
- "--row-security-role": String,
108
- "-o": "--out"
109
- },
110
- { argv: rawArgs.slice(2), permissive: true }
111
- );
112
-
113
- if (args._.includes("--help") || rawArgs.includes("--help")) {
114
- printBackupHelp();
115
- return;
116
- }
117
-
118
- const connectionString = requireConnection();
119
- const dbName = parseDbNameFromUrl(connectionString) ?? "database";
120
- const out = args["--out"] || process.env.BACKUP_DESTINATION || path.join(process.cwd(), "backups");
121
- const dest = parseBackupDestination(out);
122
-
123
- print("");
124
- print(chalk.bold(" 💾 Rebase DB Backup"));
125
- print(chalk.gray(` Database: ${dbName}`));
126
- print(chalk.gray(` Destination: ${out}`));
127
- print("");
128
-
129
- // Version pre-flight (doctor-style).
130
- const pf = await preflight("pg_dump", connectionString);
131
- if (!pf.compatible) {
132
- outError(chalk.red(` ✗ ${pf.reason}`));
133
- process.exit(1);
134
- }
135
- print(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
136
-
137
- // Opt-in, and loud. With row security on, pg_dump stops refusing to read
138
- // rows it cannot see and starts leaving them out — an exit-0 backup that
139
- // is quietly short. Anyone choosing that should know they chose it.
140
- const rowSecurity = args["--enable-row-security"]
141
- ? { uid: "rebase-db-backup", roles: [args["--row-security-role"] || "admin"] }
142
- : undefined;
143
-
144
- if (rowSecurity) {
145
- outWarn("");
146
- outWarn(chalk.yellow(" ⚠ Dumping with row-level security ON."));
147
- outWarn(chalk.gray(` Reading as roles [${rowSecurity.roles.join(", ")}], which satisfies the generated`));
148
- outWarn(chalk.gray(" `admin_full_access` policy. This backup contains exactly the rows those"));
149
- outWarn(chalk.gray(" policies admit — a table whose policies have no admin rule comes out short,"));
150
- outWarn(chalk.gray(" and pg_dump will not say so. Prefer granting the dumping role BYPASSRLS."));
151
- outWarn("");
152
- }
153
-
154
- try {
155
- if (dest.kind === "local") {
156
- // Honour an explicit `…/name.dump` path; otherwise treat it as a
157
- // directory and auto-name the file.
158
- const explicitFile = dest.path.endsWith(".dump");
159
- const dump = await createDump({
160
- connectionString,
161
- dbName,
162
- outDir: explicitFile ? path.dirname(dest.path) : dest.path,
163
- fileName: explicitFile ? path.basename(dest.path) : undefined,
164
- excludeSchemas: args["--exclude-schema"],
165
- noOwner: args["--no-owner"],
166
- inheritStdio: true,
167
- rowSecurity
168
- });
169
- await assertDumpValid(dump.localFile);
170
- print("");
171
- print(chalk.green(` ✓ Backup written to ${dump.localFile} (${formatBytes(dump.sizeBytes)})`));
172
- if (dump.globalsFile) {
173
- print(chalk.gray(` ✓ Roles captured to ${dump.globalsFile} (needed so RLS survives a restore).`));
174
- }
175
- } else {
176
- const storage = await resolveStorageForDestination(dest, process.env);
177
- const dump = await createDump({
178
- connectionString,
179
- dbName,
180
- excludeSchemas: args["--exclude-schema"],
181
- noOwner: args["--no-owner"],
182
- inheritStdio: true,
183
- rowSecurity
184
- });
185
- try {
186
- await assertDumpValid(dump.localFile);
187
- const uploaded = await uploadBackup(storage!, dump.localFile, dest);
188
- print("");
189
- print(chalk.green(` ✓ Backup uploaded to ${uploaded.storageUrl} (${formatBytes(dump.sizeBytes)})`));
190
- // Upload the roles sidecar next to the dump so a restore can
191
- // recreate roles the dump's GRANT/RLS statements depend on.
192
- if (dump.globalsFile && fs.existsSync(dump.globalsFile)) {
193
- const globalsUpload = await uploadBackup(storage!, dump.globalsFile, dest);
194
- print(chalk.gray(` ✓ Roles uploaded to ${globalsUpload.storageUrl} (needed so RLS survives a restore).`));
195
- }
196
- print(chalk.gray(" Ensure this bucket is private — backups may contain secrets and PII."));
197
- } finally {
198
- if (fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
199
- if (dump.globalsFile && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
200
- }
201
- }
202
- print("");
203
- } catch (err) {
204
- reportError(err);
205
- process.exit(1);
206
- }
207
- }
208
-
209
- // ─────────────────────────────────────────────────────────────────────────
210
- // rebase db restore <backup>
211
- // ─────────────────────────────────────────────────────────────────────────
212
- export async function restoreCommand(rawArgs: string[]): Promise<void> {
213
- const args = arg(
214
- {
215
- "--target-db": String,
216
- "--create-db": Boolean,
217
- "--clean": Boolean,
218
- "--no-owner": Boolean,
219
- "--continue-on-error": Boolean,
220
- "--yes": Boolean,
221
- "-y": "--yes"
222
- },
223
- { argv: rawArgs.slice(2), permissive: true }
224
- );
225
-
226
- const backupArg = args._[0];
227
- if (!backupArg || rawArgs.includes("--help")) {
228
- printRestoreHelp();
229
- if (!backupArg && !rawArgs.includes("--help")) process.exit(1);
230
- return;
231
- }
232
-
233
- const baseConnection = requireConnection();
234
-
235
- // Choose the target connection: an explicit --target-db (or --create-db's
236
- // implied fresh db) swaps the database name so the live one isn't clobbered.
237
- const targetDb = args["--target-db"] ?? parseDbNameFromUrl(baseConnection) ?? undefined;
238
- const targetConnection = args["--target-db"]
239
- ? withDatabaseName(baseConnection, args["--target-db"])
240
- : baseConnection;
241
-
242
- print("");
243
- print(chalk.bold(" ♻️ Rebase DB Restore"));
244
- print(chalk.gray(` Source: ${backupArg}`));
245
- print(chalk.gray(` Target: ${targetDb ?? "(from DATABASE_URL)"}`));
246
- print("");
247
-
248
- // Resolve the local file to restore from (download object-storage keys).
249
- // Also resolve the `.globals.sql` roles sidecar so cluster roles can be
250
- // recreated before the restore — without them the dump's GRANT/RLS
251
- // statements fail and RLS is silently lost.
252
- let localFile: string;
253
- let globalsSql: string | null = null;
254
- let cleanupTemp = false;
255
- try {
256
- if (/^(s3|gs):\/\//.test(backupArg)) {
257
- const dest = parseBackupDestination(backupArg.replace(/\/[^/]+$/, ""));
258
- const storage = await resolveStorageForDestination(dest, process.env);
259
- if (!storage) throw new BackupToolError("Could not resolve storage for the given URL.");
260
- const key = backupArg.replace(/^(s3|gs):\/\/[^/]+\//, "");
261
- const bucket = backupArg.replace(/^(s3|gs):\/\/([^/]+)\/.*$/, "$2");
262
- const file = await storage.getObject(key, bucket);
263
- if (!file) throw new BackupToolError(`Backup not found in storage: ${backupArg}`);
264
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rebase-restore-"));
265
- localFile = path.join(tmpDir, path.basename(key));
266
- fs.writeFileSync(localFile, Buffer.from(await file.arrayBuffer()));
267
- cleanupTemp = true;
268
- const globalsObj = await storage.getObject(globalsFileForDump(key), bucket);
269
- if (globalsObj) globalsSql = Buffer.from(await globalsObj.arrayBuffer()).toString("utf-8");
270
- } else {
271
- localFile = path.resolve(backupArg);
272
- if (!fs.existsSync(localFile)) {
273
- outError(chalk.red(` ✗ Backup file not found: ${localFile}`));
274
- process.exit(1);
275
- }
276
- const globalsPath = globalsFileForDump(localFile);
277
- if (fs.existsSync(globalsPath)) globalsSql = fs.readFileSync(globalsPath, "utf-8");
278
- }
279
-
280
- // Version pre-flight. Check against the base connection — the server
281
- // version is identical for every database, and the target may not
282
- // exist yet when --create-db is used.
283
- const pf = await preflight("pg_restore", baseConnection);
284
- if (!pf.compatible) {
285
- outError(chalk.red(` ✗ ${pf.reason}`));
286
- process.exit(1);
287
- }
288
-
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);
293
- }
294
-
295
- // Destructive-action gate. Restores overwrite data; never run without
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.
304
- if (!args["--yes"]) {
305
- outWarn(chalk.yellow(
306
- ` ⚠️ This will restore into "${targetDb ?? "the target database"}" and may overwrite existing data.`
307
- ));
308
- const confirmed = await promptConfirm(chalk.yellow(" Type 'yes' to continue: "));
309
- if (!confirmed) {
310
- print(chalk.gray(" Aborted. No changes were made."));
311
- process.exit(1);
312
- }
313
- }
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
-
321
- // Recreate cluster roles before restoring so GRANT/RLS statements in
322
- // the dump apply. Best-effort and idempotent (see applyGlobals).
323
- if (globalsSql) {
324
- print(chalk.gray(" Recreating cluster roles from the backup's roles sidecar…"));
325
- const { applied, skipped } = await applyGlobals(
326
- targetConnection,
327
- globalsSql,
328
- (m) => print(chalk.gray(m))
329
- );
330
- print(chalk.gray(` ✓ Roles: ${applied} applied, ${skipped} skipped (already present or not permitted).`));
331
- } else {
332
- outWarn(chalk.yellow(" ⚠️ No roles sidecar (.globals.sql) accompanies this backup."));
333
- outWarn(chalk.yellow(" If the dump grants to roles that don't exist (e.g. rebase_user), the restore"));
334
- outWarn(chalk.yellow(" will fail — recreate those roles first, or use a backup that includes its globals."));
335
- }
336
-
337
- await restoreDump({
338
- connectionString: targetConnection,
339
- inputFile: localFile,
340
- clean: args["--clean"],
341
- noOwner: args["--no-owner"],
342
- // Fail loudly by default so a skipped GRANT never leaves RLS off.
343
- exitOnError: !args["--continue-on-error"],
344
- inheritStdio: true
345
- });
346
-
347
- print("");
348
- print(chalk.green(` ✓ Restore completed into "${targetDb ?? "the target database"}".`));
349
- print("");
350
- } catch (err) {
351
- reportError(err);
352
- process.exit(1);
353
- } finally {
354
- if (cleanupTemp && typeof localFile! === "string" && fs.existsSync(localFile!)) {
355
- fs.rmSync(path.dirname(localFile!), { recursive: true, force: true });
356
- }
357
- }
358
- }
359
-
360
- // ─────────────────────────────────────────────────────────────────────────
361
- // rebase db backups list
362
- // ─────────────────────────────────────────────────────────────────────────
363
- export async function backupsCommand(rawArgs: string[]): Promise<void> {
364
- const action = rawArgs[2];
365
- if (!action || action === "--help") {
366
- printBackupsHelp();
367
- return;
368
- }
369
- if (action !== "list") {
370
- outError(chalk.red(`Unknown backups action: "${action}". Valid: list`));
371
- process.exit(1);
372
- }
373
-
374
- const args = arg({ "--out": String, "-o": "--out" }, { argv: rawArgs.slice(3), permissive: true });
375
- const out = args["--out"] || process.env.BACKUP_DESTINATION || path.join(process.cwd(), "backups");
376
- const dest = parseBackupDestination(out);
377
-
378
- try {
379
- const storage = await resolveStorageForDestination(dest, process.env);
380
- const backups = await listBackups(dest, storage ?? undefined);
381
- print("");
382
- if (backups.length === 0) {
383
- print(chalk.gray(` No backups found at ${out}.`));
384
- } else {
385
- print(chalk.bold(` 💾 ${backups.length} backup(s) at ${out}:`));
386
- print("");
387
- for (const b of backups) {
388
- const when = b.createdAt ? b.createdAt.toISOString() : "unknown date";
389
- const name = dest.kind === "local" ? path.basename(b.key) : b.key;
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."));
407
- }
408
- }
409
- print("");
410
- } catch (err) {
411
- reportError(err);
412
- process.exit(1);
413
- }
414
- }
415
-
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
- */
426
- async function assertDumpValid(localFile: string): Promise<void> {
427
- const check = await validateDump(localFile);
428
- if (!check.ok) {
429
- discardPartialDump(globalsFileForDump(localFile));
430
- discardPartialDump(localFile);
431
- throw new BackupToolError(
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."
434
- );
435
- }
436
- }
437
-
438
- function reportError(err: unknown): void {
439
- if (err instanceof BackupToolError) {
440
- outError(chalk.red(` ✗ ${err.message}`));
441
- if (err.hint) outError(chalk.gray(` ${err.hint}`));
442
- } else {
443
- outError(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
444
- }
445
- }
446
-
447
- function printBackupHelp(): void {
448
- print(`
449
- ${chalk.bold("rebase db backup")} — Create a database backup (pg_dump, custom format)
450
-
451
- ${chalk.green.bold("Usage")}
452
- rebase db backup [--out <path|s3://bucket/prefix>] [options]
453
-
454
- ${chalk.green.bold("Options")}
455
- ${chalk.blue("--out, -o")} <dest> Local path or s3://…/gs://… URL (default: ./backups)
456
- ${chalk.blue("--exclude-schema")} <s> Exclude a schema (repeatable)
457
- ${chalk.blue("--no-owner")} Omit ownership commands from the dump
458
- ${chalk.blue("--enable-row-security")} Dump as an admin subject instead of failing on RLS
459
- ${chalk.red("(may produce a partial dump — see below)")}
460
- ${chalk.blue("--row-security-role")} <r> Role to read as with the flag above (default: admin)
461
-
462
- ${chalk.green.bold("Row-level security")}
463
- On a managed Postgres the dumping role usually owns nothing and has no
464
- BYPASSRLS, so pg_dump refuses:
465
-
466
- ERROR: query would be affected by row-level security policy for table "..."
467
-
468
- That refusal is the safe behaviour. --enable-row-security replaces it by
469
- reading as an admin subject: Rebase sets app.uid/app.user_roles so the
470
- generated admin_full_access policy admits the dump. The dump then contains
471
- exactly what those policies admit ${chalk.red("and no error is raised for what they do not")} —
472
- a table whose policies lack an admin rule comes out short, silently.
473
-
474
- Granting the dumping role BYPASSRLS is the option that keeps a backup
475
- meaning "every row".
476
-
477
- ${chalk.green.bold("Notes")}
478
- Backups may contain secrets and PII. Use private storage destinations and
479
- enable encryption-at-rest. See docs/backups.md.
480
- `);
481
- }
482
-
483
- function printRestoreHelp(): void {
484
- print(`
485
- ${chalk.bold("rebase db restore")} — Restore a database from a backup (pg_restore)
486
-
487
- ${chalk.green.bold("Usage")}
488
- rebase db restore <backup> [options]
489
-
490
- ${chalk.green.bold("Arguments")}
491
- <backup> Local .dump file, or s3://…/gs://… object key
492
-
493
- ${chalk.green.bold("Options")}
494
- ${chalk.blue("--target-db")} <name> Restore into this database instead of DATABASE_URL's
495
- ${chalk.blue("--create-db")} Create the target database first if it doesn't exist
496
- ${chalk.blue("--clean")} Drop existing objects before recreating them
497
- ${chalk.blue("--no-owner")} Ignore ownership from the dump
498
- ${chalk.blue("--continue-on-error")} Log and continue past errors ${chalk.red("(may leave RLS un-enforced!)")}
499
- ${chalk.blue("--yes, -y")} Skip the interactive confirmation ${chalk.red("(destructive!)")}
500
-
501
- ${chalk.red.bold("Warning")}
502
- Restore is destructive and never runs automatically. Without --yes it
503
- requires an interactive 'yes'. Prefer --create-db/--target-db to restore
504
- into a fresh database rather than overwriting a live one.
505
-
506
- By default the restore aborts on the first error (--exit-on-error) so a
507
- skipped GRANT never silently leaves RLS un-enforced. Roles are recreated
508
- from the backup's .globals.sql sidecar first; keep that file next to the
509
- dump. Use --continue-on-error only when you understand the consequences.
510
- `);
511
- }
512
-
513
- function printBackupsHelp(): void {
514
- print(`
515
- ${chalk.bold("rebase db backups")} — Manage stored backups
516
-
517
- ${chalk.green.bold("Usage")}
518
- rebase db backups list [--out <path|s3://bucket/prefix>]
519
- `);
520
- }