"""Reconcile the known unversioned PostgreSQL schema. Revision ID: 202607270001 Revises: 202607260005 Create Date: 2026-07-27 This revision is intentionally idempotent. A normally versioned database at 202607260005 only receives the PostgreSQL NULLS NOT DISTINCT hardening. The one-time baseline runner may also use it, in the same transaction as an atomic stamp, to repair the explicitly allowlisted mixed schema. """ from collections.abc import Iterable from alembic import op import sqlalchemy as sa revision = "202607270001" down_revision = "202607260005" branch_labels = None depends_on = None AI_MEMORY_TABLE = "ai_memory_entries" MARKET_WATCHLIST_TABLE = "market_watchlists" APPROVAL_TABLE = "approval_requests" AI_MEMORY_OWNER_UNIQUE = "uq_ai_memory_owner_fingerprint" MARKET_WATCHLIST_OWNER_UNIQUE = "uq_market_watchlist_owner_symbol" MARKET_WATCHLIST_LEGACY_UNIQUE = "uq_market_watchlist_actor_symbol" def _inspector() -> sa.Inspector: return sa.inspect(op.get_bind()) def _table_exists(table_name: str) -> bool: return table_name in _inspector().get_table_names() def _column_names(table_name: str) -> set[str]: return { str(column["name"]) for column in _inspector().get_columns(table_name) } def _add_missing_columns( table_name: str, columns: Iterable[sa.Column], ) -> None: existing = _column_names(table_name) for column in columns: if column.name not in existing: op.add_column(table_name, column) def _index_by_name(table_name: str, index_name: str) -> dict | None: return next( ( index for index in _inspector().get_indexes(table_name) if index.get("name") == index_name ), None, ) def _ensure_index( table_name: str, index_name: str, columns: tuple[str, ...], *, unique: bool = False, ) -> None: existing = _index_by_name(table_name, index_name) if existing is not None: existing_columns = tuple(existing.get("column_names") or ()) if existing_columns != columns or bool(existing.get("unique")) != unique: raise RuntimeError( f"Existing index {index_name} does not match the reconciliation definition" ) return op.create_index(index_name, table_name, list(columns), unique=unique) def _drop_index_if_present(table_name: str, index_name: str) -> None: if _index_by_name(table_name, index_name) is not None: op.drop_index(index_name, table_name=table_name) def _unique_by_name(table_name: str, constraint_name: str) -> dict | None: return next( ( constraint for constraint in _inspector().get_unique_constraints(table_name) if constraint.get("name") == constraint_name ), None, ) def _drop_unique_if_present(table_name: str, constraint_name: str) -> None: if _unique_by_name(table_name, constraint_name) is None: return with op.batch_alter_table(table_name) as batch_op: batch_op.drop_constraint(constraint_name, type_="unique") def _uses_nulls_not_distinct(constraint: dict) -> bool: options = constraint.get("dialect_options") or {} return bool(options.get("postgresql_nulls_not_distinct")) def _ensure_owner_unique( table_name: str, constraint_name: str, columns: tuple[str, ...], ) -> None: existing = _unique_by_name(table_name, constraint_name) dialect_name = op.get_bind().dialect.name if existing is not None: if tuple(existing.get("column_names") or ()) != columns: raise RuntimeError( f"Existing constraint {constraint_name} does not match the reconciliation definition" ) if dialect_name != "postgresql" or _uses_nulls_not_distinct(existing): return with op.batch_alter_table(table_name) as batch_op: batch_op.drop_constraint(constraint_name, type_="unique") with op.batch_alter_table(table_name) as batch_op: batch_op.create_unique_constraint( constraint_name, list(columns), postgresql_nulls_not_distinct=True, ) def _ensure_foreign_key( table_name: str, constraint_name: str, local_columns: tuple[str, ...], remote_table: str, remote_columns: tuple[str, ...], ) -> None: for foreign_key in _inspector().get_foreign_keys(table_name): if ( tuple(foreign_key.get("constrained_columns") or ()) == local_columns and foreign_key.get("referred_table") == remote_table and tuple(foreign_key.get("referred_columns") or ()) == remote_columns ): ondelete = str((foreign_key.get("options") or {}).get("ondelete") or "") if ondelete.upper() != "CASCADE": raise RuntimeError( f"Existing foreign key on {table_name} does not use ON DELETE CASCADE" ) return with op.batch_alter_table(table_name) as batch_op: batch_op.create_foreign_key( constraint_name, remote_table, list(local_columns), list(remote_columns), ondelete="CASCADE", ) def _alter_without_server_default( table_name: str, columns: tuple[tuple[str, sa.types.TypeEngine], ...], ) -> None: with op.batch_alter_table(table_name) as batch_op: for column_name, column_type in columns: batch_op.alter_column( column_name, existing_type=column_type, nullable=False, server_default=None, ) def _add_business_columns() -> None: _add_missing_columns( "attendance_records", ( sa.Column( "attendance_scope", sa.String(length=32), nullable=False, server_default="company", ), sa.Column("source_status", sa.String(length=64), nullable=True), sa.Column("source_location_status", sa.String(length=64), nullable=True), sa.Column("source_updated_at", sa.DateTime(), nullable=True), sa.Column("last_seen_at", sa.DateTime(), nullable=True), sa.Column( "is_active", sa.Boolean(), nullable=False, server_default=sa.true(), ), ), ) _add_missing_columns( "audit_logs", (sa.Column("request_id", sa.String(length=64), nullable=True),), ) _add_missing_columns( "projects", ( sa.Column("display_code", sa.String(length=64), nullable=True), sa.Column("department_code", sa.String(length=64), nullable=True), sa.Column("department_name", sa.String(length=128), nullable=True), sa.Column("owner_employee_code", sa.String(length=64), nullable=True), sa.Column("source_stage", sa.String(length=32), nullable=True), sa.Column("source_stage_label", sa.String(length=128), nullable=True), sa.Column( "source_archived", sa.Boolean(), nullable=False, server_default=sa.false(), ), sa.Column("source_created_at", sa.DateTime(), nullable=True), sa.Column("source_updated_at", sa.DateTime(), nullable=True), sa.Column("last_seen_at", sa.DateTime(), nullable=True), sa.Column( "is_active", sa.Boolean(), nullable=False, server_default=sa.true(), ), sa.Column( "source_contract_amount", sa.Numeric(precision=16, scale=2), nullable=True, ), sa.Column( "source_project_investment_amount", sa.Numeric(precision=16, scale=2), nullable=True, ), ), ) _add_missing_columns( "risk_events", ( sa.Column("assigned_to", sa.String(length=128), nullable=True), sa.Column("resolved_at", sa.DateTime(), nullable=True), sa.Column("closed_at", sa.DateTime(), nullable=True), sa.Column("closed_reason", sa.Text(), nullable=True), sa.Column("review_summary", sa.Text(), nullable=True), ), ) _add_missing_columns( "work_reports", ( sa.Column("employee_code", sa.String(length=64), nullable=True), sa.Column("external_id", sa.String(length=128), nullable=True), sa.Column( "is_late", sa.Boolean(), nullable=False, server_default=sa.false(), ), sa.Column( "is_draft", sa.Boolean(), nullable=False, server_default=sa.false(), ), sa.Column("source_updated_at", sa.DateTime(), nullable=True), sa.Column("last_seen_at", sa.DateTime(), nullable=True), sa.Column( "is_active", sa.Boolean(), nullable=False, server_default=sa.true(), ), ), ) _add_missing_columns( "work_tasks", ( sa.Column( "source_system", sa.String(length=64), nullable=False, server_default="internal", ), sa.Column("external_id", sa.String(length=128), nullable=True), sa.Column("employee_code", sa.String(length=64), nullable=True), sa.Column("source_created_at", sa.DateTime(), nullable=True), sa.Column("source_updated_at", sa.DateTime(), nullable=True), sa.Column("last_seen_at", sa.DateTime(), nullable=True), sa.Column( "is_active", sa.Boolean(), nullable=False, server_default=sa.true(), ), ), ) op.execute( sa.text( """ UPDATE attendance_records SET attendance_scope = 'company' WHERE attendance_scope IS NULL """ ) ) op.execute( sa.text( """ UPDATE attendance_records SET is_active = true WHERE is_active IS NULL """ ) ) op.execute( sa.text( """ UPDATE projects SET source_archived = false WHERE source_archived IS NULL """ ) ) op.execute( sa.text( """ UPDATE projects SET is_active = true WHERE is_active IS NULL """ ) ) op.execute( sa.text( """ UPDATE work_reports SET is_late = false WHERE is_late IS NULL """ ) ) op.execute( sa.text( """ UPDATE work_reports SET is_draft = false WHERE is_draft IS NULL """ ) ) op.execute( sa.text( """ UPDATE work_reports SET is_active = true WHERE is_active IS NULL """ ) ) op.execute( sa.text( """ UPDATE work_tasks SET source_system = 'internal' WHERE source_system IS NULL """ ) ) op.execute( sa.text( """ UPDATE work_tasks SET is_active = true WHERE is_active IS NULL """ ) ) _alter_without_server_default( "attendance_records", ( ("attendance_scope", sa.String(length=32)), ("is_active", sa.Boolean()), ), ) _alter_without_server_default( "projects", ( ("source_archived", sa.Boolean()), ("is_active", sa.Boolean()), ), ) _alter_without_server_default( "work_reports", ( ("is_late", sa.Boolean()), ("is_draft", sa.Boolean()), ("is_active", sa.Boolean()), ), ) _alter_without_server_default( "work_tasks", ( ("source_system", sa.String(length=64)), ("is_active", sa.Boolean()), ), ) def _ensure_business_indexes() -> None: definitions = { "attendance_records": ( "attendance_scope", "is_active", "last_seen_at", "source_status", "source_updated_at", ), "audit_logs": ("request_id",), "projects": ( "department_code", "department_name", "display_code", "is_active", "last_seen_at", "owner_employee_code", "source_archived", "source_stage", "source_updated_at", ), "risk_events": ("assigned_to",), "work_reports": ( "employee_code", "external_id", "is_active", "is_draft", "is_late", "last_seen_at", "source_updated_at", ), "work_tasks": ( "employee_code", "external_id", "is_active", "last_seen_at", "source_updated_at", ), } for table_name, columns in definitions.items(): for column_name in columns: _ensure_index( table_name, f"ix_{table_name}_{column_name}", (column_name,), ) def _reconcile_ai_memory() -> None: _add_missing_columns( AI_MEMORY_TABLE, ( sa.Column("owner_id", sa.Integer(), nullable=True), sa.Column( "kind", sa.String(length=32), nullable=False, server_default="memory", ), ), ) op.execute( sa.text( """ UPDATE ai_memory_entries SET owner_id = NULL, kind = 'company_rule', source = 'legacy_company', status = 'active' WHERE source = 'user_rule' """ ) ) op.execute( sa.text( """ UPDATE ai_memory_entries SET kind = 'memory', status = 'archived' WHERE owner_id IS NULL AND source IN ('auto', 'hermes') """ ) ) op.execute( sa.text( """ UPDATE ai_memory_entries SET kind = 'memory' WHERE kind IS NULL """ ) ) _alter_without_server_default( AI_MEMORY_TABLE, (("kind", sa.String(length=32)),), ) fingerprint_index = _index_by_name( AI_MEMORY_TABLE, "ix_ai_memory_entries_fingerprint", ) if fingerprint_index is not None and bool(fingerprint_index.get("unique")): op.drop_index( "ix_ai_memory_entries_fingerprint", table_name=AI_MEMORY_TABLE, ) _ensure_index( AI_MEMORY_TABLE, "ix_ai_memory_entries_fingerprint", ("fingerprint",), ) _ensure_index( AI_MEMORY_TABLE, "ix_ai_memory_entries_kind", ("kind",), ) _ensure_index( AI_MEMORY_TABLE, "ix_ai_memory_entries_owner_id", ("owner_id",), ) _ensure_foreign_key( AI_MEMORY_TABLE, "fk_ai_memory_entries_owner_id", ("owner_id",), "feishu_users", ("id",), ) _ensure_owner_unique( AI_MEMORY_TABLE, AI_MEMORY_OWNER_UNIQUE, ("owner_id", "fingerprint"), ) def _reconcile_market_watchlists() -> None: _add_missing_columns( MARKET_WATCHLIST_TABLE, (sa.Column("owner_id", sa.Integer(), nullable=True),), ) _drop_unique_if_present( MARKET_WATCHLIST_TABLE, MARKET_WATCHLIST_LEGACY_UNIQUE, ) _ensure_index( MARKET_WATCHLIST_TABLE, "ix_market_watchlists_owner_id", ("owner_id",), ) _ensure_foreign_key( MARKET_WATCHLIST_TABLE, "fk_market_watchlists_owner_id", ("owner_id",), "feishu_users", ("id",), ) _ensure_owner_unique( MARKET_WATCHLIST_TABLE, MARKET_WATCHLIST_OWNER_UNIQUE, ("owner_id", "symbol"), ) def _create_feishu_app_tickets_if_missing() -> None: if not _table_exists("feishu_app_tickets"): op.create_table( "feishu_app_tickets", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("app_id", sa.String(length=128), nullable=False), sa.Column("app_ticket", sa.Text(), nullable=False), sa.Column("received_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint("id"), ) _ensure_index( "feishu_app_tickets", "ix_feishu_app_tickets_app_id", ("app_id",), unique=True, ) _ensure_index( "feishu_app_tickets", "ix_feishu_app_tickets_received_at", ("received_at",), ) def _create_admin_tombstones_if_missing() -> None: if not _table_exists("feishu_admin_bootstrap_tombstones"): op.create_table( "feishu_admin_bootstrap_tombstones", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("identity_hash", sa.String(length=64), nullable=False), sa.Column("created_at", sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint("id"), ) _ensure_index( "feishu_admin_bootstrap_tombstones", "ix_feishu_admin_bootstrap_tombstones_created_at", ("created_at",), ) _ensure_index( "feishu_admin_bootstrap_tombstones", "ix_feishu_admin_bootstrap_tombstones_identity_hash", ("identity_hash",), unique=True, ) def _drop_empty_approval_table() -> None: if not _table_exists(APPROVAL_TABLE): return count = int( op.get_bind().execute( sa.text("SELECT COUNT(*) FROM approval_requests") ).scalar_one() ) if count: raise RuntimeError( "Refusing to remove approval_requests because it contains rows" ) for table_name in _inspector().get_table_names(): for foreign_key in _inspector().get_foreign_keys(table_name): if foreign_key.get("referred_table") == APPROVAL_TABLE: raise RuntimeError( "Refusing to remove approval_requests because a foreign key depends on it" ) op.drop_table(APPROVAL_TABLE) def upgrade() -> None: _add_business_columns() _ensure_business_indexes() _reconcile_ai_memory() _reconcile_market_watchlists() _create_feishu_app_tickets_if_missing() _create_admin_tombstones_if_missing() _drop_empty_approval_table() def downgrade() -> None: raise RuntimeError( "Revision 202607270001 is irreversible: it classifies legacy data, " "adds ownership relationships, and removes the obsolete approval table. " "Restore a verified database backup instead of downgrading." )