Skip to main content

mj_controller/database/
schema.rs

1use super::*;
2use rusqlite::OpenFlags;
3
4const COMPATIBILITY_METADATA_VERSION: i64 = 30;
5
6pub(super) struct SchemaState {
7    pub(super) revision: i64,
8    minimum_compatible: Option<i64>,
9}
10
11impl SchemaState {
12    pub(super) fn ensure_supported(&self) -> Result<()> {
13        let reason = if self.revision < SCHEMA_VERSION {
14            StoreSchemaMismatchReason::NeedsMigration
15        } else if let Some(minimum_compatible) = self.minimum_compatible {
16            if minimum_compatible <= SCHEMA_VERSION {
17                return Ok(());
18            }
19            StoreSchemaMismatchReason::Incompatible { minimum_compatible }
20        } else {
21            StoreSchemaMismatchReason::InvalidCompatibilityMetadata
22        };
23        Err(StoreSchemaMismatch {
24            found: self.revision,
25            supported: SCHEMA_VERSION,
26            reason,
27        }
28        .into())
29    }
30}
31
32/// The revision, ledger, and compatibility floor must describe one snapshot.
33/// A missing floor is only legitimate before compatibility was introduced.
34pub(super) fn read_schema_state(connection: &Connection) -> Result<SchemaState> {
35    let snapshot = connection
36        .unchecked_transaction()
37        .context("start database compatibility snapshot")?;
38    let revision: i64 = snapshot
39        .query_row("PRAGMA user_version", [], |row| row.get(0))
40        .context("read database migration revision")?;
41    let minimum_compatible = if revision >= COMPATIBILITY_METADATA_VERSION {
42        let invalid = || StoreSchemaMismatch {
43            found: revision,
44            supported: SCHEMA_VERSION,
45            reason: StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
46        };
47        let (count, singleton, floor, recorded): (i64, Option<i64>, Option<i64>, Option<i64>) =
48            snapshot
49                .query_row(
50                    "SELECT count(*), min(singleton), min(minimum_compatible_version),
51                    (SELECT max(version) FROM schema_migrations)
52             FROM schema_compatibility",
53                    [],
54                    |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
55                )
56                .map_err(|error| {
57                    // Missing tables/columns and invalid field types are
58                    // structural. Busy, I/O, and interruption errors are not
59                    // evidence of an incompatible migration.
60                    let structural = match &error {
61                        rusqlite::Error::SqliteFailure(code, _) => {
62                            code.code == rusqlite::ErrorCode::Unknown
63                        }
64                        _ => true,
65                    };
66                    let error = anyhow::Error::new(error);
67                    if structural {
68                        error.context(invalid())
69                    } else {
70                        error.context("read database compatibility metadata")
71                    }
72                })?;
73        if count != 1
74            || singleton != Some(1)
75            || recorded != Some(revision)
76            || !floor
77                .is_some_and(|floor| (COMPATIBILITY_METADATA_VERSION..=revision).contains(&floor))
78        {
79            return Err(invalid().into());
80        }
81        floor
82    } else {
83        None
84    };
85    snapshot
86        .commit()
87        .context("finish database compatibility snapshot")?;
88    Ok(SchemaState {
89        revision,
90        minimum_compatible,
91    })
92}
93
94pub fn database_path() -> PathBuf {
95    data_dir().join("mj.sqlite3")
96}
97
98pub(super) fn open_writer(path: &Path) -> Result<Connection> {
99    if let Some(parent) = path.parent() {
100        fs::create_dir_all(parent)
101            .with_context(|| format!("create Mjolnir data directory {}", parent.display()))?;
102    }
103    let connection = Connection::open(path)
104        .with_context(|| format!("open Mjolnir database {}", path.display()))?;
105    connection.busy_timeout(Duration::from_secs(5))?;
106    connection.execute_batch(
107        "PRAGMA foreign_keys = ON;
108         PRAGMA journal_mode = WAL;
109         PRAGMA synchronous = FULL;",
110    )?;
111    verify_schema_once(path, &connection)?;
112    Ok(connection)
113}
114
115pub(super) fn open(path: &Path) -> Result<Connection> {
116    open_writer(path)
117}
118
119/// Open an existing database without permitting schema or data mutation.
120/// Client processes use this path so an accidental write fails locally
121/// instead of competing with the daemon's writer.
122#[cfg(not(test))]
123pub(super) fn open_reader(path: &Path) -> Result<Connection> {
124    open_reader_strict(path)
125}
126
127#[cfg(test)]
128pub(super) fn open_reader(path: &Path) -> Result<Connection> {
129    // Path-taking database helpers are migration fixtures in unit tests: they
130    // intentionally open old or not-yet-created schemas. Production query
131    // entry points compile against the strict reader above.
132    open_writer(path)
133}
134
135#[cfg_attr(test, allow(dead_code))]
136fn open_reader_strict(path: &Path) -> Result<Connection> {
137    let connection = Connection::open_with_flags(
138        path,
139        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
140    )
141    .with_context(|| format!("open Mjolnir database read-only {}", path.display()))?;
142    connection.busy_timeout(Duration::from_secs(5))?;
143    connection.execute_batch(
144        "PRAGMA foreign_keys = ON;
145         PRAGMA query_only = ON;",
146    )?;
147    read_schema_state(&connection)?.ensure_supported()?;
148    Ok(connection)
149}
150
151/// Databases this process has already migrated. A controller owns its store
152/// exclusively (`ControllerStoreGuard`), so a schema verified once stays
153/// verified and later connections skip the migration probes entirely.
154fn verified_schemas() -> &'static Mutex<HashSet<PathBuf>> {
155    static VERIFIED: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
156    VERIFIED.get_or_init(|| Mutex::new(HashSet::new()))
157}
158
159/// Stable cache identity for a database. The file itself may not exist yet, so
160/// the canonicalized parent directory carries the identity.
161fn schema_cache_key(path: &Path) -> PathBuf {
162    let Some(parent) = path
163        .parent()
164        .filter(|parent| !parent.as_os_str().is_empty())
165    else {
166        return path.to_owned();
167    };
168    match (fs::canonicalize(parent), path.file_name()) {
169        (Ok(canonical), Some(name)) => canonical.join(name),
170        _ => path.to_owned(),
171    }
172}
173
174/// Run the migration ladder the first time this process opens a database.
175/// Later opens confirm compatibility without repeating schema repairs. A
176/// database behind this build is migrated again, so a recreated file under a
177/// reused path still converges. Compatible future stores are never repaired.
178fn verify_schema_once(path: &Path, connection: &Connection) -> Result<()> {
179    let key = schema_cache_key(path);
180    let mut verified = verified_schemas()
181        .lock()
182        .unwrap_or_else(PoisonError::into_inner);
183    let state = read_schema_state(connection)?;
184    if state.revision > SCHEMA_VERSION
185        || (state.revision == SCHEMA_VERSION && verified.contains(&key))
186    {
187        // An older build must never run its repairs against a newer schema.
188        return state.ensure_supported();
189    }
190    // Holding the lock across the ladder keeps two first opens of the same
191    // database from running the additive migration steps against each other.
192    migrate_schema(connection)?;
193    read_schema_state(connection)?.ensure_supported()?;
194    verified.insert(key);
195    Ok(())
196}
197
198/// Forget that this process verified a database's schema. Only tests need it:
199/// they simulate a store written by an older build by editing the schema of a
200/// database this process has already opened, which no controller can do.
201#[cfg(test)]
202pub(super) fn forget_verified_schema(path: &Path) {
203    verified_schemas()
204        .lock()
205        .unwrap_or_else(PoisonError::into_inner)
206        .remove(&schema_cache_key(path));
207}
208
209fn migrate_schema(connection: &Connection) -> Result<()> {
210    let state = read_schema_state(connection)?;
211    let version = state.revision;
212    if version > SCHEMA_VERSION {
213        return state.ensure_supported();
214    }
215    if version == 0 {
216        connection.execute_batch(
217            "BEGIN IMMEDIATE;
218             CREATE TABLE schema_migrations (
219                 version INTEGER PRIMARY KEY CHECK(version > 0),
220                 applied_at TEXT NOT NULL
221             ) STRICT;
222             CREATE TABLE session_contexts (
223                 session_id TEXT PRIMARY KEY,
224                 bundle_id TEXT NOT NULL,
225                 created_at TEXT NOT NULL
226             ) STRICT;
227             CREATE TABLE sessions (
228                 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
229                 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
230                 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi')),
231                 last_profile TEXT NOT NULL,
232                 target_template_id TEXT NOT NULL,
233                 state TEXT NOT NULL CHECK(state IN (
234                     'provisioning','running','disconnected','checkpointing','closing','destroying',
235                     'archived','lost','error','destroyed-with-data-loss'
236                 )),
237                 native_session_id TEXT,
238                 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
239                 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
240                 updated_at TEXT NOT NULL,
241                 last_viewed_event_sequence INTEGER NOT NULL DEFAULT 0 CHECK(last_viewed_event_sequence >= 0),
242                 last_error TEXT
243             ) STRICT;
244             CREATE TABLE session_targets (
245                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
246                 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','apple-container','aws-ec2','ssh-bare','ssh-podman')),
247                 host TEXT,
248                 resource_id TEXT,
249                 address TEXT,
250                 workspace BLOB,
251                 worker_id TEXT,
252                 CHECK(
253                     (kind = 'local-bare' AND workspace IS NOT NULL
254                      AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
255                  OR (kind IN ('local-podman','apple-container') AND resource_id IS NOT NULL
256                      AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
257                  OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
258                      AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
259                  OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
260                      AND resource_id IS NULL AND address IS NULL)
261                  OR (kind = 'ssh-podman' AND host IS NOT NULL AND resource_id IS NOT NULL
262                      AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
263                 )
264             ) STRICT;
265             CREATE TABLE session_mounts (
266                 session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
267                 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
268                 source BLOB NOT NULL,
269                 destination BLOB NOT NULL,
270                 PRIMARY KEY(session_id, ordinal),
271                 UNIQUE(session_id, destination)
272             ) STRICT;
273             CREATE TABLE session_checkpoints (
274                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
275                 archive_path BLOB NOT NULL,
276                 sha256 TEXT NOT NULL CHECK(length(sha256) = 64 AND sha256 NOT GLOB '*[^0-9a-f]*'),
277                 created_at TEXT NOT NULL,
278                 event_sequence INTEGER NOT NULL CHECK(event_sequence >= 0)
279             ) STRICT;
280             CREATE TABLE mount_history (
281                 host TEXT NOT NULL CHECK(length(trim(host)) > 0),
282                 source BLOB NOT NULL,
283                 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
284                 PRIMARY KEY(host, ordinal),
285                 UNIQUE(host, source)
286             ) STRICT;
287             CREATE TABLE prompt_history (
288                 history_id INTEGER PRIMARY KEY,
289                 session_id TEXT NOT NULL REFERENCES session_contexts(session_id),
290                 event_sequence INTEGER NOT NULL CHECK(event_sequence >= 0),
291                 submitted_at TEXT NOT NULL,
292                 text TEXT NOT NULL CHECK(length(trim(text)) > 0),
293                 UNIQUE(session_id, event_sequence)
294             ) STRICT;
295             CREATE INDEX prompt_history_session_recent
296                 ON prompt_history(session_id, history_id DESC);
297             CREATE INDEX session_contexts_bundle
298                 ON session_contexts(bundle_id, session_id);
299             CREATE INDEX prompt_history_recent
300                 ON prompt_history(history_id DESC);
301             INSERT INTO schema_migrations(version, applied_at)
302                 VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
303             PRAGMA user_version = 1;
304             COMMIT;",
305        )?;
306    }
307    if version < 2 {
308        connection.execute_batch(
309            "BEGIN IMMEDIATE;
310             ALTER TABLE sessions ADD COLUMN resource_allocation TEXT;
311             INSERT INTO schema_migrations(version, applied_at)
312                 VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
313             PRAGMA user_version = 2;
314             COMMIT;",
315        )?;
316    }
317    if version < 3 {
318        connection.execute_batch(
319            "BEGIN IMMEDIATE;
320             ALTER TABLE sessions ADD COLUMN last_checkpoint_error TEXT;
321             INSERT INTO schema_migrations(version, applied_at)
322                 VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
323             PRAGMA user_version = 3;
324             COMMIT;",
325        )?;
326    }
327    if version < 4 {
328        connection.execute_batch(
329            "BEGIN IMMEDIATE;
330             ALTER TABLE sessions ADD COLUMN project_directory BLOB;
331             INSERT INTO schema_migrations(version, applied_at)
332                 VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
333             PRAGMA user_version = 4;
334             COMMIT;",
335        )?;
336    }
337    if version < 5 {
338        connection.execute_batch(
339            "BEGIN IMMEDIATE;
340             ALTER TABLE session_targets RENAME TO session_targets_v4;
341             CREATE TABLE session_targets (
342                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
343                 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','apple-container','aws-ec2','ssh-bare','ssh-podman')),
344                 host TEXT,
345                 resource_id TEXT,
346                 address TEXT,
347                 workspace BLOB,
348                 worker_id TEXT,
349                 CHECK(
350                     (kind = 'local-bare' AND workspace IS NOT NULL
351                      AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
352                  OR (kind IN ('local-podman','apple-container') AND resource_id IS NOT NULL
353                      AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
354                  OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
355                      AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
356                  OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
357                      AND resource_id IS NULL AND address IS NULL)
358                  OR (kind = 'ssh-podman' AND host IS NOT NULL AND resource_id IS NOT NULL
359                      AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
360                 )
361             ) STRICT;
362             INSERT INTO session_targets
363                 SELECT * FROM session_targets_v4;
364             DROP TABLE session_targets_v4;
365             INSERT INTO schema_migrations(version, applied_at)
366                 VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
367             PRAGMA user_version = 5;
368             COMMIT;",
369        )?;
370    }
371    if version < 6 {
372        connection.execute_batch(&format!(
373            "BEGIN IMMEDIATE;
374             ALTER TABLE session_checkpoints
375                 RENAME COLUMN event_sequence TO event_frontier;
376             ALTER TABLE prompt_history
377                 RENAME COLUMN event_sequence TO event_ordinal;
378             ALTER TABLE sessions ADD COLUMN detached_after_event_ordinal INTEGER NOT NULL
379                 DEFAULT 0 CHECK(detached_after_event_ordinal >= 0);
380             ALTER TABLE sessions ADD COLUMN managed_worktree TEXT;
381             CREATE TABLE materialized_sessions (
382                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
383                 applied_event_ordinal INTEGER NOT NULL DEFAULT 0 CHECK(applied_event_ordinal >= 0),
384                 applied_event_digest TEXT NOT NULL
385                     DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
386                     CHECK(length(applied_event_digest) = 64
387                           AND applied_event_digest NOT GLOB '*[^0-9a-f]*'),
388                 last_activity_at_ms INTEGER,
389                 execution_state TEXT NOT NULL DEFAULT 'idle'
390                     CHECK(execution_state IN ('idle','running','closing','closed')),
391                 running_started_at_ms INTEGER,
392                 session_title TEXT CHECK(session_title IS NULL OR length(trim(session_title)) > 0),
393                 configuration_json TEXT NOT NULL DEFAULT '{{}}',
394                 CHECK(
395                     (execution_state = 'running' AND running_started_at_ms IS NOT NULL)
396                     OR (execution_state != 'running' AND running_started_at_ms IS NULL)
397                 )
398             ) STRICT;
399             CREATE TABLE materialized_transcript_items (
400                 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
401                 stable_id TEXT NOT NULL CHECK(length(trim(stable_id)) > 0),
402                 position INTEGER NOT NULL CHECK(position > 0),
403                 latest_content_event_ordinal INTEGER
404                     CHECK(latest_content_event_ordinal IS NULL
405                           OR latest_content_event_ordinal >= position),
406                 created_at_ms INTEGER NOT NULL,
407                 last_changed_at_ms INTEGER NOT NULL CHECK(last_changed_at_ms >= created_at_ms),
408                 body_json TEXT NOT NULL,
409                 PRIMARY KEY(session_id, stable_id)
410             ) STRICT;
411             CREATE INDEX materialized_transcript_position
412                 ON materialized_transcript_items(session_id, position, stable_id);
413             CREATE TABLE materialized_queued_prompts (
414                 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
415                 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
416                 command_id TEXT NOT NULL CHECK(length(trim(command_id)) > 0),
417                 content_json TEXT NOT NULL,
418                 queued_at_ms INTEGER NOT NULL,
419                 PRIMARY KEY(session_id, ordinal),
420                 UNIQUE(session_id, command_id)
421             ) STRICT;
422             INSERT INTO materialized_sessions(session_id)
423                 SELECT session_id FROM sessions;
424             INSERT INTO schema_migrations(version, applied_at)
425                 VALUES (6, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
426             PRAGMA user_version = 6;
427             COMMIT;",
428        ))?;
429    }
430    // Both development lines used schema version 6: durable relay projection
431    // on this branch and managed raw-session worktrees on master. Structural
432    // guards make either already-written v6 database converge before the v7
433    // sessions-table rebuild, without inventing a second version-6 ledger row.
434    ensure_managed_worktree_column(connection)?;
435    if version < 7 {
436        ensure_relay_projection_schema(connection)?;
437        migrate_destroying_session_state(connection)?;
438    }
439    ensure_projection_digest_column(connection)?;
440    ensure_session_draft_input_column(connection)?;
441    if version < 8 {
442        // Queue entries gained a kind so a configuration change can wait in the
443        // same queue as prompts. Rows written before that are prompts.
444        connection.execute_batch(
445            "BEGIN IMMEDIATE;
446             ALTER TABLE materialized_queued_prompts
447                 ADD COLUMN kind_json TEXT NOT NULL DEFAULT '\"prompt\"';
448             INSERT INTO schema_migrations(version, applied_at)
449                 VALUES (8, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
450             PRAGMA user_version = 8;
451             COMMIT;",
452        )?;
453    }
454    // Runs last: it rebuilds `sessions`, so every column the steps above add
455    // must already exist to be copied forward.
456    if version < 9 {
457        migrate_grok_harness_kind(connection)?;
458    }
459    // Added after the v9 rebuild so the rebuild never has to copy them.
460    ensure_session_container_override_columns(connection)?;
461    ensure_session_mount_read_only_column(connection)?;
462    ensure_materialized_elicitation_column(connection)?;
463    connection.execute_batch(
464        "CREATE TABLE IF NOT EXISTS profile_config_cache (
465        profile TEXT NOT NULL, model TEXT NOT NULL, fingerprint TEXT NOT NULL,
466        observed_at INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(profile, model));
467        CREATE TABLE IF NOT EXISTS api_config_results (
468        session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
469        command_id TEXT NOT NULL, error TEXT, PRIMARY KEY(session_id, command_id));
470        CREATE TABLE IF NOT EXISTS session_turn_usage (
471        session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
472        command_id TEXT NOT NULL, completed_ordinal INTEGER NOT NULL, turn_start_position INTEGER,
473        body TEXT NOT NULL, PRIMARY KEY(session_id, command_id));
474        CREATE INDEX IF NOT EXISTS session_turn_usage_order ON session_turn_usage(session_id, completed_ordinal);
475        CREATE TABLE IF NOT EXISTS session_provider_cost (
476        session_id TEXT PRIMARY KEY REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
477        body TEXT NOT NULL);",
478    )?;
479
480    if version < 10 {
481        migrate_stopped_session_state(connection)?;
482    }
483    if version < 11 {
484        migrate_deepseek_harness_kind(connection)?;
485    }
486    if version < 12 {
487        connection.execute_batch(
488            "BEGIN IMMEDIATE;
489             ALTER TABLE sessions
490                 RENAME COLUMN detached_after_event_ordinal TO viewed_through_event_ordinal;
491             UPDATE sessions
492                 SET target_template_id = 'localhost'
493                 WHERE target_template_id = 'raw-localhost';
494             INSERT INTO schema_migrations(version, applied_at)
495                 VALUES (12, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
496             PRAGMA user_version = 12;
497             COMMIT;",
498        )?;
499    }
500    if version < 13 {
501        connection.execute_batch(
502            "BEGIN IMMEDIATE;
503             UPDATE sessions
504                SET state = 'error'
505              WHERE state = 'lost'
506                AND EXISTS(
507                    SELECT 1
508                      FROM session_checkpoints
509                     WHERE session_checkpoints.session_id = sessions.session_id
510                );
511             INSERT INTO schema_migrations(version, applied_at)
512                 VALUES (13, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
513             PRAGMA user_version = 13;
514             COMMIT;",
515        )?;
516    }
517    if version < 14 {
518        ensure_workspace_schema(connection)?;
519        connection.execute_batch(
520            "BEGIN IMMEDIATE;
521             INSERT OR IGNORE INTO schema_migrations(version, applied_at)
522                 VALUES (14, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
523             PRAGMA user_version = 14;
524             COMMIT;",
525        )?;
526    }
527    if version < 15 {
528        connection.execute_batch(
529            "BEGIN IMMEDIATE;
530             CREATE TABLE host_container_sizes (
531                 host TEXT PRIMARY KEY CHECK(length(trim(host)) > 0),
532                 cpus INTEGER NOT NULL CHECK(cpus > 0),
533                 memory_bytes INTEGER NOT NULL CHECK(memory_bytes > 0)
534             ) STRICT;
535             INSERT INTO schema_migrations(version, applied_at)
536                 VALUES (15, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
537             PRAGMA user_version = 15;
538             COMMIT;",
539        )?;
540    }
541    if version < 16 {
542        connection.execute_batch(
543            "BEGIN IMMEDIATE;
544             CREATE TABLE second_opinion_defaults (
545                 workspace_id TEXT NOT NULL CHECK(length(trim(workspace_id)) > 0),
546                 profile_id TEXT NOT NULL CHECK(length(trim(profile_id)) > 0),
547                 model TEXT NOT NULL,
548                 effort TEXT NOT NULL,
549                 PRIMARY KEY (workspace_id, profile_id, model)
550             ) STRICT;
551             INSERT INTO schema_migrations(version, applied_at)
552                 VALUES (16, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
553             PRAGMA user_version = 16;
554             COMMIT;",
555        )?;
556    }
557    if version < 17 {
558        connection.execute_batch(
559            "BEGIN IMMEDIATE;
560             CREATE TABLE second_opinion_reviews (
561                 session_id TEXT PRIMARY KEY
562                     REFERENCES sessions(session_id) ON DELETE CASCADE,
563                 workflow TEXT NOT NULL,
564                 generation INTEGER NOT NULL CHECK(generation >= 0),
565                 context_baseline INTEGER NOT NULL CHECK(context_baseline >= 0),
566                 native_lost INTEGER NOT NULL CHECK(native_lost IN (0, 1))
567             ) STRICT;
568             INSERT INTO schema_migrations(version, applied_at)
569                 VALUES (17, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
570             PRAGMA user_version = 17;
571             COMMIT;",
572        )?;
573    }
574    if version < 18 {
575        // The reviewer's conversation lives in its own journal on the target.
576        // Losing the target takes that journal with it, so the controller
577        // keeps a copy of what it has already read: the conversation stays
578        // readable for reference even though it can no longer be continued.
579        connection.execute_batch(
580            "BEGIN IMMEDIATE;
581             ALTER TABLE second_opinion_reviews
582                 ADD COLUMN reviewer_transcript TEXT NOT NULL DEFAULT '[]';
583             INSERT INTO schema_migrations(version, applied_at)
584                 VALUES (18, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
585             PRAGMA user_version = 18;
586             COMMIT;",
587        )?;
588    }
589    if version < 19 {
590        // Turn review is per workspace (is it on, and at which tier) and per
591        // session (what has already been reviewed). Neither belongs on
592        // `SessionRecord`, which is a compatibility surface with nine
593        // construction sites; `second_opinion_reviews` above is the precedent
594        // for keeping review state in its own table.
595        connection.execute_batch(
596            "BEGIN IMMEDIATE;
597             CREATE TABLE turn_review_settings (
598                 workspace_id TEXT PRIMARY KEY
599                     CHECK(length(trim(workspace_id)) > 0),
600                 auto_review INTEGER NOT NULL CHECK(auto_review IN (0, 1)),
601                 tier TEXT NOT NULL CHECK(tier IN ('quick', 'extended'))
602             ) STRICT;
603             CREATE TABLE turn_review_state (
604                 session_id TEXT PRIMARY KEY
605                     REFERENCES sessions(session_id) ON DELETE CASCADE,
606                 baselines TEXT NOT NULL,
607                 reviewed_through_ordinal INTEGER NOT NULL
608                     CHECK(reviewed_through_ordinal >= 0),
609                 prior_review TEXT,
610                 active TEXT
611             ) STRICT;
612             INSERT INTO schema_migrations(version, applied_at)
613                 VALUES (19, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
614             PRAGMA user_version = 19;
615             COMMIT;",
616        )?;
617    }
618    if version < 20 {
619        let target_table_exists: bool = connection.query_row(
620            "SELECT EXISTS(
621                 SELECT 1 FROM sqlite_master
622                 WHERE type = 'table' AND name = 'session_targets'
623             )",
624            [],
625            |row| row.get(0),
626        )?;
627        let rebuild = if target_table_exists {
628            "ALTER TABLE session_targets RENAME TO session_targets_v19;"
629        } else {
630            ""
631        };
632        let copy = if target_table_exists {
633            "INSERT INTO session_targets SELECT * FROM session_targets_v19;
634             DROP TABLE session_targets_v19;"
635        } else {
636            ""
637        };
638        connection.execute_batch(&format!(
639            "BEGIN IMMEDIATE;
640             {rebuild}
641             CREATE TABLE session_targets (
642                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
643                 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','local-docker','apple-container','aws-ec2','ssh-bare','ssh-podman')),
644                 host TEXT,
645                 resource_id TEXT,
646                 address TEXT,
647                 workspace BLOB,
648                 worker_id TEXT,
649                 CHECK(
650                     (kind = 'local-bare' AND workspace IS NOT NULL
651                      AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
652                  OR (kind IN ('local-podman','local-docker','apple-container') AND resource_id IS NOT NULL
653                      AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
654                  OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
655                      AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
656                  OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
657                      AND resource_id IS NULL AND address IS NULL)
658                  OR (kind = 'ssh-podman' AND host IS NOT NULL AND resource_id IS NOT NULL
659                      AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
660                 )
661             ) STRICT;
662             {copy}
663             INSERT INTO schema_migrations(version, applied_at)
664                 VALUES (20, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
665             PRAGMA user_version = 20;
666             COMMIT;"
667        ))?;
668    }
669    if version < 21 {
670        // Arming review moved into `[review]` in config.toml, which is where
671        // the rest of Mjolnir's durable global configuration lives and the only
672        // place a phone-only user could ever have set it. No data is
673        // migrated: a workspace-to-global mapping has no defensible merge
674        // rule, and the release note says to re-arm it in the config file.
675        connection.execute_batch(
676            "BEGIN IMMEDIATE;
677             DROP TABLE IF EXISTS turn_review_settings;
678             INSERT INTO schema_migrations(version, applied_at)
679                 VALUES (21, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
680             PRAGMA user_version = 21;
681             COMMIT;",
682        )?;
683    }
684    if version < 22 {
685        connection.execute_batch(
686            "BEGIN IMMEDIATE;
687             ALTER TABLE session_targets ADD COLUMN workspace_storage TEXT;
688             INSERT INTO schema_migrations(version, applied_at)
689                 VALUES (22, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
690             PRAGMA user_version = 22;
691             COMMIT;",
692        )?;
693    }
694    if version < 23 {
695        let add_pending_forward =
696            if table_has_column(connection, "turn_review_state", "pending_forward")? {
697                ""
698            } else {
699                "ALTER TABLE turn_review_state ADD COLUMN pending_forward TEXT;"
700            };
701        connection.execute_batch(&format!(
702            "BEGIN IMMEDIATE;
703             {add_pending_forward}
704             INSERT INTO schema_migrations(version, applied_at)
705                 VALUES (23, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
706             PRAGMA user_version = 23;
707             COMMIT;"
708        ))?;
709    }
710    if version < 24 {
711        connection.execute_batch(
712            "BEGIN IMMEDIATE;
713             ALTER TABLE session_targets RENAME TO session_targets_v23;
714             CREATE TABLE session_targets (
715                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
716                 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','local-docker','apple-container','aws-ec2','ssh-bare','ssh-podman','ssh-docker')),
717                 host TEXT,
718                 resource_id TEXT,
719                 address TEXT,
720                 workspace BLOB,
721                 worker_id TEXT,
722                 workspace_storage TEXT,
723                 CHECK(
724                     (kind = 'local-bare' AND workspace IS NOT NULL
725                      AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
726                  OR (kind IN ('local-podman','local-docker','apple-container') AND resource_id IS NOT NULL
727                      AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
728                  OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
729                      AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
730                  OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
731                      AND resource_id IS NULL AND address IS NULL)
732                  OR (kind IN ('ssh-podman','ssh-docker') AND host IS NOT NULL AND resource_id IS NOT NULL
733                      AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
734                 )
735             ) STRICT;
736             INSERT INTO session_targets SELECT * FROM session_targets_v23;
737             DROP TABLE session_targets_v23;
738             INSERT INTO schema_migrations(version, applied_at)
739                 VALUES (24, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
740             PRAGMA user_version = 24;
741             COMMIT;",
742        )?;
743    }
744    if version < 25 {
745        connection.execute_batch(
746            "BEGIN IMMEDIATE;
747             CREATE TABLE workspace_pane_sizes (
748                 workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
749                 sessions TEXT NOT NULL CHECK(sessions IN ('minimized', 'standard', 'maximized')),
750                 targets TEXT NOT NULL CHECK(targets IN ('minimized', 'standard', 'maximized')),
751                 quota TEXT NOT NULL CHECK(quota IN ('minimized', 'standard', 'maximized')),
752                 CHECK((sessions = 'maximized') + (targets = 'maximized') + (quota = 'maximized') <= 1)
753             ) STRICT;
754             INSERT INTO schema_migrations(version, applied_at)
755                 VALUES (25, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
756             PRAGMA user_version = 25;
757             COMMIT;",
758        )?;
759    }
760    if version < 26 {
761        connection.execute_batch(
762            "BEGIN IMMEDIATE;
763             CREATE TABLE session_moves (
764                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
765                 operation_id TEXT NOT NULL UNIQUE,
766                 operation_json TEXT NOT NULL CHECK(json_valid(operation_json))
767             ) STRICT;
768             INSERT INTO schema_migrations(version, applied_at)
769                 VALUES (26, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
770             PRAGMA user_version = 26;
771             COMMIT;",
772        )?;
773    }
774    if version < 27 {
775        migrate_muse_harness_kind(connection)?;
776    }
777    // The projection gained per-turn identity and outcome so a caller driving a
778    // session through the HTTP API can wait for a specific prompt and read how
779    // it ended. `api_idempotency` makes session creation retry-safe.
780    if version < 28 {
781        migrate_turn_outcome_columns(connection)?;
782    }
783    if version < 29 {
784        connection.execute_batch(
785            "BEGIN IMMEDIATE;
786             ALTER TABLE sessions ADD COLUMN create_managed_worktree INTEGER
787                 CHECK(create_managed_worktree IN (0, 1));
788             INSERT INTO schema_migrations(version, applied_at)
789                 VALUES (29, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
790             PRAGMA user_version = 29;
791             COMMIT;",
792        )?;
793    }
794    if version < 30 {
795        migrate_compatibility_metadata(connection)?;
796    }
797    if version < 31 {
798        migrate_subagent_sessions(connection)?;
799    }
800    let recorded: Option<i64> =
801        connection.query_row("SELECT max(version) FROM schema_migrations", [], |row| {
802            row.get(0)
803        })?;
804    if recorded != Some(SCHEMA_VERSION) {
805        bail!(
806            "Mjolnir database migration ledger {:?} does not match schema {}",
807            recorded,
808            SCHEMA_VERSION
809        );
810    }
811    // A database that already applied migration 14 with a build older than the
812    // one that added `client_session_state` never got the table, since that
813    // migration only ran `ensure_workspace_schema` once, at version 14. Create
814    // it unconditionally (IF NOT EXISTS) on every writer open so an
815    // already-migrated database converges too.
816    ensure_client_session_state_schema(connection)?;
817    ensure_api_events_schema(connection)?;
818    Ok(())
819}
820
821/// Breaking baseline: earlier executables reject every newer revision.
822/// Later compatible migrations retain the floor; breaking ones raise it to
823/// their revision in the same transaction as their schema and ledger changes.
824fn migrate_compatibility_metadata(connection: &Connection) -> Result<()> {
825    let transaction = connection.unchecked_transaction()?;
826    transaction.execute_batch(
827        "CREATE TABLE schema_compatibility (
828             singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
829             minimum_compatible_version INTEGER NOT NULL CHECK(minimum_compatible_version >= 30)
830         ) STRICT;
831         INSERT INTO schema_compatibility(singleton, minimum_compatible_version) VALUES (1, 30);
832         INSERT INTO schema_migrations(version, applied_at)
833             VALUES (30, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
834         PRAGMA user_version = 30;",
835    )?;
836    transaction.commit()?;
837    Ok(())
838}
839
840/// Breaking migration: older controllers do not understand that a child
841/// borrows its parent's target and could destroy shared resources.
842fn migrate_subagent_sessions(connection: &Connection) -> Result<()> {
843    let transaction = connection.unchecked_transaction()?;
844    transaction.execute_batch(
845        "CREATE TABLE IF NOT EXISTS subagent_sessions (
846             child_session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
847             parent_session_id TEXT NOT NULL REFERENCES sessions(session_id),
848             request_key TEXT NOT NULL,
849             record_json TEXT NOT NULL CHECK(json_valid(record_json)),
850             CHECK(child_session_id <> parent_session_id),
851             UNIQUE(parent_session_id, request_key)
852         ) STRICT;
853         CREATE INDEX IF NOT EXISTS subagent_sessions_parent
854             ON subagent_sessions(parent_session_id, child_session_id);
855         UPDATE schema_compatibility SET minimum_compatible_version = 31
856             WHERE singleton = 1;
857         INSERT INTO schema_migrations(version, applied_at)
858             VALUES (31, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
859         PRAGMA user_version = 31;",
860    )?;
861    transaction.commit()?;
862    Ok(())
863}
864
865pub(super) fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
866    connection
867        .query_row(
868            "SELECT EXISTS(
869                 SELECT 1 FROM pragma_table_info(?1)
870                 WHERE name = ?2
871             )",
872            params![table, column],
873            |row| row.get(0),
874        )
875        .map_err(Into::into)
876}
877
878fn ensure_workspace_schema(connection: &Connection) -> Result<()> {
879    connection.execute_batch(
880        "CREATE TABLE IF NOT EXISTS workspaces (
881             workspace_id TEXT PRIMARY KEY CHECK(length(trim(workspace_id)) > 0),
882             name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 64),
883             name_key TEXT NOT NULL UNIQUE CHECK(length(trim(name_key)) BETWEEN 1 AND 64),
884             created_at TEXT NOT NULL,
885             last_opened_at TEXT NOT NULL
886         ) STRICT;
887         INSERT OR IGNORE INTO workspaces(
888             workspace_id, name, name_key, created_at, last_opened_at
889         ) VALUES (
890             'default', 'default', 'default',
891             strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
892             strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
893         );",
894    )?;
895    if !table_has_column(connection, "session_contexts", "workspace_id")? {
896        connection.execute_batch(
897            "ALTER TABLE session_contexts
898                 ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default';",
899        )?;
900    }
901    connection.execute_batch(
902        "CREATE INDEX IF NOT EXISTS session_contexts_workspace
903             ON session_contexts(workspace_id, session_id);
904         CREATE TRIGGER IF NOT EXISTS session_contexts_workspace_insert
905         BEFORE INSERT ON session_contexts
906         WHEN NOT EXISTS(
907             SELECT 1 FROM workspaces WHERE workspace_id = NEW.workspace_id
908         )
909         BEGIN
910             SELECT RAISE(ABORT, 'unknown workspace');
911         END;
912         CREATE TRIGGER IF NOT EXISTS session_contexts_workspace_update
913         BEFORE UPDATE OF workspace_id ON session_contexts
914         WHEN NOT EXISTS(
915             SELECT 1 FROM workspaces WHERE workspace_id = NEW.workspace_id
916         )
917         BEGIN
918             SELECT RAISE(ABORT, 'unknown workspace');
919         END;
920         CREATE TABLE IF NOT EXISTS client_read_frontiers (
921             client_id TEXT NOT NULL CHECK(length(trim(client_id)) > 0),
922             workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
923             session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
924             through_event_ordinal INTEGER NOT NULL DEFAULT 0
925                 CHECK(through_event_ordinal >= 0),
926             updated_at TEXT NOT NULL,
927             PRIMARY KEY(client_id, workspace_id, session_id)
928         ) STRICT;
929         CREATE TABLE IF NOT EXISTS detached_drafts (
930             draft_id TEXT PRIMARY KEY CHECK(length(trim(draft_id)) > 0),
931             workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id),
932             session_id TEXT REFERENCES session_contexts(session_id),
933             source TEXT NOT NULL CHECK(length(trim(source)) > 0),
934             owner_pid INTEGER CHECK(owner_pid IS NULL OR owner_pid > 0),
935             saved_at TEXT NOT NULL,
936             text TEXT NOT NULL CHECK(length(text) > 0),
937             recovered_at TEXT
938         ) STRICT;
939         CREATE INDEX IF NOT EXISTS detached_drafts_workspace_recent
940             ON detached_drafts(workspace_id, saved_at DESC);",
941    )?;
942    ensure_client_session_state_schema(connection)?;
943    Ok(())
944}
945
946/// Per-viewer, per-session state a web client keeps between visits.
947///
948/// This is additive and separate from `client_read_frontiers` on purpose. A
949/// frontier is a cursor every client has; a draft is text one viewer typed and
950/// did not send, and it expires. Keeping them apart means the phone's
951/// retention policy cannot reach a terminal client's cursor.
952fn ensure_client_session_state_schema(connection: &Connection) -> Result<()> {
953    connection.execute_batch(
954        "CREATE TABLE IF NOT EXISTS client_session_state (
955             client_id TEXT NOT NULL CHECK(length(trim(client_id)) > 0),
956             workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
957             session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
958             draft TEXT NOT NULL DEFAULT '',
959             updated_at TEXT NOT NULL,
960             PRIMARY KEY(client_id, workspace_id, session_id)
961         ) STRICT;
962         CREATE INDEX IF NOT EXISTS client_session_state_age
963             ON client_session_state(updated_at);",
964    )?;
965    Ok(())
966}
967
968/// Per-session container size overrides. They are additive columns, so
969/// databases written before the dashboard could edit them open unchanged.
970fn ensure_session_container_override_columns(connection: &Connection) -> Result<()> {
971    for column in ["container_cpus", "container_memory"] {
972        if !table_has_column(connection, "sessions", column)? {
973            connection.execute_batch(&format!(
974                "BEGIN IMMEDIATE;
975                 ALTER TABLE sessions ADD COLUMN {column} TEXT;
976                 COMMIT;"
977            ))?;
978        }
979    }
980    Ok(())
981}
982
983/// Per-mount read-only flag. It is an additive column, so a database written
984/// before the mount editors offered the option opens unchanged and its mounts
985/// keep the copy-on-write overlay they were provisioned with.
986/// Add the per-turn identity and outcome columns, the queue's acceptance
987/// ordinal, and the API idempotency ledger.
988///
989/// Each addition is guarded by a structural check rather than by the version
990/// alone. A database rebuilt by another build's ladder — or by a test that
991/// rewinds `user_version` — can already carry some of these, and a bare
992/// `ALTER TABLE` would then fail the whole open.
993fn migrate_turn_outcome_columns(connection: &Connection) -> Result<()> {
994    let mut statements = String::from("BEGIN IMMEDIATE;\n");
995    if !table_has_column(connection, "materialized_sessions", "active_turn_json")? {
996        statements.push_str(
997            "ALTER TABLE materialized_sessions ADD COLUMN active_turn_json TEXT
998                 CHECK(active_turn_json IS NULL OR json_valid(active_turn_json));\n",
999        );
1000    }
1001    if !table_has_column(
1002        connection,
1003        "materialized_sessions",
1004        "last_turn_outcome_json",
1005    )? {
1006        statements.push_str(
1007            "ALTER TABLE materialized_sessions ADD COLUMN last_turn_outcome_json TEXT
1008                 CHECK(last_turn_outcome_json IS NULL OR json_valid(last_turn_outcome_json));\n",
1009        );
1010    }
1011    if !table_has_column(
1012        connection,
1013        "materialized_queued_prompts",
1014        "accepted_ordinal",
1015    )? {
1016        statements.push_str(
1017            "ALTER TABLE materialized_queued_prompts ADD COLUMN accepted_ordinal INTEGER
1018                 CHECK(accepted_ordinal IS NULL OR accepted_ordinal > 0);\n",
1019        );
1020    }
1021    statements.push_str(
1022        "CREATE TABLE IF NOT EXISTS api_idempotency (
1023             key TEXT PRIMARY KEY CHECK(length(trim(key)) BETWEEN 1 AND 128),
1024             session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
1025             created_at_ms INTEGER NOT NULL
1026         ) STRICT;
1027         INSERT INTO schema_migrations(version, applied_at)
1028             VALUES (28, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1029         PRAGMA user_version = 28;
1030         COMMIT;",
1031    );
1032    connection.execute_batch(&statements)?;
1033    Ok(())
1034}
1035
1036fn ensure_session_mount_read_only_column(connection: &Connection) -> Result<()> {
1037    if !table_has_column(connection, "session_mounts", "read_only")? {
1038        connection.execute_batch(
1039            "BEGIN IMMEDIATE;
1040             ALTER TABLE session_mounts ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0;
1041             COMMIT;",
1042        )?;
1043    }
1044    Ok(())
1045}
1046
1047fn ensure_materialized_elicitation_column(connection: &Connection) -> Result<()> {
1048    if !table_has_column(
1049        connection,
1050        "materialized_sessions",
1051        "pending_elicitations_json",
1052    )? {
1053        connection.execute_batch(
1054            "BEGIN IMMEDIATE;
1055             ALTER TABLE materialized_sessions
1056                 ADD COLUMN pending_elicitations_json TEXT NOT NULL DEFAULT '[]';
1057             COMMIT;",
1058        )?;
1059    }
1060    Ok(())
1061}
1062
1063fn ensure_managed_worktree_column(connection: &Connection) -> Result<()> {
1064    if !table_has_column(connection, "sessions", "managed_worktree")? {
1065        connection.execute_batch(
1066            "BEGIN IMMEDIATE;
1067             ALTER TABLE sessions ADD COLUMN managed_worktree TEXT;
1068             COMMIT;",
1069        )?;
1070    }
1071    Ok(())
1072}
1073
1074/// Complete the relay half of the colliding v6 migration for databases first
1075/// opened by master, whose v6 contained only `managed_worktree`.
1076fn ensure_relay_projection_schema(connection: &Connection) -> Result<()> {
1077    if table_has_column(connection, "sessions", "detached_after_event_ordinal")? {
1078        return Ok(());
1079    }
1080    connection.execute_batch(&format!(
1081        "BEGIN IMMEDIATE;
1082         ALTER TABLE session_checkpoints
1083             RENAME COLUMN event_sequence TO event_frontier;
1084         ALTER TABLE prompt_history
1085             RENAME COLUMN event_sequence TO event_ordinal;
1086         ALTER TABLE sessions ADD COLUMN detached_after_event_ordinal INTEGER NOT NULL
1087             DEFAULT 0 CHECK(detached_after_event_ordinal >= 0);
1088         CREATE TABLE materialized_sessions (
1089             session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1090             applied_event_ordinal INTEGER NOT NULL DEFAULT 0 CHECK(applied_event_ordinal >= 0),
1091             applied_event_digest TEXT NOT NULL
1092                 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
1093                 CHECK(length(applied_event_digest) = 64
1094                       AND applied_event_digest NOT GLOB '*[^0-9a-f]*'),
1095             last_activity_at_ms INTEGER,
1096             execution_state TEXT NOT NULL DEFAULT 'idle'
1097                 CHECK(execution_state IN ('idle','running','closing','closed')),
1098             running_started_at_ms INTEGER,
1099             session_title TEXT CHECK(session_title IS NULL OR length(trim(session_title)) > 0),
1100             configuration_json TEXT NOT NULL DEFAULT '{{}}',
1101             CHECK(
1102                 (execution_state = 'running' AND running_started_at_ms IS NOT NULL)
1103                 OR (execution_state != 'running' AND running_started_at_ms IS NULL)
1104             )
1105         ) STRICT;
1106         CREATE TABLE materialized_transcript_items (
1107             session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
1108             stable_id TEXT NOT NULL CHECK(length(trim(stable_id)) > 0),
1109             position INTEGER NOT NULL CHECK(position > 0),
1110             latest_content_event_ordinal INTEGER
1111                 CHECK(latest_content_event_ordinal IS NULL
1112                       OR latest_content_event_ordinal >= position),
1113             created_at_ms INTEGER NOT NULL,
1114             last_changed_at_ms INTEGER NOT NULL CHECK(last_changed_at_ms >= created_at_ms),
1115             body_json TEXT NOT NULL,
1116             PRIMARY KEY(session_id, stable_id)
1117         ) STRICT;
1118         CREATE INDEX materialized_transcript_position
1119             ON materialized_transcript_items(session_id, position, stable_id);
1120         CREATE TABLE materialized_queued_prompts (
1121             session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
1122             ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
1123             command_id TEXT NOT NULL CHECK(length(trim(command_id)) > 0),
1124             content_json TEXT NOT NULL,
1125             queued_at_ms INTEGER NOT NULL,
1126             PRIMARY KEY(session_id, ordinal),
1127             UNIQUE(session_id, command_id)
1128         ) STRICT;
1129         INSERT INTO materialized_sessions(session_id)
1130             SELECT session_id FROM sessions;
1131         COMMIT;",
1132    ))?;
1133    Ok(())
1134}
1135
1136fn migrate_destroying_session_state(connection: &Connection) -> Result<()> {
1137    // SQLite cannot widen a CHECK constraint in place. Foreign keys are
1138    // disabled only around the standard table-rebuild transaction; every
1139    // child continues to reference the replacement table by the same name.
1140    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1141    let migration = connection.execute_batch(
1142        "BEGIN IMMEDIATE;
1143         CREATE TABLE sessions_v7 (
1144             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1145             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1146             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi')),
1147             last_profile TEXT NOT NULL,
1148             target_template_id TEXT NOT NULL,
1149             state TEXT NOT NULL CHECK(state IN (
1150                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1151                 'archived','lost','error','destroyed-with-data-loss'
1152             )),
1153             native_session_id TEXT,
1154             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1155             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1156             updated_at TEXT NOT NULL,
1157             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1158                 CHECK(detached_after_event_ordinal >= 0),
1159             last_error TEXT,
1160             resource_allocation TEXT,
1161             last_checkpoint_error TEXT,
1162             project_directory BLOB,
1163             managed_worktree TEXT
1164         ) STRICT;
1165         INSERT INTO sessions_v7(
1166             session_id, title, harness_kind, last_profile, target_template_id, state,
1167             native_session_id, acp_session_title, session_title_override, updated_at,
1168             detached_after_event_ordinal, last_error, resource_allocation,
1169             last_checkpoint_error, project_directory, managed_worktree
1170         )
1171         SELECT
1172             session_id, title, harness_kind, last_profile, target_template_id, state,
1173             native_session_id, acp_session_title, session_title_override, updated_at,
1174             detached_after_event_ordinal, last_error, resource_allocation,
1175             last_checkpoint_error, project_directory, managed_worktree
1176         FROM sessions;
1177         DROP TABLE sessions;
1178         ALTER TABLE sessions_v7 RENAME TO sessions;
1179         INSERT INTO schema_migrations(version, applied_at)
1180             VALUES (7, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1181         PRAGMA user_version = 7;
1182         COMMIT;",
1183    );
1184    if migration.is_err()
1185        && let Err(error) = connection.execute_batch("ROLLBACK;")
1186    {
1187        tracing::warn!(%error, "could not roll back durable-destroying-session migration");
1188    }
1189    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1190    migration.context("migrate durable destroying session state")?;
1191    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1192    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1193    if statement.exists([])? {
1194        bail!("foreign key violation after migrating durable destroying session state");
1195    }
1196    Ok(())
1197}
1198
1199/// Admit the Grok Build harness. SQLite cannot widen a CHECK constraint in
1200/// place, so this repeats the v7 table rebuild with the wider harness list.
1201/// Foreign keys are disabled only around the rebuild transaction; every child
1202/// continues to reference the replacement table by the same name.
1203fn migrate_grok_harness_kind(connection: &Connection) -> Result<()> {
1204    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1205    let migration = connection.execute_batch(
1206        "BEGIN IMMEDIATE;
1207         CREATE TABLE sessions_v9 (
1208             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1209             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1210             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1211             last_profile TEXT NOT NULL,
1212             target_template_id TEXT NOT NULL,
1213             state TEXT NOT NULL CHECK(state IN (
1214                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1215                 'archived','lost','error','destroyed-with-data-loss'
1216             )),
1217             native_session_id TEXT,
1218             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1219             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1220             updated_at TEXT NOT NULL,
1221             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1222                 CHECK(detached_after_event_ordinal >= 0),
1223             last_error TEXT,
1224             resource_allocation TEXT,
1225             last_checkpoint_error TEXT,
1226             project_directory BLOB,
1227             managed_worktree TEXT,
1228             draft_input TEXT NOT NULL DEFAULT ''
1229         ) STRICT;
1230         INSERT INTO sessions_v9(
1231             session_id, title, harness_kind, last_profile, target_template_id, state,
1232             native_session_id, acp_session_title, session_title_override, updated_at,
1233             detached_after_event_ordinal, last_error, resource_allocation,
1234             last_checkpoint_error, project_directory, managed_worktree, draft_input
1235         )
1236         SELECT
1237             session_id, title, harness_kind, last_profile, target_template_id, state,
1238             native_session_id, acp_session_title, session_title_override, updated_at,
1239             detached_after_event_ordinal, last_error, resource_allocation,
1240             last_checkpoint_error, project_directory, managed_worktree, draft_input
1241         FROM sessions;
1242         DROP TABLE sessions;
1243         ALTER TABLE sessions_v9 RENAME TO sessions;
1244         INSERT INTO schema_migrations(version, applied_at)
1245             VALUES (9, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1246         PRAGMA user_version = 9;
1247         COMMIT;",
1248    );
1249    if migration.is_err()
1250        && let Err(error) = connection.execute_batch("ROLLBACK;")
1251    {
1252        tracing::warn!(%error, "could not roll back Grok harness migration");
1253    }
1254    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1255    migration.context("migrate sessions table for the Grok Build harness")?;
1256    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1257    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1258    if statement.exists([])? {
1259        bail!("foreign key violation after migrating the sessions harness list");
1260    }
1261    Ok(())
1262}
1263
1264/// Rename the `archived` lifecycle state to `stopped` and give sessions their
1265/// own display-only `archived` flag, which now means "hidden from the resume
1266/// dialog". SQLite cannot narrow or widen a CHECK constraint in place, so this
1267/// repeats the v9 table rebuild with the new state list and the new column.
1268/// It also adds the hidden set for native sessions Mjolnir only reads.
1269fn migrate_stopped_session_state(connection: &Connection) -> Result<()> {
1270    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1271    let migration = connection.execute_batch(
1272        "BEGIN IMMEDIATE;
1273         CREATE TABLE sessions_v10 (
1274             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1275             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1276             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1277             last_profile TEXT NOT NULL,
1278             target_template_id TEXT NOT NULL,
1279             state TEXT NOT NULL CHECK(state IN (
1280                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1281                 'stopped','lost','error','destroyed-with-data-loss'
1282             )),
1283             native_session_id TEXT,
1284             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1285             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1286             updated_at TEXT NOT NULL,
1287             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1288                 CHECK(detached_after_event_ordinal >= 0),
1289             last_error TEXT,
1290             resource_allocation TEXT,
1291             last_checkpoint_error TEXT,
1292             project_directory BLOB,
1293             managed_worktree TEXT,
1294             draft_input TEXT NOT NULL DEFAULT '',
1295             container_cpus TEXT,
1296             container_memory TEXT,
1297             archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
1298         ) STRICT;
1299         INSERT INTO sessions_v10(
1300             session_id, title, harness_kind, last_profile, target_template_id, state,
1301             native_session_id, acp_session_title, session_title_override, updated_at,
1302             detached_after_event_ordinal, last_error, resource_allocation,
1303             last_checkpoint_error, project_directory, managed_worktree, draft_input,
1304             container_cpus, container_memory
1305         )
1306         SELECT
1307             session_id, title, harness_kind, last_profile, target_template_id,
1308             CASE state WHEN 'archived' THEN 'stopped' ELSE state END,
1309             native_session_id, acp_session_title, session_title_override, updated_at,
1310             detached_after_event_ordinal, last_error, resource_allocation,
1311             last_checkpoint_error, project_directory, managed_worktree, draft_input,
1312             container_cpus, container_memory
1313         FROM sessions;
1314         DROP TABLE sessions;
1315         ALTER TABLE sessions_v10 RENAME TO sessions;
1316         CREATE TABLE hidden_native_sessions (
1317             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1318             native_session_id TEXT NOT NULL CHECK(length(trim(native_session_id)) > 0),
1319             hidden_at TEXT NOT NULL,
1320             PRIMARY KEY(harness_kind, native_session_id)
1321         ) STRICT;
1322         INSERT INTO schema_migrations(version, applied_at)
1323             VALUES (10, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1324         PRAGMA user_version = 10;
1325         COMMIT;",
1326    );
1327    if migration.is_err()
1328        && let Err(error) = connection.execute_batch("ROLLBACK;")
1329    {
1330        tracing::warn!(%error, "could not roll back stopped-session migration");
1331    }
1332    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1333    migration.context("migrate sessions table for the stopped session state")?;
1334    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1335    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1336    if statement.exists([])? {
1337        bail!("foreign key violation after migrating the stopped session state");
1338    }
1339    Ok(())
1340}
1341
1342/// Preserve existing data and dependent indexes while admitting Muse sessions.
1343fn migrate_muse_harness_kind(connection: &Connection) -> Result<()> {
1344    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1345    let migration = (|| -> Result<()> {
1346        let transaction = connection.unchecked_transaction()?;
1347        for table in ["sessions", "hidden_native_sessions"] {
1348            let sql: String = transaction.query_row(
1349                "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
1350                [table],
1351                |row| row.get(0),
1352            )?;
1353            let (_, definition) = sql
1354                .split_once('(')
1355                .context("missing harness table definition")?;
1356            if definition.contains("'deepseek','muse')") {
1357                continue;
1358            }
1359            ensure!(
1360                definition.contains("'deepseek')"),
1361                "unexpected {table} harness constraint"
1362            );
1363            let definition = definition.replace("'deepseek')", "'deepseek','muse')");
1364            let objects: Vec<String> = transaction.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name=?1 AND type IN ('index','trigger') AND sql IS NOT NULL")?
1365                .query_map([table], |row| row.get(0))?.collect::<rusqlite::Result<_>>()?;
1366            transaction.execute_batch(&format!(
1367                "CREATE TABLE {table}_muse_v27 ({definition}; INSERT INTO {table}_muse_v27 SELECT * FROM {table}; DROP TABLE {table}; ALTER TABLE {table}_muse_v27 RENAME TO {table};"
1368            ))?;
1369            for object in objects {
1370                transaction.execute_batch(&object)?;
1371            }
1372        }
1373        ensure!(
1374            !transaction
1375                .prepare("PRAGMA foreign_key_check")?
1376                .exists([])?,
1377            "foreign key violation in Muse migration"
1378        );
1379        transaction.execute_batch("INSERT INTO schema_migrations(version, applied_at) VALUES (27, strftime('%Y-%m-%dT%H:%M:%fZ','now')); PRAGMA user_version = 27;")?;
1380        transaction.commit()?;
1381        Ok(())
1382    })();
1383    let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
1384    migration.context("migrate Muse harness constraints")?;
1385    restored.context("restore foreign key enforcement after Muse migration")?;
1386    Ok(())
1387}
1388
1389/// Admit DeepSeek Harness in both stored sessions and Mjolnir's native-session
1390/// hidden set. SQLite requires rebuilding tables to widen CHECK constraints.
1391fn migrate_deepseek_harness_kind(connection: &Connection) -> Result<()> {
1392    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1393    let migration = connection.execute_batch(
1394        "BEGIN IMMEDIATE;
1395         CREATE TABLE sessions_v11 (
1396             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1397             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1398             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok','deepseek')),
1399             last_profile TEXT NOT NULL,
1400             target_template_id TEXT NOT NULL,
1401             state TEXT NOT NULL CHECK(state IN (
1402                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1403                 'stopped','lost','error','destroyed-with-data-loss'
1404             )),
1405             native_session_id TEXT,
1406             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1407             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1408             updated_at TEXT NOT NULL,
1409             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1410                 CHECK(detached_after_event_ordinal >= 0),
1411             last_error TEXT,
1412             resource_allocation TEXT,
1413             last_checkpoint_error TEXT,
1414             project_directory BLOB,
1415             managed_worktree TEXT,
1416             draft_input TEXT NOT NULL DEFAULT '',
1417             container_cpus TEXT,
1418             container_memory TEXT,
1419             archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
1420         ) STRICT;
1421         INSERT INTO sessions_v11 SELECT * FROM sessions;
1422         DROP TABLE sessions;
1423         ALTER TABLE sessions_v11 RENAME TO sessions;
1424         ALTER TABLE hidden_native_sessions RENAME TO hidden_native_sessions_v10;
1425         CREATE TABLE hidden_native_sessions (
1426             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok','deepseek')),
1427             native_session_id TEXT NOT NULL CHECK(length(trim(native_session_id)) > 0),
1428             hidden_at TEXT NOT NULL,
1429             PRIMARY KEY(harness_kind, native_session_id)
1430         ) STRICT;
1431         INSERT INTO hidden_native_sessions SELECT * FROM hidden_native_sessions_v10;
1432         DROP TABLE hidden_native_sessions_v10;
1433         INSERT INTO schema_migrations(version, applied_at)
1434             VALUES (11, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1435         PRAGMA user_version = 11;
1436         COMMIT;",
1437    );
1438    if migration.is_err()
1439        && let Err(error) = connection.execute_batch("ROLLBACK;")
1440    {
1441        tracing::warn!(%error, "could not roll back DeepSeek harness migration");
1442    }
1443    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1444    migration.context("migrate sessions table for DeepSeek Harness")?;
1445    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1446    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1447    if statement.exists([])? {
1448        bail!("foreign key violation after migrating the DeepSeek Harness list");
1449    }
1450    Ok(())
1451}
1452
1453/// Carry unsent chat input across a detach. Added as a structural guard rather
1454/// than a new schema version so databases written by either development line
1455/// converge, matching `ensure_managed_worktree_column`.
1456fn ensure_session_draft_input_column(connection: &Connection) -> Result<()> {
1457    if !table_has_column(connection, "sessions", "draft_input")? {
1458        connection.execute_batch(
1459            "BEGIN IMMEDIATE;
1460             ALTER TABLE sessions ADD COLUMN draft_input TEXT NOT NULL DEFAULT '';
1461             COMMIT;",
1462        )?;
1463    }
1464    Ok(())
1465}
1466
1467fn ensure_projection_digest_column(connection: &Connection) -> Result<()> {
1468    let present = connection.query_row(
1469        "SELECT EXISTS(
1470             SELECT 1 FROM pragma_table_info('materialized_sessions')
1471             WHERE name = 'applied_event_digest'
1472         )",
1473        [],
1474        |row| row.get::<_, bool>(0),
1475    )?;
1476    if !present {
1477        connection.execute_batch(&format!(
1478            "BEGIN IMMEDIATE;
1479             ALTER TABLE materialized_sessions ADD COLUMN applied_event_digest TEXT NOT NULL
1480                 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
1481                 CHECK(length(applied_event_digest) = 64
1482                       AND applied_event_digest NOT GLOB '*[^0-9a-f]*');
1483             COMMIT;",
1484        ))?;
1485    }
1486    Ok(())
1487}
1488
1489fn ensure_api_events_schema(connection: &Connection) -> Result<()> {
1490    connection.execute_batch(
1491        "CREATE TABLE IF NOT EXISTS api_events (
1492            seq INTEGER PRIMARY KEY AUTOINCREMENT,
1493            session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
1494            recorded_at_ms INTEGER NOT NULL,
1495            body TEXT NOT NULL CHECK(json_valid(body))
1496        ) STRICT;
1497        CREATE INDEX IF NOT EXISTS api_events_session ON api_events(session_id, seq);
1498        CREATE TABLE IF NOT EXISTS api_session_activity (
1499            session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1500            body TEXT NOT NULL CHECK(json_valid(body))
1501        ) STRICT;
1502        CREATE TRIGGER IF NOT EXISTS api_session_error_updated
1503        AFTER UPDATE OF last_error ON sessions
1504        WHEN NEW.last_error IS NOT NULL AND NEW.last_error IS NOT OLD.last_error
1505        BEGIN
1506            INSERT INTO api_events(session_id, recorded_at_ms, body)
1507            VALUES (NEW.session_id, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
1508                json_object('type', 'error', 'data', json_object('message', NEW.last_error, 'command_id', NULL)));
1509        END;
1510        CREATE TRIGGER IF NOT EXISTS api_session_error_inserted
1511        AFTER INSERT ON sessions WHEN NEW.last_error IS NOT NULL
1512        BEGIN
1513            INSERT INTO api_events(session_id, recorded_at_ms, body)
1514            VALUES (NEW.session_id, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
1515                json_object('type', 'error', 'data', json_object('message', NEW.last_error, 'command_id', NULL)));
1516        END;"
1517    )?;
1518    Ok(())
1519}
1520
1521#[cfg(test)]
1522pub(super) fn advance_test_schema(path: &Path, revision: i64, minimum_compatible: i64) {
1523    let connection = Connection::open(path).unwrap();
1524    let transaction = connection.unchecked_transaction().unwrap();
1525    transaction
1526        .execute(
1527            "UPDATE schema_compatibility SET minimum_compatible_version = ?1",
1528            [minimum_compatible],
1529        )
1530        .unwrap();
1531    transaction
1532        .execute(
1533            "INSERT INTO schema_migrations(version, applied_at) VALUES (?1, 'test')",
1534            [revision],
1535        )
1536        .unwrap();
1537    transaction
1538        .pragma_update(None, "user_version", revision)
1539        .unwrap();
1540    transaction.commit().unwrap();
1541    forget_verified_schema(path);
1542}
1543
1544#[cfg(test)]
1545mod reader_tests {
1546    use super::*;
1547
1548    /// Rewrites a store's recorded schema version the way another build's
1549    /// migration ladder would, and forgets that this process verified it.
1550    fn stamp_schema_version(path: &Path, version: i64) {
1551        if version > SCHEMA_VERSION {
1552            advance_test_schema(path, version, version);
1553            return;
1554        }
1555        let connection = Connection::open(path).unwrap();
1556        connection
1557            .execute_batch(&format!("PRAGMA user_version = {version};"))
1558            .unwrap();
1559        connection
1560            .execute(
1561                "DELETE FROM schema_migrations WHERE version > ?1",
1562                [version],
1563            )
1564            .unwrap();
1565        if version == 30 {
1566            connection
1567                .execute(
1568                    "UPDATE schema_compatibility SET minimum_compatible_version = 30 WHERE singleton = 1",
1569                    [],
1570                )
1571                .unwrap();
1572        }
1573        drop(connection);
1574        forget_verified_schema(path);
1575    }
1576
1577    #[test]
1578    fn older_readers_and_reopened_writers_preserve_a_compatible_future_schema() {
1579        let directory = tempfile::tempdir().unwrap();
1580        let path = directory.path().join("mj.sqlite3");
1581        let connection = open_writer(&path).unwrap();
1582        connection
1583            .execute_batch(
1584                "CREATE TABLE future_feature(value TEXT NOT NULL);
1585                 INSERT INTO future_feature VALUES ('preserve me');",
1586            )
1587            .unwrap();
1588        drop(connection);
1589        advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
1590
1591        let reader = open_reader_strict(&path).unwrap();
1592        assert_eq!(
1593            reader
1594                .query_row("SELECT value FROM future_feature", [], |row| row
1595                    .get::<_, String>(0))
1596                .unwrap(),
1597            "preserve me"
1598        );
1599        assert!(reader.execute("DELETE FROM future_feature", []).is_err());
1600        drop(reader);
1601
1602        // A repair would recreate this deliberately removed trigger. A future
1603        // schema is authoritative even when it differs from our own repairs.
1604        let raw = Connection::open(&path).unwrap();
1605        raw.execute_batch("DROP TRIGGER api_session_error_updated;")
1606            .unwrap();
1607        drop(raw);
1608        let writer = open_writer(&path).unwrap();
1609        assert!(!writer.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'api_session_error_updated')", [], |row| row.get::<_, bool>(0)).unwrap());
1610        assert_eq!(
1611            writer
1612                .query_row("SELECT value FROM future_feature", [], |row| row
1613                    .get::<_, String>(0))
1614                .unwrap(),
1615            "preserve me"
1616        );
1617        let state = read_schema_state(&writer).unwrap();
1618        assert_eq!(state.revision, SCHEMA_VERSION + 1);
1619        assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1620    }
1621
1622    #[test]
1623    fn invalid_compatibility_metadata_refuses_readers_and_writers() {
1624        for alteration in [
1625            "DROP TABLE schema_compatibility",
1626            "DELETE FROM schema_compatibility",
1627            "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET minimum_compatible_version = 0",
1628            "UPDATE schema_compatibility SET minimum_compatible_version = 99999",
1629            "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET singleton = 2",
1630            "PRAGMA ignore_check_constraints = ON; INSERT INTO schema_compatibility VALUES (2, 30)",
1631            "DROP TABLE schema_compatibility; CREATE TABLE schema_compatibility(singleton, minimum_compatible_version); INSERT INTO schema_compatibility VALUES (1, 'invalid')",
1632            "DELETE FROM schema_migrations WHERE version = (SELECT max(version) FROM schema_migrations)",
1633        ] {
1634            for future in [false, true] {
1635                let directory = tempfile::tempdir().unwrap();
1636                let path = directory.path().join("mj.sqlite3");
1637                drop(open_writer(&path).unwrap());
1638                if future {
1639                    advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
1640                }
1641                let raw = Connection::open(&path).unwrap();
1642                raw.execute_batch(alteration).unwrap();
1643                let before: i64 = raw
1644                    .query_row("PRAGMA schema_version", [], |row| row.get(0))
1645                    .unwrap();
1646                // Exercise the cached path as well as a fresh writer open.
1647                for error in [
1648                    open_reader_strict(&path).unwrap_err(),
1649                    open_writer(&path).unwrap_err(),
1650                ] {
1651                    let mismatch = error.downcast_ref::<StoreSchemaMismatch>().unwrap();
1652                    assert_eq!(
1653                        mismatch.reason,
1654                        StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
1655                        "{alteration}"
1656                    );
1657                }
1658                forget_verified_schema(&path);
1659                assert!(open_writer(&path).is_err(), "{alteration}");
1660                let after: i64 = raw
1661                    .query_row("PRAGMA schema_version", [], |row| row.get(0))
1662                    .unwrap();
1663                assert_eq!(
1664                    before, after,
1665                    "a rejected open repaired schema: {alteration}"
1666                );
1667            }
1668        }
1669    }
1670
1671    #[test]
1672    fn compatibility_baseline_migration_is_atomic_and_retryable() {
1673        let directory = tempfile::tempdir().unwrap();
1674        let path = directory.path().join("mj.sqlite3");
1675        let connection = open_writer(&path).unwrap();
1676        let state = read_schema_state(&connection).unwrap();
1677        assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1678        connection
1679            .execute_batch(
1680                "DROP TABLE schema_compatibility;
1681             DELETE FROM schema_migrations WHERE version >= 30;
1682             PRAGMA user_version = 29;
1683             CREATE TRIGGER reject_baseline BEFORE INSERT ON schema_migrations
1684             WHEN NEW.version = 30 BEGIN SELECT RAISE(ABORT, 'injected migration failure'); END;",
1685            )
1686            .unwrap();
1687        forget_verified_schema(&path);
1688        let error = migrate_schema(&connection).unwrap_err();
1689        assert!(error.to_string().contains("injected migration failure"));
1690        assert!(
1691            connection.is_autocommit(),
1692            "the failed migration left a transaction open"
1693        );
1694        assert_eq!(read_schema_state(&connection).unwrap().revision, 29);
1695        assert_eq!(
1696            connection
1697                .query_row("SELECT max(version) FROM schema_migrations", [], |row| row
1698                    .get::<_, i64>(
1699                    0
1700                ))
1701                .unwrap(),
1702            29
1703        );
1704        assert!(!connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'schema_compatibility')", [], |row| row.get::<_, bool>(0)).unwrap());
1705        connection
1706            .execute_batch("DROP TRIGGER reject_baseline")
1707            .unwrap();
1708        drop(connection);
1709        let writer = open_writer(&path).unwrap();
1710        let state = read_schema_state(&writer).unwrap();
1711        assert_eq!(state.revision, SCHEMA_VERSION);
1712        assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1713    }
1714
1715    /// A store ahead of this build cannot be fixed by starting a daemon of
1716    /// this build, so the reader must not say so. This is the message the
1717    /// incident in #24 printed twice a second for an hour.
1718    #[test]
1719    fn strict_reader_reports_a_newer_store_without_blaming_the_daemon() {
1720        let directory = tempfile::tempdir().unwrap();
1721        let path = directory.path().join("mj.sqlite3");
1722        drop(open_writer(&path).unwrap());
1723        stamp_schema_version(&path, SCHEMA_VERSION + 1);
1724
1725        let error = open_reader_strict(&path).unwrap_err();
1726
1727        let mismatch = error
1728            .chain()
1729            .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
1730            .expect("the reader reports the mismatch as a typed cause");
1731        assert_eq!(mismatch.found, SCHEMA_VERSION + 1);
1732        assert_eq!(mismatch.supported, SCHEMA_VERSION);
1733        let message = mismatch.to_string();
1734        assert!(message.contains("upgrade Mjolnir"), "got {message}");
1735        assert!(
1736            !message.contains("start the Mjolnir daemon"),
1737            "got {message}"
1738        );
1739    }
1740
1741    /// A store behind this build keeps the advice that works, verbatim, so
1742    /// existing log greps and runbooks keep matching.
1743    #[test]
1744    fn strict_reader_keeps_the_migrate_advice_when_the_store_is_behind() {
1745        let directory = tempfile::tempdir().unwrap();
1746        let path = directory.path().join("mj.sqlite3");
1747        drop(open_writer(&path).unwrap());
1748        stamp_schema_version(&path, SCHEMA_VERSION - 1);
1749
1750        let error = open_reader_strict(&path).unwrap_err();
1751
1752        let mismatch = error
1753            .chain()
1754            .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
1755            .expect("the reader reports the mismatch as a typed cause");
1756        assert_eq!(
1757            mismatch.to_string(),
1758            format!(
1759                "Mjolnir database schema {} is not the supported schema {SCHEMA_VERSION}; \
1760                 start the Mjolnir daemon to migrate it",
1761                SCHEMA_VERSION - 1
1762            )
1763        );
1764    }
1765
1766    #[test]
1767    fn strict_reader_rejects_mutation() {
1768        let directory = tempfile::tempdir().unwrap();
1769        let path = directory.path().join("mj.sqlite3");
1770        drop(open_writer(&path).unwrap());
1771
1772        let reader = open_reader_strict(&path).unwrap();
1773        let error = reader
1774            .execute("CREATE TABLE forbidden(value TEXT)", [])
1775            .unwrap_err();
1776        assert!(
1777            matches!(
1778                error.sqlite_error_code(),
1779                Some(rusqlite::ErrorCode::ReadOnly)
1780            ),
1781            "unexpected mutation error: {error}"
1782        );
1783    }
1784}