LearnNewsExamplesServices
Frontmatter
id17840
titleTreeStore offers no idempotent way to materialise a nested path
stateClosed
labels
enhancementaiarchitecturecore
assigneesneo-opus-ada
createdAtAug 29, 2026, 1:25 AM
updatedAtAug 29, 2026, 7:53 PM
githubUrlhttps://github.com/neomjs/neo/issues/17840
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 29, 2026, 7:53 PM

TreeStore offers no idempotent way to materialise a nested path

Closed v13.2.0/archive-v13-2-0-chunk-16 enhancementaiarchitecturecore
neo-opus-ada
neo-opus-ada commented on Aug 29, 2026, 1:25 AM

Context

Split out of #17839 rather than added to it, so each ticket resolves in one PR. #17839 teaches menu.List to render a parentId hierarchy from a TreeStore. This ticket covers building that hierarchy from path-addressed contributions — a src/data/ concern, not a menu one.

Naming it separately was requested explicitly during design review with @neo-opus-grace, on the grounds that discovering it mid-implementation is worse than scoping it up front: without it, a consumer whose entries arrive as paths still cannot construct the tree #17839 renders.

Live latest-open sweep: checked the latest 20 open issues at 2026-08-28T23:25:01Z; no equivalent found.

The Problem

Plugin and module architectures address hierarchy by path, not by parent key. A contributor declares where it belongs — 'Group', 'A/B/C' — without knowing which siblings exist or which of them already created the intermediate groups.

Two properties make this awkward against a parentId store:

  1. Intermediate nodes have no declaring record. 'A/B/C' implies A and A/B exist, but nobody declares them. A parentId store needs them to exist as records before a child can reference one.
  2. Materialisation must be idempotent and order-independent. Two contributors declaring 'Group/A' and 'Group/B' — in either order, at any time during load — must converge on one Group node. Materialising twice must not produce two.

Absent a primitive, every consumer writes the same recursive assembler over a Map and rebuilds wholesale on each change. #17839 documents that pattern appearing independently in more than one codebase.

The Architectural Reality

Neo.data.TreeStore (src/data/TreeStore.mjs) maintains its Structural Layer in #childrenMap (:72-75), keyed by parentId, and Neo.data.TreeModel (src/data/TreeModel.mjs:29-71) declares parentId, depth, isLeaf, childCount, collapsed, siblingCount, siblingIndex. The store already owns the invariants that materialisation must preserve — depth, leaf state, sibling counts — which is why this belongs to the data layer rather than to any consumer.

The direct precedent is src/data/normalizer/Tree.mjs, which exists to perform the analogous transform: nested children arrays from external APIs into the flat parentId shape the TreeStore consumes. Its class docblock states the role explicitly. Path-string → flat parentId records is the same category of transform against a different input encoding, and its natural home is alongside it.

The Fix

Provide a single materialisation entry point that accepts a path and a leaf payload, creates any missing ancestors exactly once, and returns the leaf.

  • Substrate: src/data/normalizer/ sibling, or a TreeStore method delegating to it. Directory choice follows the existing normalizer precedent; no novel placement.
  • Ancestor identity must derive deterministically from the path prefix, so the same prefix always resolves to the same node regardless of who materialises it first.
  • Separator is configurable, defaulting to /; path segments must be escapable so a segment may legitimately contain the separator.
  • Materialisation must maintain the store's own invariants (depth, isLeaf, childCount, sibling fields) rather than writing records that bypass them.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Neo.data.normalizer.Tree src/data/normalizer/Tree.mjs Unmodified; the new path normalizer is a sibling, not a change to it n/a learn/guides/datahandling/TreeStore.md Verified: file exists, docblock states the nested-children → parentId role
Neo.data.TreeStore src/data/TreeStore.mjs Gains a materialise-by-path entry point; Structural Layer semantics unchanged Existing record-level add API stays the primary path TreeStore guide Verified: #childrenMap at :72-75
Neo.data.TreeModel fields src/data/TreeModel.mjs:29-71 Maintained by materialisation, not redefined n/a TreeStore guide Verified present: parentId, depth, isLeaf, childCount, collapsed, siblingCount, siblingIndex
Neo.data.normalizer.Path new Public sibling of normalizer.Tree. normalize(data) for bulk path-addressed entries; materialize(path, data, exists) emits ancestors-first; splitPath(path) exposes the prefix descriptors n/a — additive class JSDoc + TreeStore guide Shipped in PR #17854; placement precedent is normalizer/Tree.mjs
Neo.data.normalizer.Path grammar configs new separator and escapeChar are exactly one UTF-16 code unit and must differ, validated on set; keyProperty, nameProperty, pathProperty are field-name Strings invalid grammar throws at construction rather than parsing a different tree field JSDoc + guide Reviewer falsifier: '', '::' and separator === escapeChar each parsed silently before validation
Neo.data.TreeStore#splice sort behaviour src/data/TreeStore.mjs A mutation with no visible delta re-applies active sorters to the touched levels before updateSiblingStats, so getChildren() honours its documented sort order on a collapsed branch. Gated on autoSort && sorters.length, matching every sort trigger in Collection.Base autoSort: false or no sorters keeps declaration order, unchanged, on the hidden and visible paths alike TreeStore guide Pre-existing divergence, reachable by plain sequential add(); control spec pins that arm

Decision Record impact

none. Same review as #17839: no ADR in learn/agentos/decisions/ governs data-store hierarchy.

Acceptance Criteria

  • Materialising 'A/B/C' creates A, A/B and the C leaf, correctly parented, in one call.
  • Materialising 'Group/A' then 'Group/B' produces one Group node with two children — and the reverse order produces the identical store state.
  • Re-materialising an existing path is a no-op: no duplicate nodes, no duplicate leaf.
  • Ancestor nodes carry correct depth, isLeaf: false and childCount; leaves carry isLeaf: true.
  • Sibling fields (siblingIndex, siblingCount) are correct after mixed-order insertion.
  • A path segment containing an escaped separator is treated as one segment.
  • Incremental insertion after initial load works and emits the store's normal mutation events — no full rebuild.
  • Unit coverage run via the repo's explicit Playwright configs (not bare npx playwright test).
  • Guide coverage in learn/guides/datahandling/TreeStore.md.

Out of Scope

  • Rendering. menu.List consuming a TreeStore is #17839.
  • Removing or deprecating any existing TreeStore add/load API. This is an addition.
  • Reconciling deletions — removing a leaf does not garbage-collect now-empty ancestors. Deliberately excluded: whether an empty group should disappear is a consumer policy question, not a store invariant, and guessing it here would bake one consumer's answer into the engine.
  • Ordering policy for materialised siblings beyond determinism. #17839 owns the menu-facing ordering AC.

Avoided Traps / Gold Standards Rejected

Rejected: putting this in src/menu/. It would make a data transform reachable only through a component, and the next path-addressed consumer that is not a menu would reimplement it. The normalizer/ precedent already establishes where input-shape transforms live.

Rejected: deriving ancestor ids from insertion counters. Ids must be a deterministic function of the path prefix; otherwise two contributors racing the same prefix produce two nodes, which is precisely the defect this ticket removes.

Trap: bypassing store invariants. Writing ancestor records directly into the Structural Layer without maintaining depth / childCount / sibling fields produces a store that renders correctly and reports ARIA incorrectly — the same silent, view-invisible failure mode as the dead isTreeGrid inference noted in #17839.

Related

  • #17839 — the rendering half; this ticket is its named Out-of-Scope follow-up
  • #9405 / #9406 — data.TreeModel / data.TreeStore

Handoff Retrieval Hints

  • query_raw_memories: "idempotent path materialisation TreeStore parentId ancestors grouping path"
  • Key anchors: src/data/normalizer/Tree.mjs (the precedent transform), src/data/TreeStore.mjs:72-75 (#childrenMap), src/data/TreeModel.mjs:29-71 (field set)
  • Design inputs came from two independent consumers of the path-addressed contribution shape; the awkward parts are enumerated in #17839's "Design inputs from a second real consumer".

Origin Session ID: 16636a26-a8e1-4f27-9c7b-9365f5000c0f