- AppEngine: save_project -> export_project_path(explicit path); project_modified removed (write-through has no dirty flag) - File menu's save/save-as are the export path (extension-dispatched ove/otio/fcpxml); the library row export path is unchanged - facade project_save/is_modified stay frozen, documented as the legacy export surface - docs: M13 D5 checked off; M12 inventory updated
201 lines
12 KiB
Markdown
201 lines
12 KiB
Markdown
# 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
|
||
→ 拼装 `<project>` 交给 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
|
||
压缩、库内节点级查询。
|