backupdrill 2.2.0 → 2.2.2

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,13 +1,5 @@
1
1
  import type { BackupConfig } from "./config.js";
2
2
  import type { Manifest } from "./manifest.js";
3
- /**
4
- * Parse the major version out of `pg_dump --version` output.
5
- *
6
- * MUST tolerate a trailing vendor suffix: Homebrew/macOS prints `pg_dump (PostgreSQL) 17.2`, but
7
- * Debian/PGDG (what the deployed worker image uses) prints
8
- * `pg_dump (PostgreSQL) 17.10 (Debian 17.10-1.pgdg120+1)`. Anchoring the match to end-of-string
9
- * therefore breaks in production while passing on a dev Mac. Anchor on the `(PostgreSQL)` marker
10
- * instead, falling back to the first dotted version token for non-standard builds.
11
- */
12
- export declare function parsePgDumpMajor(raw: string): number | null;
3
+ import { parsePgDumpMajor } from "./pgbin.js";
4
+ export { parsePgDumpMajor };
13
5
  export declare function runBackup(config: BackupConfig): Promise<Manifest>;
package/dist/backup.js CHANGED
@@ -11,6 +11,11 @@ import { renderRecoveryDoc } from "./recovery-doc.js";
11
11
  import { projectRefOf, refFromStorageEndpoint, assertNoHostOverride } from "./restore-engine.js";
12
12
  import { log } from "./log.js";
13
13
  import { TOOL_VERSION } from "./version.js";
14
+ import { parsePgDumpMajor } from "./pgbin.js";
15
+ import { relationBytes } from "./estimate.js";
16
+ import { dumpPartSizeFor } from "./part-size.js";
17
+ // 解析函数搬到了 pgbin(演练与恢复都要读 pg_restore 版本,放这里会成环);保留旧出口
18
+ export { parsePgDumpMajor };
14
19
  import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
15
20
  import { libpqChildEnv, urlSslModeOf } from "./restore-engine.js";
16
21
  const execFileAsync = promisify(execFile);
@@ -18,19 +23,6 @@ function majorOf(serverVersionNum) {
18
23
  // server_version_num: 150004 → 15, 170002 → 17
19
24
  return Math.floor(serverVersionNum / 10000);
20
25
  }
21
- /**
22
- * Parse the major version out of `pg_dump --version` output.
23
- *
24
- * MUST tolerate a trailing vendor suffix: Homebrew/macOS prints `pg_dump (PostgreSQL) 17.2`, but
25
- * Debian/PGDG (what the deployed worker image uses) prints
26
- * `pg_dump (PostgreSQL) 17.10 (Debian 17.10-1.pgdg120+1)`. Anchoring the match to end-of-string
27
- * therefore breaks in production while passing on a dev Mac. Anchor on the `(PostgreSQL)` marker
28
- * instead, falling back to the first dotted version token for non-standard builds.
29
- */
30
- export function parsePgDumpMajor(raw) {
31
- const match = raw.match(/\(PostgreSQL\)\s+(\d+)/) ?? raw.match(/(\d+)(?:\.\d+)+/);
32
- return match ? Number(match[1]) : null;
33
- }
34
26
  async function pgDumpVersion(bin) {
35
27
  try {
36
28
  const { stdout } = await execFileAsync(bin, ["--version"]);
@@ -76,6 +68,15 @@ async function inspectDatabase(databaseUrl, schemas) {
76
68
  join pg_namespace n on n.oid = e.extnamespace
77
69
  where e.extname <> 'plpgsql'
78
70
  order by e.extname`);
71
+ // 量体积失败不该让备份失败:分片退回保底值,200 GiB 以内的转储照常上传
72
+ let measuredBytes = null;
73
+ try {
74
+ measuredBytes = await relationBytes(client, schemas);
75
+ }
76
+ catch (error) {
77
+ log.warn(`Could not measure database size (${error.message}); ` +
78
+ `uploading with the baseline part size, which fits a dump of up to 200 GiB.`);
79
+ }
79
80
  return {
80
81
  serverVersion: version.rows[0].server_version,
81
82
  serverVersionNum: Number(versionNum.rows[0].server_version_num),
@@ -85,6 +86,7 @@ async function inspectDatabase(databaseUrl, schemas) {
85
86
  estimatedRows: Number(r.estimated_rows),
86
87
  })),
87
88
  extensions: extensions.rows,
89
+ relationBytes: measuredBytes,
88
90
  };
89
91
  }
90
92
  finally {
@@ -92,7 +94,7 @@ async function inspectDatabase(databaseUrl, schemas) {
92
94
  }
93
95
  }
94
96
  /** 流式跑 pg_dump,边写 S3 边算 sha256 与字节数。返回校验和与大小。 */
95
- async function dumpToS3(config, s3, key, pgDumpBin) {
97
+ async function dumpToS3(config, s3, key, pgDumpBin, partSize) {
96
98
  const hash = createHash("sha256");
97
99
  let bytes = 0;
98
100
  const meter = new Transform({
@@ -134,6 +136,7 @@ async function dumpToS3(config, s3, key, pgDumpBin) {
134
136
  });
135
137
  const upload = new Upload({
136
138
  client: s3,
139
+ partSize,
137
140
  params: {
138
141
  Bucket: config.storage.bucket,
139
142
  Key: key,
@@ -198,7 +201,7 @@ export async function runBackup(config) {
198
201
  const base = `${config.storage.prefix}/${config.projectName}/${timestamp}`;
199
202
  const dumpKey = `${base}/dump.pgcustom`;
200
203
  log.step(`Dumping → s3://${config.storage.bucket}/${dumpKey}`);
201
- const { bytes, sha256 } = await dumpToS3(config, s3, dumpKey, pgDumpBin);
204
+ const { bytes, sha256 } = await dumpToS3(config, s3, dumpKey, pgDumpBin, dumpPartSizeFor(db.relationBytes));
202
205
  log.ok(`Database dumped (${(bytes / 1024 / 1024).toFixed(1)} MB, sha256 ${sha256.slice(0, 12)}…)`);
203
206
  // Storage 文件同步(源配置存在才做;否则只备数据库)
204
207
  let storageResult = null;
package/dist/drill.js CHANGED
@@ -7,6 +7,7 @@ import { parseManifest } from "./manifest.js";
7
7
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
8
8
  import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, installSandboxShim, } from "./restore-engine.js";
9
9
  import { connectPg } from "./supabase-ca.js";
10
+ import { localPgRestoreMajor, restoreToolBreaksOnTarget, sandboxMajorFor } from "./pgbin.js";
10
11
  import { log } from "./log.js";
11
12
  const execFileAsync = promisify(execFile);
12
13
  // Storage 校验默认抽样上限(避免每次演练重下全部文件产生大量 egress);超限如实记录
@@ -31,10 +32,10 @@ export function sampleStorageFiles(files, n) {
31
32
  /**
32
33
  * 沙箱镜像选择:alpine 镜像只带 contrib 扩展,装不了 pgvector——含 vector 列的
33
34
  * 备份在裸镜像里恢复必然硬崩("type vector does not exist")。manifest 记录了
34
- * vector 时换用 pgvector 官方镜像(同 major),其余维持轻量 alpine。
35
+ * vector 时换用 pgvector 官方镜像(同 major),其余维持轻量 alpine。major 由调用方给出
36
+ * (sandboxMajorFor:源库与本机 pg_restore 取大者),不一定等于源库版本。
35
37
  */
36
- function sandboxImage(manifest) {
37
- const major = parseInt(manifest.database.serverVersion, 10);
38
+ function sandboxImage(manifest, major) {
38
39
  const hasVector = (manifest.database.extensions ?? []).some((e) => e.name === "vector");
39
40
  return hasVector ? `pgvector/pgvector:pg${major}` : `postgres:${major}-alpine`;
40
41
  }
@@ -432,9 +433,25 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
432
433
  }
433
434
  const checks = [...preChecks];
434
435
  await opts.supervision?.observe({ phase: "sandbox" });
435
- const pg = await startEphemeralPostgres(sandboxImage(manifest), parseInt(manifest.database.serverVersion, 10), opts.supervision?.resourceName);
436
+ const sourceMajor = parseInt(manifest.database.serverVersion, 10);
437
+ const toolMajor = await localPgRestoreMajor();
438
+ const sandboxMajor = sandboxMajorFor(sourceMajor, toolMajor);
439
+ const pg = await startEphemeralPostgres(sandboxImage(manifest, sandboxMajor), sandboxMajor, opts.supervision?.resourceName);
436
440
  let pass = false; // 异常路径视为失败,供 finally 决定 --keep 是否保留沙箱
437
441
  try {
442
+ if (sandboxMajor !== sourceMajor) {
443
+ // 报告里写明沙箱比源库新以及原因:读报告的人要知道这次证明的是
444
+ // "能恢复进 PostgreSQL N",而不是同版本
445
+ checks.push({
446
+ name: "sandbox version",
447
+ pass: true,
448
+ detail: `restored into PostgreSQL ${sandboxMajor} (source is ${sourceMajor}): ` +
449
+ (toolMajor !== null && restoreToolBreaksOnTarget(toolMajor, sourceMajor)
450
+ ? `pg_restore ${toolMajor} sends SET transaction_timeout, which PostgreSQL ${sourceMajor} rejects`
451
+ : `output of pg_restore ${toolMajor} is only guaranteed to load into PostgreSQL ${toolMajor} or newer`) +
452
+ `; restoring into a newer major version is supported`,
453
+ });
454
+ }
438
455
  // 旧 manifest(≤0.1.1)没有 extensions 字段 → 不装任何扩展,行为与从前一致
439
456
  const extensions = manifest.database.extensions ?? [];
440
457
  const unavailable = await installExtensions(pg.connString, extensions);
@@ -1,3 +1,4 @@
1
+ import type { Client } from "pg";
1
2
  import type { BackupConfig } from "./config.js";
2
3
  /**
3
4
  * Supabase egress 定价(2026-07-10 经核实,来源见 README「Egress & cost」)。
@@ -11,6 +12,13 @@ export interface EgressPricing {
11
12
  planLabel: string;
12
13
  hardCap: boolean;
13
14
  }
15
+ /**
16
+ * 要转储的 schema 里所有表的磁盘占用。pg_total_relation_size 含索引+TOAST,是 egress
17
+ * 上界(真实转储经压缩、不含索引,更小)。备份也拿它估转储体积来定分片大小。
18
+ * 直接传 relid 而不是按名字转 regclass:统计视图读出来之后表被并发删掉,
19
+ * 按名字转会报错,按 oid 查只会得到 NULL。
20
+ */
21
+ export declare function relationBytes(client: Client, schemas: string[]): Promise<number>;
14
22
  export interface EgressProjection {
15
23
  dbBytes: number;
16
24
  storageBytes: number;
package/dist/estimate.js CHANGED
@@ -4,17 +4,22 @@ import { connectPg } from "./supabase-ca.js";
4
4
  // 超出这个月度成本就该提醒用户"备份的 egress 账单快赶上订阅费了"
5
5
  const SUBSCRIPTION_FLOOR_USD = 19;
6
6
  const GB = 1024 * 1024 * 1024;
7
+ /**
8
+ * 要转储的 schema 里所有表的磁盘占用。pg_total_relation_size 含索引+TOAST,是 egress
9
+ * 上界(真实转储经压缩、不含索引,更小)。备份也拿它估转储体积来定分片大小。
10
+ * 直接传 relid 而不是按名字转 regclass:统计视图读出来之后表被并发删掉,
11
+ * 按名字转会报错,按 oid 查只会得到 NULL。
12
+ */
13
+ export async function relationBytes(client, schemas) {
14
+ const res = await client.query(`select coalesce(sum(pg_total_relation_size(relid)), 0)::bigint as bytes
15
+ from pg_stat_user_tables
16
+ where schemaname = any($1::text[])`, [schemas]);
17
+ return Number(res.rows[0].bytes);
18
+ }
7
19
  async function measureDbBytes(databaseUrl, schemas) {
8
20
  const client = await connectPg(databaseUrl);
9
21
  try {
10
- // pg_total_relation_size 含索引+TOAST,是 egress 上界(真实转储经压缩、不含索引,更小)
11
- const res = await client.query(`select coalesce(
12
- sum(pg_total_relation_size(format('%I.%I', schemaname, relname)::regclass)),
13
- 0
14
- )::bigint as bytes
15
- from pg_stat_user_tables
16
- where schemaname = any($1::text[])`, [schemas]);
17
- return Number(res.rows[0].bytes);
22
+ return await relationBytes(client, schemas);
18
23
  }
19
24
  finally {
20
25
  await client.end();
@@ -0,0 +1,4 @@
1
+ /** 能把 expectedBytes 装进 10,000 片的最小分片大小,按 MiB 取整,不低于 5 MiB。 */
2
+ export declare function partSizeFor(expectedBytes: number): number;
3
+ /** 数据库转储的分片大小:按估算体积 × 2 算,不低于 200 GiB 转储所需;量不出体积就用保底。 */
4
+ export declare function dumpPartSizeFor(relationBytes: number | null): number;
@@ -0,0 +1,27 @@
1
+ // S3 / R2 / B2 的分片上传都限 10,000 片,除最后一片外每片至少 5 MiB。
2
+ // lib-storage 只有在知道总长度时才会自己放大分片;长度未知的流(pg_dump、
3
+ // 没传长度的 Storage 拷贝)一律按 5 MiB 切,单个对象最多约 48.8 GiB,
4
+ // 再大就在上传途中报 "Exceeded 10000 parts" 失败。
5
+ const MAX_PARTS = 10_000;
6
+ const MIB = 1024 * 1024;
7
+ const GIB = 1024 * MIB;
8
+ const MIN_PART_BYTES = 5 * MIB;
9
+ // 转储体积事先不知道,只能拿表的磁盘占用(含索引与 TOAST)去估。pg_dump 的
10
+ // custom 格式默认 gzip 压缩,实测转储只有磁盘占用的零头(LZ4 TOAST 的重复文本
11
+ // 约 0.16 倍、jsonb 约 0.53 倍);已压缩的 bytea 会以 hex 导出再 gzip,略大于 1 倍。
12
+ // 所以按 2 倍留余量。
13
+ const DUMP_SIZE_HEADROOM = 2;
14
+ // 估算总有失准的时候(比如 pg_dump 没带 zlib、转储不压缩,重复文本能膨胀上百倍),
15
+ // 所以分片再设一个保底:按 200 GiB 的转储算,也就是付费最高档每项目 100 GB 的
16
+ // 2 倍。这样托管服务里任何套餐允许的转储都传得完,和估算准不准无关。
17
+ // 代价是每次上传最多多占几十 MiB 内存(21 MiB 一片,lib-storage 默认并发 4 片)。
18
+ const BASELINE_DUMP_BYTES = 200 * GIB;
19
+ /** 能把 expectedBytes 装进 10,000 片的最小分片大小,按 MiB 取整,不低于 5 MiB。 */
20
+ export function partSizeFor(expectedBytes) {
21
+ const neededBytes = Math.ceil(Math.max(0, expectedBytes) / MAX_PARTS);
22
+ return Math.max(MIN_PART_BYTES, Math.ceil(neededBytes / MIB) * MIB);
23
+ }
24
+ /** 数据库转储的分片大小:按估算体积 × 2 算,不低于 200 GiB 转储所需;量不出体积就用保底。 */
25
+ export function dumpPartSizeFor(relationBytes) {
26
+ return partSizeFor(Math.max(BASELINE_DUMP_BYTES, (relationBytes ?? 0) * DUMP_SIZE_HEADROOM));
27
+ }
package/dist/pgbin.d.ts CHANGED
@@ -5,3 +5,36 @@
5
5
  * 绝不能把 pg_dump 当恢复器执行。
6
6
  */
7
7
  export declare function resolvePgRestoreBin(): string;
8
+ /**
9
+ * Parse the major version out of `pg_dump --version` output.
10
+ *
11
+ * MUST tolerate a trailing vendor suffix: Homebrew/macOS prints `pg_dump (PostgreSQL) 17.2`, but
12
+ * Debian/PGDG (what the deployed worker image uses) prints
13
+ * `pg_dump (PostgreSQL) 17.10 (Debian 17.10-1.pgdg120+1)`. Anchoring the match to end-of-string
14
+ * therefore breaks in production while passing on a dev Mac. Anchor on the `(PostgreSQL)` marker
15
+ * instead, falling back to the first dotted version token for non-standard builds.
16
+ */
17
+ export declare function parsePgDumpMajor(raw: string): number | null;
18
+ /** 本地 pg_restore 主版本(拿不到 → null,由调用方决定 fail-open 还是 fail-closed)。 */
19
+ export declare function localPgRestoreMajor(): Promise<number | null>;
20
+ /**
21
+ * pg_restore 17 起每次恢复都先发 `SET transaction_timeout = 0`,这个参数 PostgreSQL 17
22
+ * 才有,恢复进 16 及更老的服务端第一句就报 unrecognized configuration parameter。
23
+ * 上游定性为设计如此(pgsql-bugs 2024-10),不会修,只能绕开。
24
+ */
25
+ export declare function restoreToolBreaksOnTarget(toolMajor: number, targetMajor: number): boolean;
26
+ /**
27
+ * 恢复前的工具/目标版本闸:返回拒绝理由,可放行时返回 null。dry-run 与正式恢复共用,
28
+ * 保证两条路径的判定和给用户的出路一字不差。出路有两条:目标升到 17+;或当归档
29
+ * 本身只需要 ≤16 的 pg_restore(旧版 pg_dump 写的)时,换一个与目标同版本的旧客户端。
30
+ */
31
+ export declare function restoreToolTargetBlocker(toolMajor: number, targetMajor: number, requiredMajor: number): string | null;
32
+ /**
33
+ * 演练沙箱的主版本:源库与本机 pg_restore 取大者。pg_dump/pg_restore 的输出只保证能进
34
+ * 与工具同版本或更新的服务端(官方文档原话:不保证能载入更老的主版本)——除了
35
+ * restoreToolBreaksOnTarget 这条已知的,还有新版 pg_dump 生成旧服务端不认识的语法
36
+ * (交叉审查实测:pg_dump 18 导 PG17 的继承表)。往更新的主版本恢复是官方支持的方向,
37
+ * 反过来要求用户装与源库同版本的旧工具行不通(旧 pg_restore 读不了新 pg_dump 的归档)。
38
+ * 拿不到工具版本时退回源版本。
39
+ */
40
+ export declare function sandboxMajorFor(sourceMajor: number, toolMajor: number | null): number;
package/dist/pgbin.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
1
4
  /**
2
5
  * pg_restore 二进制解析:显式 BACKUPDRILL_PG_RESTORE 最高优先;否则从
3
6
  * BACKUPDRILL_PG_DUMP 同目录推导。推导未命中(结果仍含 pg_dump 或与原值相同,
@@ -15,3 +18,60 @@ export function resolvePgRestoreBin() {
15
18
  }
16
19
  return "pg_restore";
17
20
  }
21
+ /**
22
+ * Parse the major version out of `pg_dump --version` output.
23
+ *
24
+ * MUST tolerate a trailing vendor suffix: Homebrew/macOS prints `pg_dump (PostgreSQL) 17.2`, but
25
+ * Debian/PGDG (what the deployed worker image uses) prints
26
+ * `pg_dump (PostgreSQL) 17.10 (Debian 17.10-1.pgdg120+1)`. Anchoring the match to end-of-string
27
+ * therefore breaks in production while passing on a dev Mac. Anchor on the `(PostgreSQL)` marker
28
+ * instead, falling back to the first dotted version token for non-standard builds.
29
+ */
30
+ export function parsePgDumpMajor(raw) {
31
+ const match = raw.match(/\(PostgreSQL\)\s+(\d+)/) ?? raw.match(/(\d+)(?:\.\d+)+/);
32
+ return match ? Number(match[1]) : null;
33
+ }
34
+ /** 本地 pg_restore 主版本(拿不到 → null,由调用方决定 fail-open 还是 fail-closed)。 */
35
+ export async function localPgRestoreMajor() {
36
+ try {
37
+ const { stdout } = await execFileAsync(resolvePgRestoreBin(), ["--version"]);
38
+ return parsePgDumpMajor(stdout.trim());
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * pg_restore 17 起每次恢复都先发 `SET transaction_timeout = 0`,这个参数 PostgreSQL 17
46
+ * 才有,恢复进 16 及更老的服务端第一句就报 unrecognized configuration parameter。
47
+ * 上游定性为设计如此(pgsql-bugs 2024-10),不会修,只能绕开。
48
+ */
49
+ export function restoreToolBreaksOnTarget(toolMajor, targetMajor) {
50
+ return toolMajor >= 17 && targetMajor < 17;
51
+ }
52
+ /**
53
+ * 恢复前的工具/目标版本闸:返回拒绝理由,可放行时返回 null。dry-run 与正式恢复共用,
54
+ * 保证两条路径的判定和给用户的出路一字不差。出路有两条:目标升到 17+;或当归档
55
+ * 本身只需要 ≤16 的 pg_restore(旧版 pg_dump 写的)时,换一个与目标同版本的旧客户端。
56
+ */
57
+ export function restoreToolTargetBlocker(toolMajor, targetMajor, requiredMajor) {
58
+ if (!restoreToolBreaksOnTarget(toolMajor, targetMajor))
59
+ return null;
60
+ const olderClient = requiredMajor <= targetMajor
61
+ ? ` or point BACKUPDRILL_PG_RESTORE at a pg_restore ${targetMajor} (this archive only needs v${requiredMajor})`
62
+ : "";
63
+ return (`local pg_restore is v${toolMajor}, which sends SET transaction_timeout — a setting PostgreSQL ` +
64
+ `${targetMajor} does not have, so the restore would fail on its first statement. ` +
65
+ `Create the target on PostgreSQL 17 or newer${olderClient}.`);
66
+ }
67
+ /**
68
+ * 演练沙箱的主版本:源库与本机 pg_restore 取大者。pg_dump/pg_restore 的输出只保证能进
69
+ * 与工具同版本或更新的服务端(官方文档原话:不保证能载入更老的主版本)——除了
70
+ * restoreToolBreaksOnTarget 这条已知的,还有新版 pg_dump 生成旧服务端不认识的语法
71
+ * (交叉审查实测:pg_dump 18 导 PG17 的继承表)。往更新的主版本恢复是官方支持的方向,
72
+ * 反过来要求用户装与源库同版本的旧工具行不通(旧 pg_restore 读不了新 pg_dump 的归档)。
73
+ * 拿不到工具版本时退回源版本。
74
+ */
75
+ export function sandboxMajorFor(sourceMajor, toolMajor) {
76
+ return toolMajor === null ? sourceMajor : Math.max(sourceMajor, toolMajor);
77
+ }
package/dist/restore.js CHANGED
@@ -10,22 +10,9 @@ import { restoreDatabaseArtifact, installExtensions, projectRefOf, refFromStorag
10
10
  import { verifyRestored } from "./drill.js";
11
11
  import { restoreStorage, planStorageRestore, } from "./storage-restore.js";
12
12
  import { connectPg } from "./supabase-ca.js";
13
- import { resolvePgRestoreBin } from "./pgbin.js";
13
+ import { localPgRestoreMajor, restoreToolTargetBlocker } from "./pgbin.js";
14
14
  import { parsePgDumpMajor } from "./backup.js";
15
- import { execFile } from "node:child_process";
16
- import { promisify } from "node:util";
17
15
  import { log } from "./log.js";
18
- const execFileAsync = promisify(execFile);
19
- /** 本地 pg_restore 主版本(拿不到 → null,由引擎自己的报错兜底)。 */
20
- async function localPgRestoreMajor() {
21
- try {
22
- const { stdout } = await execFileAsync(resolvePgRestoreBin(), ["--version"]);
23
- return parsePgDumpMajor(stdout.trim());
24
- }
25
- catch {
26
- return null;
27
- }
28
- }
29
16
  /** 归档可读性要求:pg_restore 必须 ≥ max(写入工具版本, 源服务端版本)——格式跟随写入工具。 */
30
17
  export function requiredRestoreToolMajor(manifest) {
31
18
  return Math.max(parsePgDumpMajor(manifest.database.pgDumpVersion) ?? 0, parseInt(manifest.database.serverVersion, 10) || 0);
@@ -272,6 +259,13 @@ async function dryRunPreflight(config, manifest, targetUrl, storageTarget) {
272
259
  else {
273
260
  log.ok(`pg_restore tool: local v${toolMajor} ≥ archive requirement v${requiredMajor}`);
274
261
  }
262
+ const toolTargetBlocker = toolMajor !== null && Number.isFinite(targetMajor)
263
+ ? restoreToolTargetBlocker(toolMajor, targetMajor, requiredMajor)
264
+ : null;
265
+ if (toolTargetBlocker) {
266
+ blockers.push(`local pg_restore v${toolMajor} cannot restore into PostgreSQL ${targetMajor}`);
267
+ log.error(`pg_restore tool: ${toolTargetBlocker}`);
268
+ }
275
269
  const extensions = db.extensions ?? [];
276
270
  if (extensions.length) {
277
271
  const available = await client.query(`select name from pg_available_extensions where name = any($1::text[])`, [extensions.map((e) => e.name)]);
@@ -447,6 +441,11 @@ export async function runRestore(config, opts) {
447
441
  `(archive format follows the writing tool). Install postgresql client ${requiredMajor}+ ` +
448
442
  `and/or point BACKUPDRILL_PG_RESTORE at it.`);
449
443
  }
444
+ // 工具/目标版本闸:pg_restore 17+ 恢复进 ≤16 的目标第一句就失败。在下载与装扩展
445
+ // 之前拦下,别等恢复到一半才炸;旧 pg_restore 又读不了新归档,所以唯一出路是新目标
446
+ const toolTargetBlocker = restoreToolTargetBlocker(toolMajor, targetMajor, requiredMajor);
447
+ if (toolTargetBlocker)
448
+ throw new Error(toolTargetBlocker);
450
449
  log.step("Downloading dump…");
451
450
  const dumpPath = join(workdir, "dump.pgcustom");
452
451
  const { sha256 } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
package/dist/storage.js CHANGED
@@ -4,6 +4,7 @@ import { pipeline } from "node:stream/promises";
4
4
  import { S3Client, ListBucketsCommand, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
5
5
  import { Upload } from "@aws-sdk/lib-storage";
6
6
  import { log } from "./log.js";
7
+ import { partSizeFor } from "./part-size.js";
7
8
  /**
8
9
  * Supabase Storage 通过 S3 兼容端点访问(endpoint 形如
9
10
  * https://<ref>.storage.supabase.co/storage/v1/s3)。这里的凭据是"读取源"的密钥,
@@ -104,6 +105,8 @@ export async function syncStorage(cfg, target) {
104
105
  const copy = pipeline(body, meter);
105
106
  const upload = new Upload({
106
107
  client: target.client,
108
+ // Body 是流,lib-storage 不知道长度,会固定按 5 MiB 切;用响应里的长度算分片
109
+ partSize: partSizeFor(got.ContentLength ?? obj.size),
107
110
  params: {
108
111
  Bucket: target.bucket,
109
112
  Key: `${target.base}/storage/${bucket}/${obj.key}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
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": {