restaurant/docs/adr-0001-menu-tree.md
Padreug 7f7915a041 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.
2026-05-09 07:11:06 +02:00

5.9 KiB
Raw Permalink Blame History

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.