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