backupdrill 0.1.1 → 0.1.2
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 +22 -3
- package/dist/backup.js +22 -5
- package/dist/drill.js +127 -17
- package/dist/lib.d.ts +1 -1
- package/dist/manifest.d.ts +12 -0
- package/dist/pgbin.d.ts +7 -0
- package/dist/pgbin.js +17 -0
- package/dist/restore.js +2 -4
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -45,9 +45,26 @@ export BACKUPDRILL_S3_SECRET_ACCESS_KEY="…"
|
|
|
45
45
|
backupdrill backup
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
All recognized variables:
|
|
49
|
+
|
|
50
|
+
| Variable | Purpose | Default |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `BACKUPDRILL_DATABASE_URL` | Source connection string (falls back to `DATABASE_URL`) | — (required) |
|
|
53
|
+
| `BACKUPDRILL_PROJECT_NAME` | Readable name used in object keys and the manifest | `supabase` |
|
|
54
|
+
| `BACKUPDRILL_SCHEMAS` | Comma-separated schemas to dump | `public` |
|
|
55
|
+
| `BACKUPDRILL_S3_ENDPOINT` | S3-compatible endpoint (R2/B2/self-hosted; omit for AWS S3) | — |
|
|
56
|
+
| `BACKUPDRILL_S3_REGION` | Destination bucket region | `auto` |
|
|
57
|
+
| `BACKUPDRILL_S3_BUCKET` | Destination bucket | — (required) |
|
|
58
|
+
| `BACKUPDRILL_S3_ACCESS_KEY_ID`, `BACKUPDRILL_S3_SECRET_ACCESS_KEY` | Destination credentials | — (required) |
|
|
59
|
+
| `BACKUPDRILL_S3_PREFIX` | Key prefix inside the bucket | `backupdrill` |
|
|
60
|
+
| `BACKUPDRILL_S3_FORCE_PATH_STYLE` | `true`/`false` — path-style addressing (R2/MinIO) | `true` when an endpoint is set |
|
|
61
|
+
| `BACKUPDRILL_SUPABASE_STORAGE_*` | Storage-file source (endpoint/region/keys/buckets) — see [Backing up Storage files](#backing-up-storage-files-too) | — |
|
|
62
|
+
| `BACKUPDRILL_PG_DUMP` | Path to the `pg_dump` binary to use | `pg_dump` on `PATH` |
|
|
63
|
+
| `BACKUPDRILL_PG_RESTORE` | Path to `pg_restore` (used by `drill` and `restore`) | next to `BACKUPDRILL_PG_DUMP`, else `pg_restore` on `PATH` |
|
|
64
|
+
|
|
48
65
|
### Config file
|
|
49
66
|
|
|
50
|
-
Copy `backupdrill.config.example.json` → `backupdrill.config.json` (it's gitignored) and fill it in. Keep secrets in env vars even when using a file.
|
|
67
|
+
Copy `backupdrill.config.example.json` → `backupdrill.config.json` (it's gitignored) and fill it in. Keep secrets in env vars even when using a file. To also back up Storage files, start from `backupdrill.config.with-storage.example.json` instead — only copy the `supabaseStorage` block once you actually have S3 access keys for your project's Storage.
|
|
51
68
|
|
|
52
69
|
```bash
|
|
53
70
|
backupdrill backup --config backupdrill.config.json
|
|
@@ -75,7 +92,7 @@ Supabase's own database restore only brings back `storage.objects` **metadata**
|
|
|
75
92
|
|
|
76
93
|
1. Dashboard → Project → **Storage → Settings → S3 Access Keys** → enable the S3 connection and generate an **Access Key ID + Secret Access Key** (these bypass RLS; keep them server-side).
|
|
77
94
|
2. Copy the **endpoint** and **region** from the same page.
|
|
78
|
-
3. Provide them (env shown; config-file keys are `supabaseStorage.*`):
|
|
95
|
+
3. Provide them (env shown; config-file keys are `supabaseStorage.*` — see `backupdrill.config.with-storage.example.json`):
|
|
79
96
|
|
|
80
97
|
```bash
|
|
81
98
|
export BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT="https://<ref>.storage.supabase.co/storage/v1/s3"
|
|
@@ -120,13 +137,15 @@ backupdrill estimate --plan pro # measures your DB + Storage, prints the proje
|
|
|
120
137
|
|
|
121
138
|
## Restoring & drilling
|
|
122
139
|
|
|
123
|
-
Prove a backup is restorable, without touching anything real — `drill` restores the latest snapshot into a throwaway Postgres
|
|
140
|
+
Prove a backup is restorable, without touching anything real — `drill` restores the latest snapshot into a throwaway Postgres and checks it. It needs **Docker** (for the throwaway Postgres) and **`pg_restore`** (same major-version rule as `pg_dump`; set `BACKUPDRILL_PG_RESTORE` if it's not on your `PATH`):
|
|
124
141
|
|
|
125
142
|
```bash
|
|
126
143
|
backupdrill drill # latest snapshot; add --snapshot <timestamp> to pick one
|
|
127
144
|
backupdrill drill --verify-all-files # also checksum every Storage file, not just a sample
|
|
128
145
|
```
|
|
129
146
|
|
|
147
|
+
Backups record your installed Postgres extensions in the manifest, and the drill pre-installs them in the sandbox before restoring — including switching to a pgvector-enabled sandbox image when your database uses `vector` columns.
|
|
148
|
+
|
|
130
149
|
When you actually need to recover, `restore` puts the database back into a target you name and pulls the Storage files down to a local folder:
|
|
131
150
|
|
|
132
151
|
```bash
|
package/dist/backup.js
CHANGED
|
@@ -50,11 +50,26 @@ async function inspectDatabase(databaseUrl, schemas) {
|
|
|
50
50
|
try {
|
|
51
51
|
const version = await client.query("show server_version");
|
|
52
52
|
const versionNum = await client.query("show server_version_num");
|
|
53
|
-
// 只统计将被 dump 的 schema,manifest
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
// 只统计将被 dump 的 schema,manifest 才与转储内容一致。
|
|
54
|
+
// 口径 = pg_class relkind in ('r','p','m'):普通表 + 分区父表 + 物化视图,
|
|
55
|
+
// 与 pg_dump 实际转储的关系集合一致,且必须与 drill 校验端(verifyRestored)
|
|
56
|
+
// 用同一查询——两端口径不同曾导致含 matview 的库演练必然误报 FAIL。
|
|
57
|
+
// 行数估算仍取 n_live_tup(planner 统计);分区父表本身不存行,天然为 0。
|
|
58
|
+
const tables = await client.query(`select n.nspname as schema, c.relname as name,
|
|
59
|
+
coalesce(s.n_live_tup, 0) as estimated_rows
|
|
60
|
+
from pg_class c
|
|
61
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
62
|
+
left join pg_stat_all_tables s on s.relid = c.oid
|
|
63
|
+
where c.relkind in ('r', 'p', 'm')
|
|
64
|
+
and n.nspname = any($1::text[])
|
|
65
|
+
order by 1, 2`, [schemas]);
|
|
66
|
+
// 非内置扩展清单(排除必装的 plpgsql):--schema 转储不含 CREATE EXTENSION,
|
|
67
|
+
// 演练沙箱要靠它在恢复前把扩展类型(如 vector)装回来
|
|
68
|
+
const extensions = await client.query(`select e.extname as name, e.extversion as version, n.nspname as schema
|
|
69
|
+
from pg_extension e
|
|
70
|
+
join pg_namespace n on n.oid = e.extnamespace
|
|
71
|
+
where e.extname <> 'plpgsql'
|
|
72
|
+
order by e.extname`);
|
|
58
73
|
return {
|
|
59
74
|
serverVersion: version.rows[0].server_version,
|
|
60
75
|
serverVersionNum: Number(versionNum.rows[0].server_version_num),
|
|
@@ -63,6 +78,7 @@ async function inspectDatabase(databaseUrl, schemas) {
|
|
|
63
78
|
name: r.name,
|
|
64
79
|
estimatedRows: Number(r.estimated_rows),
|
|
65
80
|
})),
|
|
81
|
+
extensions: extensions.rows,
|
|
66
82
|
};
|
|
67
83
|
}
|
|
68
84
|
finally {
|
|
@@ -184,6 +200,7 @@ export async function runBackup(config) {
|
|
|
184
200
|
tableCount: db.tables.length,
|
|
185
201
|
estimatedRowTotal: db.tables.reduce((n, t) => n + t.estimatedRows, 0),
|
|
186
202
|
tables: db.tables,
|
|
203
|
+
extensions: db.extensions,
|
|
187
204
|
},
|
|
188
205
|
dump: { key: dumpKey, format: "custom", bytes, sha256 },
|
|
189
206
|
storage: storageResult,
|
package/dist/drill.js
CHANGED
|
@@ -5,12 +5,22 @@ import { join } from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { Client } from "pg";
|
|
7
7
|
import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
|
|
8
|
+
import { resolvePgRestoreBin } from "./pgbin.js";
|
|
8
9
|
import { log } from "./log.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
// Storage 校验默认抽样上限(避免每次演练重下全部文件产生大量 egress);超限如实记录
|
|
11
12
|
const STORAGE_SAMPLE_CAP = 100;
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* 沙箱镜像选择:alpine 镜像只带 contrib 扩展,装不了 pgvector——含 vector 列的
|
|
15
|
+
* 备份在裸镜像里恢复必然硬崩("type vector does not exist")。manifest 记录了
|
|
16
|
+
* vector 时换用 pgvector 官方镜像(同 major),其余维持轻量 alpine。
|
|
17
|
+
*/
|
|
18
|
+
function sandboxImage(manifest) {
|
|
19
|
+
const major = parseInt(manifest.database.serverVersion, 10);
|
|
20
|
+
const hasVector = (manifest.database.extensions ?? []).some((e) => e.name === "vector");
|
|
21
|
+
return hasVector ? `pgvector/pgvector:pg${major}` : `postgres:${major}-alpine`;
|
|
22
|
+
}
|
|
23
|
+
async function startEphemeralPostgres(image) {
|
|
14
24
|
log.step(`Starting ephemeral ${image}…`);
|
|
15
25
|
const { stdout } = await execFileAsync("docker", [
|
|
16
26
|
"run",
|
|
@@ -64,6 +74,39 @@ async function startEphemeralPostgres(major) {
|
|
|
64
74
|
throw error;
|
|
65
75
|
}
|
|
66
76
|
}
|
|
77
|
+
function quoteIdent(name) {
|
|
78
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 恢复前把 manifest 记录的扩展装进沙箱(--schema 转储不含 CREATE EXTENSION)。
|
|
82
|
+
* 必须装进与源库相同的 schema:dump 里的类型是限定名(如 extensions.vector)。
|
|
83
|
+
* 装不上的扩展如实收集返回,**不在这里失败**——Supabase 项目普遍装着沙箱镜像
|
|
84
|
+
* 没有的托管扩展(pg_graphql、supabase_vault…),public 转储通常并不引用它们;
|
|
85
|
+
* 只有恢复真的因此失败时才升级为分类错误(见 drillDump)。
|
|
86
|
+
*/
|
|
87
|
+
async function installExtensions(connString, extensions) {
|
|
88
|
+
if (!extensions.length)
|
|
89
|
+
return [];
|
|
90
|
+
const client = new Client({ connectionString: connString });
|
|
91
|
+
await client.connect();
|
|
92
|
+
const unavailable = [];
|
|
93
|
+
try {
|
|
94
|
+
for (const ext of extensions) {
|
|
95
|
+
try {
|
|
96
|
+
await client.query(`create schema if not exists ${quoteIdent(ext.schema)}`);
|
|
97
|
+
// CASCADE 自动带上依赖扩展,免受 manifest 里的安装顺序摆布
|
|
98
|
+
await client.query(`create extension if not exists ${quoteIdent(ext.name)} schema ${quoteIdent(ext.schema)} cascade`);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
unavailable.push(ext.name);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await client.end();
|
|
107
|
+
}
|
|
108
|
+
return unavailable;
|
|
109
|
+
}
|
|
67
110
|
function spawnPgRestore(bin, args) {
|
|
68
111
|
return new Promise((resolve, reject) => {
|
|
69
112
|
const proc = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
@@ -73,6 +116,10 @@ function spawnPgRestore(bin, args) {
|
|
|
73
116
|
proc.on("close", (code) => resolve({ code, stderr }));
|
|
74
117
|
});
|
|
75
118
|
}
|
|
119
|
+
// 只有这些特征说明恢复失败源于沙箱缺扩展(限定类型/schema 解析不到、扩展装不上)。
|
|
120
|
+
// 真实 Supabase 项目的 manifest 几乎必然含沙箱装不上的托管扩展(pg_graphql 等),
|
|
121
|
+
// 若不按特征过滤,任何硬失败(归档损坏、OOM…)都会被误归因到沙箱。
|
|
122
|
+
const MISSING_EXTENSION_ERROR = /type "[^"]+" does not exist|schema "[^"]+" does not exist|extension "[^"]+" is not available/i;
|
|
76
123
|
// 演练沙箱是裸 Postgres,Supabase 托管的 schema/角色必然缺席。post-data 里
|
|
77
124
|
// 引用它们的失败是"环境预期",不是备份坏了;其余失败才是演练要抓的。
|
|
78
125
|
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;
|
|
@@ -116,10 +163,7 @@ export function postDataResult(code, stderr) {
|
|
|
116
163
|
* (policy TO authenticated、FK → auth.users)才归类为沙箱预期,记入报告而非失败。
|
|
117
164
|
*/
|
|
118
165
|
async function pgRestore(dumpPath, connString) {
|
|
119
|
-
const pgRestoreBin =
|
|
120
|
-
(process.env.BACKUPDRILL_PG_DUMP
|
|
121
|
-
? process.env.BACKUPDRILL_PG_DUMP.replace(/pg_dump$/, "pg_restore")
|
|
122
|
-
: "pg_restore");
|
|
166
|
+
const pgRestoreBin = resolvePgRestoreBin();
|
|
123
167
|
const common = ["--no-owner", "--no-privileges", "--dbname", connString, dumpPath];
|
|
124
168
|
const first = await spawnPgRestore(pgRestoreBin, [
|
|
125
169
|
"--clean",
|
|
@@ -141,17 +185,39 @@ async function verifyRestored(connString, manifest) {
|
|
|
141
185
|
await client.connect();
|
|
142
186
|
try {
|
|
143
187
|
const schemas = manifest.database.schemas;
|
|
144
|
-
|
|
145
|
-
|
|
188
|
+
// 口径必须与 manifest 统计端(backup.ts inspectDatabase)完全一致:
|
|
189
|
+
// pg_class relkind in ('r','p','m')。此前这里用 pg_tables(不含 matview),
|
|
190
|
+
// manifest 用 pg_stat_user_tables(含 matview)——任何有 matview 的库
|
|
191
|
+
// 演练必然误报"表数不符 + 缺表"。
|
|
192
|
+
const tablesRes = await client.query(`select n.nspname as schema, c.relname as name,
|
|
193
|
+
c.relkind as kind, c.relispopulated as populated
|
|
194
|
+
from pg_class c
|
|
195
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
196
|
+
where c.relkind in ('r', 'p', 'm')
|
|
197
|
+
and n.nspname = any($1::text[])
|
|
198
|
+
order by 1, 2`, [schemas]);
|
|
146
199
|
const restoredTables = tablesRes.rows;
|
|
147
200
|
// 逐表精确计数(演练要证明数据真回来了,估算值不够)
|
|
148
201
|
const counts = new Map();
|
|
202
|
+
const unpopulatedMatviews = new Set();
|
|
149
203
|
let rowTotal = 0;
|
|
150
204
|
for (const t of restoredTables) {
|
|
205
|
+
const key = `${t.schema}.${t.name}`;
|
|
206
|
+
// 未填充的 matview 无法 count(*)(Postgres 直接报错)。它要么备份时就没数据
|
|
207
|
+
// (dump 里本来就没有 REFRESH),要么 REFRESH 在 post-data 阶段失败——后者已由
|
|
208
|
+
// post-data 检查如实记录,这里按 0 行处理即可。
|
|
209
|
+
if (t.kind === "m" && !t.populated) {
|
|
210
|
+
counts.set(key, 0);
|
|
211
|
+
unpopulatedMatviews.add(key);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
151
214
|
const c = await client.query(`select count(*)::bigint as n from "${t.schema}"."${t.name}"`);
|
|
152
215
|
const n = Number(c.rows[0].n);
|
|
153
|
-
counts.set(
|
|
154
|
-
|
|
216
|
+
counts.set(key, n);
|
|
217
|
+
// 分区父表的 count(*) 是子分区行的总和,子分区又各自计一次;
|
|
218
|
+
// rowTotal 只累计叶子关系,避免翻倍。
|
|
219
|
+
if (t.kind !== "p")
|
|
220
|
+
rowTotal += n;
|
|
155
221
|
}
|
|
156
222
|
const checks = [];
|
|
157
223
|
// 1. 表数量一致
|
|
@@ -170,8 +236,13 @@ async function verifyRestored(connString, manifest) {
|
|
|
170
236
|
? "all manifest tables present"
|
|
171
237
|
: `missing: ${missing.map((t) => `${t.schema}.${t.name}`).join(", ")}`,
|
|
172
238
|
});
|
|
173
|
-
// 3. 备份时有数据的表,还原后不能是空的(最阴险的"备了半个库"故障)
|
|
174
|
-
|
|
239
|
+
// 3. 备份时有数据的表,还原后不能是空的(最阴险的"备了半个库"故障)。
|
|
240
|
+
// 未填充的 matview 豁免:REFRESH 失败已由 post-data 检查裁决(用户对象失败
|
|
241
|
+
// = 演练失败,Supabase 托管依赖 = 预期跳过),这里再报一次会把环境预期
|
|
242
|
+
// 误标成备份损坏。
|
|
243
|
+
const emptied = manifest.database.tables.filter((t) => t.estimatedRows > 0 &&
|
|
244
|
+
!unpopulatedMatviews.has(`${t.schema}.${t.name}`) &&
|
|
245
|
+
(counts.get(`${t.schema}.${t.name}`) ?? 0) === 0);
|
|
175
246
|
checks.push({
|
|
176
247
|
name: "populated tables came back",
|
|
177
248
|
pass: emptied.length === 0,
|
|
@@ -226,12 +297,39 @@ async function verifyStorage(s3, config, manifest, verifyAll) {
|
|
|
226
297
|
*/
|
|
227
298
|
export async function drillDump(dumpPath, manifest, snapshot, preChecks = []) {
|
|
228
299
|
const checks = [...preChecks];
|
|
229
|
-
const
|
|
230
|
-
const pg = await startEphemeralPostgres(major);
|
|
300
|
+
const pg = await startEphemeralPostgres(sandboxImage(manifest));
|
|
231
301
|
try {
|
|
302
|
+
// 旧 manifest(≤0.1.1)没有 extensions 字段 → 不装任何扩展,行为与从前一致
|
|
303
|
+
const extensions = manifest.database.extensions ?? [];
|
|
304
|
+
const unavailable = await installExtensions(pg.connString, extensions);
|
|
305
|
+
if (extensions.length) {
|
|
306
|
+
// 装不上 ≠ 演练失败:扩展本体不在 public 转储里,没丢任何已备份的数据;
|
|
307
|
+
// 若恢复真的需要它,下面的 pgRestore 会失败并给出分类错误
|
|
308
|
+
checks.push({
|
|
309
|
+
name: "sandbox extensions",
|
|
310
|
+
pass: true,
|
|
311
|
+
detail: unavailable.length
|
|
312
|
+
? `installed ${extensions.length - unavailable.length}/${extensions.length}; ` +
|
|
313
|
+
`sandbox image cannot install: ${unavailable.join(", ")}`
|
|
314
|
+
: `installed ${extensions.map((e) => e.name).join(", ")}`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
232
317
|
const started = Date.now();
|
|
233
318
|
log.step("Restoring into ephemeral Postgres…");
|
|
234
|
-
|
|
319
|
+
let postData;
|
|
320
|
+
try {
|
|
321
|
+
postData = await pgRestore(dumpPath, pg.connString);
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
// 沙箱装不上扩展 + 错误特征命中"缺类型/schema/扩展" → 假设式归因到沙箱环境
|
|
325
|
+
// (不对备份健康下断言);其余失败与扩展无关,必须原样抛出
|
|
326
|
+
const message = error.message;
|
|
327
|
+
if (unavailable.length && MISSING_EXTENSION_ERROR.test(message)) {
|
|
328
|
+
throw new Error(`restore failed; likely because the drill sandbox cannot install ` +
|
|
329
|
+
`extension(s) ${unavailable.join(", ")}. Original error: ${message}`);
|
|
330
|
+
}
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
235
333
|
const restoreSeconds = Math.round((Date.now() - started) / 100) / 10;
|
|
236
334
|
checks.push({ name: "pg_restore", pass: true, detail: `completed in ${restoreSeconds}s` });
|
|
237
335
|
// 用户自己的 post-data 对象(索引/约束/触发器)必须全部恢复;Supabase 托管对象
|
|
@@ -273,15 +371,27 @@ export async function runDrill(config, opts = {}) {
|
|
|
273
371
|
try {
|
|
274
372
|
log.step("Downloading dump…");
|
|
275
373
|
const { sha256: sha } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
|
|
374
|
+
const integrityPass = sha === manifest.dump.sha256;
|
|
276
375
|
const preChecks = [
|
|
277
376
|
{
|
|
278
377
|
name: "archive integrity",
|
|
279
|
-
pass:
|
|
280
|
-
detail:
|
|
378
|
+
pass: integrityPass,
|
|
379
|
+
detail: integrityPass
|
|
281
380
|
? `sha256 matches (${sha.slice(0, 12)}…)`
|
|
282
381
|
: `sha256 MISMATCH — got ${sha.slice(0, 12)}…, manifest ${manifest.dump.sha256.slice(0, 12)}…`,
|
|
283
382
|
},
|
|
284
383
|
];
|
|
384
|
+
// 归档校验和不符 = 演练已经 FAIL,继续恢复只会产生误导性失败;短路如实报告
|
|
385
|
+
if (!integrityPass) {
|
|
386
|
+
return {
|
|
387
|
+
snapshot,
|
|
388
|
+
pass: false,
|
|
389
|
+
checks: preChecks,
|
|
390
|
+
restoreSeconds: 0,
|
|
391
|
+
restoredTableCount: 0,
|
|
392
|
+
restoredRowTotal: 0,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
285
395
|
// Storage 文件完整性(备份含 Storage 时才校验)
|
|
286
396
|
if (manifest.storage && manifest.storage.files.length) {
|
|
287
397
|
log.step("Verifying Storage files…");
|
package/dist/lib.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { runEstimate, project } from "./estimate.js";
|
|
|
5
5
|
export { loadConfig } from "./config.js";
|
|
6
6
|
export { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
|
|
7
7
|
export type { BackupConfig, StorageConfig, SupabaseStorageConfig, } from "./config.js";
|
|
8
|
-
export type { Manifest, StorageFile, TableStat } from "./manifest.js";
|
|
8
|
+
export type { ExtensionInfo, Manifest, StorageFile, TableStat } from "./manifest.js";
|
|
9
9
|
export type { DrillReport, DrillCheck } from "./drill.js";
|
|
10
10
|
export type { RestoreResult } from "./restore.js";
|
|
11
11
|
export type { EgressPricing, EgressProjection } from "./estimate.js";
|
package/dist/manifest.d.ts
CHANGED
|
@@ -8,6 +8,17 @@ export interface TableStat {
|
|
|
8
8
|
name: string;
|
|
9
9
|
estimatedRows: number;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* 源库安装的非内置扩展(pg_extension,排除必装的 plpgsql)。
|
|
13
|
+
* --schema=public 的转储不含 CREATE EXTENSION,但表定义会引用扩展类型
|
|
14
|
+
* (如 extensions.vector)——沙箱恢复前必须按这份清单把扩展装进**同名 schema**,
|
|
15
|
+
* dump 里的限定名才解析得到(Supabase 惯例装在 "extensions" schema)。
|
|
16
|
+
*/
|
|
17
|
+
export interface ExtensionInfo {
|
|
18
|
+
name: string;
|
|
19
|
+
version: string;
|
|
20
|
+
schema: string;
|
|
21
|
+
}
|
|
11
22
|
export interface Manifest {
|
|
12
23
|
tool: "backupdrill-cli";
|
|
13
24
|
toolVersion: string;
|
|
@@ -20,6 +31,7 @@ export interface Manifest {
|
|
|
20
31
|
tableCount: number;
|
|
21
32
|
estimatedRowTotal: number;
|
|
22
33
|
tables: TableStat[];
|
|
34
|
+
extensions?: ExtensionInfo[];
|
|
23
35
|
};
|
|
24
36
|
dump: {
|
|
25
37
|
key: string;
|
package/dist/pgbin.d.ts
ADDED
package/dist/pgbin.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pg_restore 二进制解析:显式 BACKUPDRILL_PG_RESTORE 最高优先;否则从
|
|
3
|
+
* BACKUPDRILL_PG_DUMP 同目录推导。推导未命中(结果仍含 pg_dump 或与原值相同,
|
|
4
|
+
* 如指向包装脚本/带版本后缀的路径)时回退到 PATH 上的 pg_restore——
|
|
5
|
+
* 绝不能把 pg_dump 当恢复器执行。
|
|
6
|
+
*/
|
|
7
|
+
export function resolvePgRestoreBin() {
|
|
8
|
+
if (process.env.BACKUPDRILL_PG_RESTORE)
|
|
9
|
+
return process.env.BACKUPDRILL_PG_RESTORE;
|
|
10
|
+
const pgDump = process.env.BACKUPDRILL_PG_DUMP;
|
|
11
|
+
if (pgDump) {
|
|
12
|
+
const derived = pgDump.replace(/pg_dump$/, "pg_restore");
|
|
13
|
+
if (derived !== pgDump && !derived.includes("pg_dump"))
|
|
14
|
+
return derived;
|
|
15
|
+
}
|
|
16
|
+
return "pg_restore";
|
|
17
|
+
}
|
package/dist/restore.js
CHANGED
|
@@ -6,12 +6,10 @@ import { join, dirname } from "node:path";
|
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
8
8
|
import { targetClient, resolveSnapshot, getObjectText, downloadToFile, } from "./snapshots.js";
|
|
9
|
+
import { resolvePgRestoreBin } from "./pgbin.js";
|
|
9
10
|
import { log } from "./log.js";
|
|
10
11
|
function pgRestore(dumpPath, targetUrl) {
|
|
11
|
-
const bin =
|
|
12
|
-
(process.env.BACKUPDRILL_PG_DUMP
|
|
13
|
-
? process.env.BACKUPDRILL_PG_DUMP.replace(/pg_dump$/, "pg_restore")
|
|
14
|
-
: "pg_restore");
|
|
12
|
+
const bin = resolvePgRestoreBin();
|
|
15
13
|
return new Promise((resolve, reject) => {
|
|
16
14
|
const proc = spawn(bin, ["--clean", "--if-exists", "--no-owner", "--no-privileges", "--dbname", targetUrl, dumpPath], { stdio: ["ignore", "ignore", "pipe"] });
|
|
17
15
|
let stderr = "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backupdrill",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,18 +33,28 @@
|
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
|
35
35
|
"supabase",
|
|
36
|
+
"supabase-backup",
|
|
36
37
|
"backup",
|
|
38
|
+
"database-backup",
|
|
37
39
|
"postgres",
|
|
40
|
+
"postgresql",
|
|
38
41
|
"pg_dump",
|
|
42
|
+
"restore",
|
|
39
43
|
"s3",
|
|
44
|
+
"cloudflare-r2",
|
|
45
|
+
"backblaze-b2",
|
|
40
46
|
"disaster-recovery"
|
|
41
47
|
],
|
|
42
48
|
"license": "MIT",
|
|
49
|
+
"author": "BackupDrill <support@backupdrill.com> (https://backupdrill.com)",
|
|
43
50
|
"homepage": "https://backupdrill.com",
|
|
44
51
|
"repository": {
|
|
45
52
|
"type": "git",
|
|
46
53
|
"url": "git+https://github.com/backupdrill/cli.git"
|
|
47
54
|
},
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/backupdrill/cli/issues"
|
|
57
|
+
},
|
|
48
58
|
"dependencies": {
|
|
49
59
|
"@aws-sdk/client-s3": "^3.658.0",
|
|
50
60
|
"@aws-sdk/lib-storage": "^3.658.0",
|