# 3-3 [done] Rust Web Tree Realtime Event Stream 方案 v1

> 更新时间：2026-05-18（local-first 口径更新）
> 归档时间：2026-05-21（9.2 未完成项已由 3-14、3-18 及关联 smoke 验证覆盖）
> 关联新设计稿：`design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md`
> 关联 local-first 上位设计：`design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
>
> 2026-05-18 口径更新：
> - 本文的 Convex realtime substrate 只适用于 `convex_workspace`、同步副本和后续协作场景。
> - 当前早期产品默认 source 是 `local_folder`；本地树变化应优先通过 LocalFS watcher / rescan / Rust projection event 进入同一条前端 projection consumer。
> - `/api/realtime/ws` 与 `/api/tree/events` 的长期职责是统一 transport；本地工作区的数据真相仍是 LocalFS / WorkspaceSource。
>
> 关联文档：
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
> - `/mnt/Data1T/mnote/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`（历史过渡背景）
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`

## 1. 目标

这份文档用于固定 Stage C-1 的正式实时链路口径。2026-05-18 后，它应按 `WorkspaceSource` 区分底层事件来源：

- `local_folder`：LocalFS watcher / rescan / command result 是默认事件来源
- `convex_workspace`：Convex 作为 realtime substrate
- Rust 成为 tree-first graph 的 semantic owner
- Rust Web 负责正式页面 transport 与实时事件流
- 前端只消费 projection 与 delta，不再消费实验壳真相

目标效果是接近网页版 wolai / notion 的体验：

- 快速进入页面
- 树结构实时同步
- 局部变化快速响应
- 浏览器端不持有第二套树真相

## 2. 职责划分

### 2.1 Workspace event substrate

不同 `WorkspaceSource` 使用不同 substrate：

- `local_folder`：本地文件系统、watcher、rescan、命令执行结果。
- `convex_workspace`：显式 cloud / compat source 的 Convex 持久化、mutation / query、实时订阅、对象存储协作。

Convex 在这里是可选云端 / 协作 source 的 substrate，而不是所有工作区的默认 substrate：

> source-specific storage / realtime substrate

而不是页面树语义 owner。

### 2.2 Rust semantic owner

Rust kernel 负责：

- `page_tree / sidebar_tree / file_tree / subtree` 语义
- node / edge / projection 规则
- command / query 的语义收口
- domain event 的统一格式
- trace / audit / version 口径

Rust 在这里是：

> Rust semantic owner

### 2.3 Rust Web transport

Rust Web 负责：

- SSR 页面壳
- **WS push 主链**（`/api/realtime/ws`），SSE 降级为 fallback（60s 安全网）
- workspace / subtree 订阅入口
- 把 Convex 订阅与 Rust domain event 连接起来
- 向前端输出稳定的 projection snapshot + delta stream

### 2.4 前端 projection consumer

前端只负责：

- 首屏渲染
- islands 交互
- optimistic UI
- 应用 delta 到本地 projection cache

前端不再负责：

- 重新定义树结构
- 重新计算页面树语义
- 维护实验壳 iframe 作为正式实时主链

## 3. 为什么当前 tree shell 不是正式实时链路

当前 `mnote-web tree shell` 仍然只是实验壳，原因有三点：

1. 它依赖 iframe / HTML shell / postMessage 交互。
2. 它的 command path 和状态边界更接近实验 viewer，而不是正式页面 transport。
3. 它没有成为首页、sidebar、文档页共享的正式实时订阅主链。

因此当前 tree shell 可以继续保留为：

- 实验验证壳
- 独立 tree viewer
- picker / filetree 的可选增强壳

但不能继续当成正式 realtime 主链。

## 4. 正式 realtime tree event stream 分层

正式链路建议固定为四层：

### 4.1 Source-specific 持久化/订阅底座

这里负责：

- 命令落账
- 持久化页面树状态或本地文件状态
- 输出 command / watcher / mutation 后可订阅的数据变化

对于 local-first 默认路径，这层是 LocalFS watcher / rescan / command result；对于云端、同步和协作路径，这层才是 Convex。

### 4.2 Rust kernel 语义 owner

这里负责：

- 把命令结果解释为 domain event
- 把底层 mutation 变化转成树域可消费的语义事件
- 维护 projection rebuild 与 delta 生成规则

### 4.3 Rust Web WS push transport（主）+ SSE fallback

这里负责：

- 暴露正式 `/api/realtime/ws`（WebSocket push，主 chain）
- `/api/tree/events` 保留为 SSE fallback（60s 安全网），不可作为主要 transport 依赖
- 管理 workspace / subtree 订阅
- 发送 snapshot、delta、cursor、ack、resync 信号

推荐策略：

- 首屏与重连优先使用 snapshot
- 连续变化优先推送 delta
- 大规模漂移或 cursor 失配时回退到 resync snapshot

### 4.4 前端 projection consumer

这里负责：

- 接收初始 projection snapshot
- 接收增量事件
- 合并到 sidebar / filetree / page subtree cache
- 必要时触发局部重渲染

## 5. 建议事件形状

正式事件建议至少覆盖三类 scope：

- workspace
- subtree
- tree delta

### 5.1 Workspace stream

用于：

- 当前工作区根树更新
- 垃圾桶、模板、共享页等全局区域变化

建议形状：

```json
{
  "stream": "workspace",
  "workspaceId": "ws_123",
  "cursor": "evt_1001",
  "kind": "snapshot|delta|resync",
  "projection": "sidebar_tree",
  "data": {}
}
```

### 5.2 Subtree stream

用于：

- 当前文档子树
- file tree 局部展开区域
- picker 只关注的局部节点集合

建议形状：

```json
{
  "stream": "subtree",
  "workspaceId": "ws_123",
  "rootNodeId": "page_1",
  "cursor": "evt_1002",
  "kind": "snapshot|delta|resync",
  "projection": "page_tree",
  "data": {}
}
```

### 5.3 Tree delta payload

建议包含：

- `eventId`
- `commandId`
- `traceId`
- `aggregateType`
- `aggregateId`
- `op`
- `node`
- `parentNodeId`
- `position`
- `removedNodeIds`
- `changedProjectionKeys`

参考形状：

```json
{
  "eventId": "evt_1003",
  "commandId": "cmd_2001",
  "traceId": "trace_3001",
  "aggregateType": "page",
  "aggregateId": "page_9",
  "op": "create|rename|move|delete|restore|purge|embed",
  "node": {
    "id": "page_9",
    "title": "新页面"
  },
  "parentNodeId": "page_root",
  "position": 3,
  "removedNodeIds": [],
  "changedProjectionKeys": ["sidebar_tree", "file_tree"]
}
```

## 6. 与首页和首屏的关系

首页与 `/api/sidebar` 的 P0 原则必须保持不变：

- 首页不能再依赖 3104 实验壳
- `3000` 主入口必须可稳定进入 `/auth` 或文档页
- 首屏仍要有稳定 SSR snapshot，但不再通过第二条 runtime projection fallback 偷偷补拉 live 语义

也就是说：

- 首屏可用性优先于实时增强
- realtime stream 是正式增强层，不是新的首屏阻塞点

## 7. 实施顺序

推荐顺序如下：

1. 先固定首页、sidebar、picker 不再依赖 tree shell 实验壳。
2. 再固定 Rust kernel 的 tree command / projection owner 身份。
3. 然后补正式 Rust Web SSE/WS tree stream。
4. 最后把 sidebar、page subtree、filetree 逐步切到统一 stream。

## 8. 最终固定口径

长期口径固定为：

- Convex substrate 不拆
- Rust semantic owner 收口语义
- Rust Web 提供正式实时 transport
- 前端只消费 projection snapshot 与 tree delta
- iframe/postMessage tree shell 不是正式 realtime 主链


## 9. 当前实现复核（2026-05-17，WS push 迁移后更新）

这份方案继续留在 `process/`，因为 page subtree / filetree / preferred snapshot 仍未完全统一到同一条正式 live cache。**SSE transport 已降级为 fallback，主 transport 已切换为 WebSocket push**（详见 `3-14`）。

### 9.1 已完成

- [x] Rust Web 已暴露正式 `/api/realtime/ws`（WebSocket push 主链）与 `/api/tree/events`（SSE fallback），返回 `x-mnote-web-owner: mnote-web` 与 `x-mnote-tree-stream-owner: rust-web`。
- [x] `/api/tree/events` 已能输出 workspace / subtree snapshot（SSE fallback 保留）。
- [x] `stream_support.rs` 已有 cursor、delta、resync 的基础判定逻辑。
- [x] WS handler 用 `tokio::select!` 订阅 `stream_delta_tx` broadcast channel，mutation 后推送 delta 给所有客户端。
- [x] 2026-05-17 commit `57ec8322`：SSE pollMs=1000 polling 替换为 WebSocket push + SSE fallback。Convex idle 查询从 17/min 降至 0/min。
- [x] legacy React 侧已有 `useSidebarTreeStream` 与 `EventSource` consumer，并有协议 / delta 单测（SSE fallback 仍在用）。
- [x] 当前 `3000` Rust shell 已直接挂载 tree live WebSocket consumer；旧 `convex-command-log-ws` 命名只代表历史 transport 标签，默认 live cache 不再要求 Convex backend，并通过 SSE fallback 的 `data-mnote-tree-live-applied` 应用 delta / resync。
- [x] `task112` / `task120` / `task123` 已覆盖 `/api/tree/events` snapshot、delta / resync 与 stream owner 可用性。
- [x] `task165` 已验证双 pane 不重复建立第二条 tree live stream。
- [x] 2026-05-16 复核确认 workspace snapshot 同时携带 `data.dataset.kernel_sidebar_projection` 与 `data.dataset.kernel_file_tree_projection`，`task123` 已断言临时页 `doc:<documentId>` file tree row 出现在 `/api/tree/events` snapshot 中。
- [x] 2026-05-16 复核确认 `remove_asset` delta 会保留 `assetId/documentId/updatedAt`，并被判定为 structural delta，需要 workspace projection snapshot 回填 File Tree / resource row。
- [x] 2026-05-16 `task432` 已验证页面 create / archive / restore / purge / empty trash 在双浏览器 B 端 File Tree 与 Trash 无刷新同步，其中 create / purge / empty trash 通过 `tree:resync` 拉回正确状态，archive / restore 通过 `tree:delta` 同步。
- [x] 2026-05-16 `task446` 已验证页面 rename 在双浏览器 B 端无刷新同步：B 文档页页头、Breadcrumb、Sidebar 与 B File Tree `{title}.md` 均通过 `tree:delta upsert_document` 更新，rename 后 `navigationEvents=[]`。
- [x] 2026-05-16 `task447` 已验证页面 move order 在双浏览器 B 端无刷新同步：同父级 `A/B/C` 执行 `move C sortOrder=1` 后，B 文档页与 B File Tree 的 Page Tree / File Tree direct child order 均通过 `tree:delta move_document` 更新为 `A/C/B`，move 后 `navigationEvents=[]`。
- [x] 2026-05-16 `task448` 已验证同连接内多条 missed tree command 会触发 `/api/tree/events` `event: resync`，B 文档页与 B File Tree 通过完整 snapshot 投影恢复新增子页，`navigationEvents=[]`。
- [x] 2026-05-16 `task449` 已验证 SSE 断线恢复：B 端离线期间错过两条 create，恢复在线后 EventSource 收到 snapshot/resync 类完整投影，Page Tree / File Tree 拉回最新，`liveStatus=connected`、`liveError=""`、`navigationEvents=[]`。

### 9.2 归档评估（2026-05-21）

以下 9.2 的"仍未完成"项已在归档前逐条复核，确认全部过期：

- [x] **Sidebar、page subtree、filetree 统一到同一条 live stream cache。**
  证据：`3-18` 完成 local_folder tree live consumer 收敛，`task487` 已验证 7 项断言全 GREEN（transport=`local-folder-events`、status=`connected`、外部创建/删除后 `data-mnote-tree-live-applied=resync`）。`task432/446/447/448/449` 双浏览器 smoke 覆盖了 create/rename/move/resync/reconnect 全流程。

- [x] **WS 从骨架成为主实时链路。**
  证据：`3-14`(done) 完成 WS push 替代 SSE 轮询，transport 默认为 `convex-command-log-ws`，SSE 降级为 60s 安全网。`task446/447/448/449` 均在 WS push 架构上通过。

- [x] **3000 主界面 page subtree / filetree / preferred snapshot 补偿链统一验收。**
  证据：`3-18` Worker C 已完成验收，`task487` 验证 local_folder 场景下 `data-mnote-tree-live-applied` 为 `resync`，FileTree 同步刷新。

### 9.3 2026-05-16 验证记录

- `cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::stream_support -- --nocapture`：18 passed，覆盖 cursor、delta、resync、`upsert_assets`、`remove_asset` 与 structural snapshot 判定。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_events -- --nocapture`：1 passed，确认 `/api/tree/events` owner、event id 与 revision。
- `cd wolai-frontend && pnpm test src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx src/lib/tree-stream/use-sidebar-tree-stream.test.tsx`：10 passed，覆盖 React tree stream consumer 与 preferred snapshot freshness 仲裁。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js`：通过，证据 `tmp/tree-live-cache-smoke/20260516-task123/task123.stdout.json`。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_strips_tree_purge_artifacts_for_legacy_mutation -- --nocapture`：1 passed，确认 `tree.node.purge` / `documents.purge` 发往 Convex legacy mutation 前会剥离 `commandProtocol`。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js`：通过，证据 `tmp/tree-live-cache-smoke/20260516-task432/result.json`。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates -- --nocapture`：1 passed，确认 Rust SSR tree live title update 同步当前文档页 title input 与 Breadcrumb selector。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task446-tree-rename-dual-browser-live-smoke.js`：通过，证据 `tmp/tree-live-cache-smoke/20260516-task446-rename/result.json`，截图 `tmp/tree-live-cache-smoke/20260516-task446-rename/b-document-after-rename.png` 与 `tmp/tree-live-cache-smoke/20260516-task446-rename/b-filetree-after-rename.png`。
- `node --check scripts/task447-tree-move-order-dual-browser-live-smoke.js`：通过，确认 move order smoke 语法有效。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --nocapture`：1 passed，确认 Rust SSR tree local/live move apply 消费 `sortOrder` 并传入排序插入逻辑。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_move_returns_structured_payload -- --nocapture`：1 passed，确认 `/api/tree/commands` move response 保留 `sortOrder=1`。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_response_includes_rust_artifact_plan_for_domain_event -- --nocapture`：1 passed，确认 domain event 与 command log 的 `streamDelta.sortOrder=1`。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task447-tree-move-order-dual-browser-live-smoke.js`：通过，证据 `tmp/tree-live-cache-smoke/20260516-task447-move-order/result.json`，截图 `tmp/tree-live-cache-smoke/20260516-task447-move-order/b-document-after-move.png` 与 `tmp/tree-live-cache-smoke/20260516-task447-move-order/b-filetree-after-move.png`。
- `node --check scripts/task448-tree-resync-recovery-dual-browser-smoke.js`：通过，确认 resync smoke 语法有效。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task448-tree-resync-recovery-dual-browser-smoke.js`：通过，证据 `tmp/tree-live-cache-smoke/20260516-task448-resync/result.json`，截图 `tmp/tree-live-cache-smoke/20260516-task448-resync/b-document-after-resync.png` 与 `tmp/tree-live-cache-smoke/20260516-task448-resync/b-filetree-after-resync.png`。
- `node --check scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js`：通过，确认 SSE reconnect smoke 语法有效。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js`：通过，证据 `tmp/tree-live-cache-smoke/20260516-task449-reconnect/result.json`，截图 `tmp/tree-live-cache-smoke/20260516-task449-reconnect/b-document-after-reconnect.png` 与 `tmp/tree-live-cache-smoke/20260516-task449-reconnect/b-filetree-after-reconnect.png`。
