@pixpilot/supabase-backup 0.0.0 → 1.5.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.
@@ -0,0 +1,719 @@
1
+ //#region rolldown:runtime
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
+ key = keys[i];
11
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
12
+ get: ((k) => from[k]).bind(null, key),
13
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
14
+ });
15
+ }
16
+ return to;
17
+ };
18
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
+ value: mod,
20
+ enumerable: true
21
+ }) : target, mod));
22
+
23
+ //#endregion
24
+ let node_fs_promises = require("node:fs/promises");
25
+ node_fs_promises = __toESM(node_fs_promises);
26
+ let node_path = require("node:path");
27
+ node_path = __toESM(node_path);
28
+ let pg = require("pg");
29
+ pg = __toESM(pg);
30
+ let node_crypto = require("node:crypto");
31
+ node_crypto = __toESM(node_crypto);
32
+ let node_os = require("node:os");
33
+ node_os = __toESM(node_os);
34
+ let node_child_process = require("node:child_process");
35
+ node_child_process = __toESM(node_child_process);
36
+ let __aws_sdk_client_s3 = require("@aws-sdk/client-s3");
37
+ __aws_sdk_client_s3 = __toESM(__aws_sdk_client_s3);
38
+
39
+ //#region src/errors.ts
40
+ /** Error whose message is safe to display from the CLI. */
41
+ var BackupError = class extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = "BackupError";
45
+ }
46
+ };
47
+
48
+ //#endregion
49
+ //#region src/auth.ts
50
+ const unsupportedTables = [
51
+ "mfa_factors",
52
+ "sso_providers",
53
+ "saml_providers"
54
+ ];
55
+ /** Connects for read-only metadata checks; dump and restore still use libpq environment variables. */
56
+ async function connectForPreflight(connection) {
57
+ const client = new pg.Client({
58
+ host: connection.host,
59
+ port: Number(connection.port),
60
+ user: connection.user,
61
+ password: connection.password,
62
+ database: connection.database,
63
+ ssl: connection.sslmode === "disable" ? false : { rejectUnauthorized: false }
64
+ });
65
+ try {
66
+ await client.connect();
67
+ return client;
68
+ } catch {
69
+ throw new BackupError("Database preflight connection failed.");
70
+ }
71
+ }
72
+ /** Fails when non-empty durable Auth features would be omitted from the backup. */
73
+ async function ensureSupportedAuthState(db) {
74
+ const result = await db.query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'auth' AND table_name IN ('mfa_factors', 'sso_providers', 'saml_providers')");
75
+ for (const { table_name } of result.rows) {
76
+ const safeName = unsupportedTables.find((name) => name === table_name);
77
+ if (!safeName) continue;
78
+ if ((await db.query(`SELECT EXISTS (SELECT 1 FROM auth.${safeName} LIMIT 1) AS has_rows`)).rows[0]?.has_rows) throw new BackupError(`Unsupported durable Auth state detected in auth.${safeName}. Add explicit support before backing up this project.`);
79
+ }
80
+ }
81
+ /** Reads Auth column names/types for compatibility checks recorded in the manifest. */
82
+ async function getAuthColumns(db) {
83
+ const rows = await db.query("SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = 'auth' AND table_name IN ('users', 'identities') ORDER BY table_name, ordinal_position");
84
+ const result = {
85
+ "auth.users": [],
86
+ "auth.identities": []
87
+ };
88
+ for (const row of rows.rows) {
89
+ const table = `auth.${row.table_name}`;
90
+ if (table in result) result[table].push({
91
+ name: row.column_name,
92
+ dataType: row.data_type
93
+ });
94
+ }
95
+ if (Object.values(result).some((columns) => columns.length === 0)) throw new BackupError("Required Auth tables are missing.");
96
+ return result;
97
+ }
98
+ /** Verifies target Auth columns/types can accept the backed-up data. */
99
+ async function ensureAuthCompatible(db, expected) {
100
+ const actual = await getAuthColumns(db);
101
+ for (const table of Object.keys(expected)) for (const column of expected[table]) {
102
+ const target = actual[table].find((candidate) => candidate.name === column.name);
103
+ if (!target || target.dataType !== column.dataType) throw new BackupError(`Target ${table} is incompatible at column '${column.name}'. Use a Supabase project with matching Auth schema.`);
104
+ }
105
+ }
106
+ /** Returns row counts for tables included in backup/restore validation. */
107
+ async function getTableCounts(db, tables) {
108
+ const result = [];
109
+ for (const table of tables) {
110
+ if (!/^(auth\.(users|identities)|[A-Za-z_][\w$]*\.[A-Za-z_][\w$]*)$/u.test(table)) throw new BackupError("Invalid table identifier.");
111
+ const [schema, name] = table.split(".");
112
+ if (schema === void 0 || name === void 0) throw new BackupError("Invalid table identifier.");
113
+ const count = await db.query(`SELECT COUNT(*)::text AS count FROM "${schema}"."${name}"`);
114
+ result.push({
115
+ table,
116
+ count: Number(count.rows[0]?.count || 0)
117
+ });
118
+ }
119
+ return result;
120
+ }
121
+ /** Requires empty target Auth tables before modifying them. */
122
+ async function ensureAuthTablesEmpty(db) {
123
+ if ((await getTableCounts(db, ["auth.users", "auth.identities"])).some(({ count }) => count !== 0)) throw new BackupError("Target auth.users and auth.identities must be empty for recovery restore.");
124
+ }
125
+ /** Finds application table names for post-restore count validation. */
126
+ async function getApplicationTables(db, schemas) {
127
+ const values = schemas.map((schema) => `'${schema.replace(/'/gu, "''")}'`).join(", ");
128
+ return (await db.query(`SELECT table_schema, table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema IN (${values}) ORDER BY table_schema, table_name`)).rows.map(({ table_schema, table_name }) => `${table_schema}.${table_name}`);
129
+ }
130
+ /** Confirms restored table counts match the source manifest. */
131
+ async function ensureCountsMatch(db, expected) {
132
+ const actual = await getTableCounts(db, expected.map(({ table }) => table));
133
+ for (const expectedCount of expected) {
134
+ const current = actual.find(({ table }) => table === expectedCount.table);
135
+ if (!current || current.count !== expectedCount.count) throw new BackupError(`Restore validation failed for ${expectedCount.table}: row count does not match the backup manifest.`);
136
+ }
137
+ }
138
+
139
+ //#endregion
140
+ //#region src/config.ts
141
+ function required(env, name) {
142
+ const value = env[name]?.trim();
143
+ if (!value) throw new BackupError(`${name} is required.`);
144
+ return value;
145
+ }
146
+ function r2Config(env) {
147
+ return {
148
+ accessKeyId: required(env, "R2_ACCESS_KEY_ID"),
149
+ secretAccessKey: required(env, "R2_SECRET_ACCESS_KEY"),
150
+ endpoint: required(env, "R2_ENDPOINT"),
151
+ bucket: required(env, "R2_BUCKET")
152
+ };
153
+ }
154
+ /** Loads and validates backup-only environment configuration. */
155
+ function loadBackupConfig(env = process.env) {
156
+ const appSchemas = (env["APP_SCHEMAS"] || "public").split(",").map((schema) => schema.trim()).filter(Boolean);
157
+ if (!appSchemas.length || appSchemas.includes("auth") || appSchemas.some((schema) => !/^[A-Za-z_][\w$]*$/u.test(schema))) throw new BackupError("APP_SCHEMAS must contain valid application schemas and must not include auth.");
158
+ const prefix = required(env, "BACKUP_PREFIX").replace(/^\/+|\/+$/gu, "");
159
+ if (!prefix || prefix.includes("..")) throw new BackupError("BACKUP_PREFIX must be a non-empty object-key prefix.");
160
+ return {
161
+ ...r2Config(env),
162
+ ageRecipient: required(env, "BACKUP_AGE_RECIPIENT"),
163
+ sourceDatabaseUrl: required(env, "SOURCE_DATABASE_URL"),
164
+ prefix,
165
+ appSchemas
166
+ };
167
+ }
168
+ /** Loads restore credentials while allowing dry-run archive checks without a target. */
169
+ function loadRestoreConfig(env = process.env, requireTarget = false) {
170
+ const targetDatabaseUrl = env["TARGET_DATABASE_URL"]?.trim();
171
+ if (requireTarget && !targetDatabaseUrl) throw new BackupError("TARGET_DATABASE_URL is required with --apply.");
172
+ return {
173
+ ...r2Config(env),
174
+ ageIdentity: required(env, "AGE_IDENTITY"),
175
+ ...targetDatabaseUrl ? { targetDatabaseUrl } : {},
176
+ ...env["SOURCE_DATABASE_URL"]?.trim() ? { sourceDatabaseUrl: env["SOURCE_DATABASE_URL"].trim() } : {}
177
+ };
178
+ }
179
+ /** Loads the R2 prefix and credentials required by the read-only health check. */
180
+ function loadStatusConfig(env = process.env) {
181
+ const prefix = required(env, "BACKUP_PREFIX").replace(/^\/+|\/+$/gu, "");
182
+ if (!prefix || prefix.includes("..")) throw new BackupError("BACKUP_PREFIX must be a non-empty object-key prefix.");
183
+ return {
184
+ ...r2Config(env),
185
+ prefix
186
+ };
187
+ }
188
+
189
+ //#endregion
190
+ //#region src/database-url.ts
191
+ /** Parses a PostgreSQL URL without retaining it in process arguments. */
192
+ function parseDatabaseUrl(value) {
193
+ let url;
194
+ try {
195
+ url = new URL(value);
196
+ } catch {
197
+ throw new BackupError("Database URL must be a valid PostgreSQL URL.");
198
+ }
199
+ if (!["postgres:", "postgresql:"].includes(url.protocol) || !url.hostname || !url.pathname.slice(1)) throw new BackupError("Database URL must include protocol, host, and database name.");
200
+ const port = url.port || "5432";
201
+ if (port === "6543") throw new BackupError("Port 6543 is the Transaction Pooler and cannot be used for backup or restore. Use direct PostgreSQL or the Session Pooler on port 5432.");
202
+ return {
203
+ host: url.hostname,
204
+ port,
205
+ user: decodeURIComponent(url.username),
206
+ password: decodeURIComponent(url.password),
207
+ database: decodeURIComponent(url.pathname.slice(1)),
208
+ sslmode: url.searchParams.get("sslmode") || "require"
209
+ };
210
+ }
211
+ /** Converts a parsed URL to the libpq environment passed to PostgreSQL tools. */
212
+ function toLibpqEnvironment(connection) {
213
+ return {
214
+ PGHOST: connection.host,
215
+ PGPORT: connection.port,
216
+ PGUSER: connection.user,
217
+ PGPASSWORD: connection.password,
218
+ PGDATABASE: connection.database,
219
+ PGSSLMODE: connection.sslmode
220
+ };
221
+ }
222
+ /** Produces a non-secret target label used for typed restore confirmation. */
223
+ function databaseLabel(connection) {
224
+ return `${connection.host}:${connection.port}/${connection.database}`;
225
+ }
226
+ /** Prevents a configured source database from also being used as a restore target. */
227
+ function ensureDifferentDatabases(source, target) {
228
+ if (source && databaseLabel(source) === databaseLabel(target) && source.user === target.user) throw new BackupError("TARGET_DATABASE_URL resolves to SOURCE_DATABASE_URL. Restore into a fresh recovery target instead.");
229
+ }
230
+
231
+ //#endregion
232
+ //#region src/files.ts
233
+ /** Creates a private temporary directory and always removes it after the callback. */
234
+ async function withTemporaryDirectory(callback) {
235
+ const directory = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "supabase-backup-"));
236
+ try {
237
+ return await callback(directory);
238
+ } finally {
239
+ await (0, node_fs_promises.rm)(directory, {
240
+ force: true,
241
+ recursive: true
242
+ });
243
+ }
244
+ }
245
+ /** Computes the SHA-256 digest of a file. */
246
+ async function sha256File(path) {
247
+ return (0, node_crypto.createHash)("sha256").update(await (0, node_fs_promises.readFile)(path)).digest("hex");
248
+ }
249
+ /** Ensures a dump exists and is not empty before inspecting it. */
250
+ async function ensureNonEmptyFile(path) {
251
+ try {
252
+ if ((await (0, node_fs_promises.stat)(path)).size === 0) throw new Error("empty");
253
+ } catch {
254
+ throw new BackupError("Archive is missing or empty.");
255
+ }
256
+ }
257
+ /** Writes a private secret file used only as an age identity input. */
258
+ async function writePrivateFile(path, contents) {
259
+ await (0, node_fs_promises.writeFile)(path, contents, { mode: 384 });
260
+ }
261
+
262
+ //#endregion
263
+ //#region src/manifest.ts
264
+ const authTables = ["auth.users", "auth.identities"];
265
+ /** Generates immutable object names for one UTC backup timestamp. */
266
+ function backupObjectKeys(prefix, createdAt) {
267
+ const iso = createdAt.toISOString().replace(/\.\d{3}Z$/u, "Z").replace(/:/gu, "-");
268
+ const base = `${prefix}/${createdAt.toISOString().slice(0, 10).replace(/-/gu, "/")}/${iso}`;
269
+ return {
270
+ app: `${base}.app.dump.age`,
271
+ auth: `${base}.auth.dump.age`,
272
+ appChecksum: `${base}.app.sha256`,
273
+ authChecksum: `${base}.auth.sha256`,
274
+ manifest: `${base}.json`
275
+ };
276
+ }
277
+ /** Rejects malformed manifests before they can guide a restore. */
278
+ function parseManifest(value) {
279
+ let manifest;
280
+ try {
281
+ manifest = JSON.parse(value);
282
+ } catch {
283
+ throw new BackupError("Backup manifest is not valid JSON.");
284
+ }
285
+ const item = manifest;
286
+ if (!item.createdAt || !item.appObjectKey || !item.appChecksumObjectKey || !item.authObjectKey || !item.authChecksumObjectKey || !/^[a-f0-9]{64}$/u.test(item.appSha256 || "") || !/^[a-f0-9]{64}$/u.test(item.authSha256 || "") || !Array.isArray(item.authTables) || !item.authColumns || !item.appTableCounts || !item.authRowCounts) throw new BackupError("Backup manifest is incomplete or invalid.");
287
+ return item;
288
+ }
289
+
290
+ //#endregion
291
+ //#region src/process.ts
292
+ /** Runs a required system program without printing its potentially sensitive output. */
293
+ const systemRunner = { async run(command, args, options = {}) {
294
+ return new Promise((resolve, reject) => {
295
+ const child = (0, node_child_process.spawn)(command, args, {
296
+ env: {
297
+ ...process.env,
298
+ ...options.env
299
+ },
300
+ stdio: [
301
+ "ignore",
302
+ "pipe",
303
+ "pipe"
304
+ ]
305
+ });
306
+ let stdout = "";
307
+ child.stdout.on("data", (chunk) => {
308
+ stdout += chunk.toString();
309
+ });
310
+ child.once("error", () => reject(new BackupError(`Required system tool '${command}' is unavailable.`)));
311
+ child.once("close", (code) => code === 0 ? resolve(stdout.trim()) : reject(new BackupError(`${command} failed; no secrets or command output were logged.`)));
312
+ });
313
+ } };
314
+
315
+ //#endregion
316
+ //#region src/r2.ts
317
+ /** R2 object store using S3-compatible, path-style requests. */
318
+ var R2Store = class {
319
+ client;
320
+ constructor(config) {
321
+ this.config = config;
322
+ this.client = new __aws_sdk_client_s3.S3Client({
323
+ credentials: {
324
+ accessKeyId: config.accessKeyId,
325
+ secretAccessKey: config.secretAccessKey
326
+ },
327
+ endpoint: config.endpoint,
328
+ forcePathStyle: true,
329
+ region: "auto"
330
+ });
331
+ }
332
+ async has(key) {
333
+ try {
334
+ await this.client.send(new __aws_sdk_client_s3.HeadObjectCommand({
335
+ Bucket: this.config.bucket,
336
+ Key: key
337
+ }));
338
+ return true;
339
+ } catch (error) {
340
+ if (error.$metadata?.httpStatusCode === 404) return false;
341
+ throw new BackupError("R2 object lookup failed.");
342
+ }
343
+ }
344
+ async putImmutable(key, body) {
345
+ if (await this.has(key)) throw new BackupError(`Refusing to overwrite existing R2 object '${key}'.`);
346
+ try {
347
+ await this.client.send(new __aws_sdk_client_s3.PutObjectCommand({
348
+ Bucket: this.config.bucket,
349
+ Key: key,
350
+ Body: body,
351
+ IfNoneMatch: "*"
352
+ }));
353
+ } catch {
354
+ throw new BackupError(`R2 upload failed for '${key}'.`);
355
+ }
356
+ }
357
+ async get(key) {
358
+ try {
359
+ return await (await this.client.send(new __aws_sdk_client_s3.GetObjectCommand({
360
+ Bucket: this.config.bucket,
361
+ Key: key
362
+ }))).Body.transformToByteArray();
363
+ } catch {
364
+ throw new BackupError(`R2 download failed for '${key}'.`);
365
+ }
366
+ }
367
+ async list(prefix) {
368
+ try {
369
+ const keys = [];
370
+ let token;
371
+ do {
372
+ const page = await this.client.send(new __aws_sdk_client_s3.ListObjectsV2Command({
373
+ Bucket: this.config.bucket,
374
+ Prefix: prefix,
375
+ ContinuationToken: token
376
+ }));
377
+ keys.push(...(page.Contents || []).flatMap((entry) => entry.Key ? [entry.Key] : []));
378
+ token = page.NextContinuationToken;
379
+ } while (token);
380
+ return keys;
381
+ } catch {
382
+ throw new BackupError("R2 object listing failed.");
383
+ }
384
+ }
385
+ };
386
+
387
+ //#endregion
388
+ //#region src/backup.ts
389
+ const packageVersion = "1.0.0";
390
+ /** Creates encrypted, immutable app/Auth archives and publishes their manifest last. */
391
+ async function backup(env = process.env, dependencies = {}) {
392
+ return backupWithConfig(loadBackupConfig(env), dependencies);
393
+ }
394
+ /** Implements backup with injected dependencies for deterministic tests. */
395
+ async function backupWithConfig(config, dependencies = {}) {
396
+ const runner = dependencies.runner || systemRunner;
397
+ const store = dependencies.store || new R2Store(config);
398
+ const connection = parseDatabaseUrl(config.sourceDatabaseUrl);
399
+ const preflight = await connectForPreflight(connection);
400
+ let serverVersion;
401
+ let authColumns;
402
+ let appTableCounts;
403
+ let authRowCounts;
404
+ try {
405
+ await ensureSupportedAuthState(preflight);
406
+ serverVersion = (await preflight.query("SHOW server_version")).rows[0]?.version || "unknown";
407
+ authColumns = await getAuthColumns(preflight);
408
+ appTableCounts = await getTableCounts(preflight, await getApplicationTables(preflight, config.appSchemas));
409
+ authRowCounts = await getTableCounts(preflight, [...authTables]);
410
+ } finally {
411
+ await preflight.end();
412
+ }
413
+ const createdAt = dependencies.now || /* @__PURE__ */ new Date();
414
+ const keys = backupObjectKeys(config.prefix, createdAt);
415
+ for (const key of Object.values(keys)) if (await store.has(key)) throw new BackupError(`Refusing to overwrite existing R2 object '${key}'.`);
416
+ return withTemporaryDirectory(async (directory) => {
417
+ const appDump = (0, node_path.join)(directory, "app.dump");
418
+ const authDump = (0, node_path.join)(directory, "auth.dump");
419
+ const appEncrypted = `${appDump}.age`;
420
+ const authEncrypted = `${authDump}.age`;
421
+ const pgEnv = toLibpqEnvironment(connection);
422
+ await runner.run("pg_dump", [
423
+ "--format=custom",
424
+ ...config.appSchemas.map((schema) => `--schema=${schema}`),
425
+ "--no-owner",
426
+ "--no-privileges",
427
+ `--file=${appDump}`
428
+ ], { env: pgEnv });
429
+ await runner.run("pg_dump", [
430
+ "--format=custom",
431
+ "--data-only",
432
+ ...authTables.map((table) => `--table=${table}`),
433
+ "--no-owner",
434
+ "--no-privileges",
435
+ `--file=${authDump}`
436
+ ], { env: pgEnv });
437
+ for (const archive of [appDump, authDump]) {
438
+ await ensureNonEmptyFile(archive);
439
+ await runner.run("pg_restore", ["--list", archive]);
440
+ }
441
+ await runner.run("age", [
442
+ "--recipient",
443
+ config.ageRecipient,
444
+ "--output",
445
+ appEncrypted,
446
+ appDump
447
+ ]);
448
+ await (0, node_fs_promises.unlink)(appDump);
449
+ await runner.run("age", [
450
+ "--recipient",
451
+ config.ageRecipient,
452
+ "--output",
453
+ authEncrypted,
454
+ authDump
455
+ ]);
456
+ await (0, node_fs_promises.unlink)(authDump);
457
+ const [appSha256, authSha256, appBytes, authBytes, pgDumpVersion] = await Promise.all([
458
+ sha256File(appEncrypted),
459
+ sha256File(authEncrypted),
460
+ (0, node_fs_promises.stat)(appEncrypted).then((file) => file.size),
461
+ (0, node_fs_promises.stat)(authEncrypted).then((file) => file.size),
462
+ runner.run("pg_dump", ["--version"])
463
+ ]);
464
+ const manifest = {
465
+ createdAt: createdAt.toISOString(),
466
+ environment: config.prefix.split("/")[0] || "default",
467
+ appObjectKey: keys.app,
468
+ appChecksumObjectKey: keys.appChecksum,
469
+ authObjectKey: keys.auth,
470
+ authChecksumObjectKey: keys.authChecksum,
471
+ appSha256,
472
+ authSha256,
473
+ appEncryptedBytes: appBytes,
474
+ authEncryptedBytes: authBytes,
475
+ appSchemas: config.appSchemas,
476
+ authTables: [...authTables],
477
+ pgDumpVersion,
478
+ postgresServerVersion: serverVersion,
479
+ cliVersion: packageVersion,
480
+ authColumns,
481
+ appTableCounts,
482
+ authRowCounts
483
+ };
484
+ await store.putImmutable(keys.app, await (0, node_fs_promises.readFile)(appEncrypted));
485
+ await store.putImmutable(keys.auth, await (0, node_fs_promises.readFile)(authEncrypted));
486
+ await store.putImmutable(keys.appChecksum, Buffer.from(`${appSha256} ${keys.app.split("/").at(-1)}\n`));
487
+ await store.putImmutable(keys.authChecksum, Buffer.from(`${authSha256} ${keys.auth.split("/").at(-1)}\n`));
488
+ await store.putImmutable(keys.manifest, Buffer.from(`${JSON.stringify(manifest)}\n`));
489
+ for (const key of Object.values(keys)) if (!await store.has(key)) throw new BackupError("R2 verification failed after upload; manifest cannot be trusted.");
490
+ return manifest;
491
+ });
492
+ }
493
+
494
+ //#endregion
495
+ //#region src/redact.ts
496
+ /** Removes connection strings, age identities, and common credential values from text. */
497
+ function redact(value) {
498
+ return value.replace(/postgres(?:ql)?:\/\/[^\s'"`]+/giu, "[redacted database URL]").replace(/AGE-SECRET-KEY-[\w-]+/giu, "[redacted age identity]").replace(/(?:password|secret|token|access_key)\s*[=:]\s*[^\s,]+/giu, "$1=[redacted]");
499
+ }
500
+
501
+ //#endregion
502
+ //#region src/restore.ts
503
+ /** Downloads, verifies, decrypts, and optionally restores a single manifest. */
504
+ async function restore(options, env = process.env, dependencies = {}) {
505
+ if (!options.key.endsWith(".json") || options.key.includes("..")) throw new BackupError("--key must be an immutable backup manifest key ending in .json.");
506
+ const config = loadRestoreConfig(env, options.apply);
507
+ const store = dependencies.store || new R2Store(config);
508
+ const runner = dependencies.runner || systemRunner;
509
+ const manifest = parseManifest(Buffer.from(await store.get(options.key)).toString("utf8"));
510
+ const target = config.targetDatabaseUrl ? parseDatabaseUrl(config.targetDatabaseUrl) : void 0;
511
+ if (target) ensureDifferentDatabases(config.sourceDatabaseUrl ? parseDatabaseUrl(config.sourceDatabaseUrl) : void 0, target);
512
+ if (options.apply) {
513
+ if (!target) throw new BackupError("TARGET_DATABASE_URL is required with --apply.");
514
+ if (options.confirmTarget !== databaseLabel(target)) throw new BackupError(`Restore confirmation must exactly equal '${databaseLabel(target)}'.`);
515
+ }
516
+ return withTemporaryDirectory(async (directory) => {
517
+ const appEncrypted = (0, node_path.join)(directory, "app.dump.age");
518
+ const authEncrypted = (0, node_path.join)(directory, "auth.dump.age");
519
+ const appDump = (0, node_path.join)(directory, "app.dump");
520
+ const authDump = (0, node_path.join)(directory, "auth.dump");
521
+ const identity = (0, node_path.join)(directory, "identity.txt");
522
+ const [appChecksum, authChecksum] = await Promise.all([
523
+ store.get(manifest.appChecksumObjectKey),
524
+ store.get(manifest.authChecksumObjectKey),
525
+ writePrivateFile(appEncrypted, await store.get(manifest.appObjectKey)),
526
+ writePrivateFile(authEncrypted, await store.get(manifest.authObjectKey)),
527
+ writePrivateFile(identity, config.ageIdentity)
528
+ ]);
529
+ const [actualAppSha, actualAuthSha] = await Promise.all([sha256File(appEncrypted), sha256File(authEncrypted)]);
530
+ if (actualAppSha !== manifest.appSha256 || actualAuthSha !== manifest.authSha256 || !Buffer.from(appChecksum).toString("utf8").startsWith(manifest.appSha256) || !Buffer.from(authChecksum).toString("utf8").startsWith(manifest.authSha256)) throw new BackupError("Encrypted archive checksum verification failed.");
531
+ await runner.run("age", [
532
+ "--decrypt",
533
+ "--identity",
534
+ identity,
535
+ "--output",
536
+ appDump,
537
+ appEncrypted
538
+ ]);
539
+ await runner.run("age", [
540
+ "--decrypt",
541
+ "--identity",
542
+ identity,
543
+ "--output",
544
+ authDump,
545
+ authEncrypted
546
+ ]);
547
+ for (const archive of [appDump, authDump]) {
548
+ await ensureNonEmptyFile(archive);
549
+ await runner.run("pg_restore", ["--list", archive]);
550
+ }
551
+ if (!target) {
552
+ process.stdout.write(`Restore plan (no changes): manifest ${options.key}; app schemas ${manifest.appSchemas.join(", ")}; Auth tables ${manifest.authTables.join(", ")}. Supply TARGET_DATABASE_URL to run compatibility preflight.\n`);
553
+ return manifest;
554
+ }
555
+ const targetDb = await connectForPreflight(target);
556
+ try {
557
+ await ensureAuthCompatible(targetDb, manifest.authColumns);
558
+ if (!options.apply) {
559
+ process.stdout.write(`Restore plan (no changes): target ${databaseLabel(target)}; Auth tables ${manifest.authTables.join(", ")} then application schemas ${manifest.appSchemas.join(", ")}.\n`);
560
+ return manifest;
561
+ }
562
+ await ensureAuthTablesEmpty(targetDb);
563
+ const pgEnv = toLibpqEnvironment(target);
564
+ for (const table of manifest.authTables) await runner.run("pg_restore", [
565
+ "--data-only",
566
+ "--no-owner",
567
+ "--no-privileges",
568
+ "--exit-on-error",
569
+ `--table=${table}`,
570
+ authDump
571
+ ], { env: pgEnv });
572
+ await runner.run("pg_restore", [
573
+ "--no-owner",
574
+ "--no-privileges",
575
+ "--clean",
576
+ "--if-exists",
577
+ "--exit-on-error",
578
+ appDump
579
+ ], { env: pgEnv });
580
+ await ensureCountsMatch(targetDb, manifest.authRowCounts);
581
+ await ensureCountsMatch(targetDb, manifest.appTableCounts);
582
+ } finally {
583
+ await targetDb.end();
584
+ }
585
+ process.stdout.write(`Restore database phase complete for ${databaseLabel(target)}. Manually verify an existing user can authenticate and run a representative application workflow before declaring recovery complete.\n`);
586
+ return manifest;
587
+ });
588
+ }
589
+
590
+ //#endregion
591
+ //#region src/status.ts
592
+ /** Finds the latest complete manifest and checks its referenced archives remain available. */
593
+ async function getBackupStatus(prefix, store, now = /* @__PURE__ */ new Date()) {
594
+ const keys = (await store.list(`${prefix}/`)).filter((key) => key.endsWith(".json")).sort().reverse();
595
+ for (const manifestKey of keys) {
596
+ let manifest;
597
+ try {
598
+ manifest = parseManifest(Buffer.from(await store.get(manifestKey)).toString("utf8"));
599
+ } catch {
600
+ continue;
601
+ }
602
+ if (!await store.has(manifest.appObjectKey) || !await store.has(manifest.authObjectKey) || !await store.has(manifest.appChecksumObjectKey) || !await store.has(manifest.authChecksumObjectKey)) throw new BackupError(`Latest backup manifest '${manifestKey}' references missing R2 objects.`);
603
+ const ageHours = (now.getTime() - new Date(manifest.createdAt).getTime()) / 36e5;
604
+ if (!Number.isFinite(ageHours) || ageHours < 0) continue;
605
+ return {
606
+ manifest,
607
+ manifestKey,
608
+ ageHours
609
+ };
610
+ }
611
+ throw new BackupError("No valid completed backup manifest exists.");
612
+ }
613
+ /** Prints a machine-safe health result and fails when no recent valid backup exists. */
614
+ async function status(maxAgeHours, env = process.env) {
615
+ if (maxAgeHours !== void 0 && (!Number.isFinite(maxAgeHours) || maxAgeHours < 0)) throw new BackupError("--max-age-hours must be a non-negative number.");
616
+ const config = loadStatusConfig(env);
617
+ const result = await getBackupStatus(config.prefix, new R2Store(config));
618
+ if (maxAgeHours !== void 0 && result.ageHours > maxAgeHours) throw new BackupError(`Latest backup is ${result.ageHours.toFixed(1)} hours old, exceeding ${maxAgeHours} hours.`);
619
+ process.stdout.write(`Backup healthy: ${result.manifest.createdAt} (${result.ageHours.toFixed(1)} hours old)\nManifest: ${result.manifestKey}\n`);
620
+ return result;
621
+ }
622
+
623
+ //#endregion
624
+ Object.defineProperty(exports, 'BackupError', {
625
+ enumerable: true,
626
+ get: function () {
627
+ return BackupError;
628
+ }
629
+ });
630
+ Object.defineProperty(exports, 'backup', {
631
+ enumerable: true,
632
+ get: function () {
633
+ return backup;
634
+ }
635
+ });
636
+ Object.defineProperty(exports, 'backupObjectKeys', {
637
+ enumerable: true,
638
+ get: function () {
639
+ return backupObjectKeys;
640
+ }
641
+ });
642
+ Object.defineProperty(exports, 'backupWithConfig', {
643
+ enumerable: true,
644
+ get: function () {
645
+ return backupWithConfig;
646
+ }
647
+ });
648
+ Object.defineProperty(exports, 'databaseLabel', {
649
+ enumerable: true,
650
+ get: function () {
651
+ return databaseLabel;
652
+ }
653
+ });
654
+ Object.defineProperty(exports, 'ensureDifferentDatabases', {
655
+ enumerable: true,
656
+ get: function () {
657
+ return ensureDifferentDatabases;
658
+ }
659
+ });
660
+ Object.defineProperty(exports, 'getBackupStatus', {
661
+ enumerable: true,
662
+ get: function () {
663
+ return getBackupStatus;
664
+ }
665
+ });
666
+ Object.defineProperty(exports, 'loadBackupConfig', {
667
+ enumerable: true,
668
+ get: function () {
669
+ return loadBackupConfig;
670
+ }
671
+ });
672
+ Object.defineProperty(exports, 'loadRestoreConfig', {
673
+ enumerable: true,
674
+ get: function () {
675
+ return loadRestoreConfig;
676
+ }
677
+ });
678
+ Object.defineProperty(exports, 'loadStatusConfig', {
679
+ enumerable: true,
680
+ get: function () {
681
+ return loadStatusConfig;
682
+ }
683
+ });
684
+ Object.defineProperty(exports, 'parseDatabaseUrl', {
685
+ enumerable: true,
686
+ get: function () {
687
+ return parseDatabaseUrl;
688
+ }
689
+ });
690
+ Object.defineProperty(exports, 'parseManifest', {
691
+ enumerable: true,
692
+ get: function () {
693
+ return parseManifest;
694
+ }
695
+ });
696
+ Object.defineProperty(exports, 'redact', {
697
+ enumerable: true,
698
+ get: function () {
699
+ return redact;
700
+ }
701
+ });
702
+ Object.defineProperty(exports, 'restore', {
703
+ enumerable: true,
704
+ get: function () {
705
+ return restore;
706
+ }
707
+ });
708
+ Object.defineProperty(exports, 'status', {
709
+ enumerable: true,
710
+ get: function () {
711
+ return status;
712
+ }
713
+ });
714
+ Object.defineProperty(exports, 'toLibpqEnvironment', {
715
+ enumerable: true,
716
+ get: function () {
717
+ return toLibpqEnvironment;
718
+ }
719
+ });