Skip to content

Supabase 备份与恢复 Runbook

创建:2026-08-13 · 对应 docs/plans/product-launch-todo.md C4 两条 工具:scripts/backup-supabase.sh + scripts/backup-supabase-storage.mjs project-ref:jdtbyteiwnciqnfppztz


1. 为什么有这份文档

Supabase 免费版只有 7 天自动备份,而那里存着全部跨端用户数据。这是整个 launch-todo 里唯一一条「丢了不可逆」的事项——其余待办丢了最多是体验/流程问题。

超过 7 天的误删、账号问题、或 project 整体丢失,都没有任何自救手段。这份 runbook + 定时导出就是那个手段。


2. 🔴 备份对象是两份,不是一份

这是本主题最容易漏的一点。

#对象内容载体
1Postgres10 张 user_* 同步表 + 3 张推荐表 + 治理表(llm_call_log / usage_events / tts_call_log / user_quota_usage / quota_config / edge_run_log / recommend_audit_log / recommended_blocklist / recommend_config)+ vocabulary 缓冲池 + auth.userspg_dump
2Storagereading-snapshots/{user_id}/... —— 用户阅读快照的文件正文Storage REST 镜像

Storage 的字节不在 pg_dump 里。 Postgres 里只有 user_reading_pages.storage_path 这个指针。指针在、字节没了 = 客户端「回原文」全空。

桌面端本地虽有一份(dev temp/library/ / prod {app_data}/library/),但那是单机的; 换设备时靠的正是云端这份按需取回(src-tauri/src/commands/snapshot.rs + cache_protocol.rsrb-cache:// 处理器)。所以云端 Storage 丢了,用户换台机器就永久失去正文。

附带一提:auth.users 同样不可省。所有 user_* 表的 user_id 指向它; 只恢复业务表不恢复 auth,得到的是一堆无主孤儿行


3. 日常操作

3.1 凭据(都在仓库外)

存在 macOS Keychain,脚本自动读取:

bash
security add-generic-password -U -a "$USER" -s lampio-supabase-db -w
bash
security add-generic-password -U -a "$USER" -s lampio-supabase-service-role -w
bash
security add-generic-password -U -a "$USER" -s lampio-backup-passphrase -w

-w 放最后不带值 = 交互式输入,不进 shell history、不落明文文件。 连接串在 Dashboard → Project Settings → Database → Connection string (URI)。

⚠️ Keychain 交互式提示在 128 字符处截断(实测踩过两次)。连接串(~87 字符)和 口令没问题;service_role key 是 219 字符的 JWT,交互式提示存不下,必须用 -w '<值>' 直接传参(或走 env 文件)。存完务必验一下长度: security find-generic-password -s <名字> -w | wc -c

🔴 第三条(异地加密口令)是唯一一个「只存 Keychain 就等于没存」的凭据。 前两条丢了都能在 Dashboard 重置;这一条丢了 = 异地副本永久打不开。 而 Keychain 跟着这台机器,异地副本存在的意义恰恰是这台机器没了 —— 只存 Keychain 等于把保险箱钥匙锁在保险箱里。必须另存密码管理器。

✅ 2026-08-17 已办:副本存进 Bitwarden,并用密码管理器里的那份(不是 Keychain 的)实测解开 lampio-20260813-220147.tar.gz.gpg 通过。 两个刻意的选择:① 不放 iCloud 钥匙串 / Apple「密码」App —— 异地密文本身就在 iCloud Drive,钥匙再放进同一个 Apple 账号,等于两样锁一把锁;② 必须用密码管理器 里的副本去解,用 Keychain 那份试等于没验 —— 这一步专抓「粘贴漏字符 / 被输入框 截断」,而这类错误在真正需要它的那天之前,和存对了长得一模一样(§6 踩过 128 字符 静默截断)。⚠️ 复验时加 --no-symkey-cache,否则 gpg-agent 的缓存会让测试空过。

也支持 ~/.config/lampio/backup.env(脚本强制校验 chmod 600)和直接导出环境变量, 优先级:环境变量 > env 文件 > Keychain。 🔒 连接串 / service_role key 绝不进仓库,任何形式都不行。

3.2 手动跑

bash
scripts/backup-supabase.sh

其他模式:--db-only / --storage-only / --check(只验最近一次备份的新鲜度,不联网)。

3.3 定时(每周日 03:30,本机 launchd)

bash
scripts/backup-supabase.sh --install-launchd

卸载用 --uninstall-launchd;立刻试跑用 launchctl kickstart -p gui/$(id -u)/app.lampio.supabase-backup

为什么不用 GitHub Actions:那要把生产连接串 + service_role key 放进 GitHub Secrets, 且每周把全部用户数据拉进 GitHub 托管的 runner——对一个隐私敏感产品是明显更差的信任边界, 何况产物还得再上传到别处才留得住。本机 launchd 让凭据和数据都不出这台机器。

代价:机器关机/睡死就不跑。两道兜底:

  • 失败时弹系统通知(plist 里已接 osascript
  • 每月手动巡检一次 scripts/backup-supabase.sh --check(超 8 天未备份即报错退出 1)

3.4 产物结构

落在 ~/Backups/lampio/仓库外,脚本会拒绝把 BACKUP_ROOT 设在仓库内;目录 0700、文件 0600):

~/Backups/lampio/
├── LATEST -> 20260813-141500
├── backup.log
└── 20260813-141500/
    ├── manifest.json              # 时间 / project / 各步状态 / 体积
    ├── db/
    │   ├── db-public.dump         # public schema 结构+数据(custom 格式,可选择性 restore)
    │   ├── schema-public.sql      # 纯文本 schema,给人看 / diff 出 schema 漂移
    │   ├── db-auth.dump           # auth.users + auth.identities(仅数据)
    │   ├── db-storage-meta.dump   # storage.buckets + storage.objects(仅数据)
    │   └── row-counts.tsv         # 各表精确行数 —— 恢复演练的比对基准
    └── storage/reading-snapshots/
        ├── {user_id}/web/*.html
        ├── {user_id}/text/*.html
        ├── _index.tsv             # path / size / etag / mimetype / updated_at
        └── _summary.json

保留 8 份(每周一次 ≈ 2 个月滚动)。靠硬链接去重:size + eTag 都没变的 Storage 对象直接从上一份硬链接过来,所以「每周全量」在磁盘和带宽上都近似增量。

3.5 脚本自带的硬闸

不是「跑完了就算成功」,以下任一不满足即 exit 1

  1. 10 张同步表的数据段必须在 dump 里pg_restore -l 逐张断言) —— 挡的是「dump 跑完了但里面是空的」这类静默失败
  2. Storage 每个对象下载后字节数须与清单一致(挡传输截断)
  3. Storage 落盘文件数须等于清单行数
  4. BACKUP_ROOT 在仓库内 → 直接拒绝

4. 恢复:场景 A —— 误删/误改了部分数据

最常见,也最不该直接往生产库上动手。先在旁边把数据捞出来,再定点修

bash
# 1. 起一个临时本地库,把备份灌进去(不碰生产)
createdb lampio_scratch
pg_restore --no-owner --no-privileges -d lampio_scratch \
  ~/Backups/lampio/LATEST/db/db-public.dump

# 2. 在 scratch 里查出要救的行
psql lampio_scratch -c "select * from user_learning_entries where user_id='...' and deleted_at is null"

# 3. 导成 INSERT 语句,人工核对后再对生产执行
pg_dump lampio_scratch --data-only --column-inserts \
  -t user_learning_entries --file=/tmp/rescue.sql

⚠️ 往生产写回时用 INSERT ... ON CONFLICT DO UPDATE,别用 DELETE + INSERT—— 后者会把其他设备刚 push 上来的更新一起抹掉。


5. 恢复:场景 B —— 整个 project 没了

5.1 建库与连接(⚠️ 新 project 连不上直连端点)

  1. 建新 Supabase project,记下新的 project-ref。

  2. 连接方式:新建的 project 没有 db.<ref>.supabase.co:5432 直连端点(2026-08-13 演练实测: TCP Connection refused,而 project 状态是 ACTIVE_HEALTHY)。必须走 Supavisor 连接池的 session 模式

    hostportuser
    生产库(2025-12 建,直连)db.<ref>.supabase.co5432postgres
    新建 project(只有池)aws-0-<region>.pooler.supabase.com5432postgres.<ref>

    🔴 端口必须是 5432(session 模式),不能用 6543(transaction 模式)—— 后者不支持 pg_dump / pg_restore 需要的会话级状态。

  3. 不需要手工执行 sync-tables.sql 等建表脚本——db-public.dump 里带着完整结构 (表 / 索引 / RLS / policy / trigger),pg_restore 会一并建出来。演练实测恢复后 public 有 23 张表、92 个索引、23/23 张表启用 RLS、23 条 policy、20 个 set_server_updated_at trigger,全部自动到位。

    仍需手工执行的只有两样:

    • storage-setup.sql(建 bucket + storage.objects 的 RLS policy——storage schema 不在 --schema=public 的 dump 范围内)。演练实测在全新 project 上一次跑通。
    • cron-setup.sql(pg_cron 定时任务)。

5.2 恢复 auth 身份(必须先于业务表

user_* 表的 user_id 外键指向 auth.users(id)这些 UUID 必须原样保留—— 换一批 UUID 等于所有用户的数据全成孤儿。

bash
pg_restore --data-only --no-owner -d "$NEW_DB_URL" \
  ~/Backups/lampio/LATEST/db/db-auth.dump

演练实测:7 users + 7 identities 一次导入成功,无权限问题。

5.3 恢复业务数据

bash
pg_restore --no-owner --no-privileges -d "$NEW_DB_URL" \
  ~/Backups/lampio/LATEST/db/db-public.dump

不要加 --data-only(结构也在这个 dump 里,要靠它建表建索引建 RLS), 也不需要 --disable-triggers——演练已证明没必要,理由见 §6 第 ② 条。

唯一会看到的报错是 ERROR: schema "public" already exists无害(目标库自带 public schema),pg_restoreerrors ignored on restore: 1 后正常退出 0。

5.4 恢复 Storage

镜像目录的结构就是 bucket 里的结构({user_id}/{rel}),原样传回即可。 先确保 §5.1 的 storage-setup.sql 已执行(bucket 要先存在)。

⚠️ supabase storage cp --linked 走不通(演练实测):它在做 storage 操作前会先建自己的 DB 连接,而它默认用已失效的直连端点,报 IPv6 is not supported on your current network 后直接放弃;且该命令只认 --linked,没有 --project-ref,切换目标要动 supabase/.temp/(有污染既有 link、后续误部署到错 project 的风险)。

改用 Storage REST 直传(x-upsert: true 幂等):

bash
curl -X POST -H "Authorization: Bearer $SERVICE_ROLE_KEY" \
     -H "x-upsert: true" -H "Content-Type: text/html; charset=utf-8" \
     --data-binary @"<本地文件>" \
     "$SUPABASE_URL/storage/v1/object/reading-snapshots/<user_id>/<rel>"

批量传 + 逐字节回读比对的脚本参见 §6 演练所用的 drill-upload.mjs(一次性工具,未入仓; 逻辑就是遍历镜像目录 → POST → GET 回来比 sha256)。传完用 _index.tsv 核对对象数与大小。

5.5 🔴 客户端必须重新发版

这是恢复流程里最容易被忽略、且 RTO 最长的一环。

supabase_url / anon_key 自 roadmap 1-5(2026-07-23)起是构建期注入的编译期常量 (src-tauri/build.rs.env.local / CI env 读,写进二进制)。换了 project-ref = 已装在用户机器上的客户端连的还是那个死掉的旧 project,改数据库救不回来。

恢复顺序里必须包含:

  1. 更新 GitHub Secrets RB_SUPABASE_URL / RB_SUPABASE_ANON_KEY
  2. /release 发新版
  3. 靠 Tauri Updater 推给存量用户(updater endpoint 在 GitHub,不依赖 Supabase,所以这条路还活着)
  4. RVH 移动端同样要重新发版过商店审核 —— 这一环可能是几天量级

结论:保住原 project 比恢复数据重要得多。 优先考虑「把数据恢复回同一个 project-ref」,只有在 project 彻底不可用时才走换 ref 的路。


5b. 恢复:场景 C —— 这台 Mac 也没了(从异地副本起步)

🔴 场景 A / B 的每一条命令都假设 ~/Backups/lampio/LATEST/… 在本地。 但异地副本存在的唯一场景就是这台 Mac 没了 —— 那时你手上只有 iCloud 里一个 .gpg 文件,上面那些路径一条都不成立。本节负责把你接回场景 A/B 的起点。

5b.1 你手上必须有的三样

东西从哪来没有会怎样
lampio-*.tar.gz.gpgiCloud Drive LampioBackups/无解
异地加密口令🔴 密码管理器(不是 Keychain —— 它跟着旧机器一起没了)副本永久打不开,无解
Supabase 凭据Dashboard 可重置(连接串)/ 直接取(service_role)可恢复,不阻塞

如果口令丢了:异地副本作废。此时唯一指望是旧机器的磁盘还能读,或平台侧 还有别的副本 —— 而本项目实测平台没有任何自动备份(§1)。所以这条不是 「不方便」,是终局。这就是为什么 §3.1 反复强调另存密码管理器。

5b.2 新机器装依赖

bash
brew install gnupg libpq node

gnupg 不是 macOS 自带的 —— 没有它连解密都做不了。libpq 是 keg-only, 脚本会自己找 opt 路径,不用改 PATH。

仓库从 GitHub / Gitee 拉回来即可(scripts/ 和文档都在里面,不依赖旧机器)。

5b.3 解密还原到场景 A/B 期望的位置

bash
mkdir -p ~/Backups/lampio && chmod 700 ~/Backups/lampio
gpg --decrypt ~/Library/Mobile\ Documents/com~apple~CloudDocs/LampioBackups/lampio-<时间>.tar.gz.gpg \
  | tar xzf - -C ~/Backups/lampio
ln -sfn <时间> ~/Backups/lampio/LATEST

会提示输口令(从密码管理器取)。压缩包里就是完整的 <时间戳>/{db,storage,manifest.json} 目录结构,解出来即与本机备份逐字节一致 (2026-08-13 实测),所以建完 LATEST 软链后,场景 A / B 的所有命令原样可用

包内 manifest.jsonoffsite 字段会是 "pending" 而不是 "ok" —— 这是 刻意的:manifest 要先写好才能被打进压缩包,异地结果只能在打包之后才知道。 本机那份 manifest 才是终态。看到 pending 不代表备份有问题。

5b.4 验一下再往下走

bash
/usr/local/opt/libpq/bin/pg_restore -l ~/Backups/lampio/LATEST/db/db-public.dump | grep -c 'TABLE DATA'
cat ~/Backups/lampio/LATEST/db/row-counts.tsv | head
ls ~/Backups/lampio/LATEST/storage/reading-snapshots/

TOC 读得出、行数清单在、Storage 目录有内容 → 接 §4(部分数据)或 §5(整库)。


6. 恢复演练结论(2026-08-13 实做)

环境:源 = 生产 jdtbyteiwnciqnfppztz(ap-northeast-2,PostgreSQL 17.6); 目标 = 临时 project kjwjtxsjkuvqisrvgtch(ca-central-1),演练后已销毁。 备份产物 = 20260813-164430(852 KB,dump 耗时 69 秒)。

结论:三项验证全部通过,备份可恢复。

① 行数一致 ✅

10 张同步表 + 13 张业务/治理表,逐表精确行数与 row-counts.tsv 完全一致

user_learning_entries 222 · user_word_page_links 222 · user_word_cloze_contexts 19
user_known_words 16 · user_reading_pages 7 · user_reading_notes 4 · user_favorite_sites 4
user_rss_feeds 4 · user_domain_prefs 4 · user_page_annotations 0
llm_call_log 2572 · recommended_sites 82 · recommended_feeds 48 · recommended_articles 35
usage_events 57 · tts_call_log 26 · edge_run_log 20 · vocabulary 17 · user_quota_usage 11
recommend_audit_log 12 · recommended_blocklist 4 · quota_config 1 · recommend_config 1
auth.users 7 · auth.identities 7

额外做的跨表完整性检查(全为 0):孤儿 learning_entries / 孤儿 reading_pages / 悬挂 word_page_links(两个方向)/ 悬挂 reading_pages→note。 这验证了 §5.2「auth 必须先恢复」的必要性 —— 顺序对了,就没有无主行。

auth.sessions / refresh_tokens / mfa_amr_claims 恢复为 0,符合预期: 这些是会话态,不在备份范围(用户重新登录即可),不影响身份与数据归属。

② 墓碑与时间戳语义完好 ✅ —— 且 --disable-triggers 是多余的

  • deleted_at 原样保留user_known_words 里那 1 条真实墓碑恢复后仍在。 这是最关键的一条——墓碑丢了会让已删数据在所有客户端上复活(红线 #6/#6b 的整个前提)。

  • server_updated_at 保留了原值,没有被 trigger 改写成恢复时刻: 恢复后最早 2026-08-12 03:34:56Z、最晚 2026-08-13 02:48:05Z落在最近 1 小时内的行数 = 0(恢复时刻是 09:23Z)。

    原因:pg_restore 的顺序是先建表 → 灌数据 → 最后建 trigger/约束,COPY 期间 set_server_updated_at 还不存在。所以我原先写的 --disable-triggers 没有必要 (而且它需要 superuser,在 Supabase 上本来就会失败)。已从 §5.3 删除。

    这条的实际意义:恢复后客户端的 sync watermark 语义不被破坏,不会触发全量重拉

  • RLS / policy / index / trigger 全部随 db-public.dump 自动重建: 23/23 张表启用 RLS、23 条 policy、92 个索引、20 个 set_server_updated_at trigger (10 张表 × INSERT/UPDATE 两个事件)。目标 project 自带 anon / authenticated / service_role 三个角色,policy 引用它们无障碍。

③ Storage 可按 {user_id}/{rel} 原样取回 ✅

7 个对象全部传回并逐字节回读比对(sha256 + 长度):上传 7 / 一致 7 / 不符 0。 恢复后 storage.objects = 7 行,与备份时一致。

演练中踩到的坑(下次直接照抄,省 30 分钟)

  1. 新 project 没有直连端点 → 必须走 pooler session 模式。见 §5.1 的对照表。 排查特征:project 状态 ACTIVE_HEALTHY,但 db.<ref>.supabase.co:5432Connection refused(不是超时)。
  2. macOS Keychain 交互式提示在 128 字符处静默截断。219 字符的 service_role key 走 -w 提示符会被砍成 128(表现为 Storage API 报 Invalid Compact JWS)。必须用参数形式: security add-generic-password -U -a "$USER" -s <名字> -w "$(...取值命令...)" —— zsh 的 history 存的是未展开的命令原文,key 不会进历史。 DB 连接串 87 字符,在限制内,走提示符没问题。
  3. 粘贴密码时带进尾随空格,表现为 password authentication failed。 诊断法:把密码逐字符映射成掩码看有没有 ,别直接打印密码。

演练收尾清单(做完必须走一遍)

  • [x] 删除临时 project —— 里面有全部真实用户数据 + auth.users 的邮箱与密码哈希。 这是本次演练留下的唯一新增风险敞口,不删就等于把生产数据长期多存了一份在云上。 2026-08-17 已删(kjwjtxsjkuvqisrvgtch,存活 4 天)。三方交叉确认:projects list 只剩生产 ref、演练库 REST 端点连不上、supabase/.temp/project-ref 未被带歪。
  • [x] 删除 Keychain 里的演练凭据(2026-08-17 已删,security find-generic-password 查无): security delete-generic-password -s lampio-drill-dbsecurity delete-generic-password -s lampio-restore-drill
  • [x] 确认 supabase/.temp/ 的 link 目标仍是生产 ref —— 演练中为试 storage cp 曾临时切到演练 ref,用完已整目录还原并 diff -r 验证逐字节一致

下次演练若想省掉「第二份生产数据上云」这个敞口,可改用本地 postgres (brew install postgresql@17)。代价是要手工建 anon/authenticated/service_role 三个角色,且验不到 Storage 那一环。本次选云端正是为了把 ③ 也真验一遍。


7. 已知限制(诚实清单)

7.1 异地副本:✅ 已启用(2026-08-13)

tmutil destinationinfo 实测 No destinations configured(没有 Time Machine), 所以本机磁盘本来是唯一副本 —— 硬盘坏了备份跟着一起没。

现已由 backup-supabase.sh 第 6b 步补上:备份完成后 gpg AES-256 对称加密整个目录 → iCloud Drive LampioBackups/,保留 4 份,写完做完整解密 + 列归档自检才算成功。 刻意不引入新的信任方 —— 用已经在用的云盘 + 本机加密,云端只看得到密文。

启用与解密见 database-operations.md §9从异地副本起步的完整恢复流程见本文 §5b

  • 未设口令时静默跳过(不算失败),此时退回单副本状态
  • fdesetup statusFileVault is On,本机磁盘静态加密已有
  • 安全属性:加密让 iCloud 账号不再是单点 —— 攻破 iCloud 只拿到密文。 但反过来,口令丢失是终局(§5b.1)

7.2 备份本身是高敏资产

db-auth.dump用户邮箱 + 密码哈希,业务表含全部学习内容与阅读来源 URL。 脚本已做的:目录 0700 / 文件 0600 / 仓库外 / 拒绝 BACKUP_ROOT 落在仓库内 / .gitignore 兜底拦 *.dump不要把它丢进任何未加密的同步盘或聊天窗口。

7.3 不在备份范围内的东西

东西在哪project 重建时怎么办
Edge Functions 源码✅ 仓库 supabase/functions/(13 个)/edge-deploy 重新部署
Edge Function secrets哪儿都没有(CLI 只显示 digest,明文取不出)/edge-deploy skill 的 Function ↔ Secret 依赖矩阵逐个重设(LLM key / Azure Speech 等)
RLS policy / trigger / 索引supabase/sql/*.sql + schema-public.sql执行 SQL 真相源
pg_cron 定时任务supabase/sql/cron-setup.sql重新执行一次
Auth 配置(SMTP / 回调 URL / provider)❌ Dashboard 里的设置,不在 dump手工重配

7.4 Storage 镜像是「只增不减」

云端删掉的对象,本地镜像里仍留着(下一份备份的 _index.tsv 不再列它,但硬链接的旧文件还在)。 这是特性不是 bug——备份的意义正是留住已经没了的东西。代价是磁盘占用只增不减,靠 BACKUP_KEEP=8 的滚动淘汰兜底。

7.5 launchd 的固有缺口

机器关机 / 睡死 → 那一周不跑,且没有任何人会知道。两道兜底见 §3.3 (失败弹通知 + 每月 --check 巡检)。真要消除这个缺口只能换成常开的机器或云端定时, 代价见 §3.3 对 GitHub Actions 的评估。