backupdrill 2.3.0 → 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/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 的项目里排序靠后的文件永远不被校验;
@@ -92,5 +93,7 @@ export declare function runDrill(config: BackupConfig, opts?: {
92
93
  appCheckCommand?: string;
93
94
  keepSandboxOnFailure?: boolean;
94
95
  supervision?: DrillSupervision;
96
+ /** 快照从哪读;省略 = config.storage 指的 S3 兼容桶 */
97
+ store?: SnapshotStore;
95
98
  }): Promise<DrillReport>;
96
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";
@@ -329,16 +330,15 @@ export async function verifyRestored(connString, manifest) {
329
330
  * Storage 完整性校验:从备份桶重新读回文件、比对 manifest 里记录的 sha256,
330
331
  * 证明备份的文件副本真实存在且未损坏。文件多时抽样(默认上限),避免大量 egress。
331
332
  */
332
- async function verifyStorage(s3, config, manifest, verifyAll) {
333
+ async function verifyStorage(location, manifest, verifyAll) {
333
334
  const files = manifest.storage.files;
334
335
  const sample = verifyAll ? files : sampleStorageFiles(files, STORAGE_SAMPLE_CAP);
335
- // 备份写入时的键:<snapshot base>/storage/<bucket>/<key>,base 从 dump.key 反推
336
- const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
336
+ // 备份写入时的键:<snapshot base>/storage/<bucket>/<key>
337
337
  const mismatches = [];
338
338
  for (const f of sample) {
339
- const objectKey = `${base}/storage/${f.bucket}/${f.key}`;
339
+ const objectKey = `${location.base}/storage/${f.bucket}/${f.key}`;
340
340
  try {
341
- const { sha256 } = await hashObject(s3, config.storage.bucket, objectKey);
341
+ const { sha256 } = await hashObject(location.s3, location.bucket, objectKey);
342
342
  if (sha256 !== f.sha256)
343
343
  mismatches.push(`${f.bucket}/${f.key}`);
344
344
  }
@@ -569,17 +569,17 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
569
569
  /** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
570
570
  export async function runDrill(config, opts = {}) {
571
571
  validateSupervision(opts.supervision, opts.appCheckCommand, opts.keepSandboxOnFailure);
572
- const s3 = targetClient(config);
573
- const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
574
- 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;
575
575
  log.step(`Drilling snapshot ${snapshot}`);
576
- const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
576
+ const manifest = parseManifest(await reader.readManifest());
577
577
  const workdir = opts.supervision?.workdir ?? await mkdtemp(join(tmpdir(), "backupdrill-"));
578
578
  const dumpPath = join(workdir, "dump.pgcustom");
579
579
  try {
580
580
  await opts.supervision?.observe({ phase: "download", dumpBytes: manifest.dump.bytes });
581
581
  log.step("Downloading dump…");
582
- const { sha256: sha } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
582
+ const { sha256: sha } = await reader.downloadDump(manifest, dumpPath);
583
583
  const integrityPass = sha === manifest.dump.sha256;
584
584
  const preChecks = [
585
585
  {
@@ -605,7 +605,14 @@ export async function runDrill(config, opts = {}) {
605
605
  if (manifest.storage && manifest.storage.files.length) {
606
606
  await opts.supervision?.observe({ phase: "storage" });
607
607
  log.step("Verifying Storage files…");
608
- 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
+ });
609
616
  }
610
617
  return await drillDump(dumpPath, manifest, snapshot, preChecks, {
611
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";
package/dist/restore.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { BackupConfig } from "./config.js";
2
2
  import type { Manifest } from "./manifest.js";
3
+ import { type SnapshotStore } from "./snapshot-store.js";
3
4
  import { type DrillCheck } from "./drill.js";
4
5
  import { type StorageRestoreSummary } from "./storage-restore.js";
5
6
  /** 归档可读性要求:pg_restore 必须 ≥ max(写入工具版本, 源服务端版本)——格式跟随写入工具。 */
@@ -88,4 +89,6 @@ export declare function runRestore(config: BackupConfig, opts: {
88
89
  skipStorage?: boolean;
89
90
  snapshot?: string;
90
91
  storageDir?: string;
92
+ /** 快照从哪读;省略 = config.storage 指的 S3 兼容桶 */
93
+ store?: SnapshotStore;
91
94
  }): Promise<RestoreResult>;
package/dist/restore.js CHANGED
@@ -5,7 +5,7 @@ import { join, dirname, resolve, sep } from "node:path";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { GetObjectCommand } from "@aws-sdk/client-s3";
7
7
  import { parseManifest } from "./manifest.js";
8
- import { targetClient, resolveSnapshot, getObjectText, downloadToFile, } from "./snapshots.js";
8
+ import { s3SnapshotStore } from "./snapshot-store.js";
9
9
  import { restoreDatabaseArtifact, installExtensions, projectRefOf, refFromStorageEndpoint, assertNoHostOverride, normalizeHost, } from "./restore-engine.js";
10
10
  import { verifyRestored } from "./drill.js";
11
11
  import { restoreStorage, planStorageRestore, } from "./storage-restore.js";
@@ -335,11 +335,10 @@ export async function runRestore(config, opts) {
335
335
  assertNoHostOverride(config.databaseUrl);
336
336
  if (opts.targetDatabaseUrl)
337
337
  assertNoHostOverride(opts.targetDatabaseUrl);
338
- const s3 = targetClient(config);
339
- const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
340
- const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
341
- const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
342
- const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
338
+ const store = opts.store ?? s3SnapshotStore(config);
339
+ const reader = await store.openSnapshot(opts.snapshot);
340
+ const snapshot = reader.name;
341
+ const manifest = parseManifest(await reader.readManifest());
343
342
  // Storage 目标构造即校验(https + 恰为 <ref>.supabase.co):service key 只发往
344
343
  // 已验证的 Supabase 项目源;目标库连接串拒绝 host 覆盖(身份判定=实际写入目标)
345
344
  // 两个字段必须成对:只给其一时静默退回"本地下载"会让库级调用方把受保护文件
@@ -389,6 +388,11 @@ export async function runRestore(config, opts) {
389
388
  // 这次运行会不会碰 Storage(回传到目标,或下载到本地)。只有会碰时,空目标门才该
390
389
  // 提示"库已恢复、只重试 Storage";skipStorage 的纯数据库运行(托管恢复)不该看到这句。
391
390
  const alsoRestoringStorage = !!manifest.storage && !opts.skipStorage;
391
+ const storageSource = manifest.storage ? reader.storageFiles(manifest) : null;
392
+ if (alsoRestoringStorage && !storageSource) {
393
+ throw new Error("the manifest lists Storage files, but this snapshot location cannot hold them — " +
394
+ "restore the database only (skip Storage).");
395
+ }
392
396
  // dry-run:只读预检后直接返回,零写入(PRD §5.3.2)
393
397
  if (opts.dryRun) {
394
398
  log.step(`Dry run for snapshot ${snapshot} — nothing will be written`);
@@ -461,7 +465,7 @@ export async function runRestore(config, opts) {
461
465
  throw new Error(toolTargetBlocker);
462
466
  log.step("Downloading dump…");
463
467
  const dumpPath = join(workdir, "dump.pgcustom");
464
- const { sha256 } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
468
+ const { sha256 } = await reader.downloadDump(manifest, dumpPath);
465
469
  // 归档完整性是恢复的前提:哈希不符还继续,只会把"备份坏了"变成一堆误导性
466
470
  // 恢复错误(演练侧同一裁决:integrity FAIL 即短路)
467
471
  if (sha256 !== manifest.dump.sha256) {
@@ -505,7 +509,7 @@ export async function runRestore(config, opts) {
505
509
  // DB-only 阶段(hosted 检查点重试):既不回传也不本地下载,Storage 单独一段跑
506
510
  }
507
511
  else if (manifest.storage && storageTarget) {
508
- const summary = await restoreStorage({ s3, bucket: config.storage.bucket, base }, storageTarget, manifest);
512
+ const summary = await restoreStorage(storageSource, storageTarget, manifest);
509
513
  result.storageSummary = summary;
510
514
  result.storageFilesWritten = summary.filesUploaded;
511
515
  log.ok(`Storage restored: ${summary.filesUploaded}/${manifest.storage.files.length} files uploaded` +
@@ -568,9 +572,9 @@ export async function runRestore(config, opts) {
568
572
  throw new Error(`unsafe storage path escapes output directory: ${f.bucket}/${f.key}`);
569
573
  }
570
574
  await mkdir(dirname(dest), { recursive: true });
571
- const res = await s3.send(new GetObjectCommand({
572
- Bucket: config.storage.bucket,
573
- Key: `${base}/storage/${f.bucket}/${f.key}`,
575
+ const res = await storageSource.s3.send(new GetObjectCommand({
576
+ Bucket: storageSource.bucket,
577
+ Key: `${storageSource.base}/storage/${f.bucket}/${f.key}`,
574
578
  }));
575
579
  await pipeline(res.Body, createWriteStream(dest));
576
580
  result.storageFilesWritten += 1;
@@ -0,0 +1,54 @@
1
+ import type { Readable } from "node:stream";
2
+ import { type S3Client } from "@aws-sdk/client-s3";
3
+ import { type BackupConfig } from "./config.js";
4
+ import type { Manifest } from "./manifest.js";
5
+ /**
6
+ * 快照放在哪里,对引擎来说只是这几个动作:建一份新快照往里写、打开一份已有快照往外读。
7
+ * 备份、演练、恢复都只经这层读写快照。CLI 只用 S3 兼容桶(s3SnapshotStore);托管服务
8
+ * 的 worker 可以传入自己的实现(Google Drive 目标,实现不进公开包)。
9
+ */
10
+ export interface SnapshotStore {
11
+ /** name = 快照名(备份开始时的 UTC 时间戳) */
12
+ createSnapshot(name: string): Promise<SnapshotWriter>;
13
+ /** name 省略 = 最新一份 */
14
+ openSnapshot(name?: string): Promise<SnapshotReader>;
15
+ }
16
+ /** 快照里 Storage 文件的位置(<base>/storage/<bucket>/<key>)。只有 S3 兼容目标能放 Storage 文件。 */
17
+ export interface StorageFilesLocation {
18
+ s3: S3Client;
19
+ bucket: string;
20
+ base: string;
21
+ }
22
+ export interface DumpUpload {
23
+ /** 转储全部写完才 resolve;任何失败都 reject */
24
+ done: Promise<void>;
25
+ /** 中止写入,尽量不留下半截文件 */
26
+ abort(): Promise<void>;
27
+ }
28
+ export interface SnapshotWriter {
29
+ /** 记进 manifest.dump.key 的值 */
30
+ readonly dumpKey: string;
31
+ /** 这份快照能不能放 Storage 文件;不能时为 null */
32
+ readonly storageFiles: StorageFilesLocation | null;
33
+ /** 日志里显示的位置 */
34
+ describe(file: string): string;
35
+ /** relationBytes = 要转储的表的磁盘占用(量不出为 null),实现可据此决定分块 */
36
+ uploadDump(body: Readable, relationBytes: number | null): DumpUpload;
37
+ /** RECOVERY.md 写的是"从这个位置怎么恢复",所以由各实现自己渲染 */
38
+ writeRecoveryDoc(manifest: Manifest): Promise<void>;
39
+ /** manifest 是快照的"完成标记",调用方保证最后写 */
40
+ writeManifest(manifest: Manifest): Promise<void>;
41
+ }
42
+ export interface SnapshotReader {
43
+ readonly name: string;
44
+ readManifest(): Promise<string>;
45
+ /** 断点续传下载转储到本地文件,返回实际落盘的字节数与 sha256 */
46
+ downloadDump(manifest: Manifest, dest: string): Promise<{
47
+ bytes: number;
48
+ sha256: string;
49
+ }>;
50
+ /** 这份快照的 Storage 文件从哪读;实现不支持 Storage 文件时为 null */
51
+ storageFiles(manifest: Manifest): StorageFilesLocation | null;
52
+ }
53
+ /** S3 兼容桶(S3 / R2 / B2 / Wasabi)。行为与抽出这层之前逐字一致。 */
54
+ export declare function s3SnapshotStore(config: BackupConfig): SnapshotStore;
@@ -0,0 +1,76 @@
1
+ import { PutObjectCommand } from "@aws-sdk/client-s3";
2
+ import { Upload } from "@aws-sdk/lib-storage";
3
+ import { requireBucket } from "./config.js";
4
+ import { targetClient, resolveSnapshot, getObjectText, downloadToFile } from "./snapshots.js";
5
+ import { renderRecoveryDoc } from "./recovery-doc.js";
6
+ import { dumpPartSizeFor } from "./part-size.js";
7
+ /** 写入时的键都在 <prefix>/<projectName>/<快照名>/ 下;读取时 Storage 文件的 base 从 dump.key 反推。 */
8
+ function storageBaseOf(manifest) {
9
+ return manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
10
+ }
11
+ /** S3 兼容桶(S3 / R2 / B2 / Wasabi)。行为与抽出这层之前逐字一致。 */
12
+ export function s3SnapshotStore(config) {
13
+ const storage = requireBucket(config);
14
+ let client = null;
15
+ // 客户端推迟到第一次真正读写时才建:与抽层之前一致,配置错误先由前面的检查报出来
16
+ const s3 = () => (client ??= targetClient(config));
17
+ return {
18
+ async createSnapshot(name) {
19
+ const base = `${storage.prefix}/${config.projectName}/${name}`;
20
+ const location = (file) => `s3://${storage.bucket}/${base}/${file}`;
21
+ return {
22
+ dumpKey: `${base}/dump.pgcustom`,
23
+ storageFiles: { s3: s3(), bucket: storage.bucket, base },
24
+ describe: location,
25
+ uploadDump(body, relationBytes) {
26
+ const upload = new Upload({
27
+ client: s3(),
28
+ partSize: dumpPartSizeFor(relationBytes),
29
+ params: {
30
+ Bucket: storage.bucket,
31
+ Key: `${base}/dump.pgcustom`,
32
+ Body: body,
33
+ ContentType: "application/octet-stream",
34
+ },
35
+ });
36
+ return {
37
+ done: upload.done().then(() => { }),
38
+ abort: () => upload.abort(),
39
+ };
40
+ },
41
+ async writeRecoveryDoc(manifest) {
42
+ await s3().send(new PutObjectCommand({
43
+ Bucket: storage.bucket,
44
+ Key: `${base}/RECOVERY.md`,
45
+ Body: renderRecoveryDoc(manifest, {
46
+ snapshot: name,
47
+ bucket: storage.bucket,
48
+ endpoint: storage.endpoint,
49
+ region: storage.region,
50
+ prefix: storage.prefix,
51
+ projectName: config.projectName,
52
+ }),
53
+ ContentType: "text/markdown",
54
+ }));
55
+ },
56
+ async writeManifest(manifest) {
57
+ await s3().send(new PutObjectCommand({
58
+ Bucket: storage.bucket,
59
+ Key: `${base}/manifest.json`,
60
+ Body: JSON.stringify(manifest, null, 2),
61
+ ContentType: "application/json",
62
+ }));
63
+ },
64
+ };
65
+ },
66
+ async openSnapshot(name) {
67
+ const prefix = await resolveSnapshot(s3(), config, name);
68
+ return {
69
+ name: prefix.replace(/\/$/, "").split("/").pop(),
70
+ readManifest: () => getObjectText(s3(), storage.bucket, `${prefix}manifest.json`),
71
+ downloadDump: (manifest, dest) => downloadToFile(s3(), storage.bucket, manifest.dump.key, dest),
72
+ storageFiles: (manifest) => ({ s3: s3(), bucket: storage.bucket, base: storageBaseOf(manifest) }),
73
+ };
74
+ },
75
+ };
76
+ }
@@ -1,5 +1,5 @@
1
1
  import { S3Client } from "@aws-sdk/client-s3";
2
- import type { BackupConfig } from "./config.js";
2
+ import { type BackupConfig } from "./config.js";
3
3
  /** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
4
4
  export declare function targetClient(config: BackupConfig): S3Client;
5
5
  /** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
package/dist/snapshots.js CHANGED
@@ -4,28 +4,31 @@ import { pipeline } from "node:stream/promises";
4
4
  import { Writable } from "node:stream";
5
5
  import { log } from "./log.js";
6
6
  import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
7
+ import { requireBucket } from "./config.js";
7
8
  /** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
8
9
  export function targetClient(config) {
10
+ const storage = requireBucket(config);
9
11
  return new S3Client({
10
- region: config.storage.region,
11
- endpoint: config.storage.endpoint,
12
- forcePathStyle: config.storage.forcePathStyle,
12
+ region: storage.region,
13
+ endpoint: storage.endpoint,
14
+ forcePathStyle: storage.forcePathStyle,
13
15
  credentials: {
14
- accessKeyId: config.storage.accessKeyId,
15
- secretAccessKey: config.storage.secretAccessKey,
16
+ accessKeyId: storage.accessKeyId,
17
+ secretAccessKey: storage.secretAccessKey,
16
18
  },
17
19
  });
18
20
  }
19
21
  /** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
20
22
  export async function resolveSnapshot(s3, config, explicit) {
21
- const root = `${config.storage.prefix}/${config.projectName}/`;
23
+ const storage = requireBucket(config);
24
+ const root = `${storage.prefix}/${config.projectName}/`;
22
25
  if (explicit)
23
26
  return `${root}${explicit}/`;
24
27
  const prefixes = [];
25
28
  let token;
26
29
  do {
27
30
  const res = await s3.send(new ListObjectsV2Command({
28
- Bucket: config.storage.bucket,
31
+ Bucket: storage.bucket,
29
32
  Prefix: root,
30
33
  Delimiter: "/",
31
34
  ContinuationToken: token,
@@ -37,7 +40,7 @@ export async function resolveSnapshot(s3, config, explicit) {
37
40
  token = res.IsTruncated ? res.NextContinuationToken : undefined;
38
41
  } while (token);
39
42
  if (!prefixes.length) {
40
- throw new Error(`No snapshots found under s3://${config.storage.bucket}/${root}. Run a backup first.`);
43
+ throw new Error(`No snapshots found under s3://${storage.bucket}/${root}. Run a backup first.`);
41
44
  }
42
45
  prefixes.sort();
43
46
  return prefixes[prefixes.length - 1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.3.0",
3
+ "version": "2.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": {