Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: jdzhang <625013594@qq.com> Co-authored-by: jd <59188306+zhangjiadi225@users.noreply.github.com> Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> Signed-off-by: suyao <sy20010504@gmail.com> Signed-off-by: jdzhang <625013594@qq.com>
12 KiB
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).
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 anotherparentId = nullrow as a sibling (MessageService.ts:769), so a topic can hold multiple physical roots, grouped bysiblingsGroupId. 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.parentIdmust be nullable — the literalnull for root sibling groupscomment atsrc/shared/data/types/message.ts:490that 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 = rootassumption 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
- Dedicated
role = 'root', no marker column. The virtual root is a self-identifyingrole = 'root'row (data = { parts: [] },status = 'success',siblingsGroupId = 0), exactly one per topic.role = 'root'andparentId IS NULLare equivalent —parentId IS NULLstays the root lookup key (whatmessage_topic_root_uniqcovers) andcreateRootMessageTx/ 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 — noparentId IS NOT NULLcaveat. (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.) - 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.
- Explicit create + read, not an idempotent ensure. Every topic-creation
path calls
createRootMessageTx(pure insert); message-creation paths callgetRootMessageIdTx(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. - getTree exposes the real parent; tree
parentIdis non-null. A first turn keeps its real parent — the topic's virtual root — in thegetTreeresponse (no re-null), soSiblingsGroup.parentIdandTreeNode.parentIdare non-nullstring, eliminating thenull for root sibling groupsshape 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 inmessageToTreeNode, 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 thenullshape and the live-tree merge fed virtual-root parentIds into the canvas anyway, so the edge guard was needed regardless.)
topic.rootMessageIdwas considered and rejected: the partial unique index below already (a) guarantees a single root and (b) gives indexed O(1) access viaWHERE 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. (Contrasttopic.activeNodeId, which is genuine non-derivable navigation state and stays.)
Schema (src/main/data/db/schemas/message.ts)
-
Redefine
parentId IS NULLto mean only the virtual root; all content messages get a non-nullparentId. -
Add a partial unique index — the actual single-root guarantor and the root-access index in one:
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) andmessage_role_checkare 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 NULLrow = the virtual root; it is content-less and never rendered. - Every content message (
user/assistant/system) has a non-nullparentId. First-turn user messages haveparentId = <virtual root>. activeNodeIdnever points at the virtual root (it isnullfor 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, andTemporaryChatServicepersist. The v1→v2ChatMigratorbuilds 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: undefinedon an empty topic / explicitnull),createUserMessageWithPlaceholders, andcopyPathRowsTx(destination root). The "Topic already has a root message" / "…no activeNodeId" error branches are deleted. createSibling(): the sourceparentIdis 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 haveparentId = <virtual root>, so they match the normaleq(parentId, …)sibling path (theisNullbranch 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. SoSiblingsGroup.parentIdandTreeNode.parentIdare non-nullstring(message.tsdrops| nullon both);messageToTreeNodeguards 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 +
nullactiveNodeId. 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
getRootMessageIdTxand 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 isrole = '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
- Schema ✅ — partial unique index
message_topic_root_uniq; regenerated migration. - Service ✅ —
createRootMessageTx(topic-creation paths) +getRootMessageIdTx(message paths); rewirecreate/createSibling/createUserMessageWithPlaceholders/getPathRowsToNodeTx/getBranchMessages/getTree/copyPathRowsTx/duplicate/ temp-chat; delete root-sibling special cases. Tests updated + invariant coverage added. - Renderer ✅ — flow-graph edge guard (skip edges to the unrendered virtual
root) + live-builder skip of parentless rows;
GraphInputNodekeeps a nullable internal parentId. Tidy-up: dropped a vestigialparentId == nullfind inhandleClearTopicMessages(it always fell back touiMessages[0]). - Types ✅ —
SiblingsGroup.parentIdandTreeNode.parentIdare now non-nullstring; thenull for root sibling groupsshape 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 secondcreateRootMessageTxhitsmessage_topic_root_uniq, twoparentId:nullcreates become siblings under one root (not two physical roots),getPathexcludes the root,getTreekeeps first-turnparentId= 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 sharedrootRow/withRoothelper in@test-helpers/db.- Flow-canvas suites (
topicMessageFlowGraph/LiveTree) — fixtures updated for non-nullparentId(roots use the virtual-root sentinel);ChatContent.test.tsxis unchanged, and the first-message edit+resend test still passes (sibling under the virtual root via backendcreateSibling). - Full data-layer sweep green (2216 tests); node + web typecheck 0.
Related
branch-navigation.md— branch DAG UX.- Data Layer cluster —
MessageService, migrators, shared message types.