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
32pub(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 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> {
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#[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 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
164fn 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
172fn 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
187fn 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 return state.ensure_supported();
202 }
203 migrate_schema(connection)?;
206 read_schema_state(connection)?.ensure_supported()?;
207 verified.insert(key);
208 Ok(())
209}
210
211#[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
222const BASELINE_SCHEMA_VERSION: i64 = 33;
226
227const 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 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 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 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 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 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 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 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 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 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 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 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 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
445fn 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 const MINIMUM_COMPATIBLE_VERSION: i64 = 44;
511
512 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 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 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 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 #[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 #[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}