From b761db4c44dbe2f7597ff6c7b5cb07ef713267a2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 10 Feb 2026 18:57:12 -0500 Subject: [PATCH] Fix migrations 0004 and 0005 to be fully idempotent The Dockerfile runs `alembic stamp --purge base && alembic upgrade head` on every restart, so all migrations re-run against an existing database. Migration 0004 used `op.add_column()` which fails on re-run with "column already exists". Migration 0005 used `op.create_table()` which would fail similarly. Both now use raw SQL with idempotency guards: - 0004: `DO $$ BEGIN ALTER TABLE ADD COLUMN ... EXCEPTION WHEN duplicate_column` + `IF EXISTS` check before touching the tasks table - 0005: `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` Co-Authored-By: Claude Opus 4.6 --- .../versions/0004_merge_tasks_into_notes.py | 75 +++++++++++-------- alembic/versions/0005_add_chat_tables.py | 73 +++++++----------- 2 files changed, 72 insertions(+), 76 deletions(-) diff --git a/alembic/versions/0004_merge_tasks_into_notes.py b/alembic/versions/0004_merge_tasks_into_notes.py index 073bb89..4ee56d3 100644 --- a/alembic/versions/0004_merge_tasks_into_notes.py +++ b/alembic/versions/0004_merge_tasks_into_notes.py @@ -15,45 +15,60 @@ depends_on = None def upgrade() -> None: - # Add task columns to notes table - op.add_column("notes", sa.Column("status", sa.Text(), nullable=True)) - op.add_column("notes", sa.Column("priority", sa.Text(), nullable=True)) - op.add_column("notes", sa.Column("due_date", sa.Date(), nullable=True)) - op.create_index("ix_notes_status", "notes", ["status"]) - conn = op.get_bind() - # Migrate tasks that have companion notes: - # Copy status/priority/due_date to the companion note, - # and merge the task description into the note body if the note body is empty. + # Add task columns to notes table (idempotent) conn.execute(sa.text(""" - UPDATE notes - SET status = t.status, - priority = t.priority, - due_date = t.due_date, - body = COALESCE(NULLIF(t.description, ''), notes.body), - tags = CASE - WHEN array_length(t.tags, 1) IS NOT NULL THEN t.tags - ELSE notes.tags - END - FROM tasks t - WHERE t.note_id = notes.id + DO $$ BEGIN + ALTER TABLE notes ADD COLUMN status TEXT; + EXCEPTION WHEN duplicate_column THEN null; + END $$ """)) - - # Handle orphan tasks (note_id IS NULL) — insert them as new notes conn.execute(sa.text(""" - INSERT INTO notes (title, body, tags, status, priority, due_date, created_at, updated_at) - SELECT title, description, tags, status, priority, due_date, created_at, updated_at - FROM tasks - WHERE note_id IS NULL + DO $$ BEGIN + ALTER TABLE notes ADD COLUMN priority TEXT; + EXCEPTION WHEN duplicate_column THEN null; + END $$ """)) + conn.execute(sa.text(""" + DO $$ BEGIN + ALTER TABLE notes ADD COLUMN due_date DATE; + EXCEPTION WHEN duplicate_column THEN null; + END $$ + """)) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS ix_notes_status ON notes (status)" + )) - # Drop tasks table and its indexes - op.drop_table("tasks") + # Migrate tasks that have companion notes (only if tasks table exists): + conn.execute(sa.text(""" + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tasks') THEN + UPDATE notes + SET status = t.status, + priority = t.priority, + due_date = t.due_date, + body = COALESCE(NULLIF(t.description, ''), notes.body), + tags = CASE + WHEN array_length(t.tags, 1) IS NOT NULL THEN t.tags + ELSE notes.tags + END + FROM tasks t + WHERE t.note_id = notes.id; + + INSERT INTO notes (title, body, tags, status, priority, due_date, created_at, updated_at) + SELECT title, description, tags, status, priority, due_date, created_at, updated_at + FROM tasks + WHERE note_id IS NULL; + + DROP TABLE tasks; + END IF; + END $$ + """)) # Drop the old enum types that are no longer used by any table - op.execute("DROP TYPE IF EXISTS task_status") - op.execute("DROP TYPE IF EXISTS task_priority") + conn.execute(sa.text("DROP TYPE IF EXISTS task_status")) + conn.execute(sa.text("DROP TYPE IF EXISTS task_priority")) def downgrade() -> None: diff --git a/alembic/versions/0005_add_chat_tables.py b/alembic/versions/0005_add_chat_tables.py index 2cf7e12..6580a16 100644 --- a/alembic/versions/0005_add_chat_tables.py +++ b/alembic/versions/0005_add_chat_tables.py @@ -15,54 +15,35 @@ depends_on = None def upgrade() -> None: - op.create_table( - "conversations", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("title", sa.Text(), nullable=False, server_default=""), - sa.Column("model", sa.Text(), nullable=False, server_default=""), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.func.now(), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.func.now(), - nullable=False, - ), - ) + conn = op.get_bind() - op.create_table( - "messages", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column( - "conversation_id", - sa.Integer(), - sa.ForeignKey("conversations.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("role", sa.Text(), nullable=False), - sa.Column("content", sa.Text(), nullable=False, server_default=""), - sa.Column( - "context_note_id", - sa.Integer(), - sa.ForeignKey("notes.id", ondelete="SET NULL"), - nullable=True, - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.func.now(), - nullable=False, - ), - ) + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS conversations ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """)) - op.create_index("ix_messages_conversation_id", "messages", ["conversation_id"]) + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + context_note_id INTEGER REFERENCES notes(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """)) + + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS ix_messages_conversation_id ON messages (conversation_id)" + )) def downgrade() -> None: - op.drop_index("ix_messages_conversation_id", table_name="messages") - op.drop_table("messages") - op.drop_table("conversations") + op.execute("DROP INDEX IF EXISTS ix_messages_conversation_id") + op.execute("DROP TABLE IF EXISTS messages") + op.execute("DROP TABLE IF EXISTS conversations")