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 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 18:57:12 -05:00
parent d2b8ab8fe8
commit b761db4c44
2 changed files with 72 additions and 76 deletions
+45 -30
View File
@@ -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:
+27 -46
View File
@@ -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")