backupdrill 2.5.0 → 2.7.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 +116 -7
- package/dist/deadline.d.ts +9 -0
- package/dist/deadline.js +29 -0
- package/dist/drill-record.d.ts +53 -0
- package/dist/drill-record.js +126 -0
- package/dist/drill.js +100 -13
- package/dist/dump-sql.d.ts +52 -0
- package/dist/dump-sql.js +112 -0
- package/dist/exporter.d.ts +57 -0
- package/dist/exporter.js +305 -0
- package/dist/external-refs.d.ts +40 -0
- package/dist/external-refs.js +437 -0
- package/dist/index.js +61 -1
- package/dist/interrupt.d.ts +27 -0
- package/dist/interrupt.js +135 -0
- package/dist/recovery-doc.js +18 -5
- package/dist/restore-engine.d.ts +20 -6
- package/dist/restore-engine.js +130 -14
- package/dist/restore.d.ts +7 -0
- package/dist/restore.js +221 -16
- package/dist/snapshots.d.ts +5 -0
- package/dist/snapshots.js +16 -8
- package/package.json +1 -1
- package/plugin/skills/supabase-backup/assets/scheduled-backup.yml +1 -1
package/dist/restore.js
CHANGED
|
@@ -7,6 +7,7 @@ import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
|
7
7
|
import { parseManifest } from "./manifest.js";
|
|
8
8
|
import { s3SnapshotStore } from "./snapshot-store.js";
|
|
9
9
|
import { restoreDatabaseArtifact, installExtensions, projectRefOf, refFromStorageEndpoint, assertNoHostOverride, normalizeHost, } from "./restore-engine.js";
|
|
10
|
+
import { checkExternalReferences, describe } from "./external-refs.js";
|
|
10
11
|
import { verifyRestored } from "./drill.js";
|
|
11
12
|
import { restoreStorage, planStorageRestore, } from "./storage-restore.js";
|
|
12
13
|
import { connectPg } from "./supabase-ca.js";
|
|
@@ -175,32 +176,106 @@ export function nonEmptyTargetMessage(schemas, objectCount, alsoRestoringStorage
|
|
|
175
176
|
* schema 内零用户对象——关系、函数/过程、独立类型(enum/domain/range/composite)
|
|
176
177
|
* 全部计入:只查关系时,"目标里只有函数"会放行,然后在 pre-data 中途撞冲突,
|
|
177
178
|
* 留下半截目标(评审第 7 轮)。
|
|
179
|
+
* 扩展自带的对象不算(pg_depend deptype 'e'):转储不带扩展成员的定义,和恢复内容撞不上;
|
|
180
|
+
* 算进去的话,用户照我们的提示在控制台把扩展开进 public,或者上一次失败的恢复已经装好了扩展,
|
|
181
|
+
* 都会被这道门挡住(2.7.0)。扩展的**配置表和配置序列**(pg_extension.extconfig)除外:pg_dump 会把
|
|
182
|
+
* 它们的数据带上,恢复会往里插重复行,序列的 setval 还不随事务回滚(交叉审查复现),照旧算已有对象。
|
|
178
183
|
*/
|
|
179
184
|
async function assertEmptyTarget(targetUrl, schemas, alsoRestoringStorage) {
|
|
185
|
+
const objectCount = await userObjectCount(targetUrl, schemas);
|
|
186
|
+
if (objectCount > 0) {
|
|
187
|
+
throw new Error(nonEmptyTargetMessage(schemas, objectCount, alsoRestoringStorage));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** 空目标门数的那些对象(见 assertEmptyTarget)有多少个。 */
|
|
191
|
+
async function userObjectCount(targetUrl, schemas) {
|
|
180
192
|
const client = await connectPg(targetUrl);
|
|
181
193
|
try {
|
|
182
194
|
const res = await client.query(`select
|
|
183
195
|
(select count(*) from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
184
|
-
where n.nspname = any($1::text[]) and c.relkind in ('r','p','m','v','S','f','c')
|
|
196
|
+
where n.nspname = any($1::text[]) and c.relkind in ('r','p','m','v','S','f','c')
|
|
197
|
+
and not (exists (select 1 from pg_depend d where d.deptype = 'e'
|
|
198
|
+
and ((d.classid = 'pg_class'::regclass and d.objid = c.oid)
|
|
199
|
+
-- 扩展建的复合类型(dblink 的 dblink_pkey_results)登记在类型上,不在关系上
|
|
200
|
+
or (d.classid = 'pg_type'::regclass and d.objid = c.reltype)))
|
|
201
|
+
and not exists (select 1 from pg_extension e where c.oid = any(e.extconfig))))
|
|
185
202
|
+ (select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
186
|
-
where n.nspname = any($1::text[])
|
|
203
|
+
where n.nspname = any($1::text[])
|
|
204
|
+
and not exists (select 1 from pg_depend d where d.classid = 'pg_proc'::regclass
|
|
205
|
+
and d.objid = p.oid and d.deptype = 'e'))
|
|
187
206
|
+ (select count(*) from pg_type t join pg_namespace n on n.oid = t.typnamespace
|
|
188
|
-
where n.nspname = any($1::text[]) and t.typtype in ('e','d','r')
|
|
207
|
+
where n.nspname = any($1::text[]) and t.typtype in ('e','d','r')
|
|
208
|
+
and not exists (select 1 from pg_depend d where d.classid = 'pg_type'::regclass
|
|
209
|
+
and d.objid = t.oid and d.deptype = 'e'))
|
|
189
210
|
as n`, [schemas]);
|
|
190
|
-
|
|
191
|
-
if (objectCount > 0) {
|
|
192
|
-
throw new Error(nonEmptyTargetMessage(schemas, objectCount, alsoRestoringStorage));
|
|
193
|
-
}
|
|
211
|
+
return Number(res.rows[0].n);
|
|
194
212
|
}
|
|
195
213
|
finally {
|
|
196
214
|
await client.end();
|
|
197
215
|
}
|
|
198
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* 恢复失败之后目标是什么状态,给报错接的那句话。单事务失败通常就是回滚了,但"通常"不够:
|
|
219
|
+
* COMMIT 发出去、回复在路上丢了,服务端已经提交,pg_restore 照样报错(交叉审查第二轮复现)。
|
|
220
|
+
* 所以不凭推断说"已回滚",而是回头数一遍目标:空的才这么说。
|
|
221
|
+
*/
|
|
222
|
+
async function targetStateAfterFailure(targetUrl, schemas) {
|
|
223
|
+
let objectCount;
|
|
224
|
+
try {
|
|
225
|
+
objectCount = await userObjectCount(targetUrl, schemas);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
return (` The restore runs in one transaction, so a failed run normally leaves nothing behind, but checking ` +
|
|
229
|
+
`the target afterwards failed (${error.message}). Check it before running restore again.`);
|
|
230
|
+
}
|
|
231
|
+
if (objectCount === 0) {
|
|
232
|
+
return (" It ran in one transaction and was rolled back, so nothing from the snapshot is on the target and " +
|
|
233
|
+
"you can run restore again on the same project once the cause is fixed.");
|
|
234
|
+
}
|
|
235
|
+
return (` The restore runs in one transaction, yet schema(s) ${schemas.join(", ")} now hold ${objectCount} ` +
|
|
236
|
+
`object(s), so it may have committed before pg_restore lost its connection. Check the target before ` +
|
|
237
|
+
`doing anything else; restore won't write into it again while those objects are there.`);
|
|
238
|
+
}
|
|
239
|
+
/** README 里讲怎么先把 Auth 用户搬过去的那一节;报错和 dry-run 都指向它。 */
|
|
240
|
+
const AUTH_USERS_GUIDE = "https://github.com/backupdrill/cli#tables-that-reference-authusers";
|
|
241
|
+
function referencesHint(schemas) {
|
|
242
|
+
return (`This backup only holds schema(s) ${schemas.join(", ")}, so those rows have to be on the target before ` +
|
|
243
|
+
`the restore. Copy them over first (Supabase Auth users: ${AUTH_USERS_GUIDE}), then run restore again.`);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 外键检查结果打印成预检行;返回 blocker 句子(空 = 不拦)。dry-run 和正式执行共用同一份判定;
|
|
247
|
+
* 正式执行时问题不在这里打印,由调用方带着提示一起抛出,免得同一句话出现两遍。
|
|
248
|
+
*/
|
|
249
|
+
function logExternalReferences(check, schemas, printProblems) {
|
|
250
|
+
for (const note of check.unchecked) {
|
|
251
|
+
log.warn(`references outside the backup: ${note} — not checked; a failed restore is rolled back`);
|
|
252
|
+
}
|
|
253
|
+
if (check.problems.length) {
|
|
254
|
+
if (printProblems) {
|
|
255
|
+
for (const problem of check.problems)
|
|
256
|
+
log.error(`references outside the backup: ${problem}`);
|
|
257
|
+
log.warn(referencesHint(schemas));
|
|
258
|
+
}
|
|
259
|
+
return check.problems;
|
|
260
|
+
}
|
|
261
|
+
if (check.checked.length) {
|
|
262
|
+
for (const { fk, rows } of check.checked) {
|
|
263
|
+
log.ok(`references outside the backup: ${describe(fk)} — ${rows.toLocaleString("en-US")} row(s), ` +
|
|
264
|
+
`all found on the target`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
else if (!check.unchecked.length) {
|
|
268
|
+
log.ok(`references outside the backup: no foreign key points outside ${schemas.join(", ")}`);
|
|
269
|
+
}
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
199
272
|
/**
|
|
200
273
|
* dry-run 只读预检(PRD §5.3.2):零写入,把正式执行会撞的墙全部提前撞一遍。
|
|
201
274
|
* 逐项打印,任何 blocker 汇总后抛出(退出码非零 = "现在执行不会成功")。
|
|
275
|
+
* 有目标库时会把 dump 下载到本地临时目录读一遍(2.7.0 起):归档校验和、指向备份之外的外键,
|
|
276
|
+
* 这两类墙只有看了 dump 才撞得到。下载是读桶,目标库上仍然只有 select。
|
|
202
277
|
*/
|
|
203
|
-
async function dryRunPreflight(config, manifest, targetUrl, storageTarget, alsoRestoringStorage) {
|
|
278
|
+
async function dryRunPreflight(config, manifest, targetUrl, storageTarget, alsoRestoringStorage, reader) {
|
|
204
279
|
const blockers = [];
|
|
205
280
|
const db = manifest.database;
|
|
206
281
|
const mb = (n) => (n / 1024 / 1024).toFixed(1);
|
|
@@ -238,6 +313,7 @@ async function dryRunPreflight(config, manifest, targetUrl, storageTarget, alsoR
|
|
|
238
313
|
blockers.push(error.message);
|
|
239
314
|
log.error(`empty-target check: ${error.message}`);
|
|
240
315
|
}
|
|
316
|
+
let toolReadsArchive = false;
|
|
241
317
|
const client = await connectPg(targetUrl);
|
|
242
318
|
try {
|
|
243
319
|
// SHOW 的结果列名是 server_version;用 current_setting 显式起别名,
|
|
@@ -267,6 +343,7 @@ async function dryRunPreflight(config, manifest, targetUrl, storageTarget, alsoR
|
|
|
267
343
|
log.error(`pg_restore tool: local v${toolMajor} cannot read a v${requiredMajor} archive`);
|
|
268
344
|
}
|
|
269
345
|
else {
|
|
346
|
+
toolReadsArchive = true;
|
|
270
347
|
log.ok(`pg_restore tool: local v${toolMajor} ≥ archive requirement v${requiredMajor}`);
|
|
271
348
|
}
|
|
272
349
|
const toolTargetBlocker = toolMajor !== null && Number.isFinite(targetMajor)
|
|
@@ -293,6 +370,12 @@ async function dryRunPreflight(config, manifest, targetUrl, storageTarget, alsoR
|
|
|
293
370
|
finally {
|
|
294
371
|
await client.end();
|
|
295
372
|
}
|
|
373
|
+
if (toolReadsArchive) {
|
|
374
|
+
blockers.push(...(await inspectDumpForTarget(manifest, targetUrl, reader)));
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
log.warn("dump not inspected (archive checksum, references outside the backup) — needs a working pg_restore");
|
|
378
|
+
}
|
|
296
379
|
}
|
|
297
380
|
else {
|
|
298
381
|
log.warn("no target database URL — database restore would be skipped");
|
|
@@ -320,6 +403,73 @@ async function dryRunPreflight(config, manifest, targetUrl, storageTarget, alsoR
|
|
|
320
403
|
}
|
|
321
404
|
log.ok("dry run passed — nothing was written; re-run with --confirm-target to execute");
|
|
322
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* 单事务恢复特有的失败:每张表连同它的索引、序列、TOAST 表在提交前都占着一个锁槽。槽数 =
|
|
408
|
+
* max_locks_per_transaction × (max_connections + …),Supabase 最小规格(max_connections 60)实测
|
|
409
|
+
* 900 张表能过、1200 张表报 out of shared memory(2026-10-23,PG 17)。分区多的库会撞上。
|
|
410
|
+
* Supabase 只能用 CLI 调这个参数(会重启数据库),照官方文档的命令给出来。
|
|
411
|
+
*/
|
|
412
|
+
export function restoreFailureAdvice(failure) {
|
|
413
|
+
if (!/max_locks_per_transaction/.test(failure))
|
|
414
|
+
return "";
|
|
415
|
+
return (" The target ran out of lock slots: a single-transaction restore holds a lock on every table, index " +
|
|
416
|
+
"and sequence until it commits. Raise max_locks_per_transaction on the target and retry. On Supabase " +
|
|
417
|
+
"that takes the Supabase CLI and restarts the database: supabase postgres-config update " +
|
|
418
|
+
"--config max_locks_per_transaction=256 --project-ref <target-ref> --experimental");
|
|
419
|
+
}
|
|
420
|
+
/** dry-run 的读 dump 部分:下载到临时目录,对校验和,查指向备份之外的外键。返回 blocker。 */
|
|
421
|
+
async function inspectDumpForTarget(manifest, targetUrl, reader) {
|
|
422
|
+
// 正式恢复先装扩展再查外键;dry-run 不装,被引用的表可能要等扩展装上才有
|
|
423
|
+
const installed = await installedExtensions(targetUrl);
|
|
424
|
+
const pendingExtensions = (manifest.database.extensions ?? [])
|
|
425
|
+
.filter((ext) => !installed.has(ext.name))
|
|
426
|
+
.map((ext) => ext.name);
|
|
427
|
+
const workdir = await mkdtemp(join(tmpdir(), "backupdrill-dryrun-"));
|
|
428
|
+
try {
|
|
429
|
+
log.step("Downloading dump to inspect it (read-only)…");
|
|
430
|
+
const dumpPath = join(workdir, "dump.pgcustom");
|
|
431
|
+
let sha256;
|
|
432
|
+
try {
|
|
433
|
+
({ sha256 } = await reader.downloadDump(manifest, dumpPath));
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
log.error(`dump download: ${error.message}`);
|
|
437
|
+
return [`dump download failed: ${error.message}`];
|
|
438
|
+
}
|
|
439
|
+
if (sha256 !== manifest.dump.sha256) {
|
|
440
|
+
log.error(`archive integrity: sha256 ${sha256.slice(0, 12)}… does not match manifest ${manifest.dump.sha256.slice(0, 12)}…`);
|
|
441
|
+
return ["archive checksum does not match the manifest"];
|
|
442
|
+
}
|
|
443
|
+
log.ok(`archive integrity: sha256 matches (${sha256.slice(0, 12)}…)`);
|
|
444
|
+
try {
|
|
445
|
+
const check = await checkExternalReferences({
|
|
446
|
+
dumpPath,
|
|
447
|
+
dumpedSchemas: manifest.database.schemas,
|
|
448
|
+
targetUrl,
|
|
449
|
+
pendingExtensions,
|
|
450
|
+
});
|
|
451
|
+
return logExternalReferences(check, manifest.database.schemas, true);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
log.error(`references outside the backup: could not read the dump — ${error.message}`);
|
|
455
|
+
return [`could not read the dump: ${error.message}`];
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
await rm(workdir, { recursive: true, force: true }).catch(() => { });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** 目标上已经装了哪些扩展。 */
|
|
463
|
+
async function installedExtensions(targetUrl) {
|
|
464
|
+
const client = await connectPg(targetUrl);
|
|
465
|
+
try {
|
|
466
|
+
const res = await client.query("select extname from pg_extension");
|
|
467
|
+
return new Set(res.rows.map((row) => row.extname));
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
await client.end();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
323
473
|
/**
|
|
324
474
|
* 把一份快照恢复出来:数据库经统一引擎还原进目标(与演练同一条 pg_restore 路径与
|
|
325
475
|
* 错误分类),恢复后跑同一套表级验证;Storage 提供目标凭据时回传+对账(P0-D),
|
|
@@ -396,7 +546,7 @@ export async function runRestore(config, opts) {
|
|
|
396
546
|
// dry-run:只读预检后直接返回,零写入(PRD §5.3.2)
|
|
397
547
|
if (opts.dryRun) {
|
|
398
548
|
log.step(`Dry run for snapshot ${snapshot} — nothing will be written`);
|
|
399
|
-
await dryRunPreflight(config, manifest, opts.targetDatabaseUrl, storageTarget, alsoRestoringStorage);
|
|
549
|
+
await dryRunPreflight(config, manifest, opts.targetDatabaseUrl, storageTarget, alsoRestoringStorage, reader);
|
|
400
550
|
result.dryRun = true;
|
|
401
551
|
return result;
|
|
402
552
|
}
|
|
@@ -472,22 +622,77 @@ export async function runRestore(config, opts) {
|
|
|
472
622
|
throw new Error(`archive integrity check failed: downloaded sha256 ${sha256.slice(0, 12)}… ` +
|
|
473
623
|
`does not match manifest ${manifest.dump.sha256.slice(0, 12)}… — refusing to restore a corrupted dump`);
|
|
474
624
|
}
|
|
475
|
-
//
|
|
476
|
-
//
|
|
625
|
+
// 指向备份之外的外键(典型:profiles.id → auth.users),目标上缺被引用的行,恢复必然失败。
|
|
626
|
+
// 和 dry-run 同一份检查,在往目标写任何东西(连扩展都还没装)之前拦下,报错直接说缺什么、去哪补。
|
|
627
|
+
// 被引用的表也可能是待装的扩展建的:这种先记下,装完扩展再查一遍。
|
|
628
|
+
// (先装扩展再查的话,查不过时扩展已经留在目标上,带配置表的扩展会让修好之后的重跑被空目标门拦下)
|
|
477
629
|
const extensions = manifest.database.extensions ?? [];
|
|
630
|
+
const installed = extensions.length ? await installedExtensions(targetUrl) : new Set();
|
|
631
|
+
const pendingExtensions = extensions.filter((ext) => !installed.has(ext.name)).map((ext) => ext.name);
|
|
632
|
+
const checkReferences = async (pending, opts) => {
|
|
633
|
+
log.step("Checking foreign keys that point outside the backup…");
|
|
634
|
+
const references = await checkExternalReferences({
|
|
635
|
+
dumpPath,
|
|
636
|
+
dumpedSchemas: manifest.database.schemas,
|
|
637
|
+
targetUrl,
|
|
638
|
+
pendingExtensions: pending,
|
|
639
|
+
inTransaction: opts.inTransaction,
|
|
640
|
+
});
|
|
641
|
+
const referenceProblems = logExternalReferences(references, manifest.database.schemas, false);
|
|
642
|
+
if (referenceProblems.length && opts.final) {
|
|
643
|
+
throw new Error(`the restore would fail on foreign keys, so nothing from the snapshot was written:\n - ` +
|
|
644
|
+
`${referenceProblems.join("\n - ")}\n${referencesHint(manifest.database.schemas)}`);
|
|
645
|
+
}
|
|
646
|
+
return references;
|
|
647
|
+
};
|
|
648
|
+
// 还有扩展没装时,第一遍查出的问题(缺行、缺列、缺唯一索引)先不下结论:扩展装上可能补上它们
|
|
649
|
+
// (交叉审查复现过往已有表里写种子数据、建唯一索引的扩展)。交给下面"装扩展 + 复查"的事务去定,
|
|
650
|
+
// 复查不过照样拦、连扩展一起回滚。
|
|
651
|
+
// dry-run 装不了扩展,这些照旧报成 blocker:新建的 Supabase 项目通常还没装源库用过的扩展,
|
|
652
|
+
// 要是 dry-run 也先不下结论,最常见的"auth.users 是空的"就只剩一条警告了。代价是那种往别人的表里
|
|
653
|
+
// 补数据的扩展,dry-run 会比正式恢复更严
|
|
654
|
+
const firstCheck = await checkReferences(pendingExtensions, { final: pendingExtensions.length === 0 });
|
|
655
|
+
// 扩展预装:沙箱装不上只能记录(环境局限),真实目标装不上必须阻断——
|
|
656
|
+
// 用户在 dashboard 一键可开,恢复残缺副本则要人工收拾(引擎头注的裁决分工)。
|
|
657
|
+
// 有外键要等扩展装上才查得了时,装扩展和复查放在同一个事务里,复查过了才提交:复查不过就连扩展
|
|
658
|
+
// 一起回滚,目标原样不动(否则带配置表的扩展留下来,修好之后的重跑会被空目标门拦下,交叉审查复现)。
|
|
659
|
+
// 正式恢复(pg_restore)失败时装上的扩展留着:下一次运行照样要它们,空目标门也不数扩展自带的对象
|
|
660
|
+
// (配置表除外)。曾经在失败后删掉"本次新装的"扩展,交叉审查复现了它会误删别的连接同时装上的扩展。
|
|
478
661
|
if (extensions.length) {
|
|
479
662
|
log.step(`Installing ${extensions.length} extension(s) on target…`);
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
663
|
+
const unavailableMessage = (unavailable) => `target cannot install extension(s): ${unavailable.join(", ")} — enable them in ` +
|
|
664
|
+
`the Supabase dashboard (Database → Extensions) and retry.`;
|
|
665
|
+
if (firstCheck.waitingForExtensions || firstCheck.problems.length) {
|
|
666
|
+
const client = await connectPg(targetUrl);
|
|
667
|
+
try {
|
|
668
|
+
await client.query("begin");
|
|
669
|
+
const unavailable = await installExtensions(targetUrl, extensions, client);
|
|
670
|
+
if (unavailable.length)
|
|
671
|
+
throw new Error(unavailableMessage(unavailable));
|
|
672
|
+
await checkReferences([], { inTransaction: client, final: true });
|
|
673
|
+
await client.query("commit");
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
await client.query("rollback").catch(() => { });
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
679
|
+
finally {
|
|
680
|
+
await client.end();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
const unavailable = await installExtensions(targetUrl, extensions);
|
|
685
|
+
if (unavailable.length)
|
|
686
|
+
throw new Error(unavailableMessage(unavailable));
|
|
484
687
|
}
|
|
485
688
|
}
|
|
486
689
|
log.step("Restoring database into target…");
|
|
487
690
|
const engine = await restoreDatabaseArtifact({ dumpPath, connString: targetUrl, target: "supabase" });
|
|
488
691
|
if (!engine.ok) {
|
|
489
692
|
const failures = [...engine.preData.failures, ...engine.postData.failures];
|
|
490
|
-
throw new Error(`database restore failed: ${failures.join(" | ")}
|
|
693
|
+
throw new Error(`database restore failed: ${failures.join(" | ")}.` +
|
|
694
|
+
(await targetStateAfterFailure(targetUrl, manifest.database.schemas)) +
|
|
695
|
+
restoreFailureAdvice(failures.join(" ")));
|
|
491
696
|
}
|
|
492
697
|
result.restoredToDatabase = true;
|
|
493
698
|
// 与演练同一套表级验证:两条路径对同一快照必须同一结论(PRD §5.2 验收)
|
package/dist/snapshots.d.ts
CHANGED
|
@@ -2,6 +2,11 @@ import { S3Client } from "@aws-sdk/client-s3";
|
|
|
2
2
|
import { type BackupConfig } from "./config.js";
|
|
3
3
|
/** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
|
|
4
4
|
export declare function targetClient(config: BackupConfig): S3Client;
|
|
5
|
+
/**
|
|
6
|
+
* 列出 root(带尾斜杠)下的全部快照目录,返回带尾斜杠的前缀,未排序。只看 CommonPrefixes,
|
|
7
|
+
* root 下直接放的对象不算快照。
|
|
8
|
+
*/
|
|
9
|
+
export declare function listSnapshotPrefixes(s3: S3Client, bucket: string, root: string, abortSignal?: AbortSignal): Promise<string[]>;
|
|
5
10
|
/** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
|
|
6
11
|
export declare function resolveSnapshot(s3: S3Client, config: BackupConfig, explicit?: string): Promise<string>;
|
|
7
12
|
export declare function getObjectText(s3: S3Client, bucket: string, key: string): Promise<string>;
|
package/dist/snapshots.js
CHANGED
|
@@ -18,27 +18,35 @@ export function targetClient(config) {
|
|
|
18
18
|
},
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return `${root}${explicit}/`;
|
|
21
|
+
/**
|
|
22
|
+
* 列出 root(带尾斜杠)下的全部快照目录,返回带尾斜杠的前缀,未排序。只看 CommonPrefixes,
|
|
23
|
+
* root 下直接放的对象不算快照。
|
|
24
|
+
*/
|
|
25
|
+
export async function listSnapshotPrefixes(s3, bucket, root, abortSignal) {
|
|
27
26
|
const prefixes = [];
|
|
28
27
|
let token;
|
|
29
28
|
do {
|
|
30
29
|
const res = await s3.send(new ListObjectsV2Command({
|
|
31
|
-
Bucket:
|
|
30
|
+
Bucket: bucket,
|
|
32
31
|
Prefix: root,
|
|
33
32
|
Delimiter: "/",
|
|
34
33
|
ContinuationToken: token,
|
|
35
|
-
}));
|
|
34
|
+
}), { abortSignal });
|
|
36
35
|
for (const cp of res.CommonPrefixes ?? []) {
|
|
37
36
|
if (cp.Prefix)
|
|
38
37
|
prefixes.push(cp.Prefix);
|
|
39
38
|
}
|
|
40
39
|
token = res.IsTruncated ? res.NextContinuationToken : undefined;
|
|
41
40
|
} while (token);
|
|
41
|
+
return prefixes;
|
|
42
|
+
}
|
|
43
|
+
/** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
|
|
44
|
+
export async function resolveSnapshot(s3, config, explicit) {
|
|
45
|
+
const storage = requireBucket(config);
|
|
46
|
+
const root = `${storage.prefix}/${config.projectName}/`;
|
|
47
|
+
if (explicit)
|
|
48
|
+
return `${root}${explicit}/`;
|
|
49
|
+
const prefixes = await listSnapshotPrefixes(s3, storage.bucket, root);
|
|
42
50
|
if (!prefixes.length) {
|
|
43
51
|
throw new Error(`No snapshots found under s3://${storage.bucket}/${root}. Run a backup first.`);
|
|
44
52
|
}
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
#
|
|
9
9
|
# REQUIRED SECRETS (database backup)
|
|
10
10
|
# BACKUPDRILL_DATABASE_URL Supabase Session Pooler connection string
|
|
11
|
-
# (
|
|
11
|
+
# (dashboard: Connect → Session pooler).
|
|
12
12
|
# The default postgres user works as-is; a
|
|
13
13
|
# read-only role usually fails under RLS —
|
|
14
14
|
# see the README's connection-string section.
|