我们的桌面 AI 应用支持把本机数据导出成 tar.gz 备份(归档格式见备份归档格式:为什么选 tar.gz 不选 zip,四轨 JSON 与 manifest 版本探测)。恢复这份备份有两种哲学:
- 覆盖式:清空现有数据,把归档完整灌进去。实现简单,语义清晰——「回到备份那一刻」。
- 合并式:归档数据与现有数据做并集,已存在的跳过,不存在的新增。
我们选了合并式。覆盖式有三个无法接受的问题:
- 误操作不可撤销。用户在换机场景下想恢复,在多场景下也可能是「想找回某个删掉的知识库」。覆盖式会把当前库先清空——一旦选错归档文件,现有数据先没了。破坏性操作必须给用户留退路。
- 重复恢复产生双胞胎。同一个归档恢复两次(手抖点了两次、网络重试),覆盖式每次先清再灌没问题,但实现里如果清空与写入不是一个事务,中途断电就是半个库。合并式用幂等跳过天然免疫重复执行。
- 部分轨道归档会抹掉其余数据。归档是四轨可选的(见 备份归档格式:为什么选 tar.gz 不选 zip,四轨 JSON 与 manifest 版本探测),用户导出一个只有 settings 轨的归档,恢复时若按覆盖式语义,会话和知识库全没了——这显然不是用户的意图。
合并式的代价是实现复杂度:每类数据要回答「什么算已存在」。这篇讲三个机制:幂等跳过的键怎么选、外键安全的写入顺序、以及恢复后向量怎么重建。
幂等跳过:每类数据的「身份」是什么
合并式恢复的核心是定义每类数据的身份键。四条轨道用了三种不同的判重策略:
conversations:按主键 ID 跳过
会话和消息的 ID 是应用生成的 UUID,全库唯一。恢复逻辑是 INSERT OR IGNORE:
const r = insertConversation.run(/* id, ... */);
if (r.changes > 0) convImported++; else convSkipped++;
changes === 0 即「这个 ID 已存在」,计数为 skipped。同一归档恢复两次,第二次全部 skipped,结果集完全一致——幂等性由数据库唯一约束保证,而不是靠应用层先 SELECT 再 INSERT(先查再写有 TOCTOU 竞态,而且多一次往返)。
knowledge:两层判重
知识库的判重分两层:
- knowledge_bases:同样按主键 ID INSERT OR IGNORE。
- documents:按 content_hash(文件内容的 sha256)判重。同一个文件在两个知识库里各存一份是合法的(不同 ID),但同一个文件重复恢复到同一个库应该跳过。
文档恢复时有一个关键细节——恢复进来的文档 status 一律设为 'pending',而不是归档里记录的 'indexed':
const docRow = insertDoc.run(doc.id, ..., 'pending', ...);
原因很简单:归档里不存向量(备份归档格式:为什么选 tar.gz 不选 zip,四轨 JSON 与 manifest 版本探测 讲过,向量是可再生产物)。分片原文写回来了,但 sqlite-vec 里的 embedding 没有——文档必须重新走一遍嵌入管线才能被 RAG 检索到。pending 状态就是触发重建的标记,索引器会像对待新上传文档一样处理它。如果照抄归档里的 indexed,恢复出来的知识库会处于一种「看起来有数据、检索永远零命中」的僵尸状态——这比直接失败恶劣得多。
settings:upsert 而不是 ignore
设置轨的语义不同:它不是「累积型」数据,而是「当前值」数据。INSERT ON CONFLICT DO UPDATE——归档里的值覆盖现有值:
INSERT INTO settings_kv(key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
模型配置、供应商列表,恢复语义就是「回到归档时的配置」。注意密钥字段在导出端就已脱敏(密文不出库),恢复回来的是脱敏占位符,用户需要重新填 key——这在恢复确认框里有明确提示。
单事务与 FK 安全:写坏了就全部不写
整个恢复写入包在一个 SQLite 事务里:任何一条失败,整体回滚,库里不会留下半个恢复结果。这个选择配合 better-sqlite3 的同步事务几乎零成本:
const tx = db.transaction(() => {
for (const entry of entries) { /* 按拓扑序写入 */ }
});
tx();
但单事务有一个隐藏约束:外键依赖必须在事务内按拓扑序写入。knowledge 轨的依赖链是:
knowledge_bases → documents → document_chunks
先写库、再写文档、最后写分片。如果顺序反了(比如先写 chunks),外键约束立刻让事务回滚。顺序硬编码在恢复函数里,配合既有的表结构,测试覆盖了「打乱归档内部顺序也能正确恢复」的用例——防御的是未来某个版本导出端改了序列化顺序。
另一类 FK 问题更微妙:归档里的数据引用了当前库不存在的东西。比如会话消息里 assistant_id 指向的助手,在当前库里已被删除。策略是:不安全的关联跳过该条并计入 warnings,而不是让整事务失败。恢复结果对象里有 warnings: string[],UI 上会如实展示「3 条消息因所属助手不存在被跳过」。整体成功与局部诚实,两者都要。
precheck:先报告将要发生什么
恢复 API 分两步,这是刻意的设计:
- precheck:上传归档,后端只做只读检查——manifest 校验、版本兼容、统计「将导入 N 个会话 / M 篇文档 / K 条设置 / P 个附件」、磁盘剩余空间。前端弹确认框展示这份报告。
- restore:用户确认后才执行真正的事务写入。
为什么必须这样?因为恢复是改变本地数据库的操作,而用户对归档内容的预期经常是错误的(拿错了文件、忘了里面有什么、不记得是哪天导的)。precheck 把「我以为我恢复的是什么」和「实际会恢复什么」之间的鸿沟显性化了。破坏性操作的原则:先让用户看见后果,再让后果发生。
一个真实的边界 bug
恢复模块 14 个单测之外,真机 round-trip 还抓到一个:description 字段在老版本会话里可能为 NULL,导出时序列化成 JSON null,恢复时 Zod schema 写的是 z.string()——校验失败,整轨恢复被拒。修复办法是把 schema 改成 z.string().nullable().optional() 并在写入时兜底空串。
这个 bug 的教训不是「加 nullable」这么简单,而是:备份/恢复链路的测试必须用真实历史数据做 round-trip。手工构造的夹具永远是你以为的数据形状,生产库里躺着三代 schema 变迁留下的各种 NULL 组合。后来备份测试夹具里固定包含「早期版本字段缺失」的样本行。
小结
合并式恢复的三个支柱:
- 幂等由数据库约束保证(INSERT OR IGNORE / ON CONFLICT DO UPDATE),不在应用层做先查后写;
- 原子性由单事务保证,外键按拓扑序写入,不安全的关联降级为 warning 而非整体失败;
- 可再生产物不恢复原文状态——文档恢复为 pending 触发向量重建,杜绝「僵尸已索引」状态。
加上前置的 precheck 报告,整个恢复链路做到了:重复执行安全、中途失败安全、选错文件有最后一次反悔机会。
下一篇 B04 换个战场:对话分享模块的脱敏纯函数——四种密钥形态的正则、为什么脱敏只有一份实现、以及「默认外发」威胁模型。
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
Our desktop AI application supports the export of our own data into tar.gz backup (for archive format, see backup archive format: Why do you choose tar.gz instead of zip, 4 track JSON and Manifest versions detect). There are two philosophys for restoring this backup:
- Cover: emptied existing data and filled the archives in. Simple, semantic -- back to the backup moment.
- Merger: Archived data are combined with existing data, existing skipping, non-existent additions.
We chose a merger. The coverage has three unacceptable issues:
- Mishandling is irrevocable. Users want to recover in a different airport setting, and in a variety of settings, they may also be "in search of a missing knowledge base". The overlay will empty the current library first — the available data will be lost once the file is wrong. Disruptive operations must leave users back.
- Repeated recovery to produce twins. The same archive has been restored twice (twisted hands twice, network retry) and the coverage has been cleared and refilled with no problem, but if it is not a matter of emptying and writing, it is half a library. Combination is repeated by skipping natural immunizations, etc.
- Partial orbital archiving will erase the remaining data. Archiving is optional on four tracks (see Backup Archive Format: Why do you choose tar.gz instead of zip, four track JSON and master version detection), the user exports an archive with only settings tracks, and if it is restored in the syntax of the overlay, the conversation and the knowledge library are gone — clearly not the intent of the user.
The price of consolidation is the degree of complexity: each type of data has to answer "what is there?" This article talks about three mechanisms: how to select the skipping keys, how to write them safely, and how to rebuild the recovery vector.
And so on: What's "identity" for each type of data?
At the heart of the combined recovery is the definition of identity keys for each type of data. The four tracks use three different sentencing strategies:
Other Organiser
The ID of sessions and messages is the only UUID that is applied to generate, the full library. Restoring logic is INSERT OR IGNORE:
const r = insertConversation.run(/* id, ... */);
if (r.changes > 0) convImported++; else convSkipped++;
Changes = 0, "This ID already exists", counted as sskiped. The same archive was restored twice, with the second full skipped, and the results set fully consistent - the homogeneity is guaranteed only by the database and not by the application layer first SELECT and INSERT (check and write the TOCTOU competition and return one more time).
Knowledge: Two layers.
The knowledge base is judged on two levels:
- Knowledge bases: also press primary ID INSERT or IGNORE.
- Documents: sentenced to weight by center hash. The same document is legally stored in each of the two repositories of knowledge (different IDs), but it should skip to repeat the same library.
There is a key detail when the document is restored - the document that is restored is always set to 'pending', not 'indexed' as recorded in the archive:
const docRow = insertDoc.run(doc.id, ..., 'pending', ...);
The reason is simple: there is no vector in the archive (backup archive format: why do you choose tar.gz instead of zip, four track JSON and climate version detection, which is renewable). Part is written back, but the embedding in sqlite-vec does not -- the document must be re-routed to be retrieved by RAG. Pending status is the trigger mark, and the indexer handles it like a new upload document. If you copy the archive's indexed, the restored knowledge base will be in a Zombie state — which is far worse than a direct failure — “looks to have data, retrieves zero hits”.
Settings:upsert instead of ignore
The semantics of setting a track are different: it is not "cumulative" data, but "current values" data. INSERT ONCONFLICTDO UPDATE - Values in archive overwrite existing values:
INSERT INTO settings_kv(key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
Model configuration, list of suppliers, restore semantics is "the configuration when you return to the archive". Note that the key field is dissensitized at the export end (the secret is not in the library) and that it is the dissensitized placeholder who returns, and the user needs to refill the key - this is clearly indicated in the restoration confirmation box.
Single service and FK security: write bad and don't write anything.
The whole restoration is wrapped in a SQLite service: any one fails and the whole rolls back, and the library does not leave half of the recovery. This option corresponds to the almost zero cost of synchronization services for better-sqlite3:
const tx = db.transaction(() => {
{/ * Write in sprawl */}
});
tx();
There is, however, a hidden constraint on a single matter: the external key depends on writing in an extended order within the transaction. The dependency chain of the track is:
knowledge_bases → documents → document_chunks
Write the library, then the document, and finally the fraction. If the order is reversed (e.g. write chunks first), the external key constraints immediately bring the matter back to roll. Sequenced hard-coding in the restore function, matching the existing table structure, and the test covers the example of "disturbing the internal order of the archive and restoring it correctly" - The defense is that some future version of the export end changes the sequence.
Another type of FK problem is more subtle: the data in the archive quotes what does not exist in the current library. For example, the assistant to whom the session message refers has been removed from the current library. The strategy is that unsafe connections skip the article and include warnings, rather than letting the whole thing fail. Recovering the result will include Warnings: string[], and UI will show "3 messages were jumped because their assistant did not exist". Both overall success and local honesty.
First report what's going to happen.
Resume the API in two steps, this is deliberate design:
- Precheck: Upload archive, backend only for read-only check-checking-manifest, version-compatibility, statistics "To import N Sessions / M Document / K Article Settings / P Attachments" and disk space. The front-end confirmation box displays the report.
- Restore: perform real transactions only after user confirmation.
Why do you have to? Because restoration changes the operation of a local database, the user's expectations of archived content are often incorrect (the wrong file, the missing part of it, the unknown). The gap between "what I think I'm recovering" and "what I'm actually going to recover" has become more pronounced in precheck. The principle of destructive operations: to show the consequences to the user, then to let them happen.
A real border, bug.
In addition to the 14 single measurements of the recovery module, the real machine round-trip caught one: description field may be NULL in the old version of the session, and the sequences are manufactured into JSON Null, and Zod Schema wrote at the time of recovery that Zod Schema () — verification failed and the re-entry was rejected. The fixer is to replace the schema with z.stream().nullable().optional() and to go through empty strings while writing.
The lesson of this bug is not so simple as "nollable" but that backup/restoration links must be tested with real historical data. Manually constructed binds are always the shape of the data you think, and three generations of NULLs are lying in the production store. Later, the backup test kit fixed the line sample, which contained the "early version of field missing".
Summary
The three pillars of combined recovery:
- Guaranteed by the database (INSERT OR IGNORE/ONCONFLICTDO UPDATE), not to be checked before writing;
- Atomity is guaranteed by single matters, external keys are written in scaling order and unsafe association is downgraded to warning rather than failure as a whole;
- Renewable products are not restored to their original state - the document is restored to the pending trigger vector reconstruction and the "Zombie index" status is eliminated.
Together with the precheck report, the entire restoration chain was achieved: repeated implementation of security, failed security, and the wrong document had the last chance of turning back.
The next B04 for the battlefield: the dissension pure function of the dialogue-sharing module - the regularity of the four key forms, why the dissension is achieved only one and the "default outward" threat model.
Auto-fetched and translated by AI Atlas. All rights belong to the original author.