backupdrill 2.2.5 → 2.4.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
@@ -161,6 +161,8 @@ backupdrill drill --verify-all-files # also checksum every Storage file, not jus
161
161
 
162
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.
163
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
+
164
166
  ### Run your own checks against the restored copy
165
167
 
166
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/backup.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { BackupConfig } from "./config.js";
2
+ import { type SnapshotStore } from "./snapshot-store.js";
2
3
  import type { Manifest } from "./manifest.js";
3
4
  import { parsePgDumpMajor } from "./pgbin.js";
4
5
  export { parsePgDumpMajor };
5
- export declare function runBackup(config: BackupConfig): Promise<Manifest>;
6
+ export declare function runBackup(config: BackupConfig, opts?: {
7
+ /** 快照写到哪;省略 = config.storage 指的 S3 兼容桶 */
8
+ store?: SnapshotStore;
9
+ }): Promise<Manifest>;
package/dist/backup.js CHANGED
@@ -2,18 +2,15 @@ import { spawn, execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { Transform } from "node:stream";
4
4
  import { promisify } from "node:util";
5
- import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
6
- import { Upload } from "@aws-sdk/lib-storage";
5
+ import { s3SnapshotStore } from "./snapshot-store.js";
7
6
  import { MANIFEST_SCHEMA_VERSION } from "./manifest.js";
8
7
  import { syncStorage } from "./storage.js";
9
8
  import { inspectBucketAttrs } from "./storage-catalog.js";
10
- import { renderRecoveryDoc } from "./recovery-doc.js";
11
9
  import { projectRefOf, refFromStorageEndpoint, assertNoHostOverride } from "./restore-engine.js";
12
10
  import { log } from "./log.js";
13
11
  import { TOOL_VERSION } from "./version.js";
14
12
  import { parsePgDumpMajor } from "./pgbin.js";
15
13
  import { relationBytes } from "./estimate.js";
16
- import { dumpPartSizeFor } from "./part-size.js";
17
14
  // 解析函数搬到了 pgbin(演练与恢复都要读 pg_restore 版本,放这里会成环);保留旧出口
18
15
  export { parsePgDumpMajor };
19
16
  import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
@@ -93,8 +90,8 @@ async function inspectDatabase(databaseUrl, schemas) {
93
90
  await client.end();
94
91
  }
95
92
  }
96
- /** 流式跑 pg_dump,边写 S3 边算 sha256 与字节数。返回校验和与大小。 */
97
- async function dumpToS3(config, s3, key, pgDumpBin, partSize) {
93
+ /** 流式跑 pg_dump,边写快照边算 sha256 与字节数。返回校验和与大小。 */
94
+ async function dumpToSnapshot(config, writer, pgDumpBin, dumpRelationBytes) {
98
95
  const hash = createHash("sha256");
99
96
  let bytes = 0;
100
97
  const meter = new Transform({
@@ -136,33 +133,26 @@ async function dumpToS3(config, s3, key, pgDumpBin, partSize) {
136
133
  ? resolve()
137
134
  : reject(new Error(`pg_dump exited with code ${code}: ${stderr.trim()}`)));
138
135
  });
139
- const upload = new Upload({
140
- client: s3,
141
- partSize,
142
- params: {
143
- Bucket: config.storage.bucket,
144
- Key: key,
145
- Body: meter,
146
- ContentType: "application/octet-stream",
147
- },
148
- });
149
- const uploadDone = upload.done();
150
136
  // 失败双向联动:dump 失败要中止上传(绝不把半截转储当成功);上传失败也必须
151
137
  // 杀掉 pg_dump —— 否则上传侧停止消费后,背压会让 pg_dump 永远阻塞在 stdout
152
138
  // 写入上,调用方(worker 的串行队列)随之永久挂起。Promise.all 会订阅两侧,
153
139
  // 先失败的一方抛出后,另一方稍后的 rejection 也已被视为 handled。
140
+ // 开始上传也放进 try:实现可能在建上传时就同步抛错,那时 pg_dump 已经在跑,同样要杀掉。
141
+ let upload = null;
154
142
  try {
155
- await Promise.all([dumpDone, uploadDone]);
143
+ upload = writer.uploadDump(meter, dumpRelationBytes);
144
+ await Promise.all([dumpDone, upload.done]);
156
145
  }
157
146
  catch (error) {
147
+ dumpDone.catch(() => { }); // 没走到 Promise.all 时,被杀的 pg_dump 的退出也得有人接住
158
148
  dump.kill("SIGKILL");
159
149
  meter.destroy();
160
- await upload.abort().catch(() => { });
150
+ await upload?.abort().catch(() => { });
161
151
  throw error;
162
152
  }
163
153
  return { bytes, sha256: hash.digest("hex") };
164
154
  }
165
- export async function runBackup(config) {
155
+ export async function runBackup(config, opts = {}) {
166
156
  // 源身份先于一切传输(评审第 12 轮):①连接串不许带 ?host=/user= 覆盖——否则
167
157
  // 快照记的 ref 与实际读取对象脱节,恢复端的同源保护随之失真;②库与 Storage
168
158
  // 跨项目 = 配置错误,全量 dump+同步之后才发现会白烧 egress 还留下半截快照前缀
@@ -190,29 +180,26 @@ export async function runBackup(config) {
190
180
  `pg_dump must be >= the server major version. Install postgresql-client-${serverMajor} ` +
191
181
  `(or newer) and/or point BACKUPDRILL_PG_DUMP at it.`);
192
182
  }
193
- const s3 = new S3Client({
194
- region: config.storage.region,
195
- endpoint: config.storage.endpoint,
196
- forcePathStyle: config.storage.forcePathStyle,
197
- credentials: {
198
- accessKeyId: config.storage.accessKeyId,
199
- secretAccessKey: config.storage.secretAccessKey,
200
- },
201
- });
183
+ const store = opts.store ?? s3SnapshotStore(config);
202
184
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
203
- const base = `${config.storage.prefix}/${config.projectName}/${timestamp}`;
204
- const dumpKey = `${base}/dump.pgcustom`;
205
- log.step(`Dumping → s3://${config.storage.bucket}/${dumpKey}`);
206
- const { bytes, sha256 } = await dumpToS3(config, s3, dumpKey, pgDumpBin, dumpPartSizeFor(db.relationBytes));
185
+ const writer = await store.createSnapshot(timestamp);
186
+ const storageFiles = writer.storageFiles;
187
+ // 在转储之前拦下:目标放不了 Storage 文件却配了 Storage,转储完才发现等于白跑一遍
188
+ if (config.supabaseStorage && !storageFiles) {
189
+ throw new Error("Storage file backup needs an S3-compatible bucket; this backup destination only holds the database.");
190
+ }
191
+ const dumpKey = writer.dumpKey;
192
+ log.step(`Dumping → ${writer.describe("dump.pgcustom")}`);
193
+ const { bytes, sha256 } = await dumpToSnapshot(config, writer, pgDumpBin, db.relationBytes);
207
194
  log.ok(`Database dumped (${(bytes / 1024 / 1024).toFixed(1)} MB, sha256 ${sha256.slice(0, 12)}…)`);
208
195
  // Storage 文件同步(源配置存在才做;否则只备数据库)
209
196
  let storageResult = null;
210
- if (config.supabaseStorage) {
197
+ if (config.supabaseStorage && storageFiles) {
211
198
  log.step("Syncing Storage files…");
212
199
  const synced = await syncStorage(config.supabaseStorage, {
213
- client: s3,
214
- bucket: config.storage.bucket,
215
- base, // syncStorage 内部会加 /storage/<bucket>/<key>
200
+ client: storageFiles.s3,
201
+ bucket: storageFiles.bucket,
202
+ base: storageFiles.base, // syncStorage 内部会加 /storage/<bucket>/<key>
216
203
  });
217
204
  // v2 bucket 属性采集,通道 = 已有的只读库连接(per-file 元数据已随 GetObject
218
205
  // 响应在 syncStorage 内原子捕获)。失败只降级不报错:storage schema 读不到
@@ -257,26 +244,9 @@ export async function runBackup(config) {
257
244
  // RECOVERY.md(PRD §5.9.2.1)先于 manifest 落桶:manifest 是快照的"完成标记"
258
245
  // (演练/恢复按它找快照)——先写手册再写标记,"快照完成"就蕴含"手册在场";
259
246
  // 手册写失败 = 备份失败(同桶同权限,与 manifest 自身的写入风险一致)。
260
- await s3.send(new PutObjectCommand({
261
- Bucket: config.storage.bucket,
262
- Key: `${base}/RECOVERY.md`,
263
- Body: renderRecoveryDoc(manifest, {
264
- snapshot: timestamp,
265
- bucket: config.storage.bucket,
266
- endpoint: config.storage.endpoint,
267
- region: config.storage.region,
268
- prefix: config.storage.prefix,
269
- projectName: config.projectName,
270
- }),
271
- ContentType: "text/markdown",
272
- }));
273
- log.ok(`Recovery runbook written → s3://${config.storage.bucket}/${base}/RECOVERY.md`);
274
- await s3.send(new PutObjectCommand({
275
- Bucket: config.storage.bucket,
276
- Key: `${base}/manifest.json`,
277
- Body: JSON.stringify(manifest, null, 2),
278
- ContentType: "application/json",
279
- }));
280
- log.ok(`Manifest written → s3://${config.storage.bucket}/${base}/manifest.json`);
247
+ await writer.writeRecoveryDoc(manifest);
248
+ log.ok(`Recovery runbook written → ${writer.describe("RECOVERY.md")}`);
249
+ await writer.writeManifest(manifest);
250
+ log.ok(`Manifest written → ${writer.describe("manifest.json")}`);
281
251
  return manifest;
282
252
  }
package/dist/config.d.ts CHANGED
@@ -26,9 +26,15 @@ export interface BackupConfig {
26
26
  databaseUrl: string;
27
27
  projectName: string;
28
28
  schemas: string[];
29
- storage: StorageConfig;
29
+ storage?: StorageConfig;
30
30
  supabaseStorage?: SupabaseStorageConfig;
31
31
  }
32
+ /** CLI 读出来的配置一定带桶:loadConfig 缺了桶直接报错,所以返回类型里 storage 是必填 */
33
+ export type LoadedBackupConfig = BackupConfig & {
34
+ storage: StorageConfig;
35
+ };
36
+ /** 需要桶的地方先过这一道:没配桶时给一句能看懂的报错,而不是读 undefined 的属性。 */
37
+ export declare function requireBucket(config: BackupConfig): StorageConfig;
32
38
  interface RawConfig {
33
39
  databaseUrl?: string;
34
40
  projectName?: string;
@@ -40,5 +46,5 @@ interface RawConfig {
40
46
  export declare function loadConfig(opts: {
41
47
  configPath: string;
42
48
  overrides: RawConfig;
43
- }): Promise<BackupConfig>;
49
+ }): Promise<LoadedBackupConfig>;
44
50
  export {};
package/dist/config.js CHANGED
@@ -1,4 +1,11 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ /** 需要桶的地方先过这一道:没配桶时给一句能看懂的报错,而不是读 undefined 的属性。 */
3
+ export function requireBucket(config) {
4
+ if (!config.storage) {
5
+ throw new Error("No S3-compatible bucket configured (storage) — set storage.bucket and its keys.");
6
+ }
7
+ return config.storage;
8
+ }
2
9
  function fromEnv() {
3
10
  const env = process.env;
4
11
  const storage = {};
package/dist/drill.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
  /**
4
5
  * 无放回随机抽样:部分 Fisher-Yates,只洗出前 n 位(O(n) 次交换,不必全 shuffle)。
5
6
  * 此前恒取 manifest 顺序的前 CAP 个 → 超过 CAP 的项目里排序靠后的文件永远不被校验;
@@ -44,8 +45,10 @@ export interface SandboxTuning {
44
45
  settings: string[];
45
46
  /** docker --shm-size 的值;并行建索引走动态共享内存,要跟 maintenance_work_mem 一起长 */
46
47
  shmSize: string;
48
+ /** pg_restore --jobs;1 = 单连接 */
49
+ restoreJobs: number;
47
50
  }
48
- export declare function sandboxTuning(totalMemBytes: number): SandboxTuning;
51
+ export declare function sandboxTuning(totalMemBytes: number, cpus?: number): SandboxTuning;
49
52
  export declare function sandboxDataVolumeTarget(major: number): string;
50
53
  /** post-data 第二遍恢复的结果:用户对象失败 vs Supabase 托管对象的沙箱预期跳过。 */
51
54
  interface PostDataResult {
@@ -90,5 +93,7 @@ export declare function runDrill(config: BackupConfig, opts?: {
90
93
  appCheckCommand?: string;
91
94
  keepSandboxOnFailure?: boolean;
92
95
  supervision?: DrillSupervision;
96
+ /** 快照从哪读;省略 = config.storage 指的 S3 兼容桶 */
97
+ store?: SnapshotStore;
93
98
  }): Promise<DrillReport>;
94
99
  export {};
package/dist/drill.js CHANGED
@@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { parseManifest } from "./manifest.js";
7
- import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
7
+ import { hashObject } from "./snapshots.js";
8
+ import { s3SnapshotStore } from "./snapshot-store.js";
8
9
  import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, installSandboxShim, } from "./restore-engine.js";
9
10
  import { connectPg } from "./supabase-ca.js";
10
11
  import { localPgRestoreMajor, restoreToolBreaksOnTarget, sandboxMajorFor } from "./pgbin.js";
@@ -60,23 +61,31 @@ const MIB = 1024 * 1024;
60
61
  function clampMib(bytes, minMib, maxMib) {
61
62
  return Math.min(maxMib, Math.max(minMib, Math.floor(bytes / MIB)));
62
63
  }
63
- /** Docker 引擎可用内存(字节);问不到就返回 0,让 sandboxTuning 落到下限值,宁慢勿炸。 */
64
- async function dockerMemoryBudget() {
64
+ /** Docker 引擎可用内存(字节)和 CPU 数;问不到就返回 0,让 sandboxTuning 落到下限值、单连接,宁慢勿炸。 */
65
+ async function dockerResources() {
65
66
  try {
66
67
  // 探测是可选的,绝不能卡住演练:守护进程假死时 5 秒后放弃,落到下限值继续跑
67
- const { stdout } = await execFileAsync("docker", ["info", "--format", "{{.MemTotal}}"], {
68
+ const { stdout } = await execFileAsync("docker", ["info", "--format", "{{.MemTotal}} {{.NCPU}}"], {
68
69
  timeout: 5_000,
69
70
  });
70
- const bytes = Number.parseInt(stdout.trim(), 10);
71
- return Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
71
+ const [memoryBytes, cpus] = stdout.trim().split(/\s+/).map((value) => Number.parseInt(value, 10));
72
+ const positive = (value) => (value !== undefined && Number.isFinite(value) && value > 0 ? value : 0);
73
+ return { memoryBytes: positive(memoryBytes), cpus: positive(cpus) };
72
74
  }
73
75
  catch {
74
- return 0;
76
+ return { memoryBytes: 0, cpus: 0 };
75
77
  }
76
78
  }
77
- export function sandboxTuning(totalMemBytes) {
79
+ export function sandboxTuning(totalMemBytes, cpus = 1) {
80
+ const budgetMib = Math.floor(totalMemBytes / MIB);
78
81
  const sharedBuffersMib = clampMib(totalMemBytes / 4, 256, 2048);
79
82
  const maintenanceWorkMemMib = clampMib(totalMemBytes / 8, 256, 1024);
83
+ // 并行恢复时每一路都可能同时在建索引,各自吃满 maintenance_work_mem:路数 × maintenance_work_mem
84
+ // 加上 shared_buffers 不超过预算的一半,另一半留给 pg_restore、Docker 和同机的其他任务
85
+ // (8 GB 的 worker 正好 2 路,2026-10-17 在它上面实测 -j2 没出内存问题)。
86
+ // 再封顶 4 路:pg_restore 的并行度受表和索引个数限制,再多收益很小,演练还常跑在用户自己的电脑上。
87
+ const memoryBoundJobs = Math.floor((budgetMib / 2 - sharedBuffersMib) / maintenanceWorkMemMib);
88
+ const restoreJobs = Math.max(1, Math.min(cpus, memoryBoundJobs, 4));
80
89
  return {
81
90
  settings: [
82
91
  "fsync=off",
@@ -91,8 +100,9 @@ export function sandboxTuning(totalMemBytes) {
91
100
  ],
92
101
  // Docker 默认 /dev/shm 只有 64 MB;并行建索引(pgvector 的 HNSW 尤其)走动态共享内存,
93
102
  // 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`,
103
+ // 每一路并行恢复给 maintenance_work_mem 的两倍、至少 1 GB;tmpfs 只按实际用量占内存,给大不吃亏。
104
+ shmSize: `${Math.max(1024, maintenanceWorkMemMib * 2 * restoreJobs)}m`,
105
+ restoreJobs,
96
106
  };
97
107
  }
98
108
  function validateSupervision(supervision, appCheckCommand, keepSandboxOnFailure) {
@@ -132,7 +142,8 @@ async function startEphemeralPostgres(image, major, resourceName) {
132
142
  }
133
143
  }
134
144
  log.step(`Starting ephemeral ${image}…`);
135
- const tuning = sandboxTuning(await dockerMemoryBudget());
145
+ const resources = await dockerResources();
146
+ const tuning = sandboxTuning(resources.memoryBytes, resources.cpus);
136
147
  const { stdout } = await execFileAsync("docker", [
137
148
  "run",
138
149
  "-d",
@@ -187,7 +198,7 @@ async function startEphemeralPostgres(image, major, resourceName) {
187
198
  "postgres",
188
199
  "-q",
189
200
  ]);
190
- return { containerId, port, connString, volumeName };
201
+ return { containerId, port, connString, volumeName, restoreJobs: tuning.restoreJobs };
191
202
  }
192
203
  catch {
193
204
  await new Promise((r) => setTimeout(r, 500));
@@ -222,8 +233,8 @@ export function postDataResult(code, stderr) {
222
233
  * 的托管对象跳过如实返回。pre-data 的 schema 冲突跳过(dump 自带 CREATE SCHEMA,
223
234
  * 容器恒有 public)是非事件,不计入报告。
224
235
  */
225
- async function pgRestore(dumpPath, connString, dumpedSchemas) {
226
- const result = await restoreDatabaseArtifact({ dumpPath, connString, target: "sandbox", dumpedSchemas });
236
+ async function pgRestore(dumpPath, connString, dumpedSchemas, jobs) {
237
+ const result = await restoreDatabaseArtifact({ dumpPath, connString, target: "sandbox", dumpedSchemas, jobs });
227
238
  if (result.preData.failures.length) {
228
239
  throw new Error(`pg_restore failed: ${result.preData.failures.join(" | ")}`);
229
240
  }
@@ -319,16 +330,15 @@ export async function verifyRestored(connString, manifest) {
319
330
  * Storage 完整性校验:从备份桶重新读回文件、比对 manifest 里记录的 sha256,
320
331
  * 证明备份的文件副本真实存在且未损坏。文件多时抽样(默认上限),避免大量 egress。
321
332
  */
322
- async function verifyStorage(s3, config, manifest, verifyAll) {
333
+ async function verifyStorage(location, manifest, verifyAll) {
323
334
  const files = manifest.storage.files;
324
335
  const sample = verifyAll ? files : sampleStorageFiles(files, STORAGE_SAMPLE_CAP);
325
- // 备份写入时的键:<snapshot base>/storage/<bucket>/<key>,base 从 dump.key 反推
326
- const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
336
+ // 备份写入时的键:<snapshot base>/storage/<bucket>/<key>
327
337
  const mismatches = [];
328
338
  for (const f of sample) {
329
- const objectKey = `${base}/storage/${f.bucket}/${f.key}`;
339
+ const objectKey = `${location.base}/storage/${f.bucket}/${f.key}`;
330
340
  try {
331
- const { sha256 } = await hashObject(s3, config.storage.bucket, objectKey);
341
+ const { sha256 } = await hashObject(location.s3, location.bucket, objectKey);
332
342
  if (sha256 !== f.sha256)
333
343
  mismatches.push(`${f.bucket}/${f.key}`);
334
344
  }
@@ -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({
@@ -555,17 +569,17 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
555
569
  /** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
556
570
  export async function runDrill(config, opts = {}) {
557
571
  validateSupervision(opts.supervision, opts.appCheckCommand, opts.keepSandboxOnFailure);
558
- const s3 = targetClient(config);
559
- const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
560
- const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
572
+ const store = opts.store ?? s3SnapshotStore(config);
573
+ const reader = await store.openSnapshot(opts.snapshot);
574
+ const snapshot = reader.name;
561
575
  log.step(`Drilling snapshot ${snapshot}`);
562
- const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
576
+ const manifest = parseManifest(await reader.readManifest());
563
577
  const workdir = opts.supervision?.workdir ?? await mkdtemp(join(tmpdir(), "backupdrill-"));
564
578
  const dumpPath = join(workdir, "dump.pgcustom");
565
579
  try {
566
580
  await opts.supervision?.observe({ phase: "download", dumpBytes: manifest.dump.bytes });
567
581
  log.step("Downloading dump…");
568
- const { sha256: sha } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
582
+ const { sha256: sha } = await reader.downloadDump(manifest, dumpPath);
569
583
  const integrityPass = sha === manifest.dump.sha256;
570
584
  const preChecks = [
571
585
  {
@@ -591,7 +605,14 @@ export async function runDrill(config, opts = {}) {
591
605
  if (manifest.storage && manifest.storage.files.length) {
592
606
  await opts.supervision?.observe({ phase: "storage" });
593
607
  log.step("Verifying Storage files…");
594
- preChecks.push(await verifyStorage(s3, config, manifest, opts.verifyAllFiles ?? false));
608
+ const location = reader.storageFiles(manifest);
609
+ preChecks.push(location
610
+ ? await verifyStorage(location, manifest, opts.verifyAllFiles ?? false)
611
+ : {
612
+ name: "storage file integrity",
613
+ pass: false,
614
+ detail: "the manifest lists Storage files, but this snapshot location cannot hold them",
615
+ });
595
616
  }
596
617
  return await drillDump(dumpPath, manifest, snapshot, preChecks, {
597
618
  appCheckCommand: opts.appCheckCommand,
package/dist/lib.d.ts CHANGED
@@ -5,9 +5,11 @@ export { runRestore } from "./restore.js";
5
5
  export { projectRefOf, assertNoHostOverride, libpqChildEnv } from "./restore-engine.js";
6
6
  export { sameDatabaseTarget } from "./restore.js";
7
7
  export { runEstimate, project } from "./estimate.js";
8
- export { loadConfig } from "./config.js";
8
+ export { loadConfig, requireBucket } from "./config.js";
9
9
  export { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
10
- export type { BackupConfig, StorageConfig, SupabaseStorageConfig, } from "./config.js";
10
+ export { s3SnapshotStore } from "./snapshot-store.js";
11
+ export type { SnapshotStore, SnapshotWriter, SnapshotReader, DumpUpload, StorageFilesLocation, } from "./snapshot-store.js";
12
+ export type { BackupConfig, LoadedBackupConfig, StorageConfig, SupabaseStorageConfig, } from "./config.js";
11
13
  export type { BucketAttrs, ExtensionInfo, Manifest, StorageFile, TableStat } from "./manifest.js";
12
14
  export type { DrillReport, DrillCheck, DrillSupervision, DrillPhase } from "./drill.js";
13
15
  export type { RestoreResult } from "./restore.js";
package/dist/lib.js CHANGED
@@ -10,5 +10,7 @@ export { runRestore } from "./restore.js";
10
10
  export { projectRefOf, assertNoHostOverride, libpqChildEnv } from "./restore-engine.js";
11
11
  export { sameDatabaseTarget } from "./restore.js";
12
12
  export { runEstimate, project } from "./estimate.js";
13
- export { loadConfig } from "./config.js";
13
+ export { loadConfig, requireBucket } from "./config.js";
14
14
  export { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
15
+ // 快照位置接口:备份、演练、恢复都可以传入自己的实现(托管 worker 的 Google Drive 目标)
16
+ export { s3SnapshotStore } from "./snapshot-store.js";
@@ -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
@@ -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.2.5",
3
+ "version": "2.4.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": {