fix(#2279): versioned DB migrations via schema_versions table

Replace the try-and-ignore-duplicate-column approach in apply_migrations
with proper schema versioning using a shared schema_versions table.

## mechanism

New function: db::apply_versioned_migrations(conn, subsystem,
legacy_column, migrations). Tracks the applied-migration count in a
schema_versions table (one row per subsystem key). Only migrations past
the stored version run.

Legacy detection: pre-versioning databases have no schema_versions row.
The legacy_column tuple (table, column) identifies a column that exists
only in a fully-migrated legacy database. If present, all known
migrations are skipped. If absent, migrations start from 0.

## stores migrated

- broker: removes bespoke ensure_message_columns / ensure_reminder_columns.
  Unified into BROKER_MIGRATIONS (v1-v5). Legacy detector: messages.priority
  (added in the last pre-versioning migration).
- approvals: 4 historical migrations (v1-v4). Legacy detector:
  approvals.submitter.
- operator_questions: 3 historical migrations (v1-v3). Legacy detector:
  operator_questions.target.
- scheduled_prompts: 1 historical migration (v1). Legacy detector:
  scheduled_prompts.paused_at_unix.

apply_migrations removed (no callers).

## tests (db.rs)

- fresh_install_runs_all_migrations
- legacy_install_skips_all_migrations
- partial_migration_resumes_from_version
- already_at_latest_is_noop
- multiple_stores_in_same_db
This commit is contained in:
atlas 2026-07-10 14:50:39 +02:00 committed by mara
commit 7d36ec5e1f
5 changed files with 333 additions and 115 deletions

View file

@ -33,16 +33,18 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
ON operator_questions (id) WHERE answered_at IS NULL;
";
/// Additive column migrations for pre-existing databases, applied via
/// `db::apply_migrations` (try-and-ignore-duplicate-column).
/// Ordered schema migrations tracked in `schema_versions` (key
/// `"operator_questions"`). Legacy databases are detected via the `target`
/// column — the last column added before versioning — and fast-forwarded
/// past all known migrations.
const MIGRATIONS: &[&str] = &[
// v1: `multi` — checkbox-style multi-option questions.
"ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0",
// v2: `deadline_at` — optional TTL after which the watchdog auto-resolves.
"ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER",
// `target` = recipient of the question. NULL = operator
// (back-compat default for rows written before agent-to-agent
// questions existed); a non-null agent name = peer-to-peer
// question. Dashboard's `pending()` filters on `target IS NULL`
// so peer questions never leak into the operator's queue.
// v3: `target` — recipient of the question. NULL = operator (back-compat
// default); non-null = peer-to-peer question. Dashboard's `pending()`
// filters on `target IS NULL` so peer questions never leak to the operator.
"ALTER TABLE operator_questions ADD COLUMN target TEXT",
];
@ -77,7 +79,12 @@ impl OperatorQuestions {
let conn = crate::db::open(path, "operator_questions")?;
conn.execute_batch(SCHEMA)
.context("apply operator_questions schema")?;
crate::db::apply_migrations(&conn, "operator_questions", MIGRATIONS)?;
crate::db::apply_versioned_migrations(
&conn,
"operator_questions",
("operator_questions", "target"),
MIGRATIONS,
)?;
Ok(Self {
conn: Mutex::new(conn),
})