backupdrill 1.0.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/README.md +34 -7
- package/dist/backup.js +47 -0
- package/dist/drill.d.ts +8 -5
- package/dist/drill.js +27 -96
- package/dist/index.js +100 -20
- package/dist/lib.d.ts +1 -1
- package/dist/manifest.d.ts +32 -1
- package/dist/manifest.js +157 -3
- package/dist/recovery-doc.d.ts +11 -0
- package/dist/recovery-doc.js +123 -0
- package/dist/restore-engine.d.ts +80 -0
- package/dist/restore-engine.js +267 -0
- package/dist/restore.d.ts +65 -2
- package/dist/restore.js +515 -23
- package/dist/storage-catalog.d.ts +19 -0
- package/dist/storage-catalog.js +50 -0
- package/dist/storage-restore.d.ts +79 -0
- package/dist/storage-restore.js +634 -0
- package/dist/storage.d.ts +16 -1
- package/dist/storage.js +33 -2
- package/package.json +1 -1
package/dist/manifest.js
CHANGED
|
@@ -6,11 +6,155 @@
|
|
|
6
6
|
* - 缺失该字段的 manifest = 版本 1(1.0 之前写出的旧快照)
|
|
7
7
|
* - 读取端遇到**更高**的版本必须明确报错,而不是按旧结构硬解析出错误结果
|
|
8
8
|
* ——对备份工具来说,"旧程序静默误读新归档"是最不能接受的失败方式。
|
|
9
|
+
*
|
|
10
|
+
* 版本史:
|
|
11
|
+
* - 1:初始格式。
|
|
12
|
+
* - 2(恢复闭环 PRD §5.1):storage 增加 buckets 属性清单(public/大小/MIME 限制),
|
|
13
|
+
* files 增加 contentType/cacheControl/metadata 等恢复所需元数据;tables 预留
|
|
14
|
+
* 验证升级字段(本期不生成)。全部为**可加字段**——v2 读取端兼容 v1 快照。
|
|
15
|
+
*/
|
|
16
|
+
export const MANIFEST_SCHEMA_VERSION = 2;
|
|
17
|
+
/** 结构断言失败时的统一出错口径:说清哪个字段坏了,让"桶里的 manifest 被篡改/截断"可定位。 */
|
|
18
|
+
function malformed(what) {
|
|
19
|
+
throw new Error(`manifest.json is malformed: ${what}`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 演练/恢复实际依赖的字段做结构校验(手写而非 JSON Schema 依赖:CLI 保持零校验库依赖,
|
|
23
|
+
* 校验面 = 恢复代码真正会解引用的路径,新增可选字段不在此列——它们缺失是合法的 v1 形态)。
|
|
24
|
+
* 半截上传、被篡改或非本工具写的 JSON 要在这里被拦下,而不是在 pg_restore 中途炸开。
|
|
9
25
|
*/
|
|
10
|
-
|
|
26
|
+
/** 可选字段:缺席合法(v1 形态),在场就必须是期望类型——错型值会驱动恢复端做错事。 */
|
|
27
|
+
function optionalString(value, what) {
|
|
28
|
+
if (value !== undefined && typeof value !== "string")
|
|
29
|
+
malformed(what);
|
|
30
|
+
}
|
|
31
|
+
function assertManifestShape(m) {
|
|
32
|
+
// ref 驱动同源安全门:malformed 值会被当"身份已知"却永远匹配不上 = 保护静默失效
|
|
33
|
+
if (m.sourceProjectRef !== undefined && !/^[a-z0-9]{16,}$/.test(m.sourceProjectRef)) {
|
|
34
|
+
malformed("sourceProjectRef (not a Supabase project ref)");
|
|
35
|
+
}
|
|
36
|
+
const d = m.dump;
|
|
37
|
+
if (typeof d !== "object" || d === null)
|
|
38
|
+
malformed("dump section missing");
|
|
39
|
+
const dump = m.dump;
|
|
40
|
+
if (typeof dump.key !== "string" || !dump.key)
|
|
41
|
+
malformed("dump.key");
|
|
42
|
+
if (typeof dump.sha256 !== "string" || !dump.sha256)
|
|
43
|
+
malformed("dump.sha256");
|
|
44
|
+
if (typeof dump.bytes !== "number")
|
|
45
|
+
malformed("dump.bytes");
|
|
46
|
+
if (typeof m.database !== "object" || m.database === null)
|
|
47
|
+
malformed("database section missing");
|
|
48
|
+
// serverVersion 决定沙箱镜像 tag(parseInt)——不可解析的值会在起容器后才炸
|
|
49
|
+
if (typeof m.database.serverVersion !== "string" || !(parseInt(m.database.serverVersion, 10) > 0)) {
|
|
50
|
+
malformed("database.serverVersion");
|
|
51
|
+
}
|
|
52
|
+
// 恢复端的工具版本闸要解析它(requiredRestoreToolMajor)——错型值不能到那才炸
|
|
53
|
+
if (typeof m.database.pgDumpVersion !== "string" || !m.database.pgDumpVersion) {
|
|
54
|
+
malformed("database.pgDumpVersion");
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(m.database.schemas) || m.database.schemas.some((s) => typeof s !== "string" || !s)) {
|
|
57
|
+
malformed("database.schemas");
|
|
58
|
+
}
|
|
59
|
+
if (m.database.extensions !== undefined) {
|
|
60
|
+
if (!Array.isArray(m.database.extensions))
|
|
61
|
+
malformed("database.extensions");
|
|
62
|
+
for (const e of m.database.extensions) {
|
|
63
|
+
// 这些值会进 CREATE EXTENSION/SCHEMA 的标识符(quoteIdent 兜住注入,这里兜类型)
|
|
64
|
+
if (typeof e?.name !== "string" || !e.name || typeof e?.version !== "string" || typeof e?.schema !== "string" || !e.schema) {
|
|
65
|
+
malformed("database.extensions[]");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (typeof m.database.tableCount !== "number")
|
|
70
|
+
malformed("database.tableCount");
|
|
71
|
+
if (!Array.isArray(m.database.tables))
|
|
72
|
+
malformed("database.tables");
|
|
73
|
+
// 写入端恒有 tableCount === tables.length;不相等 = tables 数组被截断/篡改,
|
|
74
|
+
// 逐表检查会静默缩水而表数检查还显得正常
|
|
75
|
+
if (m.database.tableCount !== m.database.tables.length) {
|
|
76
|
+
malformed("database.tableCount does not match tables.length");
|
|
77
|
+
}
|
|
78
|
+
for (const t of m.database.tables) {
|
|
79
|
+
if (typeof t?.schema !== "string" || typeof t?.name !== "string")
|
|
80
|
+
malformed("database.tables[]");
|
|
81
|
+
// estimatedRows 缺失/错型会让演练的"备份时非空的表恢复后不为空"检查静默失效
|
|
82
|
+
// (undefined > 0 恒 false)——空表假 PASS 是最不能接受的失败方式
|
|
83
|
+
if (typeof t.estimatedRows !== "number" || !Number.isFinite(t.estimatedRows) || t.estimatedRows < 0) {
|
|
84
|
+
malformed("database.tables[].estimatedRows");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// 写入端恒写 storage: null(DB-only)或对象——字段整体缺失只可能是截断/损坏,
|
|
88
|
+
// 静默当成 DB-only 会跳过全部 Storage 验证与恢复
|
|
89
|
+
if (m.storage === undefined)
|
|
90
|
+
malformed("storage section missing (null means DB-only)");
|
|
91
|
+
if (m.storage !== null) {
|
|
92
|
+
if (typeof m.storage !== "object")
|
|
93
|
+
malformed("storage section");
|
|
94
|
+
if (typeof m.storage.fileCount !== "number" || typeof m.storage.totalBytes !== "number") {
|
|
95
|
+
malformed("storage.fileCount/totalBytes");
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(m.storage.files))
|
|
98
|
+
malformed("storage.files");
|
|
99
|
+
// 聚合数与清单对账:files 被截断而 fileCount 完好时,Storage 验证会静默缩水
|
|
100
|
+
if (m.storage.fileCount !== m.storage.files.length) {
|
|
101
|
+
malformed("storage.fileCount does not match files.length");
|
|
102
|
+
}
|
|
103
|
+
const byteSum = m.storage.files.reduce((n, f) => n + (typeof f?.bytes === "number" ? f.bytes : 0), 0);
|
|
104
|
+
if (m.storage.totalBytes !== byteSum)
|
|
105
|
+
malformed("storage.totalBytes does not match files");
|
|
106
|
+
for (const f of m.storage.files) {
|
|
107
|
+
if (typeof f?.bucket !== "string" || typeof f?.key !== "string")
|
|
108
|
+
malformed("storage.files[]");
|
|
109
|
+
if (typeof f.bytes !== "number" || typeof f.sha256 !== "string")
|
|
110
|
+
malformed("storage.files[]");
|
|
111
|
+
// 路径穿越防线:bucket/key 会被拼进本地写路径(restore)与目标上传路径。
|
|
112
|
+
// manifest 是"从桶里来的数据"——被篡改时不得升级成任意文件写。
|
|
113
|
+
// 不破坏向后兼容(2026-07-24 实测):Supabase 在写入时就把 //、/./、/../ 和
|
|
114
|
+
// 前导 / 规范化掉、拒绝反斜杠 —— storage.objects.name(备份列举到的形态)
|
|
115
|
+
// 永远不含这些段,合法快照不可能被此处拒绝。
|
|
116
|
+
if (!f.bucket || f.bucket.includes("/") || f.bucket.includes("\\") || f.bucket.includes("\0") || f.bucket === "." || f.bucket === "..") {
|
|
117
|
+
malformed("storage.files[].bucket (unsafe path segment)");
|
|
118
|
+
}
|
|
119
|
+
if (!f.key ||
|
|
120
|
+
f.key.includes("\\") ||
|
|
121
|
+
f.key.includes("\0") ||
|
|
122
|
+
f.key.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) {
|
|
123
|
+
malformed("storage.files[].key (unsafe path segment)");
|
|
124
|
+
}
|
|
125
|
+
optionalString(f.contentType, "storage.files[].contentType");
|
|
126
|
+
optionalString(f.cacheControl, "storage.files[].cacheControl");
|
|
127
|
+
optionalString(f.contentEncoding, "storage.files[].contentEncoding");
|
|
128
|
+
optionalString(f.lastModified, "storage.files[].lastModified");
|
|
129
|
+
if (f.metadata !== undefined && (typeof f.metadata !== "object" || f.metadata === null || Array.isArray(f.metadata))) {
|
|
130
|
+
malformed("storage.files[].metadata");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (m.storage.buckets !== undefined) {
|
|
134
|
+
if (!Array.isArray(m.storage.buckets))
|
|
135
|
+
malformed("storage.buckets");
|
|
136
|
+
for (const b of m.storage.buckets) {
|
|
137
|
+
if (typeof b?.name !== "string" || !b.name)
|
|
138
|
+
malformed("storage.buckets[]");
|
|
139
|
+
if (b.public !== null && b.public !== undefined && typeof b.public !== "boolean") {
|
|
140
|
+
malformed("storage.buckets[].public");
|
|
141
|
+
}
|
|
142
|
+
if (b.fileSizeLimit !== null && b.fileSizeLimit !== undefined && typeof b.fileSizeLimit !== "number") {
|
|
143
|
+
malformed("storage.buckets[].fileSizeLimit");
|
|
144
|
+
}
|
|
145
|
+
if (b.allowedMimeTypes !== null &&
|
|
146
|
+
b.allowedMimeTypes !== undefined &&
|
|
147
|
+
!(Array.isArray(b.allowedMimeTypes) && b.allowedMimeTypes.every((v) => typeof v === "string"))) {
|
|
148
|
+
malformed("storage.buckets[].allowedMimeTypes");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
11
154
|
/**
|
|
12
155
|
* 解析桶里的 manifest.json,并做**向前兼容**校验:比本程序更新的格式一律拒绝,
|
|
13
156
|
* 明确告诉用户升级,绝不按旧结构硬解析(那会静默产出错误的演练/恢复结果)。
|
|
157
|
+
* 版本关之后再做结构校验(assertManifestShape)——两类失败要给出不同的处置指引。
|
|
14
158
|
*/
|
|
15
159
|
export function parseManifest(json) {
|
|
16
160
|
let parsed;
|
|
@@ -20,11 +164,21 @@ export function parseManifest(json) {
|
|
|
20
164
|
catch (error) {
|
|
21
165
|
throw new Error(`manifest.json is not valid JSON: ${error.message}`);
|
|
22
166
|
}
|
|
23
|
-
|
|
24
|
-
if (
|
|
167
|
+
// 根检查必须先于一切属性访问:JSON 里合法的 null/数字/字符串会把裸 TypeError 抛给用户
|
|
168
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
169
|
+
malformed("root is not an object");
|
|
170
|
+
}
|
|
171
|
+
// 版本字段:**属性缺席**才算 1.0 之前的旧快照;显式 null/0/负数/小数/字符串
|
|
172
|
+
// 都不是本工具写过的值,按坏 manifest 拒绝,不得混过前向兼容闸门
|
|
173
|
+
const version = "schemaVersion" in parsed ? parsed.schemaVersion : 1;
|
|
174
|
+
if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
|
|
175
|
+
malformed(`schemaVersion ${JSON.stringify(parsed.schemaVersion)}`);
|
|
176
|
+
}
|
|
177
|
+
if (version > MANIFEST_SCHEMA_VERSION) {
|
|
25
178
|
throw new Error(`This snapshot's manifest uses schema version ${String(parsed.schemaVersion)}, but this ` +
|
|
26
179
|
`BackupDrill understands up to ${MANIFEST_SCHEMA_VERSION}. Upgrade the CLI ` +
|
|
27
180
|
`(npm i -g backupdrill@latest) to read it.`);
|
|
28
181
|
}
|
|
182
|
+
assertManifestShape(parsed);
|
|
29
183
|
return parsed;
|
|
30
184
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Manifest } from "./manifest.js";
|
|
2
|
+
/** 生成手册所需的非秘密快照坐标。刻意不收连接串/密钥类型——签名即防线。 */
|
|
3
|
+
export interface RecoveryDocContext {
|
|
4
|
+
snapshot: string;
|
|
5
|
+
bucket: string;
|
|
6
|
+
endpoint?: string;
|
|
7
|
+
region?: string;
|
|
8
|
+
prefix: string;
|
|
9
|
+
projectName: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function renderRecoveryDoc(manifest: Manifest, ctx: RecoveryDocContext): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
function quoteSqlIdent(name) {
|
|
2
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
3
|
+
}
|
|
4
|
+
/** POSIX 安全的 shell 值引用:项目名/前缀里合法的空格与元字符不得拆散或改写命令。 */
|
|
5
|
+
function shellQuote(value) {
|
|
6
|
+
return /^[A-Za-z0-9._/:@=-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
}
|
|
8
|
+
export function renderRecoveryDoc(manifest, ctx) {
|
|
9
|
+
const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
|
|
10
|
+
const db = manifest.database;
|
|
11
|
+
const extensions = db.extensions ?? [];
|
|
12
|
+
const storage = manifest.storage;
|
|
13
|
+
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
|
|
14
|
+
// 归档格式跟随 dump 工具版本(允许比服务端新):pg_restore 必须 ≥ 两者较大值,
|
|
15
|
+
// 只按服务端版本指引会让 PG17 的 pg_restore 读不了 PG18 pg_dump 写的归档。
|
|
16
|
+
// 解析口径与 backup.ts parsePgDumpMajor 一致(不 import:backup → recovery-doc 会成环)
|
|
17
|
+
const dumpToolMajor = db.pgDumpVersion.match(/\(PostgreSQL\)\s+(\d+)/)?.[1] ??
|
|
18
|
+
db.pgDumpVersion.match(/(\d+)(?:\.\d+)+/)?.[1];
|
|
19
|
+
const requiredRestoreMajor = Math.max(Number(dumpToolMajor ?? 0), parseInt(db.serverVersion, 10) || 0);
|
|
20
|
+
const extensionSql = extensions.length
|
|
21
|
+
? extensions
|
|
22
|
+
.map((e) => `create schema if not exists ${quoteSqlIdent(e.schema)};\n` +
|
|
23
|
+
`create extension if not exists ${quoteSqlIdent(e.name)} schema ${quoteSqlIdent(e.schema)} cascade;`)
|
|
24
|
+
.join("\n")
|
|
25
|
+
: "-- (this snapshot records no extensions)";
|
|
26
|
+
// 2.0 形态:凭据只经环境变量(argv 对全机器可见);写入需要键入目标 ref 确认。
|
|
27
|
+
// DB-only 快照不出现 Storage 凭据与旗标——不为用不上的能力索要宽权限 key
|
|
28
|
+
const cliRestoreCommand = [
|
|
29
|
+
`read -rs BACKUPDRILL_TARGET_DATABASE_URL && export BACKUPDRILL_TARGET_DATABASE_URL # hidden prompt`,
|
|
30
|
+
...(storage
|
|
31
|
+
? [`read -rs BACKUPDRILL_TARGET_SERVICE_ROLE_KEY && export BACKUPDRILL_TARGET_SERVICE_ROLE_KEY`]
|
|
32
|
+
: []),
|
|
33
|
+
`backupdrill restore \\`,
|
|
34
|
+
` --snapshot ${shellQuote(ctx.snapshot)} \\`,
|
|
35
|
+
` --bucket ${shellQuote(ctx.bucket)} \\`,
|
|
36
|
+
...(ctx.endpoint ? [` --endpoint ${shellQuote(ctx.endpoint)} \\`] : []),
|
|
37
|
+
...(ctx.region ? [` --region ${shellQuote(ctx.region)} \\`] : []),
|
|
38
|
+
` --prefix ${shellQuote(ctx.prefix)} \\`,
|
|
39
|
+
` --project-name ${shellQuote(ctx.projectName)} \\`,
|
|
40
|
+
` --database \\`,
|
|
41
|
+
...(storage ? [` --target-supabase-url https://<target-ref>.supabase.co \\`] : []),
|
|
42
|
+
` --confirm-target <target-ref>`,
|
|
43
|
+
].join("\n");
|
|
44
|
+
return `# BackupDrill recovery runbook — ${ctx.projectName} @ ${ctx.snapshot}
|
|
45
|
+
|
|
46
|
+
This file was written next to the snapshot it describes and is **self-contained**:
|
|
47
|
+
even if BackupDrill (the service and the website) is unreachable, everything below
|
|
48
|
+
works with standard PostgreSQL tools against this bucket alone.
|
|
49
|
+
|
|
50
|
+
## What this snapshot contains
|
|
51
|
+
|
|
52
|
+
- **Database dump**: \`${manifest.dump.key}\`
|
|
53
|
+
(pg_dump custom format, ${mb(manifest.dump.bytes)} MB, sha256 \`${manifest.dump.sha256}\`)
|
|
54
|
+
- schemas: ${db.schemas.join(", ")} — ${db.tableCount} tables, ~${db.estimatedRowTotal.toLocaleString("en-US")} rows at backup time
|
|
55
|
+
- source PostgreSQL ${db.serverVersion}, archive written by ${db.pgDumpVersion} — restore with pg_restore ${requiredRestoreMajor} or newer (the archive format follows the dump tool, not the server)
|
|
56
|
+
${extensions.length
|
|
57
|
+
? ` - extensions required: ${extensions.map((e) => `${e.name} (schema "${e.schema}")`).join(", ")}`
|
|
58
|
+
: " - no extensions recorded"}
|
|
59
|
+
- **Storage files**: ${storage
|
|
60
|
+
? `${storage.fileCount} files, ${mb(storage.totalBytes)} MB under \`${base}/storage/<bucket>/<key>\` — per-file sha256 in manifest.json`
|
|
61
|
+
: "none (database-only snapshot)"}
|
|
62
|
+
- **Machine-readable inventory**: \`${base}/manifest.json\`
|
|
63
|
+
|
|
64
|
+
## Not covered — plan for these separately
|
|
65
|
+
|
|
66
|
+
Supabase **Auth users/sessions, secret values, Edge Function code and most
|
|
67
|
+
platform settings are NOT in this snapshot**. After restoring, reconfigure them
|
|
68
|
+
by hand. This is a database + Storage snapshot, not a full project image.
|
|
69
|
+
|
|
70
|
+
## Restore the database (into an EMPTY project)
|
|
71
|
+
|
|
72
|
+
1. Create a **fresh** Supabase project (never restore onto the source project),
|
|
73
|
+
and use its **Session Pooler** connection string.
|
|
74
|
+
2. Install the required extensions first (the dump references their types):
|
|
75
|
+
|
|
76
|
+
\`\`\`sql
|
|
77
|
+
${extensionSql}
|
|
78
|
+
\`\`\`
|
|
79
|
+
|
|
80
|
+
3. Download \`${manifest.dump.key}\` from this bucket, then verify it:
|
|
81
|
+
|
|
82
|
+
\`\`\`bash
|
|
83
|
+
shasum -a 256 dump.pgcustom # must print ${manifest.dump.sha256}
|
|
84
|
+
\`\`\`
|
|
85
|
+
|
|
86
|
+
4. Restore — **without \`--clean\`**, into the empty target. Enter the password at
|
|
87
|
+
a hidden prompt so it never touches the URL, argv, or shell history:
|
|
88
|
+
|
|
89
|
+
\`\`\`bash
|
|
90
|
+
read -rs PGPASSWORD && export PGPASSWORD # hidden prompt
|
|
91
|
+
pg_restore --no-owner --no-privileges \\
|
|
92
|
+
--dbname "postgresql://postgres.<target-ref>@<target-pooler-host>:5432/postgres" dump.pgcustom
|
|
93
|
+
unset PGPASSWORD
|
|
94
|
+
\`\`\`
|
|
95
|
+
|
|
96
|
+
One error is expected and harmless: \`schema "public" already exists\` (the
|
|
97
|
+
target always has it). Do **not** use \`--clean\` against a Supabase project —
|
|
98
|
+
dropping and recreating \`public\` silently wipes Supabase's default grants for
|
|
99
|
+
\`anon\`/\`authenticated\`, and the restored API answers 401.
|
|
100
|
+
|
|
101
|
+
## Restore Storage files
|
|
102
|
+
|
|
103
|
+
Files live at \`${base}/storage/<original-bucket>/<original-key>\` in this
|
|
104
|
+
bucket. Recreate each bucket in the target project, then re-upload the files
|
|
105
|
+
${storage?.buckets ? "(bucket attributes — public flag, size and MIME limits — are recorded in manifest.json under `storage.buckets`)" : "(bucket attributes were not captured for this snapshot; recreate buckets with the settings you know)"}.
|
|
106
|
+
|
|
107
|
+
## Easiest path — the BackupDrill CLI
|
|
108
|
+
|
|
109
|
+
\`\`\`bash
|
|
110
|
+
npm install -g backupdrill
|
|
111
|
+
${cliRestoreCommand}
|
|
112
|
+
\`\`\`
|
|
113
|
+
|
|
114
|
+
Add \`--dry-run\` first to preview every check without writing anything. (Skip the
|
|
115
|
+
\`read\` lines to have the CLI prompt you itself; \`unset\` both variables afterwards.)
|
|
116
|
+
The CLI verifies the archive checksum, refuses non-empty targets, installs the
|
|
117
|
+
extensions, restores through the same engine the weekly drills use, verifies
|
|
118
|
+
table counts and row presence afterwards, then rebuilds buckets, uploads the
|
|
119
|
+
Storage files back and reconciles every one against the target.
|
|
120
|
+
|
|
121
|
+
More: https://backupdrill.com/docs/restore
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ExtensionInfo } from "./manifest.js";
|
|
2
|
+
export type RestoreTargetKind = "sandbox" | "supabase";
|
|
3
|
+
/** 主机名规范化:剥掉合法的 DNS 根点(db.x.supabase.co. ≡ db.x.supabase.co)。
|
|
4
|
+
* 身份比较不规范化 = 一个尾点就能绕过同源阻断(supabase-ca 对同类早有钉子)。 */
|
|
5
|
+
export declare function normalizeHost(hostname: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
|
|
8
|
+
* pooler 用户名 `postgres.<ref>`。放在引擎层:备份端(写 manifest.sourceProjectRef)
|
|
9
|
+
* 与恢复端(同源阻断)共用同一个身份判定。非 Supabase 形态返回 null。
|
|
10
|
+
*/
|
|
11
|
+
export declare function projectRefOf(connString: string): string | null;
|
|
12
|
+
/** 从 Supabase Storage S3 端点(https://<ref>.storage.supabase.co/…)提取项目 ref。 */
|
|
13
|
+
export declare function refFromStorageEndpoint(endpoint: string): string | null;
|
|
14
|
+
export interface ClassifiedPass {
|
|
15
|
+
/** allowlist 命中数(环境预期/托管冲突)——如实入报告,不算失败 */
|
|
16
|
+
expectedSkips: number;
|
|
17
|
+
/** 真失败(压缩到 200 字符的错误块) */
|
|
18
|
+
failures: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface EngineResult {
|
|
21
|
+
preData: ClassifiedPass;
|
|
22
|
+
postData: ClassifiedPass;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function spawnPgRestore(bin: string, args: string[], extraEnv?: NodeJS.ProcessEnv): Promise<{
|
|
26
|
+
code: number | null;
|
|
27
|
+
stderr: string;
|
|
28
|
+
}>;
|
|
29
|
+
export declare const SANDBOX_MANAGED_ERROR: RegExp;
|
|
30
|
+
export declare const SCHEMA_EXISTS_ERROR: RegExp;
|
|
31
|
+
/**
|
|
32
|
+
* 把 pg_restore 的 stderr 拆成单个错误块并按 allowlist 分类。
|
|
33
|
+
* 只对 ERROR 原因行分类,不看 "Command was:" 之后的语句文本——否则一个恰好
|
|
34
|
+
* 引用 auth.uid 的用户对象因"损坏/语法错误"失败时,会被误判成预期跳过。
|
|
35
|
+
*/
|
|
36
|
+
export declare function classifyBlocks(stderr: string, allow: RegExp): ClassifiedPass;
|
|
37
|
+
/**
|
|
38
|
+
* 一遍恢复的最终裁决,两条独立规则:
|
|
39
|
+
* 1. 被信号杀死(code=null)**无条件失败**——即使 stderr 里已有被 allowlist 解释的
|
|
40
|
+
* 错误块。真实目标恒有一个预期 schema 跳过,若跳过能抵扣信号死亡,中途被 OOM/kill
|
|
41
|
+
* 的半截恢复就会被报成功(评审第 7 轮抓出的假成功路径)。
|
|
42
|
+
* 2. 非零退出且零错误块(归档器致命错、非英文 locale…)同样不能谎报成功。
|
|
43
|
+
* 非零退出但每个错误都已分类 = pg_restore 对被忽略错误的正常退出形态,通过。
|
|
44
|
+
*/
|
|
45
|
+
export declare function finalizePass(code: number | null, stderr: string, classified: ClassifiedPass): ClassifiedPass;
|
|
46
|
+
/** 标识符转义(内嵌引号翻倍)。导出:任何把目录里读到的名字拼进 SQL 的地方都必须用它。 */
|
|
47
|
+
export declare function quoteIdent(name: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* 恢复前把 manifest 记录的扩展装进目标(--schema 转储不含 CREATE EXTENSION)。
|
|
50
|
+
* 必须装进与源库相同的 schema:dump 里的类型是限定名(如 extensions.vector)。
|
|
51
|
+
* 装不上的扩展如实收集返回,由调用方按目标类型裁决:沙箱记录后继续(托管扩展
|
|
52
|
+
* 本就装不上,public 转储通常不引用),真实目标应当阻断(用户能在 dashboard 开启)。
|
|
53
|
+
*/
|
|
54
|
+
export declare function installExtensions(connString: string, extensions: ExtensionInfo[]): Promise<string[]>;
|
|
55
|
+
/**
|
|
56
|
+
* 两遍恢复(真实首演炸出的设计,现为 drill 与真实恢复共用):
|
|
57
|
+
* 1. pre-data+data —— 表结构 + 数据。除 schema 冲突(预期)外任何错误都是硬失败。
|
|
58
|
+
* 2. post-data —— 用户自己的索引/约束/触发器必须恢复成功;allowlist 按目标类型:
|
|
59
|
+
* 沙箱豁免 Supabase 托管对象引用,真实 Supabase 目标零豁免。
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* 凭据不进 argv(D6 同源要求):连接串里的密码对 `ps` 全程可见——拆出来经
|
|
63
|
+
* PGPASSWORD 环境变量传给 libpq,argv 里的 URL 不再携带。解析不了的连接串
|
|
64
|
+
* 原样透传(libpq 自己适配的形态,不在这里破坏)。
|
|
65
|
+
*/
|
|
66
|
+
export declare function credentialSafeDbArgs(connString: string): {
|
|
67
|
+
url: string;
|
|
68
|
+
env: NodeJS.ProcessEnv;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* 拒绝连接串里的 host/hostaddr/user 查询覆盖:驱动会让它们改写实际连接目标/租户——
|
|
72
|
+
* 身份判定(ref/主机)看的是 authority,读写却发生在别处。备份侧(源身份自记)与
|
|
73
|
+
* 恢复侧(确认门/同源阻断)共用本检查。
|
|
74
|
+
*/
|
|
75
|
+
export declare function assertNoHostOverride(connString: string): void;
|
|
76
|
+
export declare function restoreDatabaseArtifact(opts: {
|
|
77
|
+
dumpPath: string;
|
|
78
|
+
connString: string;
|
|
79
|
+
target: RestoreTargetKind;
|
|
80
|
+
}): Promise<EngineResult>;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// 统一数据库恢复引擎(恢复闭环 PRD §5.2):drill 沙箱与真实 Supabase 目标共用同一条
|
|
2
|
+
// pg_restore 路径与错误分类,同一快照不得在演练与真实恢复中得出不同结论。
|
|
3
|
+
//
|
|
4
|
+
// R0 spike 的三条实证直接塑造了这里的形状:
|
|
5
|
+
// 1. 裸退出码在真实目标上永远不干净 → 成功判定 = 零未分类错误(版本化 allowlist);
|
|
6
|
+
// 2. 不用 --clean:与"只允许空目标"(D5)冲突、制造伪错,且在真实 Supabase 上
|
|
7
|
+
// DROP/CREATE public 会抹掉 anon/authenticated 的默认授权(--no-privileges 转储
|
|
8
|
+
// 不会带回来),恢复出的 API 直接 401;
|
|
9
|
+
// 3. dump 自带 CREATE SCHEMA,而目标(新容器或 Supabase)恒有同名 schema →
|
|
10
|
+
// pre-data 阶段的 schema "already exists" 是两类目标共同的预期冲突。
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { Client } from "pg";
|
|
13
|
+
import { resolvePgRestoreBin } from "./pgbin.js";
|
|
14
|
+
import { pgConnectOptions } from "./supabase-ca.js";
|
|
15
|
+
/** 主机名规范化:剥掉合法的 DNS 根点(db.x.supabase.co. ≡ db.x.supabase.co)。
|
|
16
|
+
* 身份比较不规范化 = 一个尾点就能绕过同源阻断(supabase-ca 对同类早有钉子)。 */
|
|
17
|
+
export function normalizeHost(hostname) {
|
|
18
|
+
return hostname.replace(/\.$/, "").toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
|
|
22
|
+
* pooler 用户名 `postgres.<ref>`。放在引擎层:备份端(写 manifest.sourceProjectRef)
|
|
23
|
+
* 与恢复端(同源阻断)共用同一个身份判定。非 Supabase 形态返回 null。
|
|
24
|
+
*/
|
|
25
|
+
export function projectRefOf(connString) {
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(connString);
|
|
28
|
+
const direct = normalizeHost(url.hostname).match(/^db\.([a-z0-9]{16,})\.supabase\.co$/);
|
|
29
|
+
if (direct)
|
|
30
|
+
return direct[1];
|
|
31
|
+
// 用户名必须先解码再匹配:URL 解析器保留百分号编码,而 pg/libpq 会解码——
|
|
32
|
+
// postgres%2Eref 在驱动眼里就是 postgres.ref,不解码 = 身份判定可被编码绕过
|
|
33
|
+
const pooled = decodeURIComponent(url.username).match(/^postgres\.([a-z0-9]{16,})$/);
|
|
34
|
+
if (pooled)
|
|
35
|
+
return pooled[1];
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** 从 Supabase Storage S3 端点(https://<ref>.storage.supabase.co/…)提取项目 ref。 */
|
|
43
|
+
export function refFromStorageEndpoint(endpoint) {
|
|
44
|
+
try {
|
|
45
|
+
const match = normalizeHost(new URL(endpoint).hostname).match(/^([a-z0-9]{16,})\.(?:storage\.)?supabase\.co$/);
|
|
46
|
+
return match ? match[1] : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function spawnPgRestore(bin, args, extraEnv = {}) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const proc = spawn(bin, args, {
|
|
55
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
56
|
+
env: { ...process.env, ...extraEnv },
|
|
57
|
+
});
|
|
58
|
+
let stderr = "";
|
|
59
|
+
proc.stderr.on("data", (d) => (stderr += d.toString()));
|
|
60
|
+
// 不透传原始 spawn Error:node 会把 spawnargs(含 --dbname 连接串=凭据)挂在
|
|
61
|
+
// 异常对象上,顺着 reject 流进日志/Sentry。只保留启动失败的事实与 bin 名。
|
|
62
|
+
proc.on("error", (error) => reject(new Error(`${bin} failed to start: ${error.code ?? error.message}`)));
|
|
63
|
+
proc.on("close", (code) => resolve({ code, stderr }));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// 演练沙箱是裸 Postgres,Supabase 托管的 schema/角色必然缺席。post-data 里
|
|
67
|
+
// 引用它们的失败是"环境预期",不是备份坏了;其余失败才是演练要抓的。
|
|
68
|
+
// 真实 Supabase 目标不适用:角色/托管 schema 恒在,post-data 全严格。
|
|
69
|
+
export const SANDBOX_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;
|
|
70
|
+
// pre-data 唯一的预期冲突(见文件头注 3)。目标空门/新容器保证没有其他冲突源,
|
|
71
|
+
// 任何别的 "already exists" 都是真冲突,必须失败。
|
|
72
|
+
export const SCHEMA_EXISTS_ERROR = /schema "[^"]+" already exists/i;
|
|
73
|
+
const NEVER_MATCH = /(?!)/;
|
|
74
|
+
/**
|
|
75
|
+
* 把 pg_restore 的 stderr 拆成单个错误块并按 allowlist 分类。
|
|
76
|
+
* 只对 ERROR 原因行分类,不看 "Command was:" 之后的语句文本——否则一个恰好
|
|
77
|
+
* 引用 auth.uid 的用户对象因"损坏/语法错误"失败时,会被误判成预期跳过。
|
|
78
|
+
*/
|
|
79
|
+
export function classifyBlocks(stderr, allow) {
|
|
80
|
+
const blocks = stderr
|
|
81
|
+
.split(/(?=pg_restore: error:)/)
|
|
82
|
+
.filter((b) => /pg_restore: error:/.test(b));
|
|
83
|
+
let expectedSkips = 0;
|
|
84
|
+
const failures = [];
|
|
85
|
+
for (const block of blocks) {
|
|
86
|
+
const cause = block.split(/Command was:/i)[0];
|
|
87
|
+
if (allow.test(cause)) {
|
|
88
|
+
expectedSkips += 1;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
failures.push(block.replace(/\s+/g, " ").trim().slice(0, 200));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { expectedSkips, failures };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 一遍恢复的最终裁决,两条独立规则:
|
|
98
|
+
* 1. 被信号杀死(code=null)**无条件失败**——即使 stderr 里已有被 allowlist 解释的
|
|
99
|
+
* 错误块。真实目标恒有一个预期 schema 跳过,若跳过能抵扣信号死亡,中途被 OOM/kill
|
|
100
|
+
* 的半截恢复就会被报成功(评审第 7 轮抓出的假成功路径)。
|
|
101
|
+
* 2. 非零退出且零错误块(归档器致命错、非英文 locale…)同样不能谎报成功。
|
|
102
|
+
* 非零退出但每个错误都已分类 = pg_restore 对被忽略错误的正常退出形态,通过。
|
|
103
|
+
*/
|
|
104
|
+
export function finalizePass(code, stderr, classified) {
|
|
105
|
+
const detail = stderr.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)";
|
|
106
|
+
if (code === null) {
|
|
107
|
+
return {
|
|
108
|
+
...classified,
|
|
109
|
+
failures: [...classified.failures, `pg_restore was killed by a signal: ${detail}`],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (code !== 0 && classified.expectedSkips === 0 && classified.failures.length === 0) {
|
|
113
|
+
return { ...classified, failures: [`pg_restore exited with code ${code}: ${detail}`] };
|
|
114
|
+
}
|
|
115
|
+
return classified;
|
|
116
|
+
}
|
|
117
|
+
/** 标识符转义(内嵌引号翻倍)。导出:任何把目录里读到的名字拼进 SQL 的地方都必须用它。 */
|
|
118
|
+
export function quoteIdent(name) {
|
|
119
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 恢复前把 manifest 记录的扩展装进目标(--schema 转储不含 CREATE EXTENSION)。
|
|
123
|
+
* 必须装进与源库相同的 schema:dump 里的类型是限定名(如 extensions.vector)。
|
|
124
|
+
* 装不上的扩展如实收集返回,由调用方按目标类型裁决:沙箱记录后继续(托管扩展
|
|
125
|
+
* 本就装不上,public 转储通常不引用),真实目标应当阻断(用户能在 dashboard 开启)。
|
|
126
|
+
*/
|
|
127
|
+
export async function installExtensions(connString, extensions) {
|
|
128
|
+
if (!extensions.length)
|
|
129
|
+
return [];
|
|
130
|
+
// pgConnectOptions:Supabase 主机自动带打包根 CA(node-pg 把 require 按 verify-full
|
|
131
|
+
// 处理,裸 Client 对 pooler 必报 self-signed chain);沙箱等非 Supabase 目标原样透传
|
|
132
|
+
const client = new Client(pgConnectOptions(connString));
|
|
133
|
+
await client.connect();
|
|
134
|
+
const unavailable = [];
|
|
135
|
+
try {
|
|
136
|
+
for (const ext of extensions) {
|
|
137
|
+
try {
|
|
138
|
+
await client.query(`create schema if not exists ${quoteIdent(ext.schema)}`);
|
|
139
|
+
// CASCADE 自动带上依赖扩展,免受 manifest 里的安装顺序摆布
|
|
140
|
+
await client.query(`create extension if not exists ${quoteIdent(ext.name)} schema ${quoteIdent(ext.schema)} cascade`);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
unavailable.push(ext.name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await client.end();
|
|
149
|
+
}
|
|
150
|
+
return unavailable;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 两遍恢复(真实首演炸出的设计,现为 drill 与真实恢复共用):
|
|
154
|
+
* 1. pre-data+data —— 表结构 + 数据。除 schema 冲突(预期)外任何错误都是硬失败。
|
|
155
|
+
* 2. post-data —— 用户自己的索引/约束/触发器必须恢复成功;allowlist 按目标类型:
|
|
156
|
+
* 沙箱豁免 Supabase 托管对象引用,真实 Supabase 目标零豁免。
|
|
157
|
+
*/
|
|
158
|
+
/**
|
|
159
|
+
* 凭据不进 argv(D6 同源要求):连接串里的密码对 `ps` 全程可见——拆出来经
|
|
160
|
+
* PGPASSWORD 环境变量传给 libpq,argv 里的 URL 不再携带。解析不了的连接串
|
|
161
|
+
* 原样透传(libpq 自己适配的形态,不在这里破坏)。
|
|
162
|
+
*/
|
|
163
|
+
export function credentialSafeDbArgs(connString) {
|
|
164
|
+
let url;
|
|
165
|
+
try {
|
|
166
|
+
url = new URL(connString);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// 非 URL(libpq keyword)形态拆不了密码——含内联密码的一律拒绝,不赌
|
|
170
|
+
if (/\bpassword\s*=/i.test(connString)) {
|
|
171
|
+
throw new Error("keyword-style connection strings with an inline password are not accepted — " +
|
|
172
|
+
"use the URL form; the password is moved into PGPASSWORD automatically");
|
|
173
|
+
}
|
|
174
|
+
return { url: connString, env: {} };
|
|
175
|
+
}
|
|
176
|
+
let password = "";
|
|
177
|
+
if (url.password) {
|
|
178
|
+
try {
|
|
179
|
+
password = decodeURIComponent(url.password);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// 编码坏了不许 fail-open(评审第 13 轮):原样放行 = 密码整段留在 argv
|
|
183
|
+
throw new Error("the connection string password contains malformed percent-encoding — " +
|
|
184
|
+
"fix the URL; refusing to pass it through argv");
|
|
185
|
+
}
|
|
186
|
+
url.password = "";
|
|
187
|
+
}
|
|
188
|
+
// ?password= 同样不许进 argv(libpq 认它)。剥参按**解码后的键名**匹配
|
|
189
|
+
// (pass%77ord= 也是 password=,评审第 15 轮),不用 URLSearchParams.delete:
|
|
190
|
+
// 它会重序列化其余参数(%20 变 +),libpq 按字面处理,options 之类会被改坏
|
|
191
|
+
const rawQuery = url.search.startsWith("?") ? url.search.slice(1) : url.search;
|
|
192
|
+
if (rawQuery) {
|
|
193
|
+
const kept = [];
|
|
194
|
+
const queryPasswords = [];
|
|
195
|
+
for (const part of rawQuery.split("&")) {
|
|
196
|
+
const eq = part.indexOf("=");
|
|
197
|
+
const rawKey = eq === -1 ? part : part.slice(0, eq);
|
|
198
|
+
let key;
|
|
199
|
+
try {
|
|
200
|
+
key = decodeURIComponent(rawKey.replace(/\+/g, "%20"));
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
key = rawKey;
|
|
204
|
+
}
|
|
205
|
+
if (/^password$/i.test(key)) {
|
|
206
|
+
const rawValue = eq === -1 ? "" : part.slice(eq + 1);
|
|
207
|
+
try {
|
|
208
|
+
const value = decodeURIComponent(rawValue.replace(/\+/g, "%20"));
|
|
209
|
+
if (value)
|
|
210
|
+
queryPasswords.push(value);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
throw new Error("the connection string ?password= value contains malformed percent-encoding — " +
|
|
214
|
+
"fix the URL; refusing to pass it through argv");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
kept.push(part);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (queryPasswords.length) {
|
|
222
|
+
// authority 与查询、以及重复查询项之间不一致:libpq 的取值优先级和我们的猜测
|
|
223
|
+
// 可能不同,预检用 A 通过、pg_restore 用 B 失败的分叉不可接受——直接拒绝
|
|
224
|
+
const distinct = new Set(password ? [password, ...queryPasswords] : queryPasswords);
|
|
225
|
+
if (distinct.size > 1) {
|
|
226
|
+
throw new Error("the connection string carries two different passwords (authority/?password=) — remove one");
|
|
227
|
+
}
|
|
228
|
+
password = [...distinct][0];
|
|
229
|
+
url.search = kept.length ? `?${kept.join("&")}` : "";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { url: url.toString(), env: password ? { PGPASSWORD: password } : {} };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* 拒绝连接串里的 host/hostaddr/user 查询覆盖:驱动会让它们改写实际连接目标/租户——
|
|
236
|
+
* 身份判定(ref/主机)看的是 authority,读写却发生在别处。备份侧(源身份自记)与
|
|
237
|
+
* 恢复侧(确认门/同源阻断)共用本检查。
|
|
238
|
+
*/
|
|
239
|
+
export function assertNoHostOverride(connString) {
|
|
240
|
+
try {
|
|
241
|
+
const params = new URL(connString).searchParams;
|
|
242
|
+
for (const key of params.keys()) {
|
|
243
|
+
if (/^(host|hostaddr|user)$/i.test(key)) {
|
|
244
|
+
throw new Error(`connection string carries a ?${key}= override — the effective server/identity would ` +
|
|
245
|
+
`differ from the URL authority that identity checks inspect. Use a plain connection string.`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error.message.includes("override"))
|
|
251
|
+
throw error;
|
|
252
|
+
// URL 解析不了的连接串由 pg 自己报错,这里放行
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
export async function restoreDatabaseArtifact(opts) {
|
|
256
|
+
const bin = resolvePgRestoreBin();
|
|
257
|
+
const { url, env } = credentialSafeDbArgs(opts.connString);
|
|
258
|
+
const common = ["--no-owner", "--no-privileges", "--dbname", url, opts.dumpPath];
|
|
259
|
+
const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...common], env);
|
|
260
|
+
const preData = finalizePass(first.code, first.stderr, classifyBlocks(first.stderr, SCHEMA_EXISTS_ERROR));
|
|
261
|
+
if (preData.failures.length) {
|
|
262
|
+
return { preData, postData: { expectedSkips: 0, failures: [] }, ok: false };
|
|
263
|
+
}
|
|
264
|
+
const second = await spawnPgRestore(bin, ["--section=post-data", ...common], env);
|
|
265
|
+
const postData = finalizePass(second.code, second.stderr, classifyBlocks(second.stderr, opts.target === "sandbox" ? SANDBOX_MANAGED_ERROR : NEVER_MATCH));
|
|
266
|
+
return { preData, postData, ok: postData.failures.length === 0 };
|
|
267
|
+
}
|