"""Retire the legacy fundamentals settings (A6) Revision ID: 029 Revises: 028 Create Date: 2026-08-07 00:00:00.000000 A6 removed the FMP/Finnhub/Alpha Vantage providers, the weekly ``fundamental_collector`` job and the A5 parity report. Five SystemSetting rows are left over. They are NOT all deleted, because the deploy runs migrations before restarting the service: for a short window — and for the whole of any rollback — pre-A6 code is still live, and it reads absent rows permissively (cutover absent -> disabled; ``job__enabled`` absent -> enabled). Deleting both would hand a rolled-back process a re-armed legacy collector writing over the SEC/Dolt cache. So the two rows that carry behavior become tombstones pinned to the safe value, and only the inert ones are deleted. The tombstones are dropped in a later release once the rollback window has closed; ``SettingsForm`` hides them meanwhile. """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = "029" down_revision: Union[str, None] = "028" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None # Behavior-bearing under pre-A6 code -> pin to the safe value, keep the row. _TOMBSTONES: dict[str, str] = { "fundamental_data_sec_dolt_cutover_enabled": "true", "job_fundamental_collector_enabled": "false", } # Inert either way: an absent cron falls back to a default for a job that no # longer registers, and the parity report never wrote anything. _OBSOLETE: tuple[str, ...] = ( "schedule_fundamentals_cron", "schedule_fundamentals_parity_cron", "job_fundamentals_parity_report_enabled", ) _settings = sa.table( "system_settings", sa.column("id", sa.Integer), sa.column("key", sa.String), sa.column("value", sa.Text), sa.column("updated_at", sa.DateTime(timezone=True)), ) def upgrade() -> None: conn = op.get_bind() now = sa.func.now() for key, pinned in _TOMBSTONES.items(): row = conn.execute( sa.select(_settings.c.value).where(_settings.c.key == key) ).fetchone() old_value = row[0] if row is not None else None print(f"a6_tombstone {key}: {old_value!r} -> {pinned!r}", flush=True) if row is None: conn.execute( sa.insert(_settings).values(key=key, value=pinned, updated_at=now) ) elif old_value != pinned: conn.execute( sa.update(_settings) .where(_settings.c.key == key) .values(value=pinned, updated_at=now) ) # Print the value before deleting — a bare DELETE cannot be undone from the # migration output. for key in _OBSOLETE: row = conn.execute( sa.select(_settings.c.value).where(_settings.c.key == key) ).fetchone() if row is None: print(f"a6_delete {key}: absent", flush=True) continue print(f"a6_delete {key}: {row[0]!r}", flush=True) conn.execute(sa.delete(_settings).where(_settings.c.key == key)) def downgrade() -> None: """No-op. The deleted rows configured jobs this revision's code no longer registers, and the tombstones already hold the values pre-A6 code needs. Recreating them would restore nothing useful; the printed values above cover recovery. """