工程文件版本迁移与崩溃恢复:schemaVersion、Migration 链与原子持久化
用户花三小时剪完的片子,工程文件就是他的资产。一次版本升级打不开旧文件、一次断电写坏 JSON、一次崩溃后状态丢失------任何一种都会让用户对产品彻底失去信任。本文拆解工程文件(project document)的全生命周期可靠性:版本号驱动的迁移链、读写原子性、自动保存的锁与冲突、崩溃后的恢复协议。
一、版本号:迁移的唯一锚点
document 的第一个字段就是版本:
ts
const projectDocumentSchema = z.object({
schemaVersion: z.literal(1),
// ...
})
z.literal(1) 的含义:当前代码只接受 v1,其他版本一律拒绝直接解析------打开 v0/v2 文件不能"试着 parse 看看",字段错位产生的脏数据比明确报错危险得多。加载流程先读版本、再走迁移、最后校验:
ts
async function loadProject(path: string): Promise<Result<ProjectDocument>> {
const raw = await readTextFile(path)
const json = JSON.parse(raw) // JSON 语法层
const version = readVersion(json) // 先取版本(容忍未知字段)
if (version < CURRENT_VERSION) {
const migrated = migrateUp(json, version, CURRENT_VERSION)
// 迁移后再过当前版本完整 schema
const parsed = projectDocumentSchema.safeParse(migrated)
if (!parsed.success) return fail('MIGRATION_INVALID', parsed.error.issues)
return ok(parsed.data)
}
if (version > CURRENT_VERSION) {
return fail('PROJECT_VERSION_TOO_NEW', { found: version, supported: CURRENT_VERSION })
}
// ...同版本直接校验
}
高版本文件被低版本应用打开(用户没升级 / 装了两个版本)必须拒绝并明确提示 ,而不是 strip 未知字段后保存------一旦保存,新字段被写丢,用户的新功能数据永久损坏。这是与 IPC schema 默认 strip 策略相反的场景:加载可宽容未知字段用于展示,保存绝不可静默丢弃。
二、Migration 链:每次结构变更只写一步
不要写"从任意版本迁到最新"的巨型函数------要写相邻版本间的单向小步迁移,串成链:
ts
interface Migration {
from: number
to: number
description: string
up: (doc: any) => any // 纯函数:输入旧版对象,输出新版对象
}
const migrations: Migration[] = [
{
from: 1, to: 2,
description: 'clip 增加 transform 字段(默认恒等变换)',
up: (doc) => ({
...doc,
clips: doc.clips.map((c: any) => ({
...c,
transform: c.transform ?? IDENTITY_TRANSFORM, // 旧数据补默认值
})),
}),
},
{
from: 2, to: 3,
description: 'tracks[].type 枚举增加 voiceover;字幕迁移到独立 subtitles 数组',
up: (doc) => {
// 结构重排:旧版字幕嵌在 text track clips 里,抽出为顶层 subtitles
const subtitles = extractLegacySubtitles(doc.clips)
return { ...doc, subtitles, clips: doc.clips.filter(notLegacyTextClip) }
},
},
]
function migrateUp(doc: any, from: number, to: number): any {
let current = doc
for (const m of migrations) {
if (m.from >= from && m.to <= to) {
current = m.up(current)
current.schemaVersion = m.to
}
}
return current
}
迁移编写的六条铁律:
- 纯函数、无 IO:不读文件、不弹对话框、不调 AI------迁移要可单测、可批量跑。
- 只依赖相邻版本:v1→v2 的迁移只认 v1 形状;v3 的迁移只处理 v2 的输出。
- 每步可独立测试:准备 v1 的 fixture,跑完整链得到 vN,断言字段;每个迁移单独快照。
- 默认值用
.default()语义补齐:新增字段必须给老数据安全默认(与 Zod default 策略一致)。 - 不可逆信息显式处理:删除/合并字段前把数据搬到新位置(如字幕从 clip 抽出),不能直接丢。
- 迁移与校验分离:迁移函数可以暂时产出"中间形态",链路终点必须过一次当前版本完整 schema 兜底。
迁移链是追加式的:代码演进到 v8 时,v1→v2 的迁移永远保留------你永远不知道用户上一个打开该工程的是哪年的版本。
三、写盘原子性:杜绝半个文件
崩溃最常发生的时刻就是写盘瞬间。直接 writeFile(path, data) 不是原子的:写到一半断电,文件截断成半个 JSON,工程彻底损坏。标准解法是临时文件 + fsync + rename:
ts
async function atomicWrite(path: string, data: string): Promise<void> {
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`
try {
const fd = await fs.open(tmp, 'w')
try {
await fd.writeFile(data, 'utf8')
await fd.sync() // 强制落盘,别只停在 OS 页缓存
} finally {
await fd.close()
}
await rename(tmp, path) // rename 在同卷上是原子的(POSIX 保证;Windows 上 MoveFileEx + REPLACE_EXISTING)
} catch (e) {
await rm(tmp).catch(() => {})
throw e
}
}
三个层次:write 保证内容进文件、fsync 保证落物理盘(否则断电时页缓存丢失,rename 已经完成)、rename 保证目录项切换原子------任何时刻崩溃,目标路径上要么是旧文件、要么是新文件,不存在半个。跨卷 rename 不是原子的(会退化成复制),tmp 文件必须与目标在同一目录。
四、备份代际:自动保存的时间胶囊
即使写入原子,逻辑错误(迁移 bug、用户误操作后又保存)也会把错误状态"原子地"写进去。备份策略采用代际保留(grandfather-father-son 的简化版):
text
my-project.miaoma.json ← 当前
my-project.miaoma.json.bak ← 上一次保存
backups/
my-project.20260919-143022.json ← 最近 N 份定时快照(如 10 份)
ts
class SaveCoordinator {
async save(doc: ProjectDocument) {
if (await exists(targetPath)) {
await copyFile(targetPath, backupPath) // 当前→.bak(先备份再覆盖)
}
await atomicWrite(targetPath, serialize(doc))
await rotateSnapshots(doc.projectId, keep: 10) // 定时快照轮转
}
}
打开文件时若主文件损坏,自动按 .bak → snapshots 最新 顺序尝试恢复并提示"主文件损坏,已从 X 时刻的备份恢复(可能丢失最近 N 分钟编辑)"------用户知情、数据尽力,比打不开强十倍。备份清理按数量 LRU,不按时间(防止一个老工程堆几百份备份)。
五、自动保存:防抖、锁与多窗口冲突
自动保存(autosave)三件套:
防抖写入
变更后等 1.5s 静默期再写,连续编辑只落盘一次;同时每 60s 强制一次(防止持续编辑永不落盘):
ts
class DebouncedSaver {
private timer: NodeJS.Timeout | null = null
private lastSave = Date.now()
scheduleSave(doc: ProjectDocument) {
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => this.flush(doc), 1500)
if (Date.now() - this.lastSave > 60_000) this.flush(doc)
}
}
写期间的变更竞争
异步写盘进行中用户又改了文档:必须快照待写内容(写的是调度时刻的深拷贝),写完后检查"期间是否有新变更",有则再排一次。绝不能让写盘闭包持有可变 document 引用------写到一半数据被改,文件内容可能撕裂(部分字段新、部分字段旧)。
单写者锁
同一工程被两个窗口/两个应用实例打开,最后保存者覆盖另一人的全部工作。用带进程标识与心跳的锁文件:
json
// my-project.miaoma.lock
{ "pid": 18422, "appVersion": "4.2.0", "startedAt": "2026-09-19T06:00:00Z", "host": "workstation-01" }
ts
async function acquireLock(projectPath: string): Promise<LockResult> {
const lockPath = projectPath + '.lock'
const existing = await readJson(lockPath).catch(() => null)
if (existing && (await isProcessAlive(existing.pid))) {
return { acquired: false, owner: existing } // 提示:已在另一窗口打开
}
// 锁文件过期(进程已死)→ 抢占
await atomicWrite(lockPath, { pid: process.pid, startedAt: new Date().toISOString() })
return { acquired: true }
}
锁的关键不是创建,是失效检测:pid 复用、崩溃残留都可能让锁误判。进一步可用"锁文件 mtime 心跳"(持锁方每 10s touch 一次),超过 30s 未刷新视为死锁可抢占。正常退出时删锁;崩溃后下次打开检测到死锁,提示恢复而非静默进入(崩溃现场可能需要用户决定用哪个备份)。
六、崩溃恢复协议
应用异常退出后重启,恢复流程:
text
1. 启动时扫描 userData/recovery/ 下的恢复清单(每个打开过的工程一条)
2. 对每个工程:
a. 锁文件存在但持锁 pid 已死 → 上次崩溃
b. 对比:主文件 / .bak / 最近自动快照 / 未保存草稿(见下)
c. 弹恢复面板:列出各候选的时间与来源,让用户选择
3. 用户选择 → 走标准迁移 + schema 校验 → 打开;校验失败的候选标红不允许直接用
未保存草稿(unsaved changes 保险)
自动保存只覆盖"已保存过的工程"。新建未保存的工程也需要保险:变更防抖后写入 userData/recovery/draft-<id>.json,标记 untitled: true。重启时提示"有未命名的恢复草稿"。草稿与正式文件同一 schema、同一迁移链------恢复通道不享受数据格式特权,否则它会成为脏数据后门。
七、完整性校验与写后验证
关键工程(或调试开关)下做写后读验证:写完立即重新读取并 parse + 校验,与写入对象关键字段(hash 摘要)比对。成本不高(本地 IO),能立刻抓到磁盘满、权限被改、杀软拦截写入等环境问题------失败时停止后续自动保存并报警,避免连续覆盖好备份。
更高级的做法是在文件尾部附一个结构摘要:
json
{ "document": { ... }, "__integrity": { "schemaVersion": 1, "sha256": "..." } }
读取时先验摘要,损坏在 parse 之前就被发现(半个 JSON 连版本号都读不出来的场景)。
八、小结
| 机制 | 核心做法 |
|---|---|
| 版本 | literal 版本号;低版本迁移、高版本拒绝;永不静默 strip 后保存 |
| 迁移 | 相邻版本纯函数链;追加不删;默认值补齐;终点完整校验 |
| 写盘 | tmp + fsync + rename 原子写;同卷;失败清理 |
| 备份 | .bak 上代 + 快照代际轮转;损坏时按代际恢复 |
| 自动保存 | 1.5s 防抖 + 60s 保底;写快照防撕裂 |
| 锁 | pid + mtime 心跳;死锁可抢占;冲突显式提示 |
| 崩溃恢复 | 恢复清单 + 候选面板;未命名草稿同 schema |
| 完整性 | 写后读验证;尾部 sha256 摘要 |
工程文件可靠性的设计哲学是假设每一层都会失败:假设升级会跨多个版本(迁移链)、假设断电发生在写盘中途(原子写)、假设错误数据会被保存(备份代际)、假设应用会崩溃(恢复协议)、假设磁盘会说谎(写后验证)。用户不会看到这些机制中的任何一个------他们只会形成一个朴素的信念:"这个软件不会弄丢我的东西。"这是专业工具最硬的口碑。