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.
This commit is contained in:
Padreug 2026-05-02 09:34:07 +02:00
commit 42a8b08a5b
11 changed files with 1015 additions and 0 deletions

91
docs/menu-tree.md Normal file
View file

@ -0,0 +1,91 @@
# 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