backupdrill 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BackupDrill
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # backupdrill
2
+
3
+ Open-source CLI to back up a **Supabase** project to **your own** S3/R2/B2 bucket — the Postgres database now, Storage files next — with a checksummed `manifest.json` so you can prove what was captured.
4
+
5
+ Backups stream through the CLI on your machine (or your CI) straight to your bucket. **Your data never touches our servers, because there are none — this is just a CLI.**
6
+
7
+ > Want this on a schedule, with **restore drills that prove your backup actually restores**, plus Storage-file backup and alerts? That's the hosted service → **[backupdrill.com](https://backupdrill.com)**. This CLI is the DIY layer; the cloud adds scheduling, drills, alerting, and reports on top.
8
+
9
+ ## Why
10
+
11
+ - Supabase's own database restore only brings back `storage.objects` **metadata**, not your actual Storage files.
12
+ - Pro-plan backups keep 7 days and can't be pulled into your own infrastructure; PITR is $100/mo per project.
13
+ - A `pg_dump` you've never restored is a guess.
14
+
15
+ This CLI gets your database into a bucket **you** control, with a manifest you can check.
16
+
17
+ ## Requirements
18
+
19
+ - Node.js ≥ 20
20
+ - `pg_dump` whose **major version ≥ your Supabase Postgres version** (Supabase runs PG 15–17). On macOS: `brew install libpq` then add it to `PATH`, or point `BACKUPDRILL_PG_DUMP` at the binary. The CLI checks this and refuses to run a mismatched dump.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install -g backupdrill
26
+ # or run without installing:
27
+ npx backupdrill backup
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ Configure via **env vars**, a **config file**, or **flags** (precedence: flags > env > file).
33
+
34
+ ### Env vars
35
+
36
+ ```bash
37
+ export BACKUPDRILL_DATABASE_URL="postgresql://postgres.<ref>:<pw>@aws-0-<region>.pooler.supabase.com:5432/postgres"
38
+ export BACKUPDRILL_PROJECT_NAME="my-app"
39
+ export BACKUPDRILL_S3_ENDPOINT="https://<account>.r2.cloudflarestorage.com" # omit for AWS S3
40
+ export BACKUPDRILL_S3_REGION="auto"
41
+ export BACKUPDRILL_S3_BUCKET="my-backups"
42
+ export BACKUPDRILL_S3_ACCESS_KEY_ID="…"
43
+ export BACKUPDRILL_S3_SECRET_ACCESS_KEY="…"
44
+
45
+ backupdrill backup
46
+ ```
47
+
48
+ ### Config file
49
+
50
+ Copy `backupdrill.config.example.json` → `backupdrill.config.json` (it's gitignored) and fill it in. Keep secrets in env vars even when using a file.
51
+
52
+ ```bash
53
+ backupdrill backup --config backupdrill.config.json
54
+ ```
55
+
56
+ ## Get the connection string
57
+
58
+ Use the **Session Pooler** string from your Supabase dashboard (Project Settings → Database → Connection string → Session pooler). The default `postgres` user works as-is — it owns your tables, so `pg_dump` passes row-level security.
59
+
60
+ **Least-privilege role (advanced, usually not viable on Supabase):** `pg_dump` needs `select` on *sequences* too, and it **errors on any RLS-enabled table the role can't bypass** — and most Supabase tables have RLS on, while `bypassrls` can't be granted without superuser. Only use a dedicated role if your schema has no RLS tables:
61
+
62
+ ```sql
63
+ -- replace <generate-a-strong-password> before running
64
+ create role backup_reader with login password '<generate-a-strong-password>';
65
+ grant usage on schema public to backup_reader;
66
+ grant select on all tables in schema public to backup_reader;
67
+ grant select on all sequences in schema public to backup_reader;
68
+ alter default privileges in schema public grant select on tables to backup_reader;
69
+ alter default privileges in schema public grant select on sequences to backup_reader;
70
+ ```
71
+
72
+ ## Backing up Storage files too
73
+
74
+ Supabase's own database restore only brings back `storage.objects` **metadata** — not the files. To capture the actual files, give the CLI read access to your project's Storage via its S3-compatible endpoint:
75
+
76
+ 1. Dashboard → Project → **Storage → Settings → S3 Access Keys** → enable the S3 connection and generate an **Access Key ID + Secret Access Key** (these bypass RLS; keep them server-side).
77
+ 2. Copy the **endpoint** and **region** from the same page.
78
+ 3. Provide them (env shown; config-file keys are `supabaseStorage.*`):
79
+
80
+ ```bash
81
+ export BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT="https://<ref>.storage.supabase.co/storage/v1/s3"
82
+ export BACKUPDRILL_SUPABASE_STORAGE_REGION="<project-region>"
83
+ export BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID="…"
84
+ export BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY="…"
85
+ # optional: only specific buckets — BACKUPDRILL_SUPABASE_STORAGE_BUCKETS="avatars,uploads"
86
+ ```
87
+
88
+ With those set, `backupdrill backup` also copies every Storage file into your bucket and records each file's checksum in the manifest. Omit them to back up the database only.
89
+
90
+ ## Egress & cost — read this before scheduling daily backups
91
+
92
+ A backup pulls data **out** of Supabase, which counts as **egress on your Supabase bill**. Verified rates (2026-07-04, [egress docs](https://supabase.com/docs/guides/platform/manage-your-usage/egress) + [pricing](https://supabase.com/pricing)):
93
+
94
+ - Backup traffic (pg_dump + first-time file downloads) is **uncached egress: $0.09/GB** above your plan's included allowance.
95
+ - Included uncached egress: **Free 5 GB · Pro 250 GB · Team 250 GB** per month.
96
+ - **Spend Cap is ON by default (Pro/Team).** If a backup pushes you past the quota, Supabase **throttles your whole project's egress** for the rest of the month and the backup fails — turn Spend Cap OFF to be billed the overage instead.
97
+ - **Free is a hard cap:** exceed 5 GB and the project is restricted (never billed). A large daily backup can't complete on Free.
98
+
99
+ Rough cost (Pro, Spend Cap OFF, backups = your only egress):
100
+
101
+ | DB + Storage | Weekly | Daily |
102
+ |---|---|---|
103
+ | 10 GB | $0/mo (within 250 GB) | ~$4.50/mo |
104
+ | 50 GB | ~$0/mo | **~$112/mo** ⚠ |
105
+
106
+ Cost scales with **size × frequency**. Run `backupdrill estimate` to project it for *your* project, and prefer weekly (or a smaller scope) once your data is large. The compressed dump is smaller than raw DB size, so real DB egress is below the `estimate` upper bound; Storage egress is exact.
107
+
108
+ ```bash
109
+ backupdrill estimate --plan pro # measures your DB + Storage, prints the projection + warnings
110
+ ```
111
+
112
+ ## What lands in your bucket
113
+
114
+ ```
115
+ <prefix>/<project>/<timestamp>/
116
+ ├── dump.pgcustom # pg_dump --format=custom (public schema by default)
117
+ ├── storage/<bucket>/<key> # your Storage files (if configured)
118
+ └── manifest.json # schemas, tables, row counts, file list, sizes + sha256
119
+ ```
120
+
121
+ ## Restoring & drilling
122
+
123
+ Prove a backup is restorable, without touching anything real — `drill` restores the latest snapshot into a throwaway Postgres (needs Docker) and checks it:
124
+
125
+ ```bash
126
+ backupdrill drill # latest snapshot; add --snapshot <timestamp> to pick one
127
+ backupdrill drill --verify-all-files # also checksum every Storage file, not just a sample
128
+ ```
129
+
130
+ When you actually need to recover, `restore` puts the database back into a target you name and pulls the Storage files down to a local folder:
131
+
132
+ ```bash
133
+ backupdrill restore \
134
+ --target-database-url "postgresql://…/postgres" \
135
+ --storage-dir ./recovered
136
+ ```
137
+
138
+ Or restore the dump by hand:
139
+
140
+ ```bash
141
+ pg_restore --clean --if-exists --no-owner --dbname "<target-connection-string>" dump.pgcustom
142
+ ```
143
+
144
+ ## GitHub Action / scheduled backups
145
+
146
+ Want this running on a schedule without paying for a server? Run it in GitHub Actions.
147
+
148
+ 1. Copy [`examples/scheduled-backup.yml`](examples/scheduled-backup.yml) into **your own** repo at `.github/workflows/scheduled-backup.yml`. (It lives under `examples/` here so GitHub doesn't try to run it in this repo — it's a template.) It runs daily and can also be triggered by hand from the Actions tab. The workflow installs `postgresql-client-17` from the PGDG apt repo (so `pg_dump`'s major version matches Supabase) and points the CLI at it via `BACKUPDRILL_PG_DUMP`.
149
+ 2. Add these repo secrets (Settings → Secrets and variables → Actions):
150
+ - **Required:** `BACKUPDRILL_DATABASE_URL`, `BACKUPDRILL_S3_BUCKET`, `BACKUPDRILL_S3_ACCESS_KEY_ID`, `BACKUPDRILL_S3_SECRET_ACCESS_KEY`
151
+ - **Optional (destination):** `BACKUPDRILL_S3_ENDPOINT` (for R2/B2), `BACKUPDRILL_S3_REGION`, `BACKUPDRILL_PROJECT_NAME`
152
+ - **Optional (Storage files):** set all four of `BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT`, `BACKUPDRILL_SUPABASE_STORAGE_REGION`, `BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID`, `BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY` to also back up Storage files; leave them unset to back up the database only.
153
+
154
+ Before switching to a **daily** schedule, read the egress-cost section above — every run is egress on your Supabase bill.
155
+
156
+ ## Roadmap
157
+
158
+ - [x] Database backup → your bucket, with checksummed manifest
159
+ - [x] **Storage file sync** (the files Supabase restores leave behind)
160
+ - [x] `estimate` — project your monthly Supabase egress cost before you schedule
161
+ - [x] `drill` — restore a snapshot into an ephemeral Postgres and prove it comes back
162
+ - [x] Storage-file integrity check inside the drill (checksums, sampled by default)
163
+ - [x] `restore` — recover a snapshot into a target DB + pull Storage files down
164
+ - [x] GitHub Action wrapper
165
+
166
+ For scheduling, automated restore drills, and alerting, use the hosted service at [backupdrill.com](https://backupdrill.com).
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,13 @@
1
+ import type { BackupConfig } from "./config.js";
2
+ import type { Manifest } from "./manifest.js";
3
+ /**
4
+ * Parse the major version out of `pg_dump --version` output.
5
+ *
6
+ * MUST tolerate a trailing vendor suffix: Homebrew/macOS prints `pg_dump (PostgreSQL) 17.2`, but
7
+ * Debian/PGDG (what the deployed worker image uses) prints
8
+ * `pg_dump (PostgreSQL) 17.10 (Debian 17.10-1.pgdg120+1)`. Anchoring the match to end-of-string
9
+ * therefore breaks in production while passing on a dev Mac. Anchor on the `(PostgreSQL)` marker
10
+ * instead, falling back to the first dotted version token for non-standard builds.
11
+ */
12
+ export declare function parsePgDumpMajor(raw: string): number | null;
13
+ export declare function runBackup(config: BackupConfig): Promise<Manifest>;
package/dist/backup.js ADDED
@@ -0,0 +1,195 @@
1
+ import { spawn, execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { Transform } from "node:stream";
4
+ import { promisify } from "node:util";
5
+ import { Client } from "pg";
6
+ import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
7
+ import { Upload } from "@aws-sdk/lib-storage";
8
+ import { syncStorage } from "./storage.js";
9
+ import { log } from "./log.js";
10
+ const execFileAsync = promisify(execFile);
11
+ function majorOf(serverVersionNum) {
12
+ // server_version_num: 150004 → 15, 170002 → 17
13
+ return Math.floor(serverVersionNum / 10000);
14
+ }
15
+ /**
16
+ * Parse the major version out of `pg_dump --version` output.
17
+ *
18
+ * MUST tolerate a trailing vendor suffix: Homebrew/macOS prints `pg_dump (PostgreSQL) 17.2`, but
19
+ * Debian/PGDG (what the deployed worker image uses) prints
20
+ * `pg_dump (PostgreSQL) 17.10 (Debian 17.10-1.pgdg120+1)`. Anchoring the match to end-of-string
21
+ * therefore breaks in production while passing on a dev Mac. Anchor on the `(PostgreSQL)` marker
22
+ * instead, falling back to the first dotted version token for non-standard builds.
23
+ */
24
+ export function parsePgDumpMajor(raw) {
25
+ const match = raw.match(/\(PostgreSQL\)\s+(\d+)/) ?? raw.match(/(\d+)(?:\.\d+)+/);
26
+ return match ? Number(match[1]) : null;
27
+ }
28
+ async function pgDumpVersion(bin) {
29
+ try {
30
+ const { stdout } = await execFileAsync(bin, ["--version"]);
31
+ const raw = stdout.trim();
32
+ const major = parsePgDumpMajor(raw);
33
+ if (major === null)
34
+ throw new Error(`Unrecognized pg_dump version string: ${raw}`);
35
+ return { raw, major };
36
+ }
37
+ catch (error) {
38
+ const err = error;
39
+ if (err.code === "ENOENT") {
40
+ throw new Error(`pg_dump not found (looked for "${bin}"). Install the PostgreSQL client ` +
41
+ `matching your server's major version, or set BACKUPDRILL_PG_DUMP to its path.`);
42
+ }
43
+ throw error;
44
+ }
45
+ }
46
+ async function inspectDatabase(databaseUrl, schemas) {
47
+ const client = new Client({ connectionString: databaseUrl });
48
+ await client.connect();
49
+ try {
50
+ const version = await client.query("show server_version");
51
+ const versionNum = await client.query("show server_version_num");
52
+ // 只统计将被 dump 的 schema,manifest 才与转储内容一致
53
+ const tables = await client.query(`select schemaname as schema, relname as name, n_live_tup as estimated_rows
54
+ from pg_stat_user_tables
55
+ where schemaname = any($1::text[])
56
+ order by schemaname, relname`, [schemas]);
57
+ return {
58
+ serverVersion: version.rows[0].server_version,
59
+ serverVersionNum: Number(versionNum.rows[0].server_version_num),
60
+ tables: tables.rows.map((r) => ({
61
+ schema: r.schema,
62
+ name: r.name,
63
+ estimatedRows: Number(r.estimated_rows),
64
+ })),
65
+ };
66
+ }
67
+ finally {
68
+ await client.end();
69
+ }
70
+ }
71
+ /** 流式跑 pg_dump,边写 S3 边算 sha256 与字节数。返回校验和与大小。 */
72
+ async function dumpToS3(config, s3, key, pgDumpBin) {
73
+ const hash = createHash("sha256");
74
+ let bytes = 0;
75
+ const meter = new Transform({
76
+ transform(chunk, _enc, cb) {
77
+ hash.update(chunk);
78
+ bytes += chunk.length;
79
+ cb(null, chunk);
80
+ },
81
+ });
82
+ const schemaArgs = config.schemas.map((s) => `--schema=${s}`);
83
+ const dump = spawn(pgDumpBin, [
84
+ "--format=custom",
85
+ "--no-owner",
86
+ "--no-privileges",
87
+ ...schemaArgs,
88
+ "--dbname",
89
+ config.databaseUrl,
90
+ ], { stdio: ["ignore", "pipe", "pipe"] });
91
+ let stderr = "";
92
+ dump.stderr.on("data", (d) => {
93
+ stderr += d.toString();
94
+ });
95
+ dump.stdout.pipe(meter);
96
+ const dumpDone = new Promise((resolve, reject) => {
97
+ dump.on("error", reject); // 启动失败(如 ENOENT)
98
+ dump.on("close", (code) => code === 0
99
+ ? resolve()
100
+ : reject(new Error(`pg_dump exited with code ${code}: ${stderr.trim()}`)));
101
+ });
102
+ const upload = new Upload({
103
+ client: s3,
104
+ params: {
105
+ Bucket: config.storage.bucket,
106
+ Key: key,
107
+ Body: meter,
108
+ ContentType: "application/octet-stream",
109
+ },
110
+ });
111
+ const uploadDone = upload.done();
112
+ // 正确性以 pg_dump 退出码为准:dump 失败则中止上传,绝不把半截转储当成功
113
+ try {
114
+ await dumpDone;
115
+ }
116
+ catch (error) {
117
+ meter.destroy(error);
118
+ await upload.abort().catch(() => { });
119
+ throw error;
120
+ }
121
+ await uploadDone;
122
+ return { bytes, sha256: hash.digest("hex") };
123
+ }
124
+ export async function runBackup(config) {
125
+ const pgDumpBin = process.env.BACKUPDRILL_PG_DUMP || "pg_dump";
126
+ log.step("Inspecting database…");
127
+ const db = await inspectDatabase(config.databaseUrl, config.schemas);
128
+ const serverMajor = majorOf(db.serverVersionNum);
129
+ log.ok(`Postgres ${db.serverVersion} — schema(s) ${config.schemas.join(", ")}: ` +
130
+ `${db.tables.length} tables, ` +
131
+ `~${db.tables.reduce((n, t) => n + t.estimatedRows, 0).toLocaleString()} rows`);
132
+ // pg_dump 主版本必须 ≥ 服务端,否则转储会失败或不完整(PRD §4.2)
133
+ const pgd = await pgDumpVersion(pgDumpBin);
134
+ if (pgd.major < serverMajor) {
135
+ throw new Error(`pg_dump is v${pgd.major} but the server is v${serverMajor}. ` +
136
+ `pg_dump must be >= the server major version. Install postgresql-client-${serverMajor} ` +
137
+ `(or newer) and/or point BACKUPDRILL_PG_DUMP at it.`);
138
+ }
139
+ const s3 = new S3Client({
140
+ region: config.storage.region,
141
+ endpoint: config.storage.endpoint,
142
+ forcePathStyle: config.storage.forcePathStyle,
143
+ credentials: {
144
+ accessKeyId: config.storage.accessKeyId,
145
+ secretAccessKey: config.storage.secretAccessKey,
146
+ },
147
+ });
148
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
149
+ const base = `${config.storage.prefix}/${config.projectName}/${timestamp}`;
150
+ const dumpKey = `${base}/dump.pgcustom`;
151
+ log.step(`Dumping → s3://${config.storage.bucket}/${dumpKey}`);
152
+ const { bytes, sha256 } = await dumpToS3(config, s3, dumpKey, pgDumpBin);
153
+ log.ok(`Database dumped (${(bytes / 1024 / 1024).toFixed(1)} MB, sha256 ${sha256.slice(0, 12)}…)`);
154
+ // Storage 文件同步(源配置存在才做;否则只备数据库)
155
+ let storageResult = null;
156
+ if (config.supabaseStorage) {
157
+ log.step("Syncing Storage files…");
158
+ const synced = await syncStorage(config.supabaseStorage, {
159
+ client: s3,
160
+ bucket: config.storage.bucket,
161
+ base, // syncStorage 内部会加 /storage/<bucket>/<key>
162
+ });
163
+ storageResult = {
164
+ fileCount: synced.fileCount,
165
+ totalBytes: synced.totalBytes,
166
+ files: synced.files,
167
+ };
168
+ log.ok(`Storage synced (${synced.fileCount} files, ` +
169
+ `${(synced.totalBytes / 1024 / 1024).toFixed(1)} MB)`);
170
+ }
171
+ const manifest = {
172
+ tool: "backupdrill-cli",
173
+ toolVersion: process.env.npm_package_version || "0.1.0",
174
+ createdAt: new Date().toISOString(),
175
+ projectName: config.projectName,
176
+ database: {
177
+ serverVersion: db.serverVersion,
178
+ pgDumpVersion: pgd.raw,
179
+ schemas: config.schemas,
180
+ tableCount: db.tables.length,
181
+ estimatedRowTotal: db.tables.reduce((n, t) => n + t.estimatedRows, 0),
182
+ tables: db.tables,
183
+ },
184
+ dump: { key: dumpKey, format: "custom", bytes, sha256 },
185
+ storage: storageResult,
186
+ };
187
+ await s3.send(new PutObjectCommand({
188
+ Bucket: config.storage.bucket,
189
+ Key: `${base}/manifest.json`,
190
+ Body: JSON.stringify(manifest, null, 2),
191
+ ContentType: "application/json",
192
+ }));
193
+ log.ok(`Manifest written → s3://${config.storage.bucket}/${base}/manifest.json`);
194
+ return manifest;
195
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 目标对象存储(S3 兼容:AWS S3 / Cloudflare R2 / Backblaze B2 / Supabase Storage 的 S3 端点均可)。
3
+ * 凭据永远来自用户自己的环境 —— CLI 不托管任何密钥,这是"数据不落我们盘"叙事在开源侧的兑现。
4
+ */
5
+ export interface StorageConfig {
6
+ endpoint?: string;
7
+ region: string;
8
+ bucket: string;
9
+ accessKeyId: string;
10
+ secretAccessKey: string;
11
+ prefix: string;
12
+ forcePathStyle: boolean;
13
+ }
14
+ /**
15
+ * 备份"源":Supabase Storage 的 S3 兼容端点。存在则一并同步 Storage 文件;
16
+ * 缺省则只备数据库。密钥在 Supabase 控制台 Storage → S3 Access Keys 生成。
17
+ */
18
+ export interface SupabaseStorageConfig {
19
+ endpoint: string;
20
+ region: string;
21
+ accessKeyId: string;
22
+ secretAccessKey: string;
23
+ buckets?: string[];
24
+ }
25
+ export interface BackupConfig {
26
+ databaseUrl: string;
27
+ projectName: string;
28
+ schemas: string[];
29
+ storage: StorageConfig;
30
+ supabaseStorage?: SupabaseStorageConfig;
31
+ }
32
+ interface RawConfig {
33
+ databaseUrl?: string;
34
+ projectName?: string;
35
+ schemas?: string[];
36
+ storage?: Partial<StorageConfig>;
37
+ supabaseStorage?: Partial<SupabaseStorageConfig>;
38
+ }
39
+ /** 合并优先级:显式 flag > 环境变量 > 配置文件。 */
40
+ export declare function loadConfig(opts: {
41
+ configPath: string;
42
+ overrides: RawConfig;
43
+ }): Promise<BackupConfig>;
44
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,116 @@
1
+ import { readFile } from "node:fs/promises";
2
+ function fromEnv() {
3
+ const env = process.env;
4
+ const storage = {};
5
+ if (env.BACKUPDRILL_S3_ENDPOINT)
6
+ storage.endpoint = env.BACKUPDRILL_S3_ENDPOINT;
7
+ if (env.BACKUPDRILL_S3_REGION)
8
+ storage.region = env.BACKUPDRILL_S3_REGION;
9
+ if (env.BACKUPDRILL_S3_BUCKET)
10
+ storage.bucket = env.BACKUPDRILL_S3_BUCKET;
11
+ if (env.BACKUPDRILL_S3_ACCESS_KEY_ID)
12
+ storage.accessKeyId = env.BACKUPDRILL_S3_ACCESS_KEY_ID;
13
+ if (env.BACKUPDRILL_S3_SECRET_ACCESS_KEY)
14
+ storage.secretAccessKey = env.BACKUPDRILL_S3_SECRET_ACCESS_KEY;
15
+ if (env.BACKUPDRILL_S3_PREFIX)
16
+ storage.prefix = env.BACKUPDRILL_S3_PREFIX;
17
+ if (env.BACKUPDRILL_S3_FORCE_PATH_STYLE)
18
+ storage.forcePathStyle = env.BACKUPDRILL_S3_FORCE_PATH_STYLE === "true";
19
+ const supabaseStorage = {};
20
+ if (env.BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT)
21
+ supabaseStorage.endpoint = env.BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT;
22
+ if (env.BACKUPDRILL_SUPABASE_STORAGE_REGION)
23
+ supabaseStorage.region = env.BACKUPDRILL_SUPABASE_STORAGE_REGION;
24
+ if (env.BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID)
25
+ supabaseStorage.accessKeyId = env.BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID;
26
+ if (env.BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY)
27
+ supabaseStorage.secretAccessKey =
28
+ env.BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY;
29
+ if (env.BACKUPDRILL_SUPABASE_STORAGE_BUCKETS)
30
+ supabaseStorage.buckets = env.BACKUPDRILL_SUPABASE_STORAGE_BUCKETS.split(",")
31
+ .map((s) => s.trim())
32
+ .filter(Boolean);
33
+ return {
34
+ databaseUrl: env.BACKUPDRILL_DATABASE_URL || env.DATABASE_URL,
35
+ projectName: env.BACKUPDRILL_PROJECT_NAME,
36
+ schemas: env.BACKUPDRILL_SCHEMAS
37
+ ? env.BACKUPDRILL_SCHEMAS.split(",").map((s) => s.trim()).filter(Boolean)
38
+ : undefined,
39
+ storage,
40
+ supabaseStorage,
41
+ };
42
+ }
43
+ async function fromFile(path) {
44
+ try {
45
+ return JSON.parse(await readFile(path, "utf8"));
46
+ }
47
+ catch (error) {
48
+ if (error.code === "ENOENT")
49
+ return {};
50
+ throw new Error(`Could not read config file ${path}: ${error.message}`);
51
+ }
52
+ }
53
+ /** 合并优先级:显式 flag > 环境变量 > 配置文件。 */
54
+ export async function loadConfig(opts) {
55
+ const file = await fromFile(opts.configPath);
56
+ const env = fromEnv();
57
+ const o = opts.overrides;
58
+ const databaseUrl = o.databaseUrl ?? env.databaseUrl ?? file.databaseUrl;
59
+ const projectName = o.projectName ?? env.projectName ?? file.projectName ?? "supabase";
60
+ const schemas = o.schemas ?? env.schemas ?? file.schemas ?? ["public"];
61
+ const storage = {
62
+ ...file.storage,
63
+ ...env.storage,
64
+ ...o.storage,
65
+ };
66
+ const src = {
67
+ ...file.supabaseStorage,
68
+ ...env.supabaseStorage,
69
+ ...o.supabaseStorage,
70
+ };
71
+ const missing = [];
72
+ if (!databaseUrl)
73
+ missing.push("databaseUrl (BACKUPDRILL_DATABASE_URL)");
74
+ if (!storage.bucket)
75
+ missing.push("storage.bucket (BACKUPDRILL_S3_BUCKET)");
76
+ if (!storage.accessKeyId)
77
+ missing.push("storage.accessKeyId (BACKUPDRILL_S3_ACCESS_KEY_ID)");
78
+ if (!storage.secretAccessKey)
79
+ missing.push("storage.secretAccessKey (BACKUPDRILL_S3_SECRET_ACCESS_KEY)");
80
+ if (missing.length) {
81
+ throw new Error(`Missing required config:\n - ${missing.join("\n - ")}\nSet them via env vars, a config file (--config), or flags. See README.`);
82
+ }
83
+ // Storage 源是可选的;但若给了任何一项,就必须给全,否则静默跳过 Storage 是陷阱
84
+ const srcFields = [src.endpoint, src.accessKeyId, src.secretAccessKey];
85
+ const srcGiven = srcFields.filter(Boolean).length;
86
+ let supabaseStorage;
87
+ if (srcGiven > 0 && srcGiven < 3) {
88
+ throw new Error("Incomplete Supabase Storage source config: set endpoint + accessKeyId + " +
89
+ "secretAccessKey together (or none, to back up the database only).");
90
+ }
91
+ if (srcGiven === 3) {
92
+ supabaseStorage = {
93
+ endpoint: src.endpoint,
94
+ region: src.region ?? "auto",
95
+ accessKeyId: src.accessKeyId,
96
+ secretAccessKey: src.secretAccessKey,
97
+ buckets: src.buckets,
98
+ };
99
+ }
100
+ return {
101
+ databaseUrl: databaseUrl,
102
+ projectName,
103
+ schemas: schemas.length ? schemas : ["public"],
104
+ storage: {
105
+ endpoint: storage.endpoint,
106
+ region: storage.region ?? "auto",
107
+ bucket: storage.bucket,
108
+ accessKeyId: storage.accessKeyId,
109
+ secretAccessKey: storage.secretAccessKey,
110
+ prefix: storage.prefix ?? "backupdrill",
111
+ // R2/自建默认 path-style 更省心;显式给了就尊重
112
+ forcePathStyle: storage.forcePathStyle ?? Boolean(storage.endpoint),
113
+ },
114
+ supabaseStorage,
115
+ };
116
+ }
@@ -0,0 +1,38 @@
1
+ import type { BackupConfig } from "./config.js";
2
+ import type { Manifest } from "./manifest.js";
3
+ export interface DrillCheck {
4
+ name: string;
5
+ pass: boolean;
6
+ detail: string;
7
+ }
8
+ export interface DrillReport {
9
+ snapshot: string;
10
+ pass: boolean;
11
+ checks: DrillCheck[];
12
+ restoreSeconds: number;
13
+ restoredTableCount: number;
14
+ restoredRowTotal: number;
15
+ }
16
+ /** post-data 第二遍恢复的结果:用户对象失败 vs Supabase 托管对象的沙箱预期跳过。 */
17
+ interface PostDataResult {
18
+ supabaseSkipped: number;
19
+ failures: string[];
20
+ }
21
+ /** 把 pg_restore 的 stderr 拆成单个错误块并分类(Supabase 托管 vs 用户对象)。 */
22
+ export declare function classifyPostDataErrors(stderr: string): PostDataResult;
23
+ /**
24
+ * post-data 第二遍的最终裁决:分类之外,非零退出却没解析到任何错误块(被信号杀死、
25
+ * 归档器致命错误等)绝不能谎报"全部恢复"——记为通用失败。
26
+ */
27
+ export declare function postDataResult(code: number | null, stderr: string): PostDataResult;
28
+ /**
29
+ * 演练核心:把一份本地 dump 恢复进临时 Postgres 并对照 manifest 校验,然后销毁容器。
30
+ * 与 S3 解耦,便于离线测试。`preChecks` 用于把下载阶段的校验(如归档校验和)带进报告。
31
+ */
32
+ export declare function drillDump(dumpPath: string, manifest: Manifest, snapshot: string, preChecks?: DrillCheck[]): Promise<DrillReport>;
33
+ /** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
34
+ export declare function runDrill(config: BackupConfig, opts?: {
35
+ snapshot?: string;
36
+ verifyAllFiles?: boolean;
37
+ }): Promise<DrillReport>;
38
+ export {};