backupdrill 0.2.1 → 1.1.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
@@ -168,12 +168,18 @@ backupdrill restore \
168
168
  --storage-dir ./recovered
169
169
  ```
170
170
 
171
- Or restore the dump by hand:
171
+ Or restore the dump by hand — into an **empty** database, without `--clean`:
172
172
 
173
173
  ```bash
174
- pg_restore --clean --if-exists --no-owner --dbname "<target-connection-string>" dump.pgcustom
174
+ pg_restore --no-owner --no-privileges --dbname "<target-connection-string>" dump.pgcustom
175
175
  ```
176
176
 
177
+ Don't use `--clean` against a Supabase project: dropping and recreating the `public`
178
+ schema silently wipes Supabase's default grants for `anon`/`authenticated` (the dump
179
+ carries no ACLs), leaving the restored API returning 401s. One error is expected and
180
+ harmless: `schema "public" already exists` — the target always has it. The
181
+ `backupdrill restore` command above handles all of this (plus verification) for you.
182
+
177
183
  ## GitHub Action / scheduled backups
178
184
 
179
185
  Want this running on a schedule without paying for a server? Run it in GitHub Actions.
@@ -198,6 +204,23 @@ Before switching to a **daily** schedule, read the egress-cost section above —
198
204
 
199
205
  For scheduling, automated restore drills, and alerting, use the hosted service at [backupdrill.com](https://backupdrill.com).
200
206
 
207
+ ## Versioning & stability
208
+
209
+ `backupdrill` follows [semantic versioning](https://semver.org/). From **1.0.0** onwards the
210
+ following is the stable public surface — breaking it requires a major version bump:
211
+
212
+ - the commands (`backup`, `drill`, `restore`, `estimate`) and their flags
213
+ - the `BACKUPDRILL_*` environment variables and the config-file keys
214
+ - the layout written to your bucket, and the shape of `manifest.json`
215
+
216
+ `manifest.json` carries a `schemaVersion` so the format can evolve safely. A snapshot written by
217
+ a newer release is **refused with a clear message** rather than parsed on a best-effort basis —
218
+ for a backup tool, silently misreading an archive is the worst possible failure. Snapshots taken
219
+ before 1.0 have no `schemaVersion` and are treated as version 1, so older backups stay readable.
220
+
221
+ The package's **JavaScript exports are internal** to the hosted BackupDrill product and are not
222
+ covered by this promise; they may change in any release. Use the CLI if you need a stable contract.
223
+
201
224
  ## License
202
225
 
203
226
  MIT
package/dist/backup.js CHANGED
@@ -5,9 +5,12 @@ import { promisify } from "node:util";
5
5
  import { Client } from "pg";
6
6
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
7
7
  import { Upload } from "@aws-sdk/lib-storage";
8
+ import { MANIFEST_SCHEMA_VERSION } from "./manifest.js";
8
9
  import { syncStorage } from "./storage.js";
10
+ import { inspectBucketAttrs } from "./storage-catalog.js";
9
11
  import { log } from "./log.js";
10
12
  import { TOOL_VERSION } from "./version.js";
13
+ import { pgConnectOptions, dumpUrlFor } from "./supabase-ca.js";
11
14
  const execFileAsync = promisify(execFile);
12
15
  function majorOf(serverVersionNum) {
13
16
  // server_version_num: 150004 → 15, 170002 → 17
@@ -45,7 +48,9 @@ async function pgDumpVersion(bin) {
45
48
  }
46
49
  }
47
50
  async function inspectDatabase(databaseUrl, schemas) {
48
- const client = new Client({ connectionString: databaseUrl });
51
+ // Supabase 主机 → 用打包的根 CA 做 verify-full(pooler 证书自签、不在系统信任库);
52
+ // 非 Supabase(本 CLI 不限制目标)→ 保持默认,别把开源用户挡在门外
53
+ const client = new Client(pgConnectOptions(databaseUrl));
49
54
  await client.connect();
50
55
  try {
51
56
  const version = await client.query("show server_version");
@@ -97,13 +102,15 @@ async function dumpToS3(config, s3, key, pgDumpBin) {
97
102
  },
98
103
  });
99
104
  const schemaArgs = config.schemas.map((s) => `--schema=${s}`);
105
+ // pg_dump(libpq):Supabase 主机才改写成 sslmode=verify-full + sslrootcert=<打包CA临时文件>
106
+ const dumpDbUrl = dumpUrlFor(config.databaseUrl);
100
107
  const dump = spawn(pgDumpBin, [
101
108
  "--format=custom",
102
109
  "--no-owner",
103
110
  "--no-privileges",
104
111
  ...schemaArgs,
105
112
  "--dbname",
106
- config.databaseUrl,
113
+ dumpDbUrl,
107
114
  ], { stdio: ["ignore", "pipe", "pipe"] });
108
115
  let stderr = "";
109
116
  dump.stderr.on("data", (d) => {
@@ -180,7 +187,20 @@ export async function runBackup(config) {
180
187
  bucket: config.storage.bucket,
181
188
  base, // syncStorage 内部会加 /storage/<bucket>/<key>
182
189
  });
190
+ // v2 bucket 属性采集,通道 = 已有的只读库连接(per-file 元数据已随 GetObject
191
+ // 响应在 syncStorage 内原子捕获)。失败只降级不报错:storage schema 读不到
192
+ // (如收紧的 backup_reader 角色)时,manifest 缺 buckets 字段,恢复端把缺失
193
+ // 如实标 not captured(PRD §5.1.3)。
194
+ let bucketAttrs;
195
+ try {
196
+ bucketAttrs = await inspectBucketAttrs(config.databaseUrl, synced.buckets);
197
+ }
198
+ catch (error) {
199
+ log.warn(`Bucket attributes not captured (${error.message}); ` +
200
+ `this manifest will record them as absent — restore will say so instead of guessing.`);
201
+ }
183
202
  storageResult = {
203
+ ...(bucketAttrs ? { buckets: bucketAttrs } : {}),
184
204
  fileCount: synced.fileCount,
185
205
  totalBytes: synced.totalBytes,
186
206
  files: synced.files,
@@ -189,6 +209,7 @@ export async function runBackup(config) {
189
209
  `${(synced.totalBytes / 1024 / 1024).toFixed(1)} MB)`);
190
210
  }
191
211
  const manifest = {
212
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
192
213
  tool: "backupdrill-cli",
193
214
  toolVersion: TOOL_VERSION,
194
215
  createdAt: new Date().toISOString(),
package/dist/drill.d.ts CHANGED
@@ -31,13 +31,16 @@ interface PostDataResult {
31
31
  supabaseSkipped: number;
32
32
  failures: string[];
33
33
  }
34
- /** 把 pg_restore 的 stderr 拆成单个错误块并分类(Supabase 托管 vs 用户对象)。 */
34
+ /** 兼容层:历史签名保留,分类逻辑的单一权威在统一引擎(restore-engine.ts)。 */
35
35
  export declare function classifyPostDataErrors(stderr: string): PostDataResult;
36
- /**
37
- * post-data 第二遍的最终裁决:分类之外,非零退出却没解析到任何错误块(被信号杀死、
38
- * 归档器致命错误等)绝不能谎报"全部恢复"——记为通用失败。
39
- */
36
+ /** 兼容层:见 restore-engine.ts finalizePass(非零退出且零错误块 = 通用失败)。 */
40
37
  export declare function postDataResult(code: number | null, stderr: string): PostDataResult;
38
+ /** 导出:真实恢复(restore.ts)复用同一套表级验证——两条路径对同一快照必须同一结论。 */
39
+ export declare function verifyRestored(connString: string, manifest: Manifest): Promise<{
40
+ checks: DrillCheck[];
41
+ tableCount: number;
42
+ rowTotal: number;
43
+ }>;
41
44
  export declare const APP_CHECK_TIMEOUT_MS: number;
42
45
  /**
43
46
  * 语义层钩子:结构检查全绿后、销毁沙箱前,把沙箱连接串经 BACKUPDRILL_SANDBOX_URL
package/dist/drill.js CHANGED
@@ -4,8 +4,10 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { Client } from "pg";
7
+ import { parseManifest } from "./manifest.js";
7
8
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
8
- import { resolvePgRestoreBin } from "./pgbin.js";
9
+ import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, } from "./restore-engine.js";
10
+ import { pgConnectOptions } from "./supabase-ca.js";
9
11
  import { log } from "./log.js";
10
12
  const execFileAsync = promisify(execFile);
11
13
  // Storage 校验默认抽样上限(避免每次演练重下全部文件产生大量 egress);超限如实记录
@@ -91,114 +93,42 @@ async function startEphemeralPostgres(image) {
91
93
  throw error;
92
94
  }
93
95
  }
94
- function quoteIdent(name) {
95
- return `"${name.replace(/"/g, '""')}"`;
96
- }
97
- /**
98
- * 恢复前把 manifest 记录的扩展装进沙箱(--schema 转储不含 CREATE EXTENSION)。
99
- * 必须装进与源库相同的 schema:dump 里的类型是限定名(如 extensions.vector)。
100
- * 装不上的扩展如实收集返回,**不在这里失败**——Supabase 项目普遍装着沙箱镜像
101
- * 没有的托管扩展(pg_graphql、supabase_vault…),public 转储通常并不引用它们;
102
- * 只有恢复真的因此失败时才升级为分类错误(见 drillDump)。
103
- */
104
- async function installExtensions(connString, extensions) {
105
- if (!extensions.length)
106
- return [];
107
- const client = new Client({ connectionString: connString });
108
- await client.connect();
109
- const unavailable = [];
110
- try {
111
- for (const ext of extensions) {
112
- try {
113
- await client.query(`create schema if not exists ${quoteIdent(ext.schema)}`);
114
- // CASCADE 自动带上依赖扩展,免受 manifest 里的安装顺序摆布
115
- await client.query(`create extension if not exists ${quoteIdent(ext.name)} schema ${quoteIdent(ext.schema)} cascade`);
116
- }
117
- catch {
118
- unavailable.push(ext.name);
119
- }
120
- }
121
- }
122
- finally {
123
- await client.end();
124
- }
125
- return unavailable;
126
- }
127
- function spawnPgRestore(bin, args) {
128
- return new Promise((resolve, reject) => {
129
- const proc = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
130
- let stderr = "";
131
- proc.stderr.on("data", (d) => (stderr += d.toString()));
132
- proc.on("error", reject);
133
- proc.on("close", (code) => resolve({ code, stderr }));
134
- });
135
- }
136
96
  // 只有这些特征说明恢复失败源于沙箱缺扩展(限定类型/schema 解析不到、扩展装不上)。
137
97
  // 真实 Supabase 项目的 manifest 几乎必然含沙箱装不上的托管扩展(pg_graphql 等),
138
98
  // 若不按特征过滤,任何硬失败(归档损坏、OOM…)都会被误归因到沙箱。
139
99
  const MISSING_EXTENSION_ERROR = /type "[^"]+" does not exist|schema "[^"]+" does not exist|extension "[^"]+" is not available/i;
140
- // 演练沙箱是裸 Postgres,Supabase 托管的 schema/角色必然缺席。post-data 里
141
- // 引用它们的失败是"环境预期",不是备份坏了;其余失败才是演练要抓的。
142
- const SUPABASE_MANAGED_ERROR = /schema "(auth|storage|realtime|vault|extensions|graphql[a-z_]*)" does not exist|role "(authenticated|anon|service_role|supabase_[a-z_]+)" does not exist|\bauth\.uid\b|\bauth\.jwt\b/i;
143
- /** 把 pg_restore 的 stderr 拆成单个错误块并分类(Supabase 托管 vs 用户对象)。 */
100
+ /** 兼容层:历史签名保留,分类逻辑的单一权威在统一引擎(restore-engine.ts)。 */
144
101
  export function classifyPostDataErrors(stderr) {
145
- const blocks = stderr
146
- .split(/(?=pg_restore: error:)/)
147
- .filter((b) => /pg_restore: error:/.test(b));
148
- let supabaseSkipped = 0;
149
- const failures = [];
150
- for (const block of blocks) {
151
- // 只对 ERROR 原因行分类,不看 "Command was:" 之后的语句文本——否则一个恰好
152
- // 引用 auth.uid 的用户对象因"损坏/语法错误"失败时,会被误判成预期跳过。
153
- const cause = block.split(/Command was:/i)[0];
154
- if (SUPABASE_MANAGED_ERROR.test(cause)) {
155
- supabaseSkipped += 1;
156
- }
157
- else {
158
- failures.push(block.replace(/\s+/g, " ").trim().slice(0, 200));
159
- }
160
- }
161
- return { supabaseSkipped, failures };
102
+ const classified = classifyBlocks(stderr, SANDBOX_MANAGED_ERROR);
103
+ return { supabaseSkipped: classified.expectedSkips, failures: classified.failures };
162
104
  }
163
- /**
164
- * post-data 第二遍的最终裁决:分类之外,非零退出却没解析到任何错误块(被信号杀死、
165
- * 归档器致命错误等)绝不能谎报"全部恢复"——记为通用失败。
166
- */
105
+ /** 兼容层:见 restore-engine.ts finalizePass(非零退出且零错误块 = 通用失败)。 */
167
106
  export function postDataResult(code, stderr) {
168
- const result = classifyPostDataErrors(stderr);
169
- if (code !== 0 && result.supabaseSkipped === 0 && result.failures.length === 0) {
170
- result.failures.push(`pg_restore post-data exited with ${code === null ? "a signal" : `code ${code}`}: ` +
171
- (stderr.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)"));
172
- }
173
- return result;
107
+ const classified = finalizePass(code, stderr, classifyBlocks(stderr, SANDBOX_MANAGED_ERROR));
108
+ return { supabaseSkipped: classified.expectedSkips, failures: classified.failures };
174
109
  }
175
110
  /**
176
- * 两遍恢复(真实首演炸出的设计):
177
- * 1. pre-data+data —— 表结构 + 数据。任何错误都是硬失败:数据回不来 = 演练失败。
178
- * 2. post-data —— 用户自己的索引/约束/触发器**必须**恢复成功(它们是"备份完整"的
179
- * 一部分,一刀切跳过会让坏备份漏网);只有引用 Supabase 托管对象的失败
180
- * (policy TO authenticated、FK → auth.users)才归类为沙箱预期,记入报告而非失败。
111
+ * 沙箱恢复 = 统一引擎(target: "sandbox")。pre-data 失败按硬失败抛出——数据回不来
112
+ * = 演练失败,且错误文本要供缺扩展归因(MISSING_EXTENSION_ERROR)使用;post-data
113
+ * 的托管对象跳过如实返回。pre-data 的 schema 冲突跳过(dump 自带 CREATE SCHEMA,
114
+ * 容器恒有 public)是非事件,不计入报告。
181
115
  */
182
116
  async function pgRestore(dumpPath, connString) {
183
- const pgRestoreBin = resolvePgRestoreBin();
184
- const common = ["--no-owner", "--no-privileges", "--dbname", connString, dumpPath];
185
- const first = await spawnPgRestore(pgRestoreBin, [
186
- "--clean",
187
- "--if-exists",
188
- "--section=pre-data",
189
- "--section=data",
190
- ...common,
191
- ]);
192
- // pg_restore 对无害告警会返回非 0;仅在明确 error 时判失败
193
- if (first.code !== 0 && /error:/i.test(first.stderr)) {
194
- throw new Error(`pg_restore failed: ${first.stderr.trim()}`);
117
+ const result = await restoreDatabaseArtifact({ dumpPath, connString, target: "sandbox" });
118
+ if (result.preData.failures.length) {
119
+ throw new Error(`pg_restore failed: ${result.preData.failures.join(" | ")}`);
195
120
  }
196
- const second = await spawnPgRestore(pgRestoreBin, ["--section=post-data", ...common]);
197
- return postDataResult(second.code, second.stderr);
121
+ return {
122
+ supabaseSkipped: result.postData.expectedSkips,
123
+ failures: result.postData.failures,
124
+ };
198
125
  }
199
126
  // ── 校验 ────────────────────────────────────────────────────────────
200
- async function verifyRestored(connString, manifest) {
201
- const client = new Client({ connectionString: connString });
127
+ /** 导出:真实恢复(restore.ts)复用同一套表级验证——两条路径对同一快照必须同一结论。 */
128
+ export async function verifyRestored(connString, manifest) {
129
+ // pgConnectOptions:真实 Supabase 目标(restore 复用本函数)需要打包根 CA;
130
+ // 沙箱的 docker 连接串非 Supabase 主机,原样透传,行为不变
131
+ const client = new Client(pgConnectOptions(connString));
202
132
  await client.connect();
203
133
  try {
204
134
  const schemas = manifest.database.schemas;
@@ -228,7 +158,9 @@ async function verifyRestored(connString, manifest) {
228
158
  unpopulatedMatviews.add(key);
229
159
  continue;
230
160
  }
231
- const c = await client.query(`select count(*)::bigint as n from "${t.schema}"."${t.name}"`);
161
+ // 名字来自恢复出的库(= 来自 dump = 桶里的数据):必须走标识符转义,
162
+ // 内嵌引号的合法名不炸,构造名不注入
163
+ const c = await client.query(`select count(*)::bigint as n from ${quoteIdent(t.schema)}.${quoteIdent(t.name)}`);
232
164
  const n = Number(c.rows[0].n);
233
165
  counts.set(key, n);
234
166
  // 分区父表的 count(*) 是子分区行的总和,子分区又各自计一次;
@@ -488,7 +420,7 @@ export async function runDrill(config, opts = {}) {
488
420
  const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
489
421
  const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
490
422
  log.step(`Drilling snapshot ${snapshot}`);
491
- const manifest = JSON.parse(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
423
+ const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
492
424
  const workdir = await mkdtemp(join(tmpdir(), "backupdrill-"));
493
425
  const dumpPath = join(workdir, "dump.pgcustom");
494
426
  try {
package/dist/estimate.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Client } from "pg";
2
2
  import { measureStorage } from "./storage.js";
3
3
  import { log } from "./log.js";
4
+ import { pgConnectOptions } from "./supabase-ca.js";
4
5
  // 超出这个月度成本就该提醒用户"备份的 egress 账单快赶上订阅费了"
5
6
  const SUBSCRIPTION_FLOOR_USD = 19;
6
7
  const GB = 1024 * 1024 * 1024;
7
8
  async function measureDbBytes(databaseUrl, schemas) {
8
- const client = new Client({ connectionString: databaseUrl });
9
+ const client = new Client(pgConnectOptions(databaseUrl));
9
10
  await client.connect();
10
11
  try {
11
12
  // pg_total_relation_size 含索引+TOAST,是 egress 上界(真实转储经压缩、不含索引,更小)
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { loadConfig } from "./config.js";
4
4
  import { runBackup } from "./backup.js";
5
5
  import { runEstimate } from "./estimate.js";
6
6
  import { runDrill } from "./drill.js";
7
- import { runRestore } from "./restore.js";
7
+ import { runRestore, NO_SOURCE_DATABASE } from "./restore.js";
8
8
  import { log } from "./log.js";
9
9
  import { TOOL_VERSION } from "./version.js";
10
10
  // Supabase egress 定价表(2026-07-10 核实,来源见 README「Egress & cost」)。
@@ -137,28 +137,46 @@ program
137
137
  .option("--project-name <name>", "project name used in the object key")
138
138
  .action(async (opts) => {
139
139
  try {
140
- const config = await loadConfig({
141
- configPath: opts.config,
142
- overrides: {
143
- projectName: opts.projectName,
144
- databaseUrl: "postgresql://unused", // restore 读桶,不连源库
145
- storage: {
146
- bucket: opts.bucket,
147
- endpoint: opts.endpoint,
148
- region: opts.region,
149
- prefix: opts.prefix,
150
- },
140
+ // 源库连接串不再无条件屏蔽:配置/环境里有真实源时,runRestore 的同源阻断门
141
+ // 要拿它比对;纯 flag 驱动(无任何源配置)才退回占位符
142
+ const overrides = {
143
+ projectName: opts.projectName,
144
+ storage: {
145
+ bucket: opts.bucket,
146
+ endpoint: opts.endpoint,
147
+ region: opts.region,
148
+ prefix: opts.prefix,
151
149
  },
152
- });
150
+ };
151
+ let config;
152
+ try {
153
+ config = await loadConfig({ configPath: opts.config, overrides });
154
+ }
155
+ catch (error) {
156
+ if (!/databaseUrl/.test(error.message))
157
+ throw error;
158
+ config = await loadConfig({
159
+ configPath: opts.config,
160
+ overrides: { ...overrides, databaseUrl: NO_SOURCE_DATABASE },
161
+ });
162
+ }
153
163
  const result = await runRestore(config, {
154
164
  targetDatabaseUrl: opts.targetDatabaseUrl,
155
165
  storageDir: opts.storageDir,
156
166
  snapshot: opts.snapshot,
157
167
  });
158
- log.ok(`Restore complete — snapshot ${result.snapshot}: ` +
168
+ const failedChecks = (result.databaseChecks ?? []).filter((c) => !c.pass);
169
+ const summary = `snapshot ${result.snapshot}: ` +
159
170
  `database ${result.restoredToDatabase ? "restored" : "skipped"}, ` +
160
171
  `${result.storageFilesWritten} Storage files written` +
161
- (result.storageDir ? ` to ${result.storageDir}` : ""));
172
+ (result.storageDir ? ` to ${result.storageDir}` : "");
173
+ if (failedChecks.length) {
174
+ log.error(`Restore finished with ${failedChecks.length} FAILED verification check(s) — ${summary}`);
175
+ process.exitCode = 1;
176
+ }
177
+ else {
178
+ log.ok(`Restore complete — ${summary}`);
179
+ }
162
180
  }
163
181
  catch (error) {
164
182
  log.error(error.message);
package/dist/lib.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  export { runBackup } from "./backup.js";
2
+ export { SUPABASE_ROOT_CA, pgConnectOptions } from "./supabase-ca.js";
2
3
  export { runDrill, drillDump } from "./drill.js";
3
4
  export { runRestore } from "./restore.js";
4
5
  export { runEstimate, project } from "./estimate.js";
5
6
  export { loadConfig } from "./config.js";
6
7
  export { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
7
8
  export type { BackupConfig, StorageConfig, SupabaseStorageConfig, } from "./config.js";
8
- export type { ExtensionInfo, Manifest, StorageFile, TableStat } from "./manifest.js";
9
+ export type { BucketAttrs, ExtensionInfo, Manifest, StorageFile, TableStat } from "./manifest.js";
9
10
  export type { DrillReport, DrillCheck } from "./drill.js";
10
11
  export type { RestoreResult } from "./restore.js";
11
12
  export type { EgressPricing, EgressProjection } from "./estimate.js";
package/dist/lib.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // 库入口:把引擎函数暴露给下游复用(产品 worker 直接 import 这些,而不是重写)。
2
2
  // CLI(bin)是这套引擎的薄封装;worker 在其外加调度、凭据托管、告警、报告。
3
3
  export { runBackup } from "./backup.js";
4
+ // verify-full:只导出**原子** API。刻意不导出裸 SUPABASE_SSL —— 它一旦和 connectionString
5
+ // 搭配使用,URL 里的 sslmode=disable 又会把它覆盖掉,等于把这次修掉的绕过再放回去。
6
+ export { SUPABASE_ROOT_CA, pgConnectOptions } from "./supabase-ca.js";
4
7
  export { runDrill, drillDump } from "./drill.js";
5
8
  export { runRestore } from "./restore.js";
6
9
  export { runEstimate, project } from "./estimate.js";
@@ -7,6 +7,23 @@ export interface TableStat {
7
7
  schema: string;
8
8
  name: string;
9
9
  estimatedRows: number;
10
+ primaryKeyColumns?: string[];
11
+ exactRows?: number;
12
+ sampleHash?: string;
13
+ sampleAlgorithm?: string;
14
+ freshnessColumn?: string;
15
+ freshnessValue?: string;
16
+ }
17
+ /**
18
+ * 源 bucket 的可恢复属性(v2,读自源库 storage.buckets——S3 兼容端点拿不到这些)。
19
+ * 值为 null = 源端就没设置;字段整体缺失 = 当次备份没捕获到(如 storage schema 无读权限),
20
+ * 恢复端遇缺失须如实标 "not captured",不得猜测(PRD §5.1.3)。
21
+ */
22
+ export interface BucketAttrs {
23
+ name: string;
24
+ public?: boolean | null;
25
+ fileSizeLimit?: number | null;
26
+ allowedMimeTypes?: string[] | null;
10
27
  }
11
28
  /**
12
29
  * 源库安装的非内置扩展(pg_extension,排除必装的 plpgsql)。
@@ -19,7 +36,24 @@ export interface ExtensionInfo {
19
36
  version: string;
20
37
  schema: string;
21
38
  }
39
+ /**
40
+ * manifest.json 的格式版本。**这是留在客户桶里、可能躺很多年的持久产物**——将来改结构时,
41
+ * 读取端要靠它分支判断,而不是去猜 toolVersion。
42
+ *
43
+ * 约定:
44
+ * - 缺失该字段的 manifest = 版本 1(1.0 之前写出的旧快照)
45
+ * - 读取端遇到**更高**的版本必须明确报错,而不是按旧结构硬解析出错误结果
46
+ * ——对备份工具来说,"旧程序静默误读新归档"是最不能接受的失败方式。
47
+ *
48
+ * 版本史:
49
+ * - 1:初始格式。
50
+ * - 2(恢复闭环 PRD §5.1):storage 增加 buckets 属性清单(public/大小/MIME 限制),
51
+ * files 增加 contentType/cacheControl/metadata 等恢复所需元数据;tables 预留
52
+ * 验证升级字段(本期不生成)。全部为**可加字段**——v2 读取端兼容 v1 快照。
53
+ */
54
+ export declare const MANIFEST_SCHEMA_VERSION = 2;
22
55
  export interface Manifest {
56
+ schemaVersion?: number;
23
57
  tool: "backupdrill-cli";
24
58
  toolVersion: string;
25
59
  createdAt: string;
@@ -40,6 +74,7 @@ export interface Manifest {
40
74
  sha256: string;
41
75
  };
42
76
  storage: null | {
77
+ buckets?: BucketAttrs[];
43
78
  fileCount: number;
44
79
  totalBytes: number;
45
80
  files: StorageFile[];
@@ -50,4 +85,15 @@ export interface StorageFile {
50
85
  key: string;
51
86
  bytes: number;
52
87
  sha256: string;
88
+ contentType?: string;
89
+ cacheControl?: string;
90
+ contentEncoding?: string;
91
+ metadata?: Record<string, unknown>;
92
+ lastModified?: string;
53
93
  }
94
+ /**
95
+ * 解析桶里的 manifest.json,并做**向前兼容**校验:比本程序更新的格式一律拒绝,
96
+ * 明确告诉用户升级,绝不按旧结构硬解析(那会静默产出错误的演练/恢复结果)。
97
+ * 版本关之后再做结构校验(assertManifestShape)——两类失败要给出不同的处置指引。
98
+ */
99
+ export declare function parseManifest(json: string): Manifest;