"""UI product events: hourly aggregate hypertable One row per (hour, event, view, prop, value, referrer) — never one row per interaction. See ``backend/core/events.py`` for the schema and the reasoning: the table has no column an identifier could be written into, so it cannot hold a sequence of one person's actions even in principle. TimescaleDB hypertable on ``bucket`` with a 30-day chunk interval (the read pattern is "last N weeks, grouped by event"), plus a retention policy so old buckets drop themselves. ``RETENTION_DAYS`` is the operator-set retention limit — change it here, not by hand on the box, so the deployed value is reviewable. Postgres-only: guarded to no-op on the SQLite bind (tests / CI / offline Alembic), where events.py's aggregate sink is inert and only the JSONL activity line is written. Revision ID: 0003_ui_events Revises: 0002_climate_hypertables Create Date: 2026-07-23 """ from alembic import op revision = "0003_ui_events" down_revision = "0002_climate_hypertables" branch_labels = None depends_on = None # Aggregate rows carry no personal data, so this is a storage/usefulness choice # rather than a GDPR one. 400 days keeps a full year-over-year comparison. RETENTION_DAYS = 400 def upgrade() -> None: if op.get_bind().dialect.name != "postgresql": return # SQLite fallback (tests/CI): the aggregate sink is a no-op there. op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb") # Every dimension is NOT NULL DEFAULT '' rather than nullable: '' is a real # value ("this event does not use this slot"), and it keeps the primary key # usable (NULLs would defeat the ON CONFLICT upsert). op.execute(""" CREATE TABLE ui_event_hourly ( bucket TIMESTAMPTZ NOT NULL, event TEXT NOT NULL, view TEXT NOT NULL DEFAULT '', prop TEXT NOT NULL DEFAULT '', value TEXT NOT NULL DEFAULT '', referrer TEXT NOT NULL DEFAULT '', count BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (bucket, event, view, prop, value, referrer) ) """) # The PK includes the range-partition column (bucket), which TimescaleDB # requires of any unique index on a hypertable. op.execute( "SELECT create_hypertable('ui_event_hourly', " "by_range('bucket', INTERVAL '30 days'))") op.execute( f"SELECT add_retention_policy('ui_event_hourly', INTERVAL '{RETENTION_DAYS} days')") # The dashboard query is "one event over time", so index it that way. op.execute("CREATE INDEX ui_event_hourly_event_idx ON ui_event_hourly (event, bucket DESC)") def downgrade() -> None: if op.get_bind().dialect.name != "postgresql": return op.execute("DROP TABLE IF EXISTS ui_event_hourly")