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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-05 20:46:03 +02:00
commit 30754bfa8f

View file

@ -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,24 +389,34 @@ 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 #
# ---------------------------------------------------------------- #
# 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'"
)
if categories_exists:
await db.execute(
"""
INSERT INTO restaurant.menu_nodes
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,
@ -405,12 +425,10 @@ async def m002_menu_tree(db):
"""
)
# ---------------------------------------------------------------- #
# Backfill: depth-1 from subcategories #
# ---------------------------------------------------------------- #
if categories_exists and subcategories_exists:
await db.execute(
"""
INSERT INTO restaurant.menu_nodes
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,
@ -424,35 +442,37 @@ async def m002_menu_tree(db):
# 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(
"ALTER TABLE restaurant.menu_items ADD COLUMN node_id TEXT;"
"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 = COALESCE(subcategory_id, category_id);
"""
"UPDATE restaurant.menu_items SET node_id = category_id "
"WHERE node_id IS NULL;"
)
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;")