backupdrill 2.3.0 → 2.5.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/ping.js ADDED
@@ -0,0 +1,217 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { log } from "./log.js";
3
+ import { projectRefOf, refFromStorageEndpoint } from "./restore-engine.js";
4
+ import { TOOL_VERSION } from "./version.js";
5
+ // 按 Healthchecks.io 的 ping 约定报告一次 backup / drill(https://healthchecks.io/docs/http_api/):
6
+ // 开始 <url>/start,成功 <url>,失败 <url>/fail,失败原因放请求体。三次请求带同一个 rid,
7
+ // Healthchecks 才能把开始和结束配成一对算耗时,同一个检查并发跑两次也不会串。
8
+ // 监控是旁路:ping 出任何问题只打警告,绝不改变备份/演练本身的结果和退出码。
9
+ /** 每次请求的上限:Healthchecks 建议必须设超时,卡住的 /start 不能拖住备份本身 */
10
+ const TIMEOUT_MS = 10_000;
11
+ /** 网络错误和 5xx 重试;4xx(404 = 网址写错)重试也没用 */
12
+ const ATTEMPTS = 3;
13
+ const RETRY_DELAY_MS = 1_000;
14
+ /** 失败原因只给个梗概:Healthchecks 会把请求体原样放进告警邮件和事件日志 */
15
+ const BODY_LIMIT = 1_000;
16
+ const NO_PING = {
17
+ start: async () => { },
18
+ success: async () => { },
19
+ fail: async () => { },
20
+ };
21
+ export function createPinger(rawUrl, opts = {}) {
22
+ if (!rawUrl)
23
+ return NO_PING;
24
+ let base;
25
+ try {
26
+ base = new URL(rawUrl);
27
+ if (base.protocol !== "https:" && base.protocol !== "http:")
28
+ throw new Error("not http(s)");
29
+ }
30
+ catch {
31
+ // 网址本身不对也只是监控没接上:备份照跑,Healthchecks 那边会因为收不到 ping 报警
32
+ log.warn("The ping URL is not a valid http(s) URL, so no pings will be sent. The run carries on.");
33
+ return NO_PING;
34
+ }
35
+ // fetch 拒绝带用户名密码的网址,而且拒绝时的报错里有整条网址;在这里先拦下,警告里不复述它
36
+ if (base.username || base.password) {
37
+ log.warn("The ping URL contains a username or password, which pings can't send, so no pings will be sent. The run carries on.");
38
+ return NO_PING;
39
+ }
40
+ const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
41
+ const attempts = opts.attempts ?? ATTEMPTS;
42
+ const retryDelayMs = opts.retryDelayMs ?? RETRY_DELAY_MS;
43
+ const rid = randomUUID();
44
+ const endpoint = (suffix) => {
45
+ const url = new URL(base.href);
46
+ // 改 pathname 而不是拼字符串:slug 网址可能带 ?create=1,后缀要落在问号前面。
47
+ // 结尾的斜杠三种 ping 都去掉:Healthchecks 的 ping 路由不认带斜杠的网址
48
+ url.pathname = url.pathname.replace(/\/+$/, "") + suffix;
49
+ url.searchParams.set("rid", rid);
50
+ return url.href;
51
+ };
52
+ // 日志里只写 start/success/fail,不写网址:网址里的 UUID 谁拿到都能替你报平安
53
+ const send = async (kind, url, body) => {
54
+ let lastProblem = "";
55
+ for (let attempt = 1; attempt <= attempts; attempt++) {
56
+ try {
57
+ const res = await fetch(url, {
58
+ method: "POST",
59
+ body,
60
+ headers: {
61
+ "content-type": "text/plain; charset=utf-8",
62
+ "user-agent": `backupdrill/${TOOL_VERSION}`,
63
+ },
64
+ signal: AbortSignal.timeout(timeoutMs),
65
+ });
66
+ const answer = await readStart(res);
67
+ if (res.ok) {
68
+ // Healthchecks.io 对查无此检查、超频的 ping 也回 200,正文是 "OK (not found)" /
69
+ // "OK (rate limited)"(https://healthchecks.io/docs/http_api/);这类 ping 没记上,要提醒
70
+ if (!/^OK \(/.test(answer))
71
+ return;
72
+ lastProblem = `Healthchecks answered "${answer.replace(/[^\x20-\x7e]/g, "").slice(0, 60)}"`;
73
+ break;
74
+ }
75
+ lastProblem = `HTTP ${res.status}`;
76
+ if (res.status < 500)
77
+ break;
78
+ }
79
+ catch (error) {
80
+ // 只报超时或错误码,不报原始报错:有些报错会把整条网址带出来
81
+ const err = error;
82
+ lastProblem =
83
+ err.name === "TimeoutError"
84
+ ? `no response within ${Math.round(timeoutMs / 1000)}s`
85
+ : typeof err.cause?.code === "string"
86
+ ? err.cause.code
87
+ : "network error";
88
+ }
89
+ if (attempt < attempts)
90
+ await new Promise((r) => setTimeout(r, retryDelayMs * attempt));
91
+ }
92
+ log.warn(`Could not send the ${kind} ping (${lastProblem}). The run carries on.`);
93
+ };
94
+ return {
95
+ start: () => send("start", endpoint("/start")),
96
+ success: (summary) => send("success", endpoint(""), truncate(summary)),
97
+ fail: (reason, sensitive = []) => send("fail", endpoint("/fail"), truncate(scrubForPing(reason, sensitive))),
98
+ };
99
+ }
100
+ /** 只读响应开头一小段:Healthchecks 的回答就几个字节,网址填错指到大页面时也不整页下载 */
101
+ async function readStart(res, limit = 256) {
102
+ if (!res.body)
103
+ return "";
104
+ const reader = res.body.getReader();
105
+ const chunks = [];
106
+ let size = 0;
107
+ try {
108
+ while (size < limit) {
109
+ const { done, value } = await reader.read();
110
+ if (done)
111
+ break;
112
+ chunks.push(value);
113
+ size += value.length;
114
+ }
115
+ }
116
+ finally {
117
+ await reader.cancel().catch(() => { });
118
+ }
119
+ return Buffer.concat(chunks).subarray(0, limit).toString("utf8").trim();
120
+ }
121
+ function truncate(text) {
122
+ return text.length > BODY_LIMIT ? `${text.slice(0, BODY_LIMIT - 1)}…` : text;
123
+ }
124
+ /** backup 成功时发给监控的一行。快照名取对象键里的目录名(和 drill --snapshot 认的是同一个) */
125
+ export function backupSummary(manifest) {
126
+ return (`backupdrill ${TOOL_VERSION}: backup complete, ${manifest.database.tableCount} tables` +
127
+ (manifest.storage ? ` + ${manifest.storage.fileCount} Storage files` : " (database only)") +
128
+ `, snapshot ${manifest.dump.key.split("/").at(-2) ?? manifest.createdAt}`);
129
+ }
130
+ /** drill 跑完(没抛错)后发哪种 ping、带什么:通过 = 成功;没通过 = 失败,列出没过的检查 */
131
+ export function drillOutcome(report) {
132
+ const outcome = `snapshot ${report.snapshot}, ${report.restoredTableCount} tables / ` +
133
+ `${report.restoredRowTotal.toLocaleString()} rows restored in ${report.restoreSeconds}s`;
134
+ if (report.pass)
135
+ return { pass: true, text: `backupdrill ${TOOL_VERSION}: drill passed, ${outcome}` };
136
+ const failed = report.checks.filter((c) => !c.pass).map((c) => `${c.name}: ${c.detail}`);
137
+ return {
138
+ pass: false,
139
+ text: `backupdrill ${TOOL_VERSION}: drill failed, ${outcome}. Failed checks:\n${failed.join("\n")}`,
140
+ };
141
+ }
142
+ /** 报错可以自带一句发给监控用的说法(pingMessage),比如不能复述配置文件原文的那种 */
143
+ export function pingMessageOf(error) {
144
+ const err = error;
145
+ return typeof err?.pingMessage === "string" ? err.pingMessage : String(err?.message ?? error);
146
+ }
147
+ /**
148
+ * 失败原因发出去之前抹掉连接信息。先按值抹(这次运行确实用到的连接串、密码、主机、密钥),
149
+ * 再按形状兜底:pg / S3 的报错会带主机名、IP、整条连接串,配置文件解析失败时 JSON.parse
150
+ * 的报错还会带一截文件原文,这些都不是事先知道的值。
151
+ */
152
+ export function scrubForPing(text, sensitive) {
153
+ let out = text;
154
+ // 长的先换:密码可能是连接串的一部分,先换短的会把长的打散,长的就匹配不上了
155
+ for (const value of [...new Set(sensitive)].sort((a, b) => b.length - a.length)) {
156
+ if (value.length >= 4)
157
+ out = out.split(value).join("[redacted]");
158
+ }
159
+ return out
160
+ // Postgres 把具体的值放在 DETAIL / CONTEXT 这类附加字段里(唯一键冲突的键值、COPY 出错的整行),
161
+ // pg_restore 还会回显出错的 SQL。从第一个这类字段起把这一行剩下的全部去掉:值里什么字符都可能有,
162
+ // 包括多条报错之间的拼接符 " | ",所以不能指望找到字段的结尾。引擎上报的恢复错误块已经压成一行
163
+ .replace(/\b(DETAIL|CONTEXT|HINT|QUERY|STATEMENT|LINE \d+|Command was):[^\n]*/g, "$1: [removed]")
164
+ // 主报错里偶尔也带值:键值对 (col)=(value),以及 invalid input syntax for type x: "value"
165
+ .replace(/\(([^()]*)\)=\((?:[^()]|\([^()]*\))*\)/g, "($1)=([removed])")
166
+ .replace(/(invalid input (?:syntax|value) for [^:"\n]*: )"[^"\n]*"/gi, '$1"[removed]"')
167
+ .replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>`]+/gi, "[url]")
168
+ .replace(/[^\s"'<>`/@]*:[^\s"'<>`/@]*@/g, "[redacted]@")
169
+ // Supabase pooler 的用户名是 postgres.<项目 ref>
170
+ .replace(/\bpostgres\.[a-z0-9]{16,}\b/gi, "postgres.[ref]")
171
+ .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]")
172
+ .replace(/\b[0-9a-f]{1,4}(?::[0-9a-f]{0,4}){3,7}\b/gi, "[ip]")
173
+ // 至少两个点的主机名;常见文件扩展名放过,免得把 backupdrill.config.json 也抹了
174
+ .replace(/\b(?:[a-z0-9-]+\.){2,}(?!(?:json|md|ya?ml|js|ts|txt|log|sql|pgcustom)\b)[a-z]{2,}\b/gi, "[host]");
175
+ }
176
+ /** 这次运行里不该出现在 ping 里的值:连接串、密码、主机、项目 ref、桶和 Storage 的密钥。 */
177
+ export function sensitiveValuesOf(config, extraDatabaseUrl) {
178
+ const env = process.env;
179
+ const values = [
180
+ // Storage 端点里有项目 ref(<ref>.storage.supabase.co),库与 Storage 不是同一项目的报错会带出它
181
+ ...[config?.supabaseStorage?.endpoint, env.BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT].flatMap((endpoint) => endpoint ? [refFromStorageEndpoint(endpoint) ?? undefined, hostOf(endpoint)] : []),
182
+ ...[config?.storage?.endpoint, env.BACKUPDRILL_S3_ENDPOINT].map(hostOf),
183
+ env.BACKUPDRILL_S3_ACCESS_KEY_ID,
184
+ env.BACKUPDRILL_S3_SECRET_ACCESS_KEY,
185
+ env.BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID,
186
+ env.BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY,
187
+ config?.storage?.accessKeyId,
188
+ config?.storage?.secretAccessKey,
189
+ config?.supabaseStorage?.accessKeyId,
190
+ config?.supabaseStorage?.secretAccessKey,
191
+ ];
192
+ for (const conn of [config?.databaseUrl, extraDatabaseUrl, env.BACKUPDRILL_DATABASE_URL, env.DATABASE_URL]) {
193
+ if (!conn)
194
+ continue;
195
+ values.push(conn, projectRefOf(conn) ?? undefined);
196
+ // 用户名不进列表:常见的就是 postgres,按值抹会把报错里每个 postgres 都换掉;
197
+ // pooler 用户名里真正要藏的项目 ref 上面已经单独抹了
198
+ try {
199
+ const url = new URL(conn);
200
+ values.push(url.hostname, url.password, decodeURIComponent(url.password));
201
+ }
202
+ catch {
203
+ // 解析不了的连接串整条已经在列表里了
204
+ }
205
+ }
206
+ return values.filter((v) => Boolean(v));
207
+ }
208
+ function hostOf(endpoint) {
209
+ if (!endpoint)
210
+ return undefined;
211
+ try {
212
+ return new URL(endpoint).hostname;
213
+ }
214
+ catch {
215
+ return undefined;
216
+ }
217
+ }
package/dist/restore.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { BackupConfig } from "./config.js";
2
2
  import type { Manifest } from "./manifest.js";
3
+ import { type SnapshotStore } from "./snapshot-store.js";
3
4
  import { type DrillCheck } from "./drill.js";
4
5
  import { type StorageRestoreSummary } from "./storage-restore.js";
5
6
  /** 归档可读性要求:pg_restore 必须 ≥ max(写入工具版本, 源服务端版本)——格式跟随写入工具。 */
@@ -88,4 +89,6 @@ export declare function runRestore(config: BackupConfig, opts: {
88
89
  skipStorage?: boolean;
89
90
  snapshot?: string;
90
91
  storageDir?: string;
92
+ /** 快照从哪读;省略 = config.storage 指的 S3 兼容桶 */
93
+ store?: SnapshotStore;
91
94
  }): Promise<RestoreResult>;
package/dist/restore.js CHANGED
@@ -5,7 +5,7 @@ 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
7
  import { parseManifest } from "./manifest.js";
8
- import { targetClient, resolveSnapshot, getObjectText, downloadToFile, } from "./snapshots.js";
8
+ import { s3SnapshotStore } from "./snapshot-store.js";
9
9
  import { restoreDatabaseArtifact, installExtensions, projectRefOf, refFromStorageEndpoint, assertNoHostOverride, normalizeHost, } from "./restore-engine.js";
10
10
  import { verifyRestored } from "./drill.js";
11
11
  import { restoreStorage, planStorageRestore, } from "./storage-restore.js";
@@ -335,11 +335,10 @@ export async function runRestore(config, opts) {
335
335
  assertNoHostOverride(config.databaseUrl);
336
336
  if (opts.targetDatabaseUrl)
337
337
  assertNoHostOverride(opts.targetDatabaseUrl);
338
- const s3 = targetClient(config);
339
- const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
340
- const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
341
- const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
342
- const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
338
+ const store = opts.store ?? s3SnapshotStore(config);
339
+ const reader = await store.openSnapshot(opts.snapshot);
340
+ const snapshot = reader.name;
341
+ const manifest = parseManifest(await reader.readManifest());
343
342
  // Storage 目标构造即校验(https + 恰为 <ref>.supabase.co):service key 只发往
344
343
  // 已验证的 Supabase 项目源;目标库连接串拒绝 host 覆盖(身份判定=实际写入目标)
345
344
  // 两个字段必须成对:只给其一时静默退回"本地下载"会让库级调用方把受保护文件
@@ -389,6 +388,11 @@ export async function runRestore(config, opts) {
389
388
  // 这次运行会不会碰 Storage(回传到目标,或下载到本地)。只有会碰时,空目标门才该
390
389
  // 提示"库已恢复、只重试 Storage";skipStorage 的纯数据库运行(托管恢复)不该看到这句。
391
390
  const alsoRestoringStorage = !!manifest.storage && !opts.skipStorage;
391
+ const storageSource = manifest.storage ? reader.storageFiles(manifest) : null;
392
+ if (alsoRestoringStorage && !storageSource) {
393
+ throw new Error("the manifest lists Storage files, but this snapshot location cannot hold them — " +
394
+ "restore the database only (skip Storage).");
395
+ }
392
396
  // dry-run:只读预检后直接返回,零写入(PRD §5.3.2)
393
397
  if (opts.dryRun) {
394
398
  log.step(`Dry run for snapshot ${snapshot} — nothing will be written`);
@@ -461,7 +465,7 @@ export async function runRestore(config, opts) {
461
465
  throw new Error(toolTargetBlocker);
462
466
  log.step("Downloading dump…");
463
467
  const dumpPath = join(workdir, "dump.pgcustom");
464
- const { sha256 } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
468
+ const { sha256 } = await reader.downloadDump(manifest, dumpPath);
465
469
  // 归档完整性是恢复的前提:哈希不符还继续,只会把"备份坏了"变成一堆误导性
466
470
  // 恢复错误(演练侧同一裁决:integrity FAIL 即短路)
467
471
  if (sha256 !== manifest.dump.sha256) {
@@ -505,7 +509,7 @@ export async function runRestore(config, opts) {
505
509
  // DB-only 阶段(hosted 检查点重试):既不回传也不本地下载,Storage 单独一段跑
506
510
  }
507
511
  else if (manifest.storage && storageTarget) {
508
- const summary = await restoreStorage({ s3, bucket: config.storage.bucket, base }, storageTarget, manifest);
512
+ const summary = await restoreStorage(storageSource, storageTarget, manifest);
509
513
  result.storageSummary = summary;
510
514
  result.storageFilesWritten = summary.filesUploaded;
511
515
  log.ok(`Storage restored: ${summary.filesUploaded}/${manifest.storage.files.length} files uploaded` +
@@ -568,9 +572,9 @@ export async function runRestore(config, opts) {
568
572
  throw new Error(`unsafe storage path escapes output directory: ${f.bucket}/${f.key}`);
569
573
  }
570
574
  await mkdir(dirname(dest), { recursive: true });
571
- const res = await s3.send(new GetObjectCommand({
572
- Bucket: config.storage.bucket,
573
- Key: `${base}/storage/${f.bucket}/${f.key}`,
575
+ const res = await storageSource.s3.send(new GetObjectCommand({
576
+ Bucket: storageSource.bucket,
577
+ Key: `${storageSource.base}/storage/${f.bucket}/${f.key}`,
574
578
  }));
575
579
  await pipeline(res.Body, createWriteStream(dest));
576
580
  result.storageFilesWritten += 1;
@@ -0,0 +1,54 @@
1
+ import type { Readable } from "node:stream";
2
+ import { type S3Client } from "@aws-sdk/client-s3";
3
+ import { type BackupConfig } from "./config.js";
4
+ import type { Manifest } from "./manifest.js";
5
+ /**
6
+ * 快照放在哪里,对引擎来说只是这几个动作:建一份新快照往里写、打开一份已有快照往外读。
7
+ * 备份、演练、恢复都只经这层读写快照。CLI 只用 S3 兼容桶(s3SnapshotStore);托管服务
8
+ * 的 worker 可以传入自己的实现(Google Drive 目标,实现不进公开包)。
9
+ */
10
+ export interface SnapshotStore {
11
+ /** name = 快照名(备份开始时的 UTC 时间戳) */
12
+ createSnapshot(name: string): Promise<SnapshotWriter>;
13
+ /** name 省略 = 最新一份 */
14
+ openSnapshot(name?: string): Promise<SnapshotReader>;
15
+ }
16
+ /** 快照里 Storage 文件的位置(<base>/storage/<bucket>/<key>)。只有 S3 兼容目标能放 Storage 文件。 */
17
+ export interface StorageFilesLocation {
18
+ s3: S3Client;
19
+ bucket: string;
20
+ base: string;
21
+ }
22
+ export interface DumpUpload {
23
+ /** 转储全部写完才 resolve;任何失败都 reject */
24
+ done: Promise<void>;
25
+ /** 中止写入,尽量不留下半截文件 */
26
+ abort(): Promise<void>;
27
+ }
28
+ export interface SnapshotWriter {
29
+ /** 记进 manifest.dump.key 的值 */
30
+ readonly dumpKey: string;
31
+ /** 这份快照能不能放 Storage 文件;不能时为 null */
32
+ readonly storageFiles: StorageFilesLocation | null;
33
+ /** 日志里显示的位置 */
34
+ describe(file: string): string;
35
+ /** relationBytes = 要转储的表的磁盘占用(量不出为 null),实现可据此决定分块 */
36
+ uploadDump(body: Readable, relationBytes: number | null): DumpUpload;
37
+ /** RECOVERY.md 写的是"从这个位置怎么恢复",所以由各实现自己渲染 */
38
+ writeRecoveryDoc(manifest: Manifest): Promise<void>;
39
+ /** manifest 是快照的"完成标记",调用方保证最后写 */
40
+ writeManifest(manifest: Manifest): Promise<void>;
41
+ }
42
+ export interface SnapshotReader {
43
+ readonly name: string;
44
+ readManifest(): Promise<string>;
45
+ /** 断点续传下载转储到本地文件,返回实际落盘的字节数与 sha256 */
46
+ downloadDump(manifest: Manifest, dest: string): Promise<{
47
+ bytes: number;
48
+ sha256: string;
49
+ }>;
50
+ /** 这份快照的 Storage 文件从哪读;实现不支持 Storage 文件时为 null */
51
+ storageFiles(manifest: Manifest): StorageFilesLocation | null;
52
+ }
53
+ /** S3 兼容桶(S3 / R2 / B2 / Wasabi)。行为与抽出这层之前逐字一致。 */
54
+ export declare function s3SnapshotStore(config: BackupConfig): SnapshotStore;
@@ -0,0 +1,76 @@
1
+ import { PutObjectCommand } from "@aws-sdk/client-s3";
2
+ import { Upload } from "@aws-sdk/lib-storage";
3
+ import { requireBucket } from "./config.js";
4
+ import { targetClient, resolveSnapshot, getObjectText, downloadToFile } from "./snapshots.js";
5
+ import { renderRecoveryDoc } from "./recovery-doc.js";
6
+ import { dumpPartSizeFor } from "./part-size.js";
7
+ /** 写入时的键都在 <prefix>/<projectName>/<快照名>/ 下;读取时 Storage 文件的 base 从 dump.key 反推。 */
8
+ function storageBaseOf(manifest) {
9
+ return manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
10
+ }
11
+ /** S3 兼容桶(S3 / R2 / B2 / Wasabi)。行为与抽出这层之前逐字一致。 */
12
+ export function s3SnapshotStore(config) {
13
+ const storage = requireBucket(config);
14
+ let client = null;
15
+ // 客户端推迟到第一次真正读写时才建:与抽层之前一致,配置错误先由前面的检查报出来
16
+ const s3 = () => (client ??= targetClient(config));
17
+ return {
18
+ async createSnapshot(name) {
19
+ const base = `${storage.prefix}/${config.projectName}/${name}`;
20
+ const location = (file) => `s3://${storage.bucket}/${base}/${file}`;
21
+ return {
22
+ dumpKey: `${base}/dump.pgcustom`,
23
+ storageFiles: { s3: s3(), bucket: storage.bucket, base },
24
+ describe: location,
25
+ uploadDump(body, relationBytes) {
26
+ const upload = new Upload({
27
+ client: s3(),
28
+ partSize: dumpPartSizeFor(relationBytes),
29
+ params: {
30
+ Bucket: storage.bucket,
31
+ Key: `${base}/dump.pgcustom`,
32
+ Body: body,
33
+ ContentType: "application/octet-stream",
34
+ },
35
+ });
36
+ return {
37
+ done: upload.done().then(() => { }),
38
+ abort: () => upload.abort(),
39
+ };
40
+ },
41
+ async writeRecoveryDoc(manifest) {
42
+ await s3().send(new PutObjectCommand({
43
+ Bucket: storage.bucket,
44
+ Key: `${base}/RECOVERY.md`,
45
+ Body: renderRecoveryDoc(manifest, {
46
+ snapshot: name,
47
+ bucket: storage.bucket,
48
+ endpoint: storage.endpoint,
49
+ region: storage.region,
50
+ prefix: storage.prefix,
51
+ projectName: config.projectName,
52
+ }),
53
+ ContentType: "text/markdown",
54
+ }));
55
+ },
56
+ async writeManifest(manifest) {
57
+ await s3().send(new PutObjectCommand({
58
+ Bucket: storage.bucket,
59
+ Key: `${base}/manifest.json`,
60
+ Body: JSON.stringify(manifest, null, 2),
61
+ ContentType: "application/json",
62
+ }));
63
+ },
64
+ };
65
+ },
66
+ async openSnapshot(name) {
67
+ const prefix = await resolveSnapshot(s3(), config, name);
68
+ return {
69
+ name: prefix.replace(/\/$/, "").split("/").pop(),
70
+ readManifest: () => getObjectText(s3(), storage.bucket, `${prefix}manifest.json`),
71
+ downloadDump: (manifest, dest) => downloadToFile(s3(), storage.bucket, manifest.dump.key, dest),
72
+ storageFiles: (manifest) => ({ s3: s3(), bucket: storage.bucket, base: storageBaseOf(manifest) }),
73
+ };
74
+ },
75
+ };
76
+ }
@@ -1,5 +1,5 @@
1
1
  import { S3Client } from "@aws-sdk/client-s3";
2
- import type { BackupConfig } from "./config.js";
2
+ import { type BackupConfig } from "./config.js";
3
3
  /** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
4
4
  export declare function targetClient(config: BackupConfig): S3Client;
5
5
  /** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
package/dist/snapshots.js CHANGED
@@ -4,28 +4,31 @@ import { pipeline } from "node:stream/promises";
4
4
  import { Writable } from "node:stream";
5
5
  import { log } from "./log.js";
6
6
  import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
7
+ import { requireBucket } from "./config.js";
7
8
  /** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
8
9
  export function targetClient(config) {
10
+ const storage = requireBucket(config);
9
11
  return new S3Client({
10
- region: config.storage.region,
11
- endpoint: config.storage.endpoint,
12
- forcePathStyle: config.storage.forcePathStyle,
12
+ region: storage.region,
13
+ endpoint: storage.endpoint,
14
+ forcePathStyle: storage.forcePathStyle,
13
15
  credentials: {
14
- accessKeyId: config.storage.accessKeyId,
15
- secretAccessKey: config.storage.secretAccessKey,
16
+ accessKeyId: storage.accessKeyId,
17
+ secretAccessKey: storage.secretAccessKey,
16
18
  },
17
19
  });
18
20
  }
19
21
  /** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
20
22
  export async function resolveSnapshot(s3, config, explicit) {
21
- const root = `${config.storage.prefix}/${config.projectName}/`;
23
+ const storage = requireBucket(config);
24
+ const root = `${storage.prefix}/${config.projectName}/`;
22
25
  if (explicit)
23
26
  return `${root}${explicit}/`;
24
27
  const prefixes = [];
25
28
  let token;
26
29
  do {
27
30
  const res = await s3.send(new ListObjectsV2Command({
28
- Bucket: config.storage.bucket,
31
+ Bucket: storage.bucket,
29
32
  Prefix: root,
30
33
  Delimiter: "/",
31
34
  ContinuationToken: token,
@@ -37,7 +40,7 @@ export async function resolveSnapshot(s3, config, explicit) {
37
40
  token = res.IsTruncated ? res.NextContinuationToken : undefined;
38
41
  } while (token);
39
42
  if (!prefixes.length) {
40
- throw new Error(`No snapshots found under s3://${config.storage.bucket}/${root}. Run a backup first.`);
43
+ throw new Error(`No snapshots found under s3://${storage.bucket}/${root}. Run a backup first.`);
41
44
  }
42
45
  prefixes.sort();
43
46
  return prefixes[prefixes.length - 1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.3.0",
3
+ "version": "2.5.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": {
@@ -17,9 +17,15 @@
17
17
  },
18
18
  "files": [
19
19
  "dist",
20
+ "plugin/skills",
20
21
  "README.md",
21
22
  "LICENSE"
22
23
  ],
24
+ "pi": {
25
+ "skills": [
26
+ "./plugin/skills"
27
+ ]
28
+ },
23
29
  "scripts": {
24
30
  "build": "tsc",
25
31
  "prepublishOnly": "npm run build && npm test",
@@ -43,7 +49,9 @@
43
49
  "s3",
44
50
  "cloudflare-r2",
45
51
  "backblaze-b2",
46
- "disaster-recovery"
52
+ "disaster-recovery",
53
+ "healthchecks",
54
+ "pi-package"
47
55
  ],
48
56
  "license": "MIT",
49
57
  "author": "BackupDrill <support@backupdrill.com> (https://backupdrill.com)",
package/plugin/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.
@@ -0,0 +1,79 @@
1
+ # BackupDrill plugin
2
+
3
+ Back up a Supabase project to a bucket you own and prove the backup restores, by asking your coding agent. The plugin is one skill that teaches the agent to use [backupdrill](https://github.com/backupdrill/cli), the open-source (MIT) CLI behind backupdrill.com:
4
+
5
+ - `estimate` measures the database and Storage files and projects the Supabase egress cost of backing them up.
6
+ - `backup` dumps the Postgres database with `pg_dump`, plus Storage files once you add their S3 keys, into your S3, R2 or B2 bucket, with a checksummed `manifest.json` and a `RECOVERY.md` in every snapshot.
7
+ - `drill` restores a snapshot into a throwaway Postgres in Docker, checks that the tables and their data came back, and checksums a sample of the Storage files.
8
+ - `restore` puts a snapshot into a new, empty Supabase project, after a dry run and only once you confirm the target.
9
+
10
+ The agent runs every command as `npx -y backupdrill@2.5.0`, so the CLI version stays fixed.
11
+
12
+ ## What you need
13
+
14
+ - A shell the agent can use, such as Claude Code. In a chat with no terminal, the agent can explain the commands but not run them.
15
+ - Node.js 20 or newer.
16
+ - `pg_dump` and `pg_restore` version 17, which handle Supabase's Postgres 15 to 17. On macOS: `brew install libpq@17` (plain `libpq` is now 18, whose dumps can contain SQL that Postgres 17 rejects).
17
+ - Docker, for `drill`.
18
+ - A bucket on AWS S3, Cloudflare R2 or Backblaze B2, with a key that can list, read and write objects in it.
19
+
20
+ ## Install
21
+
22
+ In Claude Code:
23
+
24
+ ```
25
+ /plugin marketplace add backupdrill/cli
26
+ /plugin install backupdrill@backupdrill
27
+ ```
28
+
29
+ Or from a terminal:
30
+
31
+ ```sh
32
+ claude plugin install backupdrill --marketplace backupdrill/cli
33
+ ```
34
+
35
+ In Pi, the same skill comes with the npm package:
36
+
37
+ ```sh
38
+ pi install npm:backupdrill
39
+ ```
40
+
41
+ ## Things to ask
42
+
43
+ - "Back up my Supabase project to my R2 bucket."
44
+ - "How much egress would a daily backup of this project add on the Pro plan?"
45
+ - "Run a restore drill on the latest backup and tell me whether it passed."
46
+ - "Set up a weekly backup in GitHub Actions."
47
+ - "Restore last night's snapshot into my new staging project. Dry run first."
48
+
49
+ ## Your credentials
50
+
51
+ The skill keeps your connection string and bucket keys in `.env.backupdrill` in your project root and adds that file to `.gitignore`. It creates the file with placeholders and asks you to fill it in your editor. The agent is told never to open or print the file, never to ask for keys in the chat, and to load the file only for the command that needs it.
52
+
53
+ ## What runs and where your data goes
54
+
55
+ Everything runs on your machine:
56
+
57
+ - `npx` downloads the `backupdrill` package from the npm registry the first time.
58
+ - The CLI connects to your Supabase project's database, to its Storage if you add Storage S3 keys, and to your bucket. `restore` also connects to the target project you name.
59
+ - `drill` starts a temporary Postgres container in Docker. The first time, Docker downloads the Postgres image from Docker Hub.
60
+ - Nothing goes to BackupDrill. There is no account, telemetry or license check.
61
+
62
+ What the agent reads while it works, such as command output with table names, row counts and file names, goes to your model provider like the rest of your conversation. The CLI itself never deletes objects in your bucket.
63
+
64
+ ## Troubleshooting
65
+
66
+ - `Missing required config`: the message names the missing setting. Fill that line in `.env.backupdrill`.
67
+ - A `pg_dump` version error: install the version 17 PostgreSQL client and set `BACKUPDRILL_PG_DUMP` to its `pg_dump`.
68
+ - Can't connect to the database: use the Session pooler string from the Connect button in the Supabase dashboard, not the Direct one. Resume the project if it's paused.
69
+ - `drill` can't start a container: start Docker.
70
+
71
+ The CLI's [README](https://github.com/backupdrill/cli#readme) covers every option, and [backupdrill.com/docs/cli](https://backupdrill.com/docs/cli) walks through the same steps.
72
+
73
+ ## Support
74
+
75
+ Open an issue at [github.com/backupdrill/cli/issues](https://github.com/backupdrill/cli/issues) or email support@backupdrill.com. Privacy policy: [backupdrill.com/privacy](https://backupdrill.com/privacy).
76
+
77
+ ## License
78
+
79
+ MIT