backupdrill 2.6.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.
@@ -66,7 +66,16 @@ ${extensions.length
66
66
  Supabase **Auth users/sessions, secret values, Edge Function code and most
67
67
  platform settings are NOT in this snapshot**. After restoring, reconfigure them
68
68
  by hand. This is a database + Storage snapshot, not a full project image.
69
-
69
+ ${db.schemas.includes("auth")
70
+ ? ""
71
+ : `
72
+ If a table in this snapshot has a foreign key into \`auth.users\` (a \`profiles.id\`
73
+ that references it, for example), copy the Auth users into the new project
74
+ **before** restoring, or that foreign key can't be created and the restore fails.
75
+ \`backupdrill restore --dry-run\` checks those keys against the new project and reports
76
+ the rows that have no user, plus any key it couldn't check.
77
+ Triggers on \`auth.users\` aren't in this snapshot either; recreate them afterwards.
78
+ `}
70
79
  ## Restore the database (into an EMPTY project)
71
80
 
72
81
  1. Create a **fresh** Supabase project (never restore onto the source project),
@@ -94,7 +103,8 @@ unset PGPASSWORD
94
103
  \`\`\`
95
104
 
96
105
  One error is expected and harmless: \`schema "public" already exists\` (the
97
- target always has it). Do **not** use \`--clean\` against a Supabase project —
106
+ target always has it). A foreign key error mentioning \`auth.users\` means the
107
+ Auth users were not copied in first (see "Not covered" above). Do **not** use \`--clean\` against a Supabase project —
98
108
  dropping and recreating \`public\` silently wipes Supabase's default grants for
99
109
  \`anon\`/\`authenticated\`, and the restored API answers 401.
100
110
 
@@ -113,9 +123,12 @@ ${cliRestoreCommand}
113
123
 
114
124
  Add \`--dry-run\` first to preview every check without writing anything. (Skip the
115
125
  \`read\` lines to have the CLI prompt you itself; \`unset\` both variables afterwards.)
116
- The CLI verifies the archive checksum, refuses non-empty targets, installs the
117
- extensions, restores through the same engine the weekly drills use, verifies
118
- table counts and row presence afterwards, then rebuilds buckets, uploads the
126
+ The CLI verifies the archive checksum, refuses non-empty targets, checks that rows
127
+ referenced outside the snapshot (such as \`auth.users\`) are on the target, installs
128
+ the extensions, restores the database in a single transaction through the same
129
+ engine the weekly drills use (an SQL error rolls it back and the extensions stay
130
+ installed; if the connection drops while it commits, check the target before
131
+ retrying), verifies table counts and row presence afterwards, then rebuilds buckets, uploads the
119
132
  Storage files back and reconciles every one against the target.
120
133
 
121
134
  More: https://backupdrill.com/docs/restore
@@ -1,3 +1,4 @@
1
+ import type { Client } from "pg";
1
2
  import type { ExtensionInfo } from "./manifest.js";
2
3
  export type RestoreTargetKind = "sandbox" | "supabase";
3
4
  /** 主机名规范化:先按驱动语义百分号解码(pg-connection-string / libpq 都会解码主机名,
@@ -118,7 +119,9 @@ export declare function quoteIdent(name: string): string;
118
119
  * 装不上的扩展如实收集返回,由调用方按目标类型裁决:沙箱记录后继续(托管扩展
119
120
  * 本就装不上,public 转储通常不引用),真实目标应当阻断(用户能在 dashboard 开启)。
120
121
  */
121
- export declare function installExtensions(connString: string, extensions: ExtensionInfo[]): Promise<string[]>;
122
+ export declare function installExtensions(connString: string, extensions: ExtensionInfo[],
123
+ /** 调用方开着的事务:装在它里面(每个扩展一个保存点,装不上的不连累别的),提交与否由调用方定 */
124
+ inTransaction?: Client): Promise<string[]>;
122
125
  /**
123
126
  * 沙箱专用:预置 Supabase 的最小运行面,让**引用**它的用户对象能建出来被验证,而不是
124
127
  * 被当作"托管对象跳过"。只装桩,不装数据 —— 演练验证的是用户自己的 schema。
@@ -151,10 +154,9 @@ export declare function installSandboxShim(connString: string, opts: {
151
154
  authFunctions: boolean;
152
155
  }): Promise<void>;
153
156
  /**
154
- * 两遍恢复(真实首演炸出的设计,现为 drill 与真实恢复共用):
157
+ * 两遍恢复(真实首演炸出的设计;2.7.0 起只用于演练沙箱,真实目标见 restoreInOneTransaction):
155
158
  * 1. pre-data+data —— 表结构 + 数据。除 schema 冲突(预期)外任何错误都是硬失败。
156
- * 2. post-data —— 用户自己的索引/约束/触发器必须恢复成功;allowlist 按目标类型:
157
- * 沙箱豁免 Supabase 托管对象引用,真实 Supabase 目标零豁免。
159
+ * 2. post-data —— 用户自己的索引/约束/触发器必须恢复成功;沙箱豁免 Supabase 托管对象引用。
158
160
  */
159
161
  /**
160
162
  * 凭据不进 argv(D6 同源要求):连接串里的密码对 `ps` 全程可见——拆出来经
@@ -190,6 +192,12 @@ export declare function splitPostDataList(listing: string): {
190
192
  indexBuilds: string[];
191
193
  rest: string[];
192
194
  };
195
+ /** 不连库的 pg_restore(--list、-f - 出 SQL),整段收集 stdout。只用于目录和 DDL 这类小输出。 */
196
+ export declare function pgRestoreOutput(bin: string, args: string[]): Promise<{
197
+ code: number | null;
198
+ stdout: string;
199
+ stderr: string;
200
+ }>;
193
201
  /**
194
202
  * 并行建索引那遍(带 --verbose)里出过错的条目 dumpId。pg_restore 对每个出错条目先打一行
195
203
  * `from TOC entry <dumpId>; …`(warn_or_exit_horribly,只在 verbose 下输出)。这行正文是一次
@@ -197,6 +205,12 @@ export declare function splitPostDataList(listing: string): {
197
205
  * 所以按子串找、不按行首找。
198
206
  */
199
207
  export declare function failedTocEntries(stderr: string): Set<string>;
208
+ /**
209
+ * 单事务恢复只会有一条错误(--exit-on-error)。给人看的形态:主消息 + DETAIL/HINT 全文,再带上出错
210
+ * 语句的开头,方便认出是哪个对象。不再截成 200 字符——那样会把 "is not present in table" 这半句
211
+ * 切掉,用户看不出缺的是什么(2026-10-22 实验原样截在 "is no")。
212
+ */
213
+ export declare function firstRestoreError(stderr: string): string;
200
214
  /**
201
215
  * 两遍恢复(形状见 credentialSafeDbArgs 上方那段"两遍恢复"注释),`jobs` > 1 时按下面的方式并行(2026-10-17 实测:2 核机器上同一份
202
216
  * dump,原生 -j2 比 -j1 在 5 GB 快 14%、10 GB 快 12%):
@@ -207,8 +221,8 @@ export declare function failedTocEntries(stderr: string): Set<string>;
207
221
  * 2. post-data 拆成两份清单(splitPostDataList):建索引那份并行,退出码 0 即通过,报过错
208
222
  * 的条目单连接重跑定结论;其余单连接、照旧按目标类型分类。
209
223
  * 两遍下来,同一份备份不管几路并行,结论都和 jobs=1 一样。
210
- * 真实 Supabase 目标(restore.ts)不传 jobs:每一路都是一条 pooler 连接,小规格项目的连接数
211
- * 和算力都紧,并行没测过,保持原来的单连接两遍。
224
+ * 真实 Supabase 目标(restore.ts)不走这里:单事务恢复(restoreInOneTransaction)不能并行,
225
+ * 而且每一路都是一条 pooler 连接,小规格项目的连接数和算力都紧。
212
226
  */
213
227
  export declare function restoreDatabaseArtifact(opts: {
214
228
  dumpPath: string;
@@ -219,3 +233,20 @@ export declare function restoreDatabaseArtifact(opts: {
219
233
  /** pg_restore 并行路数;不传或 ≤1 = 单连接两遍。 */
220
234
  jobs?: number;
221
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">;
@@ -8,12 +8,18 @@
8
8
  // 不会带回来),恢复出的 API 直接 401;
9
9
  // 3. dump 自带 CREATE SCHEMA,而目标(新容器或 Supabase)恒有同名 schema →
10
10
  // pre-data 阶段的 schema "already exists" 是两类目标共同的预期冲突。
11
+ //
12
+ // 真实目标(2.7.0 起)整份恢复放在一个事务里:失败就整体回滚,目标还是空的,修好原因可以在同一个
13
+ // 项目上直接重跑。此前两遍恢复各自自动提交,post-data 里一条外键失败,表和数据已经落库,重跑被
14
+ // 空目标门挡住(2026-10-22 实验:profiles → auth.users)。沙箱用完即毁,不需要事务,保留两遍 + 并行。
11
15
  import { spawn } from "node:child_process";
12
16
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
13
17
  import { tmpdir } from "node:os";
14
18
  import { join } from "node:path";
19
+ import { MAX_COPY_HEADER, parseCopyHeader, parseCreateSchemas } from "./dump-sql.js";
15
20
  import { resolvePgRestoreBin } from "./pgbin.js";
16
21
  import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
22
+ import { interrupted, onInterrupt, stopOnInterrupt } from "./interrupt.js";
17
23
  /** 主机名规范化:先按驱动语义百分号解码(pg-connection-string / libpq 都会解码主机名,
18
24
  * `supabase%2Ecom` 在驱动眼里就是 supabase.com),再剥掉合法的 DNS 根点(db.x.supabase.co. ≡
19
25
  * db.x.supabase.co)。身份比较不规范化 = 一个尾点或一个 %2E 就能绕过同源阻断(交叉审查)。 */
@@ -168,13 +174,19 @@ export function urlSslModeOf(connString) {
168
174
  }
169
175
  }
170
176
  export function spawnPgRestore(bin, args, extraEnv = {}, opts = { supabaseHost: false }) {
177
+ // 收到信号之后不再起新的一遍(两遍恢复、并行建索引后的重跑都是一个接一个起的)
178
+ if (interrupted())
179
+ return Promise.reject(new Error("interrupted; not starting pg_restore"));
171
180
  return new Promise((resolve, reject) => {
172
181
  const proc = spawn(bin, args, {
173
182
  stdio: ["ignore", "ignore", "pipe"],
174
183
  env: libpqChildEnv(extraEnv, process.env, opts),
175
184
  });
185
+ // `kill <pid>` 只发给 node 时 pg_restore 收不到;中断时由我们停掉它
186
+ stopOnInterrupt(proc);
176
187
  let stderr = "";
177
- proc.stderr.on("data", (d) => (stderr += d.toString()));
188
+ proc.stderr.setEncoding("utf8");
189
+ proc.stderr.on("data", (d) => (stderr += d));
178
190
  // 不透传原始 spawn Error:node 会把 spawnargs(含 --dbname 连接串=凭据)挂在
179
191
  // 异常对象上,顺着 reject 流进日志/Sentry。只保留启动失败的事实与 bin 名。
180
192
  proc.on("error", (error) => reject(new Error(`${bin} failed to start: ${error.code ?? error.message}`)));
@@ -326,27 +338,36 @@ export function quoteIdent(name) {
326
338
  * 装不上的扩展如实收集返回,由调用方按目标类型裁决:沙箱记录后继续(托管扩展
327
339
  * 本就装不上,public 转储通常不引用),真实目标应当阻断(用户能在 dashboard 开启)。
328
340
  */
329
- export async function installExtensions(connString, extensions) {
341
+ export async function installExtensions(connString, extensions,
342
+ /** 调用方开着的事务:装在它里面(每个扩展一个保存点,装不上的不连累别的),提交与否由调用方定 */
343
+ inTransaction) {
330
344
  if (!extensions.length)
331
345
  return [];
332
346
  // pgConnectOptions:Supabase 主机自动带打包根 CA(node-pg 把 require 按 verify-full
333
347
  // 处理,裸 Client 对 pooler 必报 self-signed chain);沙箱等非 Supabase 目标原样透传
334
- const client = await connectPg(connString);
348
+ const client = inTransaction ?? (await connectPg(connString));
335
349
  const unavailable = [];
336
350
  try {
337
351
  for (const ext of extensions) {
352
+ if (inTransaction)
353
+ await client.query("savepoint backupdrill_extension");
338
354
  try {
339
355
  await client.query(`create schema if not exists ${quoteIdent(ext.schema)}`);
340
356
  // CASCADE 自动带上依赖扩展,免受 manifest 里的安装顺序摆布
341
357
  await client.query(`create extension if not exists ${quoteIdent(ext.name)} schema ${quoteIdent(ext.schema)} cascade`);
358
+ if (inTransaction)
359
+ await client.query("release savepoint backupdrill_extension");
342
360
  }
343
361
  catch {
362
+ if (inTransaction)
363
+ await client.query("rollback to savepoint backupdrill_extension");
344
364
  unavailable.push(ext.name);
345
365
  }
346
366
  }
347
367
  }
348
368
  finally {
349
- await client.end();
369
+ if (!inTransaction)
370
+ await client.end();
350
371
  }
351
372
  return unavailable;
352
373
  }
@@ -406,10 +427,9 @@ export async function installSandboxShim(connString, opts) {
406
427
  }
407
428
  }
408
429
  /**
409
- * 两遍恢复(真实首演炸出的设计,现为 drill 与真实恢复共用):
430
+ * 两遍恢复(真实首演炸出的设计;2.7.0 起只用于演练沙箱,真实目标见 restoreInOneTransaction):
410
431
  * 1. pre-data+data —— 表结构 + 数据。除 schema 冲突(预期)外任何错误都是硬失败。
411
- * 2. post-data —— 用户自己的索引/约束/触发器必须恢复成功;allowlist 按目标类型:
412
- * 沙箱豁免 Supabase 托管对象引用,真实 Supabase 目标零豁免。
432
+ * 2. post-data —— 用户自己的索引/约束/触发器必须恢复成功;沙箱豁免 Supabase 托管对象引用。
413
433
  */
414
434
  /**
415
435
  * 凭据不进 argv(D6 同源要求):连接串里的密码对 `ps` 全程可见——拆出来经
@@ -587,17 +607,25 @@ export function splitPostDataList(listing) {
587
607
  }
588
608
  return { indexBuilds, rest };
589
609
  }
590
- function listPostData(bin, dumpPath) {
610
+ /** 不连库的 pg_restore(--list、-f - 出 SQL),整段收集 stdout。只用于目录和 DDL 这类小输出。 */
611
+ export function pgRestoreOutput(bin, args) {
612
+ // 和 spawnPgRestore 一样:收到信号之后不再起,起了的中断时停掉(并行演练里读目录的那一步也是它)
613
+ if (interrupted())
614
+ return Promise.reject(new Error("interrupted; not starting pg_restore"));
591
615
  return new Promise((resolve, reject) => {
592
616
  // 只读归档、不连库,但同样钉 C locale,和恢复子进程一致
593
- const proc = spawn(bin, ["--list", "--section=post-data", dumpPath], {
617
+ const proc = spawn(bin, args, {
594
618
  stdio: ["ignore", "pipe", "pipe"],
595
619
  env: libpqChildEnv(),
596
620
  });
621
+ stopOnInterrupt(proc);
597
622
  let stdout = "";
598
623
  let stderr = "";
599
- proc.stdout.on("data", (d) => (stdout += d.toString()));
600
- proc.stderr.on("data", (d) => (stderr += d.toString()));
624
+ // 按流解码:一个多字节字符可能被拆在两块里,逐块 toString 会把它解坏(schema 名里的中文就会对不上)
625
+ proc.stdout.setEncoding("utf8");
626
+ proc.stderr.setEncoding("utf8");
627
+ proc.stdout.on("data", (d) => (stdout += d));
628
+ proc.stderr.on("data", (d) => (stderr += d));
601
629
  proc.on("error", (error) => reject(new Error(`${bin} failed to start: ${error.code ?? error.message}`)));
602
630
  proc.on("close", (code) => resolve({ code, stdout, stderr }));
603
631
  });
@@ -617,6 +645,88 @@ function mergePasses(passes) {
617
645
  failures: passes.flatMap((pass) => pass.failures),
618
646
  };
619
647
  }
648
+ /**
649
+ * 真实目标:整份归档一次 `pg_restore --single-transaction`(隐含 --exit-on-error),任何一条语句失败,
650
+ * 连接断开,服务端回滚,目标上不留任何东西。Supabase 官方的整库恢复指引(psql --single-transaction
651
+ * + ON_ERROR_STOP)也是这个做法。
652
+ *
653
+ * 唯一的障碍是文件头注 3 的预期冲突:dump 自带 CREATE SCHEMA public,一报错整个事务就没了。所以先用
654
+ * 目录(-L)把**目标上已经存在**的 schema 条目拿掉,其余照原顺序原样恢复;目标上没有的 schema
655
+ * (比如 BACKUPDRILL_SCHEMAS 里的自定义 schema)照常 CREATE。拿掉之后就没有任何可以容忍的错误了,
656
+ * 和两遍恢复时真实目标"除 schema 冲突外零豁免"的口径一致。
657
+ * 代价是锁:每张表连同索引、序列、TOAST 各占一个锁槽,直到提交。max_connections 60 的目标上实测
658
+ * 900 张表能过、1200 张表报 out of shared memory(restore.ts restoreFailureAdvice 给出调参办法);
659
+ * 撞上了也是整体回滚,不会留半截。
660
+ */
661
+ async function restoreInOneTransaction(bin, dumpPath, connString, common, env, childOpts) {
662
+ const fail = (failure) => ({ expectedSkips: 0, failures: [failure] });
663
+ const listing = await pgRestoreOutput(bin, ["--list", dumpPath]);
664
+ if (listing.code !== 0) {
665
+ return fail(`pg_restore --list exited with code ${listing.code}: ${listing.stderr.trim().replace(/\s+/g, " ").slice(0, 200)}`);
666
+ }
667
+ const entries = listing.stdout.split("\n").filter((line) => /^\d+;/.test(line));
668
+ const listDir = await mkdtemp(join(tmpdir(), "backupdrill-restore-"));
669
+ try {
670
+ // 目录行里的名字和属主之间只隔一个空格,名字本身又可以带空格,拆不准。让 pg_restore 把这几条
671
+ // 写成 SQL,按顺序读 CREATE SCHEMA 后面的标识符,第 i 条语句就是第 i 行
672
+ const schemaEntries = entries.filter((line) => /^\d+; \d+ \d+ SCHEMA - /.test(line));
673
+ const skip = new Set();
674
+ if (schemaEntries.length) {
675
+ const schemaList = join(listDir, "schemas.list");
676
+ await writeFile(schemaList, schemaEntries.join("\n") + "\n");
677
+ const ddl = await pgRestoreOutput(bin, ["-f", "-", `--use-list=${schemaList}`, dumpPath]);
678
+ const names = parseCreateSchemas(ddl.stdout);
679
+ if (ddl.code !== 0 || names.length !== schemaEntries.length) {
680
+ return fail(`could not read the schema entries in the archive (pg_restore exited with code ${ddl.code})`);
681
+ }
682
+ const client = await connectPg(connString);
683
+ try {
684
+ const existing = await client.query("select nspname from pg_namespace where nspname = any($1::text[])", [names]);
685
+ const present = new Set(existing.rows.map((row) => row.nspname));
686
+ schemaEntries.forEach((line, i) => {
687
+ if (present.has(names[i]))
688
+ skip.add(line);
689
+ });
690
+ }
691
+ finally {
692
+ await client.end();
693
+ }
694
+ }
695
+ const listFile = join(listDir, "restore.list");
696
+ await writeFile(listFile, entries.filter((line) => !skip.has(line)).join("\n") + "\n");
697
+ const run = await spawnPgRestore(bin, ["--single-transaction", `--use-list=${listFile}`, ...common], env, childOpts);
698
+ if (run.code === 0)
699
+ return { expectedSkips: 0, failures: [] };
700
+ if (run.code === null)
701
+ return fail(`pg_restore was killed by a signal: ${firstRestoreError(run.stderr)}`);
702
+ return fail(firstRestoreError(run.stderr));
703
+ }
704
+ finally {
705
+ await rm(listDir, { recursive: true, force: true });
706
+ }
707
+ }
708
+ /**
709
+ * 单事务恢复只会有一条错误(--exit-on-error)。给人看的形态:主消息 + DETAIL/HINT 全文,再带上出错
710
+ * 语句的开头,方便认出是哪个对象。不再截成 200 字符——那样会把 "is not present in table" 这半句
711
+ * 切掉,用户看不出缺的是什么(2026-10-22 实验原样截在 "is no")。
712
+ */
713
+ export function firstRestoreError(stderr) {
714
+ const block = stderr.split(/(?=pg_restore: error:)/).find((b) => b.startsWith("pg_restore: error:"));
715
+ if (!block)
716
+ return stderr.trim().replace(/\s+/g, " ").slice(0, 300) || "(no stderr)";
717
+ const lines = block.split("\n");
718
+ const message = lines[0]
719
+ .replace(/^pg_restore: error: (?:could not execute query: )?/, "")
720
+ .replace(/^ERROR:\s+/, "")
721
+ .trim();
722
+ const details = lines.filter((line) => /^(?:DETAIL|HINT):/.test(line)).map((line) => line.replace(/\s+/g, " ").trim());
723
+ const commandAt = lines.findIndex((line) => line.startsWith("Command was:"));
724
+ const command = commandAt < 0
725
+ ? ""
726
+ : lines.slice(commandAt).join(" ").replace(/^Command was:\s*/, "").replace(/\s+/g, " ").trim();
727
+ const statement = command ? ` (statement: ${command.length > 160 ? `${command.slice(0, 160)}…` : command})` : "";
728
+ return [message, ...details].join(" ") + statement;
729
+ }
620
730
  /**
621
731
  * 两遍恢复(形状见 credentialSafeDbArgs 上方那段"两遍恢复"注释),`jobs` > 1 时按下面的方式并行(2026-10-17 实测:2 核机器上同一份
622
732
  * dump,原生 -j2 比 -j1 在 5 GB 快 14%、10 GB 快 12%):
@@ -627,8 +737,8 @@ function mergePasses(passes) {
627
737
  * 2. post-data 拆成两份清单(splitPostDataList):建索引那份并行,退出码 0 即通过,报过错
628
738
  * 的条目单连接重跑定结论;其余单连接、照旧按目标类型分类。
629
739
  * 两遍下来,同一份备份不管几路并行,结论都和 jobs=1 一样。
630
- * 真实 Supabase 目标(restore.ts)不传 jobs:每一路都是一条 pooler 连接,小规格项目的连接数
631
- * 和算力都紧,并行没测过,保持原来的单连接两遍。
740
+ * 真实 Supabase 目标(restore.ts)不走这里:单事务恢复(restoreInOneTransaction)不能并行,
741
+ * 而且每一路都是一条 pooler 连接,小规格项目的连接数和算力都紧。
632
742
  */
633
743
  export async function restoreDatabaseArtifact(opts) {
634
744
  const bin = resolvePgRestoreBin();
@@ -638,6 +748,10 @@ export async function restoreDatabaseArtifact(opts) {
638
748
  const { url, env } = credentialSafeDbArgs(dumpUrlFor(opts.connString));
639
749
  const common = ["--no-owner", "--no-privileges", "--dbname", url, opts.dumpPath];
640
750
  const childOpts = { supabaseHost: isSupabaseHost(opts.connString), urlSslMode: urlSslModeOf(url) };
751
+ if (opts.target === "supabase") {
752
+ const pass = await restoreInOneTransaction(bin, opts.dumpPath, opts.connString, common, env, childOpts);
753
+ return { preData: pass, postData: { expectedSkips: 0, failures: [] }, ok: pass.failures.length === 0 };
754
+ }
641
755
  const jobs = Math.max(1, Math.floor(opts.jobs ?? 1));
642
756
  const parallel = jobs > 1;
643
757
  const jobsArg = parallel ? [`--jobs=${jobs}`] : [];
@@ -652,7 +766,7 @@ export async function restoreDatabaseArtifact(opts) {
652
766
  const postData = finalizePass(second.code, second.stderr, classifyBlocks(second.stderr, postDataAllow));
653
767
  return { preData, postData, ok: postData.failures.length === 0 };
654
768
  }
655
- const listing = await listPostData(bin, opts.dumpPath);
769
+ const listing = await pgRestoreOutput(bin, ["--list", "--section=post-data", opts.dumpPath]);
656
770
  if (listing.code !== 0) {
657
771
  const detail = listing.stderr.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)";
658
772
  const postData = { expectedSkips: 0, failures: [`pg_restore --list exited with code ${listing.code}: ${detail}`] };
@@ -660,6 +774,7 @@ export async function restoreDatabaseArtifact(opts) {
660
774
  }
661
775
  const { indexBuilds, rest } = splitPostDataList(listing.stdout);
662
776
  const listDir = await mkdtemp(join(tmpdir(), "backupdrill-restore-"));
777
+ const forgetListDir = onInterrupt(() => rm(listDir, { recursive: true, force: true }).catch(() => { }));
663
778
  try {
664
779
  const passes = [];
665
780
  if (indexBuilds.length) {
@@ -704,5 +819,88 @@ export async function restoreDatabaseArtifact(opts) {
704
819
  }
705
820
  finally {
706
821
  await rm(listDir, { recursive: true, force: true });
822
+ forgetListDir();
707
823
  }
708
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.d.ts CHANGED
@@ -68,6 +68,13 @@ export declare function assertConfirmedTarget(confirmTarget: string | undefined,
68
68
  * 用户会以为自己漏了一步,其实只需要换一个空项目。
69
69
  */
70
70
  export declare function nonEmptyTargetMessage(schemas: string[], objectCount: number, alsoRestoringStorage: boolean): string;
71
+ /**
72
+ * 单事务恢复特有的失败:每张表连同它的索引、序列、TOAST 表在提交前都占着一个锁槽。槽数 =
73
+ * max_locks_per_transaction × (max_connections + …),Supabase 最小规格(max_connections 60)实测
74
+ * 900 张表能过、1200 张表报 out of shared memory(2026-10-23,PG 17)。分区多的库会撞上。
75
+ * Supabase 只能用 CLI 调这个参数(会重启数据库),照官方文档的命令给出来。
76
+ */
77
+ export declare function restoreFailureAdvice(failure: string): string;
71
78
  /**
72
79
  * 把一份快照恢复出来:数据库经统一引擎还原进目标(与演练同一条 pg_restore 路径与
73
80
  * 错误分类),恢复后跑同一套表级验证;Storage 提供目标凭据时回传+对账(P0-D),