backupdrill 2.2.3 → 2.2.5
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 +11 -0
- package/dist/backup.js +5 -3
- package/dist/restore-engine.js +13 -3
- package/dist/restore.d.ts +6 -0
- package/dist/restore.js +23 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,17 @@ npm install -g backupdrill
|
|
|
31
31
|
npx backupdrill backup
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
## Use it from Claude Code or Cursor
|
|
35
|
+
|
|
36
|
+
The [`plugin/`](plugin/) folder is a plugin for Claude Code and Cursor. It teaches the agent to run `estimate`, `backup`, `drill` and `restore` for you, keeps your keys in a gitignored `.env.backupdrill` that the agent never opens, and always does a dry run before a restore. In Claude Code:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
/plugin marketplace add backupdrill/cli
|
|
40
|
+
/plugin install backupdrill@backupdrill
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Details are in [plugin/README.md](plugin/README.md).
|
|
44
|
+
|
|
34
45
|
## Usage
|
|
35
46
|
|
|
36
47
|
Configure via **env vars**, a **config file**, or **flags** (precedence: flags > env > file).
|
package/dist/backup.js
CHANGED
|
@@ -17,7 +17,7 @@ import { dumpPartSizeFor } from "./part-size.js";
|
|
|
17
17
|
// 解析函数搬到了 pgbin(演练与恢复都要读 pg_restore 版本,放这里会成环);保留旧出口
|
|
18
18
|
export { parsePgDumpMajor };
|
|
19
19
|
import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
|
|
20
|
-
import { libpqChildEnv, urlSslModeOf } from "./restore-engine.js";
|
|
20
|
+
import { credentialSafeDbArgs, libpqChildEnv, urlSslModeOf } from "./restore-engine.js";
|
|
21
21
|
const execFileAsync = promisify(execFile);
|
|
22
22
|
function majorOf(serverVersionNum) {
|
|
23
23
|
// server_version_num: 150004 → 15, 170002 → 17
|
|
@@ -106,7 +106,9 @@ async function dumpToS3(config, s3, key, pgDumpBin, partSize) {
|
|
|
106
106
|
});
|
|
107
107
|
const schemaArgs = config.schemas.map((s) => `--schema=${s}`);
|
|
108
108
|
// pg_dump(libpq):Supabase 主机才改写成 sslmode=verify-full + sslrootcert=<打包CA临时文件>
|
|
109
|
-
|
|
109
|
+
// 凭据不进 argv(D6):argv 对同机任何进程的 `ps` 可见。与恢复路径同一个函数——
|
|
110
|
+
// 先改写 SSL 再拆密码,密码经 PGPASSWORD 交给 libpq
|
|
111
|
+
const { url: dumpDbUrl, env: credentialEnv } = credentialSafeDbArgs(dumpUrlFor(config.databaseUrl));
|
|
110
112
|
const dump = spawn(pgDumpBin, [
|
|
111
113
|
"--format=custom",
|
|
112
114
|
"--no-owner",
|
|
@@ -118,7 +120,7 @@ async function dumpToS3(config, s3, key, pgDumpBin, partSize) {
|
|
|
118
120
|
// 环境剔除改写目标/身份的 PG* 变量:pg_dump 只认 --dbname 里的 URL(TLS 策略等保留)
|
|
119
121
|
{
|
|
120
122
|
stdio: ["ignore", "pipe", "pipe"],
|
|
121
|
-
env: libpqChildEnv(
|
|
123
|
+
env: libpqChildEnv(credentialEnv, process.env, {
|
|
122
124
|
supabaseHost: isSupabaseHost(config.databaseUrl),
|
|
123
125
|
urlSslMode: urlSslModeOf(dumpDbUrl),
|
|
124
126
|
}),
|
package/dist/restore-engine.js
CHANGED
|
@@ -395,6 +395,10 @@ export function credentialSafeDbArgs(connString) {
|
|
|
395
395
|
}
|
|
396
396
|
return { url: connString, env: {} };
|
|
397
397
|
}
|
|
398
|
+
// 兜底(入口的 assertNoHostOverride 已拒字面 #):片段留在输出里,libpq 会把 `#?host=…` 当查询参数
|
|
399
|
+
if (url.hash) {
|
|
400
|
+
throw new Error("connection string contains a literal '#' — percent-encode it as %23 (for example inside the password).");
|
|
401
|
+
}
|
|
398
402
|
let password = "";
|
|
399
403
|
if (url.password) {
|
|
400
404
|
try {
|
|
@@ -427,9 +431,9 @@ export function credentialSafeDbArgs(connString) {
|
|
|
427
431
|
if (/^password$/i.test(key)) {
|
|
428
432
|
const rawValue = eq === -1 ? "" : part.slice(eq + 1);
|
|
429
433
|
try {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
434
|
+
// 空值也要记:libpq 取最后一个(空 = 回落 .pgpass),只记非空会把前面的旧值
|
|
435
|
+
// 当成密码——?password=stale&password= 预检走 .pgpass、pg_dump 却用 stale
|
|
436
|
+
queryPasswords.push(decodeURIComponent(rawValue.replace(/\+/g, "%20")));
|
|
433
437
|
}
|
|
434
438
|
catch {
|
|
435
439
|
throw new Error("the connection string ?password= value contains malformed percent-encoding — " +
|
|
@@ -469,6 +473,12 @@ export function assertNoHostOverride(connString) {
|
|
|
469
473
|
if (containsNul(connString)) {
|
|
470
474
|
throw new Error("connection string contains a NUL byte — use a plain connection string.");
|
|
471
475
|
}
|
|
476
|
+
// 字面 # 同样在解析前拒:WHATWG URL 把 # 之后当片段,这里看到的主机干净;libpq 不认片段,
|
|
477
|
+
// 剥掉 ?password= 后剩下的 `#?host=…` 会被它读成查询参数,改写连接目标(交叉审查)。
|
|
478
|
+
// 合法的 # 只会出现在密码等值里,写成 %23 即可
|
|
479
|
+
if (connString.includes("#")) {
|
|
480
|
+
throw new Error("connection string contains a literal '#' — percent-encode it as %23 (for example inside the password).");
|
|
481
|
+
}
|
|
472
482
|
// 解析不了的连接串直接拒:曾经"放行让 pg 自己报错",但 pg 的解析器比 WHATWG URL 宽
|
|
473
483
|
// (`postgresql://user@/db?host=…` 这类串 pg 接受、这里解析失败),放行 = 身份判定为空却照样连接
|
|
474
484
|
// (交叉审查)。校验逻辑放在 try 外面——曾按文案里有没有 "override" 决定重抛,新理由一措辞不同就被吞。
|
package/dist/restore.d.ts
CHANGED
|
@@ -61,6 +61,12 @@ export declare function assertRequestApplies(manifest: Manifest, wantsDatabase:
|
|
|
61
61
|
* 这道门要求人把目标身份亲手打一遍。
|
|
62
62
|
*/
|
|
63
63
|
export declare function assertConfirmedTarget(confirmTarget: string | undefined, targetDatabaseUrl?: string, targetSupabaseUrl?: string): void;
|
|
64
|
+
/**
|
|
65
|
+
* 空目标门的拒绝文案。"只重试 Storage"那句只对同时恢复 Storage 的运行有意义:库已写完、
|
|
66
|
+
* Storage 中途断了,重跑时才会在这道门上被拦。纯数据库恢复(托管恢复只做数据库)带上它,
|
|
67
|
+
* 用户会以为自己漏了一步,其实只需要换一个空项目。
|
|
68
|
+
*/
|
|
69
|
+
export declare function nonEmptyTargetMessage(schemas: string[], objectCount: number, alsoRestoringStorage: boolean): string;
|
|
64
70
|
/**
|
|
65
71
|
* 把一份快照恢复出来:数据库经统一引擎还原进目标(与演练同一条 pg_restore 路径与
|
|
66
72
|
* 错误分类),恢复后跑同一套表级验证;Storage 提供目标凭据时回传+对账(P0-D),
|
package/dist/restore.js
CHANGED
|
@@ -155,6 +155,20 @@ export function assertConfirmedTarget(confirmTarget, targetDatabaseUrl, targetSu
|
|
|
155
155
|
`(use --dry-run first to preview all checks without writing).`);
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* 空目标门的拒绝文案。"只重试 Storage"那句只对同时恢复 Storage 的运行有意义:库已写完、
|
|
160
|
+
* Storage 中途断了,重跑时才会在这道门上被拦。纯数据库恢复(托管恢复只做数据库)带上它,
|
|
161
|
+
* 用户会以为自己漏了一步,其实只需要换一个空项目。
|
|
162
|
+
*/
|
|
163
|
+
export function nonEmptyTargetMessage(schemas, objectCount, alsoRestoringStorage) {
|
|
164
|
+
const message = `target schema(s) ${schemas.join(", ")} contain ${objectCount} existing object(s) — ` +
|
|
165
|
+
`restore only writes into an empty target. Create a fresh Supabase project ` +
|
|
166
|
+
`(or empty these schemas) and retry.`;
|
|
167
|
+
if (!alsoRestoringStorage)
|
|
168
|
+
return message;
|
|
169
|
+
return (`${message} If a previous run already restored the database and only Storage was ` +
|
|
170
|
+
`interrupted, re-run WITHOUT the database target to retry Storage alone.`);
|
|
171
|
+
}
|
|
158
172
|
/**
|
|
159
173
|
* 空目标门(PRD §5.3.3 / 决策 D5):目标 schema 里存在任何用户对象即拒绝写入。
|
|
160
174
|
* "空"的定义来自 R0 spike:不是"schema 不存在"(Supabase 恒有 public),而是
|
|
@@ -162,7 +176,7 @@ export function assertConfirmedTarget(confirmTarget, targetDatabaseUrl, targetSu
|
|
|
162
176
|
* 全部计入:只查关系时,"目标里只有函数"会放行,然后在 pre-data 中途撞冲突,
|
|
163
177
|
* 留下半截目标(评审第 7 轮)。
|
|
164
178
|
*/
|
|
165
|
-
async function assertEmptyTarget(targetUrl, schemas) {
|
|
179
|
+
async function assertEmptyTarget(targetUrl, schemas, alsoRestoringStorage) {
|
|
166
180
|
const client = await connectPg(targetUrl);
|
|
167
181
|
try {
|
|
168
182
|
const res = await client.query(`select
|
|
@@ -175,11 +189,7 @@ async function assertEmptyTarget(targetUrl, schemas) {
|
|
|
175
189
|
as n`, [schemas]);
|
|
176
190
|
const objectCount = Number(res.rows[0].n);
|
|
177
191
|
if (objectCount > 0) {
|
|
178
|
-
throw new Error(
|
|
179
|
-
`restore only writes into an empty target. Create a fresh Supabase project ` +
|
|
180
|
-
`(or empty these schemas) and retry. If a previous run already restored the ` +
|
|
181
|
-
`database and only Storage was interrupted, re-run WITHOUT the database target ` +
|
|
182
|
-
`to retry Storage alone.`);
|
|
192
|
+
throw new Error(nonEmptyTargetMessage(schemas, objectCount, alsoRestoringStorage));
|
|
183
193
|
}
|
|
184
194
|
}
|
|
185
195
|
finally {
|
|
@@ -190,7 +200,7 @@ async function assertEmptyTarget(targetUrl, schemas) {
|
|
|
190
200
|
* dry-run 只读预检(PRD §5.3.2):零写入,把正式执行会撞的墙全部提前撞一遍。
|
|
191
201
|
* 逐项打印,任何 blocker 汇总后抛出(退出码非零 = "现在执行不会成功")。
|
|
192
202
|
*/
|
|
193
|
-
async function dryRunPreflight(config, manifest, targetUrl, storageTarget) {
|
|
203
|
+
async function dryRunPreflight(config, manifest, targetUrl, storageTarget, alsoRestoringStorage) {
|
|
194
204
|
const blockers = [];
|
|
195
205
|
const db = manifest.database;
|
|
196
206
|
const mb = (n) => (n / 1024 / 1024).toFixed(1);
|
|
@@ -221,7 +231,7 @@ async function dryRunPreflight(config, manifest, targetUrl, storageTarget) {
|
|
|
221
231
|
log.ok("target identity: distinct from source");
|
|
222
232
|
}
|
|
223
233
|
try {
|
|
224
|
-
await assertEmptyTarget(targetUrl, db.schemas);
|
|
234
|
+
await assertEmptyTarget(targetUrl, db.schemas, alsoRestoringStorage);
|
|
225
235
|
log.ok(`target schemas (${db.schemas.join(", ")}) are empty`);
|
|
226
236
|
}
|
|
227
237
|
catch (error) {
|
|
@@ -376,10 +386,13 @@ export async function runRestore(config, opts) {
|
|
|
376
386
|
storageFilesWritten: 0,
|
|
377
387
|
};
|
|
378
388
|
assertRequestApplies(manifest, !!opts.targetDatabaseUrl, !!storageTarget);
|
|
389
|
+
// 这次运行会不会碰 Storage(回传到目标,或下载到本地)。只有会碰时,空目标门才该
|
|
390
|
+
// 提示"库已恢复、只重试 Storage";skipStorage 的纯数据库运行(托管恢复)不该看到这句。
|
|
391
|
+
const alsoRestoringStorage = !!manifest.storage && !opts.skipStorage;
|
|
379
392
|
// dry-run:只读预检后直接返回,零写入(PRD §5.3.2)
|
|
380
393
|
if (opts.dryRun) {
|
|
381
394
|
log.step(`Dry run for snapshot ${snapshot} — nothing will be written`);
|
|
382
|
-
await dryRunPreflight(config, manifest, opts.targetDatabaseUrl, storageTarget);
|
|
395
|
+
await dryRunPreflight(config, manifest, opts.targetDatabaseUrl, storageTarget, alsoRestoringStorage);
|
|
383
396
|
result.dryRun = true;
|
|
384
397
|
return result;
|
|
385
398
|
}
|
|
@@ -408,7 +421,7 @@ export async function runRestore(config, opts) {
|
|
|
408
421
|
"at a fresh Supabase project.");
|
|
409
422
|
}
|
|
410
423
|
log.step("Checking target is empty…");
|
|
411
|
-
await assertEmptyTarget(targetUrl, manifest.database.schemas);
|
|
424
|
+
await assertEmptyTarget(targetUrl, manifest.database.schemas, alsoRestoringStorage);
|
|
412
425
|
// 版本预检不只属于 dry-run:跳过 dry-run 直接执行时,目标比源旧照样会
|
|
413
426
|
// 半途炸掉或部分恢复(评审第 8 轮)——写入前必须拦住
|
|
414
427
|
const versionClient = await connectPg(targetUrl);
|
package/package.json
CHANGED