From 30754bfa8f34045284dc016eebfee49ad17e5fb8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 5 May 2026 20:46:03 +0200 Subject: [PATCH] migrations: make m002_menu_tree idempotent Every step now uses CREATE [TABLE|INDEX] IF NOT EXISTS or is wrapped via a _safe(stmt) helper that swallows OperationalError, and backfill INSERTs become INSERT OR IGNORE. So a partially-applied m002 (interrupted by a crash before the dbversion bump) re-runs cleanly on next startup instead of failing on duplicate-table / duplicate-index / duplicate-PK errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- migrations.py | 128 +++++++++++++++++++++++++++++--------------------- 1 file changed, 74 insertions(+), 54 deletions(-) diff --git a/migrations.py b/migrations.py index d927a45..3aa40af 100644 --- a/migrations.py +++ b/migrations.py @@ -359,12 +359,22 @@ async def m002_menu_tree(db): via the CMS is friendlier than wiping. """ + # Idempotent: every step uses IF [NOT] EXISTS or is wrapped in + # try/except so a partially-applied m002 (interrupted by a crash + # before the dbversion bump) re-runs cleanly on next startup. + + async def _safe(stmt): + try: + await db.execute(stmt) + except Exception: + pass + # ---------------------------------------------------------------- # # New menu_nodes table # # ---------------------------------------------------------------- # await db.execute( f""" - CREATE TABLE restaurant.menu_nodes ( + CREATE TABLE IF NOT EXISTS restaurant.menu_nodes ( id TEXT PRIMARY KEY, restaurant_id TEXT NOT NULL, parent_id TEXT, @@ -379,80 +389,90 @@ async def m002_menu_tree(db): """ ) await db.execute( - "CREATE INDEX restaurant.idx_menu_nodes_restaurant " + "CREATE INDEX IF NOT EXISTS restaurant.idx_menu_nodes_restaurant " "ON menu_nodes(restaurant_id);" ) await db.execute( - "CREATE INDEX restaurant.idx_menu_nodes_parent " + "CREATE INDEX IF NOT EXISTS restaurant.idx_menu_nodes_parent " "ON menu_nodes(parent_id);" ) await db.execute( - "CREATE INDEX restaurant.idx_menu_nodes_path " + "CREATE INDEX IF NOT EXISTS restaurant.idx_menu_nodes_path " "ON menu_nodes(path);" ) - # ---------------------------------------------------------------- # - # Backfill: top-level (depth 0) from categories # - # ---------------------------------------------------------------- # - await db.execute( - """ - INSERT INTO restaurant.menu_nodes - (id, restaurant_id, parent_id, name, description, sort_order, - image_url, depth, path, time) - SELECT id, restaurant_id, NULL, name, description, sort_order, - image_url, 0, id, time - FROM restaurant.categories; - """ + # Backfill from categories/subcategories. INSERT OR IGNORE in case + # an earlier run partially populated, and the SELECTs no-op on a + # retry where categories/subcategories have already been dropped. + categories_exists = await db.fetchone( + "SELECT name FROM restaurant.sqlite_master " + "WHERE type='table' AND name='categories'" + ) + subcategories_exists = await db.fetchone( + "SELECT name FROM restaurant.sqlite_master " + "WHERE type='table' AND name='subcategories'" ) - # ---------------------------------------------------------------- # - # Backfill: depth-1 from subcategories # - # ---------------------------------------------------------------- # - await db.execute( - """ - INSERT INTO restaurant.menu_nodes - (id, restaurant_id, parent_id, name, description, sort_order, - image_url, depth, path, time) - SELECT s.id, c.restaurant_id, s.category_id, s.name, NULL, - s.sort_order, NULL, 1, c.id || '/' || s.id, s.time - FROM restaurant.subcategories s - JOIN restaurant.categories c ON c.id = s.category_id; - """ - ) + if categories_exists: + await db.execute( + """ + INSERT OR IGNORE INTO restaurant.menu_nodes + (id, restaurant_id, parent_id, name, description, sort_order, + image_url, depth, path, time) + SELECT id, restaurant_id, NULL, name, description, sort_order, + image_url, 0, id, time + FROM restaurant.categories; + """ + ) + + if categories_exists and subcategories_exists: + await db.execute( + """ + INSERT OR IGNORE INTO restaurant.menu_nodes + (id, restaurant_id, parent_id, name, description, sort_order, + image_url, depth, path, time) + SELECT s.id, c.restaurant_id, s.category_id, s.name, NULL, + s.sort_order, NULL, 1, c.id || '/' || s.id, s.time + FROM restaurant.subcategories s + JOIN restaurant.categories c ON c.id = s.category_id; + """ + ) # ---------------------------------------------------------------- # # Add menu_items.node_id and backfill # # subcategory wins if both set # # ---------------------------------------------------------------- # + await _safe("ALTER TABLE restaurant.menu_items ADD COLUMN node_id TEXT;") + + item_cols = { + r["name"] + for r in await db.fetchall("PRAGMA restaurant.table_info(menu_items)") + } + if "subcategory_id" in item_cols and "category_id" in item_cols: + await db.execute( + "UPDATE restaurant.menu_items " + "SET node_id = COALESCE(subcategory_id, category_id);" + ) + elif "category_id" in item_cols: + await db.execute( + "UPDATE restaurant.menu_items SET node_id = category_id " + "WHERE node_id IS NULL;" + ) + await db.execute( - "ALTER TABLE restaurant.menu_items ADD COLUMN node_id TEXT;" - ) - await db.execute( - """ - UPDATE restaurant.menu_items - SET node_id = COALESCE(subcategory_id, category_id); - """ - ) - await db.execute( - "CREATE INDEX restaurant.idx_menu_items_node " + "CREATE INDEX IF NOT EXISTS restaurant.idx_menu_items_node " "ON menu_items(node_id);" ) # ---------------------------------------------------------------- # # Drop old columns + tables # # # - # `ALTER TABLE ... DROP COLUMN` requires SQLite ≥ 3.35 (2021-03). # - # LNbits's pinned dependencies are on a modern SQLite, but if a # - # downstream user is on something older the column drops will # - # fail loudly and they'll need to upgrade SQLite — preferable to # - # the table-rebuild dance which has more failure modes. # + # `ALTER TABLE ... DROP COLUMN` requires SQLite ≥ 3.35 (2021-03) # + # and refuses to drop a column referenced by an index — drop the # + # index first. # # ---------------------------------------------------------------- # - await db.execute( - "ALTER TABLE restaurant.menu_items DROP COLUMN subcategory_id;" - ) - await db.execute( - "ALTER TABLE restaurant.menu_items DROP COLUMN category_id;" - ) - await db.execute("DROP INDEX restaurant.idx_menu_items_category;") - await db.execute("DROP TABLE restaurant.subcategories;") - await db.execute("DROP TABLE restaurant.categories;") + await db.execute("DROP INDEX IF EXISTS restaurant.idx_menu_items_category;") + await _safe("ALTER TABLE restaurant.menu_items DROP COLUMN subcategory_id;") + await _safe("ALTER TABLE restaurant.menu_items DROP COLUMN category_id;") + await db.execute("DROP TABLE IF EXISTS restaurant.subcategories;") + await db.execute("DROP TABLE IF EXISTS restaurant.categories;")