diff --git a/central/alembic/versions/0020_not_null_timestamps.py b/central/alembic/versions/0020_not_null_timestamps.py new file mode 100644 index 0000000..abe0241 --- /dev/null +++ b/central/alembic/versions/0020_not_null_timestamps.py @@ -0,0 +1,63 @@ +"""Enforce NOT NULL on server-defaulted timestamps the models already require. + +0011, 0012, 0014 and 0017 created these columns with ``server_default=now()`` +but without ``nullable=False``, so the database accepted NULL while the models +declare them non-optional. No application path writes NULL to them (inserts +omit them and rely on the default; updates set ``now``), and before this +revision was written no NULL existed in production or in the development +database. + +The upgrade refuses to run — changing nothing — if any NULL is found, instead +of guessing a value for it. + +Revision ID: 0020_not_null_timestamps +Revises: 0019_fix_timestamp_defaults +""" +from alembic import op +import sqlalchemy as sa + +revision = "0020_not_null_timestamps" +down_revision = "0019_fix_timestamp_defaults" +branch_labels = None +depends_on = None + +COLUMNS = ( + ("admin_users", "created_at"), + ("agent_events", "occurred_at"), + ("agent_snapshots", "updated_at"), + ("ai_eval_cache", "created_at"), + ("ai_runs", "started_at"), + ("ai_work_items", "created_at"), + ("ai_work_items", "updated_at"), + ("audit_events", "occurred_at"), + ("fiscal_context_entries", "created_at"), + ("fiscal_context_entries", "updated_at"), + ("installations", "created_at"), + ("installations", "updated_at"), + ("kb_knowledge", "created_at"), + ("kb_observations", "observed_at"), + ("kb_products", "created_at"), + ("kb_products", "updated_at"), + ("tenant_product_context", "created_at"), + ("tenant_product_context", "updated_at"), + ("web_sessions", "created_at"), + ("web_sessions", "last_seen_at"), +) + + +def upgrade(): + bind = op.get_bind() + offenders = [] + for table, column in COLUMNS: + nulls = bind.execute(sa.text(f'SELECT count(*) FROM "{table}" WHERE "{column}" IS NULL')).scalar() + if nulls: + offenders.append(f"{table}.{column}={nulls}") + if offenders: + raise RuntimeError("0020 aborted, NULL values present (fix them explicitly first): " + ", ".join(offenders)) + for table, column in COLUMNS: + op.alter_column(table, column, nullable=False, existing_type=sa.DateTime(timezone=True), existing_server_default=sa.func.now()) + + +def downgrade(): + for table, column in reversed(COLUMNS): + op.alter_column(table, column, nullable=True, existing_type=sa.DateTime(timezone=True), existing_server_default=sa.func.now())