docs: README + ADR for menu tree refactor

README.md
  - Update intro: 'menu tree' is now arbitrary-depth (cap 4
    levels), items can attach to any node.
  - Update Nostr publisher description to mention ancestor 't'
    tags (slugified, root-first) so clients can filter on
    #t=hot-beverages, #t=coffee-based, etc.
  - Replace the Data model table's categories/subcategories rows
    with a single menu_nodes row that explains the adjacency-list
    + materialized-path + depth shape and points at the ADR.
  - Replace the boilerplate 'full CRUD for categories,
    subcategories, ...' line with a real menu_nodes API list,
    including the cascade-detach behavior on delete and the
    rename-triggers-subtree-republish behavior on update.

docs/adr-0001-menu-tree.md
  - New ADR explaining the storage choice (adjacency list +
    materialized path + denormalized depth), the alternatives
    considered (closure table, Postgres ltree, pure adjacency,
    nested set), and the consequences. Provides the rationale
    so future contributors don't relitigate the decision.
This commit is contained in:
Padreug 2026-05-02 09:14:26 +02:00
commit 7f7915a041
2 changed files with 186 additions and 11 deletions

View file

@ -12,9 +12,11 @@ restaurants for a festival — can subscribe and stay live.
**A CMS for restaurant operators.** One LNbits account can host one or **A CMS for restaurant operators.** One LNbits account can host one or
many restaurants under the same login. Each restaurant carries its own many restaurants under the same login. Each restaurant carries its own
profile, menu tree (categories → subcategories → items), modifier profile, an arbitrary-depth **menu tree** (capped at 4 levels — e.g.
groups (required choices and optional addons, single- or multi-select), *Drinks → Hot Beverages → Coffee-based → Espressos*) where items can
per-item availability windows, inventory, and Nostr identity. attach to **any** node (not just leaves), modifier groups (required
choices and optional addons, single- or multi-select), per-item
availability windows, inventory, and Nostr identity.
**A REST API.** Public read endpoints serve menu trees and item **A REST API.** Public read endpoints serve menu trees and item
details; gated write endpoints (admin key) handle CRUD; an unauthenticated details; gated write endpoints (admin key) handle CRUD; an unauthenticated
@ -23,8 +25,10 @@ order placement endpoint accepts carts and returns a Lightning invoice.
**A Nostr publisher.** Menu items are published as NIP-99 classified **A Nostr publisher.** Menu items are published as NIP-99 classified
listings (kind 30402, parameterized replaceable) every time they're listings (kind 30402, parameterized replaceable) every time they're
created or edited; restaurant profiles are kind 0 metadata; deletions created or edited; restaurant profiles are kind 0 metadata; deletions
are NIP-09. Tags carry structured price, dietary flags, allergens, and are NIP-09. Tags carry structured price, the ancestor category chain
ingredients so subscribers can filter without parsing markdown. (slugified, root-first, so clients can filter on `#t=hot-beverages`
etc.), dietary flags, allergens, and ingredients so subscribers can
filter without parsing markdown.
**An order pipeline.** Every cart placed against this restaurant **An order pipeline.** Every cart placed against this restaurant
becomes one order with snapshotted line-item prices and selected becomes one order with snapshotted line-item prices and selected
@ -102,10 +106,13 @@ Each restaurant's LNbits instance:
| Table | Purpose | | Table | Purpose |
| --------------------- | ------------------------------------------------------ | | --------------------- | ------------------------------------------------------ |
| `restaurants` | One row per restaurant. Owns a wallet + Nostr pubkey. | | `restaurants` | One row per restaurant. Owns a wallet + Nostr pubkey. |
| `categories` | Top-level menu sections. | | `menu_nodes` | The menu tree. Self-referential (`parent_id`); carries |
| `subcategories` | Optional second level under a category. | | | denormalized `path` + `depth` for cheap subtree ops. |
| `menu_items` | Items, with structured dietary/allergens/ingredients, | | | Capped at 4 levels. |
| | images, stock, availability, Nostr event id. | | `menu_items` | Items keyed to a node (`node_id`). Structured |
| | dietary / allergens / ingredients, images, stock, |
| | Nostr event id. Items can attach to **any** node, not |
| | just leaves. |
| `modifier_groups` | Choice groups (`required`/`optional`, `one`/`many`). | | `modifier_groups` | Choice groups (`required`/`optional`, `one`/`many`). |
| `modifiers` | Individual options with `price_delta`. | | `modifiers` | Individual options with `price_delta`. |
| `availability_windows`| Per-item time-of-day + weekday availability. | | `availability_windows`| Per-item time-of-day + weekday availability. |
@ -114,6 +121,15 @@ Each restaurant's LNbits instance:
| `print_jobs` | Thermal printer queue with retry tracking. | | `print_jobs` | Thermal printer queue with retry tracking. |
| `settings` | Per-instance toggles (Nostr publish, auto-accept, …). | | `settings` | Per-instance toggles (Nostr publish, auto-accept, …). |
The menu is an **adjacency list with denormalized materialized path**:
each node has a `parent_id` self-FK, plus a `path` TEXT column
(`'rootid'` or `'rootid/childid/...'`) and an integer `depth`. This
gives cheap subtree queries (`WHERE path LIKE :p || '%'`), trivial
cycle detection on move, and a single-statement subtree path rewrite —
identical on SQLite + Postgres, no `ltree` extension needed. See
[`docs/adr-0001-menu-tree.md`](docs/adr-0001-menu-tree.md) for the
trade-off vs. a closure table.
Money amounts on `orders`/`order_items` are stored as integer **msat** Money amounts on `orders`/`order_items` are stored as integer **msat**
for precision. Item prices are floats in their declared currency for precision. Item prices are floats in their declared currency
(`sat`, `USD`, `GTQ`, etc.); the order pipeline multiplies by 1000 to (`sat`, `USD`, `GTQ`, etc.); the order pipeline multiplies by 1000 to
@ -181,8 +197,21 @@ Owners write with their wallet's admin key:
- `PUT /restaurant/api/v1/print_jobs/{id}/ack` — printer-pi - `PUT /restaurant/api/v1/print_jobs/{id}/ack` — printer-pi
acknowledgement acknowledgement
Plus full CRUD for categories, subcategories, modifier groups, Plus full CRUD for menu nodes (the tree), modifier groups, modifiers,
modifiers, and availability windows. and availability windows. Menu node operations:
- `POST /restaurant/api/v1/menu_nodes` — create (depth + path
derived from parent; rejected at depth > 4)
- `PUT /restaurant/api/v1/menu_nodes/{id}` — rename / desc / sort /
image. Rename re-publishes every item in the subtree so their
ancestor `t` tags update on Nostr.
- `PUT /restaurant/api/v1/menu_nodes/{id}/move` — body
`{new_parent_id}`; single-statement subtree rewrite, cycle-checked
- `DELETE /restaurant/api/v1/menu_nodes/{id}?cascade=true|false`
default blocks if the node has children or items; cascade deletes
the subtree and **detaches** items (sets `node_id` to null) rather
than wiping them, since items carry `nostr_event_id`s and revenue
history.
## Customer-facing webapp integration ## Customer-facing webapp integration

146
docs/adr-0001-menu-tree.md Normal file
View file

@ -0,0 +1,146 @@
# ADR 0001 — Menu storage: adjacency list with materialized path
**Status:** Accepted
**Date:** 2026-04-29
**Supersedes:** initial scaffold's flat `categories` + `subcategories` model.
## Context
Real restaurant menus are nested:
*Drinks → Hot Beverages → Coffee-based → Espressos*. The initial
scaffold pinned a fixed two-level shape (categories + subcategories
tables). That was a transcription of the LNbits "category +
subcategory" idiom rather than a real data-model decision. The
legacy Atitlan.io project we're carrying forward already used a
self-FK tree (`Category.parentId` in
`Atitlan.io/Legacy/server-fastify/prisma/schema.prisma`).
We also need:
- Items attaching to **any** node, not just leaves (a "Drinks" node
can carry both children and its own items).
- A small **maximum depth** so the UI stays navigable (we picked 4
levels — *root → kid → grandkid → great-grandkid*).
- Cheap "subtree of X" reads (the customer webapp asks for an entire
menu in one round trip).
- Cheap "move subtree" writes (operators reorganize menus).
- Cheap cycle + depth validation on move.
- Identical behavior on **SQLite + Postgres**, which LNbits both
support.
## Decision
Store the tree as an **adjacency list** (`parent_id` self-FK) plus
denormalized **materialized path** (`path` TEXT, `'/'`-separated
node ids) and **depth** (INTEGER, 0..3).
Indexes: `(restaurant_id)`, `(parent_id)`, `(path)`.
```
menu_nodes
├── id TEXT PK
├── restaurant_id TEXT
├── parent_id TEXT NULL -- NULL = root of restaurant
├── name TEXT
├── description TEXT
├── sort_order INTEGER
├── image_url TEXT
├── depth INTEGER -- 0..3
├── path TEXT -- 'rootid' or 'rootid/childid/...'
└── time TIMESTAMP
```
Menu items get `node_id` (replacing `category_id` + `subcategory_id`).
`MenuItem.node_id` is **Optional** in the persisted shape (orphans
allowed when a parent is deleted with `cascade=False`); the
`CreateMenuItem` request body requires it (newly-created items must
land somewhere).
### What this gives us
| Operation | Cost |
| -------------------------------- | ---------------------------------------- |
| Children of node X | `WHERE parent_id = X` — index hit |
| Subtree of node X | `WHERE path LIKE X.path \|\| '%'` — index hit |
| Ancestors of node X | split `path` into ids, fetch by id (≤4) |
| Cycle check on move | `node_id in new_parent.path.split('/')` — O(depth) |
| Max-depth check on create / move | compare integers — O(1) |
| Move subtree (rewrite paths) | one `UPDATE … SET path = new_prefix \|\| SUBSTR(path, len(old)+1)` |
| Build full tree | one `SELECT *` ordered by `(depth, sort_order)`, assemble in O(n) Python |
For the realistic scale (550 nodes per restaurant, depth ≤ 4), the
"build full tree" pass takes microseconds. We never reach for
recursive CTEs.
## Alternatives considered
### Closure table
A separate `menu_node_paths` table holding every (ancestor,
descendant) pair. Best read characteristics for very deep trees
with thousands of nodes — cheap descendant queries via a single
join, no string matching. Rejected because:
- **Maintenance overhead.** Every insert writes one row per
ancestor; every move deletes and rewrites the entire subtree's
rows; every delete is a fan-out. At our scale (depth ≤ 4) this
is pure overhead.
- **Two sources of truth.** The closure table can drift from
`parent_id` on bugs. We'd have to test and lock both.
- **No real win.** Subtree queries on the path column are already
index-backed and fast at this scale.
We'd revisit if a single instance ever hosted thousands of nodes per
restaurant. Today it doesn't.
### Postgres `ltree`
A first-class materialized-path type with GiST indexes. Lovely on
Postgres. **Rejected** because LNbits also supports SQLite, which
has no `ltree`. We don't want a per-backend code path.
A `path` TEXT column gives us the same query shape (`LIKE prefix ||
'%'`) on both backends. If a deployment ever wanted GiST-indexed
performance, an opt-in migration to `ltree` could be added later
without changing the model API.
### Pure adjacency list (no path / no depth)
Keep `parent_id`, drop the denormalized columns. Subtree queries
require recursive CTEs (Postgres + SQLite both support them).
**Rejected** because:
- Recursive CTE syntax is *almost* identical between SQLite and
Postgres but not quite, and writing portable migrations becomes
fiddly.
- Cycle detection on move requires walking with another CTE.
- Move's path rewrite isn't a single statement; you'd have to
recompute every descendant's depth in app code.
The denormalized columns are cheap (one `path: TEXT`, one `depth:
INT` per node) and remove all of these papercuts.
### Nested set (lft / rgt)
Optimal subtree reads, terrible writes (every insert / move shifts
half the tree's `lft`/`rgt` values). **Rejected** as obviously
wrong-shaped for an interactive CMS where operators reorganize
menus often.
## Consequences
- Operators can build menus of any shape up to 4 levels, with items
attachable at any depth.
- Subtree moves are a single SQL statement.
- The CMS uses Quasar's `q-tree` directly off the hydrated tree
returned by `GET /api/v1/restaurants/{id}/menu`.
- Items can be orphaned (their `node_id` is nullable). The CMS UI
surfaces orphans as "unfiled" so operators can re-home them.
- Nostr listings (NIP-99 kind 30402) carry one `t` tag per ancestor
name (slugified, root-first). Renaming a node re-publishes every
item in its subtree so the new tag set lands.
## Migration
`m002_menu_tree` (shipped) backfills `menu_nodes` from the prior
`categories` + `subcategories` tables, then drops them. See
`migrations.py` for the SQL.