fix(#2279): idempotent column-guard migrations, drop invalid ADD COLUMN IF NOT EXISTS

This commit is contained in:
damocles 2026-07-10 16:51:50 +02:00 committed by mara
commit dfbf198ef3
5 changed files with 188 additions and 121 deletions

View file

@ -10,6 +10,8 @@ use hive_sh4re::wire_time::now_unix;
use hive_sh4re::{Approval, ApprovalKind, ApprovalStatus}; use hive_sh4re::{Approval, ApprovalKind, ApprovalStatus};
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use crate::db::Migration;
const SCHEMA: &str = r" const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS approvals ( CREATE TABLE IF NOT EXISTS approvals (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -25,20 +27,32 @@ CREATE INDEX IF NOT EXISTS idx_approvals_pending
"; ";
/// Ordered schema migrations tracked in `schema_versions` (key `"approvals"`). /// Ordered schema migrations tracked in `schema_versions` (key `"approvals"`).
/// Legacy databases are detected via the `submitter` column — the last /// Each migration declares the column it adds, so a legacy DB (fully or
/// column added before versioning was introduced — and fast-forwarded past /// partially migrated before versioning) converges by skipping the migrations
/// all known migrations. New columns go here as v5, v6, … /// whose column already exists. New columns go here as v5, v6, …
const MIGRATIONS: &[&str] = &[ const MIGRATIONS: &[Migration] = &[
// v1: `kind` (pre-Phase-8 dbs): legacy rows default to `apply_commit`. // v1: `kind` (pre-Phase-8 dbs): legacy rows default to `apply_commit`.
"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS \ Migration {
sql: "ALTER TABLE approvals ADD COLUMN \
kind TEXT NOT NULL DEFAULT 'apply_commit'", kind TEXT NOT NULL DEFAULT 'apply_commit'",
adds_column: Some(("approvals", "kind")),
},
// v2: `description`: manager-supplied note on the dashboard card. // v2: `description`: manager-supplied note on the dashboard card.
"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS description TEXT", Migration {
sql: "ALTER TABLE approvals ADD COLUMN description TEXT",
adds_column: Some(("approvals", "description")),
},
// v3: `fetched_sha`: canonical sha hive-c0re resolved at submit time. // v3: `fetched_sha`: canonical sha hive-c0re resolved at submit time.
"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS fetched_sha TEXT", Migration {
sql: "ALTER TABLE approvals ADD COLUMN fetched_sha TEXT",
adds_column: Some(("approvals", "fetched_sha")),
},
// v4: `submitter`: authenticated agent that submitted the approval. // v4: `submitter`: authenticated agent that submitted the approval.
// Legacy rows are NULL → callers fall back to the root agent. // Legacy rows are NULL → callers fall back to the root agent.
"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS submitter TEXT", Migration {
sql: "ALTER TABLE approvals ADD COLUMN submitter TEXT",
adds_column: Some(("approvals", "submitter")),
},
]; ];
pub struct Approvals { pub struct Approvals {
@ -50,12 +64,7 @@ impl Approvals {
let conn = crate::db::open(path, "approvals")?; let conn = crate::db::open(path, "approvals")?;
conn.execute_batch(SCHEMA) conn.execute_batch(SCHEMA)
.context("apply approvals schema")?; .context("apply approvals schema")?;
crate::db::apply_versioned_migrations( crate::db::apply_versioned_migrations(&conn, "approvals", MIGRATIONS)?;
&conn,
"approvals",
("approvals", "submitter"),
MIGRATIONS,
)?;
Ok(Self { Ok(Self {
conn: Mutex::new(conn), conn: Mutex::new(conn),
}) })

View file

@ -10,6 +10,8 @@ use anyhow::{Context, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use hive_sh4re::wire_time::now_unix; use hive_sh4re::wire_time::now_unix;
use hive_sh4re::{InboxRow, Message}; use hive_sh4re::{InboxRow, Message};
use crate::db::Migration;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize; use serde::Serialize;
use tokio::sync::broadcast; use tokio::sync::broadcast;
@ -157,52 +159,60 @@ pub struct Broker {
} }
/// Ordered schema migrations for the broker. Tracked in `schema_versions` /// Ordered schema migrations for the broker. Tracked in `schema_versions`
/// under key `"broker"`. Legacy databases (fully migrated via the old /// under key `"broker"`. Each migration declares the column it adds, so a
/// per-column check approach) are detected via the `priority` column on /// legacy DB (fully or partially migrated via the old per-column approach)
/// `messages` — the last column added before versioning. /// is converged by skipping the migrations whose column already exists.
const BROKER_MIGRATIONS: &[&str] = &[ const BROKER_MIGRATIONS: &[Migration] = &[
// v1: acked_at on messages, with backfill so existing delivered rows // v1: acked_at on messages, with backfill so existing delivered rows
// are not phantom-requeued on the next open. // are not phantom-requeued on the next open. The BEGIN/COMMIT block
// // makes ALTER + UPDATE atomic — either both land or neither, so the
// IF NOT EXISTS: safe to run on a partially-migrated legacy DB where // guard column (acked_at) faithfully marks the whole step as done.
// acked_at already exists but priority (the legacy marker) does not. Migration {
// The BEGIN/COMMIT block makes ALTER + UPDATE atomic — if the UPDATE sql: "BEGIN;\
// fails the version counter is not bumped and the whole step retries. ALTER TABLE messages ADD COLUMN acked_at INTEGER;\
"BEGIN;\
ALTER TABLE messages ADD COLUMN IF NOT EXISTS acked_at INTEGER;\
UPDATE messages SET acked_at = delivered_at \ UPDATE messages SET acked_at = delivered_at \
WHERE delivered_at IS NOT NULL AND acked_at IS NULL;\ WHERE delivered_at IS NOT NULL AND acked_at IS NULL;\
COMMIT;", COMMIT;",
adds_column: Some(("messages", "acked_at")),
},
// v2: in_reply_to for thread-parent tracking. NULL = root of a thread. // v2: in_reply_to for thread-parent tracking. NULL = root of a thread.
"ALTER TABLE messages ADD COLUMN IF NOT EXISTS in_reply_to INTEGER", Migration {
sql: "ALTER TABLE messages ADD COLUMN in_reply_to INTEGER",
adds_column: Some(("messages", "in_reply_to")),
},
// v3: priority for operator-message fast-path. Rebuild the delivery // v3: priority for operator-message fast-path. Rebuild the delivery
// index to include priority as a secondary sort key. // index to include priority as a secondary sort key. Atomic BEGIN/COMMIT
"BEGIN;\ // so priority existing implies the index rebuild also committed.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS \ Migration {
sql: "BEGIN;\
ALTER TABLE messages ADD COLUMN \
priority INTEGER NOT NULL DEFAULT 0;\ priority INTEGER NOT NULL DEFAULT 0;\
DROP INDEX IF EXISTS idx_messages_undelivered;\ DROP INDEX IF EXISTS idx_messages_undelivered;\
CREATE INDEX IF NOT EXISTS idx_messages_undelivered \ CREATE INDEX IF NOT EXISTS idx_messages_undelivered \
ON messages (recipient, priority DESC, id) WHERE delivered_at IS NULL;\ ON messages (recipient, priority DESC, id) WHERE delivered_at IS NULL;\
COMMIT;", COMMIT;",
adds_column: Some(("messages", "priority")),
},
// v4: attempt_count on reminders for the MAX_REMINDER_ATTEMPTS cap. // v4: attempt_count on reminders for the MAX_REMINDER_ATTEMPTS cap.
"ALTER TABLE reminders ADD COLUMN IF NOT EXISTS \ Migration {
sql: "ALTER TABLE reminders ADD COLUMN \
attempt_count INTEGER NOT NULL DEFAULT 0", attempt_count INTEGER NOT NULL DEFAULT 0",
adds_column: Some(("reminders", "attempt_count")),
},
// v5: last_error on reminders — last delivery failure surfaced on the // v5: last_error on reminders — last delivery failure surfaced on the
// dashboard so a stuck reminder is visible without digging in logs. // dashboard so a stuck reminder is visible without digging in logs.
"ALTER TABLE reminders ADD COLUMN IF NOT EXISTS last_error TEXT", Migration {
sql: "ALTER TABLE reminders ADD COLUMN last_error TEXT",
adds_column: Some(("reminders", "last_error")),
},
]; ];
impl Broker { impl Broker {
pub fn open(path: &Path) -> Result<Self> { pub fn open(path: &Path) -> Result<Self> {
let conn = crate::db::open(path, "broker")?; let conn = crate::db::open(path, "broker")?;
conn.execute_batch(SCHEMA).context("apply broker schema")?; conn.execute_batch(SCHEMA).context("apply broker schema")?;
crate::db::apply_versioned_migrations( crate::db::apply_versioned_migrations(&conn, "broker", BROKER_MIGRATIONS)
&conn, .context("broker migrations")?;
"broker",
("messages", "priority"),
BROKER_MIGRATIONS,
)
.context("broker migrations")?;
let (events, _) = broadcast::channel(EVENT_CHANNEL); let (events, _) = broadcast::channel(EVENT_CHANNEL);
Ok(Self { Ok(Self {
conn: Mutex::new(conn), conn: Mutex::new(conn),

View file

@ -11,10 +11,12 @@
//! tracks the applied count in a `schema_versions` table (one row per //! tracks the applied count in a `schema_versions` table (one row per
//! `subsystem` key). Only migrations past the stored version run. //! `subsystem` key). Only migrations past the stored version run.
//! //!
//! Legacy databases (written before versioning) are detected via //! Legacy databases (written before versioning) need no special marker:
//! `legacy_column = (table, column)` — a column that exists only in //! each column-adding migration carries the `(table, column)` it
//! a fully-migrated legacy DB. Present → skip all migrations. Absent → //! introduces, so re-walking a pre-versioning DB from version 0 simply
//! start from 0. See each store's `MIGRATIONS` constant for the list. //! skips the migrations whose column already exists — converging a fully-
//! or partially-migrated legacy DB without ever re-running an `ADD COLUMN`
//! against an existing column. See each store's `MIGRATIONS` constant.
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
@ -33,36 +35,57 @@ CREATE TABLE IF NOT EXISTS schema_versions (
version INTEGER NOT NULL DEFAULT 0 version INTEGER NOT NULL DEFAULT 0
)"; )";
/// One numbered schema migration.
///
/// `sql` runs once, at the index whose value is the current stored version.
/// `adds_column`, when set, is the `(table, column)` this migration
/// introduces: if that column already exists — a pre-versioning DB migrated
/// by the old try-and-ignore path, or a partially-migrated one — `sql` is
/// skipped and only the version counter advances. This makes column-adding
/// migrations idempotent *without* `SQLite`'s unsupported `ALTER TABLE … ADD
/// COLUMN IF NOT EXISTS`, so re-walking a legacy DB from version 0 never
/// re-runs an `ADD COLUMN` against a column that is already there (which
/// would fail with "duplicate column name" and wedge startup permanently).
///
/// Multi-statement migrations wrap their `ALTER` + backfill / index rebuild
/// in an explicit `BEGIN; … COMMIT;` batch, so "the added column exists" is a
/// faithful proxy for "this whole migration committed".
pub struct Migration {
/// SQL for this version — a single statement or a `BEGIN; … COMMIT;` batch.
pub sql: &'static str,
/// `(table, column)` this migration adds, if any. Present → the migration
/// is skipped when the column already exists (idempotent replay).
pub adds_column: Option<(&'static str, &'static str)>,
}
/// Apply numbered schema migrations for `subsystem`, tracking progress in /// Apply numbered schema migrations for `subsystem`, tracking progress in
/// the shared `schema_versions` table. /// the shared `schema_versions` table.
/// ///
/// - `subsystem` — unique key for this store in `schema_versions` /// - `subsystem` — unique key for this store in `schema_versions`
/// (e.g. `"approvals"`, `"broker"`). /// (e.g. `"approvals"`, `"broker"`).
/// - `legacy_column` — `(table_name, column_name)` of a column that exists /// - `migrations` — ordered list of [`Migration`]s; migration `i` runs when
/// in a fully-migrated pre-versioning database. When there is no /// the stored version is `i`. A pre-versioning DB (no `schema_versions`
/// `schema_versions` row yet and this column is present, all `migrations` /// row) starts at version 0 and re-walks every migration, but each
/// are skipped (they were previously applied via the old try-and-ignore /// column-adding migration is skipped when its `adds_column` is already
/// approach). When the column is absent, we start at version 0. /// present — so a fully- or partially-migrated legacy DB converges without
/// - `migrations` — ordered list of SQL batches; each batch runs once, at /// re-running an `ADD COLUMN` against an existing column.
/// the index whose value is the current version.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if any migration statement fails or the version update /// Returns an error if any migration statement fails or the version update
/// fails. A failed migration leaves the DB at the last successfully /// fails. Each migration + its version bump is a single `execute_batch`
/// committed version (each step is its own implicit transaction via /// (multi-statement migrations wrap themselves in `BEGIN; … COMMIT;`), so a
/// `execute_batch`). /// failure leaves the DB at the last successfully committed version.
pub fn apply_versioned_migrations( pub fn apply_versioned_migrations(
conn: &Connection, conn: &Connection,
subsystem: &str, subsystem: &str,
legacy_column: (&str, &str), migrations: &[Migration],
migrations: &[&str],
) -> Result<()> { ) -> Result<()> {
// Ensure the version-tracking table exists (idempotent). // Ensure the version-tracking table exists (idempotent).
conn.execute_batch(SCHEMA_VERSIONS_DDL) conn.execute_batch(SCHEMA_VERSIONS_DDL)
.context("create schema_versions table")?; .context("create schema_versions table")?;
// Look up the stored version for this subsystem. // Current version for this subsystem — 0 (and a fresh row) when absent.
let stored: Option<i64> = conn let stored: Option<i64> = conn
.query_row( .query_row(
"SELECT version FROM schema_versions WHERE store = ?1", "SELECT version FROM schema_versions WHERE store = ?1",
@ -71,43 +94,32 @@ pub fn apply_versioned_migrations(
) )
.optional() .optional()
.context("read schema_versions")?; .context("read schema_versions")?;
let version = if let Some(v) = stored { let version = if let Some(v) = stored {
usize::try_from(v.max(0)).unwrap_or(0) usize::try_from(v.max(0)).unwrap_or(0)
} else { } else {
// No entry yet. Check the legacy-column to decide where to start.
// If it exists, all known migrations were already applied by the
// old try-and-ignore path — skip them. If it doesn't exist, start
// at 0 (fresh install or partial state).
let (legacy_table, legacy_col) = legacy_column;
// SAFETY: `legacy_table` is always a static table-name literal
// supplied by each store's `open` (e.g. `"messages"`). Dynamic
// or untrusted table names must not be passed here — if that
// ever changes, switch to `pragma_table_info(?1)` with two
// bound parameters.
let is_legacy: bool = conn
.prepare(&format!(
"SELECT 1 FROM pragma_table_info('{legacy_table}') WHERE name = ?1"
))
.context("prepare legacy-column probe")?
.exists([legacy_col])
.context("check legacy column")?;
let initial = if is_legacy { migrations.len() } else { 0 };
conn.execute( conn.execute(
"INSERT INTO schema_versions (store, version) VALUES (?1, ?2)", "INSERT INTO schema_versions (store, version) VALUES (?1, 0)",
params![subsystem, i64::try_from(initial).unwrap_or(0)], [subsystem],
) )
.context("insert schema_versions row")?; .context("insert schema_versions row")?;
initial 0
}; };
if version >= migrations.len() { if version >= migrations.len() {
return Ok(()); return Ok(());
} }
for (i, stmt) in migrations.iter().enumerate().skip(version) { for (i, mig) in migrations.iter().enumerate().skip(version) {
conn.execute_batch(stmt) // Skip a column-adding migration whose column is already present:
.with_context(|| format!("{subsystem} migration v{} failed: {stmt}", i + 1))?; // a legacy DB migrated by the old path, or a resumed partial one.
let already_applied = match mig.adds_column {
Some((table, column)) => column_exists(conn, table, column)?,
None => false,
};
if !already_applied {
conn.execute_batch(mig.sql)
.with_context(|| format!("{subsystem} migration v{} failed: {}", i + 1, mig.sql))?;
}
let next = i64::try_from(i + 1).unwrap_or(i64::MAX); let next = i64::try_from(i + 1).unwrap_or(i64::MAX);
conn.execute( conn.execute(
"UPDATE schema_versions SET version = ?1 WHERE store = ?2", "UPDATE schema_versions SET version = ?1 WHERE store = ?2",
@ -118,6 +130,17 @@ pub fn apply_versioned_migrations(
Ok(()) Ok(())
} }
/// Whether `column` exists on `table` in this connection's schema. Both
/// arguments are bound parameters — `pragma_table_info(?1)` accepts the table
/// name as a bound value — so this is not a SQL-injection surface even if a
/// caller ever passes a non-static name.
fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
conn.prepare("SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2")
.context("prepare column probe")?
.exists(params![table, column])
.context("check column existence")
}
/// Open a connection to the sqlite file at `path`, creating the parent /// Open a connection to the sqlite file at `path`, creating the parent
/// directory if needed. `subsystem` labels error contexts (`"broker"`, /// directory if needed. `subsystem` labels error contexts (`"broker"`,
/// `"approvals"`, …). /// `"approvals"`, …).
@ -147,17 +170,26 @@ mod tests {
} }
const TEST_SCHEMA: &str = "CREATE TABLE IF NOT EXISTS things (id INTEGER PRIMARY KEY)"; const TEST_SCHEMA: &str = "CREATE TABLE IF NOT EXISTS things (id INTEGER PRIMARY KEY)";
const TEST_MIGRATIONS: &[&str] = &[ const TEST_MIGRATIONS: &[Migration] = &[
"ALTER TABLE things ADD COLUMN IF NOT EXISTS alpha TEXT", Migration {
"ALTER TABLE things ADD COLUMN IF NOT EXISTS beta INTEGER NOT NULL DEFAULT 0", sql: "ALTER TABLE things ADD COLUMN alpha TEXT",
"ALTER TABLE things ADD COLUMN IF NOT EXISTS gamma TEXT", adds_column: Some(("things", "alpha")),
},
Migration {
sql: "ALTER TABLE things ADD COLUMN beta INTEGER NOT NULL DEFAULT 0",
adds_column: Some(("things", "beta")),
},
Migration {
sql: "ALTER TABLE things ADD COLUMN gamma TEXT",
adds_column: Some(("things", "gamma")),
},
]; ];
#[test] #[test]
fn fresh_install_runs_all_migrations() { fn fresh_install_runs_all_migrations() {
let conn = open_tmp(); let conn = open_tmp();
conn.execute_batch(TEST_SCHEMA).unwrap(); conn.execute_batch(TEST_SCHEMA).unwrap();
apply_versioned_migrations(&conn, "things", ("things", "gamma"), TEST_MIGRATIONS).unwrap(); apply_versioned_migrations(&conn, "things", TEST_MIGRATIONS).unwrap();
// All three columns should exist. // All three columns should exist.
for col in ["alpha", "beta", "gamma"] { for col in ["alpha", "beta", "gamma"] {
let exists: bool = conn let exists: bool = conn
@ -190,7 +222,7 @@ mod tests {
alpha TEXT, beta INTEGER NOT NULL DEFAULT 0, gamma TEXT)", alpha TEXT, beta INTEGER NOT NULL DEFAULT 0, gamma TEXT)",
) )
.unwrap(); .unwrap();
apply_versioned_migrations(&conn, "things", ("things", "gamma"), TEST_MIGRATIONS).unwrap(); apply_versioned_migrations(&conn, "things", TEST_MIGRATIONS).unwrap();
// Version should be set to migrations.len() (3), not 0. // Version should be set to migrations.len() (3), not 0.
let v: i64 = conn let v: i64 = conn
.query_row( .query_row(
@ -212,7 +244,7 @@ mod tests {
INSERT INTO schema_versions (store, version) VALUES ('things', 1)", INSERT INTO schema_versions (store, version) VALUES ('things', 1)",
) )
.unwrap(); .unwrap();
apply_versioned_migrations(&conn, "things", ("things", "gamma"), TEST_MIGRATIONS).unwrap(); apply_versioned_migrations(&conn, "things", TEST_MIGRATIONS).unwrap();
// Only beta and gamma should have been added (alpha already existed). // Only beta and gamma should have been added (alpha already existed).
for col in ["alpha", "beta", "gamma"] { for col in ["alpha", "beta", "gamma"] {
let exists: bool = conn let exists: bool = conn
@ -241,9 +273,9 @@ mod tests {
fn already_at_latest_is_noop() { fn already_at_latest_is_noop() {
let conn = open_tmp(); let conn = open_tmp();
conn.execute_batch(TEST_SCHEMA).unwrap(); conn.execute_batch(TEST_SCHEMA).unwrap();
apply_versioned_migrations(&conn, "things", ("things", "gamma"), TEST_MIGRATIONS).unwrap(); apply_versioned_migrations(&conn, "things", TEST_MIGRATIONS).unwrap();
// Second call is a no-op. // Second call is a no-op.
apply_versioned_migrations(&conn, "things", ("things", "gamma"), TEST_MIGRATIONS).unwrap(); apply_versioned_migrations(&conn, "things", TEST_MIGRATIONS).unwrap();
let v: i64 = conn let v: i64 = conn
.query_row( .query_row(
"SELECT version FROM schema_versions WHERE store = 'things'", "SELECT version FROM schema_versions WHERE store = 'things'",
@ -265,15 +297,19 @@ mod tests {
apply_versioned_migrations( apply_versioned_migrations(
&conn, &conn,
"store_a", "store_a",
("a", "col1"), &[Migration {
&["ALTER TABLE a ADD COLUMN col1 TEXT"], sql: "ALTER TABLE a ADD COLUMN col1 TEXT",
adds_column: Some(("a", "col1")),
}],
) )
.unwrap(); .unwrap();
apply_versioned_migrations( apply_versioned_migrations(
&conn, &conn,
"store_b", "store_b",
("b", "colx"), &[Migration {
&["ALTER TABLE b ADD COLUMN colx INTEGER NOT NULL DEFAULT 0"], sql: "ALTER TABLE b ADD COLUMN colx INTEGER NOT NULL DEFAULT 0",
adds_column: Some(("b", "colx")),
}],
) )
.unwrap(); .unwrap();
// Each store tracks independently. // Each store tracks independently.
@ -296,23 +332,20 @@ mod tests {
} }
/// A partially-migrated legacy database: some columns from the old /// A partially-migrated legacy database: some columns from the old
/// try-and-ignore path exist, but the legacy marker column (used to /// try-and-ignore path exist, with no `schema_versions` row yet. The
/// fast-forward fresh schema_versions entries to `len`) is absent. /// per-migration `adds_column` guard must skip the already-present
/// With idempotent `ADD COLUMN IF NOT EXISTS` migrations this must /// columns and add only the missing ones — no "duplicate column name".
/// succeed — the already-present columns are skipped and only the
/// missing ones are added.
#[test] #[test]
fn partial_legacy_completes_without_error() { fn partial_legacy_completes_without_error() {
let conn = open_tmp(); let conn = open_tmp();
// Simulate a DB that has `alpha` (v1) but not `beta` (v2) or // Simulate a DB that has `alpha` (v1) but not `beta` (v2) or
// `gamma` (v3). No schema_versions row exists yet, and `gamma` // `gamma` (v3). No schema_versions row exists → start from v0 and
// (the legacy marker) is absent → the function cannot fast-forward // guard-skip alpha while adding beta + gamma.
// and must start from v0 with idempotent migrations.
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS things (id INTEGER PRIMARY KEY, alpha TEXT)", "CREATE TABLE IF NOT EXISTS things (id INTEGER PRIMARY KEY, alpha TEXT)",
) )
.unwrap(); .unwrap();
apply_versioned_migrations(&conn, "things", ("things", "gamma"), TEST_MIGRATIONS).unwrap(); apply_versioned_migrations(&conn, "things", TEST_MIGRATIONS).unwrap();
// All three columns must now exist. // All three columns must now exist.
for col in ["alpha", "beta", "gamma"] { for col in ["alpha", "beta", "gamma"] {
let exists: bool = conn let exists: bool = conn
@ -322,7 +355,10 @@ mod tests {
.unwrap() .unwrap()
.exists([]) .exists([])
.unwrap(); .unwrap();
assert!(exists, "column {col} missing after partial-legacy migration"); assert!(
exists,
"column {col} missing after partial-legacy migration"
);
} }
// Version must be at the latest. // Version must be at the latest.
let v: i64 = conn let v: i64 = conn
@ -332,6 +368,9 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(v, 3, "must reach latest version after completing partial legacy"); assert_eq!(
v, 3,
"must reach latest version after completing partial legacy"
);
} }
} }

View file

@ -19,6 +19,8 @@ use hive_sh4re::wire_time::now_unix;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize; use serde::Serialize;
use crate::db::Migration;
const SCHEMA: &str = r" const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS operator_questions ( CREATE TABLE IF NOT EXISTS operator_questions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -37,16 +39,25 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
/// `"operator_questions"`). Legacy databases are detected via the `target` /// `"operator_questions"`). Legacy databases are detected via the `target`
/// column — the last column added before versioning — and fast-forwarded /// column — the last column added before versioning — and fast-forwarded
/// past all known migrations. /// past all known migrations.
const MIGRATIONS: &[&str] = &[ const MIGRATIONS: &[Migration] = &[
// v1: `multi` — checkbox-style multi-option questions. // v1: `multi` — checkbox-style multi-option questions.
"ALTER TABLE operator_questions ADD COLUMN IF NOT EXISTS \ Migration {
sql: "ALTER TABLE operator_questions ADD COLUMN \
multi INTEGER NOT NULL DEFAULT 0", multi INTEGER NOT NULL DEFAULT 0",
adds_column: Some(("operator_questions", "multi")),
},
// v2: `deadline_at` — optional TTL after which the watchdog auto-resolves. // v2: `deadline_at` — optional TTL after which the watchdog auto-resolves.
"ALTER TABLE operator_questions ADD COLUMN IF NOT EXISTS deadline_at INTEGER", Migration {
sql: "ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER",
adds_column: Some(("operator_questions", "deadline_at")),
},
// v3: `target` — recipient of the question. NULL = operator (back-compat // v3: `target` — recipient of the question. NULL = operator (back-compat
// default); non-null = peer-to-peer question. Dashboard's `pending()` // default); non-null = peer-to-peer question. Dashboard's `pending()`
// filters on `target IS NULL` so peer questions never leak to the operator. // filters on `target IS NULL` so peer questions never leak to the operator.
"ALTER TABLE operator_questions ADD COLUMN IF NOT EXISTS target TEXT", Migration {
sql: "ALTER TABLE operator_questions ADD COLUMN target TEXT",
adds_column: Some(("operator_questions", "target")),
},
]; ];
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@ -80,12 +91,7 @@ impl OperatorQuestions {
let conn = crate::db::open(path, "operator_questions")?; let conn = crate::db::open(path, "operator_questions")?;
conn.execute_batch(SCHEMA) conn.execute_batch(SCHEMA)
.context("apply operator_questions schema")?; .context("apply operator_questions schema")?;
crate::db::apply_versioned_migrations( crate::db::apply_versioned_migrations(&conn, "operator_questions", MIGRATIONS)?;
&conn,
"operator_questions",
("operator_questions", "target"),
MIGRATIONS,
)?;
Ok(Self { Ok(Self {
conn: Mutex::new(conn), conn: Mutex::new(conn),
}) })

View file

@ -20,6 +20,8 @@ use hive_sh4re::wire_time::now_unix;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::db::Migration;
/// Typed error returned by [`ScheduledPrompts::pause`] and /// Typed error returned by [`ScheduledPrompts::pause`] and
/// [`ScheduledPrompts::resume`] when the target row does not exist or /// [`ScheduledPrompts::resume`] when the target row does not exist or
/// is already cancelled. Handlers downcast on this type to emit 404 /// is already cancelled. Handlers downcast on this type to emit 404
@ -186,17 +188,18 @@ impl ScheduledPrompts {
.context("enable foreign keys")?; .context("enable foreign keys")?;
conn.execute_batch(SCHEMA) conn.execute_batch(SCHEMA)
.context("apply scheduled_prompts schema")?; .context("apply scheduled_prompts schema")?;
// Versioned migration: add paused_at_unix. Legacy databases are // Versioned migration: add paused_at_unix. The per-migration
// detected via the presence of this column (it was the only // `adds_column` guard skips the ALTER on a legacy DB that already
// migration before versioning was introduced). // has the column (it was the only migration before versioning).
crate::db::apply_versioned_migrations( crate::db::apply_versioned_migrations(
&conn, &conn,
"scheduled_prompts", "scheduled_prompts",
("scheduled_prompts", "paused_at_unix"),
&[ &[
// v1: add paused_at_unix for per-schedule pause support. // v1: add paused_at_unix for per-schedule pause support.
"ALTER TABLE scheduled_prompts \ Migration {
ADD COLUMN IF NOT EXISTS paused_at_unix INTEGER", sql: "ALTER TABLE scheduled_prompts ADD COLUMN paused_at_unix INTEGER",
adds_column: Some(("scheduled_prompts", "paused_at_unix")),
},
], ],
)?; )?;
// Migration: recreate the due-rows index to also exclude paused // Migration: recreate the due-rows index to also exclude paused