v2-refactor-temp/docs/chat/message-tree-virtual-root.md
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).
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).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:
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.flow/topicMessageFlowGraph.ts, flow/topicMessageFlowLiveTree.ts).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.
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.
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.)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.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.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.)
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:
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.
parentId IS NULL row = the virtual
root; it is content-less and never rendered.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).(parentId = root, siblingsGroupId) group.MessageService / TopicService)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.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.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.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.
null
activeNodeId. Acceptable (one tiny content-less row).getRootMessageIdTx and insert as siblings — no root race. The partial unique
index is the backstop against a buggy double-create.WHERE role = 'system' etc.) need no
special handling — the virtual root is role = 'root', so it is excluded
by construction.| 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 |
message_topic_root_uniq; regenerated
migration.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.GraphInputNode keeps a nullable
internal parentId. Tidy-up: dropped a vestigial parentId == null find in
handleClearTopicMessages (it always fell back to uiMessages[0]).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.
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.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).branch-navigation.md — branch DAG UX.MessageService,
migrators, shared message types.