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/dist/drill.js ADDED
@@ -0,0 +1,295 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { Client } from "pg";
7
+ import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
8
+ import { log } from "./log.js";
9
+ const execFileAsync = promisify(execFile);
10
+ // Storage 校验默认抽样上限(避免每次演练重下全部文件产生大量 egress);超限如实记录
11
+ const STORAGE_SAMPLE_CAP = 100;
12
+ async function startEphemeralPostgres(major) {
13
+ const image = `postgres:${major}-alpine`;
14
+ log.step(`Starting ephemeral ${image}…`);
15
+ const { stdout } = await execFileAsync("docker", [
16
+ "run",
17
+ "-d",
18
+ "--rm",
19
+ "-e",
20
+ "POSTGRES_PASSWORD=drill",
21
+ "-e",
22
+ "POSTGRES_DB=postgres",
23
+ "-p",
24
+ "127.0.0.1:0:5432", // 随机主机端口,避免撞端口
25
+ image,
26
+ ]);
27
+ const containerId = stdout.trim();
28
+ try {
29
+ const portOut = await execFileAsync("docker", [
30
+ "port",
31
+ containerId,
32
+ "5432/tcp",
33
+ ]);
34
+ // 形如 "127.0.0.1:54321"
35
+ const port = portOut.stdout.trim().split(":").pop().trim();
36
+ const connString = `postgresql://postgres:drill@127.0.0.1:${port}/postgres`;
37
+ // 等就绪(容器内 pg_isready),最多 ~30s。
38
+ // 必须 -h 127.0.0.1 强制走 TCP:官方镜像 initdb 阶段先起一个只听 unix socket 的临时
39
+ // 服务跑初始化,默认(socket)检查在该阶段就通过,pg_restore 随后连 TCP 正撞上
40
+ // 临时服务停止/正式服务重启的窗口 → "server closed the connection unexpectedly"。
41
+ // 阶段一不听 TCP,所以 TCP 检查首次通过 = 正式服务已就绪。
42
+ for (let i = 0; i < 60; i++) {
43
+ try {
44
+ await execFileAsync("docker", [
45
+ "exec",
46
+ containerId,
47
+ "pg_isready",
48
+ "-h",
49
+ "127.0.0.1",
50
+ "-U",
51
+ "postgres",
52
+ "-q",
53
+ ]);
54
+ return { containerId, port, connString };
55
+ }
56
+ catch {
57
+ await new Promise((r) => setTimeout(r, 500));
58
+ }
59
+ }
60
+ throw new Error("ephemeral Postgres did not become ready within 30s");
61
+ }
62
+ catch (error) {
63
+ await execFileAsync("docker", ["rm", "-f", containerId]).catch(() => { });
64
+ throw error;
65
+ }
66
+ }
67
+ function spawnPgRestore(bin, args) {
68
+ return new Promise((resolve, reject) => {
69
+ const proc = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
70
+ let stderr = "";
71
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
72
+ proc.on("error", reject);
73
+ proc.on("close", (code) => resolve({ code, stderr }));
74
+ });
75
+ }
76
+ // 演练沙箱是裸 Postgres,Supabase 托管的 schema/角色必然缺席。post-data 里
77
+ // 引用它们的失败是"环境预期",不是备份坏了;其余失败才是演练要抓的。
78
+ const SUPABASE_MANAGED_ERROR = /schema "(auth|storage|realtime|vault|extensions|graphql[a-z_]*)" does not exist|role "(authenticated|anon|service_role|supabase_[a-z_]+)" does not exist|\bauth\.uid\b|\bauth\.jwt\b/i;
79
+ /** 把 pg_restore 的 stderr 拆成单个错误块并分类(Supabase 托管 vs 用户对象)。 */
80
+ export function classifyPostDataErrors(stderr) {
81
+ const blocks = stderr
82
+ .split(/(?=pg_restore: error:)/)
83
+ .filter((b) => /pg_restore: error:/.test(b));
84
+ let supabaseSkipped = 0;
85
+ const failures = [];
86
+ for (const block of blocks) {
87
+ // 只对 ERROR 原因行分类,不看 "Command was:" 之后的语句文本——否则一个恰好
88
+ // 引用 auth.uid 的用户对象因"损坏/语法错误"失败时,会被误判成预期跳过。
89
+ const cause = block.split(/Command was:/i)[0];
90
+ if (SUPABASE_MANAGED_ERROR.test(cause)) {
91
+ supabaseSkipped += 1;
92
+ }
93
+ else {
94
+ failures.push(block.replace(/\s+/g, " ").trim().slice(0, 200));
95
+ }
96
+ }
97
+ return { supabaseSkipped, failures };
98
+ }
99
+ /**
100
+ * post-data 第二遍的最终裁决:分类之外,非零退出却没解析到任何错误块(被信号杀死、
101
+ * 归档器致命错误等)绝不能谎报"全部恢复"——记为通用失败。
102
+ */
103
+ export function postDataResult(code, stderr) {
104
+ const result = classifyPostDataErrors(stderr);
105
+ if (code !== 0 && result.supabaseSkipped === 0 && result.failures.length === 0) {
106
+ result.failures.push(`pg_restore post-data exited with ${code === null ? "a signal" : `code ${code}`}: ` +
107
+ (stderr.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)"));
108
+ }
109
+ return result;
110
+ }
111
+ /**
112
+ * 两遍恢复(真实首演炸出的设计):
113
+ * 1. pre-data+data —— 表结构 + 数据。任何错误都是硬失败:数据回不来 = 演练失败。
114
+ * 2. post-data —— 用户自己的索引/约束/触发器**必须**恢复成功(它们是"备份完整"的
115
+ * 一部分,一刀切跳过会让坏备份漏网);只有引用 Supabase 托管对象的失败
116
+ * (policy TO authenticated、FK → auth.users)才归类为沙箱预期,记入报告而非失败。
117
+ */
118
+ async function pgRestore(dumpPath, connString) {
119
+ const pgRestoreBin = process.env.BACKUPDRILL_PG_RESTORE ||
120
+ (process.env.BACKUPDRILL_PG_DUMP
121
+ ? process.env.BACKUPDRILL_PG_DUMP.replace(/pg_dump$/, "pg_restore")
122
+ : "pg_restore");
123
+ const common = ["--no-owner", "--no-privileges", "--dbname", connString, dumpPath];
124
+ const first = await spawnPgRestore(pgRestoreBin, [
125
+ "--clean",
126
+ "--if-exists",
127
+ "--section=pre-data",
128
+ "--section=data",
129
+ ...common,
130
+ ]);
131
+ // pg_restore 对无害告警会返回非 0;仅在明确 error 时判失败
132
+ if (first.code !== 0 && /error:/i.test(first.stderr)) {
133
+ throw new Error(`pg_restore failed: ${first.stderr.trim()}`);
134
+ }
135
+ const second = await spawnPgRestore(pgRestoreBin, ["--section=post-data", ...common]);
136
+ return postDataResult(second.code, second.stderr);
137
+ }
138
+ // ── 校验 ────────────────────────────────────────────────────────────
139
+ async function verifyRestored(connString, manifest) {
140
+ const client = new Client({ connectionString: connString });
141
+ await client.connect();
142
+ try {
143
+ const schemas = manifest.database.schemas;
144
+ const tablesRes = await client.query(`select schemaname as schema, tablename as name
145
+ from pg_tables where schemaname = any($1::text[])`, [schemas]);
146
+ const restoredTables = tablesRes.rows;
147
+ // 逐表精确计数(演练要证明数据真回来了,估算值不够)
148
+ const counts = new Map();
149
+ let rowTotal = 0;
150
+ for (const t of restoredTables) {
151
+ const c = await client.query(`select count(*)::bigint as n from "${t.schema}"."${t.name}"`);
152
+ const n = Number(c.rows[0].n);
153
+ counts.set(`${t.schema}.${t.name}`, n);
154
+ rowTotal += n;
155
+ }
156
+ const checks = [];
157
+ // 1. 表数量一致
158
+ checks.push({
159
+ name: "table count",
160
+ pass: restoredTables.length === manifest.database.tableCount,
161
+ detail: `restored ${restoredTables.length}, manifest ${manifest.database.tableCount}`,
162
+ });
163
+ // 2. manifest 里的每张表都还原出来了
164
+ const restoredSet = new Set(counts.keys());
165
+ const missing = manifest.database.tables.filter((t) => !restoredSet.has(`${t.schema}.${t.name}`));
166
+ checks.push({
167
+ name: "no missing tables",
168
+ pass: missing.length === 0,
169
+ detail: missing.length === 0
170
+ ? "all manifest tables present"
171
+ : `missing: ${missing.map((t) => `${t.schema}.${t.name}`).join(", ")}`,
172
+ });
173
+ // 3. 备份时有数据的表,还原后不能是空的(最阴险的"备了半个库"故障)
174
+ const emptied = manifest.database.tables.filter((t) => t.estimatedRows > 0 && (counts.get(`${t.schema}.${t.name}`) ?? 0) === 0);
175
+ checks.push({
176
+ name: "populated tables came back",
177
+ pass: emptied.length === 0,
178
+ detail: emptied.length === 0
179
+ ? "no populated table restored empty"
180
+ : `restored empty despite manifest rows: ${emptied
181
+ .map((t) => `${t.schema}.${t.name}`)
182
+ .join(", ")}`,
183
+ });
184
+ return { checks, tableCount: restoredTables.length, rowTotal };
185
+ }
186
+ finally {
187
+ await client.end();
188
+ }
189
+ }
190
+ /**
191
+ * Storage 完整性校验:从备份桶重新读回文件、比对 manifest 里记录的 sha256,
192
+ * 证明备份的文件副本真实存在且未损坏。文件多时抽样(默认上限),避免大量 egress。
193
+ */
194
+ async function verifyStorage(s3, config, manifest, verifyAll) {
195
+ const files = manifest.storage.files;
196
+ const sample = verifyAll ? files : files.slice(0, STORAGE_SAMPLE_CAP);
197
+ // 备份写入时的键:<snapshot base>/storage/<bucket>/<key>,base 从 dump.key 反推
198
+ const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
199
+ const mismatches = [];
200
+ for (const f of sample) {
201
+ const objectKey = `${base}/storage/${f.bucket}/${f.key}`;
202
+ try {
203
+ const { sha256 } = await hashObject(s3, config.storage.bucket, objectKey);
204
+ if (sha256 !== f.sha256)
205
+ mismatches.push(`${f.bucket}/${f.key}`);
206
+ }
207
+ catch {
208
+ mismatches.push(`${f.bucket}/${f.key} (unreadable)`);
209
+ }
210
+ }
211
+ const scope = verifyAll
212
+ ? `all ${files.length}`
213
+ : `${sample.length}/${files.length} sampled`;
214
+ return {
215
+ name: "storage file integrity",
216
+ pass: mismatches.length === 0,
217
+ detail: mismatches.length === 0
218
+ ? `${scope} files match their manifest checksums`
219
+ : `${mismatches.length} of ${scope} failed: ${mismatches.slice(0, 5).join(", ")}`,
220
+ };
221
+ }
222
+ // ── 主流程 ──────────────────────────────────────────────────────────
223
+ /**
224
+ * 演练核心:把一份本地 dump 恢复进临时 Postgres 并对照 manifest 校验,然后销毁容器。
225
+ * 与 S3 解耦,便于离线测试。`preChecks` 用于把下载阶段的校验(如归档校验和)带进报告。
226
+ */
227
+ export async function drillDump(dumpPath, manifest, snapshot, preChecks = []) {
228
+ const checks = [...preChecks];
229
+ const major = parseInt(manifest.database.serverVersion, 10);
230
+ const pg = await startEphemeralPostgres(major);
231
+ try {
232
+ const started = Date.now();
233
+ log.step("Restoring into ephemeral Postgres…");
234
+ const postData = await pgRestore(dumpPath, pg.connString);
235
+ const restoreSeconds = Math.round((Date.now() - started) / 100) / 10;
236
+ checks.push({ name: "pg_restore", pass: true, detail: `completed in ${restoreSeconds}s` });
237
+ // 用户自己的 post-data 对象(索引/约束/触发器)必须全部恢复;Supabase 托管对象
238
+ // 的沙箱预期跳过如实写进报告(演练不假装它们恢复了)。
239
+ checks.push({
240
+ name: "post-data objects",
241
+ pass: postData.failures.length === 0,
242
+ detail: postData.failures.length > 0
243
+ ? `${postData.failures.length} failed: ${postData.failures[0]}`
244
+ : postData.supabaseSkipped > 0
245
+ ? `user objects restored; ${postData.supabaseSkipped} Supabase-managed object(s) skipped (auth schema/roles do not exist in the drill sandbox)`
246
+ : "all post-data objects restored",
247
+ });
248
+ const verify = await verifyRestored(pg.connString, manifest);
249
+ checks.push(...verify.checks);
250
+ return {
251
+ snapshot,
252
+ pass: checks.every((c) => c.pass),
253
+ checks,
254
+ restoreSeconds,
255
+ restoredTableCount: verify.tableCount,
256
+ restoredRowTotal: verify.rowTotal,
257
+ };
258
+ }
259
+ finally {
260
+ log.step("Tearing down ephemeral Postgres…");
261
+ await execFileAsync("docker", ["rm", "-f", pg.containerId]).catch(() => { });
262
+ }
263
+ }
264
+ /** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
265
+ export async function runDrill(config, opts = {}) {
266
+ const s3 = targetClient(config);
267
+ const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
268
+ const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
269
+ log.step(`Drilling snapshot ${snapshot}`);
270
+ const manifest = JSON.parse(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
271
+ const workdir = await mkdtemp(join(tmpdir(), "backupdrill-"));
272
+ const dumpPath = join(workdir, "dump.pgcustom");
273
+ try {
274
+ log.step("Downloading dump…");
275
+ const { sha256: sha } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
276
+ const preChecks = [
277
+ {
278
+ name: "archive integrity",
279
+ pass: sha === manifest.dump.sha256,
280
+ detail: sha === manifest.dump.sha256
281
+ ? `sha256 matches (${sha.slice(0, 12)}…)`
282
+ : `sha256 MISMATCH — got ${sha.slice(0, 12)}…, manifest ${manifest.dump.sha256.slice(0, 12)}…`,
283
+ },
284
+ ];
285
+ // Storage 文件完整性(备份含 Storage 时才校验)
286
+ if (manifest.storage && manifest.storage.files.length) {
287
+ log.step("Verifying Storage files…");
288
+ preChecks.push(await verifyStorage(s3, config, manifest, opts.verifyAllFiles ?? false));
289
+ }
290
+ return await drillDump(dumpPath, manifest, snapshot, preChecks);
291
+ }
292
+ finally {
293
+ await rm(workdir, { recursive: true, force: true }).catch(() => { });
294
+ }
295
+ }
@@ -0,0 +1,27 @@
1
+ import type { BackupConfig } from "./config.js";
2
+ /**
3
+ * Supabase egress 定价(2026-07-04 经核实,来源见 README「Egress & cost」)。
4
+ * 单价 = 超出计划包含额度后的每 GB 价;included = 各计划每月包含的 egress。
5
+ * 备份 = 把数据拉出 Supabase = egress,计入用户账单。这是 PRD §4.3 的上线否决项:
6
+ * 让用户"我们帮你算过账"而不是收到意外账单。
7
+ */
8
+ export interface EgressPricing {
9
+ pricePerGb: number;
10
+ includedGb: number;
11
+ planLabel: string;
12
+ hardCap: boolean;
13
+ }
14
+ export interface EgressProjection {
15
+ dbBytes: number;
16
+ storageBytes: number;
17
+ perRunGb: number;
18
+ rows: Array<{
19
+ frequency: string;
20
+ runsPerMonth: number;
21
+ monthlyEgressGb: number;
22
+ overageGb: number;
23
+ monthlyCost: number;
24
+ }>;
25
+ }
26
+ export declare function project(dbBytes: number, storageBytes: number, pricing: EgressPricing): EgressProjection;
27
+ export declare function runEstimate(config: BackupConfig, pricing: EgressPricing): Promise<EgressProjection>;
@@ -0,0 +1,102 @@
1
+ import { Client } from "pg";
2
+ import { measureStorage } from "./storage.js";
3
+ import { log } from "./log.js";
4
+ // 超出这个月度成本就该提醒用户"备份的 egress 账单快赶上订阅费了"
5
+ const SUBSCRIPTION_FLOOR_USD = 19;
6
+ const GB = 1024 * 1024 * 1024;
7
+ async function measureDbBytes(databaseUrl, schemas) {
8
+ const client = new Client({ connectionString: databaseUrl });
9
+ await client.connect();
10
+ try {
11
+ // pg_total_relation_size 含索引+TOAST,是 egress 上界(真实转储经压缩、不含索引,更小)
12
+ const res = await client.query(`select coalesce(
13
+ sum(pg_total_relation_size(format('%I.%I', schemaname, relname)::regclass)),
14
+ 0
15
+ )::bigint as bytes
16
+ from pg_stat_user_tables
17
+ where schemaname = any($1::text[])`, [schemas]);
18
+ return Number(res.rows[0].bytes);
19
+ }
20
+ finally {
21
+ await client.end();
22
+ }
23
+ }
24
+ export function project(dbBytes, storageBytes, pricing) {
25
+ const perRunGb = (dbBytes + storageBytes) / GB;
26
+ const frequencies = [
27
+ ["weekly", 52 / 12], // ≈4.33 次/月
28
+ ["daily", 30],
29
+ ];
30
+ const rows = frequencies.map(([frequency, runsPerMonth]) => {
31
+ const monthlyEgressGb = perRunGb * runsPerMonth;
32
+ // 保守:假设备份是该项目唯一 egress 来源;真实还要叠加应用自身流量
33
+ const overageGb = Math.max(0, monthlyEgressGb - pricing.includedGb);
34
+ return {
35
+ frequency,
36
+ runsPerMonth: Math.round(runsPerMonth * 10) / 10,
37
+ monthlyEgressGb: Math.round(monthlyEgressGb * 10) / 10,
38
+ overageGb: Math.round(overageGb * 10) / 10,
39
+ monthlyCost: Math.round(overageGb * pricing.pricePerGb * 100) / 100,
40
+ };
41
+ });
42
+ return { dbBytes, storageBytes, perRunGb, rows };
43
+ }
44
+ export async function runEstimate(config, pricing) {
45
+ log.step("Measuring database size…");
46
+ const dbBytes = await measureDbBytes(config.databaseUrl, config.schemas);
47
+ log.ok(`Database (schema ${config.schemas.join(", ")}): ${(dbBytes / GB).toFixed(2)} GB (upper bound)`);
48
+ let storageBytes = 0;
49
+ if (config.supabaseStorage) {
50
+ log.step("Measuring Storage size (listing only, no download)…");
51
+ const m = await measureStorage(config.supabaseStorage);
52
+ storageBytes = m.totalBytes;
53
+ log.ok(`Storage: ${m.fileCount} files, ${(storageBytes / GB).toFixed(2)} GB`);
54
+ }
55
+ const projection = project(dbBytes, storageBytes, pricing);
56
+ console.error("");
57
+ console.error(` Egress per backup: ~${projection.perRunGb.toFixed(2)} GB ` +
58
+ `(DB dump is smaller after compression; Storage is exact)`);
59
+ console.error(` Plan: ${pricing.planLabel} — ${pricing.includedGb} GB egress included, ` +
60
+ `then $${pricing.pricePerGb}/GB`);
61
+ console.error("");
62
+ console.error(" Frequency Runs/mo Monthly egress Overage Est. cost/mo");
63
+ console.error(" ───────── ─────── ────────────── ─────── ────────────");
64
+ for (const r of projection.rows) {
65
+ console.error(` ${r.frequency.padEnd(9)} ${String(r.runsPerMonth).padStart(7)} ` +
66
+ `${(r.monthlyEgressGb + " GB").padStart(14)} ` +
67
+ `${(r.overageGb + " GB").padStart(7)} ` +
68
+ `$${r.monthlyCost.toFixed(2)}`);
69
+ }
70
+ console.error("");
71
+ console.error(" Note: assumes backups are this project's only egress. Your app's own " +
72
+ "traffic\n adds to the total. This is an upper bound — check manifest.json " +
73
+ "after a\n real run for the actual bytes transferred.");
74
+ // 守护提示:egress 是把双刃剑,两种失败模式都要点破
75
+ const daily = projection.rows.find((r) => r.frequency === "daily");
76
+ const weekly = projection.rows.find((r) => r.frequency === "weekly");
77
+ console.error("");
78
+ if (pricing.hardCap) {
79
+ // Free:超额=限流,备份直接失败,daily 大库根本跑不完
80
+ if (daily && daily.overageGb > 0) {
81
+ log.warn(`${pricing.planLabel} egress is a HARD CAP (${pricing.includedGb} GB). ` +
82
+ `Daily backups (~${daily.monthlyEgressGb} GB/mo) will blow the cap and ` +
83
+ `get your project throttled — the backup itself will fail. ` +
84
+ `Use weekly at most, or upgrade to Pro.`);
85
+ }
86
+ }
87
+ else {
88
+ // Pro/Team:Spend Cap 决定是"限流"还是"账单"
89
+ log.warn("Spend Cap is ON by default on Pro/Team. If a backup pushes you past the " +
90
+ `${pricing.includedGb} GB egress quota, Supabase throttles your project's ` +
91
+ "egress for the rest of the month (your app's traffic too), and the backup " +
92
+ "fails. Turn Spend Cap OFF to allow billed overage instead — then the " +
93
+ "numbers above are what you'd pay.");
94
+ const worst = daily?.monthlyCost ?? 0;
95
+ if (worst >= SUBSCRIPTION_FLOOR_USD) {
96
+ log.warn(`At daily frequency the egress overage alone (~$${worst.toFixed(0)}/mo) is ` +
97
+ `in the range of a paid backup subscription. Consider weekly ` +
98
+ `(~$${(weekly?.monthlyCost ?? 0).toFixed(0)}/mo overage) or a smaller scope.`);
99
+ }
100
+ }
101
+ return projection;
102
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { loadConfig } from "./config.js";
4
+ import { runBackup } from "./backup.js";
5
+ import { runEstimate } from "./estimate.js";
6
+ import { runDrill } from "./drill.js";
7
+ import { runRestore } from "./restore.js";
8
+ import { log } from "./log.js";
9
+ // Supabase egress 定价表(2026-07-04 核实,来源见 README「Egress & cost」)。
10
+ // 备份流量 100% 未缓存,故用 uncached 单价 $0.09/GB;included 用 uncached 额度。
11
+ // Free 硬上限(超额即限流,不计费);Pro/Team 由 Spend Cap 决定超额限流还是计费。
12
+ const EGRESS_PRICING = {
13
+ free: { planLabel: "Free", includedGb: 5, pricePerGb: 0.09, hardCap: true },
14
+ pro: { planLabel: "Pro", includedGb: 250, pricePerGb: 0.09, hardCap: false },
15
+ team: { planLabel: "Team", includedGb: 250, pricePerGb: 0.09, hardCap: false },
16
+ };
17
+ const program = new Command();
18
+ program
19
+ .name("backupdrill")
20
+ .description("Back up a Supabase project — Postgres database (and, soon, Storage files) — " +
21
+ "to your own S3/R2/B2 bucket, with a checksummed manifest.\n\n" +
22
+ "Want scheduling, restore drills, and alerts? → https://backupdrill.com")
23
+ .version(process.env.npm_package_version || "0.1.0");
24
+ program
25
+ .command("backup")
26
+ .description("Run a one-off backup to your own bucket")
27
+ .option("-c, --config <path>", "path to a JSON config file", "backupdrill.config.json")
28
+ .option("--database-url <url>", "Supabase Session Pooler connection string")
29
+ .option("--project-name <name>", "readable name for this project (used in the object key)")
30
+ .option("--schema <name...>", "schema(s) to back up (default: public; platform schemas like auth/storage are excluded)")
31
+ .option("--bucket <name>", "target S3/R2/B2 bucket")
32
+ .option("--endpoint <url>", "S3-compatible endpoint (required for R2/B2; omit for AWS S3)")
33
+ .option("--region <region>", "bucket region (default: auto)")
34
+ .option("--prefix <prefix>", "key prefix inside the bucket (default: backupdrill)")
35
+ .action(async (opts) => {
36
+ try {
37
+ const config = await loadConfig({
38
+ configPath: opts.config,
39
+ overrides: {
40
+ databaseUrl: opts.databaseUrl,
41
+ projectName: opts.projectName,
42
+ schemas: opts.schema,
43
+ storage: {
44
+ bucket: opts.bucket,
45
+ endpoint: opts.endpoint,
46
+ region: opts.region,
47
+ prefix: opts.prefix,
48
+ },
49
+ },
50
+ });
51
+ const manifest = await runBackup(config);
52
+ const storageNote = manifest.storage
53
+ ? ` + ${manifest.storage.fileCount} Storage files`
54
+ : " (database only)";
55
+ log.ok(`Backup complete — ${manifest.database.tableCount} tables` +
56
+ `${storageNote}.`);
57
+ // stdout 输出机器可读结果,便于在 CI / GitHub Action 里消费
58
+ process.stdout.write(JSON.stringify({ ok: true, manifest }) + "\n");
59
+ }
60
+ catch (error) {
61
+ log.error(error.message);
62
+ process.exitCode = 1;
63
+ }
64
+ });
65
+ program
66
+ .command("drill")
67
+ .description("Restore a snapshot into an ephemeral Postgres and prove it comes back (needs Docker)")
68
+ .option("-c, --config <path>", "path to a JSON config file", "backupdrill.config.json")
69
+ .option("--snapshot <timestamp>", "which snapshot to drill (default: latest)")
70
+ .option("--bucket <name>", "bucket the backups live in")
71
+ .option("--endpoint <url>", "S3-compatible endpoint (for R2/B2)")
72
+ .option("--region <region>", "bucket region")
73
+ .option("--prefix <prefix>", "key prefix inside the bucket (default: backupdrill)")
74
+ .option("--project-name <name>", "project name used in the object key")
75
+ .option("--verify-all-files", "checksum every Storage file, not just a sample")
76
+ .action(async (opts) => {
77
+ try {
78
+ const config = await loadConfig({
79
+ configPath: opts.config,
80
+ overrides: {
81
+ projectName: opts.projectName,
82
+ // drill 只读桶,不连源库;给 databaseUrl 占位以通过校验
83
+ databaseUrl: "postgresql://unused",
84
+ storage: {
85
+ bucket: opts.bucket,
86
+ endpoint: opts.endpoint,
87
+ region: opts.region,
88
+ prefix: opts.prefix,
89
+ },
90
+ },
91
+ });
92
+ const report = await runDrill(config, {
93
+ snapshot: opts.snapshot,
94
+ verifyAllFiles: opts.verifyAllFiles,
95
+ });
96
+ console.error("");
97
+ for (const c of report.checks) {
98
+ console.error(` ${c.pass ? "✓" : "✗"} ${c.name} — ${c.detail}`);
99
+ }
100
+ console.error("");
101
+ log[report.pass ? "ok" : "error"](`Drill ${report.pass ? "PASSED" : "FAILED"} — snapshot ${report.snapshot}, ` +
102
+ `${report.restoredTableCount} tables / ${report.restoredRowTotal.toLocaleString()} rows ` +
103
+ `restored in ${report.restoreSeconds}s`);
104
+ process.stdout.write(JSON.stringify({ ok: report.pass, report }) + "\n");
105
+ if (!report.pass)
106
+ process.exitCode = 1;
107
+ }
108
+ catch (error) {
109
+ log.error(error.message);
110
+ process.exitCode = 1;
111
+ }
112
+ });
113
+ program
114
+ .command("restore")
115
+ .description("Recover a snapshot: restore the database into a target and pull Storage files down")
116
+ .option("-c, --config <path>", "path to a JSON config file", "backupdrill.config.json")
117
+ .option("--target-database-url <url>", "connection string to restore the database INTO")
118
+ .option("--storage-dir <path>", "local directory to write Storage files to")
119
+ .option("--snapshot <timestamp>", "which snapshot to restore (default: latest)")
120
+ .option("--bucket <name>", "bucket the backups live in")
121
+ .option("--endpoint <url>", "S3-compatible endpoint (for R2/B2)")
122
+ .option("--region <region>", "bucket region")
123
+ .option("--prefix <prefix>", "key prefix inside the bucket (default: backupdrill)")
124
+ .option("--project-name <name>", "project name used in the object key")
125
+ .action(async (opts) => {
126
+ try {
127
+ const config = await loadConfig({
128
+ configPath: opts.config,
129
+ overrides: {
130
+ projectName: opts.projectName,
131
+ databaseUrl: "postgresql://unused", // restore 读桶,不连源库
132
+ storage: {
133
+ bucket: opts.bucket,
134
+ endpoint: opts.endpoint,
135
+ region: opts.region,
136
+ prefix: opts.prefix,
137
+ },
138
+ },
139
+ });
140
+ const result = await runRestore(config, {
141
+ targetDatabaseUrl: opts.targetDatabaseUrl,
142
+ storageDir: opts.storageDir,
143
+ snapshot: opts.snapshot,
144
+ });
145
+ log.ok(`Restore complete — snapshot ${result.snapshot}: ` +
146
+ `database ${result.restoredToDatabase ? "restored" : "skipped"}, ` +
147
+ `${result.storageFilesWritten} Storage files written` +
148
+ (result.storageDir ? ` to ${result.storageDir}` : ""));
149
+ }
150
+ catch (error) {
151
+ log.error(error.message);
152
+ process.exitCode = 1;
153
+ }
154
+ });
155
+ program
156
+ .command("estimate")
157
+ .description("Measure your DB + Storage size and project monthly Supabase egress cost")
158
+ .option("-c, --config <path>", "path to a JSON config file", "backupdrill.config.json")
159
+ .option("--database-url <url>", "Supabase Session Pooler connection string")
160
+ .option("--schema <name...>", "schema(s) to measure (default: public)")
161
+ .option("--plan <plan>", "your Supabase plan for the cost estimate: free | pro | team", "pro")
162
+ .action(async (opts) => {
163
+ try {
164
+ const config = await loadConfig({
165
+ configPath: opts.config,
166
+ overrides: {
167
+ databaseUrl: opts.databaseUrl,
168
+ schemas: opts.schema,
169
+ // estimate 不写数据,给目标桶占位以通过校验(仅用 databaseUrl + 源)
170
+ storage: { bucket: "estimate", accessKeyId: "estimate", secretAccessKey: "estimate" },
171
+ },
172
+ });
173
+ const pricing = EGRESS_PRICING[opts.plan];
174
+ if (!pricing)
175
+ throw new Error(`Unknown plan "${opts.plan}". Use free | pro | team.`);
176
+ await runEstimate(config, pricing);
177
+ }
178
+ catch (error) {
179
+ log.error(error.message);
180
+ process.exitCode = 1;
181
+ }
182
+ });
183
+ program.parseAsync().catch((error) => {
184
+ log.error(error.message);
185
+ process.exitCode = 1;
186
+ });
package/dist/lib.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { runBackup } from "./backup.js";
2
+ export { runDrill, drillDump } from "./drill.js";
3
+ export { runRestore } from "./restore.js";
4
+ export { runEstimate, project } from "./estimate.js";
5
+ export { loadConfig } from "./config.js";
6
+ export { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
7
+ export type { BackupConfig, StorageConfig, SupabaseStorageConfig, } from "./config.js";
8
+ export type { Manifest, StorageFile, TableStat } from "./manifest.js";
9
+ export type { DrillReport, DrillCheck } from "./drill.js";
10
+ export type { RestoreResult } from "./restore.js";
11
+ export type { EgressPricing, EgressProjection } from "./estimate.js";
package/dist/lib.js ADDED
@@ -0,0 +1,8 @@
1
+ // 库入口:把引擎函数暴露给下游复用(产品 worker 直接 import 这些,而不是重写)。
2
+ // CLI(bin)是这套引擎的薄封装;worker 在其外加调度、凭据托管、告警、报告。
3
+ export { runBackup } from "./backup.js";
4
+ export { runDrill, drillDump } from "./drill.js";
5
+ export { runRestore } from "./restore.js";
6
+ export { runEstimate, project } from "./estimate.js";
7
+ export { loadConfig } from "./config.js";
8
+ export { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
package/dist/log.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export declare const log: {
2
+ step: (msg: string) => void;
3
+ ok: (msg: string) => void;
4
+ warn: (msg: string) => void;
5
+ error: (msg: string) => void;
6
+ };
package/dist/log.js ADDED
@@ -0,0 +1,12 @@
1
+ // 极简日志:全部走 stderr,让 stdout 留给未来可能的机器可读输出(如 --json)
2
+ const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
3
+ const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
4
+ const green = (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s);
5
+ const red = (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s);
6
+ const yellow = (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s);
7
+ export const log = {
8
+ step: (msg) => console.error(dim("→ ") + msg),
9
+ ok: (msg) => console.error(green("✓ ") + msg),
10
+ warn: (msg) => console.error(yellow("! ") + msg),
11
+ error: (msg) => console.error(red("✗ ") + msg),
12
+ };