feat(identifiers): add reversible projection migration
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-22 00:27:48 +02:00
parent ce52e9d1e2
commit cb1b028fd1
18 changed files with 921 additions and 27 deletions

View file

@ -0,0 +1,182 @@
"""add reversible work-record identifier migration support
Revision ID: b8d4f0a2c6e1
Revises: a7c3e9f1b4d2
Create Date: 2026-08-22
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "b8d4f0a2c6e1"
down_revision = "a7c3e9f1b4d2"
branch_labels = None
depends_on = None
_CASCADE_REFERENCES = """
DO $$
DECLARE
fk RECORD;
updated_definition TEXT;
BEGIN
FOR fk IN
SELECT
constraint_row.conrelid::regclass AS relation_name,
constraint_row.conname AS constraint_name,
pg_get_constraintdef(constraint_row.oid) AS definition
FROM pg_constraint AS constraint_row
WHERE constraint_row.contype = 'f'
AND constraint_row.confrelid IN (
'workplans'::regclass,
'tasks'::regclass
)
LOOP
updated_definition := regexp_replace(
fk.definition,
' ON UPDATE (NO ACTION|RESTRICT|CASCADE|SET NULL|SET DEFAULT)',
'',
'i'
);
IF position(' ON DELETE ' IN updated_definition) > 0 THEN
updated_definition := regexp_replace(
updated_definition,
' ON DELETE ',
' ON UPDATE CASCADE ON DELETE ',
'i'
);
ELSE
updated_definition := updated_definition || ' ON UPDATE CASCADE';
END IF;
EXECUTE format(
'ALTER TABLE %s DROP CONSTRAINT %I',
fk.relation_name,
fk.constraint_name
);
EXECUTE format(
'ALTER TABLE %s ADD CONSTRAINT %I %s',
fk.relation_name,
fk.constraint_name,
updated_definition
);
END LOOP;
END $$;
"""
_REMOVE_UPDATE_CASCADE = """
DO $$
DECLARE
fk RECORD;
updated_definition TEXT;
BEGIN
FOR fk IN
SELECT
constraint_row.conrelid::regclass AS relation_name,
constraint_row.conname AS constraint_name,
pg_get_constraintdef(constraint_row.oid) AS definition
FROM pg_constraint AS constraint_row
WHERE constraint_row.contype = 'f'
AND constraint_row.confrelid IN (
'workplans'::regclass,
'tasks'::regclass
)
LOOP
updated_definition := regexp_replace(
fk.definition,
' ON UPDATE CASCADE',
'',
'i'
);
EXECUTE format(
'ALTER TABLE %s DROP CONSTRAINT %I',
fk.relation_name,
fk.constraint_name
);
EXECUTE format(
'ALTER TABLE %s ADD CONSTRAINT %I %s',
fk.relation_name,
fk.constraint_name,
updated_definition
);
END LOOP;
END $$;
"""
def upgrade() -> None:
op.create_table(
"work_record_identifier_aliases",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("old_id", UUID(as_uuid=True), nullable=False),
sa.Column("new_id", UUID(as_uuid=True), nullable=False),
sa.Column("record_kind", sa.String(length=20), nullable=False),
sa.Column("record_id", sa.String(length=160), nullable=False),
sa.Column("repo_slug", sa.String(length=100), nullable=False),
sa.Column("namespace", sa.String(length=64), nullable=False),
sa.Column("plan_sha256", sa.String(length=64), nullable=False),
sa.Column(
"migration_status",
sa.String(length=20),
nullable=False,
server_default="prepared",
),
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reversed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"record_kind IN ('workplan', 'task')",
name="ck_work_record_identifier_aliases_kind",
),
sa.CheckConstraint(
"migration_status IN ('prepared', 'applied', 'reversed')",
name="ck_work_record_identifier_aliases_status",
),
sa.UniqueConstraint("old_id", name="uq_work_record_identifier_aliases_old_id"),
sa.UniqueConstraint("new_id", name="uq_work_record_identifier_aliases_new_id"),
)
op.create_index(
"ix_work_record_identifier_aliases_record_id",
"work_record_identifier_aliases",
["record_id"],
)
op.create_index(
"ix_work_record_identifier_aliases_repo_slug",
"work_record_identifier_aliases",
["repo_slug"],
)
op.create_index(
"ix_work_record_identifier_aliases_plan_sha256",
"work_record_identifier_aliases",
["plan_sha256"],
)
op.execute(_CASCADE_REFERENCES)
def downgrade() -> None:
op.execute(_REMOVE_UPDATE_CASCADE)
op.drop_index(
"ix_work_record_identifier_aliases_plan_sha256",
table_name="work_record_identifier_aliases",
)
op.drop_index(
"ix_work_record_identifier_aliases_repo_slug",
table_name="work_record_identifier_aliases",
)
op.drop_index(
"ix_work_record_identifier_aliases_record_id",
table_name="work_record_identifier_aliases",
)
op.drop_table("work_record_identifier_aliases")