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:
@@ -15,45 +15,60 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> 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()
|
conn = op.get_bind()
|
||||||
|
|
||||||
# Migrate tasks that have companion notes:
|
# Add task columns to notes table (idempotent)
|
||||||
# Copy status/priority/due_date to the companion note,
|
|
||||||
# and merge the task description into the note body if the note body is empty.
|
|
||||||
conn.execute(sa.text("""
|
conn.execute(sa.text("""
|
||||||
UPDATE notes
|
DO $$ BEGIN
|
||||||
SET status = t.status,
|
ALTER TABLE notes ADD COLUMN status TEXT;
|
||||||
priority = t.priority,
|
EXCEPTION WHEN duplicate_column THEN null;
|
||||||
due_date = t.due_date,
|
END $$
|
||||||
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
|
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
# Handle orphan tasks (note_id IS NULL) — insert them as new notes
|
|
||||||
conn.execute(sa.text("""
|
conn.execute(sa.text("""
|
||||||
INSERT INTO notes (title, body, tags, status, priority, due_date, created_at, updated_at)
|
DO $$ BEGIN
|
||||||
SELECT title, description, tags, status, priority, due_date, created_at, updated_at
|
ALTER TABLE notes ADD COLUMN priority TEXT;
|
||||||
FROM tasks
|
EXCEPTION WHEN duplicate_column THEN null;
|
||||||
WHERE note_id IS 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
|
# Migrate tasks that have companion notes (only if tasks table exists):
|
||||||
op.drop_table("tasks")
|
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
|
# Drop the old enum types that are no longer used by any table
|
||||||
op.execute("DROP TYPE IF EXISTS task_status")
|
conn.execute(sa.text("DROP TYPE IF EXISTS task_status"))
|
||||||
op.execute("DROP TYPE IF EXISTS task_priority")
|
conn.execute(sa.text("DROP TYPE IF EXISTS task_priority"))
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -15,54 +15,35 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.create_table(
|
conn = op.get_bind()
|
||||||
"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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
op.create_table(
|
conn.execute(sa.text("""
|
||||||
"messages",
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
id SERIAL PRIMARY KEY,
|
||||||
sa.Column(
|
title TEXT NOT NULL DEFAULT '',
|
||||||
"conversation_id",
|
model TEXT NOT NULL DEFAULT '',
|
||||||
sa.Integer(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
def downgrade() -> None:
|
||||||
op.drop_index("ix_messages_conversation_id", table_name="messages")
|
op.execute("DROP INDEX IF EXISTS ix_messages_conversation_id")
|
||||||
op.drop_table("messages")
|
op.execute("DROP TABLE IF EXISTS messages")
|
||||||
op.drop_table("conversations")
|
op.execute("DROP TABLE IF EXISTS conversations")
|
||||||
|
|||||||
Reference in New Issue
Block a user