"""Add tip_dismissal table for persisting tip dismissals. `useOptimizationTips` only kept dismissed tip ids in a local `Set` — they came back on every reload. This adds a workspace-scoped dismissal record keyed by `(workspace_id, tip_id)` so a dismissed tip stays dismissed across reloads, until the underlying situation changes materially. `data_hash` is a fingerprint of the tip's `data` payload at dismiss time (see `app.utils.tip_predicates.hash_tip_data`). `TipsService` only honors a dismissal while the rule's current output still hashes the same; once the numbers move (e.g. more days now projected to expire at age 4), the dismissal is stale and the tip resurfaces automatically. A dismissal that never expired could permanently silence a safety-relevant warning such as `preserve_for_age_4` — that would be worse than not persisting dismissals at all, so expiry-on-change is not optional here. Revision ID: 010 Revises: 009 Create Date: 2026-09-17 """ from __future__ import annotations from alembic import op revision: str = "010" down_revision: str | None = "009" branch_labels: str | None = None depends_on: str | None = None _UPGRADE_SQL = """ CREATE TABLE tip_dismissal ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, tip_id TEXT NOT NULL, data_hash TEXT NOT NULL, dismissed_at TIMESTAMP NOT NULL, UNIQUE(workspace_id, tip_id), FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); """ _DOWNGRADE_SQL = """ DROP TABLE tip_dismissal; """ def upgrade() -> None: from sqlalchemy import text for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: op.execute(text(stmt)) def downgrade() -> None: from sqlalchemy import text for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: op.execute(text(stmt))