Files
mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md
T
lix-2026 5f97800489 chore: align local-first control plane and editor fixes
- wire SQLite control-plane access/session paths into Rust web local-folder routes

- preserve local Markdown attachment semantics across upload, reload, and secondary-pane resource tabs

- refresh design governance docs, Reasonix task templates, and bug records

- retire root .mcp.json local MCP config
2026-05-23 23:38:42 +08:00

644 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 5-5 [reference] 主编辑区与树域单一真源对齐方案 v1
> 更新时间:2026-05-18
>
> 当前状态:`reference`。本文作为 Page Aggregate 单一真源长期方案保留;当前执行进度与剩余项以 `5-6` 及 MVP 后阶段 checklist 为准。
>
> 2026-05-18 口径补充:
> - 页面聚合的默认 source 已开始向 local-first workspace 收口;页面正文、标题、设置和上传资源不应再默认把 Convex 视作主数据层。
> - 本文中涉及 Convex 的表述只应理解为 local-first 与 Convex-backed 两种 `WorkspaceSource` 的过渡兼容背景,不应再作为默认产品形态解释。
>
> 2026-05-19 口径补充:
> - local-first 下,本地 `.md` 文件是正文真相;Page Aggregate / EditorBlockDocument / tiptap state 都是投影或工作副本。
> - `documents/save` 只能作为 compat adapter,不能继续承担长期写侧仲裁。AI 后台写入、tiptap 保存、外部编辑器修改必须统一到本地文件版本冲突模型。
>
> 2026-05-22 口径补充:
> - 默认控制面已由 Rust SQLite `control-plane` 承接;本文中“Convex 作为控制面 / 可选同步协作 source”的表述仅代表 2026-05-18 阶段性背景。当前 Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 边界。
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md`
> - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-3-tiptap-leptos-rust-migration-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
## 1. 文档目的
这份文档只回答一个当前已经暴露出来的主线问题:
> **当 `leptos-tiptap` 已经成为页面内正式主编辑区后,页面树 / 文件树 / 页面标题 / 页面设置 / 页面正文,是否已经统一成 Rust 主导的单一真源架构。**
当前结论是:
> **还没有。**
当前系统已经不再是:
- `iframe runtime`
- 纯前端假接入
-`BlockNote` 主链
但也还不是:
- Rust 持有页面聚合真相
- Leptos island 只消费 Rust projection
- 树域与主编辑区共享同一份 page aggregate
它现在更接近于:
> **`Rust snapshot 主读链 + local-first / Convex-backed 双 source 过渡 + 前端本地 state 仍存在` 的混合态。**
因此,当前看到的这些“小问题”:
- 标题单一真源问题已经开始收口,但页面聚合仍未完全统一
- 页面宽度和主编辑区宽度看起来不是一套规则
- 页面设置大部分只改了壳层,没有真正进入主编辑器
并不是孤立 UI bug,而是同一个底层断层的表现:
> **树域 projection 与主编辑区 page runtime 之间,还缺一个统一的 page aggregate 真相层。**
---
## 2. 先给结论
### 2.1 当前不能说已经实现了 Rust 单一真源
虽然当前文档页已经:
- 默认使用页面内正式 `leptos-tiptap` island
- 不再依赖外部 iframe bridge 作为主编辑器
- 正文保存已经能按 `workspaceId/documentId` 绑定到正确页面
但以下事实仍然成立:
- 读取主链已固定为 Rust `mnote.page_aggregate.v1` snapshot,不再通过 TS builder 兜底
- 标题、正文、页面设置虽已开始收口到同一组 page aggregate command family,但 projection 回流与运行时语义还没完全闭环
- 页面树 / 文件树与页头标题的一致性已明显改善,但页头标题、页面设置与编辑器 runtime 还没有完全共享同一份聚合真相
- `pageOptions` 已部分进入 `leptos-tiptap` 运行时语义层,但还没有整体收口完成
所以当前不能把这条线描述成:
> **“主编辑区、页面树、文件树已经统一为 Rust 下唯一真源”。**
更准确的表述应该是:
> **树域已经在向 Rust canonical contract 靠拢,但文档页主编辑区仍处于页面聚合尚未完成的混合切流阶段。**
### 2.2 当前最该收口的不是换编辑器,而是统一页面聚合边界
当前不该重新争论:
- 要不要 `Tiptap`
- 要不要 `leptos-tiptap`
- 要不要继续保留 Convex 作为显式 cloud/compat/sync replica source
这些结论都已经足够明确:
- `Tiptap` 继续作为浏览器输入 runtime
- `leptos-tiptap` 继续作为 Leptos 内正式 editor island 接缝
- `local_folder` 作为默认页面数据真相,SQLite control-plane 持有默认控制面,Convex / 服务端退居显式 cloud/compat/sync replica source
当前真正要收口的是:
> **页面这一层,到底由谁持有聚合语义,前端到底应该消费什么,编辑器到底应该回发什么。**
local-first 后,答案进一步收紧为:
> **正文真相由本地 Markdown 文件持有;Page Aggregate 负责把文件投影成 UI 和 editor runtime 所需结构;写侧必须围绕文件版本做仲裁,而不是继续让 `documents/save` 兼容面决定谁的 revision 有效。**
### 2.3 后续主线应固定为 Page Aggregate,而不是继续零散补洞
从长期架构看,当前主线不应再描述成:
- “继续补几个 `leptos-tiptap` 细节”
- “再把标题同步修一下”
- “再把页面设置接一点进去”
后续主线应改写成:
> **建立 Rust 主导的 `page aggregate`:让页面树 / 文件树 / 页面头部 / 页面设置 / 主编辑区 / AI 写入,都围绕同一组 page projection 与 page command family 运转。**
---
## 3. 当前问题的精确判断
## 3.1 当前页面数据并不是一个统一聚合
当前文档页读取主链已经固定走 Rust `mnote.page_aggregate.v1` 快照:
- Rust `/api/page-aggregate/:id` snapshot
- 页面本地 aggregate state reducer
- preferred sidebar snapshot / 页头标题补偿链
需要特别区分“Rust route 主读链”和“完整 kernel-native projection”:当前 `/api/page-aggregate/:id` 已经由 Rust route / Rust runtime adapter 对外提供稳定 `mnote.page_aggregate.v1` 契约,前端不再恢复 TS runtime builder 主链;但底层 substrate 仍主要来自 `documents:getMeta + documents:getContent` 的兼容聚合。因此当前 projection provenance 应标记为 `CompatMetaContentJoin`,不能写成已经完全由 Rust kernel 原生投影闭环的 `KernelProjection`
这意味着当前已经不再是:
- `page.tsx` 手工拉 `meta + content` 再现场拼 props
但也还不是:
- 页面所有读写、标题回流、页面设置运行时语义都只围绕一份 Rust page aggregate 自动闭环
这条链本身已经说明:
> **当前页面已经进入 Rust-first 聚合读取阶段,但 canonical page projection 仍未成为页面域唯一真相。**
### 3.1.1 标题链已开始收口,但尚未完成页面聚合统一
当前页头标题已经不再单纯由 `DocumentContent` 内部的长期本地 `pageTitle` 真相驱动,而是开始依赖与 sidebar / breadcrumb 共享的 preferred sidebar snapshot。
这带来两个直接后果:
1. 标题不同步这一类“前端本地状态长期漂移”问题已经明显减少
2. 但标题仍然没有和正文、页面设置一起统一到单一 page aggregate command / projection 边界中
于是:
- 页头、breadcrumb、sidebar、page tree、file tree 的标题一致性已有一轮真实收口
- 但从聚合语义上看,标题仍是一条相对独立的更新链,不等于 page aggregate 已经完成
这类问题不是“防抖没配好”,而是:
> **标题更新仍然是页面聚合之外的一条独立 side effect 链。**
### 3.1.2 正文和页面树仍然不是同一份 projection family
当前正文编辑器使用的是:
- `leptos-tiptap` runtime
- `/api/documents/save`
- `EditorBlockDocument / Tiptap / blocks` 的转换边界
而页面子树 / outline / evidence 使用的是:
- `pageSubtree`
- 前端本地定义的 TS 结构
- “内容与标题未变化时复用旧快照”的策略
这说明当前主编辑区与树域虽然已经能同时工作,但它们仍然不是:
- 同一个 Rust projection family 的不同视图
而是:
- 一边是编辑器保存链
- 一边是页面子树附带快照
因此当前 `pageSubtree` 更像:
- 页面阅读态和 TOC 的辅助投影
而不是:
- 主编辑区与树域共享的 canonical page aggregate
### 3.1.3 页面设置已部分进入 island,但还不是完整 editor runtime 语义
当前页面设置里至少有三类配置:
#### A. 页面壳层布局类
- `wideLayout`
- `smallText`
- `layoutDensity`
#### B. 页面视图类
- `showToc`
- `collapseBacklinks`
- `hideChildPages`
#### C. 编辑器语义类
- `showHeadingNumbers`
- `showBlockRefCount`
- `embedDefaultBlockId`
现在的主要问题不是“这些字段没有存下来”,而是:
> **它们虽然能持久化到 documents 表,也已有一部分进入 `leptos-tiptap` island,但还没有统一进入正式 page aggregate,更没有完整地进入 editor runtime 语义层。**
结果就是:
- 一部分设置已经作用于 island 布局或排版
- 一部分设置只影响阅读态
- 一部分设置被展示出来,但主编辑器内部几乎不消费
这也是为什么当前会出现:
> **页面设置看起来像是真的,但很多只是 UI 层局部生效。**
### 3.1.4 所谓“Rust 路径”里仍有明显 compat 痕迹
当前 rename / move / archive 等页面操作,虽然已经开始声明:
- `preferredCommandName`
但真实落地仍大量停留在:
- `documents.title.update`
- `documents.move`
- `documents.delete`
这说明当前并不能说:
- 树域命令已经完整切到 Rust tree command family
更不能说:
- 页面编辑域已经和树域命令收敛到同一个 page aggregate contract
因此现状应判定为:
> **Rust bridge 已经进入主链,但 page aggregate command cutover 仍未完成。**
### 3.1.5 写侧冲突不能继续藏在 `documents/save` 后面
当前读侧已优先消费 Rust `mnote.page_aggregate.v1` snapshot,但写侧仍有明显兼容痕迹:
- tiptap 保存仍通过 `/api/documents/save` 兼容入口进入 `page.body.save`
- AI 兼容工具写正文时也可能最终进入同一条保存链
- local-first 下 agent 还会直接修改 `.md` 文件
如果继续让这些写入都挤在 `documents/save` 兼容面后面,会再次出现:
- 谁拥有最新 revision
- AI 写入是否覆盖了用户未保存编辑
- tiptap autosave 是否覆盖了 agent 刚写回的文件
- Page Aggregate 读到的是旧 content、EditorBlockDocument 还是最新 `.md`
因此写侧必须改成 VSCode-like 文件版本模型:
```text
current .md file
-> fileVersion = hash + mtime + size
-> Page Aggregate snapshot 带 baseFileVersion
-> tiptap 工作副本记录 baseFileVersion 与 dirty 状态
-> AI / 外部编辑器写入触发 watcher
-> clean editor 自动 reloaddirty editor 进入 conflict state
```
这意味着 `EditorBlockDocument` 原生落库闭环不能理解成“再建一份新的正文真相”。local-first 下更合理的定位是:
- `.md` 是 canonical body
- `EditorBlockDocument` 是 runtime-native projection / cache
- `.mnote/cache/<page-id>.editor.json` 或内存缓存可用于加速和保留编辑器特有信息,但必须带 `sourceFileVersion`
- 当 source file version 不匹配时,cache 失效并重新从 `.md` 投影
长期命令面应从 `/api/documents/save` compat route 收口到 `page.body.write` / LocalFS executor,并显式接收 `expectedFileVersion`。任何不带 expected version 的正文写入都只能进入 compat / import 路径,不能作为自动保存主链。
---
## 4. 为什么这些问题会直接影响长期方向
## 4.1 它会削弱 Rust + Leptos 的真正优势
你们要的不是:
- “一个能跑的 Tiptap 页面”
而是:
- Rust 掌握页面与树的真相
- Leptos 成为页面内正式 editor island
- AI 最终直接接入主编辑区写作
如果标题、正文、设置、树节点标题分别走不同链路,那么 Rust + Leptos 的长期优势就会被削弱成:
- 只是“比纯前端多了一层 bridge”
而不是:
- “由 Rust 持有统一对象语义,前端只消费真相投影”
### 4.1.1 AI 直接写主编辑区会缺乏稳定入口
如果后续 AI 要直接进入主编辑区编写,而当前系统没有统一的 page aggregate command family,那么 AI 最终只能选下面几条坏路:
- 直接操作前端 DOM
- 直接操作 Tiptap JSON
- 直接调用不同的标题 / 正文 / 设置接口拼写入
这三条路都不符合长期目标。
长期正确路线必须是:
> **AI 先操作 Rust page aggregate 的命令与块语义,再由 editor island 把结果投影到 live editing surface。**
### 4.1.2 树域与页面域会继续出现“双真相漂移”
只要页面树 / 文件树消费的还是一套资源 meta,而主编辑区头部和页面设置是另一套前端状态,那么后续还会不断出现:
- 标题单一真源已开始收口,但当前尚未和正文、页面设置统一成同一 page aggregate 命令边界
- 页面设置变更只影响一部分视图
- 阅读态与编辑态宽度规则不一致
- 新增一个页面能力时,要改三四条链路
这类问题越到后面越难收。
---
## 5. 正确的目标重写
当前阶段的目标不应再写成:
- “把 `leptos-tiptap` 再打磨得更像官方模板”
而应重写为:
> **建立 Rust 主导的 `page aggregate`,让页面树 / 文件树 / 页面头部 / 页面设置 / 主编辑区 / AI 写入共享同一份页面真相。**
这句话拆开后,意味着下面五点。
### 5.1 Page Aggregate Projection
Rust 侧需要提供一个统一的页面聚合投影,至少包含:
- `page_identity`
- `workspace_id`
- `document_id`
- `node_id`
- `page_head`
- `title`
- `icon`
- `cover`
- `updated_at`
- `permissions`
- `page_layout`
- `wide_layout`
- `small_text`
- `layout_density`
- `show_toc`
- `show_structure`
- `page_body`
- `editor_document`
- `revision`
- `conflict_detection_key`
- `stats`
- `page_tree`
- `outline`
- `child_pages`
- `resource_meta`
当前不要求一次把所有字段做满,但必须先把口径冻结。
### 5.2 Page Command Family
后续页面域的操作不应继续被拆成互不统属的零散 mutation,而应在语义上统一到 page aggregate 之下。
最小命令族至少包括:
- `page.head.rename`
- `page.layout.update`
- `page.body.save`
- `page.body.insert_reference`
- `page.body.set_embed_default_block`
- `page.tree.refresh_projection`
这不意味着现在立刻把所有接口名都改掉,而是:
> **从现在开始,所有页面能力都应先判断它属于哪个 page aggregate 子域,而不是继续新增离散 mutation。**
### 5.3 Leptos Island 的正式边界
`leptos-tiptap` island 的职责应收敛为:
- 消费 `page_body`
- 消费必要的 `page_layout` 编辑器相关配置
- 回发正文与块级命令
- 暴露可供 AI / 页面壳使用的稳定 editor handle
它不应再负责:
- 定义页面总真相
- 自己发明页面设置语义
- 在页面壳之外另持有一套页面元信息状态
### 5.4 Tree Domain 的接缝
页面树 / 文件树不需要知道编辑器内部细节,但必须与页面聚合共享:
- 同一份 `document_id`
- 同一份页面标题
- 同一份 `resource_meta`
- 同一份页面可见状态与能力语义
也就是说:
- 树域保持 tree-first
- 页面域保持 page aggregate
- 两者通过稳定 projection contract 对齐
而不是:
- 树域一套标题来源
- 页面域另一套标题来源
### 5.5 Convex 的正确定位
当前不应该把 Convex 当作默认页面主数据层继续扩写,也不应该无计划硬拆已有 Convex 路径。
正确口径是:
- local folder 是早期产品默认页面数据真相
- Convex / 服务端继续作为账号、分享、同步、协作和远端副本控制面
- Rust 持有 canonical contract 与语义编排
- Leptos / Next 负责消费 projection 与呈现 island
因此,“Rust 单一真源”在当前阶段的正确含义不是:
- 只有 Rust 数据库
而是:
- **Rust 持有语义单一真源,local-first workspace 持有默认数据真相,SQLite control-plane 持有默认控制面;Convex 只作为历史迁移源、显式 cloud source / compat / sync replica。**
---
## 6. 当前哪些地方需要纠偏
## 6.1 需要纠偏的不是“页面里还有前端状态”
页面壳中存在一些本地临时状态本身没有问题。
问题在于:
- 哪些状态是临时 UI 状态
- 哪些状态却在冒充对象真相
后续必须把两者分清。
### 6.1.1 可以继续留在前端壳的状态
- inspector 开关
- comments drawer 开关
- history drawer 开关
- 当前是否在编辑态
- 当前 host observability
这些都是 UI state,不必进入 Rust 真相层。
### 6.1.2 必须退出前端壳真相地位的状态
- 页面标题
- 页面宽度 / 页面布局正式选项
- 编辑器默认嵌入位置
- 页面 outline 的正式来源
- 页面 stats 的正式来源
这些都不应继续被前端 `useState` 长期定义为页面事实。
## 6.2 需要纠偏的不是继续换保存底座
当前正文保存链已经基本可用,真正的问题不是“保存不到页面”,而是:
- 保存成功不等于 page aggregate 已建立
因此:
- 现在不该推翻 `documents.save`
- 也不该另造一套临时保存格式
而应做的是:
> **把标题 / 正文 / 页面设置的语义边界对齐到同一 page aggregate 之下。**
## 6.3 需要纠偏的是页面设置的产品口径
当前页面设置里已经混入了三类字段:
- 真正属于页面布局的
- 真正属于阅读壳的
- 真正属于编辑器 runtime 的
后续必须先分类,再决定哪些继续保留在页面设置面板里,哪些推迟支持,哪些进入 editor island。
否则只会继续出现:
- UI 有按钮
- 数据能存
- 但主编辑器不真正消费
---
## 7. 分阶段落地
下面的阶段不是完整开发清单,而是主线纠偏顺序。
## 7.1 Phase F: 冻结 Page Aggregate Contract
目标:
- 在设计层明确页面聚合的 canonical contract
- 不再让页面页头 / 正文 / 设置 / 子树各自长字段
完成标准:
- 有单独的 page aggregate contract 文档
- 明确 `page_head / page_layout / page_body / page_tree` 的最小字段
- 明确哪些字段是 UI state,哪些字段是 aggregate truth
## 7.2 Phase G: 主文档页改为消费统一聚合
目标:
- 文档页不再手工拼 `title + options + content + pageSubtree`
- 改为消费统一页面聚合返回值
完成标准:
- 页面头部使用聚合中的 `page_head`
- 页面设置初始值使用聚合中的 `page_layout`
- 主编辑区 bootstrap 使用聚合中的 `page_body`
- 树与 TOC 使用聚合中的 `page_tree`
## 7.3 Phase H: `leptos-tiptap` 正式消费 editor-related page options
目标:
- 把真正属于编辑器 runtime 的设置接入 `leptos-tiptap`
最小优先级建议:
1. `wideLayout`
2. `smallText`
3. `layoutDensity`
4. `showHeadingNumbers`
5. `embedDefaultBlockId`
完成标准:
- inspector 改动后,主编辑区表现真实变化
- 不再出现“设置能存,但编辑器内部几乎没变化”
## 7.4 Phase I: 树域与页面域标题统一
目标:
- rename 后页头、sidebar row、page tree、file tree 一致刷新
完成标准:
- 标题变更只经过一套 page aggregate command 语义
- 所有标题消费者都来自同一份更新后的 projection
## 7.5 Phase J: AI 写入入口对齐 page aggregate
> 2026-05-16 口径补充:`mnote.doc.fetch/find/plan_update` 与 `mnote.block.fetch/replace/insert_after/move_after` 已进入 Rust Hermes tool manifest 与 dispatch,最小块级读写闭环已启动。本文继续保留本节,是因为 `scope=selection`、`format=page_xml/text`、多块插入、复杂块移动矩阵和持久审阅 UI 仍由 `7-10` 继续验收。
目标:
- AI 不再绕过 page aggregate 直接拼前端对象
完成标准:
- AI 能通过统一页面命令把内容写入主编辑区
- 人工编辑与 AI 编辑共享同一块语义边界
---
## 8. 当前阶段的完成判定
只有当下面这些条件同时成立时,才能把这条线描述成:
> **“主编辑区与树域已经基本统一到 Rust 主导的单一真源架构”。**
- 文档页消费统一 `page aggregate projection`
- 标题 / 正文 / 页面设置不再是三条分裂真相链
- `leptos-tiptap` island 真正消费 editor-related `page_layout`
- 树标题与页头标题来自同一份 projection
- AI 写入入口开始围绕 page aggregate command family 设计
在此之前,正确口径都应保持为:
> **`leptos-tiptap` 主编辑区已基本可用,但页面聚合仍未收口,当前仍处于从混合态向 Rust 单一真源过渡的过程中。**
---
## 9. 本文结论
这次暴露出的页面宽度不统一、页面设置只部分生效,以及标题链虽已收口但聚合仍未统一,并不是坏消息。
它们的价值在于:
> **它们准确暴露了当前真正缺的不是“再修几个细节”,而是“把页面域提升成一个与树域对齐的 Rust page aggregate”。**
因此,后续主线不应继续散落成:
- 修标题
- 修宽度
- 修页面设置
而应统一收口为:
> **Page Aggregate Contract**
>
> **Page Aggregate Projection**
>
> **Page Aggregate Command Family**
这才是让 `tree-first graph kernel``leptos-tiptap` 主编辑区、以及后续 AI 直接写入主编辑区三者真正对齐的正确底层方向。