Files
CherryHQ-cherry-studio/v2-refactor-temp/docs/chat/message-tree-virtual-root.md
2026-06-17 18:20:35 +08:00

237 lines
12 KiB
Markdown

# Message Tree — Per-Topic Virtual Root
**Status:** implemented on `feat/message-tree-virtual-root` (follow-up to
`#15951`). Originates from the `#15951` (chat message flows) review thread:
the reviewer questioned the `null for root sibling groups` shape and asked
for a virtual root node so the tree has a single guaranteed root (see
[Decisions](#decisions)).
## Problem
The message tree is an adjacency list (`message.parentId`) with the
convention **`parentId = null` ⟺ root**.
- `MessageService.create({ parentId: null })` enforces a single root —
it rejects a second root with *"Topic already has a root message"*
(`MessageService.ts:848`).
- But `createSibling()` on a root message **bypasses** that check: it
inserts another `parentId = null` row as a sibling
(`MessageService.ts:769`), so a topic can hold **multiple physical
roots**, grouped by `siblingsGroupId`. This is how "resend / edit the
first user message" is implemented today — as a *root sibling*.
Consequences of multiple physical roots:
- The read path special-cases root sibling groups with an
`isNull(parentId)` branch (`MessageService.ts:557`).
- `SiblingsGroup.parentId` must be nullable — the literal
`null for root sibling groups` comment at
`src/shared/data/types/message.ts:490` that started the review.
- The flow canvas carries dedicated "expand root sibling groups into
independent root trees" / "multiple root trees" logic
(`flow/topicMessageFlowGraph.ts`, `flow/topicMessageFlowLiveTree.ts`).
- The `parentId IS NULL = root` assumption is spread across **~101
(main) / 8 (shared) / 25 (renderer)** sites, each of which conflates
"the root" with "the first user message."
Product requirement (from the thread): resending the first user message
must stay in the **same topic** (DeepSeek / ChatGPT UX), not spawn a new
topic — so "forbid first-turn resend" is not an option.
## Target design — virtual root sentinel
Every topic owns exactly **one content-less virtual root message row**
(`parentId = null`). Every real conversation message hangs **below** it,
so the first user turn and its resends are ordinary siblings under a
shared parent:
```
virtual root (parentId = null, no content, never rendered)
├─ user "v1" ┐
├─ user "v2" ├─ one siblingsGroup — "resend first message" = a normal sibling
└─ user "v3" ┘
└─ assistant → user → assistant → …
```
This makes first-turn resend **structurally identical** to any other
sibling creation, and the single-root guarantee becomes a DB invariant
instead of application discipline.
### Decisions
1. **Dedicated `role = 'root'`, no marker column.** The virtual root is a
self-identifying `role = 'root'` row (`data = { parts: [] }`,
`status = 'success'`, `siblingsGroupId = 0`), exactly one per topic.
`role = 'root'` and `parentId IS NULL` are equivalent — `parentId IS NULL`
stays the root *lookup* key (what `message_topic_root_uniq` covers) and
`createRootMessageTx` / the migrator are the sole writers of both. Because
the role is dedicated, role-filtered *content* queries (`WHERE role = 'system'`
etc.) exclude the root for free — no `parentId IS NOT NULL` caveat. (A
separate discriminator column was rejected: it would have to be threaded
through every query/type; extending the role enum is lighter and
self-describing.)
2. **Eager creation.** The virtual root is inserted in the **same
transaction that creates the topic**, so every topic has its root from
birth. No lazy "ensure-on-first-message" branch.
3. **Explicit create + read, not an idempotent ensure.** Every topic-creation
path calls `createRootMessageTx` (pure insert); message-creation paths call
`getRootMessageIdTx` (read, throws if absent). No create-if-missing in message
paths — a missing root is a loud bug (a creation path forgot it), not silently
papered over.
4. **getTree exposes the real parent; tree `parentId` is non-null.** A first turn
keeps its real parent — the topic's virtual root — in the `getTree` response
(no re-null), so `SiblingsGroup.parentId` and `TreeNode.parentId` are non-null
`string`, eliminating the `null for root sibling groups` shape that prompted the
review. The virtual root is never returned as a tree node; the flow-graph edge
builder skips edges whose parent isn't a rendered node, so first turns still
render as graph roots. Non-null is established by control-flow narrowing (a guard
in `messageToTreeNode`, a skip in the live builder), not assertions. (An earlier
draft re-nulled at the boundary to avoid touching the renderer — dropped because
it kept the `null` shape and the live-tree merge fed virtual-root parentIds into
the canvas anyway, so the edge guard was needed regardless.)
> `topic.rootMessageId` was considered and **rejected**: the partial
> unique index below already (a) guarantees a single root and (b) gives
> indexed O(1) access via `WHERE topic_id = ? AND parent_id IS NULL`. A
> pointer column would only duplicate a derivable fact and add a sync
> burden on create/delete/migrate. (Contrast `topic.activeNodeId`, which
> is genuine non-derivable navigation state and stays.)
### Schema (`src/main/data/db/schemas/message.ts`)
- Redefine `parentId IS NULL` to mean **only the virtual root**; all
content messages get a non-null `parentId`.
- Add a partial unique index — the actual single-root guarantor and the
root-access index in one:
```sql
CREATE UNIQUE INDEX message_topic_root_uniq ON message(topic_id)
WHERE parent_id IS NULL;
```
- The existing self-FK (`parentId → message.id`, `ON DELETE SET NULL`)
and `message_role_check` are unchanged.
No `topic` schema change. v2 schemas are throwaway, so this lands as a
regenerated migration, not a patch.
### Invariants
- Each topic has **exactly one** `parentId IS NULL` row = the virtual
root; it is content-less and **never rendered**.
- Every content message (`user` / `assistant` / `system`) has a non-null
`parentId`. First-turn user messages have `parentId = <virtual root>`.
- `activeNodeId` never points at the virtual root (it is `null` for an
empty topic, otherwise a content message).
- The "root sibling" concept no longer exists — first-turn siblings are a
normal `(parentId = root, siblingsGroupId)` group.
### Write paths (`MessageService` / `TopicService`)
- Every topic-creation path inserts the virtual root via
`MessageService.createRootMessageTx(tx, topicId)` (pure insert):
`TopicService.create`, `TopicService.duplicate`, and `TemporaryChatService`
persist. The v1→v2 `ChatMigrator` builds the same row inline per topic
(batch insert) and reparents former physical roots onto it, so migrated
topics match freshly created ones.
- Message-creation paths resolve the parent via `getRootMessageIdTx(tx, topicId)`
(read + throw-if-missing): `MessageService.create` (`parentId: undefined` on an
empty topic / explicit `null`), `createUserMessageWithPlaceholders`, and
`copyPathRowsTx` (destination root). The *"Topic already has a root message"* /
*"…no activeNodeId"* error branches are **deleted**.
- `createSibling()`: the source `parentId` is now always non-null, so the
root-sibling special case disappears; it becomes a uniform insert.
### Read paths
- `getPathRowsToNodeTx` (`:508`) walks up, stops at the virtual root, and
**excludes it** from the returned path (the displayed conversation
starts at the first user message).
- `getBranchMessages`: first-turn siblings now have `parentId = <virtual root>`,
so they match the normal `eq(parentId, …)` sibling path (the `isNull` branch is
simply never hit, since the path excludes the virtual root).
- `getTree`: fetch the virtual root, drop it from the active path, and treat its
children as the logical roots. First-turn nodes keep their **real** parent (the
virtual root id) — no re-null — and the virtual root is never returned as a node.
So `SiblingsGroup.parentId` and `TreeNode.parentId` are non-null `string`
(`message.ts` drops `| null` on both); `messageToTreeNode` guards the (impossible)
null parent to narrow without an assertion.
### Renderer
The flow canvas needs **one** change: the edge builder
(`flow/topicMessageFlowGraph.ts`) skips edges whose parent isn't a rendered node —
the virtual root, which first turns hang off but which is never a node — so first
turns still render as graph roots. `GraphInputNode` keeps a nullable internal
`parentId` (null = "no rendered parent"). The live builder
(`topicMessageFlowLiveTree.ts`) skips a parentless row (never occurs) so its node
`parentId` is non-null too. This edge guard was needed regardless: the live-tree
merge feeds the real (virtual-root) parentId into the canvas, so re-nulling in
`getTree` alone never sufficed — which is why Option X (above) is both cleaner and
the only consistent option.
## Edge cases
- **Empty / never-used topic:** holds just the virtual root + `null`
`activeNodeId`. Acceptable (one tiny content-less row).
- **Concurrent first messages:** the virtual root already exists (created in the
topic's creation tx), so racing first messages both resolve it via
`getRootMessageIdTx` and insert as siblings — no root race. The partial unique
index is the backstop against a buggy double-create.
- **Multi-model first turn:** unchanged — N assistant placeholders are
children of the (now non-root) first user message.
- **Role-filtered content queries** (`WHERE role = 'system'` etc.) need no
special handling — the virtual root is `role = 'root'`, so it is excluded
by construction.
## Alternatives considered
| Alternative | Why rejected |
|---|---|
| **Synthetic (presentation-only) root** — keep `parentId = null` roots in DB, fabricate a single root only in the tree layer | Does not give the *DB-level* single-root guarantee the reviewer asked for; the multi-root data shape and scattered assumptions remain |
| **`topic.rootMessageId` pointer** | Redundant with the partial unique index (which already guarantees + indexes the root); adds a sync burden — rejected in-thread |
| **`parentId = topicId` (topic *is* the root)** | Breaks the `parentId → message.id` self-FK |
| **Forbid first-turn resend (treat as new topic)** | Violates the same-topic product requirement |
## Phased plan & blast radius
1. **Schema** ✅ — partial unique index `message_topic_root_uniq`; regenerated
migration.
2. **Service** ✅ — `createRootMessageTx` (topic-creation paths) + `getRootMessageIdTx`
(message paths); rewire `create` / `createSibling` /
`createUserMessageWithPlaceholders` / `getPathRowsToNodeTx` / `getBranchMessages`
/ `getTree` / `copyPathRowsTx` / `duplicate` / temp-chat; delete root-sibling
special cases. Tests updated + invariant coverage added.
3. **Renderer** ✅ — flow-graph edge guard (skip edges to the unrendered virtual
root) + live-builder skip of parentless rows; `GraphInputNode` keeps a nullable
internal parentId. Tidy-up: dropped a vestigial `parentId == null` find in
`handleClearTopicMessages` (it always fell back to `uiMessages[0]`).
4. **Types** ✅ — `SiblingsGroup.parentId` and `TreeNode.parentId` are now non-null
`string`; the `null for root sibling groups` shape is **removed** (the reviewer's
original concern), since first-turn groups carry the virtual root as their parent.
Done as a follow-up to `#15951`, separate from it.
## Validation
- `MessageService.test.ts` — root-sibling cases rewritten as virtual-root child
siblings; invariant coverage added: topic-create inserts exactly one root, a
second `createRootMessageTx` hits `message_topic_root_uniq`, two `parentId:null`
creates become siblings under one root (not two physical roots), `getPath`
excludes the root, `getTree` keeps first-turn `parentId` = the virtual root id.
- `TopicService` / `TemporaryChatService` / `PersistentChatContextProvider` /
`ChatMigrator` / orphan-checker suites — seed fixtures moved to the single-root
model (one virtual root per topic) via the shared `rootRow`/`withRoot` helper in
`@test-helpers/db`.
- Flow-canvas suites (`topicMessageFlowGraph` / `LiveTree`) — fixtures updated for
non-null `parentId` (roots use the virtual-root sentinel); `ChatContent.test.tsx`
is unchanged, and the first-message edit+resend test still passes (sibling under
the virtual root via backend `createSibling`).
- Full data-layer sweep green (2216 tests); node + web typecheck 0.
## Related
- [`branch-navigation.md`](../ai/branch-navigation.md) — branch DAG UX.
- [Data Layer cluster](../ai/data-cluster.md) — `MessageService`,
migrators, shared message types.