主题
Supabase 备份与恢复 Runbook
创建:2026-08-13 · 对应
docs/plans/product-launch-todo.mdC4 两条 工具:scripts/backup-supabase.sh+scripts/backup-supabase-storage.mjsproject-ref:jdtbyteiwnciqnfppztz
1. 为什么有这份文档
Supabase 免费版只有 7 天自动备份,而那里存着全部跨端用户数据。这是整个 launch-todo 里唯一一条「丢了不可逆」的事项——其余待办丢了最多是体验/流程问题。
超过 7 天的误删、账号问题、或 project 整体丢失,都没有任何自救手段。这份 runbook + 定时导出就是那个手段。
2. 🔴 备份对象是两份,不是一份
这是本主题最容易漏的一点。
| # | 对象 | 内容 | 载体 |
|---|---|---|---|
| 1 | Postgres | 10 张 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.users | pg_dump |
| 2 | Storage | reading-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.rs 的 rb-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 -wbash
security add-generic-password -U -a "$USER" -s lampio-supabase-service-role -wbash
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:
- 10 张同步表的数据段必须在 dump 里(
pg_restore -l逐张断言) —— 挡的是「dump 跑完了但里面是空的」这类静默失败 - Storage 每个对象下载后字节数须与清单一致(挡传输截断)
- Storage 落盘文件数须等于清单行数
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 连不上直连端点)
建新 Supabase project,记下新的 project-ref。
连接方式:新建的 project 没有
db.<ref>.supabase.co:5432直连端点(2026-08-13 演练实测: TCPConnection refused,而 project 状态是ACTIVE_HEALTHY)。必须走 Supavisor 连接池的 session 模式:host port user 生产库(2025-12 建,有直连) db.<ref>.supabase.co5432 postgres新建 project(只有池) aws-0-<region>.pooler.supabase.com5432 postgres.<ref>🔴 端口必须是 5432(session 模式),不能用 6543(transaction 模式)—— 后者不支持
pg_dump/pg_restore需要的会话级状态。不需要手工执行
sync-tables.sql等建表脚本——db-public.dump里带着完整结构 (表 / 索引 / RLS / policy / trigger),pg_restore会一并建出来。演练实测恢复后 public 有 23 张表、92 个索引、23/23 张表启用 RLS、23 条 policy、20 个set_server_updated_attrigger,全部自动到位。仍需手工执行的只有两样:
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_restore 会 errors 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,改数据库救不回来。
恢复顺序里必须包含:
- 更新 GitHub Secrets
RB_SUPABASE_URL/RB_SUPABASE_ANON_KEY /release发新版- 靠 Tauri Updater 推给存量用户(updater endpoint 在 GitHub,不依赖 Supabase,所以这条路还活着)
- RVH 移动端同样要重新发版过商店审核 —— 这一环可能是几天量级
结论:保住原 project 比恢复数据重要得多。 优先考虑「把数据恢复回同一个 project-ref」,只有在 project 彻底不可用时才走换 ref 的路。
5b. 恢复:场景 C —— 这台 Mac 也没了(从异地副本起步)
🔴 场景 A / B 的每一条命令都假设
~/Backups/lampio/LATEST/…在本地。 但异地副本存在的唯一场景就是这台 Mac 没了 —— 那时你手上只有 iCloud 里一个.gpg文件,上面那些路径一条都不成立。本节负责把你接回场景 A/B 的起点。
5b.1 你手上必须有的三样
| 东西 | 从哪来 | 没有会怎样 |
|---|---|---|
lampio-*.tar.gz.gpg | iCloud Drive LampioBackups/ | 无解 |
| 异地加密口令 | 🔴 密码管理器(不是 Keychain —— 它跟着旧机器一起没了) | 副本永久打不开,无解 |
| Supabase 凭据 | Dashboard 可重置(连接串)/ 直接取(service_role) | 可恢复,不阻塞 |
如果口令丢了:异地副本作废。此时唯一指望是旧机器的磁盘还能读,或平台侧 还有别的副本 —— 而本项目实测平台没有任何自动备份(§1)。所以这条不是 「不方便」,是终局。这就是为什么 §3.1 反复强调另存密码管理器。
5b.2 新机器装依赖
bash
brew install gnupg libpq nodegnupg 不是 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.json的offsite字段会是"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_attrigger (10 张表 × INSERT/UPDATE 两个事件)。目标 project 自带anon/authenticated/service_role三个角色,policy 引用它们无障碍。
③ Storage 可按 {user_id}/{rel} 原样取回 ✅
7 个对象全部传回并逐字节回读比对(sha256 + 长度):上传 7 / 一致 7 / 不符 0。 恢复后 storage.objects = 7 行,与备份时一致。
演练中踩到的坑(下次直接照抄,省 30 分钟)
- 新 project 没有直连端点 → 必须走 pooler session 模式。见 §5.1 的对照表。 排查特征:project 状态
ACTIVE_HEALTHY,但db.<ref>.supabase.co:5432是Connection refused(不是超时)。 - 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 字符,在限制内,走提示符没问题。 - 粘贴密码时带进尾随空格,表现为
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-db、security 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 status→ FileVault 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 的评估。