backupdrill 2.7.0 → 2.7.1

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.d.ts CHANGED
@@ -60,7 +60,9 @@ export declare function classifyPostDataErrors(stderr: string): PostDataResult;
60
60
  /** 兼容层:见 restore-engine.ts finalizePass(非零退出且零错误块 = 通用失败)。 */
61
61
  export declare function postDataResult(code: number | null, stderr: string): PostDataResult;
62
62
  /** 导出:真实恢复(restore.ts)复用同一套表级验证——两条路径对同一快照必须同一结论。 */
63
- export declare function verifyRestored(connString: string, manifest: Manifest): Promise<{
63
+ export declare function verifyRestored(connString: string, manifest: Manifest,
64
+ /** 恢复用的那份 dump:恢复出空表时回查它,见第 3 项检查 */
65
+ dumpPath?: string): Promise<{
64
66
  checks: DrillCheck[];
65
67
  tableCount: number;
66
68
  rowTotal: number;
package/dist/drill.js CHANGED
@@ -7,7 +7,7 @@ import { promisify } from "node:util";
7
7
  import { parseManifest } from "./manifest.js";
8
8
  import { hashObject } from "./snapshots.js";
9
9
  import { s3SnapshotStore } from "./snapshot-store.js";
10
- import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, installSandboxShim, } from "./restore-engine.js";
10
+ import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, installSandboxShim, tableDataInDump, } from "./restore-engine.js";
11
11
  import { connectPg } from "./supabase-ca.js";
12
12
  import { localPgRestoreMajor, restoreToolBreaksOnTarget, sandboxMajorFor } from "./pgbin.js";
13
13
  import { log } from "./log.js";
@@ -320,7 +320,9 @@ async function pgRestore(dumpPath, connString, dumpedSchemas, jobs) {
320
320
  }
321
321
  // ── 校验 ────────────────────────────────────────────────────────────
322
322
  /** 导出:真实恢复(restore.ts)复用同一套表级验证——两条路径对同一快照必须同一结论。 */
323
- export async function verifyRestored(connString, manifest) {
323
+ export async function verifyRestored(connString, manifest,
324
+ /** 恢复用的那份 dump:恢复出空表时回查它,见第 3 项检查 */
325
+ dumpPath) {
324
326
  // pgConnectOptions:真实 Supabase 目标(restore 复用本函数)需要打包根 CA;
325
327
  // 沙箱的 docker 连接串非 Supabase 主机,原样透传,行为不变
326
328
  const client = await connectPg(connString);
@@ -383,17 +385,28 @@ export async function verifyRestored(connString, manifest) {
383
385
  // 未填充的 matview 豁免:REFRESH 失败已由 post-data 检查裁决(用户对象失败
384
386
  // = 演练失败,Supabase 托管依赖 = 预期跳过),这里再报一次会把环境预期
385
387
  // 误标成备份损坏。
386
- const emptied = manifest.database.tables.filter((t) => t.estimatedRows > 0 &&
388
+ const restoredEmpty = manifest.database.tables.filter((t) => t.estimatedRows > 0 &&
387
389
  !unpopulatedMatviews.has(`${t.schema}.${t.name}`) &&
388
390
  (counts.get(`${t.schema}.${t.name}`) ?? 0) === 0);
391
+ // manifest 的行数是 pg_dump 之前另开连接取的 n_live_tup(统计估算),和 dump 不是同一个快照。
392
+ // 队列、会话、一次性密钥这类表,取统计时有行、dump 时已经删光,恢复出 0 行是对的
393
+ // (2026-10-24 生产演练:recovery_job_secrets)。所以拿 dump 本身作证:dump 里也是 0 行才放过,
394
+ // dump 有行或查不清,照样判失败。
395
+ const emptyInBackup = [];
396
+ const emptied = [];
397
+ for (const t of restoredEmpty) {
398
+ const inDump = dumpPath ? await tableDataInDump(dumpPath, t.schema, t.name) : "unknown";
399
+ (inDump === "empty" ? emptyInBackup : emptied).push(t);
400
+ }
401
+ const names = (tables) => tables.map((t) => `${t.schema}.${t.name}`).join(", ");
389
402
  checks.push({
390
403
  name: "populated tables came back",
391
404
  pass: emptied.length === 0,
392
- detail: emptied.length === 0
393
- ? "no populated table restored empty"
394
- : `restored empty despite manifest rows: ${emptied
395
- .map((t) => `${t.schema}.${t.name}`)
396
- .join(", ")}`,
405
+ detail: emptied.length > 0
406
+ ? `restored empty despite manifest rows: ${names(emptied)}`
407
+ : emptyInBackup.length > 0
408
+ ? `no populated table restored empty (already empty in the backup: ${names(emptyInBackup)})`
409
+ : "no populated table restored empty",
397
410
  });
398
411
  return { checks, tableCount: restoredTables.length, rowTotal };
399
412
  }
@@ -600,7 +613,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
600
613
  : "all post-data objects restored",
601
614
  });
602
615
  await opts.supervision?.observe({ phase: "verify" });
603
- const verify = await verifyRestored(pg.connString, manifest);
616
+ const verify = await verifyRestored(pg.connString, manifest, dumpPath);
604
617
  checks.push(...verify.checks);
605
618
  // 语义层钩子:只在结构层全绿时才跑——结构已失败时再跑应用检查,只会把
606
619
  // 一次失败报成两层失败,误导排查方向。没配置则不跑、报告里不出现该行。
@@ -37,6 +37,8 @@ export declare function parsePartitionAttachments(sql: string): Array<{
37
37
  * 带引号的列名可以跨行,所以按位置往下读,不按行切。
38
38
  */
39
39
  export declare function parseTableColumns(sql: string): Map<string, Map<string, string>>;
40
+ /** 一个 COPY 头最长读这么多字符;超过就当作读不了,免得坏归档把内存吃满。 */
41
+ export declare const MAX_COPY_HEADER = 100000;
40
42
  /**
41
43
  * 数据段的 COPY 头(dumpTableData_copy):"COPY <表> <列清单> FROM stdin;"。没有列的表列清单是空串,
42
44
  * 所以那一行中间是两个空格,返回空列表。
package/dist/dump-sql.js CHANGED
@@ -79,6 +79,8 @@ export function parseTableColumns(sql) {
79
79
  }
80
80
  return tables;
81
81
  }
82
+ /** 一个 COPY 头最长读这么多字符;超过就当作读不了,免得坏归档把内存吃满。 */
83
+ export const MAX_COPY_HEADER = 100_000;
82
84
  /**
83
85
  * 数据段的 COPY 头(dumpTableData_copy):"COPY <表> <列清单> FROM stdin;"。没有列的表列清单是空串,
84
86
  * 所以那一行中间是两个空格,返回空列表。
@@ -12,15 +12,13 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import { StringDecoder } from "node:string_decoder";
15
- import { decodeCopyField, formatQualified, parseCopyHeader, parseForeignKeys, parsePartitionAttachments, parseTableColumns, } from "./dump-sql.js";
15
+ import { MAX_COPY_HEADER, decodeCopyField, formatQualified, parseCopyHeader, parseForeignKeys, parsePartitionAttachments, parseTableColumns, } from "./dump-sql.js";
16
16
  import { libpqChildEnv, pgRestoreOutput, quoteIdent } from "./restore-engine.js";
17
17
  import { resolvePgRestoreBin } from "./pgbin.js";
18
18
  import { connectPg } from "./supabase-ca.js";
19
19
  /** 一批最多带多少个不同的键去目标库查。uuid 键一批约 200 KB 参数,目标端一次反连接。 */
20
20
  const KEY_BATCH = 5000;
21
21
  const keyOf = (name) => `${name.schema}\u0000${name.name}`;
22
- /** 一个 COPY 头最长读这么多字符;超过就当作读不了,免得坏归档把内存吃满。 */
23
- const MAX_COPY_HEADER = 100_000;
24
22
  /**
25
23
  * 读 dump,找出指向 dumpedSchemas 之外的外键,逐条拿目标库对行。只读:目标库上只跑 select。
26
24
  * pg_restore 起不来、归档读不了时抛错,由调用方当成 blocker。单条外键查不成(目标端报错、
@@ -20,7 +20,8 @@ export declare function stopOnInterrupt(child: ChildProcess, opts?: {
20
20
  }): () => void;
21
21
  /**
22
22
  * CLI 专用:在 what(比如 "drill")运行期间接管 SIGINT/SIGTERM。第一次收到信号时跑完登记的清理,
23
- * 以 130(SIGINT)/ 143(SIGTERM)退出;清理途中再按一次 Ctrl+C 就不等了,直接退出。
23
+ * 以 130(SIGINT)/ 143(SIGTERM)退出;清理途中再按一次 Ctrl+C 就不等了,直接退出
24
+ * (1 秒内重复到达的同一个信号不算,见 REPEATED_SIGNAL_GRACE_MS)。
24
25
  * 返回卸载函数。
25
26
  */
26
27
  export declare function exitCleanlyOnSignals(what: string): () => void;
package/dist/interrupt.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { log } from "./log.js";
2
2
  const pending = new Set();
3
3
  let interruptedBy = null;
4
+ let interruptedAt = 0;
5
+ // npx / npm exec 会把自己收到的 SIGINT/SIGTERM 再转发给 CLI(@npmcli/run-script 的 signal-manager)。
6
+ // 终端里的 Ctrl+C 发给整个前台进程组,所以按一次,CLI 几乎同时收到两次:不挡的话第二次会被当成
7
+ // "再按一次跳过清理",沙箱照样留下。离第一次不到这么久的同一个信号算同一次。
8
+ const REPEATED_SIGNAL_GRACE_MS = 1000;
4
9
  /** 收到过信号没有。主流程据此闭嘴:信号处理正在收尾并会自己退出,别再打印报告、发 ping、写记录。 */
5
10
  export function interrupted() {
6
11
  return interruptedBy !== null;
@@ -108,17 +113,21 @@ async function runTasks(tasks) {
108
113
  }
109
114
  /**
110
115
  * CLI 专用:在 what(比如 "drill")运行期间接管 SIGINT/SIGTERM。第一次收到信号时跑完登记的清理,
111
- * 以 130(SIGINT)/ 143(SIGTERM)退出;清理途中再按一次 Ctrl+C 就不等了,直接退出。
116
+ * 以 130(SIGINT)/ 143(SIGTERM)退出;清理途中再按一次 Ctrl+C 就不等了,直接退出
117
+ * (1 秒内重复到达的同一个信号不算,见 REPEATED_SIGNAL_GRACE_MS)。
112
118
  * 返回卸载函数。
113
119
  */
114
120
  export function exitCleanlyOnSignals(what) {
115
121
  const handler = (signal) => {
116
122
  const code = signal === "SIGINT" ? 130 : 143;
117
123
  if (interruptedBy) {
124
+ if (signal === interruptedBy && performance.now() - interruptedAt < REPEATED_SIGNAL_GRACE_MS)
125
+ return;
118
126
  log.error(`${signal} again — exiting without finishing the cleanup`);
119
127
  process.exit(code);
120
128
  }
121
129
  interruptedBy = signal;
130
+ interruptedAt = performance.now();
122
131
  log.warn(`${signal} received — stopping the ${what} and cleaning up` +
123
132
  (signal === "SIGINT" ? " (press Ctrl+C again to skip)…" : "…"));
124
133
  void runCleanups().finally(() => {
@@ -233,3 +233,20 @@ export declare function restoreDatabaseArtifact(opts: {
233
233
  /** pg_restore 并行路数;不传或 ≤1 = 单连接两遍。 */
234
234
  jobs?: number;
235
235
  }): Promise<EngineResult>;
236
+ /**
237
+ * 逐块读 `pg_restore --data-only -f -` 的输出,判断某张表的 COPY 段有没有数据行。
238
+ * 返回一个喂数据的函数:能下结论时返回 "rows" / "empty",确定查不清返回 "unknown",
239
+ * 还要更多输出返回 null。纯函数,方便单测。
240
+ *
241
+ * 只按 \n 切行,不用 readline:readline 把 \r 也当换行(引号名里可以有 \r),还会把一整行数据
242
+ * 读进内存(大 bytea 行能有几百 MB)。COPY 头之后只看紧跟着的几个字符。
243
+ * 带引号的名字里还可以有换行(列名里甚至能写 FROM stdin; 和 \.),COPY 头就会跨行:攒到引号配平、
244
+ * 以 FROM stdin; 结尾为止,再按 COPY 头的语法解析、核对是不是这张表(同 scanDumpData)。
245
+ */
246
+ export declare function copyDataProbe(schema: string, table: string): (chunk: string) => "rows" | "empty" | "unknown" | null;
247
+ /**
248
+ * 这张表在 dump 里有没有数据行(判断见 copyDataProbe)。拿到答案就停掉 pg_restore,不读完整份 dump。
249
+ * 没有 COPY 段(matview 的数据是 REFRESH)、COPY 头读不懂或不是这张表、pg_restore 起不来或出错,
250
+ * 都返回 "unknown",由调用方按"查不清"处理。
251
+ */
252
+ export declare function tableDataInDump(dumpPath: string, schema: string, table: string): Promise<"rows" | "empty" | "unknown">;
@@ -16,7 +16,7 @@ import { spawn } from "node:child_process";
16
16
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
17
17
  import { tmpdir } from "node:os";
18
18
  import { join } from "node:path";
19
- import { parseCreateSchemas } from "./dump-sql.js";
19
+ import { MAX_COPY_HEADER, parseCopyHeader, parseCreateSchemas } from "./dump-sql.js";
20
20
  import { resolvePgRestoreBin } from "./pgbin.js";
21
21
  import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
22
22
  import { interrupted, onInterrupt, stopOnInterrupt } from "./interrupt.js";
@@ -822,3 +822,85 @@ export async function restoreDatabaseArtifact(opts) {
822
822
  forgetListDir();
823
823
  }
824
824
  }
825
+ /**
826
+ * 逐块读 `pg_restore --data-only -f -` 的输出,判断某张表的 COPY 段有没有数据行。
827
+ * 返回一个喂数据的函数:能下结论时返回 "rows" / "empty",确定查不清返回 "unknown",
828
+ * 还要更多输出返回 null。纯函数,方便单测。
829
+ *
830
+ * 只按 \n 切行,不用 readline:readline 把 \r 也当换行(引号名里可以有 \r),还会把一整行数据
831
+ * 读进内存(大 bytea 行能有几百 MB)。COPY 头之后只看紧跟着的几个字符。
832
+ * 带引号的名字里还可以有换行(列名里甚至能写 FROM stdin; 和 \.),COPY 头就会跨行:攒到引号配平、
833
+ * 以 FROM stdin; 结尾为止,再按 COPY 头的语法解析、核对是不是这张表(同 scanDumpData)。
834
+ */
835
+ export function copyDataProbe(schema, table) {
836
+ let buffer = "";
837
+ let header = null;
838
+ let inData = false;
839
+ return (chunk) => {
840
+ buffer += chunk;
841
+ for (;;) {
842
+ if (inData) {
843
+ // 结束符一行只有 \.;数据里以 \. 开头的值会被转义成 \\.,不会混淆
844
+ if (buffer.startsWith("\\.\n"))
845
+ return "empty";
846
+ if (!"\\.\n".startsWith(buffer))
847
+ return "rows";
848
+ return null; // 字符还不够判断
849
+ }
850
+ const newline = buffer.indexOf("\n");
851
+ if (newline < 0)
852
+ return buffer.length + (header?.length ?? 0) > MAX_COPY_HEADER ? "unknown" : null;
853
+ const line = buffer.slice(0, newline);
854
+ buffer = buffer.slice(newline + 1);
855
+ if (header === null && !line.startsWith("COPY "))
856
+ continue;
857
+ header = header === null ? line : `${header}\n${line}`;
858
+ const balanced = (header.split('"').length - 1) % 2 === 0;
859
+ if (!(balanced && header.endsWith(" FROM stdin;"))) {
860
+ if (header.length > MAX_COPY_HEADER)
861
+ return "unknown";
862
+ continue;
863
+ }
864
+ const parsed = parseCopyHeader(header);
865
+ header = null;
866
+ if (!parsed || parsed.table.schema !== schema || parsed.table.name !== table)
867
+ return "unknown";
868
+ inData = true;
869
+ }
870
+ };
871
+ }
872
+ /**
873
+ * 这张表在 dump 里有没有数据行(判断见 copyDataProbe)。拿到答案就停掉 pg_restore,不读完整份 dump。
874
+ * 没有 COPY 段(matview 的数据是 REFRESH)、COPY 头读不懂或不是这张表、pg_restore 起不来或出错,
875
+ * 都返回 "unknown",由调用方按"查不清"处理。
876
+ */
877
+ export function tableDataInDump(dumpPath, schema, table) {
878
+ if (interrupted())
879
+ return Promise.resolve("unknown");
880
+ return new Promise((resolve) => {
881
+ // -n/-t 在 pg_restore 里是精确名,不是 pg_dump 那种模式;不连库,只往 stdout 出 SQL。
882
+ // 和 pgRestoreOutput 一样钉 C locale
883
+ const proc = spawn(resolvePgRestoreBin(), ["--data-only", `--schema=${schema}`, `--table=${table}`, "--file=-", dumpPath], { stdio: ["ignore", "pipe", "ignore"], env: libpqChildEnv() });
884
+ const forget = stopOnInterrupt(proc);
885
+ const probe = copyDataProbe(schema, table);
886
+ let answer = null;
887
+ // 按流解码:多字节字符可能被拆在两块里
888
+ proc.stdout.setEncoding("utf8");
889
+ proc.stdout.on("data", (chunk) => {
890
+ if (answer !== null)
891
+ return;
892
+ answer = probe(chunk);
893
+ if (answer !== null)
894
+ proc.kill("SIGTERM");
895
+ });
896
+ proc.on("error", () => {
897
+ forget();
898
+ resolve("unknown");
899
+ });
900
+ // 拿到答案后是我们自己停的,退出码不看;没拿到答案就退出(出错或没有 COPY 段)= 查不清
901
+ proc.on("close", () => {
902
+ forget();
903
+ resolve(answer ?? "unknown");
904
+ });
905
+ });
906
+ }
package/dist/restore.js CHANGED
@@ -697,7 +697,7 @@ export async function runRestore(config, opts) {
697
697
  result.restoredToDatabase = true;
698
698
  // 与演练同一套表级验证:两条路径对同一快照必须同一结论(PRD §5.2 验收)
699
699
  log.step("Verifying restored tables…");
700
- const verified = await verifyRestored(targetUrl, manifest);
700
+ const verified = await verifyRestored(targetUrl, manifest, dumpPath);
701
701
  result.databaseChecks = verified.checks;
702
702
  result.databaseVerified = verified.checks.every((c) => c.pass);
703
703
  for (const check of verified.checks) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
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": {
package/plugin/README.md CHANGED
@@ -7,7 +7,7 @@ Back up a Supabase project to a bucket you own and prove the backup restores, by
7
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
8
  - `restore` puts a snapshot into a new, empty Supabase project, after a dry run and only once you confirm the target.
9
9
 
10
- The agent runs every command as `npx -y backupdrill@2.5.0`, so the CLI version stays fixed.
10
+ The agent runs every command as `npx -y backupdrill@2.7.0`, so the CLI version stays fixed.
11
11
 
12
12
  ## What you need
13
13
 
@@ -7,7 +7,7 @@ description: Use when the user wants to back up a Supabase project to their own
7
7
 
8
8
  backupdrill is an open-source (MIT) command-line tool. `backup` dumps the project's Postgres database with `pg_dump`, copies Storage files too once their S3 keys are set, and writes everything to a bucket the user owns, with a checksummed `manifest.json` and a `RECOVERY.md` in every snapshot. `drill` restores a snapshot into a throwaway Postgres in Docker and checks that it came back. The CLI runs on the user's machine. It talks to their Supabase project, their bucket and the local Docker daemon, and sends nothing to BackupDrill.
9
9
 
10
- Always run it as `npx -y backupdrill@2.5.0 <command>`, so every run uses the same version.
10
+ Always run it as `npx -y backupdrill@2.7.0 <command>`, so every run uses the same version.
11
11
 
12
12
  You need a shell on the user's machine for this. If you don't have one, walk the user through the same commands instead of running them.
13
13
 
@@ -69,7 +69,7 @@ Every backup reads the whole database, and every configured Storage file, out of
69
69
  ```sh
70
70
  ( set +x; unset DATABASE_URL $(env | sed -n 's/^\(BACKUPDRILL_[A-Za-z0-9_]*\)=.*/\1/p')
71
71
  set -a; . ./.env.backupdrill 2>/dev/null || { echo ".env.backupdrill could not be loaded" >&2; exit 1; }; set +a
72
- npx -y backupdrill@2.5.0 estimate --plan pro )
72
+ npx -y backupdrill@2.7.0 estimate --plan pro )
73
73
  ```
74
74
 
75
75
  Use `--plan free`, `pro` or `team` to match their Supabase plan, and read the projection back before suggesting a daily schedule. On a large database, suggest weekly.
@@ -80,7 +80,7 @@ Use `--plan free`, `pro` or `team` to match their Supabase plan, and read the pr
80
80
  report="$(mktemp "${TMPDIR:-/tmp}/backupdrill-backup.XXXXXX")" || exit 1; echo "stdout → $report"
81
81
  ( set +x; unset DATABASE_URL $(env | sed -n 's/^\(BACKUPDRILL_[A-Za-z0-9_]*\)=.*/\1/p')
82
82
  set -a; . ./.env.backupdrill 2>/dev/null || { echo ".env.backupdrill could not be loaded" >&2; exit 1; }; set +a
83
- npx -y backupdrill@2.5.0 backup ) > "$report"
83
+ npx -y backupdrill@2.7.0 backup ) > "$report"
84
84
  ```
85
85
 
86
86
  - The progress lines on stderr show where everything went (`Dumping → s3://…`, `Manifest written → s3://…`) and end with `Backup complete — N tables + M Storage files`. stdout goes to the file named on the first line, a new one each run that only the user can read: one line of JSON with the whole manifest, which lists every table and Storage file, so read only the fields you need from it.
@@ -93,7 +93,7 @@ report="$(mktemp "${TMPDIR:-/tmp}/backupdrill-backup.XXXXXX")" || exit 1; echo "
93
93
  ```sh
94
94
  ( set +x; unset DATABASE_URL $(env | sed -n 's/^\(BACKUPDRILL_[A-Za-z0-9_]*\)=.*/\1/p')
95
95
  set -a; . ./.env.backupdrill 2>/dev/null || { echo ".env.backupdrill could not be loaded" >&2; exit 1; }; set +a
96
- npx -y backupdrill@2.5.0 drill )
96
+ npx -y backupdrill@2.7.0 drill )
97
97
  ```
98
98
 
99
99
  - It reads the latest snapshot from the bucket, restores it into a throwaway Postgres container, runs its checks and removes the container. It doesn't connect to the Supabase project. The first run pulls a Postgres image from Docker Hub.
@@ -112,7 +112,7 @@ A restore writes data, so go step by step and let the user drive.
112
112
  ```sh
113
113
  ( set +x; unset DATABASE_URL $(env | sed -n 's/^\(BACKUPDRILL_[A-Za-z0-9_]*\)=.*/\1/p')
114
114
  set -a; . ./.env.backupdrill 2>/dev/null || { echo ".env.backupdrill could not be loaded" >&2; exit 1; }; set +a
115
- npx -y backupdrill@2.5.0 restore --database --target-supabase-url https://<target-ref>.supabase.co --dry-run )
115
+ npx -y backupdrill@2.7.0 restore --database --target-supabase-url https://<target-ref>.supabase.co --dry-run )
116
116
  ```
117
117
 
118
118
  Leave out `--target-supabase-url` when the snapshot is database only. To write Storage files to a local folder instead of the new project, use `--storage-dir ./recovered`.
@@ -95,7 +95,7 @@ jobs:
95
95
 
96
96
  - name: Run backupdrill
97
97
  # Pinned so every run uses the same CLI. Raise it on purpose when you upgrade.
98
- run: npx -y backupdrill@2.5.0 backup
98
+ run: npx -y backupdrill@2.7.0 backup
99
99
  env:
100
100
  # --- required ---
101
101
  BACKUPDRILL_DATABASE_URL: ${{ secrets.BACKUPDRILL_DATABASE_URL }}