restaurant/docs/menu-tree.md
Padreug 42a8b08a5b docs: Obsidian-style vault under docs/
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.
2026-05-09 07:11:06 +02:00

3.3 KiB
Raw Permalink Blame History

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_id and 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)

  • 1slices 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):

  1. SELECT * FROM menu_nodes WHERE restaurant_id = :rid ORDER BY depth, sort_order, time
  2. SELECT * FROM menu_items WHERE restaurant_id = :rid
  3. Build by_id: dict[id, MenuNode] in Python.
  4. Walk rows, attaching each non-root to by_id[parent_id].children.
  5. Walk items, attaching each to by_id[node_id].items.

For typical restaurant sizes (550 nodes, 10200 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