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> {
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#[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 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
151fn 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
159fn 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
174fn 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 return state.ensure_supported();
189 }
190 migrate_schema(connection)?;
193 read_schema_state(connection)?.ensure_supported()?;
194 verified.insert(key);
195 Ok(())
196}
197
198#[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 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 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 if version < 9 {
457 migrate_grok_harness_kind(connection)?;
458 }
459 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 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 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 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 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 ensure_client_session_state_schema(connection)?;
817 ensure_api_events_schema(connection)?;
818 Ok(())
819}
820
821fn 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
840fn 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
946fn 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
968fn 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
983fn 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
1074fn 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 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
1199fn 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
1264fn 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
1342fn 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
1389fn 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
1453fn 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 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 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 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 #[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 #[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}