backupdrill 0.1.2 → 0.2.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/README.md CHANGED
@@ -6,10 +6,12 @@ Backups stream through the CLI on your machine (or your CI) straight to your buc
6
6
 
7
7
  > Want this on a schedule, with **restore drills that prove your backup actually restores**, plus alerts and reports? 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
8
 
9
+ 🎥 Prefer watching? [Where do your backups actually go — explained in about a minute](https://backupdrill.com/guides/test-supabase-backup-restore#explainer-video).
10
+
9
11
  ## Why
10
12
 
11
13
  - 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.
14
+ - Pro-plan backups keep 7 days and can't be pulled into your own infrastructure; PITR is $100/month per project.
13
15
  - A `pg_dump` you've never restored is a guess.
14
16
 
15
17
  This CLI gets your database into a bucket **you** control, with a manifest you can check.
@@ -106,19 +108,19 @@ With those set, `backupdrill backup` also copies every Storage file into your bu
106
108
 
107
109
  ## Egress & cost — read this before scheduling daily backups
108
110
 
109
- 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)):
111
+ A backup pulls data **out** of Supabase, which counts as **egress on your Supabase bill**. Verified rates (2026-07-10, [egress docs](https://supabase.com/docs/guides/platform/manage-your-usage/egress) + [pricing](https://supabase.com/pricing)):
110
112
 
111
113
  - Backup traffic (pg_dump + first-time file downloads) is **uncached egress: $0.09/GB** above your plan's included allowance.
112
114
  - Included uncached egress: **Free 5 GB · Pro 250 GB · Team 250 GB** per month.
113
115
  - **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.
114
- - **Free is a hard cap:** exceed 5 GB and the project is restricted (never billed). A large daily backup can't complete on Free.
116
+ - **Free never bills overage.** Exceeding the included 5 GB/month starts Supabase's Fair Use process: a notification to your billing email, then a grace period, then egress restrictions if usage stays over (the restriction clears at the next billing cycle or when you upgrade). Sustained large backups are not viable on Free.
115
117
 
116
118
  Rough cost (Pro, Spend Cap OFF, backups = your only egress):
117
119
 
118
120
  | DB + Storage | Weekly | Daily |
119
121
  |---|---|---|
120
- | 10 GB | $0/mo (within 250 GB) | ~$4.50/mo |
121
- | 50 GB | ~$0/mo | **~$112/mo** ⚠ |
122
+ | 10 GB | $0/month (within 250 GB) | ~$4.50/month |
123
+ | 50 GB | ~$0/month | **~$112/month** ⚠ |
122
124
 
123
125
  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.
124
126
 
@@ -146,6 +148,16 @@ backupdrill drill --verify-all-files # also checksum every Storage file, not jus
146
148
 
147
149
  Backups record your installed Postgres extensions in the manifest, and the drill pre-installs them in the sandbox before restoring — including switching to a pgvector-enabled sandbox image when your database uses `vector` columns.
148
150
 
151
+ ### Run your own checks against the restored copy
152
+
153
+ The drill's structural checks (tables, rows, checksums) can't know what "working" means for *your* app — RLS behavior and business workflows only have answers in your code. `--check-cmd` hands you the restored sandbox before it's destroyed:
154
+
155
+ ```bash
156
+ backupdrill drill --check-cmd "npm run smoke" # BACKUPDRILL_SANDBOX_URL points at the restored copy
157
+ ```
158
+
159
+ Your command runs after all structural checks pass, with `BACKUPDRILL_SANDBOX_URL` set to the sandbox's connection string. Exit 0 means pass; the result shows up in the report as an `app checks` line, and a non-zero exit fails the drill (the report keeps structural and app-level failures clearly separate). Entirely optional — without the flag, nothing changes and the report has no `app checks` line. There's a 10-minute timeout, and `--keep` leaves the sandbox container running when a drill fails so you can inspect it (`docker rm -f <id>` when done).
160
+
149
161
  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:
150
162
 
151
163
  ```bash
package/dist/drill.d.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import type { BackupConfig } from "./config.js";
2
2
  import type { Manifest } from "./manifest.js";
3
+ /**
4
+ * 无放回随机抽样:部分 Fisher-Yates,只洗出前 n 位(O(n) 次交换,不必全 shuffle)。
5
+ * 此前恒取 manifest 顺序的前 CAP 个 → 超过 CAP 的项目里排序靠后的文件永远不被校验;
6
+ * 随机抽样让每次演练覆盖不同子集,损坏的文件长期必然被抽中。
7
+ * 随机源用 Math.random 足够:演练是一次性进程,无可复现性要求——校验失败的文件名
8
+ * 会原样列进报告,定位问题不依赖重放同一批样本。
9
+ */
10
+ export declare function sampleStorageFiles<T>(files: T[], n: number): T[];
3
11
  export interface DrillCheck {
4
12
  name: string;
5
13
  pass: boolean;
@@ -25,14 +33,30 @@ export declare function classifyPostDataErrors(stderr: string): PostDataResult;
25
33
  * 归档器致命错误等)绝不能谎报"全部恢复"——记为通用失败。
26
34
  */
27
35
  export declare function postDataResult(code: number | null, stderr: string): PostDataResult;
36
+ export declare const APP_CHECK_TIMEOUT_MS: number;
37
+ /**
38
+ * 语义层钩子:结构检查全绿后、销毁沙箱前,把沙箱连接串经 BACKUPDRILL_SANDBOX_URL
39
+ * 交给用户命令执行(RLS 行为、业务流可用性——判据只存在于应用侧,引擎不解释
40
+ * 命令输出,只裁决退出码:0 = pass)。
41
+ * 子进程输出定向到 stderr:stdout 是 CLI 的机器可读 JSON 通道,不能被污染。
42
+ * 红线:托管 worker 绝不能把用户输入传到这里(任意代码执行);此钩子只属于
43
+ * 用户在自己机器上运行的 CLI(见 PRD §1.4 演练后应用自检钩子)。
44
+ */
45
+ export declare function runAppCheck(command: string, connString: string, timeoutMs?: number): Promise<DrillCheck>;
28
46
  /**
29
47
  * 演练核心:把一份本地 dump 恢复进临时 Postgres 并对照 manifest 校验,然后销毁容器。
30
48
  * 与 S3 解耦,便于离线测试。`preChecks` 用于把下载阶段的校验(如归档校验和)带进报告。
31
49
  */
32
- export declare function drillDump(dumpPath: string, manifest: Manifest, snapshot: string, preChecks?: DrillCheck[]): Promise<DrillReport>;
50
+ export declare function drillDump(dumpPath: string, manifest: Manifest, snapshot: string, preChecks?: DrillCheck[], opts?: {
51
+ appCheckCommand?: string;
52
+ keepSandboxOnFailure?: boolean;
53
+ }): Promise<DrillReport>;
33
54
  /** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
34
55
  export declare function runDrill(config: BackupConfig, opts?: {
35
56
  snapshot?: string;
36
57
  verifyAllFiles?: boolean;
58
+ /** CLI 专用。红线:托管 worker 绝不能传入(在 worker 上执行用户命令 = RCE)。 */
59
+ appCheckCommand?: string;
60
+ keepSandboxOnFailure?: boolean;
37
61
  }): Promise<DrillReport>;
38
62
  export {};
package/dist/drill.js CHANGED
@@ -10,6 +10,23 @@ import { log } from "./log.js";
10
10
  const execFileAsync = promisify(execFile);
11
11
  // Storage 校验默认抽样上限(避免每次演练重下全部文件产生大量 egress);超限如实记录
12
12
  const STORAGE_SAMPLE_CAP = 100;
13
+ /**
14
+ * 无放回随机抽样:部分 Fisher-Yates,只洗出前 n 位(O(n) 次交换,不必全 shuffle)。
15
+ * 此前恒取 manifest 顺序的前 CAP 个 → 超过 CAP 的项目里排序靠后的文件永远不被校验;
16
+ * 随机抽样让每次演练覆盖不同子集,损坏的文件长期必然被抽中。
17
+ * 随机源用 Math.random 足够:演练是一次性进程,无可复现性要求——校验失败的文件名
18
+ * 会原样列进报告,定位问题不依赖重放同一批样本。
19
+ */
20
+ export function sampleStorageFiles(files, n) {
21
+ if (files.length <= n)
22
+ return files;
23
+ const pool = [...files];
24
+ for (let i = 0; i < n; i++) {
25
+ const j = i + Math.floor(Math.random() * (pool.length - i));
26
+ [pool[i], pool[j]] = [pool[j], pool[i]];
27
+ }
28
+ return pool.slice(0, n);
29
+ }
13
30
  /**
14
31
  * 沙箱镜像选择:alpine 镜像只带 contrib 扩展,装不了 pgvector——含 vector 列的
15
32
  * 备份在裸镜像里恢复必然硬崩("type vector does not exist")。manifest 记录了
@@ -264,7 +281,7 @@ async function verifyRestored(connString, manifest) {
264
281
  */
265
282
  async function verifyStorage(s3, config, manifest, verifyAll) {
266
283
  const files = manifest.storage.files;
267
- const sample = verifyAll ? files : files.slice(0, STORAGE_SAMPLE_CAP);
284
+ const sample = verifyAll ? files : sampleStorageFiles(files, STORAGE_SAMPLE_CAP);
268
285
  // 备份写入时的键:<snapshot base>/storage/<bucket>/<key>,base 从 dump.key 反推
269
286
  const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
270
287
  const mismatches = [];
@@ -279,25 +296,75 @@ async function verifyStorage(s3, config, manifest, verifyAll) {
279
296
  mismatches.push(`${f.bucket}/${f.key} (unreadable)`);
280
297
  }
281
298
  }
282
- const scope = verifyAll
283
- ? `all ${files.length}`
284
- : `${sample.length}/${files.length} sampled`;
299
+ // 抽样时如实说明这是随机样本(N of M);全量(--verify-all-files 或文件数 ≤ 上限)说 all
300
+ const scope = sample.length === files.length
301
+ ? `all ${files.length} files`
302
+ : `random sample of ${sample.length} of ${files.length} files`;
285
303
  return {
286
304
  name: "storage file integrity",
287
305
  pass: mismatches.length === 0,
288
306
  detail: mismatches.length === 0
289
- ? `${scope} files match their manifest checksums`
290
- : `${mismatches.length} of ${scope} failed: ${mismatches.slice(0, 5).join(", ")}`,
307
+ ? `${scope} match their manifest checksums`
308
+ : `${mismatches.length} failed in ${scope}: ${mismatches.slice(0, 5).join(", ")}`,
291
309
  };
292
310
  }
311
+ // ── 应用自检钩子(语义层)────────────────────────────────────────────
312
+ // 挂死的 smoke test 不能永远挡住沙箱销毁;10 分钟对"冒烟"级检查绰绰有余
313
+ export const APP_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
314
+ /**
315
+ * 语义层钩子:结构检查全绿后、销毁沙箱前,把沙箱连接串经 BACKUPDRILL_SANDBOX_URL
316
+ * 交给用户命令执行(RLS 行为、业务流可用性——判据只存在于应用侧,引擎不解释
317
+ * 命令输出,只裁决退出码:0 = pass)。
318
+ * 子进程输出定向到 stderr:stdout 是 CLI 的机器可读 JSON 通道,不能被污染。
319
+ * 红线:托管 worker 绝不能把用户输入传到这里(任意代码执行);此钩子只属于
320
+ * 用户在自己机器上运行的 CLI(见 PRD §1.4 演练后应用自检钩子)。
321
+ */
322
+ export function runAppCheck(command, connString, timeoutMs = APP_CHECK_TIMEOUT_MS) {
323
+ const started = Date.now();
324
+ return new Promise((resolve) => {
325
+ const proc = spawn(command, {
326
+ shell: true,
327
+ stdio: ["ignore", 2, 2],
328
+ env: { ...process.env, BACKUPDRILL_SANDBOX_URL: connString },
329
+ timeout: timeoutMs,
330
+ killSignal: "SIGKILL",
331
+ });
332
+ proc.on("error", (error) => {
333
+ resolve({
334
+ name: "app checks",
335
+ pass: false,
336
+ detail: `command failed to start: ${error.message}`,
337
+ });
338
+ });
339
+ proc.on("close", (code, signal) => {
340
+ const seconds = Math.round((Date.now() - started) / 100) / 10;
341
+ if (signal) {
342
+ resolve({
343
+ name: "app checks",
344
+ pass: false,
345
+ detail: `terminated by ${signal} after ${seconds}s (timeout ${timeoutMs / 1000}s)`,
346
+ });
347
+ return;
348
+ }
349
+ resolve({
350
+ name: "app checks",
351
+ pass: code === 0,
352
+ detail: code === 0
353
+ ? `command exited 0 in ${seconds}s`
354
+ : `command exited ${code} after ${seconds}s`,
355
+ });
356
+ });
357
+ });
358
+ }
293
359
  // ── 主流程 ──────────────────────────────────────────────────────────
294
360
  /**
295
361
  * 演练核心:把一份本地 dump 恢复进临时 Postgres 并对照 manifest 校验,然后销毁容器。
296
362
  * 与 S3 解耦,便于离线测试。`preChecks` 用于把下载阶段的校验(如归档校验和)带进报告。
297
363
  */
298
- export async function drillDump(dumpPath, manifest, snapshot, preChecks = []) {
364
+ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], opts = {}) {
299
365
  const checks = [...preChecks];
300
366
  const pg = await startEphemeralPostgres(sandboxImage(manifest));
367
+ let pass = false; // 异常路径视为失败,供 finally 决定 --keep 是否保留沙箱
301
368
  try {
302
369
  // 旧 manifest(≤0.1.1)没有 extensions 字段 → 不装任何扩展,行为与从前一致
303
370
  const extensions = manifest.database.extensions ?? [];
@@ -345,9 +412,16 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = []) {
345
412
  });
346
413
  const verify = await verifyRestored(pg.connString, manifest);
347
414
  checks.push(...verify.checks);
415
+ // 语义层钩子:只在结构层全绿时才跑——结构已失败时再跑应用检查,只会把
416
+ // 一次失败报成两层失败,误导排查方向。没配置则不跑、报告里不出现该行。
417
+ if (opts.appCheckCommand && checks.every((c) => c.pass)) {
418
+ log.step("Running app checks…");
419
+ checks.push(await runAppCheck(opts.appCheckCommand, pg.connString));
420
+ }
421
+ pass = checks.every((c) => c.pass);
348
422
  return {
349
423
  snapshot,
350
- pass: checks.every((c) => c.pass),
424
+ pass,
351
425
  checks,
352
426
  restoreSeconds,
353
427
  restoredTableCount: verify.tableCount,
@@ -355,8 +429,15 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = []) {
355
429
  };
356
430
  }
357
431
  finally {
358
- log.step("Tearing down ephemeral Postgres…");
359
- await execFileAsync("docker", ["rm", "-f", pg.containerId]).catch(() => { });
432
+ if (opts.keepSandboxOnFailure && !pass) {
433
+ // --rm 容器不 docker rm 就一直活着;用户排查完 stop/rm 时 --rm 自动清理
434
+ log.warn(`--keep: sandbox left running for inspection — ${pg.connString} ` +
435
+ `(remove with: docker rm -f ${pg.containerId.slice(0, 12)})`);
436
+ }
437
+ else {
438
+ log.step("Tearing down ephemeral Postgres…");
439
+ await execFileAsync("docker", ["rm", "-f", pg.containerId]).catch(() => { });
440
+ }
360
441
  }
361
442
  }
362
443
  /** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
@@ -397,7 +478,10 @@ export async function runDrill(config, opts = {}) {
397
478
  log.step("Verifying Storage files…");
398
479
  preChecks.push(await verifyStorage(s3, config, manifest, opts.verifyAllFiles ?? false));
399
480
  }
400
- return await drillDump(dumpPath, manifest, snapshot, preChecks);
481
+ return await drillDump(dumpPath, manifest, snapshot, preChecks, {
482
+ appCheckCommand: opts.appCheckCommand,
483
+ keepSandboxOnFailure: opts.keepSandboxOnFailure,
484
+ });
401
485
  }
402
486
  finally {
403
487
  await rm(workdir, { recursive: true, force: true }).catch(() => { });
@@ -1,6 +1,6 @@
1
1
  import type { BackupConfig } from "./config.js";
2
2
  /**
3
- * Supabase egress 定价(2026-07-04 经核实,来源见 README「Egress & cost」)。
3
+ * Supabase egress 定价(2026-07-10 经核实,来源见 README「Egress & cost」)。
4
4
  * 单价 = 超出计划包含额度后的每 GB 价;included = 各计划每月包含的 egress。
5
5
  * 备份 = 把数据拉出 Supabase = egress,计入用户账单。这是 PRD §4.3 的上线否决项:
6
6
  * 让用户"我们帮你算过账"而不是收到意外账单。
package/dist/estimate.js CHANGED
@@ -59,10 +59,10 @@ export async function runEstimate(config, pricing) {
59
59
  console.error(` Plan: ${pricing.planLabel} — ${pricing.includedGb} GB egress included, ` +
60
60
  `then $${pricing.pricePerGb}/GB`);
61
61
  console.error("");
62
- console.error(" Frequency Runs/mo Monthly egress Overage Est. cost/mo");
63
- console.error(" ───────── ─────── ────────────── ─────── ────────────");
62
+ console.error(" Frequency Runs/month Monthly egress Overage Est. cost/month");
63
+ console.error(" ───────── ────────── ────────────── ─────── ───────────────");
64
64
  for (const r of projection.rows) {
65
- console.error(` ${r.frequency.padEnd(9)} ${String(r.runsPerMonth).padStart(7)} ` +
65
+ console.error(` ${r.frequency.padEnd(9)} ${String(r.runsPerMonth).padStart(10)} ` +
66
66
  `${(r.monthlyEgressGb + " GB").padStart(14)} ` +
67
67
  `${(r.overageGb + " GB").padStart(7)} ` +
68
68
  `$${r.monthlyCost.toFixed(2)}`);
@@ -76,11 +76,13 @@ export async function runEstimate(config, pricing) {
76
76
  const weekly = projection.rows.find((r) => r.frequency === "weekly");
77
77
  console.error("");
78
78
  if (pricing.hardCap) {
79
- // Free:超额=限流,备份直接失败,daily 大库根本跑不完
79
+ // Free:超额不计费,走 Fair Use 通知 → 宽限期 → 限制;持续超额的大备份在 Free 跑不通
80
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. ` +
81
+ log.warn(`${pricing.planLabel} plan egress is never billed, but exceeding the included ` +
82
+ `${pricing.includedGb} GB/month starts Supabase's Fair Use process: a ` +
83
+ `notification, then a grace period, then egress restrictions. Daily backups ` +
84
+ `(~${daily.monthlyEgressGb} GB/month) would keep you permanently over the ` +
85
+ `line — sustained large backups are not viable on Free. ` +
84
86
  `Use weekly at most, or upgrade to Pro.`);
85
87
  }
86
88
  }
@@ -93,9 +95,9 @@ export async function runEstimate(config, pricing) {
93
95
  "numbers above are what you'd pay.");
94
96
  const worst = daily?.monthlyCost ?? 0;
95
97
  if (worst >= SUBSCRIPTION_FLOOR_USD) {
96
- log.warn(`At daily frequency the egress overage alone (~$${worst.toFixed(0)}/mo) is ` +
98
+ log.warn(`At daily frequency the egress overage alone (~$${worst.toFixed(0)}/month) is ` +
97
99
  `in the range of a paid backup subscription. Consider weekly ` +
98
- `(~$${(weekly?.monthlyCost ?? 0).toFixed(0)}/mo overage) or a smaller scope.`);
100
+ `(~$${(weekly?.monthlyCost ?? 0).toFixed(0)}/month overage) or a smaller scope.`);
99
101
  }
100
102
  }
101
103
  return projection;
package/dist/index.js CHANGED
@@ -7,9 +7,10 @@ import { runDrill } from "./drill.js";
7
7
  import { runRestore } from "./restore.js";
8
8
  import { log } from "./log.js";
9
9
  import { TOOL_VERSION } from "./version.js";
10
- // Supabase egress 定价表(2026-07-04 核实,来源见 README「Egress & cost」)。
10
+ // Supabase egress 定价表(2026-07-10 核实,来源见 README「Egress & cost」)。
11
11
  // 备份流量 100% 未缓存,故用 uncached 单价 $0.09/GB;included 用 uncached 额度。
12
- // Free 硬上限(超额即限流,不计费);Pro/Team 由 Spend Cap 决定超额限流还是计费。
12
+ // Free 永不计费:超额走 Fair Use 流程(通知 → 宽限期 → 限制),持续大备份不可行;
13
+ // Pro/Team 由 Spend Cap 决定超额限流还是计费。
13
14
  const EGRESS_PRICING = {
14
15
  free: { planLabel: "Free", includedGb: 5, pricePerGb: 0.09, hardCap: true },
15
16
  pro: { planLabel: "Pro", includedGb: 250, pricePerGb: 0.09, hardCap: false },
@@ -74,6 +75,9 @@ program
74
75
  .option("--prefix <prefix>", "key prefix inside the bucket (default: backupdrill)")
75
76
  .option("--project-name <name>", "project name used in the object key")
76
77
  .option("--verify-all-files", "checksum every Storage file, not just a sample")
78
+ .option("--check-cmd <command>", "after structural checks pass, run your own smoke test against the restored sandbox " +
79
+ "(connection string in BACKUPDRILL_SANDBOX_URL; exit 0 = pass, reported as 'app checks')")
80
+ .option("--keep", "if the drill fails, keep the sandbox container running for inspection")
77
81
  .action(async (opts) => {
78
82
  try {
79
83
  const config = await loadConfig({
@@ -93,6 +97,8 @@ program
93
97
  const report = await runDrill(config, {
94
98
  snapshot: opts.snapshot,
95
99
  verifyAllFiles: opts.verifyAllFiles,
100
+ appCheckCommand: opts.checkCmd,
101
+ keepSandboxOnFailure: opts.keep,
96
102
  });
97
103
  console.error("");
98
104
  for (const c of report.checks) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
5
5
  "type": "module",
6
6
  "bin": {