# M13:oakstorage 数据库后端、实时持久化与项目管理器 > 前置:M10(oakstorage 骨架与文件后端)已完成;.ove/otio/fcpxml 后端 > 与全特性序列化器(S1)已落地。本计划把工程存储从"文件 + 手动保存" > 切换为"数据库 + 实时写穿",并交付达芬奇式的项目管理器窗口。 > > 约束(用户已定):数据库同时支持 PostgreSQL 与 SQLite(sea-orm); > **所有改动实时写数据库,无保存按钮**;数据库为默认后端;工程可在 > 数据库与 .ove / .otio / .fcpxml 之间导入导出;**撤销历史持久化** > (无限撤销)。 ## 0. 形态决策(定稿) - **按聚合粒度数据库化**,不做全关系型。库只管四件事:工程元信息、 KV 设置、加载加速快照、节点粒度的命令日志。领域语义(时间线结构、 连接、关键帧、效果链)全部留在节点 XML 里——**工程状态 = 节点图 + settings,没有第三种东西**,因此节点粒度是封闭全集。 - **journal 由 diff 产生,不由命令申报**。每条命令 redo 后把工程在 内存里重新序列化(S1 的 save 按节点吐 XML),与上一版逐节点比对, 变化/新增/删除的节点各落一行。零命令侵入:现有与未来的命令类型 (含调整图层)自动覆盖,正确性与序列化器共用同一事实源。 - **journal 同时就是持久化撤销历史**:回放 = 快照 + 按 seq 顺序把 identity 为 X 的节点整个替换成 new_xml;撤销到任意点 = 逆序回写 old_xml。old/new_xml 都是整节点 XML,定位靠 node_identity 主键, 不碰 XML 内部。 - **快照只加速加载**:默认每 600s(`Storage/SnapshotIntervalSec`, 可配)脏状态下落一份全量 XML;定期清理只留最近 3 份。快照损坏也 能从空工程 + 全 journal 重建。 - **崩溃恢复**:journal 同步写 ⇒ kill -9 最坏丢 0 条命令。 - 默认后端 = 数据库(本地 SQLite 单库文件;PG 用连接串)。.ove 降级 为导入/导出格式。 ## 1. Schema(SQLite / PG 同构,sea-orm migration 管理) ```sql projects( id INTEGER PRIMARY KEY, -- PG: BIGSERIAL uuid TEXT UNIQUE NOT NULL, name TEXT NOT NULL, schema_ver INTEGER NOT NULL, -- serializer CURRENT_VERSION created_at TIMESTAMP NOT NULL, modified_at TIMESTAMP NOT NULL, command_seq BIGINT NOT NULL DEFAULT 0 -- 当前头的命令序号 ) settings( project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (project_id, key) ) snapshots( -- 周期全量快照(定期清理) project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, command_seq BIGINT NOT NULL, payload TEXT NOT NULL, -- 全特性 XML(与 .ove 同源) written_at TIMESTAMP NOT NULL, PRIMARY KEY (project_id, command_seq) ) journal( -- 命令日志:每命令每受影响节点一行 project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, seq BIGINT NOT NULL, node_identity BIGINT NOT NULL, -- 0 = settings 伪节点 kind TEXT NOT NULL, -- 'redo'|'undo'|'jump'|'group'|'import' old_xml TEXT, -- 命令前像(undo 回放;新增为 NULL) new_xml TEXT, -- 命令后像(redo 回放;删除为 NULL) at TIMESTAMP NOT NULL, PRIMARY KEY (project_id, seq, node_identity) ) ``` - settings 变更以 `node_identity = 0` 的伪节点行入 journal,payload 为 变更键的 KV 片段。 - 项目管理器统计(轨道/片段/素材数)打开工程时从节点图派生, 不落库。 - journal 保留窗口:`Storage/JournalRetentionDays`(默认 0 = 全保留; 行均 KB 级,全保留量级可接受)。窗口外撤销历史失效(文档注明)。 ## 2. 运转方式 - **写穿**:redo/undo/jump 成功后一个事务:diff 出的受影响节点各行 + `command_seq + 1` + `modified_at`。组命令在 `group_end` 提交一次; `group_abort` 不写。 - **快照**:后台线程,latest-wins,不卡 UI;写后清理旧快照(留 3 份)。 - **加载**:最新快照(无则空工程)+ 其后 journal 按 seq 重放 new_xml → 拼装 `` 交给 serializer::load。 - **导入**(.ove/.otio/.fcpxml → 库):解析进内存工程 → 一个事务写 `kind='import'` 的全节点 journal 行(seq=1)。**导出**反向走内存 序列化,不落库。 - **退出**:无保存;退出前等快照线程 flush(latest-wins 队列排空)。 ## 3. 写穿接入点 facade 的 undo 推送路径挂钩(`oakengine_undo_push` / `undo_group_end` / `undo` / `redo` / `jump` 成功后)→ 当前工程会话执行 §2 写穿。 新建/导入建立 (project ↔ storage session) 绑定;关闭解绑。状态栏 脏标记改为"已写入/写入中"。 > D2 落地记录(2026-08):`crates/oakengine/src/storage.rs` 实现绑定表 > (project handle ctx → `{db uri, uuid}`)、写穿(每次 undo 路径成功 > 后对全部绑定工程调 `DatabaseBackend::save`,diff 式,未变工程 no-op)、 > 快照线程(`Storage/SnapshotIntervalSec`,默认 600s,latest-wins, > 退出 `oakengine_storage_flush` 排空)与 `last_error` 降级。 > **配置默认值**:`Storage/Backend` 的默认值 = `"sqlite"`,`Storage/SqlitePath` > 的默认值 = `<系统数据目录>/library.db`(`FileFunctions::get_configuration_location` > 的 macOS Application Support / XDG 位置,尊重 `OAK_CONFIG_DIR`)。 > **启用语义**:`Storage/Backend` 显式为 `"sqlite"`/`"database"` 才启用写穿; > 键缺失 = "无库配置"(工程不绑定、写穿不触发)——这是 §2"无库配置优雅 > 降级"的默认形态,保证 headless 消费者(oak-cli)与测试进程永远不写 > 用户的真实库。app 侧(D4/D5)在启动时显式设置该配置即可启用。 > > D3 落地记录(2026-08):`oakdb+pg://` 全量走通(load/save/load_at/ > snapshot/list/delete/duplicate/rename/export/import)。方言差异收敛在 > 连接与 migration 两层:`crates/oakstorage/src/backends/database/ > migration.rs` 按 `DatabaseBackend` 选 SQLite/PG DDL(PG 仅 `BIGSERIAL` > PK + `BIGINT` FK,其余同构;`CREATE TABLE IF NOT EXISTS` 幂等,每次 > 连接时执行;payload 维持 TEXT,不做 BYTEA);sea-orm 实体与 save/replay > 逻辑两库共用零分支。`connect_pg` 先单次直连探测(pool 对被拒连接会 > 退避重试到 acquire 超时,死库会挂起数秒),失败映射干净 E_IO。 > **配置**:`Storage/Backend = "pg"` 启用,`Storage/PgUrl` 给连接串 > (`user:pass@host:5432/dbname`,容忍 `postgres://` 前缀,oakdb uri > 剥掉);URI 的 `?project=` 选择器只在 query 含 `project=` 键时生效, > 否则整段(含 `?sslmode=…`)视为连接串。**测试矩阵**:SQLite 18 个 > 常驻;PG 13 个变体(round-trip/journal 语义/快照重放/撤销/清理/管理 > API/导入导出/选择)门控 `OAK_TEST_PG_URL`,未设置则早退打印说明 > (CI 无 PG 也绿);非法连接串(E_INVALID,parse 期)+ 连不上(E_IO, > 单次探测)为常驻错误路径测试,无需真实 PG。共享 fixture 抽到 > `tests/common/mod.rs`(建 fixture/保存加载/行检查 sqlite+pg 双探针)。 ## 4. 项目管理器窗口(app) 达芬奇式启动窗 + 菜单 文件→项目管理器: - 列表:name、modified_at、时长/轨道/片段/素材统计、双击打开。 - 新建 / 重命名 / 复制 / 删除(确认对话框)。 - 导入 .ove/.otio/.fcpxml 为新库行;选中工程导出为 .ove/.otio/.fcpxml。 - 数据源走 oakstorage 会话 API(Rust 直调;需要 C ABI 时 facade 只增)。 > D4 落地记录(2026-08):app 只链接 `liboakengine` dylib,故数据源走 > 新增 C ABI(`crates/oakengine/src/library.rs`,只增): > `oakengine_library_list`(JSON 行:uuid/name/created/modified + 派生 > 统计)/ `_create` / `_delete` / `_rename` / `_duplicate` / `_import` > / `_export`(按扩展名走 oakstorage registry 分发 ove-xml 或 otio 后端) > / `oakengine_project_load_library`(载入并**绑定**写穿会话,与 > `project_load` 同契约)。有副作用的 create/duplicate/import 返回 uuid > 用单次定长缓冲调用(不能两段式 measure-then-read——会在 C 侧执行两次)。 > app 侧:`src/manager.rs`(`ProjectManager` 内容视图 + `ManagerEvent` > 请求枚举 + `NamePrompt`/`ConfirmContent` 子对话框;时间/时长格式化为 > 无依赖纯函数)。`src/app.rs`:无 `--project` 启动时开管理器(驱动根 > entity,不能走 `WindowHandle::update`——`spawn_modal` 的 > `update_window` 会重入失败);`run_with` 启动时 > `real::configure_storage()`(仅在 `Storage/Backend` 未配置时设 > `sqlite`,路径用 facade 默认 `<数据目录>/library.db`),退出前 > `storage_flush()`。菜单语义(提前并入 D5):保存/另存为 → > 导出工程文件…(⌘S,扩展名分发 ove/otio/fcpxml);打开 → 从库中打开… > (= 管理器)+ 打开工程文件…;新增 项目管理器…。状态栏自动保存段 → > 库写入状态(`storage_bound` / `last_error`,失败红色)。MockEngine 带 > 内存假库(3 行种子 + 全操作),app gpui 测试覆盖建/删/复制/重命名/ > 导入/导出/打开;facade 侧 `it_library.rs` 5 测试(建/列/打开绑定写穿/ > 重命名复制删除/导出导入 round-trip/禁用降级)。截图 > docs/screenshot-manager.png + -en.png。 ## 5. 分期与判据 | 期 | 内容 | 完成判据 | |----|------|----------| | D1 | sea-orm schema + SQLite 后端(save/load/快照/重放/截断/list/delete/duplicate/rename/export) | `cargo test -p oakstorage`:全特性 round-trip 字段比对、快照+journal 重放、撤销到任意点、截断、管理 API、导入导出 | | D2 | diff 写穿挂钩(facade undo 路径)+ 快照线程 + 退出 flush | 编辑工程 → kill -9 → 重开恢复到最后一条命令;撤销历史跨会话可用(集成测试) | | D3 | PG 后端(同 schema)+ 连接配置 | `OAK_TEST_PG_URL` 存在时 PG 测试全绿,否则自动 ignore | | D4 | 项目管理器窗口 + 导入导出 + 启动接线 | app 测试:建/删/复制/重命名/导入/导出;中英截图入 docs | | D5 | ✅ .ove 手动保存语义退役(菜单改导入/导出,脏标记改写入状态)+ 文档更新 | 全量测试绿 | 依赖:D1→D2→D3;D4 可在 D1 后并行;D5 最后。 > D5 落地记录(2026-08):手动保存残留清空。app 侧 trait > `AppEngine::project_modified`/`save_project` 删除(写穿后工程永远"已 > 写入",无脏标记可读);菜单的 导出工程文件… 改走 > `export_project_path`(扩展名分发 ove/otio/fcpxml,.ove 分支经冻结 > ABI `oakengine_project_save` 写出,facade 注释标注 legacy/导出路径, > 行为不变);`RealEngine` 的 `modified` 字段及全部更新点移除, > `oakengine_project_is_modified` 不再被 app 侧调用(FFI 声明删除, > facade 保留)。状态栏写入状态(D4)为唯一落点。文档:本计划 §5 勾 > 掉 D5、M12 现状盘点更新、project-storage 双语文档无手动保存残留 > ("没有保存按钮"描述本就准确)。全量测试绿。 ## 6. 风险与对策 | 风险 | 对策 | |------|------| | 大工程每命令一次内存序列化 | 微秒-低毫秒级;超阈值记日志,必要时增量序列化优化 | | sea-orm async ↔ 同步 facade | 后端内嵌私有 current_thread runtime | | PG 测试依赖外部服务 | env 门控 ignore,CI 先只跑 SQLite | | 多实例同库并发 | v1 单写者(busy_timeout/PG 行锁);多写者 M14 | | 快照线程与退出竞态 | 退出前 flush 闸门 | ## 7. 不在本期 - 全关系型节点表、命令级语义序列化、多写者协作、云同步、payload 压缩、库内节点级查询。