backupdrill 2.2.1 → 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.js CHANGED
@@ -12,6 +12,8 @@ import { projectRefOf, refFromStorageEndpoint, assertNoHostOverride } from "./re
12
12
  import { log } from "./log.js";
13
13
  import { TOOL_VERSION } from "./version.js";
14
14
  import { parsePgDumpMajor } from "./pgbin.js";
15
+ import { relationBytes } from "./estimate.js";
16
+ import { dumpPartSizeFor } from "./part-size.js";
15
17
  // 解析函数搬到了 pgbin(演练与恢复都要读 pg_restore 版本,放这里会成环);保留旧出口
16
18
  export { parsePgDumpMajor };
17
19
  import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
@@ -66,6 +68,15 @@ async function inspectDatabase(databaseUrl, schemas) {
66
68
  join pg_namespace n on n.oid = e.extnamespace
67
69
  where e.extname <> 'plpgsql'
68
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
+ }
69
80
  return {
70
81
  serverVersion: version.rows[0].server_version,
71
82
  serverVersionNum: Number(versionNum.rows[0].server_version_num),
@@ -75,6 +86,7 @@ async function inspectDatabase(databaseUrl, schemas) {
75
86
  estimatedRows: Number(r.estimated_rows),
76
87
  })),
77
88
  extensions: extensions.rows,
89
+ relationBytes: measuredBytes,
78
90
  };
79
91
  }
80
92
  finally {
@@ -82,7 +94,7 @@ async function inspectDatabase(databaseUrl, schemas) {
82
94
  }
83
95
  }
84
96
  /** 流式跑 pg_dump,边写 S3 边算 sha256 与字节数。返回校验和与大小。 */
85
- async function dumpToS3(config, s3, key, pgDumpBin) {
97
+ async function dumpToS3(config, s3, key, pgDumpBin, partSize) {
86
98
  const hash = createHash("sha256");
87
99
  let bytes = 0;
88
100
  const meter = new Transform({
@@ -124,6 +136,7 @@ async function dumpToS3(config, s3, key, pgDumpBin) {
124
136
  });
125
137
  const upload = new Upload({
126
138
  client: s3,
139
+ partSize,
127
140
  params: {
128
141
  Bucket: config.storage.bucket,
129
142
  Key: key,
@@ -188,7 +201,7 @@ export async function runBackup(config) {
188
201
  const base = `${config.storage.prefix}/${config.projectName}/${timestamp}`;
189
202
  const dumpKey = `${base}/dump.pgcustom`;
190
203
  log.step(`Dumping → s3://${config.storage.bucket}/${dumpKey}`);
191
- const { bytes, sha256 } = await dumpToS3(config, s3, dumpKey, pgDumpBin);
204
+ const { bytes, sha256 } = await dumpToS3(config, s3, dumpKey, pgDumpBin, dumpPartSizeFor(db.relationBytes));
192
205
  log.ok(`Database dumped (${(bytes / 1024 / 1024).toFixed(1)} MB, sha256 ${sha256.slice(0, 12)}…)`);
193
206
  // Storage 文件同步(源配置存在才做;否则只备数据库)
194
207
  let storageResult = null;
@@ -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/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.1",
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": {