backupdrill 2.2.4 → 2.3.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
@@ -31,6 +31,17 @@ npm install -g backupdrill
31
31
  npx backupdrill backup
32
32
  ```
33
33
 
34
+ ## Use it from Claude Code or Cursor
35
+
36
+ The [`plugin/`](plugin/) folder is a plugin for Claude Code and Cursor. It teaches the agent to run `estimate`, `backup`, `drill` and `restore` for you, keeps your keys in a gitignored `.env.backupdrill` that the agent never opens, and always does a dry run before a restore. In Claude Code:
37
+
38
+ ```
39
+ /plugin marketplace add backupdrill/cli
40
+ /plugin install backupdrill@backupdrill
41
+ ```
42
+
43
+ Details are in [plugin/README.md](plugin/README.md).
44
+
34
45
  ## Usage
35
46
 
36
47
  Configure via **env vars**, a **config file**, or **flags** (precedence: flags > env > file).
@@ -150,6 +161,8 @@ backupdrill drill --verify-all-files # also checksum every Storage file, not jus
150
161
 
151
162
  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.
152
163
 
164
+ The sandbox restore runs up to 4 parallel `pg_restore` jobs, sized from the CPUs and memory Docker reports, so a 2 vCPU / 8 GB machine gets 2. Table data and index builds run in parallel. Foreign keys, triggers and policies run on a single connection to keep their error messages separate, including errors about Supabase-managed objects such as `auth.users`. `backupdrill restore` into a real Supabase project stays on a single connection.
165
+
153
166
  ### Run your own checks against the restored copy
154
167
 
155
168
  The drill's structural checks (tables, rows, checksums) can't know what "working" means for *your* app — business invariants only have answers in your code. `--check-cmd` hands you the restored sandbox before it's destroyed:
package/dist/drill.d.ts CHANGED
@@ -44,8 +44,10 @@ export interface SandboxTuning {
44
44
  settings: string[];
45
45
  /** docker --shm-size 的值;并行建索引走动态共享内存,要跟 maintenance_work_mem 一起长 */
46
46
  shmSize: string;
47
+ /** pg_restore --jobs;1 = 单连接 */
48
+ restoreJobs: number;
47
49
  }
48
- export declare function sandboxTuning(totalMemBytes: number): SandboxTuning;
50
+ export declare function sandboxTuning(totalMemBytes: number, cpus?: number): SandboxTuning;
49
51
  export declare function sandboxDataVolumeTarget(major: number): string;
50
52
  /** post-data 第二遍恢复的结果:用户对象失败 vs Supabase 托管对象的沙箱预期跳过。 */
51
53
  interface PostDataResult {
package/dist/drill.js CHANGED
@@ -60,23 +60,31 @@ const MIB = 1024 * 1024;
60
60
  function clampMib(bytes, minMib, maxMib) {
61
61
  return Math.min(maxMib, Math.max(minMib, Math.floor(bytes / MIB)));
62
62
  }
63
- /** Docker 引擎可用内存(字节);问不到就返回 0,让 sandboxTuning 落到下限值,宁慢勿炸。 */
64
- async function dockerMemoryBudget() {
63
+ /** Docker 引擎可用内存(字节)和 CPU 数;问不到就返回 0,让 sandboxTuning 落到下限值、单连接,宁慢勿炸。 */
64
+ async function dockerResources() {
65
65
  try {
66
66
  // 探测是可选的,绝不能卡住演练:守护进程假死时 5 秒后放弃,落到下限值继续跑
67
- const { stdout } = await execFileAsync("docker", ["info", "--format", "{{.MemTotal}}"], {
67
+ const { stdout } = await execFileAsync("docker", ["info", "--format", "{{.MemTotal}} {{.NCPU}}"], {
68
68
  timeout: 5_000,
69
69
  });
70
- const bytes = Number.parseInt(stdout.trim(), 10);
71
- return Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
70
+ const [memoryBytes, cpus] = stdout.trim().split(/\s+/).map((value) => Number.parseInt(value, 10));
71
+ const positive = (value) => (value !== undefined && Number.isFinite(value) && value > 0 ? value : 0);
72
+ return { memoryBytes: positive(memoryBytes), cpus: positive(cpus) };
72
73
  }
73
74
  catch {
74
- return 0;
75
+ return { memoryBytes: 0, cpus: 0 };
75
76
  }
76
77
  }
77
- export function sandboxTuning(totalMemBytes) {
78
+ export function sandboxTuning(totalMemBytes, cpus = 1) {
79
+ const budgetMib = Math.floor(totalMemBytes / MIB);
78
80
  const sharedBuffersMib = clampMib(totalMemBytes / 4, 256, 2048);
79
81
  const maintenanceWorkMemMib = clampMib(totalMemBytes / 8, 256, 1024);
82
+ // 并行恢复时每一路都可能同时在建索引,各自吃满 maintenance_work_mem:路数 × maintenance_work_mem
83
+ // 加上 shared_buffers 不超过预算的一半,另一半留给 pg_restore、Docker 和同机的其他任务
84
+ // (8 GB 的 worker 正好 2 路,2026-10-17 在它上面实测 -j2 没出内存问题)。
85
+ // 再封顶 4 路:pg_restore 的并行度受表和索引个数限制,再多收益很小,演练还常跑在用户自己的电脑上。
86
+ const memoryBoundJobs = Math.floor((budgetMib / 2 - sharedBuffersMib) / maintenanceWorkMemMib);
87
+ const restoreJobs = Math.max(1, Math.min(cpus, memoryBoundJobs, 4));
80
88
  return {
81
89
  settings: [
82
90
  "fsync=off",
@@ -91,8 +99,9 @@ export function sandboxTuning(totalMemBytes) {
91
99
  ],
92
100
  // Docker 默认 /dev/shm 只有 64 MB;并行建索引(pgvector 的 HNSW 尤其)走动态共享内存,
93
101
  // 64 MB 会撞 "No space left on device" 把好备份误判失败(交叉审查 2026-09-12 复现)。
94
- // 给 maintenance_work_mem 的两倍、至少 1 GB;tmpfs 只按实际用量占内存,给大不吃亏。
95
- shmSize: `${Math.max(1024, maintenanceWorkMemMib * 2)}m`,
102
+ // 每一路并行恢复给 maintenance_work_mem 的两倍、至少 1 GB;tmpfs 只按实际用量占内存,给大不吃亏。
103
+ shmSize: `${Math.max(1024, maintenanceWorkMemMib * 2 * restoreJobs)}m`,
104
+ restoreJobs,
96
105
  };
97
106
  }
98
107
  function validateSupervision(supervision, appCheckCommand, keepSandboxOnFailure) {
@@ -132,7 +141,8 @@ async function startEphemeralPostgres(image, major, resourceName) {
132
141
  }
133
142
  }
134
143
  log.step(`Starting ephemeral ${image}…`);
135
- const tuning = sandboxTuning(await dockerMemoryBudget());
144
+ const resources = await dockerResources();
145
+ const tuning = sandboxTuning(resources.memoryBytes, resources.cpus);
136
146
  const { stdout } = await execFileAsync("docker", [
137
147
  "run",
138
148
  "-d",
@@ -187,7 +197,7 @@ async function startEphemeralPostgres(image, major, resourceName) {
187
197
  "postgres",
188
198
  "-q",
189
199
  ]);
190
- return { containerId, port, connString, volumeName };
200
+ return { containerId, port, connString, volumeName, restoreJobs: tuning.restoreJobs };
191
201
  }
192
202
  catch {
193
203
  await new Promise((r) => setTimeout(r, 500));
@@ -222,8 +232,8 @@ export function postDataResult(code, stderr) {
222
232
  * 的托管对象跳过如实返回。pre-data 的 schema 冲突跳过(dump 自带 CREATE SCHEMA,
223
233
  * 容器恒有 public)是非事件,不计入报告。
224
234
  */
225
- async function pgRestore(dumpPath, connString, dumpedSchemas) {
226
- const result = await restoreDatabaseArtifact({ dumpPath, connString, target: "sandbox", dumpedSchemas });
235
+ async function pgRestore(dumpPath, connString, dumpedSchemas, jobs) {
236
+ const result = await restoreDatabaseArtifact({ dumpPath, connString, target: "sandbox", dumpedSchemas, jobs });
227
237
  if (result.preData.failures.length) {
228
238
  throw new Error(`pg_restore failed: ${result.preData.failures.join(" | ")}`);
229
239
  }
@@ -478,7 +488,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
478
488
  log.step("Restoring into ephemeral Postgres…");
479
489
  let postData;
480
490
  try {
481
- postData = await pgRestore(dumpPath, pg.connString, manifest.database.schemas);
491
+ postData = await pgRestore(dumpPath, pg.connString, manifest.database.schemas, pg.restoreJobs);
482
492
  }
483
493
  catch (error) {
484
494
  // 沙箱装不上扩展 + 错误特征命中"缺类型/schema/扩展" → 假设式归因到沙箱环境
@@ -491,7 +501,11 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
491
501
  throw error;
492
502
  }
493
503
  const restoreSeconds = Math.round((Date.now() - started) / 100) / 10;
494
- checks.push({ name: "pg_restore", pass: true, detail: `completed in ${restoreSeconds}s` });
504
+ checks.push({
505
+ name: "pg_restore",
506
+ pass: true,
507
+ detail: `completed in ${restoreSeconds}s` + (pg.restoreJobs > 1 ? ` (${pg.restoreJobs} parallel jobs)` : ""),
508
+ });
495
509
  // 用户自己的 post-data 对象(索引/约束/触发器)必须全部恢复;Supabase 托管对象
496
510
  // 的沙箱预期跳过如实写进报告(演练不假装它们恢复了)。
497
511
  checks.push({
@@ -91,8 +91,25 @@ export declare function classifyBlocks(stderr: string, allow: RegExp): Classifie
91
91
  * 的半截恢复就会被报成功(评审第 7 轮抓出的假成功路径)。
92
92
  * 2. 非零退出且零错误块(归档器致命错、非英文 locale…)同样不能谎报成功。
93
93
  * 非零退出但每个错误都已分类 = pg_restore 对被忽略错误的正常退出形态,通过。
94
+ * 3. 并行遍(`parallel`)不能只信切块:多个 worker 进程共用一个 stderr,而 PostgreSQL 一条
95
+ * 报错分好几次 write(`pg_restore:`、` `、`error: `、正文,见 src/common/logging.c),
96
+ * 两条同时写会交错,一条真错误的前缀可能被拆散、正文挂到另一条预期跳过的块里——切块
97
+ * 看不到它,演练照样 PASS。所以并行遍只在两种情况下通过:退出码 0(一个错误都没有),
98
+ * 或 pg_restore 最后打印的 "errors ignored on restore: N" 恰好等于预期跳过数。
99
+ * worker 里出的错 PostgreSQL 会多算一次(17 实测 4 个错报 8,mark_restore_job_done
100
+ * 又 +1),所以只要 worker 出过错 N 就一定大于预期跳过数——这条规则只放行"所有错误都出在
101
+ * fork 之前的单进程阶段"的那种并行遍,调用方要据此安排哪些对象放进并行遍。
102
+ * 没有这行汇总的非零退出 = 中途致命退出,同样失败。
94
103
  */
95
- export declare function finalizePass(code: number | null, stderr: string, classified: ClassifiedPass): ClassifiedPass;
104
+ export declare function finalizePass(code: number | null, stderr: string, classified: ClassifiedPass, opts?: {
105
+ parallel?: boolean;
106
+ }): ClassifiedPass;
107
+ /**
108
+ * pg_restore 正常跑完时打印的错误总数;没跑完(致命退出)或认不出时返回 null。
109
+ * 只认 stderr 的最后一个非空行、且整行就是这句汇总:这行在所有 worker 退出之后由主进程打印,
110
+ * 不会和别的输出交错;前面 "Command was:" 里的用户 SQL 就算含同样字样也冒充不了它。
111
+ */
112
+ export declare function ignoredErrorTotal(stderr: string): number | null;
96
113
  /** 标识符转义(内嵌引号翻倍)。导出:任何把目录里读到的名字拼进 SQL 的地方都必须用它。 */
97
114
  export declare function quoteIdent(name: string): string;
98
115
  /**
@@ -159,10 +176,46 @@ export declare function credentialSafeDbArgs(connString: string): {
159
176
  * 目标)的 options 没有租户语义,照常放行。
160
177
  */
161
178
  export declare function assertNoHostOverride(connString: string): void;
179
+ /**
180
+ * 把 `pg_restore --list --section=post-data` 的目录拆成两份 -L 清单:
181
+ * - `indexBuilds`:INDEX 和 CONSTRAINT(主键/唯一/排除约束,本质也是建索引)。恢复里最慢的就是
182
+ * 它们(2026-10-17 实测 post-data 占 pg_restore 时间的 67–77%,大头是建索引)。并行跑;
183
+ * 出过错的条目再单连接重跑一遍定结论(见 restoreDatabaseArtifact)。
184
+ * - `rest`:FK CONSTRAINT、TRIGGER、POLICY、INDEX ATTACH、COMMENT 等。指向 auth.users 的外键
185
+ * 这类预期跳过全在这里,并行时 worker 报错会交错、计数也不准(见 finalizePass 规则 3),
186
+ * 所以这份始终单连接跑,保留逐块分类。外键依赖主键/唯一约束,也必须排在 indexBuilds 之后。
187
+ * 注释行(`;` 开头)和空行丢掉:-L 只按行首的 dumpId 认条目。
188
+ */
189
+ export declare function splitPostDataList(listing: string): {
190
+ indexBuilds: string[];
191
+ rest: string[];
192
+ };
193
+ /**
194
+ * 并行建索引那遍(带 --verbose)里出过错的条目 dumpId。pg_restore 对每个出错条目先打一行
195
+ * `from TOC entry <dumpId>; …`(warn_or_exit_horribly,只在 verbose 下输出)。这行正文是一次
196
+ * write 写完的:并行时它的 `pg_restore: ` 前缀可能被别的 worker 拆开,正文本身不会,
197
+ * 所以按子串找、不按行首找。
198
+ */
199
+ export declare function failedTocEntries(stderr: string): Set<string>;
200
+ /**
201
+ * 两遍恢复(形状见 credentialSafeDbArgs 上方那段"两遍恢复"注释),`jobs` > 1 时按下面的方式并行(2026-10-17 实测:2 核机器上同一份
202
+ * dump,原生 -j2 比 -j1 在 5 GB 快 14%、10 GB 快 12%):
203
+ * 1. pre-data+data 一遍加 --jobs。pg_restore 并行时先在主进程里单独跑完 pre-data,fork 之后
204
+ * 才并行灌数据,所以唯一的预期冲突(schema 已存在)一定出在单进程阶段,finalizePass
205
+ * 规则 3 的计数核对成立。同一遍里建表再灌数据,pg_restore 才会用 TRUNCATE+COPY,
206
+ * wal_level=minimal 的沙箱因此不写这部分 WAL——拆成两遍就没了这个加速。
207
+ * 2. post-data 拆成两份清单(splitPostDataList):建索引那份并行,退出码 0 即通过,报过错
208
+ * 的条目单连接重跑定结论;其余单连接、照旧按目标类型分类。
209
+ * 两遍下来,同一份备份不管几路并行,结论都和 jobs=1 一样。
210
+ * 真实 Supabase 目标(restore.ts)不传 jobs:每一路都是一条 pooler 连接,小规格项目的连接数
211
+ * 和算力都紧,并行没测过,保持原来的单连接两遍。
212
+ */
162
213
  export declare function restoreDatabaseArtifact(opts: {
163
214
  dumpPath: string;
164
215
  connString: string;
165
216
  target: RestoreTargetKind;
166
217
  /** manifest.database.schemas:沙箱 allowlist 据此剔掉转储自带的托管 schema(见 sandboxManagedError)。 */
167
218
  dumpedSchemas?: string[];
219
+ /** pg_restore 并行路数;不传或 ≤1 = 单连接两遍。 */
220
+ jobs?: number;
168
221
  }): Promise<EngineResult>;
@@ -9,6 +9,9 @@
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 { mkdtemp, rm, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
12
15
  import { resolvePgRestoreBin } from "./pgbin.js";
13
16
  import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
14
17
  /** 主机名规范化:先按驱动语义百分号解码(pg-connection-string / libpq 都会解码主机名,
@@ -268,8 +271,17 @@ export function classifyBlocks(stderr, allow) {
268
271
  * 的半截恢复就会被报成功(评审第 7 轮抓出的假成功路径)。
269
272
  * 2. 非零退出且零错误块(归档器致命错、非英文 locale…)同样不能谎报成功。
270
273
  * 非零退出但每个错误都已分类 = pg_restore 对被忽略错误的正常退出形态,通过。
274
+ * 3. 并行遍(`parallel`)不能只信切块:多个 worker 进程共用一个 stderr,而 PostgreSQL 一条
275
+ * 报错分好几次 write(`pg_restore:`、` `、`error: `、正文,见 src/common/logging.c),
276
+ * 两条同时写会交错,一条真错误的前缀可能被拆散、正文挂到另一条预期跳过的块里——切块
277
+ * 看不到它,演练照样 PASS。所以并行遍只在两种情况下通过:退出码 0(一个错误都没有),
278
+ * 或 pg_restore 最后打印的 "errors ignored on restore: N" 恰好等于预期跳过数。
279
+ * worker 里出的错 PostgreSQL 会多算一次(17 实测 4 个错报 8,mark_restore_job_done
280
+ * 又 +1),所以只要 worker 出过错 N 就一定大于预期跳过数——这条规则只放行"所有错误都出在
281
+ * fork 之前的单进程阶段"的那种并行遍,调用方要据此安排哪些对象放进并行遍。
282
+ * 没有这行汇总的非零退出 = 中途致命退出,同样失败。
271
283
  */
272
- export function finalizePass(code, stderr, classified) {
284
+ export function finalizePass(code, stderr, classified, opts = {}) {
273
285
  const detail = stderr.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)";
274
286
  if (code === null) {
275
287
  return {
@@ -280,8 +292,30 @@ export function finalizePass(code, stderr, classified) {
280
292
  if (code !== 0 && classified.expectedSkips === 0 && classified.failures.length === 0) {
281
293
  return { ...classified, failures: [`pg_restore exited with code ${code}: ${detail}`] };
282
294
  }
295
+ if (opts.parallel && code !== 0 && classified.failures.length === 0) {
296
+ const reported = ignoredErrorTotal(stderr);
297
+ if (reported === null || reported !== classified.expectedSkips) {
298
+ return {
299
+ ...classified,
300
+ failures: [
301
+ `parallel pg_restore reported ${reported ?? "an unknown number of"} error(s), ` +
302
+ `but only ${classified.expectedSkips} could be attributed to expected skips: ${detail}`,
303
+ ],
304
+ };
305
+ }
306
+ }
283
307
  return classified;
284
308
  }
309
+ /**
310
+ * pg_restore 正常跑完时打印的错误总数;没跑完(致命退出)或认不出时返回 null。
311
+ * 只认 stderr 的最后一个非空行、且整行就是这句汇总:这行在所有 worker 退出之后由主进程打印,
312
+ * 不会和别的输出交错;前面 "Command was:" 里的用户 SQL 就算含同样字样也冒充不了它。
313
+ */
314
+ export function ignoredErrorTotal(stderr) {
315
+ const lastLine = stderr.trimEnd().split("\n").pop() ?? "";
316
+ const match = /^pg_restore: warning: errors ignored on restore: (\d+)$/.exec(lastLine);
317
+ return match ? Number(match[1]) : null;
318
+ }
285
319
  /** 标识符转义(内嵌引号翻倍)。导出:任何把目录里读到的名字拼进 SQL 的地方都必须用它。 */
286
320
  export function quoteIdent(name) {
287
321
  return `"${name.replace(/"/g, '""')}"`;
@@ -526,6 +560,76 @@ export function assertNoHostOverride(connString) {
526
560
  }
527
561
  }
528
562
  }
563
+ /**
564
+ * 把 `pg_restore --list --section=post-data` 的目录拆成两份 -L 清单:
565
+ * - `indexBuilds`:INDEX 和 CONSTRAINT(主键/唯一/排除约束,本质也是建索引)。恢复里最慢的就是
566
+ * 它们(2026-10-17 实测 post-data 占 pg_restore 时间的 67–77%,大头是建索引)。并行跑;
567
+ * 出过错的条目再单连接重跑一遍定结论(见 restoreDatabaseArtifact)。
568
+ * - `rest`:FK CONSTRAINT、TRIGGER、POLICY、INDEX ATTACH、COMMENT 等。指向 auth.users 的外键
569
+ * 这类预期跳过全在这里,并行时 worker 报错会交错、计数也不准(见 finalizePass 规则 3),
570
+ * 所以这份始终单连接跑,保留逐块分类。外键依赖主键/唯一约束,也必须排在 indexBuilds 之后。
571
+ * 注释行(`;` 开头)和空行丢掉:-L 只按行首的 dumpId 认条目。
572
+ */
573
+ export function splitPostDataList(listing) {
574
+ const indexBuilds = [];
575
+ const rest = [];
576
+ for (const line of listing.split("\n")) {
577
+ if (!/^\d+;/.test(line))
578
+ continue;
579
+ // 格式:`<dumpId>; <tableoid> <oid> <类型> <schema> <名字> <属主>`,类型可含空格
580
+ // (FK CONSTRAINT、INDEX ATTACH),所以按类型开头匹配并单独排除 INDEX ATTACH
581
+ if (/^\d+; \d+ \d+ (INDEX|CONSTRAINT) /.test(line) && !/^\d+; \d+ \d+ INDEX ATTACH /.test(line)) {
582
+ indexBuilds.push(line);
583
+ }
584
+ else {
585
+ rest.push(line);
586
+ }
587
+ }
588
+ return { indexBuilds, rest };
589
+ }
590
+ function listPostData(bin, dumpPath) {
591
+ return new Promise((resolve, reject) => {
592
+ // 只读归档、不连库,但同样钉 C locale,和恢复子进程一致
593
+ const proc = spawn(bin, ["--list", "--section=post-data", dumpPath], {
594
+ stdio: ["ignore", "pipe", "pipe"],
595
+ env: libpqChildEnv(),
596
+ });
597
+ let stdout = "";
598
+ let stderr = "";
599
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
600
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
601
+ proc.on("error", (error) => reject(new Error(`${bin} failed to start: ${error.code ?? error.message}`)));
602
+ proc.on("close", (code) => resolve({ code, stdout, stderr }));
603
+ });
604
+ }
605
+ /**
606
+ * 并行建索引那遍(带 --verbose)里出过错的条目 dumpId。pg_restore 对每个出错条目先打一行
607
+ * `from TOC entry <dumpId>; …`(warn_or_exit_horribly,只在 verbose 下输出)。这行正文是一次
608
+ * write 写完的:并行时它的 `pg_restore: ` 前缀可能被别的 worker 拆开,正文本身不会,
609
+ * 所以按子串找、不按行首找。
610
+ */
611
+ export function failedTocEntries(stderr) {
612
+ return new Set([...stderr.matchAll(/from TOC entry (\d+);/g)].map((match) => match[1]));
613
+ }
614
+ function mergePasses(passes) {
615
+ return {
616
+ expectedSkips: passes.reduce((sum, pass) => sum + pass.expectedSkips, 0),
617
+ failures: passes.flatMap((pass) => pass.failures),
618
+ };
619
+ }
620
+ /**
621
+ * 两遍恢复(形状见 credentialSafeDbArgs 上方那段"两遍恢复"注释),`jobs` > 1 时按下面的方式并行(2026-10-17 实测:2 核机器上同一份
622
+ * dump,原生 -j2 比 -j1 在 5 GB 快 14%、10 GB 快 12%):
623
+ * 1. pre-data+data 一遍加 --jobs。pg_restore 并行时先在主进程里单独跑完 pre-data,fork 之后
624
+ * 才并行灌数据,所以唯一的预期冲突(schema 已存在)一定出在单进程阶段,finalizePass
625
+ * 规则 3 的计数核对成立。同一遍里建表再灌数据,pg_restore 才会用 TRUNCATE+COPY,
626
+ * wal_level=minimal 的沙箱因此不写这部分 WAL——拆成两遍就没了这个加速。
627
+ * 2. post-data 拆成两份清单(splitPostDataList):建索引那份并行,退出码 0 即通过,报过错
628
+ * 的条目单连接重跑定结论;其余单连接、照旧按目标类型分类。
629
+ * 两遍下来,同一份备份不管几路并行,结论都和 jobs=1 一样。
630
+ * 真实 Supabase 目标(restore.ts)不传 jobs:每一路都是一条 pooler 连接,小规格项目的连接数
631
+ * 和算力都紧,并行没测过,保持原来的单连接两遍。
632
+ */
529
633
  export async function restoreDatabaseArtifact(opts) {
530
634
  const bin = resolvePgRestoreBin();
531
635
  // TLS 全链路 verify-full(创始人承诺项):pg_restore 与 pg_dump 同一改写——
@@ -534,12 +638,71 @@ export async function restoreDatabaseArtifact(opts) {
534
638
  const { url, env } = credentialSafeDbArgs(dumpUrlFor(opts.connString));
535
639
  const common = ["--no-owner", "--no-privileges", "--dbname", url, opts.dumpPath];
536
640
  const childOpts = { supabaseHost: isSupabaseHost(opts.connString), urlSslMode: urlSslModeOf(url) };
537
- const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...common], env, childOpts);
538
- const preData = finalizePass(first.code, first.stderr, classifyBlocks(first.stderr, SCHEMA_EXISTS_ERROR));
641
+ const jobs = Math.max(1, Math.floor(opts.jobs ?? 1));
642
+ const parallel = jobs > 1;
643
+ const jobsArg = parallel ? [`--jobs=${jobs}`] : [];
644
+ const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...jobsArg, ...common], env, childOpts);
645
+ const preData = finalizePass(first.code, first.stderr, classifyBlocks(first.stderr, SCHEMA_EXISTS_ERROR), { parallel });
539
646
  if (preData.failures.length) {
540
647
  return { preData, postData: { expectedSkips: 0, failures: [] }, ok: false };
541
648
  }
542
- const second = await spawnPgRestore(bin, ["--section=post-data", ...common], env, childOpts);
543
- const postData = finalizePass(second.code, second.stderr, classifyBlocks(second.stderr, opts.target === "sandbox" ? sandboxManagedError(opts.dumpedSchemas ?? []) : NEVER_MATCH));
544
- return { preData, postData, ok: postData.failures.length === 0 };
649
+ const postDataAllow = opts.target === "sandbox" ? sandboxManagedError(opts.dumpedSchemas ?? []) : NEVER_MATCH;
650
+ if (!parallel) {
651
+ const second = await spawnPgRestore(bin, ["--section=post-data", ...common], env, childOpts);
652
+ const postData = finalizePass(second.code, second.stderr, classifyBlocks(second.stderr, postDataAllow));
653
+ return { preData, postData, ok: postData.failures.length === 0 };
654
+ }
655
+ const listing = await listPostData(bin, opts.dumpPath);
656
+ if (listing.code !== 0) {
657
+ const detail = listing.stderr.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)";
658
+ const postData = { expectedSkips: 0, failures: [`pg_restore --list exited with code ${listing.code}: ${detail}`] };
659
+ return { preData, postData, ok: false };
660
+ }
661
+ const { indexBuilds, rest } = splitPostDataList(listing.stdout);
662
+ const listDir = await mkdtemp(join(tmpdir(), "backupdrill-restore-"));
663
+ try {
664
+ const passes = [];
665
+ if (indexBuilds.length) {
666
+ const listFile = join(listDir, "index-builds.list");
667
+ await writeFile(listFile, indexBuilds.join("\n") + "\n");
668
+ const run = await spawnPgRestore(bin, ["--section=post-data", `--use-list=${listFile}`, "--verbose", ...jobsArg, ...common], env, childOpts);
669
+ // 退出码 0 = 一个错都没有。出过错时不读交错的报错正文,只取出错条目的 dumpId,把这些条目
670
+ // 单连接重跑、照单连接的规则分类:一条 CREATE INDEX / ADD CONSTRAINT 失败不留半成品,
671
+ // 重跑得到的就是 jobs=1 时的结论(交叉审查:引用托管 schema 函数的索引在单连接时是预期
672
+ // 跳过,并行时不能因此判失败;也不能靠忽略"已存在"去猜哪些建好了,沙箱里预装的扩展
673
+ // 对象会和用户索引撞名)。资源原因偶发失败的条目,重跑成功即通过,和单连接本来一样。
674
+ if (run.code !== 0) {
675
+ const failedIds = failedTocEntries(run.stderr);
676
+ const failed = indexBuilds.filter((line) => failedIds.has(line.slice(0, line.indexOf(";"))));
677
+ // 被信号杀死、没有汇总行(中途致命退出,后面的条目根本没跑)、或认不出任何出错条目:
678
+ // 都说明这一遍没走完或没法归因,直接失败,不重跑
679
+ // 出错条目的编号即使被用户 SQL 冒充,也只会多重跑一条、让它报"已存在"而判失败,不会漏掉真错误
680
+ const finished = run.code !== null && ignoredErrorTotal(run.stderr) !== null && failed.length > 0;
681
+ if (!finished) {
682
+ const detail = run.stderr.trim().replace(/\s+/g, " ").slice(-200) || "(no stderr)";
683
+ passes.push({
684
+ expectedSkips: 0,
685
+ failures: [`parallel index builds did not finish cleanly (exit ${run.code ?? "signal"}): ${detail}`],
686
+ });
687
+ }
688
+ else {
689
+ const rerunFile = join(listDir, "index-builds-rerun.list");
690
+ await writeFile(rerunFile, failed.join("\n") + "\n");
691
+ const rerun = await spawnPgRestore(bin, ["--section=post-data", `--use-list=${rerunFile}`, ...common], env, childOpts);
692
+ passes.push(finalizePass(rerun.code, rerun.stderr, classifyBlocks(rerun.stderr, postDataAllow)));
693
+ }
694
+ }
695
+ }
696
+ if (rest.length) {
697
+ const listFile = join(listDir, "rest.list");
698
+ await writeFile(listFile, rest.join("\n") + "\n");
699
+ const run = await spawnPgRestore(bin, ["--section=post-data", `--use-list=${listFile}`, ...common], env, childOpts);
700
+ passes.push(finalizePass(run.code, run.stderr, classifyBlocks(run.stderr, postDataAllow)));
701
+ }
702
+ const postData = mergePasses(passes);
703
+ return { preData, postData, ok: postData.failures.length === 0 };
704
+ }
705
+ finally {
706
+ await rm(listDir, { recursive: true, force: true });
707
+ }
545
708
  }
package/dist/restore.d.ts CHANGED
@@ -61,6 +61,12 @@ export declare function assertRequestApplies(manifest: Manifest, wantsDatabase:
61
61
  * 这道门要求人把目标身份亲手打一遍。
62
62
  */
63
63
  export declare function assertConfirmedTarget(confirmTarget: string | undefined, targetDatabaseUrl?: string, targetSupabaseUrl?: string): void;
64
+ /**
65
+ * 空目标门的拒绝文案。"只重试 Storage"那句只对同时恢复 Storage 的运行有意义:库已写完、
66
+ * Storage 中途断了,重跑时才会在这道门上被拦。纯数据库恢复(托管恢复只做数据库)带上它,
67
+ * 用户会以为自己漏了一步,其实只需要换一个空项目。
68
+ */
69
+ export declare function nonEmptyTargetMessage(schemas: string[], objectCount: number, alsoRestoringStorage: boolean): string;
64
70
  /**
65
71
  * 把一份快照恢复出来:数据库经统一引擎还原进目标(与演练同一条 pg_restore 路径与
66
72
  * 错误分类),恢复后跑同一套表级验证;Storage 提供目标凭据时回传+对账(P0-D),
package/dist/restore.js CHANGED
@@ -155,6 +155,20 @@ export function assertConfirmedTarget(confirmTarget, targetDatabaseUrl, targetSu
155
155
  `(use --dry-run first to preview all checks without writing).`);
156
156
  }
157
157
  }
158
+ /**
159
+ * 空目标门的拒绝文案。"只重试 Storage"那句只对同时恢复 Storage 的运行有意义:库已写完、
160
+ * Storage 中途断了,重跑时才会在这道门上被拦。纯数据库恢复(托管恢复只做数据库)带上它,
161
+ * 用户会以为自己漏了一步,其实只需要换一个空项目。
162
+ */
163
+ export function nonEmptyTargetMessage(schemas, objectCount, alsoRestoringStorage) {
164
+ const message = `target schema(s) ${schemas.join(", ")} contain ${objectCount} existing object(s) — ` +
165
+ `restore only writes into an empty target. Create a fresh Supabase project ` +
166
+ `(or empty these schemas) and retry.`;
167
+ if (!alsoRestoringStorage)
168
+ return message;
169
+ return (`${message} If a previous run already restored the database and only Storage was ` +
170
+ `interrupted, re-run WITHOUT the database target to retry Storage alone.`);
171
+ }
158
172
  /**
159
173
  * 空目标门(PRD §5.3.3 / 决策 D5):目标 schema 里存在任何用户对象即拒绝写入。
160
174
  * "空"的定义来自 R0 spike:不是"schema 不存在"(Supabase 恒有 public),而是
@@ -162,7 +176,7 @@ export function assertConfirmedTarget(confirmTarget, targetDatabaseUrl, targetSu
162
176
  * 全部计入:只查关系时,"目标里只有函数"会放行,然后在 pre-data 中途撞冲突,
163
177
  * 留下半截目标(评审第 7 轮)。
164
178
  */
165
- async function assertEmptyTarget(targetUrl, schemas) {
179
+ async function assertEmptyTarget(targetUrl, schemas, alsoRestoringStorage) {
166
180
  const client = await connectPg(targetUrl);
167
181
  try {
168
182
  const res = await client.query(`select
@@ -175,11 +189,7 @@ async function assertEmptyTarget(targetUrl, schemas) {
175
189
  as n`, [schemas]);
176
190
  const objectCount = Number(res.rows[0].n);
177
191
  if (objectCount > 0) {
178
- throw new Error(`target schema(s) ${schemas.join(", ")} contain ${objectCount} existing object(s) — ` +
179
- `restore only writes into an empty target. Create a fresh Supabase project ` +
180
- `(or empty these schemas) and retry. If a previous run already restored the ` +
181
- `database and only Storage was interrupted, re-run WITHOUT the database target ` +
182
- `to retry Storage alone.`);
192
+ throw new Error(nonEmptyTargetMessage(schemas, objectCount, alsoRestoringStorage));
183
193
  }
184
194
  }
185
195
  finally {
@@ -190,7 +200,7 @@ async function assertEmptyTarget(targetUrl, schemas) {
190
200
  * dry-run 只读预检(PRD §5.3.2):零写入,把正式执行会撞的墙全部提前撞一遍。
191
201
  * 逐项打印,任何 blocker 汇总后抛出(退出码非零 = "现在执行不会成功")。
192
202
  */
193
- async function dryRunPreflight(config, manifest, targetUrl, storageTarget) {
203
+ async function dryRunPreflight(config, manifest, targetUrl, storageTarget, alsoRestoringStorage) {
194
204
  const blockers = [];
195
205
  const db = manifest.database;
196
206
  const mb = (n) => (n / 1024 / 1024).toFixed(1);
@@ -221,7 +231,7 @@ async function dryRunPreflight(config, manifest, targetUrl, storageTarget) {
221
231
  log.ok("target identity: distinct from source");
222
232
  }
223
233
  try {
224
- await assertEmptyTarget(targetUrl, db.schemas);
234
+ await assertEmptyTarget(targetUrl, db.schemas, alsoRestoringStorage);
225
235
  log.ok(`target schemas (${db.schemas.join(", ")}) are empty`);
226
236
  }
227
237
  catch (error) {
@@ -376,10 +386,13 @@ export async function runRestore(config, opts) {
376
386
  storageFilesWritten: 0,
377
387
  };
378
388
  assertRequestApplies(manifest, !!opts.targetDatabaseUrl, !!storageTarget);
389
+ // 这次运行会不会碰 Storage(回传到目标,或下载到本地)。只有会碰时,空目标门才该
390
+ // 提示"库已恢复、只重试 Storage";skipStorage 的纯数据库运行(托管恢复)不该看到这句。
391
+ const alsoRestoringStorage = !!manifest.storage && !opts.skipStorage;
379
392
  // dry-run:只读预检后直接返回,零写入(PRD §5.3.2)
380
393
  if (opts.dryRun) {
381
394
  log.step(`Dry run for snapshot ${snapshot} — nothing will be written`);
382
- await dryRunPreflight(config, manifest, opts.targetDatabaseUrl, storageTarget);
395
+ await dryRunPreflight(config, manifest, opts.targetDatabaseUrl, storageTarget, alsoRestoringStorage);
383
396
  result.dryRun = true;
384
397
  return result;
385
398
  }
@@ -408,7 +421,7 @@ export async function runRestore(config, opts) {
408
421
  "at a fresh Supabase project.");
409
422
  }
410
423
  log.step("Checking target is empty…");
411
- await assertEmptyTarget(targetUrl, manifest.database.schemas);
424
+ await assertEmptyTarget(targetUrl, manifest.database.schemas, alsoRestoringStorage);
412
425
  // 版本预检不只属于 dry-run:跳过 dry-run 直接执行时,目标比源旧照样会
413
426
  // 半途炸掉或部分恢复(评审第 8 轮)——写入前必须拦住
414
427
  const versionClient = await connectPg(targetUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.2.4",
3
+ "version": "2.3.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": {