Add a navigable Obsidian vault as the project's first-class technical documentation. Notes cross-reference with [[wikilinks]]; docs/index.md is the Map of Content. New notes: index.md MOC, entry point architecture.md what the extension owns vs what lives outside data-model.md entity-by-entity schema reference menu-tree.md the arbitrary-depth tree concept order-flow.md state machine + invoice listener + print nostr-layer.md kinds 0/30402/5/1059, signing, t-tags api-reference.md endpoint catalog by audience cms.md Vue 3 + Quasar 2 UMD conventions, q-tree webapp-integration.md multi-restaurant cart pattern + atomicity glossary.md domain terms Existing notes (kept as-is): adr-0001-menu-tree.md the storage choice rationale design-conversation.md trimmed transcript README.md adds a Documentation section pointing at docs/index.md with the headline note list. Each note links to ~3-5 others; the vault forms a connected graph. A project-level memory rule (saved outside the repo) commits us to keeping these docs in sync as the code evolves: any commit that materially changes schema, API, order flow, Nostr surface, CMS conventions, or webapp integration must update the relevant note(s) in the same commit.
3.3 KiB
Menu tree
Real menus are nested:
Drinks → Hot Beverages → Coffee-based → Espressos. The extension
models this as a single self-referential menu_nodes table.
Storage shape
Adjacency list (parent_id self-FK) plus two denormalized columns:
path—'rootid'or'rootid/childid/...'(slash-separated ids).depth— integer 0..3 (zero-indexed; cap is 4 levels).
See data-model for the column list and adr-0001-menu-tree for
why this shape was chosen over a closure table or ltree.
Rules
- Max depth is 4 (
MAX_MENU_DEPTH = 3). Creates that would exceed the cap return HTTP 400. Moves that would push descendants past the cap also return 400. - Items can attach to any node — the leaf-only constraint of the legacy two-level shape is gone. A "Drinks" node can hold its own drinks AND nest sub-categories below it.
- Cycle prevention on move: the new parent's path must not contain the moved node's id.
- Cascade-delete detaches items rather than wiping them. Items
carry
nostr_event_idand revenue history, so the operator re-homes orphans through the cms rather than losing them.
Operations
| Op | Cost | How |
|---|---|---|
| Children of node | O(log n) | WHERE parent_id = :id (indexed) |
| Subtree of node | O(log n) | WHERE path LIKE :p || '%' (indexed) |
| Ancestors of node | O(depth) | split path, fetch by id |
| Cycle check on move | O(depth) | id in new parent's path.split('/') |
| Max-depth check | O(1) | integer compare |
| Move subtree | one statement | path = new_prefix || SUBSTR(path, len(old)+1) |
| Build full tree for restaurant | O(n+m) Python | one SELECT * → assemble in memory |
Move
The load-bearing operation. Single-statement subtree rewrite:
UPDATE restaurant.menu_nodes
SET path = :new_prefix || SUBSTR(path, :old_len + 1),
depth = depth + :delta
WHERE path = :old_path
OR path LIKE :old_path || '/%'
SUBSTR is 1-indexed on both SQLite and Postgres, so `len(old_path)
- 1
slices the old prefix off correctly. Followed by a separateUPDATE menu_nodes SET parent_id = :new_pid WHERE id = :node_id` for the moved root (descendants keep their parent_id; only paths + depths shift).
Implementation lives in crud.move_menu_node.
Tree assembly
Customers and the cms both want the whole tree in one call.
crud.get_menu_tree(restaurant_id):
SELECT * FROM menu_nodes WHERE restaurant_id = :rid ORDER BY depth, sort_order, timeSELECT * FROM menu_items WHERE restaurant_id = :rid- Build
by_id: dict[id, MenuNode]in Python. - Walk rows, attaching each non-root to
by_id[parent_id].children. - Walk items, attaching each to
by_id[node_id].items.
For typical restaurant sizes (5–50 nodes, 10–200 items) this is microseconds. Identical on SQLite + Postgres, no recursive CTE needed.
Items at non-leaf levels
A node can have BOTH children AND items attached. The cms
renders both at each level. The nostr-layer tags items with
their full ancestor chain (root-first, slugified) so a customer
filtering for #t=hot-beverages finds everything under that branch
regardless of how deeply it nests.
See also
- data-model — columns + indexes
- cms — the q-tree builder UI
- nostr-layer — ancestor
ttags - adr-0001-menu-tree — adjacency vs. closure trade-off