backupdrill 2.1.0 → 2.1.2

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
@@ -1,17 +1,19 @@
1
1
  # backupdrill
2
2
 
3
- Open-source CLI to back up a **Supabase** project to **your own** S3/R2/B2 bucket — the Postgres database **and** Storage files, together — with a checksummed `manifest.json` so you can prove what was captured.
3
+ Open-source CLI to back up a **Supabase** project to **your own** S3/R2/B2 bucket — the Postgres database and, once you configure its S3 keys, your Storage files — with a checksummed `manifest.json` so you can prove what was captured.
4
+
5
+ Backup is half the job. `backupdrill drill` restores your latest snapshot into a throwaway Postgres and **verifies it actually restores** — because a backup you've never restored is a guess.
4
6
 
5
7
  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
8
 
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.
9
+ > Want the same engine on a schedule, with drills that run themselves, alerts, and emailed reports? That's the hosted service → **[backupdrill.com](https://backupdrill.com)**. This CLI is the DIY layer; the cloud adds scheduling, alerting, and reports on top.
8
10
 
9
11
  🎥 Prefer watching? [Where do your backups actually go — explained in about a minute](https://backupdrill.com/guides/test-supabase-backup-restore#explainer-video).
10
12
 
11
13
  ## Why
12
14
 
13
15
  - Supabase's own database restore only brings back `storage.objects` **metadata**, not your actual Storage files.
14
- - Pro-plan backups keep 7 days and can't be pulled into your own infrastructure; PITR is $100/month per project.
16
+ - Pro-plan backups keep 7 days and — on the physical backup process current projects use — can't be downloaded; PITR is $100/month per project.
15
17
  - A `pg_dump` you've never restored is a guess.
16
18
 
17
19
  This CLI gets your database into a bucket **you** control, with a manifest you can check.
package/dist/backup.js CHANGED
@@ -2,7 +2,6 @@ import { spawn, execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { Transform } from "node:stream";
4
4
  import { promisify } from "node:util";
5
- import { Client } from "pg";
6
5
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
7
6
  import { Upload } from "@aws-sdk/lib-storage";
8
7
  import { MANIFEST_SCHEMA_VERSION } from "./manifest.js";
@@ -12,7 +11,7 @@ import { renderRecoveryDoc } from "./recovery-doc.js";
12
11
  import { projectRefOf, refFromStorageEndpoint, assertNoHostOverride } from "./restore-engine.js";
13
12
  import { log } from "./log.js";
14
13
  import { TOOL_VERSION } from "./version.js";
15
- import { pgConnectOptions, dumpUrlFor } from "./supabase-ca.js";
14
+ import { dumpUrlFor, connectPg } from "./supabase-ca.js";
16
15
  const execFileAsync = promisify(execFile);
17
16
  function majorOf(serverVersionNum) {
18
17
  // server_version_num: 150004 → 15, 170002 → 17
@@ -52,8 +51,7 @@ async function pgDumpVersion(bin) {
52
51
  async function inspectDatabase(databaseUrl, schemas) {
53
52
  // Supabase 主机 → 用打包的根 CA 做 verify-full(pooler 证书自签、不在系统信任库);
54
53
  // 非 Supabase(本 CLI 不限制目标)→ 保持默认,别把开源用户挡在门外
55
- const client = new Client(pgConnectOptions(databaseUrl));
56
- await client.connect();
54
+ const client = await connectPg(databaseUrl);
57
55
  try {
58
56
  const version = await client.query("show server_version");
59
57
  const versionNum = await client.query("show server_version_num");
package/dist/drill.js CHANGED
@@ -3,11 +3,10 @@ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
- import { Client } from "pg";
7
6
  import { parseManifest } from "./manifest.js";
8
7
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
9
8
  import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, } from "./restore-engine.js";
10
- import { pgConnectOptions } from "./supabase-ca.js";
9
+ import { connectPg } from "./supabase-ca.js";
11
10
  import { log } from "./log.js";
12
11
  const execFileAsync = promisify(execFile);
13
12
  // Storage 校验默认抽样上限(避免每次演练重下全部文件产生大量 egress);超限如实记录
@@ -128,8 +127,7 @@ async function pgRestore(dumpPath, connString) {
128
127
  export async function verifyRestored(connString, manifest) {
129
128
  // pgConnectOptions:真实 Supabase 目标(restore 复用本函数)需要打包根 CA;
130
129
  // 沙箱的 docker 连接串非 Supabase 主机,原样透传,行为不变
131
- const client = new Client(pgConnectOptions(connString));
132
- await client.connect();
130
+ const client = await connectPg(connString);
133
131
  try {
134
132
  const schemas = manifest.database.schemas;
135
133
  // 口径必须与 manifest 统计端(backup.ts inspectDatabase)完全一致:
package/dist/estimate.js CHANGED
@@ -1,13 +1,11 @@
1
- import { Client } from "pg";
2
1
  import { measureStorage } from "./storage.js";
3
2
  import { log } from "./log.js";
4
- import { pgConnectOptions } from "./supabase-ca.js";
3
+ import { connectPg } from "./supabase-ca.js";
5
4
  // 超出这个月度成本就该提醒用户"备份的 egress 账单快赶上订阅费了"
6
5
  const SUBSCRIPTION_FLOOR_USD = 19;
7
6
  const GB = 1024 * 1024 * 1024;
8
7
  async function measureDbBytes(databaseUrl, schemas) {
9
- const client = new Client(pgConnectOptions(databaseUrl));
10
- await client.connect();
8
+ const client = await connectPg(databaseUrl);
11
9
  try {
12
10
  // pg_total_relation_size 含索引+TOAST,是 egress 上界(真实转储经压缩、不含索引,更小)
13
11
  const res = await client.query(`select coalesce(
package/dist/index.js CHANGED
File without changes
@@ -5,7 +5,9 @@ export type RestoreTargetKind = "sandbox" | "supabase";
5
5
  export declare function normalizeHost(hostname: string): string;
6
6
  /**
7
7
  * 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
8
- * pooler 用户名 `postgres.<ref>`。放在引擎层:备份端(写 manifest.sourceProjectRef)
8
+ * pooler 用户名 `<role>.<ref>`(默认 postgres,BackupDrill 免密接入建的 backupdrill_* 角色
9
+ * 同样走 Supavisor,租户身份只看最后一段 ref;角色名限小写字母/数字/下划线,与 Postgres 标识符
10
+ * 未加引号时的折叠规则一致)。放在引擎层:备份端(写 manifest.sourceProjectRef)
9
11
  * 与恢复端(同源阻断)共用同一个身份判定。非 Supabase 形态返回 null。
10
12
  */
11
13
  export declare function projectRefOf(connString: string): string | null;
@@ -9,9 +9,8 @@
9
9
  // 3. dump 自带 CREATE SCHEMA,而目标(新容器或 Supabase)恒有同名 schema →
10
10
  // pre-data 阶段的 schema "already exists" 是两类目标共同的预期冲突。
11
11
  import { spawn } from "node:child_process";
12
- import { Client } from "pg";
13
12
  import { resolvePgRestoreBin } from "./pgbin.js";
14
- import { pgConnectOptions, dumpUrlFor } from "./supabase-ca.js";
13
+ import { dumpUrlFor, connectPg } from "./supabase-ca.js";
15
14
  /** 主机名规范化:剥掉合法的 DNS 根点(db.x.supabase.co. ≡ db.x.supabase.co)。
16
15
  * 身份比较不规范化 = 一个尾点就能绕过同源阻断(supabase-ca 对同类早有钉子)。 */
17
16
  export function normalizeHost(hostname) {
@@ -19,7 +18,9 @@ export function normalizeHost(hostname) {
19
18
  }
20
19
  /**
21
20
  * 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
22
- * pooler 用户名 `postgres.<ref>`。放在引擎层:备份端(写 manifest.sourceProjectRef)
21
+ * pooler 用户名 `<role>.<ref>`(默认 postgres,BackupDrill 免密接入建的 backupdrill_* 角色
22
+ * 同样走 Supavisor,租户身份只看最后一段 ref;角色名限小写字母/数字/下划线,与 Postgres 标识符
23
+ * 未加引号时的折叠规则一致)。放在引擎层:备份端(写 manifest.sourceProjectRef)
23
24
  * 与恢复端(同源阻断)共用同一个身份判定。非 Supabase 形态返回 null。
24
25
  */
25
26
  export function projectRefOf(connString) {
@@ -30,7 +31,7 @@ export function projectRefOf(connString) {
30
31
  return direct[1];
31
32
  // 用户名必须先解码再匹配:URL 解析器保留百分号编码,而 pg/libpq 会解码——
32
33
  // postgres%2Eref 在驱动眼里就是 postgres.ref,不解码 = 身份判定可被编码绕过
33
- const pooled = decodeURIComponent(url.username).match(/^postgres\.([a-z0-9]{16,})$/);
34
+ const pooled = decodeURIComponent(url.username).match(/^[a-z0-9_]+\.([a-z0-9]{16,})$/);
34
35
  if (pooled)
35
36
  return pooled[1];
36
37
  return null;
@@ -129,8 +130,7 @@ export async function installExtensions(connString, extensions) {
129
130
  return [];
130
131
  // pgConnectOptions:Supabase 主机自动带打包根 CA(node-pg 把 require 按 verify-full
131
132
  // 处理,裸 Client 对 pooler 必报 self-signed chain);沙箱等非 Supabase 目标原样透传
132
- const client = new Client(pgConnectOptions(connString));
133
- await client.connect();
133
+ const client = await connectPg(connString);
134
134
  const unavailable = [];
135
135
  try {
136
136
  for (const ext of extensions) {
package/dist/restore.js CHANGED
@@ -4,13 +4,12 @@ import { tmpdir } from "node:os";
4
4
  import { join, dirname, resolve, sep } from "node:path";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { GetObjectCommand } from "@aws-sdk/client-s3";
7
- import { Client } from "pg";
8
7
  import { parseManifest } from "./manifest.js";
9
8
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, } from "./snapshots.js";
10
9
  import { restoreDatabaseArtifact, installExtensions, projectRefOf, refFromStorageEndpoint, assertNoHostOverride, normalizeHost, } from "./restore-engine.js";
11
10
  import { verifyRestored } from "./drill.js";
12
11
  import { restoreStorage, planStorageRestore, } from "./storage-restore.js";
13
- import { pgConnectOptions } from "./supabase-ca.js";
12
+ import { connectPg } from "./supabase-ca.js";
14
13
  import { resolvePgRestoreBin } from "./pgbin.js";
15
14
  import { parsePgDumpMajor } from "./backup.js";
16
15
  import { execFile } from "node:child_process";
@@ -177,8 +176,7 @@ export function assertConfirmedTarget(confirmTarget, targetDatabaseUrl, targetSu
177
176
  * 留下半截目标(评审第 7 轮)。
178
177
  */
179
178
  async function assertEmptyTarget(targetUrl, schemas) {
180
- const client = new Client(pgConnectOptions(targetUrl));
181
- await client.connect();
179
+ const client = await connectPg(targetUrl);
182
180
  try {
183
181
  const res = await client.query(`select
184
182
  (select count(*) from pg_class c join pg_namespace n on n.oid = c.relnamespace
@@ -243,8 +241,7 @@ async function dryRunPreflight(config, manifest, targetUrl, storageTarget) {
243
241
  blockers.push(error.message);
244
242
  log.error(`empty-target check: ${error.message}`);
245
243
  }
246
- const client = new Client(pgConnectOptions(targetUrl));
247
- await client.connect();
244
+ const client = await connectPg(targetUrl);
248
245
  try {
249
246
  // SHOW 的结果列名是 server_version;用 current_setting 显式起别名,
250
247
  // 否则取错列得 NaN,而 NaN < x 恒 false = 版本降级永远查不出来
@@ -413,8 +410,7 @@ export async function runRestore(config, opts) {
413
410
  await assertEmptyTarget(targetUrl, manifest.database.schemas);
414
411
  // 版本预检不只属于 dry-run:跳过 dry-run 直接执行时,目标比源旧照样会
415
412
  // 半途炸掉或部分恢复(评审第 8 轮)——写入前必须拦住
416
- const versionClient = new Client(pgConnectOptions(targetUrl));
417
- await versionClient.connect();
413
+ const versionClient = await connectPg(targetUrl);
418
414
  let targetMajor;
419
415
  try {
420
416
  const res = await versionClient.query("select current_setting('server_version') as v");
@@ -1,10 +1,4 @@
1
- // Bucket 属性采集(Manifest v2,恢复闭环 PRD §5.1.3):public / 大小限制 / MIME 限制。
2
- // 通道 = 源库 storage.buckets —— S3 兼容端点看不到这些属性,而备份本来就持有只读库连接,
3
- // 零新增凭据(R0 spike 实证)。per-file 元数据不在这里:它随 syncStorage 的 GetObject
4
- // 响应原子捕获(见 storage.ts fileMetadataFromS3)。
5
- // 任何失败都由调用方降级为"未捕获"(manifest 缺 buckets 字段),绝不弄失败一次备份。
6
- import { Client } from "pg";
7
- import { pgConnectOptions } from "./supabase-ca.js";
1
+ import { connectPg } from "./supabase-ca.js";
8
2
  /**
9
3
  * 行 → manifest 形状的纯映射,含完整性关口:查询结果必须覆盖每一个请求的 bucket。
10
4
  * storage.buckets 上的 RLS 或并发删除会**静默**少返回行——把不完整清单写进 manifest,
@@ -36,8 +30,7 @@ export function bucketAttrsFromRows(requestedBuckets, bucketRows) {
36
30
  }
37
31
  /** 查询源库的 bucket 属性。bucketNames 含空 bucket(恢复端要能重建它们)。 */
38
32
  export async function inspectBucketAttrs(databaseUrl, bucketNames) {
39
- const client = new Client(pgConnectOptions(databaseUrl));
40
- await client.connect();
33
+ const client = await connectPg(databaseUrl);
41
34
  try {
42
35
  const bucketRows = await client.query(`select id, name, public, file_size_limit, allowed_mime_types
43
36
  from storage.buckets where id = any($1::text[]) or name = any($1::text[])
@@ -1,3 +1,4 @@
1
+ import { Client } from "pg";
1
2
  export declare const SUPABASE_ROOT_CA = "-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n";
2
3
  /** node-pg 的 ssl 配置:验签(用打包的 Supabase 根 CA)+ 验主机名(rejectUnauthorized)。 */
3
4
  export declare const SUPABASE_SSL: {
@@ -30,3 +31,17 @@ export declare function pgConnectOptions(databaseUrl: string): {
30
31
  connectionString: string;
31
32
  ssl?: typeof SUPABASE_SSL;
32
33
  };
34
+ /**
35
+ * 给一个 pg Client 挂上 error 监听。**必须在 connect() 之前挂。**
36
+ *
37
+ * 为什么:node-postgres 在连接建立后被对端掐断(Supabase 抖动、项目被暂停、网络抖)时,
38
+ * 除了让正在等的查询 reject,还会在 client 上 emit "error";EventEmitter 没有监听者的 "error"
39
+ * 直接抛成 uncaughtException。本引擎跑在 BackupDrill worker 进程里,一个客户库断连就能杀掉
40
+ * 整个 worker,连带打断其他客户正在跑的任务(2026-08-28 复盘)。挂上之后查询照常 reject、
41
+ * 调用方的 try/finally 照常收尾,进程活着。只记一行日志:断连的原因会从 reject 的错误里报出来。
42
+ */
43
+ export declare function attachPgErrorGuard(client: {
44
+ on(event: "error", listener: (error: Error) => void): unknown;
45
+ }): void;
46
+ /** 连接一个 Postgres:pgConnectOptions(TLS 口径)+ error 监听 + connect(),三步不可分开。 */
47
+ export declare function connectPg(databaseUrl: string): Promise<Client>;
@@ -1,3 +1,4 @@
1
+ import { Client } from "pg";
1
2
  /**
2
3
  * Supabase Root 2021 CA —— 打包进引擎,用于对用户 Supabase 源库连接做 verify-full。
3
4
  *
@@ -238,3 +239,24 @@ export function pgConnectOptions(databaseUrl) {
238
239
  return { connectionString: databaseUrl };
239
240
  return { connectionString: stripSslParams(databaseUrl), ssl: SUPABASE_SSL };
240
241
  }
242
+ /**
243
+ * 给一个 pg Client 挂上 error 监听。**必须在 connect() 之前挂。**
244
+ *
245
+ * 为什么:node-postgres 在连接建立后被对端掐断(Supabase 抖动、项目被暂停、网络抖)时,
246
+ * 除了让正在等的查询 reject,还会在 client 上 emit "error";EventEmitter 没有监听者的 "error"
247
+ * 直接抛成 uncaughtException。本引擎跑在 BackupDrill worker 进程里,一个客户库断连就能杀掉
248
+ * 整个 worker,连带打断其他客户正在跑的任务(2026-08-28 复盘)。挂上之后查询照常 reject、
249
+ * 调用方的 try/finally 照常收尾,进程活着。只记一行日志:断连的原因会从 reject 的错误里报出来。
250
+ */
251
+ export function attachPgErrorGuard(client) {
252
+ client.on("error", (error) => {
253
+ console.warn(`[pg] connection error after connect: ${error.message}`);
254
+ });
255
+ }
256
+ /** 连接一个 Postgres:pgConnectOptions(TLS 口径)+ error 监听 + connect(),三步不可分开。 */
257
+ export async function connectPg(databaseUrl) {
258
+ const client = new Client(pgConnectOptions(databaseUrl));
259
+ attachPgErrorGuard(client);
260
+ await client.connect();
261
+ return client;
262
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
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": {