@rebasepro/server-postgres 0.0.1-canary.4829d6e

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 (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. package/src/websocket.ts +640 -0
@@ -0,0 +1,383 @@
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
+ import { logger } from "@rebasepro/server";
13
+ import type { StorageController } from "@rebasepro/server";
14
+ import {
15
+ BackupDestination,
16
+ parseBackupDestination,
17
+ parseDbNameFromUrl,
18
+ resolveConnectionString,
19
+ withDatabaseName
20
+ } from "./pg-tools";
21
+ import {
22
+ BackupToolError,
23
+ createDump,
24
+ ensureDatabaseExists,
25
+ listBackups,
26
+ preflight,
27
+ restoreDump,
28
+ uploadBackup
29
+ } from "./backup-service";
30
+
31
+ function formatBytes(bytes: number): string {
32
+ if (bytes < 1024) return `${bytes} B`;
33
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
34
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
35
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
36
+ }
37
+
38
+ /**
39
+ * Build a StorageController for an object-storage destination from the same
40
+ * `S3_*` env vars the backend uses. Returns `null` for local destinations.
41
+ */
42
+ async function resolveStorageForDestination(
43
+ dest: BackupDestination,
44
+ env: Record<string, string | undefined>
45
+ ): Promise<StorageController | null> {
46
+ if (dest.kind === "local") return null;
47
+ if (dest.kind === "gcs") {
48
+ const { GCSStorageController } = await import("@rebasepro/server");
49
+ return new GCSStorageController({ type: "gcs", bucket: dest.bucket });
50
+ }
51
+ // s3 (also covers R2/MinIO/Hetzner/GCS-interop via S3_ENDPOINT)
52
+ if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {
53
+ throw new BackupToolError(
54
+ "S3 destination requires S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY in the environment.",
55
+ "Set the same S3_* variables your backend uses for storage."
56
+ );
57
+ }
58
+ const { S3StorageController } = await import("@rebasepro/server");
59
+ return new S3StorageController({
60
+ type: "s3",
61
+ bucket: dest.bucket,
62
+ region: env.S3_REGION || "auto",
63
+ accessKeyId: env.S3_ACCESS_KEY_ID,
64
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
65
+ endpoint: env.S3_ENDPOINT,
66
+ forcePathStyle: env.S3_FORCE_PATH_STYLE === "true"
67
+ });
68
+ }
69
+
70
+ function requireConnection(): string {
71
+ const conn = resolveConnectionString(process.env);
72
+ if (!conn) {
73
+ logger.error(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
74
+ process.exit(1);
75
+ }
76
+ return conn;
77
+ }
78
+
79
+ async function promptConfirm(question: string): Promise<boolean> {
80
+ // Non-interactive shells (CI, pipes) can't answer — treat as "no".
81
+ if (!process.stdin.isTTY) return false;
82
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
83
+ try {
84
+ const answer: string = await new Promise((resolve) => rl.question(question, resolve));
85
+ return /^y(es)?$/i.test(answer.trim());
86
+ } finally {
87
+ rl.close();
88
+ }
89
+ }
90
+
91
+ // ─────────────────────────────────────────────────────────────────────────
92
+ // rebase db backup
93
+ // ─────────────────────────────────────────────────────────────────────────
94
+ export async function backupCommand(rawArgs: string[]): Promise<void> {
95
+ const args = arg(
96
+ {
97
+ "--out": String,
98
+ "--exclude-schema": [String],
99
+ "--no-owner": Boolean,
100
+ "-o": "--out"
101
+ },
102
+ { argv: rawArgs.slice(2), permissive: true }
103
+ );
104
+
105
+ if (args._.includes("--help") || rawArgs.includes("--help")) {
106
+ printBackupHelp();
107
+ return;
108
+ }
109
+
110
+ const connectionString = requireConnection();
111
+ const dbName = parseDbNameFromUrl(connectionString) ?? "database";
112
+ const out = args["--out"] || process.env.BACKUP_DESTINATION || path.join(process.cwd(), "backups");
113
+ const dest = parseBackupDestination(out);
114
+
115
+ logger.info("");
116
+ logger.info(chalk.bold(" 💾 Rebase DB Backup"));
117
+ logger.info(chalk.gray(` Database: ${dbName}`));
118
+ logger.info(chalk.gray(` Destination: ${out}`));
119
+ logger.info("");
120
+
121
+ // Version pre-flight (doctor-style).
122
+ const pf = await preflight("pg_dump", connectionString);
123
+ if (!pf.compatible) {
124
+ logger.error(chalk.red(` ✗ ${pf.reason}`));
125
+ process.exit(1);
126
+ }
127
+ logger.info(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
128
+
129
+ try {
130
+ if (dest.kind === "local") {
131
+ // Honour an explicit `…/name.dump` path; otherwise treat it as a
132
+ // directory and auto-name the file.
133
+ const explicitFile = dest.path.endsWith(".dump");
134
+ const dump = await createDump({
135
+ connectionString,
136
+ dbName,
137
+ outDir: explicitFile ? path.dirname(dest.path) : dest.path,
138
+ fileName: explicitFile ? path.basename(dest.path) : undefined,
139
+ excludeSchemas: args["--exclude-schema"],
140
+ noOwner: args["--no-owner"],
141
+ inheritStdio: true
142
+ });
143
+ logger.info("");
144
+ logger.info(chalk.green(` ✓ Backup written to ${dump.localFile} (${formatBytes(dump.sizeBytes)})`));
145
+ } else {
146
+ const storage = await resolveStorageForDestination(dest, process.env);
147
+ const dump = await createDump({
148
+ connectionString,
149
+ dbName,
150
+ excludeSchemas: args["--exclude-schema"],
151
+ noOwner: args["--no-owner"],
152
+ inheritStdio: true
153
+ });
154
+ try {
155
+ const uploaded = await uploadBackup(storage!, dump.localFile, dest);
156
+ logger.info("");
157
+ logger.info(chalk.green(` ✓ Backup uploaded to ${uploaded.storageUrl} (${formatBytes(dump.sizeBytes)})`));
158
+ logger.info(chalk.gray(" Ensure this bucket is private — backups may contain secrets and PII."));
159
+ } finally {
160
+ if (fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
161
+ }
162
+ }
163
+ logger.info("");
164
+ } catch (err) {
165
+ reportError(err);
166
+ process.exit(1);
167
+ }
168
+ }
169
+
170
+ // ─────────────────────────────────────────────────────────────────────────
171
+ // rebase db restore <backup>
172
+ // ─────────────────────────────────────────────────────────────────────────
173
+ export async function restoreCommand(rawArgs: string[]): Promise<void> {
174
+ const args = arg(
175
+ {
176
+ "--target-db": String,
177
+ "--create-db": Boolean,
178
+ "--clean": Boolean,
179
+ "--no-owner": Boolean,
180
+ "--yes": Boolean,
181
+ "-y": "--yes"
182
+ },
183
+ { argv: rawArgs.slice(2), permissive: true }
184
+ );
185
+
186
+ const backupArg = args._[0];
187
+ if (!backupArg || rawArgs.includes("--help")) {
188
+ printRestoreHelp();
189
+ if (!backupArg && !rawArgs.includes("--help")) process.exit(1);
190
+ return;
191
+ }
192
+
193
+ const baseConnection = requireConnection();
194
+
195
+ // Choose the target connection: an explicit --target-db (or --create-db's
196
+ // implied fresh db) swaps the database name so the live one isn't clobbered.
197
+ const targetDb = args["--target-db"] ?? parseDbNameFromUrl(baseConnection) ?? undefined;
198
+ const targetConnection = args["--target-db"]
199
+ ? withDatabaseName(baseConnection, args["--target-db"])
200
+ : baseConnection;
201
+
202
+ logger.info("");
203
+ logger.info(chalk.bold(" ♻️ Rebase DB Restore"));
204
+ logger.info(chalk.gray(` Source: ${backupArg}`));
205
+ logger.info(chalk.gray(` Target: ${targetDb ?? "(from DATABASE_URL)"}`));
206
+ logger.info("");
207
+
208
+ // Resolve the local file to restore from (download object-storage keys).
209
+ let localFile: string;
210
+ let cleanupTemp = false;
211
+ try {
212
+ if (/^(s3|gs):\/\//.test(backupArg)) {
213
+ const dest = parseBackupDestination(backupArg.replace(/\/[^/]+$/, ""));
214
+ const storage = await resolveStorageForDestination(dest, process.env);
215
+ if (!storage) throw new BackupToolError("Could not resolve storage for the given URL.");
216
+ const key = backupArg.replace(/^(s3|gs):\/\/[^/]+\//, "");
217
+ const bucket = backupArg.replace(/^(s3|gs):\/\/([^/]+)\/.*$/, "$2");
218
+ const file = await storage.getObject(key, bucket);
219
+ if (!file) throw new BackupToolError(`Backup not found in storage: ${backupArg}`);
220
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rebase-restore-"));
221
+ localFile = path.join(tmpDir, path.basename(key));
222
+ fs.writeFileSync(localFile, Buffer.from(await file.arrayBuffer()));
223
+ cleanupTemp = true;
224
+ } else {
225
+ localFile = path.resolve(backupArg);
226
+ if (!fs.existsSync(localFile)) {
227
+ logger.error(chalk.red(` ✗ Backup file not found: ${localFile}`));
228
+ process.exit(1);
229
+ }
230
+ }
231
+
232
+ // Version pre-flight. Check against the base connection — the server
233
+ // version is identical for every database, and the target may not
234
+ // exist yet when --create-db is used.
235
+ const pf = await preflight("pg_restore", baseConnection);
236
+ if (!pf.compatible) {
237
+ logger.error(chalk.red(` ✗ ${pf.reason}`));
238
+ process.exit(1);
239
+ }
240
+
241
+ // Create the target database when requested.
242
+ if (args["--create-db"]) {
243
+ if (!targetDb) {
244
+ logger.error(chalk.red(" ✗ --create-db requires a resolvable target database name (use --target-db)."));
245
+ process.exit(1);
246
+ }
247
+ const created = await ensureDatabaseExists(baseConnection, targetDb);
248
+ logger.info(chalk.gray(created ? ` ✓ Created database "${targetDb}".` : ` • Database "${targetDb}" already exists.`));
249
+ }
250
+
251
+ // Destructive-action gate. Restores overwrite data; never run without
252
+ // an explicit yes (interactive confirmation or --yes).
253
+ if (!args["--yes"]) {
254
+ logger.warn(chalk.yellow(
255
+ ` ⚠️ This will restore into "${targetDb ?? "the target database"}" and may overwrite existing data.`
256
+ ));
257
+ const confirmed = await promptConfirm(chalk.yellow(" Type 'yes' to continue: "));
258
+ if (!confirmed) {
259
+ logger.info(chalk.gray(" Aborted. No changes were made."));
260
+ process.exit(1);
261
+ }
262
+ }
263
+
264
+ await restoreDump({
265
+ connectionString: targetConnection,
266
+ inputFile: localFile,
267
+ clean: args["--clean"],
268
+ noOwner: args["--no-owner"],
269
+ inheritStdio: true
270
+ });
271
+
272
+ logger.info("");
273
+ logger.info(chalk.green(` ✓ Restore completed into "${targetDb ?? "the target database"}".`));
274
+ logger.info("");
275
+ } catch (err) {
276
+ reportError(err);
277
+ process.exit(1);
278
+ } finally {
279
+ if (cleanupTemp && typeof localFile! === "string" && fs.existsSync(localFile!)) {
280
+ fs.rmSync(path.dirname(localFile!), { recursive: true, force: true });
281
+ }
282
+ }
283
+ }
284
+
285
+ // ─────────────────────────────────────────────────────────────────────────
286
+ // rebase db backups list
287
+ // ─────────────────────────────────────────────────────────────────────────
288
+ export async function backupsCommand(rawArgs: string[]): Promise<void> {
289
+ const action = rawArgs[2];
290
+ if (!action || action === "--help") {
291
+ printBackupsHelp();
292
+ return;
293
+ }
294
+ if (action !== "list") {
295
+ logger.error(chalk.red(`Unknown backups action: "${action}". Valid: list`));
296
+ process.exit(1);
297
+ }
298
+
299
+ const args = arg({ "--out": String, "-o": "--out" }, { argv: rawArgs.slice(3), permissive: true });
300
+ const out = args["--out"] || process.env.BACKUP_DESTINATION || path.join(process.cwd(), "backups");
301
+ const dest = parseBackupDestination(out);
302
+
303
+ try {
304
+ const storage = await resolveStorageForDestination(dest, process.env);
305
+ const backups = await listBackups(dest, storage ?? undefined);
306
+ logger.info("");
307
+ if (backups.length === 0) {
308
+ logger.info(chalk.gray(` No backups found at ${out}.`));
309
+ } else {
310
+ logger.info(chalk.bold(` 💾 ${backups.length} backup(s) at ${out}:`));
311
+ logger.info("");
312
+ for (const b of backups) {
313
+ const when = b.createdAt ? b.createdAt.toISOString() : "unknown date";
314
+ const name = dest.kind === "local" ? path.basename(b.key) : b.key;
315
+ logger.info(` ${chalk.green("●")} ${chalk.bold(name)} ${chalk.gray(`— ${when}`)}`);
316
+ }
317
+ }
318
+ logger.info("");
319
+ } catch (err) {
320
+ reportError(err);
321
+ process.exit(1);
322
+ }
323
+ }
324
+
325
+ function reportError(err: unknown): void {
326
+ if (err instanceof BackupToolError) {
327
+ logger.error(chalk.red(` ✗ ${err.message}`));
328
+ if (err.hint) logger.error(chalk.gray(` ${err.hint}`));
329
+ } else {
330
+ logger.error(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
331
+ }
332
+ }
333
+
334
+ function printBackupHelp(): void {
335
+ logger.info(`
336
+ ${chalk.bold("rebase db backup")} — Create a database backup (pg_dump, custom format)
337
+
338
+ ${chalk.green.bold("Usage")}
339
+ rebase db backup [--out <path|s3://bucket/prefix>] [options]
340
+
341
+ ${chalk.green.bold("Options")}
342
+ ${chalk.blue("--out, -o")} <dest> Local path or s3://…/gs://… URL (default: ./backups)
343
+ ${chalk.blue("--exclude-schema")} <s> Exclude a schema (repeatable)
344
+ ${chalk.blue("--no-owner")} Omit ownership commands from the dump
345
+
346
+ ${chalk.green.bold("Notes")}
347
+ Backups may contain secrets and PII. Use private storage destinations and
348
+ enable encryption-at-rest. See docs/backups.md.
349
+ `);
350
+ }
351
+
352
+ function printRestoreHelp(): void {
353
+ logger.info(`
354
+ ${chalk.bold("rebase db restore")} — Restore a database from a backup (pg_restore)
355
+
356
+ ${chalk.green.bold("Usage")}
357
+ rebase db restore <backup> [options]
358
+
359
+ ${chalk.green.bold("Arguments")}
360
+ <backup> Local .dump file, or s3://…/gs://… object key
361
+
362
+ ${chalk.green.bold("Options")}
363
+ ${chalk.blue("--target-db")} <name> Restore into this database instead of DATABASE_URL's
364
+ ${chalk.blue("--create-db")} Create the target database first if it doesn't exist
365
+ ${chalk.blue("--clean")} Drop existing objects before recreating them
366
+ ${chalk.blue("--no-owner")} Ignore ownership from the dump
367
+ ${chalk.blue("--yes, -y")} Skip the interactive confirmation ${chalk.red("(destructive!)")}
368
+
369
+ ${chalk.red.bold("Warning")}
370
+ Restore is destructive and never runs automatically. Without --yes it
371
+ requires an interactive 'yes'. Prefer --create-db/--target-db to restore
372
+ into a fresh database rather than overwriting a live one.
373
+ `);
374
+ }
375
+
376
+ function printBackupsHelp(): void {
377
+ logger.info(`
378
+ ${chalk.bold("rebase db backups")} — Manage stored backups
379
+
380
+ ${chalk.green.bold("Usage")}
381
+ rebase db backups list [--out <path|s3://bucket/prefix>]
382
+ `);
383
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Scheduled backups, wired into the server cron system.
3
+ *
4
+ * A backend enables nightly (or custom-schedule) backups by dropping a cron
5
+ * file that default-exports {@link createBackupCron}. The heavy lifting —
6
+ * dump, upload, prune — reuses the same primitives as the CLI so behaviour
7
+ * is identical between a manual `rebase db backup` and an automated run.
8
+ */
9
+ import fs from "fs";
10
+ import type { CronJobDefinition } from "@rebasepro/types";
11
+ import type { StorageController } from "@rebasepro/server";
12
+ import {
13
+ BackupDestination,
14
+ parseBackupDestination,
15
+ parseDbNameFromUrl,
16
+ resolveConnectionString
17
+ } from "./pg-tools";
18
+ // NOTE: `./backup-service` pulls in `execa` (ESM-only). It is imported
19
+ // lazily inside the handler so the pure `backupCronConfigFromEnv` parser can
20
+ // be unit-tested under CommonJS without loading it.
21
+
22
+ export interface BackupCronConfig {
23
+ /** Cron expression, e.g. `"0 3 * * *"` for 03:00 daily. */
24
+ schedule: string;
25
+ /** Postgres connection string. Defaults to `DATABASE_URL`. */
26
+ connectionString: string;
27
+ /** Where backups are written: a local path or an `s3://` / `gs://` URL. */
28
+ destination: BackupDestination;
29
+ /**
30
+ * Storage controller used for `s3`/`gcs` destinations. Reuse the one the
31
+ * backend already configured (S3/GCS/Local StorageController). Not needed
32
+ * for local destinations.
33
+ */
34
+ storage?: StorageController;
35
+ /** Delete backups older than this many days. `0` disables pruning. */
36
+ retentionDays?: number;
37
+ /** Always keep at least this many recent backups regardless of age. */
38
+ keepMinimum?: number;
39
+ /** Schemas to exclude from the dump (defaults to Atlas revision schema). */
40
+ excludeSchemas?: string[];
41
+ /** Cron job display name. */
42
+ name?: string;
43
+ /** Whether the job is enabled. */
44
+ enabled?: boolean;
45
+ }
46
+
47
+ export interface EnvResolution {
48
+ /** Resolved config, present when a schedule is configured. */
49
+ config?: Omit<BackupCronConfig, "storage">;
50
+ /** True when `BACKUP_SCHEDULE` is unset — the cron should be a no-op. */
51
+ disabled?: boolean;
52
+ /** Human-readable reason the config could not be built. */
53
+ error?: string;
54
+ }
55
+
56
+ /**
57
+ * Build backup-cron config purely from environment variables.
58
+ * Recognised keys:
59
+ * - `BACKUP_SCHEDULE` cron expression (required to enable)
60
+ * - `BACKUP_DESTINATION` local path or `s3://` / `gs://` URL (required)
61
+ * - `BACKUP_RETENTION_DAYS` integer days (optional)
62
+ * - `BACKUP_KEEP_MINIMUM` integer count (optional)
63
+ * - `DATABASE_URL` connection string
64
+ *
65
+ * Pure and side-effect free so it can be unit-tested without a database.
66
+ */
67
+ export function backupCronConfigFromEnv(env: Record<string, string | undefined>): EnvResolution {
68
+ const schedule = env.BACKUP_SCHEDULE?.trim();
69
+ if (!schedule) {
70
+ return { disabled: true };
71
+ }
72
+
73
+ const connectionString = resolveConnectionString(env);
74
+ if (!connectionString) {
75
+ return { error: "BACKUP_SCHEDULE is set but DATABASE_URL is not configured." };
76
+ }
77
+
78
+ const destinationRaw = env.BACKUP_DESTINATION?.trim();
79
+ if (!destinationRaw) {
80
+ return { error: "BACKUP_SCHEDULE is set but BACKUP_DESTINATION is not configured." };
81
+ }
82
+ const destination = parseBackupDestination(destinationRaw);
83
+
84
+ const retentionDays = parseOptionalInt(env.BACKUP_RETENTION_DAYS);
85
+ if (retentionDays === "invalid") {
86
+ return { error: `BACKUP_RETENTION_DAYS must be an integer, got "${env.BACKUP_RETENTION_DAYS}".` };
87
+ }
88
+ const keepMinimum = parseOptionalInt(env.BACKUP_KEEP_MINIMUM);
89
+ if (keepMinimum === "invalid") {
90
+ return { error: `BACKUP_KEEP_MINIMUM must be an integer, got "${env.BACKUP_KEEP_MINIMUM}".` };
91
+ }
92
+
93
+ return {
94
+ config: {
95
+ schedule,
96
+ connectionString,
97
+ destination,
98
+ retentionDays: retentionDays ?? undefined,
99
+ keepMinimum: keepMinimum ?? undefined
100
+ }
101
+ };
102
+ }
103
+
104
+ function parseOptionalInt(value: string | undefined): number | null | "invalid" {
105
+ if (value === undefined || value.trim() === "") return null;
106
+ const n = Number(value);
107
+ if (!Number.isInteger(n) || n < 0) return "invalid";
108
+ return n;
109
+ }
110
+
111
+ /**
112
+ * Create a {@link CronJobDefinition} that dumps the database, uploads the
113
+ * result to the configured destination, and prunes old backups. Object
114
+ * destinations require {@link BackupCronConfig.storage}.
115
+ */
116
+ export function createBackupCron(config: BackupCronConfig): CronJobDefinition {
117
+ const dbName = parseDbNameFromUrl(config.connectionString) ?? "database";
118
+ const excludeSchemas = config.excludeSchemas ?? ["rebase"];
119
+
120
+ return {
121
+ name: config.name ?? "Scheduled database backup",
122
+ schedule: config.schedule,
123
+ description: "Dumps the Postgres database and uploads it to the configured backup destination.",
124
+ enabled: config.enabled ?? true,
125
+ // Backups of a large database can take a while; allow up to an hour.
126
+ timeoutSeconds: 3600,
127
+ async handler({ log }) {
128
+ const { createDump, pruneBackups, uploadBackup } = await import("./backup-service");
129
+ const { destination } = config;
130
+
131
+ if (destination.kind !== "local" && !config.storage) {
132
+ throw new Error(
133
+ `Backup destination is ${destination.kind} but no storage controller was provided. ` +
134
+ "Pass the backend's configured StorageController to createBackupCron({ storage })."
135
+ );
136
+ }
137
+
138
+ log(`Starting backup of "${dbName}"…`);
139
+ const outDir = destination.kind === "local" ? destination.path : undefined;
140
+ const dump = await createDump({
141
+ connectionString: config.connectionString,
142
+ dbName,
143
+ outDir,
144
+ excludeSchemas
145
+ });
146
+ log(`Dump created: ${dump.fileName} (${formatBytes(dump.sizeBytes)})`);
147
+
148
+ let storedKey = dump.localFile;
149
+ try {
150
+ if (destination.kind !== "local") {
151
+ const uploaded = await uploadBackup(config.storage!, dump.localFile, destination);
152
+ storedKey = uploaded.storageUrl;
153
+ log(`Uploaded to ${uploaded.storageUrl}`);
154
+ }
155
+ } finally {
156
+ // For object-storage destinations the local dump was a temp
157
+ // file — remove it once uploaded (or on failure).
158
+ if (destination.kind !== "local" && fs.existsSync(dump.localFile)) {
159
+ fs.unlinkSync(dump.localFile);
160
+ }
161
+ }
162
+
163
+ let pruned: string[] = [];
164
+ if (config.retentionDays && config.retentionDays > 0) {
165
+ pruned = await pruneBackups(
166
+ destination,
167
+ { retentionDays: config.retentionDays, keepMinimum: config.keepMinimum },
168
+ config.storage
169
+ );
170
+ if (pruned.length > 0) {
171
+ log(`Pruned ${pruned.length} backup(s) older than ${config.retentionDays} day(s).`);
172
+ }
173
+ }
174
+
175
+ return {
176
+ backup: storedKey,
177
+ sizeBytes: dump.sizeBytes,
178
+ pruned: pruned.length
179
+ };
180
+ }
181
+ };
182
+ }
183
+
184
+ function formatBytes(bytes: number): string {
185
+ if (bytes < 1024) return `${bytes} B`;
186
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
187
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
188
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
189
+ }