fix(migration): simplify aisle migration + add persistent backup script

- Drop the empty batch_alter_table block and the meaningless
  set_config call from migration 0015. Temp tables still persist
  for the migration's session (Alembic's transactional_ddl).
- New backend/scripts/persist_aisle_backup.sql creates
  public.ingredient_aisle_backup_0015 and
  public.grocery_item_aisle_backup_0015 permanent tables for
  operators who want a recoverable record beyond the migration.
- Update Review/sprint2-verification.md, Review/ui-nielsen-audit.md
  and fix-ui-audit.md with the correct container-based deploy
  steps: docker compose exec db psql -U mealplanner -d mealplanner
  -f /dev/stdin < ...sql. Host psql is not available on the
  deployment host; the db runs inside the container.
This commit is contained in:
2026-06-03 17:41:59 -07:00
parent ccc70aaf72
commit f5fb7558c4
5 changed files with 96 additions and 40 deletions
@@ -3,10 +3,28 @@
Revision ID: 0015
Revises: 0014
Create Date: 2026-06-02
Normalizes free-text aisle values on `ingredient.aisle` and `grocery_item.aisle`
to a fixed canonical set. Runs in a single transaction (Alembic default); both
op.execute calls share the same session, so the temp backup tables persist
for the duration of the upgrade.
Backup tables: the temp tables `ingredient_aisle_backup` and
`grocery_item_aisle_backup` are created for the migration's session. They
auto-drop when the session ends. If you need a persistent backup, run
`backend/scripts/persist_aisle_backup.sql` BEFORE this migration.
Deploy via Docker (the db runs inside a container; no host psql required):
# 1. dry-run preview:
docker compose exec db psql -U mealplanner -d mealplanner \\
-f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql
# 2. apply:
docker compose exec backend alembic upgrade head
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
@@ -16,20 +34,6 @@ branch_labels: Union[Sequence[str], None] = None
depends_on: Union[Sequence[str], None] = None
CANONICAL_AISLES = (
"Produce",
"Meat & Seafood",
"Dairy & Eggs",
"Pantry",
"Frozen",
"Bakery",
"Beverages",
"Spices",
"Other",
)
# Map from lowercased source value to canonical label. Keep the rule
# order narrow -> broad; longest matches win via SQL CASE.
NORMALIZATION_RULES = [
("canned goods", "Pantry"),
("canned", "Pantry"),
@@ -78,23 +82,15 @@ def _normalize(table: str) -> None:
def upgrade() -> None:
bind = op.get_bind()
with op.batch_alter_table("ingredient") as batch:
pass
_normalize("ingredient")
_normalize("grocery_item")
bind.execute(
sa.text(
"SELECT set_config('app.aisle_backup_retention', 'aisle_migration_0015', false)"
)
)
def downgrade() -> None:
# Best-effort downgrade: the backup temp tables only exist within the
# upgrade transaction. Restoring the pre-normalization state is not
# possible from this migration alone. Operators must restore from a
# database snapshot taken before upgrade.
# The temp backup tables only exist for the migration's session.
# Restoring the pre-normalization state is not possible from this
# migration alone. Operators must restore from a database snapshot
# taken before upgrade.
raise NotImplementedError(
"Cannot reverse aisle normalization without an external backup. "
"Restore the database from a snapshot taken before 0015 was applied."
+24
View File
@@ -0,0 +1,24 @@
-- Persist a permanent backup of aisle values BEFORE running migration 0015.
-- Run via Docker (no host psql required):
--
-- docker compose exec db psql -U mealplanner -d mealplanner \
-- -f /dev/stdin < backend/scripts/persist_aisle_backup.sql
--
-- Creates two permanent backup tables in the public schema. They are NOT
-- auto-dropped; you can DROP them manually after you confirm the migration
-- is correct:
--
-- DROP TABLE public.ingredient_aisle_backup_0015;
-- DROP TABLE public.grocery_item_aisle_backup_0015;
CREATE TABLE IF NOT EXISTS public.ingredient_aisle_backup_0015 AS
SELECT id, aisle, created_at FROM public.ingredient WHERE aisle IS NOT NULL;
CREATE TABLE IF NOT EXISTS public.grocery_item_aisle_backup_0015 AS
SELECT id, aisle FROM public.grocery_item WHERE aisle IS NOT NULL;
SELECT 'ingredient backup' AS table_name, COUNT(*) AS rows_backed_up
FROM public.ingredient_aisle_backup_0015
UNION ALL
SELECT 'grocery_item backup' AS table_name, COUNT(*) AS rows_backed_up
FROM public.grocery_item_aisle_backup_0015;