fix: idempotent migrations (ADD COLUMN IF NOT EXISTS), stale doc, safety note

This commit is contained in:
atlas 2026-07-10 16:25:02 +02:00 committed by mara
commit c47faf0af9
5 changed files with 80 additions and 26 deletions

View file

@ -30,14 +30,15 @@ CREATE INDEX IF NOT EXISTS idx_approvals_pending
/// all known migrations. New columns go here as v5, v6, …
const MIGRATIONS: &[&str] = &[
// v1: `kind` (pre-Phase-8 dbs): legacy rows default to `apply_commit`.
"ALTER TABLE approvals ADD COLUMN kind TEXT NOT NULL DEFAULT 'apply_commit'",
"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS \
kind TEXT NOT NULL DEFAULT 'apply_commit'",
// v2: `description`: manager-supplied note on the dashboard card.
"ALTER TABLE approvals ADD COLUMN description TEXT",
"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS description TEXT",
// v3: `fetched_sha`: canonical sha hive-c0re resolved at submit time.
"ALTER TABLE approvals ADD COLUMN fetched_sha TEXT",
"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS fetched_sha TEXT",
// v4: `submitter`: authenticated agent that submitted the approval.
// Legacy rows are NULL → callers fall back to the root agent.
"ALTER TABLE approvals ADD COLUMN submitter TEXT",
"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS submitter TEXT",
];
pub struct Approvals {

View file

@ -162,23 +162,34 @@ pub struct Broker {
/// `messages` — the last column added before versioning.
const BROKER_MIGRATIONS: &[&str] = &[
// v1: acked_at on messages, with backfill so existing delivered rows
// are not phantom-requeued on the next open. The multi-statement
// batch runs atomically (execute_batch wraps in a transaction).
"ALTER TABLE messages ADD COLUMN acked_at INTEGER;\
UPDATE messages SET acked_at = delivered_at WHERE delivered_at IS NOT NULL;",
// are not phantom-requeued on the next open.
//
// IF NOT EXISTS: safe to run on a partially-migrated legacy DB where
// acked_at already exists but priority (the legacy marker) does not.
// The BEGIN/COMMIT block makes ALTER + UPDATE atomic — if the UPDATE
// fails the version counter is not bumped and the whole step retries.
"BEGIN;\
ALTER TABLE messages ADD COLUMN IF NOT EXISTS acked_at INTEGER;\
UPDATE messages SET acked_at = delivered_at \
WHERE delivered_at IS NOT NULL AND acked_at IS NULL;\
COMMIT;",
// v2: in_reply_to for thread-parent tracking. NULL = root of a thread.
"ALTER TABLE messages ADD COLUMN in_reply_to INTEGER",
"ALTER TABLE messages ADD COLUMN IF NOT EXISTS in_reply_to INTEGER",
// v3: priority for operator-message fast-path. Rebuild the delivery
// index to include priority as a secondary sort key.
"ALTER TABLE messages ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;\
"BEGIN;\
ALTER TABLE messages ADD COLUMN IF NOT EXISTS \
priority INTEGER NOT NULL DEFAULT 0;\
DROP INDEX IF EXISTS idx_messages_undelivered;\
CREATE INDEX idx_messages_undelivered \
ON messages (recipient, priority DESC, id) WHERE delivered_at IS NULL",
CREATE INDEX IF NOT EXISTS idx_messages_undelivered \
ON messages (recipient, priority DESC, id) WHERE delivered_at IS NULL;\
COMMIT;",
// v4: attempt_count on reminders for the MAX_REMINDER_ATTEMPTS cap.
"ALTER TABLE reminders ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE reminders ADD COLUMN IF NOT EXISTS \
attempt_count INTEGER NOT NULL DEFAULT 0",
// v5: last_error on reminders — last delivery failure surfaced on the
// dashboard so a stuck reminder is visible without digging in logs.
"ALTER TABLE reminders ADD COLUMN last_error TEXT",
"ALTER TABLE reminders ADD COLUMN IF NOT EXISTS last_error TEXT",
];
impl Broker {
@ -1134,11 +1145,6 @@ impl Broker {
}
}
/// Idempotent messages-table migrations. Adds `acked_at` and
/// back-fills it for every already-delivered row, so the
/// pre-migration sessions count as "fully handled" and won't be
/// resurfaced by the first `requeue_inflight` after upgrade.
#[cfg(test)]
mod tests {
use super::*;

View file

@ -80,6 +80,11 @@ pub fn apply_versioned_migrations(
// 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"
@ -143,9 +148,9 @@ mod tests {
const TEST_SCHEMA: &str = "CREATE TABLE IF NOT EXISTS things (id INTEGER PRIMARY KEY)";
const TEST_MIGRATIONS: &[&str] = &[
"ALTER TABLE things ADD COLUMN alpha TEXT",
"ALTER TABLE things ADD COLUMN beta INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE things ADD COLUMN gamma TEXT",
"ALTER TABLE things ADD COLUMN IF NOT EXISTS alpha TEXT",
"ALTER TABLE things ADD COLUMN IF NOT EXISTS beta INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE things ADD COLUMN IF NOT EXISTS gamma TEXT",
];
#[test]
@ -289,4 +294,44 @@ mod tests {
assert_eq!(va, 1);
assert_eq!(vb, 1);
}
/// A partially-migrated legacy database: some columns from the old
/// try-and-ignore path exist, but the legacy marker column (used to
/// fast-forward fresh schema_versions entries to `len`) is absent.
/// With idempotent `ADD COLUMN IF NOT EXISTS` migrations this must
/// succeed — the already-present columns are skipped and only the
/// missing ones are added.
#[test]
fn partial_legacy_completes_without_error() {
let conn = open_tmp();
// Simulate a DB that has `alpha` (v1) but not `beta` (v2) or
// `gamma` (v3). No schema_versions row exists yet, and `gamma`
// (the legacy marker) is absent → the function cannot fast-forward
// and must start from v0 with idempotent migrations.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS things (id INTEGER PRIMARY KEY, alpha TEXT)",
)
.unwrap();
apply_versioned_migrations(&conn, "things", ("things", "gamma"), TEST_MIGRATIONS).unwrap();
// All three columns must now exist.
for col in ["alpha", "beta", "gamma"] {
let exists: bool = conn
.prepare(&format!(
"SELECT 1 FROM pragma_table_info('things') WHERE name = '{col}'"
))
.unwrap()
.exists([])
.unwrap();
assert!(exists, "column {col} missing after partial-legacy migration");
}
// Version must be at the latest.
let v: i64 = conn
.query_row(
"SELECT version FROM schema_versions WHERE store = 'things'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(v, 3, "must reach latest version after completing partial legacy");
}
}

View file

@ -39,13 +39,14 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
/// 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",
"ALTER TABLE operator_questions ADD COLUMN IF NOT EXISTS \
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",
"ALTER TABLE operator_questions ADD COLUMN IF NOT EXISTS deadline_at INTEGER",
// 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",
"ALTER TABLE operator_questions ADD COLUMN IF NOT EXISTS target TEXT",
];
#[derive(Debug, Clone, Serialize)]

View file

@ -195,7 +195,8 @@ impl ScheduledPrompts {
("scheduled_prompts", "paused_at_unix"),
&[
// v1: add paused_at_unix for per-schedule pause support.
"ALTER TABLE scheduled_prompts ADD COLUMN paused_at_unix INTEGER",
"ALTER TABLE scheduled_prompts \
ADD COLUMN IF NOT EXISTS paused_at_unix INTEGER",
],
)?;
// Migration: recreate the due-rows index to also exclude paused