import type { Client } from "pg"; import type { ExtensionInfo } from "./manifest.js"; import { type ForeignKeyDef } from "./dump-sql.js"; import { type OrphanedForeignKey } from "./orphaned-foreign-keys.js"; import { type GrantRestore, type GrantedObject } from "./grants.js"; export type RestoreTargetKind = "sandbox" | "supabase"; /** 主机名规范化:先按驱动语义百分号解码(pg-connection-string / libpq 都会解码主机名, * `supabase%2Ecom` 在驱动眼里就是 supabase.com),再剥掉合法的 DNS 根点(db.x.supabase.co. ≡ * db.x.supabase.co)。身份比较不规范化 = 一个尾点或一个 %2E 就能绕过同源阻断(交叉审查)。 */ /** * 连接串任何位置解码后出现 NUL(或原文里的 %00)都视为启动包字段注入:驱动把 NUL 当字段分隔符, * 后面的内容(user=… / options=reference=…)会成为额外的启动参数,连接目标与身份判定脱节。 * 合法连接串不存在 NUL,整串一刀切,不再逐字段追(交叉审查:query 值里的 NUL 同样能注入)。 */ export declare function containsNul(connString: string): boolean; /** Supavisor 集群别名语法:用户名里出现字面量小写 `.cluster.`(handler_helpers.ex),之后全是别名。 */ export declare function isClusterAliasUsername(username: string): boolean; export declare function normalizeHost(hostname: string): string; /** * 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db..supabase.co` 或 * pooler 用户名 `.`:Supavisor 在**最后一个点**切分租户(handler_helpers.ex),角色名 * 可以含大写、连字符甚至点,所以角色段不做限制、只认最后一段 ref;并且必须是 Supabase pooler * 主机——同样形状的用户名落在别的主机上不是 Supabase 租户身份(交叉审查)。 * 放在引擎层:备份端(写 manifest.sourceProjectRef) * 与恢复端(同源阻断)共用同一个身份判定。非 Supabase 形态返回 null。 */ export declare function projectRefOf(connString: string): string | null; /** 从 Supabase Storage S3 端点(https://.storage.supabase.co/…)提取项目 ref。 */ export declare function refFromStorageEndpoint(endpoint: string): string | null; export interface ClassifiedPass { /** allowlist 命中数(环境预期/托管冲突)——如实入报告,不算失败 */ expectedSkips: number; /** 真失败(压缩空白、按 FAILURE_DETAIL_MAX_CHARS 截断的错误块) */ failures: string[]; } export interface EngineResult { preData: ClassifiedPass; postData: ClassifiedPass; ok: boolean; /** 因源库自带孤儿行而以 NOT VALID 建成的外键(见 orphaned-foreign-keys.ts);沙箱和真实目标一样,不算失败 */ orphanedForeignKeys?: OrphanedForeignKey[]; /** 授权重建的结果(grants.ts);恢复没走到这一步时不存在 */ grants?: GrantRestore; /** * 真实目标:失败发生在主事务提交之后(授权或外键那一步),表和数据已经在目标上。 * 调用方据此如实说明,不能再说"已整体回滚"。 */ committed?: boolean; } /** * 空目标门数的对象(见 restore.ts assertEmptyTarget):这些 schema 里不属于扩展的关系、函数、类型。 * schemas 是一个 text[] 表达式(参数 $1::text[] 或字面量数组)。带 Auth 的恢复在事务里锁住之后用同一判据重查 */ export declare function userObjectCountSql(schemas: string): string; /** 恢复完要重建授权的范围与依据:manifest.database.schemas 和 manifest.database.grants。 */ export interface GrantsToRestore { schemas: string[]; /** undefined = 旧快照没有记录,按 Supabase 以前的默认授权补 public */ recorded: GrantedObject[] | undefined; } /** * 从目录条目里挑出交给 addForeignKeys 的外键(isDeferrableForeignKey)。让 pg_restore 把全部外键条目 * 写成 SQL,第 i 条语句对第 i 行(-L 按清单顺序输出)。对不上数或对不上名字(认不出的外键形态) * 就一条也不挪:全部留给 pg_restore,行为和以前一样。 * 这些外键上的 COMMENT 一起挪:它们依赖外键本身,留在原处会因为对象还不存在而失败(交叉审查复现)。 */ declare function deferredForeignKeys(bin: string, dumpPath: string, entries: string[], listDir: string, alsoFullyDumped?: string[], /** * restore --finish 用:读不出来就报错,不退回"一条也不挪"。正常恢复里退回是安全的(外键全留给 pg_restore, * 在主事务里建),接着做的时候退回就是漏加外键 */ strict?: boolean): Promise<{ lines: Set; fks: ForeignKeyDef[]; commentSql?: string; }>; /** * 新版 pg_restore 的脚本首尾各有一条 psql 元命令(\restrict / \unrestrict ),node-pg 执行不了。 * 只认位置:开头那条是第一行非空、非 -- 注释的行,结尾那条是最后一行非空行。注释正文是字符串, * 里面换行后恰好以 \restrict 开头的行要原样保留(交叉审查)。 */ export declare function withoutRestrictWrapper(script: string): string; /** * libpq 子进程(pg_dump / pg_restore)的环境。`supabaseHost` 为真时再剔除 PGOPTIONS:Supavisor 会从 * options 里读租户覆盖(reference=…),而 Node 客户端对 Supabase 主机已显式钉死 options;非 Supabase * 主机两边都尊重用户的 PGOPTIONS——两个客户端在任何情况下看到的都是同一份连接语义。 */ export declare function libpqChildEnv(extraEnv?: NodeJS.ProcessEnv, base?: NodeJS.ProcessEnv, opts?: { supabaseHost: boolean; urlSslMode?: string | null; }): NodeJS.ProcessEnv; /** 见 libpqChildEnv:一个绝不存在的根证书路径 = 让 libpq 的 require 既不验证也不去找默认 root.crt。 */ export declare const NO_VERIFY_ROOTCERT_SENTINEL = "/nonexistent/backupdrill-no-verify-root.crt"; /** 连接串 query 里的 sslmode(驱动语义:URL 优先于环境变量);解析不了或没写 → null。 */ export declare function urlSslModeOf(connString: string): string | null; export declare function spawnPgRestore(bin: string, args: string[], extraEnv?: NodeJS.ProcessEnv, opts?: { supabaseHost: boolean; urlSslMode?: string | null; signal?: AbortSignal; }): Promise<{ code: number | null; stderr: string; }>; /** * 沙箱 allowlist,**按 manifest 实际转储的 schema 生成**(交叉审查 2026-09-11 第五轮): * CLI 用户用 BACKUPDRILL_SCHEMAS=public,auth 把 auth 也转了,那 auth 里的对象就是**他自己的** * 备份内容 —— 缺了是真失败,不能再当托管缺席豁免;否则一条依赖 auth.normalize_key() 的 * 唯一索引建不出来,演练照样 PASS。这是 installSandboxShim "转储带 auth 就不建桩"的对称 * 另一半:桩不建、豁免也不给,两边口径一致。角色豁免不受影响 —— 转储里从来没有 CREATE ROLE。 * * **两端锚定**(第四轮,终结子串匹配这一整类):匹配的是 classifyBlocks 抽出来的**整条主消息**, * 必须从头到尾就是一条"缺对象"诊断。不锚定的话用户行值能借道混进来 —— 表达式索引 `(col::uuid)` * 撞上一行内容恰好是 `relation "auth.users" does not exist` 的数据,主消息就是 * `invalid input syntax for type uuid: "relation "auth.users" does not exist"`,子串一匹配, * 一条建失败的唯一索引就被报成了预期跳过。旧的 \bauth\.uid\b / \bauth\.jwt\b 裸子串已删: * 它们唯一对应的真实诊断 `function auth.uid() does not exist` 由 function 分支覆盖。 */ export declare function sandboxManagedError(dumpedSchemas?: string[]): RegExp; /** 默认形态(什么托管 schema 都没转):兼容层与"只转 public"的托管 worker 路径。 */ export declare const SANDBOX_MANAGED_ERROR: RegExp; export declare const SCHEMA_EXISTS_ERROR: RegExp; export declare function classifyBlocks(stderr: string, allow: RegExp): ClassifiedPass; /** * 一遍恢复的最终裁决,两条独立规则: * 1. 被信号杀死(code=null)**无条件失败**——即使 stderr 里已有被 allowlist 解释的 * 错误块。真实目标恒有一个预期 schema 跳过,若跳过能抵扣信号死亡,中途被 OOM/kill * 的半截恢复就会被报成功(评审第 7 轮抓出的假成功路径)。 * 2. 非零退出且零错误块(归档器致命错、非英文 locale…)同样不能谎报成功。 * 非零退出但每个错误都已分类 = pg_restore 对被忽略错误的正常退出形态,通过。 * 3. 并行遍(`parallel`)不能只信切块:多个 worker 进程共用一个 stderr,而 PostgreSQL 一条 * 报错分好几次 write(`pg_restore:`、` `、`error: `、正文,见 src/common/logging.c), * 两条同时写会交错,一条真错误的前缀可能被拆散、正文挂到另一条预期跳过的块里——切块 * 看不到它,演练照样 PASS。所以并行遍只在两种情况下通过:退出码 0(一个错误都没有), * 或 pg_restore 最后打印的 "errors ignored on restore: N" 恰好等于预期跳过数。 * worker 里出的错 PostgreSQL 会多算一次(17 实测 4 个错报 8,mark_restore_job_done * 又 +1),所以只要 worker 出过错 N 就一定大于预期跳过数——这条规则只放行"所有错误都出在 * fork 之前的单进程阶段"的那种并行遍,调用方要据此安排哪些对象放进并行遍。 * 没有这行汇总的非零退出 = 中途致命退出,同样失败。 */ export declare function finalizePass(code: number | null, stderr: string, classified: ClassifiedPass, opts?: { parallel?: boolean; }): ClassifiedPass; /** * pg_restore 正常跑完时打印的错误总数;没跑完(致命退出)或认不出时返回 null。 * 只认 stderr 的最后一个非空行、且整行就是这句汇总:这行在所有 worker 退出之后由主进程打印, * 不会和别的输出交错;前面 "Command was:" 里的用户 SQL 就算含同样字样也冒充不了它。 */ export declare function ignoredErrorTotal(stderr: string): number | null; /** 标识符转义(内嵌引号翻倍)。导出:任何把目录里读到的名字拼进 SQL 的地方都必须用它。 */ export declare function quoteIdent(name: string): string; /** * 恢复前把 manifest 记录的扩展装进目标(--schema 转储不含 CREATE EXTENSION)。 * 必须装进与源库相同的 schema:dump 里的类型是限定名(如 extensions.vector)。 * 装不上的扩展如实收集返回,由调用方按目标类型裁决:沙箱记录后继续(托管扩展 * 本就装不上,public 转储通常不引用),真实目标应当阻断(用户能在 dashboard 开启)。 */ export declare function installExtensions(connString: string, extensions: ExtensionInfo[], /** 调用方开着的事务:装在它里面(每个扩展一个保存点,装不上的不连累别的),提交与否由调用方定 */ inTransaction?: Client): Promise; /** * 沙箱专用:预置 Supabase 的最小运行面,让**引用**它的用户对象能建出来被验证,而不是 * 被当作"托管对象跳过"。只装桩,不装数据 —— 演练验证的是用户自己的 schema。 * * 触因(2026-09-11,真实用户 105 表 12 GB 库的首次演练):`create function * public.is_admin(p uuid default auth.uid())` —— 函数在 pre-data,参数默认值建函数时 * 就要解析,沙箱没有 auth schema 就整遍硬失败。同一根因还会吃掉更常见的 * `create table ... (created_by uuid default auth.uid())`:表建不出来、数据也没了, * 演练只能报"缺表"。把 pre-data 的容错放宽不解决后者;补上运行面两者都解决。 * * 桩的形状: * auth.uid() → uuid、auth.jwt() → jsonb、auth.role() → text、auth.email() → text, * 全部返回 null(恢复过程中不会真调它们:COPY 不求值列默认,策略/函数体不执行); * 角色 anon / authenticated / service_role(nologin):RLS 策略的 `to authenticated` * 要它们在场才能建;恢复完重建授权(grants.ts)也要它们,演练因此和真实恢复走同一段。 * 刻意不建 auth.users 之类的表:空的 auth.users 会让 FK 校验在 post-data 真失败, * 比"缺表跳过"更糟。FK → auth.users 仍按 SANDBOX_MANAGED_ERROR 的 relation 形态跳过。 * * 转储自带 auth schema 时(CLI 的 BACKUPDRILL_SCHEMAS=public,auth,自托管场景)**不建函数**: * pg_restore 的 CREATE FUNCTION auth.uid() 会撞我们的桩报 "already exists",而那是 pre-data、 * 全严格 → 一次本来能过的演练被 shim 弄挂(交叉审查 2026-09-11)。角色照建:转储里 * 从来没有 CREATE ROLE。 * * **绝不对真实 Supabase 目标调用**:那里 auth 是真的,create or replace 会覆盖平台函数。 */ export declare function sandboxShimSql(opts: { authFunctions: boolean; }): string; export declare function installSandboxShim(connString: string, opts: { authFunctions: boolean; }): Promise; /** * 两遍恢复(真实首演炸出的设计;2.7.0 起只用于演练沙箱,真实目标见 restoreInOneTransaction): * 1. pre-data+data —— 表结构 + 数据。除 schema 冲突(预期)外任何错误都是硬失败。 * 2. post-data —— 用户自己的索引/约束/触发器必须恢复成功;沙箱豁免 Supabase 托管对象引用。 */ /** * 凭据不进 argv(D6 同源要求):连接串里的密码对 `ps` 全程可见——拆出来经 * PGPASSWORD 环境变量传给 libpq,argv 里的 URL 不再携带。解析不了的连接串 * 原样透传(libpq 自己适配的形态,不在这里破坏)。 */ export declare function credentialSafeDbArgs(connString: string): { url: string; env: NodeJS.ProcessEnv; }; /** * 拒绝连接串里的 host/hostaddr/user 查询覆盖:驱动会让它们改写实际连接目标/租户—— * 身份判定(ref/主机)看的是 authority,读写却发生在别处。备份侧(源身份自记)与 * 恢复侧(确认门/同源阻断)共用本检查。 * Supabase pooler 主机上再拒绝**任何** `?options=`:Supavisor 从 options 里解析 `reference=` * 并让它优先于用户名里的租户段(handler_helpers.ex),而且会再解一次编码、认反斜杠转义—— * 试图识别"哪种 options 值是租户覆盖"是在追它的解析器,`%2572eference` 这类双重编码就绕过了 * (交叉审查)。pooler 串本来就不需要 options,整个参数一律拒绝;非 pooler 主机(普通 Postgres * 目标)的 options 没有租户语义,照常放行。 */ export declare function assertNoHostOverride(connString: string): void; /** * 把 `pg_restore --list --section=post-data` 的目录拆成两份 -L 清单: * - `indexBuilds`:INDEX 和 CONSTRAINT(主键/唯一/排除约束,本质也是建索引)。恢复里最慢的就是 * 它们(2026-10-17 实测 post-data 占 pg_restore 时间的 67–77%,大头是建索引)。并行跑; * 出过错的条目再单连接重跑一遍定结论(见 restoreDatabaseArtifact)。 * - `rest`:FK CONSTRAINT、TRIGGER、POLICY、INDEX ATTACH、COMMENT 等。指向 auth.users 的外键 * 这类预期跳过全在这里,并行时 worker 报错会交错、计数也不准(见 finalizePass 规则 3), * 所以这份始终单连接跑,保留逐块分类。外键依赖主键/唯一约束,也必须排在 indexBuilds 之后。 * 注释行(`;` 开头)和空行丢掉:-L 只按行首的 dumpId 认条目。 */ export declare function splitPostDataList(listing: string): { indexBuilds: string[]; rest: string[]; }; /** 不连库的 pg_restore(--list、-f - 出 SQL),整段收集 stdout。只用于目录和 DDL 这类小输出。 */ export declare function pgRestoreOutput(bin: string, args: string[], opts?: { signal?: AbortSignal; }): Promise<{ code: number | null; stdout: string; stderr: string; }>; /** * 并行建索引那遍(带 --verbose)里出过错的条目 dumpId。pg_restore 对每个出错条目先打一行 * `from TOC entry ; …`(warn_or_exit_horribly,只在 verbose 下输出)。这行正文是一次 * write 写完的:并行时它的 `pg_restore: ` 前缀可能被别的 worker 拆开,正文本身不会, * 所以按子串找、不按行首找。 */ export declare function failedTocEntries(stderr: string): Set; /** 主 dump 要恢复的条目清单(写在 listDir 里),和留到提交之后再加的外键 */ export type MainRestorePlan = { ok: true; listFile: string; deferred: Awaited>; } | { ok: false; failure: string; }; /** * 单事务恢复要交给 pg_restore 的清单:目标上已经有的 schema 条目拿掉(见 restoreInOneTransaction), * 两端都在备份里的外键拿掉、留到提交之后加。alsoFullyDumped(tableKey)是同一次恢复里另外整表写进去的表 * ——带 Auth 的恢复里的 auth.users、auth.identities:指向它们的外键也留到提交之后,源库的孤儿行建成 NOT VALID */ export declare function prepareMainRestore(bin: string, dumpPath: string, connString: string, listDir: string, opts?: { alsoFullyDumped?: string[]; schemasCreatedFirst?: string[]; }): Promise; /** * 主事务提交之后:先补授权,再加留下来的外键。授权紧跟着主事务、放在外键前面:提交到这里之间,恢复出来的 * 对象用的是目标的默认授权(主事务插不进别的语句)。外键那一步更可能失败,不能让它拖住授权 */ export declare function afterCommit(connString: string, grantsToRestore: GrantsToRestore, deferred: { fks: ForeignKeyDef[]; commentSql?: string; }, opts?: { /** 授权补完之后调用(带 Auth 的恢复据此更新恢复记录);它抛错按授权失败处理之外的失败,原样抛出 */ onGrantsApplied?: () => Promise | void; /** * 接着做(restore --finish):目标上已经有同名外键的,定义一样(NOT VALID 与否不论,孤儿行会让它 NOT VALID) * 就跳过,不一样就停下报告,不覆盖。授权本来就可以重复执行 */ resume?: boolean; }): Promise<{ pass: ClassifiedPass; orphaned?: OrphanedForeignKey[]; committed?: true; grants?: GrantRestore; }>; /** * restore --finish 用:提交之后要加的外键,和那次恢复算出来的一模一样(同一份 dump、同一套 deferredForeignKeys 规则) */ export declare function deferredForeignKeysOf(dumpPath: string, listDir: string, alsoFullyDumped: string[]): Promise<{ fks: ForeignKeyDef[]; commentSql?: string; }>; /** * 单事务恢复只会有一条错误(--exit-on-error)。给人看的形态:主消息 + DETAIL/HINT 全文,再带上出错 * 语句的开头,方便认出是哪个对象。不再截成 200 字符——那样会把 "is not present in table" 这半句 * 切掉,用户看不出缺的是什么(2026-10-22 实验原样截在 "is no")。 */ export declare function firstRestoreError(stderr: string): string; /** * 两遍恢复(形状见 credentialSafeDbArgs 上方那段"两遍恢复"注释),`jobs` > 1 时按下面的方式并行(2026-10-17 实测:2 核机器上同一份 * dump,原生 -j2 比 -j1 在 5 GB 快 14%、10 GB 快 12%): * 1. pre-data+data 一遍加 --jobs。pg_restore 并行时先在主进程里单独跑完 pre-data,fork 之后 * 才并行灌数据,所以唯一的预期冲突(schema 已存在)一定出在单进程阶段,finalizePass * 规则 3 的计数核对成立。同一遍里建表再灌数据,pg_restore 才会用 TRUNCATE+COPY, * wal_level=minimal 的沙箱因此不写这部分 WAL——拆成两遍就没了这个加速。 * 2. post-data 拆成两份清单(splitPostDataList):建索引那份并行,退出码 0 即通过,报过错 * 的条目单连接重跑定结论;其余单连接、照旧按目标类型分类。 * 两遍下来,同一份备份不管几路并行,结论都和 jobs=1 一样。 * 真实 Supabase 目标(restore.ts)不走这里:单事务恢复(restoreInOneTransaction)不能并行, * 而且每一路都是一条 pooler 连接,小规格项目的连接数和算力都紧。 */ export declare function restoreDatabaseArtifact(opts: { dumpPath: string; connString: string; target: RestoreTargetKind; /** manifest.database.schemas:沙箱 allowlist 据此剔掉转储自带的托管 schema(见 sandboxManagedError)。 */ dumpedSchemas?: string[]; /** pg_restore 并行路数;不传或 ≤1 = 单连接两遍。 */ jobs?: number; /** 恢复完照它重建 PUBLIC 和 API 角色的授权;沙箱和真实目标一样做 */ grants: GrantsToRestore; }): Promise; /** * 逐块读 `pg_restore --data-only -f -` 的输出,判断某张表的 COPY 段有没有数据行。 * 返回一个喂数据的函数:能下结论时返回 "rows" / "empty",确定查不清返回 "unknown", * 还要更多输出返回 null。纯函数,方便单测。 * * 只按 \n 切行,不用 readline:readline 把 \r 也当换行(引号名里可以有 \r),还会把一整行数据 * 读进内存(大 bytea 行能有几百 MB)。COPY 头之后只看紧跟着的几个字符。 * 带引号的名字里还可以有换行(列名里甚至能写 FROM stdin; 和 \.),COPY 头就会跨行:攒到引号配平、 * 以 FROM stdin; 结尾为止,再按 COPY 头的语法解析、核对是不是这张表(同 scanDumpData)。 */ export declare function copyDataProbe(schema: string, table: string): (chunk: string) => "rows" | "empty" | "unknown" | null; /** * 这张表在 dump 里有没有数据行(判断见 copyDataProbe)。拿到答案就停掉 pg_restore,不读完整份 dump。 * 没有 COPY 段(matview 的数据是 REFRESH)、COPY 头读不懂或不是这张表、pg_restore 起不来或出错, * 都返回 "unknown",由调用方按"查不清"处理。 */ export declare function tableDataInDump(dumpPath: string, schema: string, table: string): Promise<"rows" | "empty" | "unknown">; export {};