tag-hierarchy
v0.2.1
Published
A tiny, zero-dependency projection kernel: flat tagged items + a declarative query → an on-demand hierarchy tree. Pure, immutable, deterministic.
Maintainers
Readme
tag-hierarchy
A hierarchy is a lossy projection of a flat tagged relation.
A tiny, zero-dependency, framework-agnostic projection kernel. Hand it a flat set
of { id, tags } items plus a declarative query and it returns a tree — projected on
demand. Storage stays flat; structure, grouping, and ordering all fall out of one tag
mechanism.
import { tagQuery } from 'tag-hierarchy';
const items = [
{ id: '1', tags: ['project:auth', 'status:open', 'prio:003'] },
{ id: '2', tags: ['project:auth', 'status:done', 'prio:001'] },
{ id: '3', tags: ['project:ui', 'status:open', 'prio:002'] },
];
// Group open items by project, ordered by priority.
const tree = tagQuery(
{ all: ['status:open'], else: 'keep', order: { by: 'tag', prefix: 'prio:' } },
items,
);The query grammar
A query is a plain object; every field is optional. It is declarative and order-free — an LLM (or a human) can author it and read it back to explain exactly what a view does.
{
all?: string[] // AND-set: an item must carry ALL of these tags to match here
then?: Query[] // sub-queries that refine the matched set (each claims its items)
else?: 'drop' | 'keep' // what happens to the remainder (default 'drop')
order?: OrderSpec // how to order the LEAVES at this level
rest?: boolean // deprecated alias of `else` (true = 'keep')
}At one level, over the items in scope:
allfilters into the items that carry every listed tag, and the remainder.thensub-queries refine the matched set, left to right. Each sub-query claims the items it places — a claimed item is removed before the next sub-query runs, so nothing leaks forward and nothing is ever duplicated.- The leftover matched items (claimed by no sub-query): at a node without
thenthey are the node's own content — leaves here, or auto-grouped by highest-frequency tag withelse:'keep'. At a node withthen, the branches define the partition: unclaimed items join the remainder below and follow the sameelsegate. - The remainder (non-matching items, plus unclaimed matched items when
thenis present): withelse:'keep'it is brought back under one sibling node markedrest: true(it doesn't carryall, so it can't sit underneath), auto-grouped by frequency inside that bucket; withelse:'drop'(the default) it is filtered out.
A sub-query (branch) inside then contributes only the subtree of items it matches: its
own else is purely local — it organizes that matched content ('keep' auto-groups it,
'drop' flattens it) and a branch never produces a rest node. Items a branch doesn't match
fall through to the remainder above, gated solely by the owning node's else. So the
outermost query's else is the single authority over the global remainder.
So else:'keep' means “be exhaustive and organized” — everything not explicitly placed
lands in one clearly-partitioned “everything else” bucket a renderer can style
distinctly. { else: 'keep' } alone groups the whole input by tag frequency;
{ all: ['x'] } is a plain filter. (rest: boolean is the deprecated 0.1.x spelling —
still accepted, but not alongside else.)
Collapsing pass-through chains
The root is always a tags: [] container (a deliberate non-goal to root the tree at
the query's AND-set — the uniform shape is worth one level), and nested refinements can
produce chains of nodes with no leaves and a single child. If that costs your renderer
depth, collapse them:
import { collapseChains } from 'tag-hierarchy';
const flat = collapseChains(tree); // pure; merges each chain into one node carrying
// the combined AND-set, renumbers depths, and
// preserves any rest:true markerOrdering is tag-dependent
Sort is not a separate engine — it is choosing which tag-facet drives the order.
{ by: 'id' } // stable, ascending by id (default)
{ by: 'tag', prefix: 'date-', dir?: 'asc' } // by the value of the date- tag, lexically
{ by: 'tagFrequency', dir?: 'asc' } // by how common an item's tags areLexically-sortable tags are the sort key: pad them — date-2026-02-15, prio:003 —
and string order is semantic order. Items with no matching tag sort last.
Guarantees
- Pure & deterministic — no IO, no clock, no randomness; same inputs → same frozen tree, independent of input order.
- No duplication — an id never appears in two leaves. With
else:'keep'at every filtering level (or noall), every input id appears in exactly one leaf. - Tags invariant — every leaf under a node carries all of that node's tags.
- Stack-safe at scale — the data-driven grouping and the final freeze are iterative, so high-cardinality inputs don't overflow the call stack.
- Fail-loud — a malformed query throws
TagQueryErrorwith a precise path; never a silent drop.
The kernel knows only { id, tags }. Express everything else as tags — status:open,
parent:42, date-2026-02-15 — and the one mechanism covers filtering, grouping, and
ordering. Visibility is the caller's job: pass only the items the viewer may see and the
projection inherits the boundary.
For LLM agents
The grammar is small, order-free, and fail-loud, so a model can author a query, read the error if it's wrong, and self-correct. Two aids ship in the box:
LLM-USAGE.md— an example-first usage card (realdata → treeoutputs plus the rules small models trip on). Paste it into context.queryJsonSchema— a JSON Schema (draft 2020-12) for the query, exported from the package, for constrained / structured output:import { queryJsonSchema } from 'tag-hierarchy';
Develop
npm run typecheck # tsc --noEmit (strict)
npm test # node --test via tsx
npm run build # emit dist/ + .d.tsLineage
A clean-room redesign of the 2018 trokster/tagQuery
(MIT), by the same author — same spirit (flat tags projected into an on-demand tree, a
leading AND-set, nestable refinement, a "keep the rest" branch auto-grouped by frequency),
with sound, pure semantics and a declarative grammar.
License
MIT.
