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

91 lines
3.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```sql
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 separate
`UPDATE 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
- [[data-model]] — columns + indexes
- [[cms]] — the q-tree builder UI
- [[nostr-layer]] — ancestor `t` tags
- [[adr-0001-menu-tree]] — adjacency vs. closure trade-off