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
98/// A writer-capable connection whose transactions take the WAL write lock at
99/// `BEGIN`, where the busy handler applies. A DEFERRED transaction that has
100/// already read cannot wait: SQLite only calls the busy handler when the
101/// connection holds no transaction, so the upgrade to a write returns
102/// `SQLITE_BUSY` at once (issue 1117).
103pub(super) fn open_writer(path: &Path) -> Result<Connection> {
104    let mut connection = open_writable(path)?;
105    connection.set_transaction_behavior(rusqlite::TransactionBehavior::Immediate);
106    Ok(connection)
107}
108
109fn open_writable(path: &Path) -> Result<Connection> {
110    if let Some(parent) = path.parent() {
111        fs::create_dir_all(parent)
112            .with_context(|| format!("create Mjolnir data directory {}", parent.display()))?;
113    }
114    let connection = Connection::open(path)
115        .with_context(|| format!("open Mjolnir database {}", path.display()))?;
116    connection.busy_timeout(Duration::from_secs(5))?;
117    connection.execute_batch(
118        "PRAGMA foreign_keys = ON;
119         PRAGMA journal_mode = WAL;
120         PRAGMA synchronous = FULL;",
121    )?;
122    verify_schema_once(path, &connection)?;
123    Ok(connection)
124}
125
126pub(super) fn open(path: &Path) -> Result<Connection> {
127    open_writer(path)
128}
129
130/// Open an existing database without permitting schema or data mutation.
131/// Client processes use this path so an accidental write fails locally
132/// instead of competing with the daemon's writer.
133#[cfg(not(test))]
134pub(super) fn open_reader(path: &Path) -> Result<Connection> {
135    open_reader_strict(path)
136}
137
138#[cfg(test)]
139pub(super) fn open_reader(path: &Path) -> Result<Connection> {
140    // Path-taking database helpers are migration fixtures in unit tests: they
141    // intentionally open old or not-yet-created schemas. Production query
142    // entry points compile against the strict reader above. The connection is
143    // writable but keeps SQLite's DEFERRED default, so a fixture read does not
144    // take the write lock.
145    open_writable(path)
146}
147
148#[cfg_attr(test, allow(dead_code))]
149fn open_reader_strict(path: &Path) -> Result<Connection> {
150    let connection = Connection::open_with_flags(
151        path,
152        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
153    )
154    .with_context(|| format!("open Mjolnir database read-only {}", path.display()))?;
155    connection.busy_timeout(Duration::from_secs(5))?;
156    connection.execute_batch(
157        "PRAGMA foreign_keys = ON;
158         PRAGMA query_only = ON;",
159    )?;
160    read_schema_state(&connection)?.ensure_supported()?;
161    Ok(connection)
162}
163
164/// Databases this process has already migrated. A controller owns its store
165/// exclusively (`ControllerStoreGuard`), so a schema verified once stays
166/// verified and later connections skip the migration probes entirely.
167fn verified_schemas() -> &'static Mutex<HashSet<PathBuf>> {
168    static VERIFIED: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
169    VERIFIED.get_or_init(|| Mutex::new(HashSet::new()))
170}
171
172/// Stable cache identity for a database. The file itself may not exist yet, so
173/// the canonicalized parent directory carries the identity.
174fn schema_cache_key(path: &Path) -> PathBuf {
175    let Some(parent) = path
176        .parent()
177        .filter(|parent| !parent.as_os_str().is_empty())
178    else {
179        return path.to_owned();
180    };
181    match (fs::canonicalize(parent), path.file_name()) {
182        (Ok(canonical), Some(name)) => canonical.join(name),
183        _ => path.to_owned(),
184    }
185}
186
187/// Run the migration ladder the first time this process opens a database.
188/// Later opens confirm compatibility without repeating schema repairs. A
189/// database behind this build is migrated again, so a recreated file under a
190/// reused path still converges. Compatible future stores are never repaired.
191fn verify_schema_once(path: &Path, connection: &Connection) -> Result<()> {
192    let key = schema_cache_key(path);
193    let mut verified = verified_schemas()
194        .lock()
195        .unwrap_or_else(PoisonError::into_inner);
196    let state = read_schema_state(connection)?;
197    if state.revision > SCHEMA_VERSION
198        || (state.revision == SCHEMA_VERSION && verified.contains(&key))
199    {
200        // An older build must never run its repairs against a newer schema.
201        return state.ensure_supported();
202    }
203    // Holding the lock across the ladder keeps two first opens of the same
204    // database from running the additive migration steps against each other.
205    migrate_schema(connection)?;
206    read_schema_state(connection)?.ensure_supported()?;
207    verified.insert(key);
208    Ok(())
209}
210
211/// Forget that this process verified a database's schema. Only tests need it:
212/// they simulate a store written by an older build by editing the schema of a
213/// database this process has already opened, which no controller can do.
214#[cfg(test)]
215pub(super) fn forget_verified_schema(path: &Path) {
216    verified_schemas()
217        .lock()
218        .unwrap_or_else(PoisonError::into_inner)
219        .remove(&schema_cache_key(path));
220}
221
222/// The oldest revision this build upgrades in place: the revision Mjolnir 2.7.2
223/// shipped. A new store is created directly at this revision from
224/// `baseline.sql`; stores written by older builds are refused.
225const BASELINE_SCHEMA_VERSION: i64 = 33;
226
227/// The compatibility floor a baseline store records. Migration 32 (ZCode) was
228/// the last breaking change before the baseline.
229const BASELINE_MINIMUM_COMPATIBLE_VERSION: i64 = 32;
230
231fn migrate_schema(connection: &Connection) -> Result<()> {
232    let state = read_schema_state(connection)?;
233    let version = state.revision;
234    if version > SCHEMA_VERSION {
235        return state.ensure_supported();
236    }
237    if version == 0 {
238        create_baseline_schema(connection)?;
239    } else if version < BASELINE_SCHEMA_VERSION {
240        // Every release from 2.7.2 through 2.9.x still carries the migration
241        // chain below the baseline; every later release refuses, as this one
242        // does. That range is closed, so the advice never goes stale.
243        bail!(
244            "Mjolnir database schema {version} was written by a Mjolnir release older than 2.7.2, \
245             which this build cannot upgrade; upgrade through any Mjolnir release from 2.7.2 \
246             through 2.9.x first, or start with a fresh data directory (--instance NAME or \
247             MJ_DATA_DIR)"
248        );
249    }
250    // Compatible: adds one table. Older readers ignore it and treat read-write
251    // mounts as copy-on-write, a behaviour difference rather than lost data.
252    // Older writers rewrite `session_mounts` but never touch this table, so its
253    // rows survive their updates; a row only applies while a mount with the same
254    // source and destination is still not read-only, so an older build that
255    // makes the mount read-only or removes it keeps that choice. The
256    // compatibility floor stays where it is.
257    if version < 34 {
258        connection.execute_batch(
259            "BEGIN IMMEDIATE;
260             CREATE TABLE IF NOT EXISTS session_mount_access (
261                 session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
262                 source BLOB NOT NULL,
263                 destination BLOB NOT NULL,
264                 access TEXT NOT NULL CHECK(access IN ('rw')),
265                 PRIMARY KEY(session_id, destination)
266             ) STRICT;
267             INSERT INTO schema_migrations(version, applied_at)
268                 VALUES (34, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
269             PRAGMA user_version = 34;
270             COMMIT;",
271        )?;
272    }
273    // Compatible: adds one nullable column. Older readers ignore it, and the
274    // older writer's session upsert lists columns explicitly, so it preserves
275    // the value. An older executable launching such a session uses the shared
276    // `/workspace` instead of the recorded per-session path, which is a
277    // behaviour difference, not data loss. The compatibility floor stays where
278    // it is.
279    if version < 35 {
280        connection.execute_batch(
281            "BEGIN IMMEDIATE;
282             ALTER TABLE sessions ADD COLUMN container_workspace TEXT;
283             INSERT INTO schema_migrations(version, applied_at)
284                 VALUES (35, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
285             PRAGMA user_version = 35;
286             COMMIT;",
287        )?;
288    }
289    // Compatible: adds one nullable column. Older readers ignore it, and the
290    // older writer's session upsert lists columns explicitly, so it preserves
291    // the value. An older executable launching such a session runs it without
292    // the mbx build cache, which is a behaviour difference, not data loss. The
293    // compatibility floor stays where it is.
294    if version < 36 {
295        connection.execute_batch(
296            "BEGIN IMMEDIATE;
297             ALTER TABLE sessions ADD COLUMN build_cache_json TEXT;
298             INSERT INTO schema_migrations(version, applied_at)
299                 VALUES (36, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
300             PRAGMA user_version = 36;
301             COMMIT;",
302        )?;
303    }
304    // Compatible: adds one nullable column to `session_targets`. Only a
305    // container sub-agent child row ever carries a value, and older builds
306    // could never start such a child, so an older update that rewrites the row
307    // without the column loses nothing usable. Older readers ignore it. The
308    // compatibility floor stays where it is.
309    if version < 37 {
310        connection.execute_batch(
311            "BEGIN IMMEDIATE;
312             ALTER TABLE session_targets ADD COLUMN borrowed_from TEXT;
313             INSERT INTO schema_migrations(version, applied_at)
314                 VALUES (37, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
315             PRAGMA user_version = 37;
316             COMMIT;",
317        )?;
318    }
319    // Compatible: adds one table holding the dashboard's conversation pane
320    // arrangement per workspace. Older readers never select from it and older
321    // writers never touch it, so their updates leave its rows intact; a
322    // workspace deleted by an older build still removes them through the
323    // foreign key. Losing the table only means the conversation area opens as
324    // a single pane. The compatibility floor stays where it is.
325    if version < 38 {
326        connection.execute_batch(
327            "BEGIN IMMEDIATE;
328             CREATE TABLE IF NOT EXISTS workspace_layouts (
329                 workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
330                 layout TEXT NOT NULL
331             ) STRICT;
332             INSERT INTO schema_migrations(version, applied_at)
333                 VALUES (38, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
334             PRAGMA user_version = 38;
335             COMMIT;",
336        )?;
337    }
338    // Breaking: persisted input_required API events can now omit the structured
339    // request. Older readers require it and fail to deserialize the event log;
340    // the shared read/write compatibility floor must advance with the revision.
341    if version < 39 {
342        connection.execute_batch(
343            "BEGIN IMMEDIATE;
344             UPDATE schema_compatibility SET minimum_compatible_version = 39 WHERE singleton = 1;
345             INSERT INTO schema_migrations(version, applied_at)
346                 VALUES (39, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
347             PRAGMA user_version = 39;
348             COMMIT;",
349        )?;
350    }
351    // Breaking: native child projections and relay observations must be preserved
352    // by every reader/writer; older builds cannot interpret their lifecycle.
353    if version < 40 {
354        connection.execute_batch(
355            "BEGIN IMMEDIATE;
356             CREATE TABLE native_agents (
357                 owner TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
358                 child TEXT NOT NULL,
359                 staging INTEGER NOT NULL CHECK(staging IN (0,1)),
360                 body TEXT NOT NULL CHECK(json_valid(body)),
361                 PRIMARY KEY(owner, child, staging)
362             ) STRICT;
363             CREATE TABLE native_agent_transcript (
364                 owner TEXT NOT NULL,
365                 child TEXT NOT NULL,
366                 staging INTEGER NOT NULL,
367                 stable_id TEXT NOT NULL,
368                 position INTEGER NOT NULL,
369                 body TEXT NOT NULL CHECK(json_valid(body)),
370                 PRIMARY KEY(owner, child, staging, stable_id),
371                 FOREIGN KEY(owner, child, staging) REFERENCES native_agents(owner, child, staging)
372                     ON DELETE CASCADE ON UPDATE CASCADE
373             ) STRICT;
374             CREATE INDEX native_agent_transcript_position ON native_agent_transcript(owner, child, staging, position);
375             CREATE TABLE native_agent_replay (
376                 owner TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE
377             ) STRICT;
378             UPDATE schema_compatibility SET minimum_compatible_version = 40 WHERE singleton = 1;
379             INSERT INTO schema_migrations(version, applied_at)
380                 VALUES (40, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
381             PRAGMA user_version = 40;
382             COMMIT;",
383        )?;
384    }
385    // Breaking: clear-context relay commands/outcomes are persisted in event
386    // JSON. Older readers cannot decode them or honor the context boundary.
387    if version < 41 {
388        connection.execute_batch(
389            "BEGIN IMMEDIATE;
390             UPDATE schema_compatibility SET minimum_compatible_version = 41 WHERE singleton = 1;
391             INSERT INTO schema_migrations(version, applied_at)
392                 VALUES (41, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
393             PRAGMA user_version = 41;
394             COMMIT;",
395        )?;
396    }
397
398    // Breaking: older layout writers discard Browse identity and pin badges.
399    if version < 42 {
400        connection.execute_batch(
401            "BEGIN IMMEDIATE;
402             UPDATE schema_compatibility SET minimum_compatible_version = 42 WHERE singleton = 1;
403             INSERT INTO schema_migrations(version, applied_at)
404                 VALUES (42, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
405             PRAGMA user_version = 42;
406             COMMIT;",
407        )?;
408    }
409
410    // Breaking: durable steering commands/observations and native availability
411    // cannot be interpreted or preserved by older readers and writers.
412    if version < 43 {
413        connection.execute_batch("BEGIN IMMEDIATE;
414            UPDATE schema_compatibility SET minimum_compatible_version = 43 WHERE singleton = 1;
415            INSERT INTO schema_migrations(version, applied_at) VALUES (43, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
416            PRAGMA user_version = 43;
417            COMMIT;")?;
418    }
419
420    // Breaking: new quota recovery commands in stored relay JSON cannot be
421    // read or preserved by older binaries, even though the cache is additive.
422    if version < 44 {
423        connection.execute_batch("BEGIN IMMEDIATE;
424            CREATE TABLE quota_reset_cache (identity TEXT PRIMARY KEY, body TEXT NOT NULL);
425            UPDATE schema_compatibility SET minimum_compatible_version = 44 WHERE singleton = 1;
426            INSERT INTO schema_migrations(version, applied_at) VALUES (44, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
427            PRAGMA user_version = 44;
428            COMMIT;")?;
429    }
430
431    let recorded: Option<i64> =
432        connection.query_row("SELECT max(version) FROM schema_migrations", [], |row| {
433            row.get(0)
434        })?;
435    if recorded != Some(SCHEMA_VERSION) {
436        bail!(
437            "Mjolnir database migration ledger {:?} does not match schema {}",
438            recorded,
439            SCHEMA_VERSION
440        );
441    }
442    Ok(())
443}
444
445/// Create an empty store at the baseline revision in one immediate transaction.
446/// The revision is read again under the write lock, so a second process that
447/// raced to create the same store finds it already created.
448fn create_baseline_schema(connection: &Connection) -> Result<()> {
449    connection.execute_batch("BEGIN IMMEDIATE;")?;
450    let created = (|| -> Result<()> {
451        let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
452        if version != 0 {
453            return Ok(());
454        }
455        connection.execute_batch(include_str!("baseline.sql"))?;
456        connection.execute(
457            "INSERT INTO schema_compatibility(singleton, minimum_compatible_version) VALUES (1, ?1)",
458            [BASELINE_MINIMUM_COMPATIBLE_VERSION],
459        )?;
460        connection.execute(
461            "INSERT INTO schema_migrations(version, applied_at)
462             VALUES (?1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
463            [BASELINE_SCHEMA_VERSION],
464        )?;
465        connection.pragma_update(None, "user_version", BASELINE_SCHEMA_VERSION)?;
466        Ok(())
467    })();
468    match created {
469        Ok(()) => connection
470            .execute_batch("COMMIT;")
471            .context("commit baseline database schema"),
472        Err(error) => {
473            if let Err(rollback) = connection.execute_batch("ROLLBACK;") {
474                tracing::warn!(%rollback, "could not roll back a failed baseline schema");
475            }
476            Err(error.context("create baseline database schema"))
477        }
478    }
479}
480
481#[cfg(test)]
482pub(super) fn advance_test_schema(path: &Path, revision: i64, minimum_compatible: i64) {
483    let connection = Connection::open(path).unwrap();
484    let transaction = connection.unchecked_transaction().unwrap();
485    transaction
486        .execute(
487            "UPDATE schema_compatibility SET minimum_compatible_version = ?1",
488            [minimum_compatible],
489        )
490        .unwrap();
491    transaction
492        .execute(
493            "INSERT INTO schema_migrations(version, applied_at) VALUES (?1, 'test')",
494            [revision],
495        )
496        .unwrap();
497    transaction
498        .pragma_update(None, "user_version", revision)
499        .unwrap();
500    transaction.commit().unwrap();
501    forget_verified_schema(path);
502}
503
504#[cfg(test)]
505mod reader_tests {
506    use super::*;
507
508    /// The oldest executable revision that can still read and write a store at
509    /// `SCHEMA_VERSION`. Migration 44 adds durable quota recovery commands.
510    const MINIMUM_COMPATIBLE_VERSION: i64 = 44;
511
512    /// Rewrites a store's recorded schema version the way another build's
513    /// migration ladder would, and forgets that this process verified it.
514    fn stamp_schema_version(path: &Path, version: i64) {
515        if version > SCHEMA_VERSION {
516            advance_test_schema(path, version, version);
517            return;
518        }
519        let connection = Connection::open(path).unwrap();
520        connection
521            .execute_batch(&format!("PRAGMA user_version = {version};"))
522            .unwrap();
523        connection
524            .execute(
525                "DELETE FROM schema_migrations WHERE version > ?1",
526                [version],
527            )
528            .unwrap();
529        if version == 30 {
530            connection
531                .execute(
532                    "UPDATE schema_compatibility SET minimum_compatible_version = 30 WHERE singleton = 1",
533                    [],
534                )
535                .unwrap();
536        }
537        drop(connection);
538        forget_verified_schema(path);
539    }
540
541    #[test]
542    fn native_agents_and_unstructured_input_raise_the_store_compatibility_floor() {
543        let directory = tempfile::tempdir().unwrap();
544        let path = directory.path().join("mj.sqlite3");
545        let connection = open_writer(&path).unwrap();
546        connection
547            .execute_batch(
548                "BEGIN IMMEDIATE;
549             DROP TABLE quota_reset_cache;
550             DROP TABLE native_agent_transcript;
551             DROP TABLE native_agents;
552             DROP TABLE native_agent_replay;
553             DELETE FROM schema_migrations WHERE version >= 39;
554             UPDATE schema_compatibility SET minimum_compatible_version = 32;
555             PRAGMA user_version = 38;
556             COMMIT;",
557            )
558            .unwrap();
559        migrate_schema(&connection).unwrap();
560        let state = read_schema_state(&connection).unwrap();
561        assert_eq!(state.revision, SCHEMA_VERSION);
562        assert_eq!(state.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
563        let event = ApiEventData::InputRequired {
564            request: None,
565            turn_id: Some(1),
566        };
567        #[derive(serde::Deserialize)]
568        struct LegacyInputEvent {
569            #[serde(rename = "request")]
570            _request: mj_core::elicitation::ElicitationRequest,
571        }
572        let encoded = serde_json::to_value(&event).unwrap();
573        assert!(serde_json::from_value::<LegacyInputEvent>(encoded["data"].clone()).is_err());
574    }
575
576    #[test]
577    fn older_readers_and_reopened_writers_preserve_a_compatible_future_schema() {
578        let directory = tempfile::tempdir().unwrap();
579        let path = directory.path().join("mj.sqlite3");
580        let connection = open_writer(&path).unwrap();
581        connection
582            .execute_batch(
583                "CREATE TABLE future_feature(value TEXT NOT NULL);
584                 INSERT INTO future_feature VALUES ('preserve me');",
585            )
586            .unwrap();
587        drop(connection);
588        advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
589
590        let reader = open_reader_strict(&path).unwrap();
591        assert_eq!(
592            reader
593                .query_row("SELECT value FROM future_feature", [], |row| row
594                    .get::<_, String>(0))
595                .unwrap(),
596            "preserve me"
597        );
598        assert!(reader.execute("DELETE FROM future_feature", []).is_err());
599        drop(reader);
600
601        // A repair would recreate this deliberately removed trigger. A future
602        // schema is authoritative even when it differs from our own repairs.
603        let raw = Connection::open(&path).unwrap();
604        raw.execute_batch("DROP TRIGGER api_session_error_updated;")
605            .unwrap();
606        drop(raw);
607        let writer = open_writer(&path).unwrap();
608        assert!(!writer.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'api_session_error_updated')", [], |row| row.get::<_, bool>(0)).unwrap());
609        assert_eq!(
610            writer
611                .query_row("SELECT value FROM future_feature", [], |row| row
612                    .get::<_, String>(0))
613                .unwrap(),
614            "preserve me"
615        );
616        let state = read_schema_state(&writer).unwrap();
617        assert_eq!(state.revision, SCHEMA_VERSION + 1);
618        assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
619    }
620
621    #[test]
622    fn invalid_compatibility_metadata_refuses_readers_and_writers() {
623        for alteration in [
624            "DROP TABLE schema_compatibility",
625            "DELETE FROM schema_compatibility",
626            "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET minimum_compatible_version = 0",
627            "UPDATE schema_compatibility SET minimum_compatible_version = 99999",
628            "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET singleton = 2",
629            "PRAGMA ignore_check_constraints = ON; INSERT INTO schema_compatibility VALUES (2, 30)",
630            "DROP TABLE schema_compatibility; CREATE TABLE schema_compatibility(singleton, minimum_compatible_version); INSERT INTO schema_compatibility VALUES (1, 'invalid')",
631            "DELETE FROM schema_migrations WHERE version = (SELECT max(version) FROM schema_migrations)",
632        ] {
633            for future in [false, true] {
634                let directory = tempfile::tempdir().unwrap();
635                let path = directory.path().join("mj.sqlite3");
636                drop(open_writer(&path).unwrap());
637                if future {
638                    advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
639                }
640                let raw = Connection::open(&path).unwrap();
641                raw.execute_batch(alteration).unwrap();
642                let before: i64 = raw
643                    .query_row("PRAGMA schema_version", [], |row| row.get(0))
644                    .unwrap();
645                // Exercise the cached path as well as a fresh writer open.
646                for error in [
647                    open_reader_strict(&path).unwrap_err(),
648                    open_writer(&path).unwrap_err(),
649                ] {
650                    let mismatch = error.downcast_ref::<StoreSchemaMismatch>().unwrap();
651                    assert_eq!(
652                        mismatch.reason,
653                        StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
654                        "{alteration}"
655                    );
656                }
657                forget_verified_schema(&path);
658                assert!(open_writer(&path).is_err(), "{alteration}");
659                let after: i64 = raw
660                    .query_row("PRAGMA schema_version", [], |row| row.get(0))
661                    .unwrap();
662                assert_eq!(
663                    before, after,
664                    "a rejected open repaired schema: {alteration}"
665                );
666            }
667        }
668    }
669
670    #[test]
671    fn a_failed_baseline_leaves_an_empty_store_that_a_retry_creates() {
672        let directory = tempfile::tempdir().unwrap();
673        let path = directory.path().join("mj.sqlite3");
674        let connection = Connection::open(&path).unwrap();
675        // A table the baseline also creates makes its batch fail part way.
676        connection
677            .execute_batch("CREATE TABLE workspaces(conflict TEXT)")
678            .unwrap();
679
680        let error = migrate_schema(&connection).unwrap_err();
681
682        assert!(format!("{error:#}").contains("create baseline database schema"));
683        assert!(
684            connection.is_autocommit(),
685            "the failed baseline left a transaction open"
686        );
687        assert_eq!(read_schema_state(&connection).unwrap().revision, 0);
688        let tables: i64 = connection
689            .query_row(
690                "SELECT count(*) FROM sqlite_schema WHERE type = 'table'",
691                [],
692                |row| row.get(0),
693            )
694            .unwrap();
695        assert_eq!(tables, 1, "only the conflicting table remains");
696
697        connection.execute_batch("DROP TABLE workspaces").unwrap();
698        drop(connection);
699        let writer = open_writer(&path).unwrap();
700        let state = read_schema_state(&writer).unwrap();
701        assert_eq!(state.revision, SCHEMA_VERSION);
702        assert_eq!(state.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
703    }
704
705    #[test]
706    fn a_store_from_before_the_baseline_is_refused_with_upgrade_advice() {
707        let directory = tempfile::tempdir().unwrap();
708        let path = directory.path().join("mj.sqlite3");
709        let connection = Connection::open(&path).unwrap();
710        connection
711            .execute_batch(&format!(
712                "PRAGMA user_version = {};",
713                COMPATIBILITY_METADATA_VERSION - 1
714            ))
715            .unwrap();
716        drop(connection);
717
718        let error = open_writer(&path).unwrap_err();
719
720        let message = format!("{error:#}");
721        assert!(message.contains("older than 2.7.2"), "{message}");
722        assert!(
723            message.contains("from 2.7.2 through 2.9.x"),
724            "advice must name the closed range of releases that can migrate: {message}"
725        );
726    }
727
728    /// A store ahead of this build cannot be fixed by starting a daemon of
729    /// this build, so the reader must not say so. This is the message the
730    /// incident in #24 printed twice a second for an hour.
731    #[test]
732    fn strict_reader_reports_a_newer_store_without_blaming_the_daemon() {
733        let directory = tempfile::tempdir().unwrap();
734        let path = directory.path().join("mj.sqlite3");
735        drop(open_writer(&path).unwrap());
736        stamp_schema_version(&path, SCHEMA_VERSION + 1);
737
738        let error = open_reader_strict(&path).unwrap_err();
739
740        let mismatch = error
741            .chain()
742            .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
743            .expect("the reader reports the mismatch as a typed cause");
744        assert_eq!(mismatch.found, SCHEMA_VERSION + 1);
745        assert_eq!(mismatch.supported, SCHEMA_VERSION);
746        let message = mismatch.to_string();
747        assert!(message.contains("upgrade Mjolnir"), "got {message}");
748        assert!(
749            !message.contains("start the Mjolnir daemon"),
750            "got {message}"
751        );
752    }
753
754    /// A store behind this build keeps the advice that works, verbatim, so
755    /// existing log greps and runbooks keep matching.
756    #[test]
757    fn strict_reader_keeps_the_migrate_advice_when_the_store_is_behind() {
758        let directory = tempfile::tempdir().unwrap();
759        let path = directory.path().join("mj.sqlite3");
760        drop(open_writer(&path).unwrap());
761        let raw = Connection::open(&path).unwrap();
762        raw.execute_batch(&format!(
763            "UPDATE schema_compatibility SET minimum_compatible_version = {0};
764             DELETE FROM schema_migrations WHERE version > {0};
765             INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES ({0}, 'test');
766             PRAGMA user_version = {0};",
767            SCHEMA_VERSION - 1
768        ))
769        .unwrap();
770        drop(raw);
771
772        let error = open_reader_strict(&path).unwrap_err();
773
774        let mismatch = error
775            .chain()
776            .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
777            .expect("the reader reports the mismatch as a typed cause");
778        assert_eq!(
779            mismatch.to_string(),
780            format!(
781                "Mjolnir database schema {} is not the supported schema {SCHEMA_VERSION}; \
782                 start the Mjolnir daemon to migrate it",
783                SCHEMA_VERSION - 1
784            )
785        );
786    }
787
788    #[test]
789    fn strict_reader_rejects_mutation() {
790        let directory = tempfile::tempdir().unwrap();
791        let path = directory.path().join("mj.sqlite3");
792        drop(open_writer(&path).unwrap());
793
794        let reader = open_reader_strict(&path).unwrap();
795        let error = reader
796            .execute("CREATE TABLE forbidden(value TEXT)", [])
797            .unwrap_err();
798        assert!(
799            matches!(
800                error.sqlite_error_code(),
801                Some(rusqlite::ErrorCode::ReadOnly)
802            ),
803            "unexpected mutation error: {error}"
804        );
805    }
806}