Context
Follow-up to #42 (position-lifecycle watchers). The four `findUnalerted*` queries filter on the `*_alerted_at IS NULL` columns added in migration `0003_position_alert_dedup`, but no index was added on those columns. At current scale (~30 open positions) this is fine, but as the table grows historical-row count, the planner falls back to a sequential scan every 5 minutes.
Same applies to `findUnalertedExpired`: it has no upper bound on `expiration`, so the scan grows unboundedly with history.
Proposed fix
Add four partial indexes:
```sql
CREATE INDEX idx_pos_unalerted_mini ON position_states (expiration, created)
WHERE mini_lifetime_alerted_at IS NULL AND is_closed = false AND is_denied = false;
CREATE INDEX idx_pos_unalerted_expiring_soon ON position_states (expiration)
WHERE expiring_soon_alerted_at IS NULL AND is_closed = false AND is_denied = false;
CREATE INDEX idx_pos_unalerted_expired ON position_states (expiration)
WHERE expired_alerted_at IS NULL AND is_closed = false AND is_denied = false;
CREATE INDEX idx_pos_unalerted_phase2 ON position_states (expiration, challenge_period)
WHERE phase2_alerted_at IS NULL AND is_closed = false AND is_denied = false;
```
Plus an upper bound on `findUnalertedExpired` (e.g. `expiration > now - 30d`) so historical decayed positions drop out of the scan.
Acceptance
- New migration `0004_alert_dedup_indexes` with the four partial indexes.
- `findUnalertedExpired` adds a 30-day lower bound on `expiration`.
- `EXPLAIN` on each query uses index scan rather than seq scan after migration.
Context
Follow-up to #42 (position-lifecycle watchers). The four `findUnalerted*` queries filter on the `*_alerted_at IS NULL` columns added in migration `0003_position_alert_dedup`, but no index was added on those columns. At current scale (~30 open positions) this is fine, but as the table grows historical-row count, the planner falls back to a sequential scan every 5 minutes.
Same applies to `findUnalertedExpired`: it has no upper bound on `expiration`, so the scan grows unboundedly with history.
Proposed fix
Add four partial indexes:
```sql
CREATE INDEX idx_pos_unalerted_mini ON position_states (expiration, created)
WHERE mini_lifetime_alerted_at IS NULL AND is_closed = false AND is_denied = false;
CREATE INDEX idx_pos_unalerted_expiring_soon ON position_states (expiration)
WHERE expiring_soon_alerted_at IS NULL AND is_closed = false AND is_denied = false;
CREATE INDEX idx_pos_unalerted_expired ON position_states (expiration)
WHERE expired_alerted_at IS NULL AND is_closed = false AND is_denied = false;
CREATE INDEX idx_pos_unalerted_phase2 ON position_states (expiration, challenge_period)
WHERE phase2_alerted_at IS NULL AND is_closed = false AND is_denied = false;
```
Plus an upper bound on `findUnalertedExpired` (e.g. `expiration > now - 30d`) so historical decayed positions drop out of the scan.
Acceptance