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 +21 -0
- package/README.md +170 -0
- package/dist/backup.d.ts +13 -0
- package/dist/backup.js +195 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.js +116 -0
- package/dist/drill.d.ts +38 -0
- package/dist/drill.js +295 -0
- package/dist/estimate.d.ts +27 -0
- package/dist/estimate.js +102 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +186 -0
- package/dist/lib.d.ts +11 -0
- package/dist/lib.js +8 -0
- package/dist/log.d.ts +6 -0
- package/dist/log.js +12 -0
- package/dist/manifest.d.ts +41 -0
- package/dist/manifest.js +1 -0
- package/dist/restore.d.ts +16 -0
- package/dist/restore.js +77 -0
- package/dist/snapshots.d.ts +17 -0
- package/dist/snapshots.js +73 -0
- package/dist/storage.d.ts +24 -0
- package/dist/storage.js +92 -0
- package/package.json +58 -0
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
|
package/dist/backup.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/drill.d.ts
ADDED
|
@@ -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 {};
|