Preserve First: Recovery Design for Local Creative Data

Preserve First: Recovery Design for Local Creative Data

优先保护:本地创意数据的恢复设计

The most dangerous button in a creative tool is not “delete.” It is the recovery dialog that appears when something is already wrong: “We couldn’t load your project — start fresh?” One click, and the file that failed to parse at 23:40 is gone at 23:41, together with the chapter it contained. The tool was trying to help. 创意工具中最危险的按钮并不是“删除”,而是当出现问题时弹出的恢复对话框:“无法加载您的项目——要重新开始吗?”只需点击一下,那个在 23:40 解析失败的文件及其包含的章节,在 23:41 就彻底消失了。工具本意是想提供帮助,但“本地优先”(Local-first)软件承担不起这种代价。

Local-first software cannot afford that. There is no server copy, no support ticket that restores yesterday’s state, no account trash bin. When your app is the only place a manuscript exists, recovery code is the product’s promise — and the promise has to be: preservation is the default, destruction requires narrow, earned authority. 这里没有服务器备份,没有可以恢复昨日状态的支持工单,也没有账户回收站。当你的应用是手稿存在的唯一场所时,恢复代码就是产品的承诺——而这个承诺必须是:保存是默认行为,销毁则需要经过严格且合法的授权。

This is how that rule is implemented in WorldScript Studio, an open-source writing studio that stores projects in the browser (IndexedDB) or on the desktop filesystem. Code references are from the repository at commit 2d9157c0 (2026-09-28), release v1.28.8; simplified excerpts are labeled. 这就是 WorldScript Studio 实现该规则的方式,这是一个将项目存储在浏览器(IndexedDB)或桌面文件系统中的开源写作工作室。代码引用来自提交记录 2d9157c0 (2026-09-28) 的仓库,版本为 v1.28.8;文中已标注简化后的摘录。

1. Refuse, don’t improvise

1. 拒绝,而非即兴处理

The canonical autosave path classifies what it finds on disk before writing anything. The classification set is small and explicit: 规范的自动保存路径在写入任何内容之前,会先对磁盘上发现的内容进行分类。分类集合很小且明确:

// services/projectAutosaveCanonicalWriter.ts (excerpt)
export type CanonicalAutosaveRefusal = 
  | 'MALFORMED' 
  | 'FUTURE' 
  | 'SUPPORTED_OLDER' 
  | 'UNSUPPORTED_OLDER' 
  | 'GENERATION_CONTRADICTION';

The important part is what happens on anything that is not a clean current state — the header comment states it plainly: “fail closed with a typed refusal; there is deliberately NO fallback to storageService.saveProject.” No best-effort write over a file whose shape the app did not expect. A FUTURE record (written by a newer build) is not “fixed” by an older one. A GENERATION_CONTRADICTION is not smoothed over. 关键在于当遇到非当前干净状态的内容时会发生什么——头部注释说得很清楚:“以类型化的拒绝方式关闭;故意不回退到 storageService.saveProject。”对于应用预期之外格式的文件,绝不进行“尽力而为”的覆盖写入。由较新版本构建的 FUTURE 记录不会被旧版本“修复”,GENERATION_CONTRADICTION 也不会被掩盖。

And a refusal is not a silent no-op: it rejects the coordinator operation, so indexing, analytics, and the UI success state cannot claim durability that never happened. A refusal is a loud, typed, user-visible “I did not save, and here is why” — which is precisely what lets the user keep the still-open editor state and act, instead of discovering the loss tomorrow. 拒绝不是静默的无操作:它会拒绝协调器操作,因此索引、分析和 UI 成功状态不会声称发生了并未实际发生的持久化。拒绝是一个响亮的、类型化的、用户可见的“我没有保存,原因如下”——这正是让用户能够保留当前打开的编辑器状态并采取行动的关键,而不是等到明天才发现数据丢失。

2. Destruction needs earned authority

2. 销毁需要合法的授权

When startup itself fails, the recovery options are computed by one pure function — and its default answer to every destructive question is no: 当启动本身失败时,恢复选项由一个纯函数计算得出——它对每一个破坏性问题的默认回答都是“不”:

// services/startupRecoveryPolicy.ts (excerpt)
// a stored project the editor cannot load is kept as it is — 
// reload only; no quarantine, no database reset.
if (error instanceof PersistedProjectNotLoadableError) {
  return {
    failureKind: 'project-corrupt',
    canQuarantine: false,
    canReset: false,
    canSafeOpen: false,
  };
}

The full matrix is deliberately narrow: Quarantine exists only for a corrupt project on the filesystem backend. Reset exists only for a storage-level failure on IndexedDB. Safe Open exists only for a refused desktop project (unsupported version, migration gap). A project I/O error, an unavailable desktop storage authority, a corrupt in-browser record: none of them can escalate into quarantine or reset. 完整的矩阵被刻意限制得很窄:隔离(Quarantine)仅存在于文件系统后端项目损坏时;重置(Reset)仅存在于 IndexedDB 存储级故障时;安全打开(Safe Open)仅存在于被拒绝的桌面项目(不支持的版本、迁移间隙)时。项目 I/O 错误、不可用的桌面存储权限、损坏的浏览器内记录:这些都不会升级为隔离或重置。

The code comment frames the design rule directly — non-destructive failures never gain quarantine authority. Recoverability is not a mood the UI is in; it is a property of the failure kind, decided in one audited place. 代码注释直接定义了设计规则——非破坏性故障绝不会获得隔离权限。可恢复性不是 UI 的一种“心情”;它是故障类型的一种属性,由一个经过审计的地方统一决定。

3. Quarantine is a rename, not a delete

3. 隔离是重命名,而非删除

When quarantine is warranted, it moves the project directory into a quarantined-projects area — recoverable, inspectable, reversible. The interesting engineering is in what surrounds that rename: Every destructive operation takes a project lock — the same lock that fenced writes hold — so a passing check cannot be invalidated by a delete racing a save. 当需要隔离时,它会将项目目录移动到隔离项目区域——可恢复、可检查、可逆转。有趣的工程设计在于重命名周围的处理:每个破坏性操作都会获取一个项目锁——与围栏写入(fenced writes)持有的锁相同——因此,通过的检查不会因为删除操作与保存操作竞争而失效。

The lock file lives outside the project directory, as a sibling. A lock inside the directory could be relocated by the very quarantine it is supposed to fence. A random per-creation token sits next to project.json. Delete and quarantine take it with the directory; every create writes a fresh one. A deleted-then-recreated project never matches an old window’s token — even if the new project.json is byte-identical to the old one. 锁文件位于项目目录之外,作为同级文件存在。如果锁在目录内部,它可能会被它本应围栏的隔离操作所移动。一个随机的“创建令牌”位于 project.json 旁边。删除和隔离操作会将其与目录一起处理;每次创建都会写入一个新的令牌。一个被删除后重新创建的项目永远不会匹配旧窗口的令牌——即使新的 project.json 与旧的在字节上完全相同。

That last mechanism exists because of a multi-window failure mode the code names explicitly: if window B deletes a project while window A still holds a loaded baseline, a save from A must not recreate it — recreating it from the stale snapshot would resurrect deliberately removed data. Preservation has a second side: preserving means respecting destruction that already legitimately happened. 最后一个机制的存在是因为代码中明确指出的多窗口故障模式:如果窗口 B 在窗口 A 仍持有已加载基准的情况下删除了项目,窗口 A 的保存操作绝不能重新创建它——从陈旧快照中重新创建它会复活被刻意删除的数据。保存有第二个层面:保存意味着尊重已经合法发生的销毁。

4. The reset that fails closed

4. 失败即关闭的重置

The browser-side nuclear option — resetting IndexedDB — runs behind a gate with a generation/epoch invariant: a connection open that started before or during a reset can never proceed against the new database. Every registered connection closer must finish its teardown before the reset settles, including closers registered while the drain is already running. 浏览器端的“核选项”——重置 IndexedDB——运行在一个带有代际/纪元不变性的门控之后:在重置之前或期间打开的连接永远无法针对新数据库进行操作。每个已注册的连接关闭器必须在重置完成前完成其拆除工作,包括在清理过程已经运行时注册的关闭器。

And if any closer throws, the whole reset rejects — fail-closed, leaving the old state untouched rather than half-torn-down. Half-recovery is worse than no recovery. A reset that completes while one connection still holds the old store open has not recovered anything; it has created two truths. 如果任何关闭器抛出异常,整个重置过程就会拒绝——失败即关闭,保持旧状态不变,而不是处于半拆除状态。半恢复比不恢复更糟糕。如果一个重置在仍有一个连接持有旧存储的情况下完成,它并没有恢复任何东西;它只是创造了两个事实。

5. The escape hatch the user owns

5. 用户拥有的逃生舱

All of the above protects data inside the app’s stores. The final layer gives the user a copy outside of them: a full-library backup, aggregated into a single archive — a ZIP carrying a vault.bin encrypted with AES-256-GCM, the key derived from a user passphrase via PBKDF2-HMAC-SHA-256 with 600,000 iterations. 以上所有内容都是为了保护应用存储内部的数据。最后一层为用户提供了外部副本:全库备份,聚合为一个单一的归档文件——一个包含 vault.bin 的 ZIP 包,该文件使用 AES-256-GCM 加密,密钥通过用户密码经由 PBKDF2-HMAC-SHA-256 和 600,000 次迭代导出。

Two properties matter here. First, the passphrase is the user’s, not the platform’s — the backup stays readable even if the app, the account that never existed, and the vendor all disappear. Second, encryption travels with the file: a backup emailed to yourself or dropped into a cloud folder does not become a plaintext copy of your manuscript. 这里有两个关键点。首先,密码是用户的,而不是平台的——即使应用、不存在的账户以及供应商全部消失,备份依然可读。其次,加密与文件同行:通过电子邮件发送给自己或放入云文件夹的备份,不会变成手稿的明文副本。