backupdrill 2.6.0 → 2.7.1

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.
@@ -0,0 +1,435 @@
1
+ // 指向备份之外的外键:恢复前查目标库里有没有被引用的行。
2
+ //
3
+ // 触因(2026-10-22 实验):只备 public 的快照,profiles.id 外键指向 auth.users。演练沙箱没有
4
+ // auth.users,这条外键被归为托管对象跳过,演练 PASS;dry-run 只拿 manifest 对目标环境,
5
+ // 根本不看 dump,也过了;恢复到新 Supabase 项目时 auth.users 在、但一行都没有,
6
+ // ADD CONSTRAINT 校验已有行失败。外键定义和引用方的数据都只在 dump 里,所以这项检查必须读 dump。
7
+ //
8
+ // 只查"会校验已有行"的外键(NOT VALID / NOT ENFORCED 的恢复时不校验),只查被引用表在
9
+ // 备份之外的(备份之内的表和数据一起恢复,天然对得上)。
10
+ import { spawn } from "node:child_process";
11
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { StringDecoder } from "node:string_decoder";
15
+ import { MAX_COPY_HEADER, decodeCopyField, formatQualified, parseCopyHeader, parseForeignKeys, parsePartitionAttachments, parseTableColumns, } from "./dump-sql.js";
16
+ import { libpqChildEnv, pgRestoreOutput, quoteIdent } from "./restore-engine.js";
17
+ import { resolvePgRestoreBin } from "./pgbin.js";
18
+ import { connectPg } from "./supabase-ca.js";
19
+ /** 一批最多带多少个不同的键去目标库查。uuid 键一批约 200 KB 参数,目标端一次反连接。 */
20
+ const KEY_BATCH = 5000;
21
+ const keyOf = (name) => `${name.schema}\u0000${name.name}`;
22
+ /**
23
+ * 读 dump,找出指向 dumpedSchemas 之外的外键,逐条拿目标库对行。只读:目标库上只跑 select。
24
+ * pg_restore 起不来、归档读不了时抛错,由调用方当成 blocker。单条外键查不成(目标端报错、
25
+ * 数据块读不了)只影响它自己,记进 unchecked,不拖累别的外键的结论。
26
+ */
27
+ export async function checkExternalReferences(opts) {
28
+ const bin = resolvePgRestoreBin();
29
+ const result = { checked: [], problems: [], unchecked: [], waitingForExtensions: false };
30
+ const listing = await pgRestoreOutput(bin, ["--list", opts.dumpPath]);
31
+ if (listing.code !== 0)
32
+ throw new Error(`pg_restore --list exited with code ${listing.code}: ${oneLine(listing.stderr)}`);
33
+ const entries = listing.stdout.split("\n");
34
+ const fkEntries = entries.filter((line) => /^\d+; \d+ \d+ FK CONSTRAINT /.test(line));
35
+ if (!fkEntries.length)
36
+ return result;
37
+ const attachEntries = entries.filter((line) => /^\d+; \d+ \d+ TABLE ATTACH /.test(line));
38
+ // 建表语句:比较时要用引用方列的类型
39
+ const tableEntries = entries.filter((line) => /^\d+; \d+ \d+ TABLE (?!DATA |ATTACH )/.test(line));
40
+ const listDir = await mkdtemp(join(tmpdir(), "backupdrill-refs-"));
41
+ let definitions;
42
+ try {
43
+ const listFile = join(listDir, "fk.list");
44
+ await writeFile(listFile, [...tableEntries, ...fkEntries, ...attachEntries].join("\n") + "\n");
45
+ const ddl = await pgRestoreOutput(bin, ["-f", "-", `--use-list=${listFile}`, opts.dumpPath]);
46
+ if (ddl.code !== 0)
47
+ throw new Error(`pg_restore exited with code ${ddl.code}: ${oneLine(ddl.stderr)}`);
48
+ definitions = ddl.stdout;
49
+ }
50
+ finally {
51
+ await rm(listDir, { recursive: true, force: true });
52
+ }
53
+ const foreignKeys = parseForeignKeys(definitions);
54
+ if (foreignKeys.length < fkEntries.length) {
55
+ result.unchecked.push(`${fkEntries.length - foreignKeys.length} foreign key definition(s) in the dump are in a form this check can't read`);
56
+ }
57
+ const dumped = new Set(opts.dumpedSchemas);
58
+ const external = foreignKeys.filter((fk) => !dumped.has(fk.references.schema) && fk.validatesRows);
59
+ if (!external.length)
60
+ return result;
61
+ // 分区父表上的外键,数据在各级叶子分区里(dump 只给叶子写 COPY)
62
+ const children = new Map();
63
+ for (const { parent, child } of parsePartitionAttachments(definitions)) {
64
+ children.set(keyOf(parent), [...(children.get(keyOf(parent)) ?? []), child]);
65
+ }
66
+ const leavesOf = (table) => {
67
+ const below = children.get(keyOf(table)) ?? [];
68
+ return below.length ? below.flatMap(leavesOf) : [table];
69
+ };
70
+ const pending = opts.pendingExtensions ?? [];
71
+ const waitForExtensions = (fk, what) => {
72
+ result.waitingForExtensions = true;
73
+ result.unchecked.push(`${describe(fk)}: ${what}; extension(s) ${pending.join(", ")} that the restore installs first may provide it, ` +
74
+ `so it is checked after they are installed`);
75
+ };
76
+ const tableColumns = parseTableColumns(definitions);
77
+ const client = opts.inTransaction ?? (await connectPg(opts.targetUrl));
78
+ if (opts.inTransaction)
79
+ insideCallerTransaction.add(client);
80
+ try {
81
+ const scans = [];
82
+ for (const fk of external) {
83
+ let lookup;
84
+ try {
85
+ lookup = await comparisonPlan(client, fk, tableColumns.get(keyOf(fk.table)));
86
+ }
87
+ catch (error) {
88
+ result.unchecked.push(`${describe(fk)}: could not look up ${formatQualified(fk.references)} on the target (${error.message})`);
89
+ continue;
90
+ }
91
+ if ("unchecked" in lookup) {
92
+ if (lookup.typeMissing && pending.length)
93
+ waitForExtensions(fk, lookup.unchecked);
94
+ else
95
+ result.unchecked.push(`${describe(fk)}: ${lookup.unchecked}`);
96
+ continue;
97
+ }
98
+ if ("problem" in lookup) {
99
+ if (lookup.tableMissing && pending.length) {
100
+ waitForExtensions(fk, `${formatQualified(fk.references)} isn't on the target yet`);
101
+ }
102
+ else {
103
+ result.problems.push(`${describe(fk)}: ${lookup.problem}`);
104
+ }
105
+ continue;
106
+ }
107
+ const checked = { fk, rows: 0, missingRows: 0 };
108
+ result.checked.push(checked);
109
+ scans.push({ checked, comparisons: lookup.comparisons, only: lookup.only, leaves: new Set(leavesOf(fk.table).map(keyOf)), seen: new Set(), batch: new Map() });
110
+ }
111
+ if (scans.length) {
112
+ try {
113
+ await scanDumpData(bin, opts.dumpPath, scans, client);
114
+ }
115
+ catch (error) {
116
+ for (const scan of scans)
117
+ scan.failed ??= `could not read the dump's data (${error.message})`;
118
+ }
119
+ for (const scan of scans) {
120
+ const unseen = [...scan.leaves].filter((table) => !scan.seen.has(table));
121
+ if (!scan.failed && unseen.length) {
122
+ const names = unseen.map((key) => {
123
+ const [schema, name] = key.split("\u0000");
124
+ return formatQualified({ schema, name });
125
+ });
126
+ scan.failed = `could not read the dumped data of ${names.join(", ")}`;
127
+ }
128
+ const { fk, rows, missingRows } = scan.checked;
129
+ if (missingRows > 0) {
130
+ // 已经确认缺的行是事实,后面查不下去也不能把它吞掉
131
+ result.problems.push(scan.failed
132
+ ? `${describe(fk)}: at least ${missingRows.toLocaleString("en-US")} row(s) point at ` +
133
+ `${formatQualified(fk.references)} rows the target doesn't have (the check stopped early: ${scan.failed})`
134
+ : `${describe(fk)}: ${missingRows.toLocaleString("en-US")} of ${rows.toLocaleString("en-US")} row(s) ` +
135
+ `point at ${formatQualified(fk.references)} rows the target doesn't have`);
136
+ }
137
+ else if (scan.failed) {
138
+ result.unchecked.push(`${describe(fk)}: ${scan.failed}`);
139
+ result.checked = result.checked.filter((c) => c !== scan.checked);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ finally {
145
+ if (opts.inTransaction)
146
+ insideCallerTransaction.delete(client);
147
+ else
148
+ await client.end();
149
+ }
150
+ return result;
151
+ }
152
+ export function describe(fk) {
153
+ return `${formatQualified(fk.table)} → ${formatQualified(fk.references)} (${fk.constraint})`;
154
+ }
155
+ /**
156
+ * 目标端的查询一律放进只读事务,查完回滚:dry-run 承诺零写入,而类型转换、域约束、自定义相等
157
+ * 运算符都能调用户函数(交叉审查第二轮复现过域 CHECK 写审计表)。每条语句各开一个事务,
158
+ * 一条出错不会让后面的查询跟着报 "current transaction is aborted"。
159
+ */
160
+ /** 用的是调用方事务的连接:不能另开事务,改用保存点,查完回滚到保存点(写了什么都撤掉)。 */
161
+ const insideCallerTransaction = new WeakSet();
162
+ async function readOnly(client, sql, params) {
163
+ if (insideCallerTransaction.has(client)) {
164
+ await client.query("savepoint backupdrill_check");
165
+ try {
166
+ // 保存点里也设成只读:回滚到保存点撤不掉序列的 nextval/setval(交叉审查复现),只读才拦得住;
167
+ // 这个设置随保存点一起撤销,外层事务照样能提交扩展
168
+ await client.query("set local transaction_read_only = on");
169
+ await client.query("set local row_security = off");
170
+ return await client.query(sql, params);
171
+ }
172
+ finally {
173
+ // 回滚之后保存点还在,不 release 的话每条查询都往里套一层
174
+ await client.query("rollback to savepoint backupdrill_check").catch(() => { });
175
+ await client.query("release savepoint backupdrill_check").catch(() => { });
176
+ }
177
+ }
178
+ await client.query("begin transaction read only");
179
+ try {
180
+ // 外键校验看得到被 RLS 藏起来的行,我们的查询看不到:关掉行级安全,真被它影响时 Postgres 直接报错
181
+ // (算"没查成"),而不是悄悄少返回几行、把存在的行报成缺(推送前审查)
182
+ await client.query("set local row_security = off");
183
+ return await client.query(sql, params);
184
+ }
185
+ finally {
186
+ await client.query("rollback").catch(() => { });
187
+ }
188
+ }
189
+ /**
190
+ * 拿来写 `::类型` 的名字。format_type 给 bpchar 和 bit 的是 character / bit,写进转换里就成了
191
+ * char(1) / bit(1),值被截成一位(交叉审查复现:ABCD 和 AXYZ 被当成同一个键)。这两个换成不带长度的写法。
192
+ */
193
+ const CAST_NAME = (oid) => `case ${oid}
194
+ when 'pg_catalog.bpchar'::regtype then 'pg_catalog.bpchar'
195
+ when 'pg_catalog._bpchar'::regtype then 'pg_catalog.bpchar[]'
196
+ when 'pg_catalog.bit'::regtype then 'pg_catalog.varbit'
197
+ when 'pg_catalog._bit'::regtype then 'pg_catalog.varbit[]'
198
+ else format_type(${oid}, null) end`;
199
+ /**
200
+ * 照外键自己的做法定比较方式(ATAddForeignKeyConstraint):找被引用列上那个唯一索引,取它每列的
201
+ * 运算符族,在族里找 (索引输入类型, 引用方类型) 的相等运算符;找不到而引用方类型能隐式转成索引输入
202
+ * 类型时,用 (输入类型, 输入类型) 那个。运算符带 schema 写死:裸 `=` 在 search_path 里看不到
203
+ * extensions 时会把 citext 解析成 text 的相等,大小写不同的值就被误报成缺(交叉审查复现)。
204
+ * 引用方列的类型从 dump 的建表语句读,值本来就出自这一列。
205
+ * 被引用的表、列、唯一索引不在时返回 problem(建外键必然失败);其余没法照做的报 unchecked。
206
+ */
207
+ async function comparisonPlan(client, fk, columnsInDump) {
208
+ const relation = `${quoteIdent(fk.references.schema)}.${quoteIdent(fk.references.name)}`;
209
+ const exists = await readOnly(client, "select to_regclass($1)::oid as oid, (select relkind from pg_class where oid = to_regclass($1)) as kind", [relation]);
210
+ const relid = exists.rows[0].oid;
211
+ if (!relid) {
212
+ return { problem: `${formatQualified(fk.references)} does not exist on the target`, tableMissing: true };
213
+ }
214
+ const attributes = await readOnly(client, `select a.attname as name, a.attnum::int as num from pg_attribute a
215
+ where a.attrelid = $1::oid and a.attnum > 0 and not a.attisdropped and a.attname = any($2::text[])`, [relid, fk.referencedColumns]);
216
+ const attnum = new Map(attributes.rows.map((row) => [row.name, row.num]));
217
+ const absent = fk.referencedColumns.filter((name) => !attnum.has(name));
218
+ if (absent.length) {
219
+ return { problem: `${formatQualified(fk.references)} on the target has no column(s) ${absent.join(", ")}`, tableMissing: false };
220
+ }
221
+ // 外键要求被引用列上有一个恰好覆盖这几列的唯一索引(顺序不限),而且不能是可延迟的(indimmediate)
222
+ const indexes = await readOnly(client, `select i.indkey::text as keys, i.indclass::text as classes, i.indnkeyatts::int as nkeys from pg_index i
223
+ where i.indrelid = $1::oid and i.indisunique and i.indimmediate and i.indisvalid
224
+ and i.indpred is null and i.indexprs is null`, [relid]);
225
+ const wanted = fk.referencedColumns.map((name) => attnum.get(name)).sort((a, b) => a - b).join(" ");
226
+ const index = indexes.rows
227
+ .map((row) => ({ keys: row.keys.split(" ").slice(0, row.nkeys).map(Number), classes: row.classes.split(" ") }))
228
+ .find((row) => [...row.keys].sort((a, b) => a - b).join(" ") === wanted);
229
+ if (!index) {
230
+ return {
231
+ problem: `${formatQualified(fk.references)} on the target has no unique index on (${fk.referencedColumns.join(", ")}), so the foreign key can't be created`,
232
+ tableMissing: false,
233
+ };
234
+ }
235
+ const comparisons = [];
236
+ for (let i = 0; i < fk.columns.length; i++) {
237
+ const column = fk.columns[i];
238
+ const declared = columnsInDump?.get(column);
239
+ if (!declared)
240
+ return { unchecked: `could not read the type of column ${column} from the dump` };
241
+ const fkType = await readOnly(client,
242
+ // 域按外键的做法换成底层类型(可以多层嵌套)
243
+ `with recursive t(oid, base, depth) as (
244
+ select to_regtype($1)::oid, to_regtype($1)::oid, 0
245
+ union all
246
+ select t.oid, ty.typbasetype, t.depth + 1 from t join pg_type ty on ty.oid = t.base
247
+ where ty.typtype = 'd' and t.depth < 20)
248
+ select oid::text as oid, base::text as base, ${CAST_NAME("base")} as name from t order by depth desc limit 1`, [declared]);
249
+ const fkBase = fkType.rows[0]?.base;
250
+ if (!fkType.rows[0]?.oid || !fkBase) {
251
+ return { unchecked: `column ${column} uses type ${declared}, which is missing on the target`, typeMissing: true };
252
+ }
253
+ const opclass = index.classes[index.keys.indexOf(attnum.get(fk.referencedColumns[i]))];
254
+ // 先找 (输入类型, 引用方类型);没有就看能不能隐式转成输入类型,用 (输入类型, 输入类型)
255
+ const operator = await readOnly(client, `select n.nspname as schema, o.oprname as name,
256
+ ${CAST_NAME("o.oprright")} as right, o.oprright <> $2::oid as coerced
257
+ from pg_opclass oc
258
+ join pg_amop am on am.amopfamily = oc.opcfamily and am.amoplefttype = oc.opcintype and am.amopstrategy = 3
259
+ join pg_operator o on o.oid = am.amopopr
260
+ join pg_namespace n on n.oid = o.oprnamespace
261
+ where oc.oid = $1::oid
262
+ and (am.amoprighttype = $2::oid
263
+ or (am.amoprighttype = oc.opcintype
264
+ and not exists (select 1 from pg_amop x where x.amopfamily = oc.opcfamily and x.amoplefttype = oc.opcintype
265
+ and x.amoprighttype = $2::oid and x.amopstrategy = 3)
266
+ and exists (select 1 from pg_cast c where c.castsource = $2::oid and c.casttarget = oc.opcintype
267
+ and c.castcontext = 'i')))
268
+ order by (am.amoprighttype = $2::oid) desc limit 1`, [opclass, fkBase]);
269
+ const op = operator.rows[0];
270
+ if (!op) {
271
+ return { unchecked: `can't tell how the foreign key would compare ${declared} with ${formatQualified(fk.references)}.${fk.referencedColumns[i]}` };
272
+ }
273
+ // 运算符名只由符号组成(系统表保证),schema 照常加引号
274
+ comparisons.push({
275
+ referencedColumn: fk.referencedColumns[i],
276
+ operator: `OPERATOR(${quoteIdent(op.schema)}.${op.name})`,
277
+ rightCast: op.coerced ? `${fkType.rows[0].name}::${op.right}` : op.right,
278
+ });
279
+ }
280
+ // 普通表的外键只认它自己的行,继承出来的子表里的行不算(查 ONLY);分区表的行都在分区里,不能加 ONLY
281
+ return { comparisons, only: exists.rows[0].kind !== "p" };
282
+ }
283
+ /**
284
+ * 把需要的表的数据从 dump 里流出来(pg_restore --data-only -f -),按 COPY 块分发给各条外键。
285
+ * 流式读,内存只占一批键。-n/-t 在 pg_restore 里是精确名字匹配、两者取交集,
286
+ * 多选出来的表按 COPY 头里的全名再筛一遍。
287
+ */
288
+ async function scanDumpData(bin, dumpPath, scans, client) {
289
+ const wanted = new Map();
290
+ const schemas = new Set();
291
+ const names = new Set();
292
+ for (const scan of scans) {
293
+ for (const table of scan.leaves) {
294
+ wanted.set(table, [...(wanted.get(table) ?? []), scan]);
295
+ const [schema, name] = table.split("\u0000");
296
+ schemas.add(schema);
297
+ names.add(name);
298
+ }
299
+ }
300
+ const args = ["--data-only", "-f", "-"];
301
+ for (const schema of schemas)
302
+ args.push("-n", schema);
303
+ for (const name of names)
304
+ args.push("-t", name);
305
+ args.push(dumpPath);
306
+ const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], env: libpqChildEnv() });
307
+ let stderr = "";
308
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
309
+ const exited = new Promise((resolve, reject) => {
310
+ proc.on("error", (error) => reject(new Error(`${bin} failed to start: ${error.code ?? error.message}`)));
311
+ proc.on("close", resolve);
312
+ });
313
+ exited.catch(() => { });
314
+ const flush = async (scan) => {
315
+ if (!scan.batch.size || scan.failed)
316
+ return;
317
+ const entries = [...scan.batch.values()];
318
+ scan.batch.clear();
319
+ try {
320
+ const missing = await missingKeyIndexes(client, scan.checked.fk, scan.comparisons, scan.only, entries.map((e) => e.key));
321
+ for (const index of missing)
322
+ scan.checked.missingRows += entries[index - 1].rows;
323
+ }
324
+ catch (error) {
325
+ // 只停这一条外键:比如值转不成被引用列的类型
326
+ scan.failed = `could not compare its rows on the target (${error.message})`;
327
+ }
328
+ };
329
+ // 当前 COPY 块里每条外键要取的列下标;null = 不在 COPY 块里,[] = 在一个不需要的块里
330
+ let current = null;
331
+ // 带引号的名字里可以有换行,COPY 头就会跨行:攒到引号配平、以 FROM stdin; 结尾为止
332
+ let header = null;
333
+ const openHeader = (text) => {
334
+ const parsed = parseCopyHeader(text);
335
+ current = [];
336
+ if (!parsed)
337
+ return; // 读不了的块整块跳过;它的表没记成 seen,最后会报没查成
338
+ const consumers = wanted.get(keyOf(parsed.table)) ?? [];
339
+ for (const scan of consumers) {
340
+ scan.seen.add(keyOf(parsed.table));
341
+ const indexes = scan.checked.fk.columns.map((column) => parsed.columns.indexOf(column));
342
+ if (indexes.some((i) => i < 0)) {
343
+ // 生成列不进 COPY
344
+ scan.failed ??= `column(s) ${scan.checked.fk.columns.join(", ")} are not in the dumped data of ${formatQualified(parsed.table)}`;
345
+ continue;
346
+ }
347
+ current.push({ scan, indexes });
348
+ }
349
+ };
350
+ const onLine = (line) => {
351
+ if (current === null) {
352
+ if (header === null && !line.startsWith("COPY "))
353
+ return;
354
+ header = header === null ? line : `${header}\n${line}`;
355
+ const quotes = header.split('"').length - 1;
356
+ if ((quotes % 2 === 0 && header.endsWith(" FROM stdin;")) || header.length > MAX_COPY_HEADER) {
357
+ openHeader(header);
358
+ header = null;
359
+ }
360
+ return;
361
+ }
362
+ if (line === "\\.") {
363
+ current = null;
364
+ return;
365
+ }
366
+ if (!current.length)
367
+ return;
368
+ const fields = line.split("\t");
369
+ let full = null;
370
+ for (const { scan, indexes } of current) {
371
+ if (scan.failed)
372
+ continue;
373
+ const key = indexes.map((i) => (i < fields.length ? decodeCopyField(fields[i]) : null));
374
+ if (key.some((value) => value === null))
375
+ continue;
376
+ scan.checked.rows += 1;
377
+ const id = JSON.stringify(key);
378
+ const seen = scan.batch.get(id);
379
+ if (seen)
380
+ seen.rows += 1;
381
+ else
382
+ scan.batch.set(id, { key: key, rows: 1 });
383
+ if (scan.batch.size >= KEY_BATCH)
384
+ (full ??= []).push(scan);
385
+ }
386
+ // 一个接一个查:几条外键共用一条连接,同时开事务会互相串(一条报错把另一条的查询也拖成 aborted)
387
+ if (full) {
388
+ const ready = full;
389
+ return ready.reduce((chain, scan) => chain.then(() => flush(scan)), Promise.resolve());
390
+ }
391
+ };
392
+ try {
393
+ const decoder = new StringDecoder("utf8");
394
+ let carry = "";
395
+ // for await 读 stdout 自带背压:查库的时候 pg_restore 停着等
396
+ for await (const chunk of proc.stdout) {
397
+ const lines = (carry + decoder.write(chunk)).split("\n");
398
+ carry = lines.pop();
399
+ for (const line of lines) {
400
+ const wait = onLine(line);
401
+ if (wait)
402
+ await wait;
403
+ }
404
+ }
405
+ carry += decoder.end();
406
+ if (carry)
407
+ await onLine(carry);
408
+ for (const scan of scans)
409
+ await flush(scan);
410
+ }
411
+ finally {
412
+ if (proc.exitCode === null)
413
+ proc.kill();
414
+ }
415
+ const code = await exited;
416
+ if (code !== 0)
417
+ throw new Error(`pg_restore exited with code ${code}: ${oneLine(stderr)}`);
418
+ }
419
+ /** 一批键里哪些在被引用表里找不到;返回 1 起的下标。比较方式见 comparisonPlan。 */
420
+ async function missingKeyIndexes(client, fk, comparisons, only, keys) {
421
+ const aliases = comparisons.map((_, i) => `c${i}`);
422
+ const sql = `select k.i::int as i from unnest(${comparisons.map((_, i) => `$${i + 1}::text[]`).join(", ")}) ` +
423
+ `with ordinality as k(${aliases.join(", ")}, i) ` +
424
+ `where not exists (select 1 from ${only ? "only " : ""}${quoteIdent(fk.references.schema)}.${quoteIdent(fk.references.name)} r where ` +
425
+ comparisons
426
+ // 被引用列不做转换(转成 character / bit 这类名字会截断它);运算符写死了 schema,右边的类型也定了
427
+ .map((c, i) => `r.${quoteIdent(c.referencedColumn)} ${c.operator} k.${aliases[i]}::${c.rightCast}`)
428
+ .join(" and ") +
429
+ `)`;
430
+ const res = await readOnly(client, sql, comparisons.map((_, i) => keys.map((key) => key[i])));
431
+ return res.rows.map((row) => row.i);
432
+ }
433
+ function oneLine(text) {
434
+ return text.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)";
435
+ }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { Writable } from "node:stream";
10
10
  import { runRestore, NO_SOURCE_DATABASE } from "./restore.js";
11
11
  import { recordDrillOutcome } from "./drill-record.js";
12
12
  import { DEFAULT_EXPORTER_PORT, startExporter } from "./exporter.js";
13
+ import { exitCleanlyOnSignals, interrupted } from "./interrupt.js";
13
14
  /** 隐藏式秘密输入(PRD §5.3.1):终端不回显;无 TTY(CI/脚本)时明确要求走环境变量。 */
14
15
  async function promptSecret(label) {
15
16
  if (!process.stdin.isTTY) {
@@ -119,6 +120,8 @@ program
119
120
  .option("--record", "write the outcome as a small JSON file in <prefix>/<project>.drills/, where " +
120
121
  "`backupdrill exporter` reads it. Needs write access to the bucket")
121
122
  .action(async (opts) => {
123
+ // Ctrl+C / SIGTERM:删掉沙箱容器、停掉 --check-cmd 和 pg_restore、删下载的 dump,再以 130/143 退出
124
+ const stopHandlingSignals = exitCleanlyOnSignals("drill");
122
125
  const ping = createPinger(await resolvePingUrl({ flag: opts.pingUrl, configPath: opts.config }));
123
126
  await ping.start();
124
127
  // config 放在 try 外面:出错时 /fail 要用它抹敏感值,--record 要用它记一笔"没通过"
@@ -148,6 +151,9 @@ program
148
151
  appCheckCommand: opts.checkCmd,
149
152
  keepSandboxOnFailure: opts.keep,
150
153
  });
154
+ // 被打断的演练不出报告、不 ping、不记录:信号处理在收尾,完了会以 130/143 退出
155
+ if (interrupted())
156
+ return;
151
157
  console.error("");
152
158
  for (const c of report.checks) {
153
159
  console.error(` ${c.pass ? "✓" : "✗"} ${c.name} — ${c.detail}`);
@@ -169,6 +175,8 @@ program
169
175
  }
170
176
  }
171
177
  catch (error) {
178
+ if (interrupted())
179
+ return;
172
180
  const message = error.message;
173
181
  log.error(message);
174
182
  process.exitCode = 1;
@@ -177,6 +185,9 @@ program
177
185
  await recordDrillOutcome(config, null);
178
186
  await ping.fail(`backupdrill ${TOOL_VERSION}: drill failed. ${pingMessageOf(error)}`, sensitiveValuesOf(config));
179
187
  }
188
+ finally {
189
+ stopHandlingSignals();
190
+ }
180
191
  });
181
192
  program
182
193
  .command("restore")
@@ -188,7 +199,8 @@ program
188
199
  .option("--target-supabase-url <url>", "target project URL (https://<ref>.supabase.co) to upload Storage files into; " +
189
200
  "service-role key via env BACKUPDRILL_TARGET_SERVICE_ROLE_KEY or a hidden prompt")
190
201
  .option("--confirm-target <ref>", "type the TARGET project ref to confirm writing to it")
191
- .option("--dry-run", "run every read-only preflight check and write nothing")
202
+ .option("--dry-run", "run every read-only preflight check and write nothing (downloads the dump to check it, " +
203
+ "including foreign keys that point outside the backup)")
192
204
  .option("--acknowledge-unverified-source", "proceed although the source identity cannot be verified (legacy snapshot without config) — " +
193
205
  "same-source protection is inert; double-check the target first")
194
206
  .option("--storage-dir <path>", "local directory to write Storage files to (when not uploading)")
@@ -0,0 +1,28 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ type Cleanup = () => Promise<void> | void;
3
+ /** 收到过信号没有。主流程据此闭嘴:信号处理正在收尾并会自己退出,别再打印报告、发 ping、写记录。 */
4
+ export declare function interrupted(): boolean;
5
+ /**
6
+ * 收到信号之后不再开始新的一步(起容器、跑 pg_restore、跑检查命令)。清理在跑的时候主流程还活着,
7
+ * 一个刚返回的探测会接着往下走:不拦的话,清理快照之后才起的沙箱就没人删了(交叉审查)。
8
+ */
9
+ export declare function throwIfInterrupted(): void;
10
+ /** 登记一个中断时要跑的清理;返回注销函数,正常走完后调用。 */
11
+ export declare function onInterrupt(cleanup: Cleanup): () => void;
12
+ /**
13
+ * 中断时停掉一个子进程。先 SIGTERM 给它收尾的机会,2 秒后还在就 SIGKILL。
14
+ * group:子进程自成进程组(detached,如 --check-cmd),按组发信号,连它派生的进程一起停。
15
+ * 看的是**整组**还在不在,不是组长:shell 收到 SIGTERM 先退了,忽略 SIGTERM 的孙进程还在,
16
+ * 只盯组长就会跳过 SIGKILL 把它留下(交叉审查复现)。
17
+ */
18
+ export declare function stopOnInterrupt(child: ChildProcess, opts?: {
19
+ group?: boolean;
20
+ }): () => void;
21
+ /**
22
+ * CLI 专用:在 what(比如 "drill")运行期间接管 SIGINT/SIGTERM。第一次收到信号时跑完登记的清理,
23
+ * 以 130(SIGINT)/ 143(SIGTERM)退出;清理途中再按一次 Ctrl+C 就不等了,直接退出
24
+ * (1 秒内重复到达的同一个信号不算,见 REPEATED_SIGNAL_GRACE_MS)。
25
+ * 返回卸载函数。
26
+ */
27
+ export declare function exitCleanlyOnSignals(what: string): () => void;
28
+ export {};
@@ -0,0 +1,144 @@
1
+ import { log } from "./log.js";
2
+ const pending = new Set();
3
+ let interruptedBy = null;
4
+ let interruptedAt = 0;
5
+ // npx / npm exec 会把自己收到的 SIGINT/SIGTERM 再转发给 CLI(@npmcli/run-script 的 signal-manager)。
6
+ // 终端里的 Ctrl+C 发给整个前台进程组,所以按一次,CLI 几乎同时收到两次:不挡的话第二次会被当成
7
+ // "再按一次跳过清理",沙箱照样留下。离第一次不到这么久的同一个信号算同一次。
8
+ const REPEATED_SIGNAL_GRACE_MS = 1000;
9
+ /** 收到过信号没有。主流程据此闭嘴:信号处理正在收尾并会自己退出,别再打印报告、发 ping、写记录。 */
10
+ export function interrupted() {
11
+ return interruptedBy !== null;
12
+ }
13
+ /**
14
+ * 收到信号之后不再开始新的一步(起容器、跑 pg_restore、跑检查命令)。清理在跑的时候主流程还活着,
15
+ * 一个刚返回的探测会接着往下走:不拦的话,清理快照之后才起的沙箱就没人删了(交叉审查)。
16
+ */
17
+ export function throwIfInterrupted() {
18
+ if (interruptedBy)
19
+ throw new Error(`${interruptedBy} received; stopping`);
20
+ }
21
+ /** 登记一个中断时要跑的清理;返回注销函数,正常走完后调用。 */
22
+ export function onInterrupt(cleanup) {
23
+ pending.add(cleanup);
24
+ return () => {
25
+ pending.delete(cleanup);
26
+ };
27
+ }
28
+ /**
29
+ * 中断时停掉一个子进程。先 SIGTERM 给它收尾的机会,2 秒后还在就 SIGKILL。
30
+ * group:子进程自成进程组(detached,如 --check-cmd),按组发信号,连它派生的进程一起停。
31
+ * 看的是**整组**还在不在,不是组长:shell 收到 SIGTERM 先退了,忽略 SIGTERM 的孙进程还在,
32
+ * 只盯组长就会跳过 SIGKILL 把它留下(交叉审查复现)。
33
+ */
34
+ export function stopOnInterrupt(child, opts = {}) {
35
+ const pid = child.pid;
36
+ // Windows 没有负 pid 的进程组信号,process.kill(-pid) 直接报错:那里退回到只看、只停子进程本身
37
+ // (和 --check-cmd 超时那条路一样),孙进程可能留下。没在 Windows 上测过
38
+ const byGroup = opts.group && process.platform !== "win32";
39
+ const alive = () => {
40
+ if (!pid)
41
+ return false;
42
+ if (!byGroup)
43
+ return child.exitCode === null && child.signalCode === null;
44
+ try {
45
+ process.kill(-pid, 0);
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ };
52
+ const signal = (sig) => {
53
+ try {
54
+ if (byGroup && pid)
55
+ process.kill(-pid, sig);
56
+ else
57
+ child.kill(sig);
58
+ }
59
+ catch {
60
+ // 已经退出
61
+ }
62
+ };
63
+ const forget = onInterrupt(async () => {
64
+ if (!alive())
65
+ return;
66
+ signal("SIGTERM");
67
+ const deadline = Date.now() + 2_000;
68
+ while (alive() && Date.now() < deadline)
69
+ await new Promise((resolve) => setTimeout(resolve, 100));
70
+ if (alive())
71
+ signal("SIGKILL");
72
+ });
73
+ // 组长退出时组里可能还有进程:那就继续登记着,中断时照样能按组停掉
74
+ child.once("close", () => {
75
+ if (!alive())
76
+ forget();
77
+ });
78
+ return forget;
79
+ }
80
+ /**
81
+ * 每个清理最多等这么久:docker 守护进程卡住时也要能退出。要比沙箱的收尾(等 docker run 落定 20 秒、
82
+ * docker rm 最多 60 秒)长,不然收尾还没做完就退出了。
83
+ */
84
+ const CLEANUP_TIMEOUT_MS = 90_000;
85
+ async function runCleanups() {
86
+ // 一直跑到没有登记为止:清理途中主流程可能还登记了新东西(比如收到信号时正在返回的 docker run)
87
+ for (let round = 0; pending.size && round < 10; round++) {
88
+ const tasks = [...pending].reverse();
89
+ pending.clear();
90
+ await runTasks(tasks);
91
+ }
92
+ }
93
+ async function runTasks(tasks) {
94
+ for (const task of tasks) {
95
+ let timer;
96
+ try {
97
+ const finished = await Promise.race([
98
+ Promise.resolve()
99
+ .then(task)
100
+ .then(() => true),
101
+ new Promise((resolve) => (timer = setTimeout(() => resolve(false), CLEANUP_TIMEOUT_MS))),
102
+ ]);
103
+ if (!finished)
104
+ log.warn(`a cleanup step did not finish within ${CLEANUP_TIMEOUT_MS / 1000}s; check \`docker ps -a\``);
105
+ }
106
+ catch {
107
+ // 清理函数自己报告失败;一个失败不挡后面的
108
+ }
109
+ finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
113
+ }
114
+ /**
115
+ * CLI 专用:在 what(比如 "drill")运行期间接管 SIGINT/SIGTERM。第一次收到信号时跑完登记的清理,
116
+ * 以 130(SIGINT)/ 143(SIGTERM)退出;清理途中再按一次 Ctrl+C 就不等了,直接退出
117
+ * (1 秒内重复到达的同一个信号不算,见 REPEATED_SIGNAL_GRACE_MS)。
118
+ * 返回卸载函数。
119
+ */
120
+ export function exitCleanlyOnSignals(what) {
121
+ const handler = (signal) => {
122
+ const code = signal === "SIGINT" ? 130 : 143;
123
+ if (interruptedBy) {
124
+ if (signal === interruptedBy && performance.now() - interruptedAt < REPEATED_SIGNAL_GRACE_MS)
125
+ return;
126
+ log.error(`${signal} again — exiting without finishing the cleanup`);
127
+ process.exit(code);
128
+ }
129
+ interruptedBy = signal;
130
+ interruptedAt = performance.now();
131
+ log.warn(`${signal} received — stopping the ${what} and cleaning up` +
132
+ (signal === "SIGINT" ? " (press Ctrl+C again to skip)…" : "…"));
133
+ void runCleanups().finally(() => {
134
+ log.error(`${what} interrupted`);
135
+ process.exit(code);
136
+ });
137
+ };
138
+ process.on("SIGINT", handler);
139
+ process.on("SIGTERM", handler);
140
+ return () => {
141
+ process.off("SIGINT", handler);
142
+ process.off("SIGTERM", handler);
143
+ };
144
+ }