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 +25 -2
- package/dist/backup.js +23 -2
- package/dist/drill.d.ts +8 -5
- package/dist/drill.js +29 -97
- package/dist/estimate.js +2 -1
- package/dist/index.js +33 -15
- package/dist/lib.d.ts +2 -1
- package/dist/lib.js +3 -0
- package/dist/manifest.d.ts +46 -0
- package/dist/manifest.js +176 -1
- package/dist/restore-engine.d.ts +63 -0
- package/dist/restore-engine.js +151 -0
- package/dist/restore.d.ts +29 -2
- package/dist/restore.js +143 -22
- package/dist/storage-catalog.d.ts +19 -0
- package/dist/storage-catalog.js +50 -0
- package/dist/storage.d.ts +16 -1
- package/dist/storage.js +33 -2
- package/dist/supabase-ca.d.ts +32 -0
- package/dist/supabase-ca.js +240 -0
- package/package.json +1 -1
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 --
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
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
|
|
146
|
-
|
|
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
|
|
169
|
-
|
|
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
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
197
|
-
|
|
121
|
+
return {
|
|
122
|
+
supabaseSkipped: result.postData.expectedSkips,
|
|
123
|
+
failures: result.postData.failures,
|
|
124
|
+
};
|
|
198
125
|
}
|
|
199
126
|
// ── 校验 ────────────────────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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";
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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;
|