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.
5.9 KiB
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 (5–50 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_idon 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-treedirectly off the hydrated tree returned byGET /api/v1/restaurants/{id}/menu. - Items can be orphaned (their
node_idis nullable). The CMS UI surfaces orphans as "unfiled" so operators can re-home them. - Nostr listings (NIP-99 kind 30402) carry one
ttag 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.