backupdrill 1.1.0 → 2.0.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/restore.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdtemp, mkdir, rm, readdir } from "node:fs/promises";
1
+ import { mkdtemp, mkdir, rm, readdir, lstat } from "node:fs/promises";
2
2
  import { createWriteStream } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, dirname, resolve, sep } from "node:path";
@@ -7,35 +7,37 @@ import { GetObjectCommand } from "@aws-sdk/client-s3";
7
7
  import { Client } from "pg";
8
8
  import { parseManifest } from "./manifest.js";
9
9
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, } from "./snapshots.js";
10
- import { restoreDatabaseArtifact, installExtensions } from "./restore-engine.js";
10
+ import { restoreDatabaseArtifact, installExtensions, projectRefOf, refFromStorageEndpoint, assertNoHostOverride, normalizeHost, } from "./restore-engine.js";
11
11
  import { verifyRestored } from "./drill.js";
12
+ import { restoreStorage, planStorageRestore, } from "./storage-restore.js";
12
13
  import { pgConnectOptions } from "./supabase-ca.js";
14
+ import { resolvePgRestoreBin } from "./pgbin.js";
15
+ import { parsePgDumpMajor } from "./backup.js";
16
+ import { execFile } from "node:child_process";
17
+ import { promisify } from "node:util";
13
18
  import { log } from "./log.js";
14
- /**
15
- * restore 无源库配置时的占位连接串(纯 flag 驱动、没有 backupdrill.config.json 的场景)。
16
- * 同源阻断门对它不生效——没有可比对的源;有真实源配置时阻断门照常工作。
17
- */
18
- export const NO_SOURCE_DATABASE = "postgresql://unused";
19
- /**
20
- * 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
21
- * pooler 用户名 `postgres.<ref>`。两种形态指向同一租户——只比 host/user 会漏掉
22
- * "源填直连、目标填 pooler"的同项目组合(评审第 7 轮)。非 Supabase 形态返回 null。
23
- */
24
- export function projectRefOf(connString) {
19
+ const execFileAsync = promisify(execFile);
20
+ /** 本地 pg_restore 主版本(拿不到 → null,由引擎自己的报错兜底)。 */
21
+ async function localPgRestoreMajor() {
25
22
  try {
26
- const url = new URL(connString);
27
- const direct = url.hostname.match(/^db\.([a-z0-9]{16,})\.supabase\.co$/);
28
- if (direct)
29
- return direct[1];
30
- const pooled = url.username.match(/^postgres\.([a-z0-9]{16,})$/);
31
- if (pooled)
32
- return pooled[1];
33
- return null;
23
+ const { stdout } = await execFileAsync(resolvePgRestoreBin(), ["--version"]);
24
+ return parsePgDumpMajor(stdout.trim());
34
25
  }
35
26
  catch {
36
27
  return null;
37
28
  }
38
29
  }
30
+ /** 归档可读性要求:pg_restore 必须 ≥ max(写入工具版本, 源服务端版本)——格式跟随写入工具。 */
31
+ export function requiredRestoreToolMajor(manifest) {
32
+ return Math.max(parsePgDumpMajor(manifest.database.pgDumpVersion) ?? 0, parseInt(manifest.database.serverVersion, 10) || 0);
33
+ }
34
+ /**
35
+ * restore 无源库配置时的占位连接串(纯 flag 驱动、没有 backupdrill.config.json 的场景)。
36
+ * 同源阻断门对它不生效——没有可比对的源;有真实源配置时阻断门照常工作。
37
+ */
38
+ export const NO_SOURCE_DATABASE = "postgresql://unused";
39
+ // projectRefOf 上移到引擎层(备份端写 manifest.sourceProjectRef 也要用);此处转出口
40
+ export { projectRefOf } from "./restore-engine.js";
39
41
  /**
40
42
  * 两个连接串是否指向同一数据库租户(纯函数,可测)。
41
43
  * Supabase 双方以项目 ref 为准(直连/pooler 形态互认);其余退回 host+user 比对
@@ -52,12 +54,121 @@ export function sameDatabaseTarget(a, b) {
52
54
  const ub = new URL(b);
53
55
  if (!ua.hostname || !ub.hostname)
54
56
  return false;
55
- return ua.hostname === ub.hostname && ua.username === ub.username;
57
+ // 用户名按驱动语义(解码后)比较,与 projectRefOf 同一口径
58
+ return (normalizeHost(ua.hostname) === normalizeHost(ub.hostname) &&
59
+ decodeURIComponent(ua.username) === decodeURIComponent(ub.username));
56
60
  }
57
61
  catch {
58
62
  return false;
59
63
  }
60
64
  }
65
+ /**
66
+ * 从目标 Project URL(https://<ref>.supabase.co)提取 ref。 */
67
+ export function refFromSupabaseUrl(supabaseUrl) {
68
+ try {
69
+ const match = normalizeHost(new URL(supabaseUrl).hostname).match(/^([a-z0-9]{16,})\.supabase\.co$/);
70
+ return match ? match[1] : null;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * 目标 Storage URL 的严格校验:必须是 https 且恰为 <ref>.supabase.co 源(无路径/查询)。
78
+ * service-role key 会作为请求头发往这个地址——校验必须发生在**第一次 fetch 之前**,
79
+ * 否则任意主机(或明文 http)都能收到 key(评审第 8 轮)。返回规范化 origin。
80
+ */
81
+ export function validateStorageTargetOrigin(supabaseUrl) {
82
+ let url;
83
+ try {
84
+ url = new URL(supabaseUrl);
85
+ }
86
+ catch {
87
+ throw new Error(`--target-supabase-url is not a valid URL: ${supabaseUrl}`);
88
+ }
89
+ const ref = normalizeHost(url.hostname).match(/^([a-z0-9]{16,})\.supabase\.co$/);
90
+ if (url.protocol !== "https:" || !ref || (url.pathname !== "/" && url.pathname !== "") || url.search) {
91
+ throw new Error(`--target-supabase-url must be exactly https://<project-ref>.supabase.co — got "${supabaseUrl}". ` +
92
+ `The service-role key is only ever sent to a verified Supabase project origin.`);
93
+ }
94
+ return url.origin;
95
+ }
96
+ // assertNoHostOverride 上移到引擎层(备份侧的源身份自记也要用);此处转出口
97
+ export { assertNoHostOverride } from "./restore-engine.js";
98
+ /**
99
+ * 备份配置能导出的全部源项目 ref:数据库连接串 + Storage 读取端点
100
+ * (https://<ref>.storage.supabase.co/...)。storage-only 恢复没有目标库连接串,
101
+ * 同源阻断必须同样覆盖"目标 Storage = 源项目"的组合(评审第 8 轮)。
102
+ */
103
+ export function sourceProjectRefs(config, manifest) {
104
+ const refs = new Set();
105
+ if (config.databaseUrl !== NO_SOURCE_DATABASE) {
106
+ const ref = projectRefOf(config.databaseUrl);
107
+ if (ref)
108
+ refs.add(ref);
109
+ }
110
+ const endpoint = config.supabaseStorage?.endpoint;
111
+ if (endpoint) {
112
+ const ref = refFromStorageEndpoint(endpoint);
113
+ if (ref)
114
+ refs.add(ref);
115
+ }
116
+ // manifest 自记的源 ref(评审第 9 轮):纯 flag 恢复没有 config 可比对,
117
+ // 快照自己就是最后一道源身份来源
118
+ if (manifest?.sourceProjectRef)
119
+ refs.add(manifest.sourceProjectRef);
120
+ return refs;
121
+ }
122
+ /**
123
+ * 请求与快照内容必须相交(评审第 15 轮,纯函数可测):什么都不会做的调用
124
+ * 不得走到"Restore complete"——自动化会把 no-op 记成一次成功恢复。
125
+ */
126
+ export function assertRequestApplies(manifest, wantsDatabase, wantsStorageUpload) {
127
+ if (wantsStorageUpload && manifest.storage === null) {
128
+ throw new Error("this snapshot is database-only — there is no Storage to upload. Drop --target-supabase-url " +
129
+ "(and pass --database if the database restore is what you want).");
130
+ }
131
+ if (!wantsDatabase && !wantsStorageUpload && manifest.storage === null) {
132
+ throw new Error("nothing to do: no database restore was requested (--database) and this snapshot has no " +
133
+ "Storage files to download.");
134
+ }
135
+ }
136
+ /**
137
+ * 正式执行的确认门(PRD §5.3.3):用户必须键入目标 project ref(或非 Supabase 目标的
138
+ * 主机名)与实际连接目标一致。误把生产源当目标是恢复流程里代价最不对称的失误,
139
+ * 这道门要求人把目标身份亲手打一遍。
140
+ */
141
+ export function assertConfirmedTarget(confirmTarget, targetDatabaseUrl, targetSupabaseUrl) {
142
+ const dbRef = targetDatabaseUrl ? projectRefOf(targetDatabaseUrl) : null;
143
+ const apiRef = targetSupabaseUrl ? refFromSupabaseUrl(targetSupabaseUrl) : null;
144
+ // 两个入口都给了且指向不同项目 → 直接硬错,和确认无关
145
+ if (dbRef && apiRef && dbRef !== apiRef) {
146
+ throw new Error(`the target database URL points at project "${dbRef}" but --target-supabase-url at "${apiRef}" — ` +
147
+ `these are different projects; fix one of them.`);
148
+ }
149
+ // 外部(非 Supabase)库目标 + Supabase Storage 目标:一个确认值盖不住两个不相干
150
+ // 的目标(评审第 12 轮)——这种混搭直接拒绝,分两次跑各自确认
151
+ if (targetDatabaseUrl && !dbRef && apiRef) {
152
+ throw new Error("the database target is not a Supabase project while the Storage target is — one confirmation " +
153
+ "cannot cover two unrelated targets. Run the database restore and the Storage restore separately.");
154
+ }
155
+ let expected = dbRef ?? apiRef;
156
+ if (!expected && targetDatabaseUrl) {
157
+ try {
158
+ expected = new URL(targetDatabaseUrl).hostname || null;
159
+ }
160
+ catch {
161
+ expected = null;
162
+ }
163
+ }
164
+ if (!expected) {
165
+ throw new Error("cannot derive the target's identity from the given URLs — refusing to write without a confirmable target.");
166
+ }
167
+ if (confirmTarget !== expected) {
168
+ throw new Error(`restore writes to target "${expected}" — confirm by re-running with --confirm-target ${expected} ` +
169
+ `(use --dry-run first to preview all checks without writing).`);
170
+ }
171
+ }
61
172
  /**
62
173
  * 空目标门(PRD §5.3.3 / 决策 D5):目标 schema 里存在任何用户对象即拒绝写入。
63
174
  * "空"的定义来自 R0 spike:不是"schema 不存在"(Supabase 恒有 public),而是
@@ -81,7 +192,9 @@ async function assertEmptyTarget(targetUrl, schemas) {
81
192
  if (objectCount > 0) {
82
193
  throw new Error(`target schema(s) ${schemas.join(", ")} contain ${objectCount} existing object(s) — ` +
83
194
  `restore only writes into an empty target. Create a fresh Supabase project ` +
84
- `(or empty these schemas) and retry.`);
195
+ `(or empty these schemas) and retry. If a previous run already restored the ` +
196
+ `database and only Storage was interrupted, re-run WITHOUT the database target ` +
197
+ `to retry Storage alone.`);
85
198
  }
86
199
  }
87
200
  finally {
@@ -89,9 +202,127 @@ async function assertEmptyTarget(targetUrl, schemas) {
89
202
  }
90
203
  }
91
204
  /**
92
- * 把一份快照恢复出来:数据库经统一引擎还原进 --target-database-url(与演练同一条
93
- * pg_restore 路径与错误分类),恢复后跑同一套表级验证;Storage 文件下载到本地目录
94
- * (回传到新 Supabase 项目的自动化是 P0-D,未到)。这是 US-5 恢复向导的 CLI 形态。
205
+ * dry-run 只读预检(PRD §5.3.2):零写入,把正式执行会撞的墙全部提前撞一遍。
206
+ * 逐项打印,任何 blocker 汇总后抛出(退出码非零 = "现在执行不会成功")。
207
+ */
208
+ async function dryRunPreflight(config, manifest, targetUrl, storageTarget) {
209
+ const blockers = [];
210
+ const db = manifest.database;
211
+ const mb = (n) => (n / 1024 / 1024).toFixed(1);
212
+ log.ok(`coverage: schemas ${db.schemas.join(", ")} — ${db.tableCount} tables, ` +
213
+ `~${db.estimatedRowTotal.toLocaleString()} rows, dump ${mb(manifest.dump.bytes)} MB`);
214
+ log.ok(manifest.storage
215
+ ? `storage: ${manifest.storage.fileCount} files, ${mb(manifest.storage.totalBytes)} MB` +
216
+ (manifest.storage.buckets ? `, ${manifest.storage.buckets.length} bucket(s) with attributes` : ", bucket attributes not captured")
217
+ : "storage: none (database-only snapshot)");
218
+ log.warn("not covered: Auth users/sessions, secret values, Edge Functions, most platform settings");
219
+ // 双目标一致性(dry-run 也要查,不能绿灯放行到正式执行才拒):库与 Storage
220
+ // 两个入口指向不同项目 = 配置错了一个
221
+ const dbRef = targetUrl ? projectRefOf(targetUrl) : null;
222
+ const apiRef = storageTarget ? refFromSupabaseUrl(storageTarget.supabaseUrl) : null;
223
+ if (dbRef && apiRef && dbRef !== apiRef) {
224
+ blockers.push(`database URL targets "${dbRef}" but Storage URL targets "${apiRef}"`);
225
+ log.error(`target consistency: database → ${dbRef}, storage → ${apiRef} — different projects`);
226
+ }
227
+ else if (dbRef || apiRef) {
228
+ log.ok(`target project: ${dbRef ?? apiRef}`);
229
+ }
230
+ if (targetUrl) {
231
+ if (config.databaseUrl !== NO_SOURCE_DATABASE && sameDatabaseTarget(config.databaseUrl, targetUrl)) {
232
+ blockers.push("target database is the backup SOURCE (same project)");
233
+ log.error("target identity: SAME AS SOURCE — would be blocked");
234
+ }
235
+ else {
236
+ log.ok("target identity: distinct from source");
237
+ }
238
+ try {
239
+ await assertEmptyTarget(targetUrl, db.schemas);
240
+ log.ok(`target schemas (${db.schemas.join(", ")}) are empty`);
241
+ }
242
+ catch (error) {
243
+ blockers.push(error.message);
244
+ log.error(`empty-target check: ${error.message}`);
245
+ }
246
+ const client = new Client(pgConnectOptions(targetUrl));
247
+ await client.connect();
248
+ try {
249
+ // SHOW 的结果列名是 server_version;用 current_setting 显式起别名,
250
+ // 否则取错列得 NaN,而 NaN < x 恒 false = 版本降级永远查不出来
251
+ const version = await client.query("select current_setting('server_version') as v");
252
+ const targetMajor = parseInt(version.rows[0].v, 10);
253
+ const sourceMajor = parseInt(db.serverVersion, 10);
254
+ if (!Number.isFinite(targetMajor)) {
255
+ blockers.push(`cannot determine target PostgreSQL version (got "${version.rows[0].v}")`);
256
+ log.error("postgres version: undeterminable — refusing to guess");
257
+ }
258
+ else if (targetMajor < sourceMajor) {
259
+ blockers.push(`target PostgreSQL ${targetMajor} < source ${sourceMajor}`);
260
+ log.error(`postgres version: target ${targetMajor} < source ${sourceMajor} — would fail`);
261
+ }
262
+ else {
263
+ log.ok(`postgres version: target ${targetMajor} ≥ source ${sourceMajor}`);
264
+ }
265
+ const toolMajor = await localPgRestoreMajor();
266
+ const requiredMajor = requiredRestoreToolMajor(manifest);
267
+ if (toolMajor === null) {
268
+ blockers.push("local pg_restore missing or version unparseable");
269
+ log.error("pg_restore tool: not found / version unparseable — set BACKUPDRILL_PG_RESTORE");
270
+ }
271
+ else if (toolMajor < requiredMajor) {
272
+ blockers.push(`local pg_restore v${toolMajor} < archive requirement v${requiredMajor}`);
273
+ log.error(`pg_restore tool: local v${toolMajor} cannot read a v${requiredMajor} archive`);
274
+ }
275
+ else {
276
+ log.ok(`pg_restore tool: local v${toolMajor} ≥ archive requirement v${requiredMajor}`);
277
+ }
278
+ const extensions = db.extensions ?? [];
279
+ if (extensions.length) {
280
+ const available = await client.query(`select name from pg_available_extensions where name = any($1::text[])`, [extensions.map((e) => e.name)]);
281
+ const availableNames = new Set(available.rows.map((r) => r.name));
282
+ const missing = extensions.filter((e) => !availableNames.has(e.name)).map((e) => e.name);
283
+ if (missing.length) {
284
+ blockers.push(`extensions unavailable on target: ${missing.join(", ")}`);
285
+ log.error(`extensions: ${missing.join(", ")} unavailable — enable in dashboard first`);
286
+ }
287
+ else {
288
+ log.ok(`extensions: all ${extensions.length} available on target`);
289
+ }
290
+ }
291
+ }
292
+ finally {
293
+ await client.end();
294
+ }
295
+ }
296
+ else {
297
+ log.warn("no target database URL — database restore would be skipped");
298
+ }
299
+ if (storageTarget) {
300
+ // 完整的 Storage 只读计划(评审第 16 轮):dry-run 不止测可达性,把桶校验、
301
+ // 残留净度、残留哈希全部提前撞一遍
302
+ try {
303
+ const plan = await planStorageRestore(storageTarget, manifest);
304
+ log.ok(`storage plan: ${plan.bucketsToCreate.length} bucket(s) to create, ` +
305
+ `${plan.verifiedResidue} verified residue file(s)`);
306
+ for (const warning of plan.warnings)
307
+ log.warn(warning);
308
+ }
309
+ catch (error) {
310
+ blockers.push(`storage plan: ${error.message}`);
311
+ log.error(`storage plan: ${error.message}`);
312
+ }
313
+ }
314
+ else if (manifest.storage) {
315
+ log.warn("no Storage target credentials — files would be downloaded locally, not uploaded");
316
+ }
317
+ if (blockers.length) {
318
+ throw new Error(`dry run found ${blockers.length} blocker(s):\n - ${blockers.join("\n - ")}`);
319
+ }
320
+ log.ok("dry run passed — nothing was written; re-run with --confirm-target to execute");
321
+ }
322
+ /**
323
+ * 把一份快照恢复出来:数据库经统一引擎还原进目标(与演练同一条 pg_restore 路径与
324
+ * 错误分类),恢复后跑同一套表级验证;Storage 提供目标凭据时回传+对账(P0-D),
325
+ * 否则下载到本地目录。这是 US-5 恢复向导的 CLI 形态。
95
326
  */
96
327
  export async function runRestore(config, opts) {
97
328
  const s3 = targetClient(config);
@@ -99,13 +330,74 @@ export async function runRestore(config, opts) {
99
330
  const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
100
331
  const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
101
332
  const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
333
+ // Storage 目标构造即校验(https + 恰为 <ref>.supabase.co):service key 只发往
334
+ // 已验证的 Supabase 项目源;目标库连接串拒绝 host 覆盖(身份判定=实际写入目标)
335
+ // 两个字段必须成对:只给其一时静默退回"本地下载"会让库级调用方把受保护文件
336
+ // 写进 worker 本地目录还以为回传成功了(评审第 9 轮)
337
+ if (!!opts.targetSupabaseUrl !== !!opts.targetServiceRoleKey) {
338
+ throw new Error("targetSupabaseUrl and targetServiceRoleKey must be provided together — " +
339
+ "refusing to silently fall back to a local download.");
340
+ }
341
+ const storageTarget = opts.targetSupabaseUrl && opts.targetServiceRoleKey
342
+ ? {
343
+ supabaseUrl: validateStorageTargetOrigin(opts.targetSupabaseUrl),
344
+ serviceRoleKey: opts.targetServiceRoleKey,
345
+ }
346
+ : null;
347
+ if (opts.targetDatabaseUrl)
348
+ assertNoHostOverride(opts.targetDatabaseUrl);
349
+ // 同源阻断(统一收口):目标(库或 Storage)的 ref 命中任何源项目 ref → 拒绝。
350
+ // 覆盖 storage-only 组合:目标 Storage = 备份读取源的项目时,upsert 会改写源。
351
+ const sourceRefs = sourceProjectRefs(config, manifest);
352
+ const targetRefs = [
353
+ opts.targetDatabaseUrl ? projectRefOf(opts.targetDatabaseUrl) : null,
354
+ opts.targetSupabaseUrl ? refFromSupabaseUrl(opts.targetSupabaseUrl) : null,
355
+ ].filter((r) => r !== null);
356
+ for (const ref of targetRefs) {
357
+ if (sourceRefs.has(ref)) {
358
+ throw new Error(`target project "${ref}" is the backup SOURCE — restoring onto the source is blocked. ` +
359
+ `Point the restore at a fresh Supabase project.`);
360
+ }
361
+ }
362
+ // 源身份必须**对每类目标可比**才算已知(评审第 10/14 轮):
363
+ // - 库目标:有真实源连接串即可(host+user 逐一可比),或有 ref;
364
+ // - Storage 目标:只有 ref 能比(无关的外部 DATABASE_URL 不构成 Storage 侧身份)。
365
+ // 不可比 + 未显式认知风险 = 拒绝,不 fail-open。
366
+ const refIdentityKnown = sourceRefs.size > 0;
367
+ const dbIdentityComparable = config.databaseUrl !== NO_SOURCE_DATABASE || refIdentityKnown;
368
+ const unverifiable = (opts.targetDatabaseUrl && !dbIdentityComparable) || (storageTarget && !refIdentityKnown);
369
+ if (unverifiable && !opts.acknowledgeUnverifiedSource) {
370
+ throw new Error("source identity cannot be verified for every requested target (legacy snapshot without a " +
371
+ "comparable source record) — same-source protection would be inert. Restore with the original " +
372
+ "backup config (-c backupdrill.config.json), or pass --acknowledge-unverified-source after " +
373
+ "double-checking the target is NOT the original project.");
374
+ }
102
375
  const result = {
103
376
  snapshot,
104
377
  restoredToDatabase: false,
105
378
  storageFilesWritten: 0,
106
379
  };
380
+ assertRequestApplies(manifest, !!opts.targetDatabaseUrl, !!storageTarget);
381
+ // dry-run:只读预检后直接返回,零写入(PRD §5.3.2)
382
+ if (opts.dryRun) {
383
+ log.step(`Dry run for snapshot ${snapshot} — nothing will be written`);
384
+ await dryRunPreflight(config, manifest, opts.targetDatabaseUrl, storageTarget);
385
+ result.dryRun = true;
386
+ return result;
387
+ }
388
+ // 正式执行的确认门(PRD §5.3.3):任何目标写入(库或 Storage)之前
389
+ if (opts.targetDatabaseUrl || storageTarget) {
390
+ assertConfirmedTarget(opts.confirmTarget, opts.targetDatabaseUrl, opts.targetSupabaseUrl);
391
+ }
107
392
  const workdir = await mkdtemp(join(tmpdir(), "backupdrill-restore-"));
108
393
  try {
394
+ // 0. Storage 只读计划先行(评审第 16 轮):可预见的 Storage 冲突必须在数据库
395
+ // 写入之前发现——"库好了、Storage 拒了"的半截目标是本序修掉的形态。
396
+ // 执行阶段(下方 2a)会重新计划:库恢复耗时内目标可能变化,复验是正确成本。
397
+ if (storageTarget && manifest.storage) {
398
+ log.step("Planning Storage restore (read-only, before any database write)…");
399
+ await planStorageRestore(storageTarget, manifest);
400
+ }
109
401
  // 1. 数据库(写入前依次过安全门:同源阻断 → 空目标 → 归档校验 → 扩展预装)
110
402
  if (opts.targetDatabaseUrl) {
111
403
  const targetUrl = opts.targetDatabaseUrl;
@@ -114,11 +406,44 @@ export async function runRestore(config, opts) {
114
406
  if (config.databaseUrl !== NO_SOURCE_DATABASE &&
115
407
  sameDatabaseTarget(config.databaseUrl, targetUrl)) {
116
408
  throw new Error("target database resolves to the same host and user as the backup source — " +
117
- "restoring onto the source project is blocked. Point --target-database-url " +
409
+ "restoring onto the source project is blocked. Point BACKUPDRILL_TARGET_DATABASE_URL " +
118
410
  "at a fresh Supabase project.");
119
411
  }
120
412
  log.step("Checking target is empty…");
121
413
  await assertEmptyTarget(targetUrl, manifest.database.schemas);
414
+ // 版本预检不只属于 dry-run:跳过 dry-run 直接执行时,目标比源旧照样会
415
+ // 半途炸掉或部分恢复(评审第 8 轮)——写入前必须拦住
416
+ const versionClient = new Client(pgConnectOptions(targetUrl));
417
+ await versionClient.connect();
418
+ let targetMajor;
419
+ try {
420
+ const res = await versionClient.query("select current_setting('server_version') as v");
421
+ targetMajor = parseInt(res.rows[0].v, 10);
422
+ }
423
+ finally {
424
+ await versionClient.end();
425
+ }
426
+ const sourceMajor = parseInt(manifest.database.serverVersion, 10);
427
+ if (!Number.isFinite(targetMajor) || targetMajor < sourceMajor) {
428
+ throw new Error(`target PostgreSQL (${Number.isFinite(targetMajor) ? targetMajor : "undeterminable"}) is older ` +
429
+ `than the source (${sourceMajor}) — pg_restore would fail or partially restore. ` +
430
+ `Create the target on PostgreSQL ${sourceMajor} or newer.`);
431
+ }
432
+ // 本地工具闸(评审第 10 轮):dump 归档格式跟随写入工具版本,老 pg_restore
433
+ // 读不了新归档——不查的话会先装完扩展再在恢复中途才炸
434
+ const toolMajor = await localPgRestoreMajor();
435
+ const requiredMajor = requiredRestoreToolMajor(manifest);
436
+ // 查不到版本(pg_restore 缺失/输出不可解析)= fail-closed:不能先装扩展改了目标,
437
+ // 才发现根本没有可用的恢复工具(评审第 11 轮)
438
+ if (toolMajor === null) {
439
+ throw new Error(`cannot determine the local pg_restore version — install postgresql client ${requiredMajor}+ ` +
440
+ `and/or point BACKUPDRILL_PG_RESTORE at it before restoring.`);
441
+ }
442
+ if (toolMajor < requiredMajor) {
443
+ throw new Error(`local pg_restore is v${toolMajor} but this archive needs v${requiredMajor}+ ` +
444
+ `(archive format follows the writing tool). Install postgresql client ${requiredMajor}+ ` +
445
+ `and/or point BACKUPDRILL_PG_RESTORE at it.`);
446
+ }
122
447
  log.step("Downloading dump…");
123
448
  const dumpPath = join(workdir, "dump.pgcustom");
124
449
  const { sha256 } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
@@ -158,13 +483,60 @@ export async function runRestore(config, opts) {
158
483
  `${verified.rowTotal.toLocaleString()} rows)`);
159
484
  }
160
485
  else {
161
- log.warn("No --target-database-url given; skipping database restore.");
486
+ log.warn("No database target (--database + BACKUPDRILL_TARGET_DATABASE_URL); skipping database restore.");
487
+ }
488
+ // 2a. Storage → 目标项目回传(P0-D:给了目标 Storage 凭据时)
489
+ if (manifest.storage && storageTarget) {
490
+ const summary = await restoreStorage({ s3, bucket: config.storage.bucket, base }, storageTarget, manifest);
491
+ result.storageSummary = summary;
492
+ result.storageFilesWritten = summary.filesUploaded;
493
+ log.ok(`Storage restored: ${summary.filesUploaded}/${manifest.storage.files.length} files uploaded` +
494
+ (summary.filesSkippedIdentical
495
+ ? `, ${summary.filesSkippedIdentical} already present (identical size)`
496
+ : "") +
497
+ ` (${(summary.bytesUploaded / 1024 / 1024).toFixed(1)} MB), ` +
498
+ `buckets created ${summary.bucketsCreated.length} / existing ${summary.bucketsExisting.length}`);
499
+ if (summary.bucketsWithoutAttrs.length) {
500
+ log.warn(`bucket attributes not captured for: ${summary.bucketsWithoutAttrs.join(", ")} — ` +
501
+ `created with defaults; review public/size/MIME settings by hand`);
502
+ }
503
+ for (const failed of summary.filesFailed.slice(0, 5)) {
504
+ log.warn(`file failed: ${failed.file} — ${failed.reason}`);
505
+ }
506
+ if (summary.filesFailed.length > 5) {
507
+ log.warn(`…and ${summary.filesFailed.length - 5} more file failure(s)`);
508
+ }
509
+ for (const drift of summary.bucketAttrDrift) {
510
+ log.warn(`bucket attribute drift: ${drift}`);
511
+ }
512
+ for (const note of summary.metadataNotes) {
513
+ log.warn(`metadata: ${note}`);
514
+ }
515
+ if (summary.retries)
516
+ log.warn(`transient failures retried: ${summary.retries}`);
517
+ const rec = summary.reconcile;
518
+ (rec.matches ? log.ok : log.error)(`reconcile: ${rec.verified}/${manifest.storage.files.length} file(s) present with matching size` +
519
+ (rec.missing.length ? `; MISSING: ${rec.missing.slice(0, 5).join(", ")}` : "") +
520
+ (rec.sizeMismatched.length ? `; SIZE MISMATCH: ${rec.sizeMismatched.slice(0, 5).join(", ")}` : "") +
521
+ (rec.extras ? `; ${rec.extras} extra object(s) on target not in this snapshot` : ""));
522
+ if (summary.checksumSample.checked) {
523
+ (summary.checksumSample.mismatched.length === 0 ? log.ok : log.error)(`checksum sample: ${summary.checksumSample.checked} file(s) re-hashed from target` +
524
+ (summary.checksumSample.mismatched.length
525
+ ? `, MISMATCH: ${summary.checksumSample.mismatched.join(", ")}`
526
+ : ", all match"));
527
+ }
528
+ log.warn(summary.ownerNote);
162
529
  }
163
- // 2. Storage 文件 → 本地目录
164
- if (manifest.storage && manifest.storage.files.length) {
530
+ else if (manifest.storage && manifest.storage.files.length) {
165
531
  const outDir = opts.storageDir ?? join(process.cwd(), `restored-storage-${snapshot}`);
166
532
  // 输出目录必须是新的/空的:路径锚定检查是词法层面的,防不住目录里**已有**的
167
533
  // 符号链接把写入引到别处;保证目录从空开始 = 所有子路径都由本进程创建,无链接可循
534
+ // 根路径若已存在,必须是真目录:mkdir/readdir/词法锚定都会跟随根上的符号链接,
535
+ // 一条预置的 link 就能把"新目录"指到别处(评审第 12 轮)
536
+ const rootStat = await lstat(outDir).catch(() => null);
537
+ if (rootStat && !rootStat.isDirectory()) {
538
+ throw new Error(`storage output path exists and is not a real directory: ${outDir}`);
539
+ }
168
540
  await mkdir(outDir, { recursive: true });
169
541
  if ((await readdir(outDir)).length > 0) {
170
542
  throw new Error(`storage output directory is not empty: ${outDir} — restore only writes into a fresh directory`);
@@ -0,0 +1,79 @@
1
+ import { type S3Client } from "@aws-sdk/client-s3";
2
+ import type { Manifest, StorageFile } from "./manifest.js";
3
+ export interface StorageRestoreTarget {
4
+ supabaseUrl: string;
5
+ serviceRoleKey: string;
6
+ }
7
+ export interface ReconcileReport {
8
+ /** manifest 文件在目标上逐键找到且尺寸一致的数量 */
9
+ verified: number;
10
+ missing: string[];
11
+ sizeMismatched: string[];
12
+ /** 目标上存在但 manifest 没有的对象数——非快照内容混在恢复目标里 = 不是干净恢复,计入失败裁决 */
13
+ extras: number;
14
+ matches: boolean;
15
+ }
16
+ export interface StorageRestoreSummary {
17
+ bucketsCreated: string[];
18
+ bucketsExisting: string[];
19
+ /** manifest 未捕获属性的 bucket(以默认设置创建,如实上报,不猜测) */
20
+ bucketsWithoutAttrs: string[];
21
+ /** 既有 bucket 与 manifest 属性不一致的描述(warn 级:可能是用户有意配置) */
22
+ bucketAttrDrift: string[];
23
+ filesUploaded: number;
24
+ bytesUploaded: number;
25
+ /** 目标已有同 key 同尺寸对象而跳过的数量(重试残留;字节级抽样校验覆盖) */
26
+ filesSkippedIdentical: number;
27
+ filesFailed: {
28
+ file: string;
29
+ reason: string;
30
+ }[];
31
+ /** 429/5xx/超时后的成功重试次数(可观测性:PRD §10.3) */
32
+ retries: number;
33
+ reconcile: ReconcileReport;
34
+ checksumSample: {
35
+ checked: number;
36
+ mismatched: string[];
37
+ };
38
+ /** 元数据降级说明(如 TUS 路径不保真的字段)——如实上报,不静默丢 */
39
+ metadataNotes: string[];
40
+ /** PRD §5.4.3:owner 语义边界,报告必须显示 */
41
+ ownerNote: string;
42
+ }
43
+ /** bucket 名单 = manifest.buckets(含空桶)∪ files 里出现过的桶(v1 快照没有 buckets 字段) */
44
+ export declare function bucketsToRestore(manifest: Manifest): {
45
+ name: string;
46
+ attrs: boolean;
47
+ }[];
48
+ export declare const STANDARD_UPLOAD_LIMIT_BYTES: number;
49
+ /**
50
+ * TUS 启用阈值:默认 = 6MB(Supabase 官方对 resumable 的建议线)——大文件从零重传
51
+ * 的标准上传既脆弱又贵;env 可覆盖(验证时压低,特殊网络环境可调高)。
52
+ */
53
+ export declare function tusThresholdBytes(): number;
54
+ /**
55
+ * 递归遍历目标 bucket 的全部对象(list API 是目录级的:id === null 的条目是"目录")。
56
+ * 返回 key → size。不依赖目标 DB 凭据 → storage-only 恢复也有完整对账。
57
+ */
58
+ export declare function walkTargetObjects(target: StorageRestoreTarget, bucket: string, counters?: {
59
+ retries: number;
60
+ }): Promise<Map<string, number>>;
61
+ /**
62
+ * 写前净度检查(纯函数,可测):既有桶里只允许"本快照的同尺寸残留"。
63
+ * 任何外来对象或尺寸冲突都在**写第一个字节之前**整体拒绝——部分写入的脏目标
64
+ * 比干净失败难收拾得多(评审第 14 轮)。
65
+ */
66
+ export declare function targetResidueViolations(files: StorageFile[], preexisting: Map<string, Map<string, number>>): string[];
67
+ /** 逐键对账(纯函数,可测):manifest 每个文件都必须在目标出现且尺寸一致;多余对象另计。 */
68
+ export declare function reconcileFiles(files: StorageFile[], actualByBucket: Map<string, Map<string, number>>): ReconcileReport;
69
+ /** dry-run / 执行前预检的公开形态:一次性 scratch 摘要,只返回计划概要与警告。 */
70
+ export declare function planStorageRestore(target: StorageRestoreTarget, manifest: Manifest): Promise<{
71
+ bucketsToCreate: string[];
72
+ verifiedResidue: number;
73
+ warnings: string[];
74
+ }>;
75
+ export declare function restoreStorage(source: {
76
+ s3: S3Client;
77
+ bucket: string;
78
+ base: string;
79
+ }, target: StorageRestoreTarget, manifest: Manifest): Promise<StorageRestoreSummary>;