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 if version < 32 {
801 migrate_zcode_harness_kind(connection)?;
802 }
803 let recorded: Option<i64> =
804 connection.query_row("SELECT max(version) FROM schema_migrations", [], |row| {
805 row.get(0)
806 })?;
807 if recorded != Some(SCHEMA_VERSION) {
808 bail!(
809 "Mjolnir database migration ledger {:?} does not match schema {}",
810 recorded,
811 SCHEMA_VERSION
812 );
813 }
814 ensure_client_session_state_schema(connection)?;
820 ensure_api_events_schema(connection)?;
821 Ok(())
822}
823
824fn migrate_compatibility_metadata(connection: &Connection) -> Result<()> {
828 let transaction = connection.unchecked_transaction()?;
829 transaction.execute_batch(
830 "CREATE TABLE schema_compatibility (
831 singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
832 minimum_compatible_version INTEGER NOT NULL CHECK(minimum_compatible_version >= 30)
833 ) STRICT;
834 INSERT INTO schema_compatibility(singleton, minimum_compatible_version) VALUES (1, 30);
835 INSERT INTO schema_migrations(version, applied_at)
836 VALUES (30, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
837 PRAGMA user_version = 30;",
838 )?;
839 transaction.commit()?;
840 Ok(())
841}
842
843fn migrate_subagent_sessions(connection: &Connection) -> Result<()> {
846 let transaction = connection.unchecked_transaction()?;
847 transaction.execute_batch(
848 "CREATE TABLE IF NOT EXISTS subagent_sessions (
849 child_session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
850 parent_session_id TEXT NOT NULL REFERENCES sessions(session_id),
851 request_key TEXT NOT NULL,
852 record_json TEXT NOT NULL CHECK(json_valid(record_json)),
853 CHECK(child_session_id <> parent_session_id),
854 UNIQUE(parent_session_id, request_key)
855 ) STRICT;
856 CREATE INDEX IF NOT EXISTS subagent_sessions_parent
857 ON subagent_sessions(parent_session_id, child_session_id);
858 UPDATE schema_compatibility SET minimum_compatible_version = 31
859 WHERE singleton = 1;
860 INSERT INTO schema_migrations(version, applied_at)
861 VALUES (31, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
862 PRAGMA user_version = 31;",
863 )?;
864 transaction.commit()?;
865 Ok(())
866}
867
868fn migrate_zcode_harness_kind(connection: &Connection) -> Result<()> {
871 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
872 let migration = (|| -> Result<()> {
873 let transaction = connection.unchecked_transaction()?;
874 for table in ["sessions", "hidden_native_sessions"] {
875 let sql: String = transaction.query_row(
876 "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
877 [table],
878 |row| row.get(0),
879 )?;
880 let (_, definition) = sql
881 .split_once('(')
882 .context("missing harness table definition")?;
883 if definition.contains("'muse','zcode')") {
884 continue;
885 }
886 ensure!(
887 definition.contains("'muse')"),
888 "unexpected {table} harness constraint"
889 );
890 let definition = definition.replace("'muse')", "'muse','zcode')");
891 let objects: Vec<String> = transaction
892 .prepare(
893 "SELECT sql FROM sqlite_schema WHERE tbl_name=?1
894 AND type IN ('index','trigger') AND sql IS NOT NULL",
895 )?
896 .query_map([table], |row| row.get(0))?
897 .collect::<rusqlite::Result<_>>()?;
898 transaction.execute_batch(&format!(
899 "CREATE TABLE {table}_zcode_v32 ({definition};
900 INSERT INTO {table}_zcode_v32 SELECT * FROM {table};
901 DROP TABLE {table};
902 ALTER TABLE {table}_zcode_v32 RENAME TO {table};"
903 ))?;
904 for object in objects {
905 transaction.execute_batch(&object)?;
906 }
907 }
908 ensure!(
909 !transaction
910 .prepare("PRAGMA foreign_key_check")?
911 .exists([])?,
912 "foreign key violation in ZCode migration"
913 );
914 transaction.execute_batch(
915 "UPDATE schema_compatibility SET minimum_compatible_version = 32
916 WHERE singleton = 1;
917 INSERT INTO schema_migrations(version, applied_at)
918 VALUES (32, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
919 PRAGMA user_version = 32;",
920 )?;
921 transaction.commit()?;
922 Ok(())
923 })();
924 let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
925 migration.context("migrate ZCode harness constraints")?;
926 restored.context("restore foreign key enforcement after ZCode migration")?;
927 Ok(())
928}
929
930pub(super) fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
931 connection
932 .query_row(
933 "SELECT EXISTS(
934 SELECT 1 FROM pragma_table_info(?1)
935 WHERE name = ?2
936 )",
937 params![table, column],
938 |row| row.get(0),
939 )
940 .map_err(Into::into)
941}
942
943fn ensure_workspace_schema(connection: &Connection) -> Result<()> {
944 connection.execute_batch(
945 "CREATE TABLE IF NOT EXISTS workspaces (
946 workspace_id TEXT PRIMARY KEY CHECK(length(trim(workspace_id)) > 0),
947 name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 64),
948 name_key TEXT NOT NULL UNIQUE CHECK(length(trim(name_key)) BETWEEN 1 AND 64),
949 created_at TEXT NOT NULL,
950 last_opened_at TEXT NOT NULL
951 ) STRICT;
952 INSERT OR IGNORE INTO workspaces(
953 workspace_id, name, name_key, created_at, last_opened_at
954 ) VALUES (
955 'default', 'default', 'default',
956 strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
957 strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
958 );",
959 )?;
960 if !table_has_column(connection, "session_contexts", "workspace_id")? {
961 connection.execute_batch(
962 "ALTER TABLE session_contexts
963 ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default';",
964 )?;
965 }
966 connection.execute_batch(
967 "CREATE INDEX IF NOT EXISTS session_contexts_workspace
968 ON session_contexts(workspace_id, session_id);
969 CREATE TRIGGER IF NOT EXISTS session_contexts_workspace_insert
970 BEFORE INSERT ON session_contexts
971 WHEN NOT EXISTS(
972 SELECT 1 FROM workspaces WHERE workspace_id = NEW.workspace_id
973 )
974 BEGIN
975 SELECT RAISE(ABORT, 'unknown workspace');
976 END;
977 CREATE TRIGGER IF NOT EXISTS session_contexts_workspace_update
978 BEFORE UPDATE OF workspace_id ON session_contexts
979 WHEN NOT EXISTS(
980 SELECT 1 FROM workspaces WHERE workspace_id = NEW.workspace_id
981 )
982 BEGIN
983 SELECT RAISE(ABORT, 'unknown workspace');
984 END;
985 CREATE TABLE IF NOT EXISTS client_read_frontiers (
986 client_id TEXT NOT NULL CHECK(length(trim(client_id)) > 0),
987 workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
988 session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
989 through_event_ordinal INTEGER NOT NULL DEFAULT 0
990 CHECK(through_event_ordinal >= 0),
991 updated_at TEXT NOT NULL,
992 PRIMARY KEY(client_id, workspace_id, session_id)
993 ) STRICT;
994 CREATE TABLE IF NOT EXISTS detached_drafts (
995 draft_id TEXT PRIMARY KEY CHECK(length(trim(draft_id)) > 0),
996 workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id),
997 session_id TEXT REFERENCES session_contexts(session_id),
998 source TEXT NOT NULL CHECK(length(trim(source)) > 0),
999 owner_pid INTEGER CHECK(owner_pid IS NULL OR owner_pid > 0),
1000 saved_at TEXT NOT NULL,
1001 text TEXT NOT NULL CHECK(length(text) > 0),
1002 recovered_at TEXT
1003 ) STRICT;
1004 CREATE INDEX IF NOT EXISTS detached_drafts_workspace_recent
1005 ON detached_drafts(workspace_id, saved_at DESC);",
1006 )?;
1007 ensure_client_session_state_schema(connection)?;
1008 Ok(())
1009}
1010
1011fn ensure_client_session_state_schema(connection: &Connection) -> Result<()> {
1018 connection.execute_batch(
1019 "CREATE TABLE IF NOT EXISTS client_session_state (
1020 client_id TEXT NOT NULL CHECK(length(trim(client_id)) > 0),
1021 workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
1022 session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
1023 draft TEXT NOT NULL DEFAULT '',
1024 updated_at TEXT NOT NULL,
1025 PRIMARY KEY(client_id, workspace_id, session_id)
1026 ) STRICT;
1027 CREATE INDEX IF NOT EXISTS client_session_state_age
1028 ON client_session_state(updated_at);",
1029 )?;
1030 Ok(())
1031}
1032
1033fn ensure_session_container_override_columns(connection: &Connection) -> Result<()> {
1036 for column in ["container_cpus", "container_memory"] {
1037 if !table_has_column(connection, "sessions", column)? {
1038 connection.execute_batch(&format!(
1039 "BEGIN IMMEDIATE;
1040 ALTER TABLE sessions ADD COLUMN {column} TEXT;
1041 COMMIT;"
1042 ))?;
1043 }
1044 }
1045 Ok(())
1046}
1047
1048fn migrate_turn_outcome_columns(connection: &Connection) -> Result<()> {
1059 let mut statements = String::from("BEGIN IMMEDIATE;\n");
1060 if !table_has_column(connection, "materialized_sessions", "active_turn_json")? {
1061 statements.push_str(
1062 "ALTER TABLE materialized_sessions ADD COLUMN active_turn_json TEXT
1063 CHECK(active_turn_json IS NULL OR json_valid(active_turn_json));\n",
1064 );
1065 }
1066 if !table_has_column(
1067 connection,
1068 "materialized_sessions",
1069 "last_turn_outcome_json",
1070 )? {
1071 statements.push_str(
1072 "ALTER TABLE materialized_sessions ADD COLUMN last_turn_outcome_json TEXT
1073 CHECK(last_turn_outcome_json IS NULL OR json_valid(last_turn_outcome_json));\n",
1074 );
1075 }
1076 if !table_has_column(
1077 connection,
1078 "materialized_queued_prompts",
1079 "accepted_ordinal",
1080 )? {
1081 statements.push_str(
1082 "ALTER TABLE materialized_queued_prompts ADD COLUMN accepted_ordinal INTEGER
1083 CHECK(accepted_ordinal IS NULL OR accepted_ordinal > 0);\n",
1084 );
1085 }
1086 statements.push_str(
1087 "CREATE TABLE IF NOT EXISTS api_idempotency (
1088 key TEXT PRIMARY KEY CHECK(length(trim(key)) BETWEEN 1 AND 128),
1089 session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
1090 created_at_ms INTEGER NOT NULL
1091 ) STRICT;
1092 INSERT INTO schema_migrations(version, applied_at)
1093 VALUES (28, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1094 PRAGMA user_version = 28;
1095 COMMIT;",
1096 );
1097 connection.execute_batch(&statements)?;
1098 Ok(())
1099}
1100
1101fn ensure_session_mount_read_only_column(connection: &Connection) -> Result<()> {
1102 if !table_has_column(connection, "session_mounts", "read_only")? {
1103 connection.execute_batch(
1104 "BEGIN IMMEDIATE;
1105 ALTER TABLE session_mounts ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0;
1106 COMMIT;",
1107 )?;
1108 }
1109 Ok(())
1110}
1111
1112fn ensure_materialized_elicitation_column(connection: &Connection) -> Result<()> {
1113 if !table_has_column(
1114 connection,
1115 "materialized_sessions",
1116 "pending_elicitations_json",
1117 )? {
1118 connection.execute_batch(
1119 "BEGIN IMMEDIATE;
1120 ALTER TABLE materialized_sessions
1121 ADD COLUMN pending_elicitations_json TEXT NOT NULL DEFAULT '[]';
1122 COMMIT;",
1123 )?;
1124 }
1125 Ok(())
1126}
1127
1128fn ensure_managed_worktree_column(connection: &Connection) -> Result<()> {
1129 if !table_has_column(connection, "sessions", "managed_worktree")? {
1130 connection.execute_batch(
1131 "BEGIN IMMEDIATE;
1132 ALTER TABLE sessions ADD COLUMN managed_worktree TEXT;
1133 COMMIT;",
1134 )?;
1135 }
1136 Ok(())
1137}
1138
1139fn ensure_relay_projection_schema(connection: &Connection) -> Result<()> {
1142 if table_has_column(connection, "sessions", "detached_after_event_ordinal")? {
1143 return Ok(());
1144 }
1145 connection.execute_batch(&format!(
1146 "BEGIN IMMEDIATE;
1147 ALTER TABLE session_checkpoints
1148 RENAME COLUMN event_sequence TO event_frontier;
1149 ALTER TABLE prompt_history
1150 RENAME COLUMN event_sequence TO event_ordinal;
1151 ALTER TABLE sessions ADD COLUMN detached_after_event_ordinal INTEGER NOT NULL
1152 DEFAULT 0 CHECK(detached_after_event_ordinal >= 0);
1153 CREATE TABLE materialized_sessions (
1154 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1155 applied_event_ordinal INTEGER NOT NULL DEFAULT 0 CHECK(applied_event_ordinal >= 0),
1156 applied_event_digest TEXT NOT NULL
1157 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
1158 CHECK(length(applied_event_digest) = 64
1159 AND applied_event_digest NOT GLOB '*[^0-9a-f]*'),
1160 last_activity_at_ms INTEGER,
1161 execution_state TEXT NOT NULL DEFAULT 'idle'
1162 CHECK(execution_state IN ('idle','running','closing','closed')),
1163 running_started_at_ms INTEGER,
1164 session_title TEXT CHECK(session_title IS NULL OR length(trim(session_title)) > 0),
1165 configuration_json TEXT NOT NULL DEFAULT '{{}}',
1166 CHECK(
1167 (execution_state = 'running' AND running_started_at_ms IS NOT NULL)
1168 OR (execution_state != 'running' AND running_started_at_ms IS NULL)
1169 )
1170 ) STRICT;
1171 CREATE TABLE materialized_transcript_items (
1172 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
1173 stable_id TEXT NOT NULL CHECK(length(trim(stable_id)) > 0),
1174 position INTEGER NOT NULL CHECK(position > 0),
1175 latest_content_event_ordinal INTEGER
1176 CHECK(latest_content_event_ordinal IS NULL
1177 OR latest_content_event_ordinal >= position),
1178 created_at_ms INTEGER NOT NULL,
1179 last_changed_at_ms INTEGER NOT NULL CHECK(last_changed_at_ms >= created_at_ms),
1180 body_json TEXT NOT NULL,
1181 PRIMARY KEY(session_id, stable_id)
1182 ) STRICT;
1183 CREATE INDEX materialized_transcript_position
1184 ON materialized_transcript_items(session_id, position, stable_id);
1185 CREATE TABLE materialized_queued_prompts (
1186 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
1187 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
1188 command_id TEXT NOT NULL CHECK(length(trim(command_id)) > 0),
1189 content_json TEXT NOT NULL,
1190 queued_at_ms INTEGER NOT NULL,
1191 PRIMARY KEY(session_id, ordinal),
1192 UNIQUE(session_id, command_id)
1193 ) STRICT;
1194 INSERT INTO materialized_sessions(session_id)
1195 SELECT session_id FROM sessions;
1196 COMMIT;",
1197 ))?;
1198 Ok(())
1199}
1200
1201fn migrate_destroying_session_state(connection: &Connection) -> Result<()> {
1202 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1206 let migration = connection.execute_batch(
1207 "BEGIN IMMEDIATE;
1208 CREATE TABLE sessions_v7 (
1209 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1210 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1211 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi')),
1212 last_profile TEXT NOT NULL,
1213 target_template_id TEXT NOT NULL,
1214 state TEXT NOT NULL CHECK(state IN (
1215 'provisioning','running','disconnected','checkpointing','closing','destroying',
1216 'archived','lost','error','destroyed-with-data-loss'
1217 )),
1218 native_session_id TEXT,
1219 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1220 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1221 updated_at TEXT NOT NULL,
1222 detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1223 CHECK(detached_after_event_ordinal >= 0),
1224 last_error TEXT,
1225 resource_allocation TEXT,
1226 last_checkpoint_error TEXT,
1227 project_directory BLOB,
1228 managed_worktree TEXT
1229 ) STRICT;
1230 INSERT INTO sessions_v7(
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
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
1241 FROM sessions;
1242 DROP TABLE sessions;
1243 ALTER TABLE sessions_v7 RENAME TO sessions;
1244 INSERT INTO schema_migrations(version, applied_at)
1245 VALUES (7, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1246 PRAGMA user_version = 7;
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 durable-destroying-session migration");
1253 }
1254 let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1255 migration.context("migrate durable destroying session state")?;
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 durable destroying session state");
1260 }
1261 Ok(())
1262}
1263
1264fn migrate_grok_harness_kind(connection: &Connection) -> Result<()> {
1269 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1270 let migration = connection.execute_batch(
1271 "BEGIN IMMEDIATE;
1272 CREATE TABLE sessions_v9 (
1273 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1274 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1275 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1276 last_profile TEXT NOT NULL,
1277 target_template_id TEXT NOT NULL,
1278 state TEXT NOT NULL CHECK(state IN (
1279 'provisioning','running','disconnected','checkpointing','closing','destroying',
1280 'archived','lost','error','destroyed-with-data-loss'
1281 )),
1282 native_session_id TEXT,
1283 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1284 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1285 updated_at TEXT NOT NULL,
1286 detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1287 CHECK(detached_after_event_ordinal >= 0),
1288 last_error TEXT,
1289 resource_allocation TEXT,
1290 last_checkpoint_error TEXT,
1291 project_directory BLOB,
1292 managed_worktree TEXT,
1293 draft_input TEXT NOT NULL DEFAULT ''
1294 ) STRICT;
1295 INSERT INTO sessions_v9(
1296 session_id, title, harness_kind, last_profile, target_template_id, state,
1297 native_session_id, acp_session_title, session_title_override, updated_at,
1298 detached_after_event_ordinal, last_error, resource_allocation,
1299 last_checkpoint_error, project_directory, managed_worktree, draft_input
1300 )
1301 SELECT
1302 session_id, title, harness_kind, last_profile, target_template_id, state,
1303 native_session_id, acp_session_title, session_title_override, updated_at,
1304 detached_after_event_ordinal, last_error, resource_allocation,
1305 last_checkpoint_error, project_directory, managed_worktree, draft_input
1306 FROM sessions;
1307 DROP TABLE sessions;
1308 ALTER TABLE sessions_v9 RENAME TO sessions;
1309 INSERT INTO schema_migrations(version, applied_at)
1310 VALUES (9, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1311 PRAGMA user_version = 9;
1312 COMMIT;",
1313 );
1314 if migration.is_err()
1315 && let Err(error) = connection.execute_batch("ROLLBACK;")
1316 {
1317 tracing::warn!(%error, "could not roll back Grok harness migration");
1318 }
1319 let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1320 migration.context("migrate sessions table for the Grok Build harness")?;
1321 foreign_keys.context("restore foreign key enforcement after schema migration")?;
1322 let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1323 if statement.exists([])? {
1324 bail!("foreign key violation after migrating the sessions harness list");
1325 }
1326 Ok(())
1327}
1328
1329fn migrate_stopped_session_state(connection: &Connection) -> Result<()> {
1335 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1336 let migration = connection.execute_batch(
1337 "BEGIN IMMEDIATE;
1338 CREATE TABLE sessions_v10 (
1339 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1340 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1341 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1342 last_profile TEXT NOT NULL,
1343 target_template_id TEXT NOT NULL,
1344 state TEXT NOT NULL CHECK(state IN (
1345 'provisioning','running','disconnected','checkpointing','closing','destroying',
1346 'stopped','lost','error','destroyed-with-data-loss'
1347 )),
1348 native_session_id TEXT,
1349 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1350 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1351 updated_at TEXT NOT NULL,
1352 detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1353 CHECK(detached_after_event_ordinal >= 0),
1354 last_error TEXT,
1355 resource_allocation TEXT,
1356 last_checkpoint_error TEXT,
1357 project_directory BLOB,
1358 managed_worktree TEXT,
1359 draft_input TEXT NOT NULL DEFAULT '',
1360 container_cpus TEXT,
1361 container_memory TEXT,
1362 archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
1363 ) STRICT;
1364 INSERT INTO sessions_v10(
1365 session_id, title, harness_kind, last_profile, target_template_id, state,
1366 native_session_id, acp_session_title, session_title_override, updated_at,
1367 detached_after_event_ordinal, last_error, resource_allocation,
1368 last_checkpoint_error, project_directory, managed_worktree, draft_input,
1369 container_cpus, container_memory
1370 )
1371 SELECT
1372 session_id, title, harness_kind, last_profile, target_template_id,
1373 CASE state WHEN 'archived' THEN 'stopped' ELSE state END,
1374 native_session_id, acp_session_title, session_title_override, updated_at,
1375 detached_after_event_ordinal, last_error, resource_allocation,
1376 last_checkpoint_error, project_directory, managed_worktree, draft_input,
1377 container_cpus, container_memory
1378 FROM sessions;
1379 DROP TABLE sessions;
1380 ALTER TABLE sessions_v10 RENAME TO sessions;
1381 CREATE TABLE hidden_native_sessions (
1382 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1383 native_session_id TEXT NOT NULL CHECK(length(trim(native_session_id)) > 0),
1384 hidden_at TEXT NOT NULL,
1385 PRIMARY KEY(harness_kind, native_session_id)
1386 ) STRICT;
1387 INSERT INTO schema_migrations(version, applied_at)
1388 VALUES (10, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1389 PRAGMA user_version = 10;
1390 COMMIT;",
1391 );
1392 if migration.is_err()
1393 && let Err(error) = connection.execute_batch("ROLLBACK;")
1394 {
1395 tracing::warn!(%error, "could not roll back stopped-session migration");
1396 }
1397 let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1398 migration.context("migrate sessions table for the stopped session state")?;
1399 foreign_keys.context("restore foreign key enforcement after schema migration")?;
1400 let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1401 if statement.exists([])? {
1402 bail!("foreign key violation after migrating the stopped session state");
1403 }
1404 Ok(())
1405}
1406
1407fn migrate_muse_harness_kind(connection: &Connection) -> Result<()> {
1409 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1410 let migration = (|| -> Result<()> {
1411 let transaction = connection.unchecked_transaction()?;
1412 for table in ["sessions", "hidden_native_sessions"] {
1413 let sql: String = transaction.query_row(
1414 "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
1415 [table],
1416 |row| row.get(0),
1417 )?;
1418 let (_, definition) = sql
1419 .split_once('(')
1420 .context("missing harness table definition")?;
1421 if definition.contains("'deepseek','muse')") {
1422 continue;
1423 }
1424 ensure!(
1425 definition.contains("'deepseek')"),
1426 "unexpected {table} harness constraint"
1427 );
1428 let definition = definition.replace("'deepseek')", "'deepseek','muse')");
1429 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")?
1430 .query_map([table], |row| row.get(0))?.collect::<rusqlite::Result<_>>()?;
1431 transaction.execute_batch(&format!(
1432 "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};"
1433 ))?;
1434 for object in objects {
1435 transaction.execute_batch(&object)?;
1436 }
1437 }
1438 ensure!(
1439 !transaction
1440 .prepare("PRAGMA foreign_key_check")?
1441 .exists([])?,
1442 "foreign key violation in Muse migration"
1443 );
1444 transaction.execute_batch("INSERT INTO schema_migrations(version, applied_at) VALUES (27, strftime('%Y-%m-%dT%H:%M:%fZ','now')); PRAGMA user_version = 27;")?;
1445 transaction.commit()?;
1446 Ok(())
1447 })();
1448 let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
1449 migration.context("migrate Muse harness constraints")?;
1450 restored.context("restore foreign key enforcement after Muse migration")?;
1451 Ok(())
1452}
1453
1454fn migrate_deepseek_harness_kind(connection: &Connection) -> Result<()> {
1457 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1458 let migration = connection.execute_batch(
1459 "BEGIN IMMEDIATE;
1460 CREATE TABLE sessions_v11 (
1461 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1462 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1463 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok','deepseek')),
1464 last_profile TEXT NOT NULL,
1465 target_template_id TEXT NOT NULL,
1466 state TEXT NOT NULL CHECK(state IN (
1467 'provisioning','running','disconnected','checkpointing','closing','destroying',
1468 'stopped','lost','error','destroyed-with-data-loss'
1469 )),
1470 native_session_id TEXT,
1471 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1472 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1473 updated_at TEXT NOT NULL,
1474 detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1475 CHECK(detached_after_event_ordinal >= 0),
1476 last_error TEXT,
1477 resource_allocation TEXT,
1478 last_checkpoint_error TEXT,
1479 project_directory BLOB,
1480 managed_worktree TEXT,
1481 draft_input TEXT NOT NULL DEFAULT '',
1482 container_cpus TEXT,
1483 container_memory TEXT,
1484 archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
1485 ) STRICT;
1486 INSERT INTO sessions_v11 SELECT * FROM sessions;
1487 DROP TABLE sessions;
1488 ALTER TABLE sessions_v11 RENAME TO sessions;
1489 ALTER TABLE hidden_native_sessions RENAME TO hidden_native_sessions_v10;
1490 CREATE TABLE hidden_native_sessions (
1491 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok','deepseek')),
1492 native_session_id TEXT NOT NULL CHECK(length(trim(native_session_id)) > 0),
1493 hidden_at TEXT NOT NULL,
1494 PRIMARY KEY(harness_kind, native_session_id)
1495 ) STRICT;
1496 INSERT INTO hidden_native_sessions SELECT * FROM hidden_native_sessions_v10;
1497 DROP TABLE hidden_native_sessions_v10;
1498 INSERT INTO schema_migrations(version, applied_at)
1499 VALUES (11, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1500 PRAGMA user_version = 11;
1501 COMMIT;",
1502 );
1503 if migration.is_err()
1504 && let Err(error) = connection.execute_batch("ROLLBACK;")
1505 {
1506 tracing::warn!(%error, "could not roll back DeepSeek harness migration");
1507 }
1508 let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1509 migration.context("migrate sessions table for DeepSeek Harness")?;
1510 foreign_keys.context("restore foreign key enforcement after schema migration")?;
1511 let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1512 if statement.exists([])? {
1513 bail!("foreign key violation after migrating the DeepSeek Harness list");
1514 }
1515 Ok(())
1516}
1517
1518fn ensure_session_draft_input_column(connection: &Connection) -> Result<()> {
1522 if !table_has_column(connection, "sessions", "draft_input")? {
1523 connection.execute_batch(
1524 "BEGIN IMMEDIATE;
1525 ALTER TABLE sessions ADD COLUMN draft_input TEXT NOT NULL DEFAULT '';
1526 COMMIT;",
1527 )?;
1528 }
1529 Ok(())
1530}
1531
1532fn ensure_projection_digest_column(connection: &Connection) -> Result<()> {
1533 let present = connection.query_row(
1534 "SELECT EXISTS(
1535 SELECT 1 FROM pragma_table_info('materialized_sessions')
1536 WHERE name = 'applied_event_digest'
1537 )",
1538 [],
1539 |row| row.get::<_, bool>(0),
1540 )?;
1541 if !present {
1542 connection.execute_batch(&format!(
1543 "BEGIN IMMEDIATE;
1544 ALTER TABLE materialized_sessions ADD COLUMN applied_event_digest TEXT NOT NULL
1545 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
1546 CHECK(length(applied_event_digest) = 64
1547 AND applied_event_digest NOT GLOB '*[^0-9a-f]*');
1548 COMMIT;",
1549 ))?;
1550 }
1551 Ok(())
1552}
1553
1554fn ensure_api_events_schema(connection: &Connection) -> Result<()> {
1555 connection.execute_batch(
1556 "CREATE TABLE IF NOT EXISTS api_events (
1557 seq INTEGER PRIMARY KEY AUTOINCREMENT,
1558 session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
1559 recorded_at_ms INTEGER NOT NULL,
1560 body TEXT NOT NULL CHECK(json_valid(body))
1561 ) STRICT;
1562 CREATE INDEX IF NOT EXISTS api_events_session ON api_events(session_id, seq);
1563 CREATE TABLE IF NOT EXISTS api_session_activity (
1564 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1565 body TEXT NOT NULL CHECK(json_valid(body))
1566 ) STRICT;
1567 CREATE TRIGGER IF NOT EXISTS api_session_error_updated
1568 AFTER UPDATE OF last_error ON sessions
1569 WHEN NEW.last_error IS NOT NULL AND NEW.last_error IS NOT OLD.last_error
1570 BEGIN
1571 INSERT INTO api_events(session_id, recorded_at_ms, body)
1572 VALUES (NEW.session_id, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
1573 json_object('type', 'error', 'data', json_object('message', NEW.last_error, 'command_id', NULL)));
1574 END;
1575 CREATE TRIGGER IF NOT EXISTS api_session_error_inserted
1576 AFTER INSERT ON sessions WHEN NEW.last_error IS NOT NULL
1577 BEGIN
1578 INSERT INTO api_events(session_id, recorded_at_ms, body)
1579 VALUES (NEW.session_id, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
1580 json_object('type', 'error', 'data', json_object('message', NEW.last_error, 'command_id', NULL)));
1581 END;"
1582 )?;
1583 Ok(())
1584}
1585
1586#[cfg(test)]
1587pub(super) fn advance_test_schema(path: &Path, revision: i64, minimum_compatible: i64) {
1588 let connection = Connection::open(path).unwrap();
1589 let transaction = connection.unchecked_transaction().unwrap();
1590 transaction
1591 .execute(
1592 "UPDATE schema_compatibility SET minimum_compatible_version = ?1",
1593 [minimum_compatible],
1594 )
1595 .unwrap();
1596 transaction
1597 .execute(
1598 "INSERT INTO schema_migrations(version, applied_at) VALUES (?1, 'test')",
1599 [revision],
1600 )
1601 .unwrap();
1602 transaction
1603 .pragma_update(None, "user_version", revision)
1604 .unwrap();
1605 transaction.commit().unwrap();
1606 forget_verified_schema(path);
1607}
1608
1609#[cfg(test)]
1610mod reader_tests {
1611 use super::*;
1612
1613 fn stamp_schema_version(path: &Path, version: i64) {
1616 if version > SCHEMA_VERSION {
1617 advance_test_schema(path, version, version);
1618 return;
1619 }
1620 let connection = Connection::open(path).unwrap();
1621 connection
1622 .execute_batch(&format!("PRAGMA user_version = {version};"))
1623 .unwrap();
1624 connection
1625 .execute(
1626 "DELETE FROM schema_migrations WHERE version > ?1",
1627 [version],
1628 )
1629 .unwrap();
1630 if version == 30 {
1631 connection
1632 .execute(
1633 "UPDATE schema_compatibility SET minimum_compatible_version = 30 WHERE singleton = 1",
1634 [],
1635 )
1636 .unwrap();
1637 }
1638 drop(connection);
1639 forget_verified_schema(path);
1640 }
1641
1642 #[test]
1643 fn older_readers_and_reopened_writers_preserve_a_compatible_future_schema() {
1644 let directory = tempfile::tempdir().unwrap();
1645 let path = directory.path().join("mj.sqlite3");
1646 let connection = open_writer(&path).unwrap();
1647 connection
1648 .execute_batch(
1649 "CREATE TABLE future_feature(value TEXT NOT NULL);
1650 INSERT INTO future_feature VALUES ('preserve me');",
1651 )
1652 .unwrap();
1653 drop(connection);
1654 advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
1655
1656 let reader = open_reader_strict(&path).unwrap();
1657 assert_eq!(
1658 reader
1659 .query_row("SELECT value FROM future_feature", [], |row| row
1660 .get::<_, String>(0))
1661 .unwrap(),
1662 "preserve me"
1663 );
1664 assert!(reader.execute("DELETE FROM future_feature", []).is_err());
1665 drop(reader);
1666
1667 let raw = Connection::open(&path).unwrap();
1670 raw.execute_batch("DROP TRIGGER api_session_error_updated;")
1671 .unwrap();
1672 drop(raw);
1673 let writer = open_writer(&path).unwrap();
1674 assert!(!writer.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'api_session_error_updated')", [], |row| row.get::<_, bool>(0)).unwrap());
1675 assert_eq!(
1676 writer
1677 .query_row("SELECT value FROM future_feature", [], |row| row
1678 .get::<_, String>(0))
1679 .unwrap(),
1680 "preserve me"
1681 );
1682 let state = read_schema_state(&writer).unwrap();
1683 assert_eq!(state.revision, SCHEMA_VERSION + 1);
1684 assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1685 }
1686
1687 #[test]
1688 fn invalid_compatibility_metadata_refuses_readers_and_writers() {
1689 for alteration in [
1690 "DROP TABLE schema_compatibility",
1691 "DELETE FROM schema_compatibility",
1692 "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET minimum_compatible_version = 0",
1693 "UPDATE schema_compatibility SET minimum_compatible_version = 99999",
1694 "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET singleton = 2",
1695 "PRAGMA ignore_check_constraints = ON; INSERT INTO schema_compatibility VALUES (2, 30)",
1696 "DROP TABLE schema_compatibility; CREATE TABLE schema_compatibility(singleton, minimum_compatible_version); INSERT INTO schema_compatibility VALUES (1, 'invalid')",
1697 "DELETE FROM schema_migrations WHERE version = (SELECT max(version) FROM schema_migrations)",
1698 ] {
1699 for future in [false, true] {
1700 let directory = tempfile::tempdir().unwrap();
1701 let path = directory.path().join("mj.sqlite3");
1702 drop(open_writer(&path).unwrap());
1703 if future {
1704 advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
1705 }
1706 let raw = Connection::open(&path).unwrap();
1707 raw.execute_batch(alteration).unwrap();
1708 let before: i64 = raw
1709 .query_row("PRAGMA schema_version", [], |row| row.get(0))
1710 .unwrap();
1711 for error in [
1713 open_reader_strict(&path).unwrap_err(),
1714 open_writer(&path).unwrap_err(),
1715 ] {
1716 let mismatch = error.downcast_ref::<StoreSchemaMismatch>().unwrap();
1717 assert_eq!(
1718 mismatch.reason,
1719 StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
1720 "{alteration}"
1721 );
1722 }
1723 forget_verified_schema(&path);
1724 assert!(open_writer(&path).is_err(), "{alteration}");
1725 let after: i64 = raw
1726 .query_row("PRAGMA schema_version", [], |row| row.get(0))
1727 .unwrap();
1728 assert_eq!(
1729 before, after,
1730 "a rejected open repaired schema: {alteration}"
1731 );
1732 }
1733 }
1734 }
1735
1736 #[test]
1737 fn compatibility_baseline_migration_is_atomic_and_retryable() {
1738 let directory = tempfile::tempdir().unwrap();
1739 let path = directory.path().join("mj.sqlite3");
1740 let connection = open_writer(&path).unwrap();
1741 let state = read_schema_state(&connection).unwrap();
1742 assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1743 connection
1744 .execute_batch(
1745 "DROP TABLE schema_compatibility;
1746 DELETE FROM schema_migrations WHERE version >= 30;
1747 PRAGMA user_version = 29;
1748 CREATE TRIGGER reject_baseline BEFORE INSERT ON schema_migrations
1749 WHEN NEW.version = 30 BEGIN SELECT RAISE(ABORT, 'injected migration failure'); END;",
1750 )
1751 .unwrap();
1752 forget_verified_schema(&path);
1753 let error = migrate_schema(&connection).unwrap_err();
1754 assert!(error.to_string().contains("injected migration failure"));
1755 assert!(
1756 connection.is_autocommit(),
1757 "the failed migration left a transaction open"
1758 );
1759 assert_eq!(read_schema_state(&connection).unwrap().revision, 29);
1760 assert_eq!(
1761 connection
1762 .query_row("SELECT max(version) FROM schema_migrations", [], |row| row
1763 .get::<_, i64>(
1764 0
1765 ))
1766 .unwrap(),
1767 29
1768 );
1769 assert!(!connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'schema_compatibility')", [], |row| row.get::<_, bool>(0)).unwrap());
1770 connection
1771 .execute_batch("DROP TRIGGER reject_baseline")
1772 .unwrap();
1773 drop(connection);
1774 let writer = open_writer(&path).unwrap();
1775 let state = read_schema_state(&writer).unwrap();
1776 assert_eq!(state.revision, SCHEMA_VERSION);
1777 assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1778 }
1779
1780 #[test]
1784 fn strict_reader_reports_a_newer_store_without_blaming_the_daemon() {
1785 let directory = tempfile::tempdir().unwrap();
1786 let path = directory.path().join("mj.sqlite3");
1787 drop(open_writer(&path).unwrap());
1788 stamp_schema_version(&path, SCHEMA_VERSION + 1);
1789
1790 let error = open_reader_strict(&path).unwrap_err();
1791
1792 let mismatch = error
1793 .chain()
1794 .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
1795 .expect("the reader reports the mismatch as a typed cause");
1796 assert_eq!(mismatch.found, SCHEMA_VERSION + 1);
1797 assert_eq!(mismatch.supported, SCHEMA_VERSION);
1798 let message = mismatch.to_string();
1799 assert!(message.contains("upgrade Mjolnir"), "got {message}");
1800 assert!(
1801 !message.contains("start the Mjolnir daemon"),
1802 "got {message}"
1803 );
1804 }
1805
1806 #[test]
1809 fn strict_reader_keeps_the_migrate_advice_when_the_store_is_behind() {
1810 let directory = tempfile::tempdir().unwrap();
1811 let path = directory.path().join("mj.sqlite3");
1812 drop(open_writer(&path).unwrap());
1813 let raw = Connection::open(&path).unwrap();
1814 raw.execute_batch(&format!(
1815 "UPDATE schema_compatibility SET minimum_compatible_version = {0};
1816 DELETE FROM schema_migrations WHERE version > {0};
1817 PRAGMA user_version = {0};",
1818 SCHEMA_VERSION - 1
1819 ))
1820 .unwrap();
1821 drop(raw);
1822
1823 let error = open_reader_strict(&path).unwrap_err();
1824
1825 let mismatch = error
1826 .chain()
1827 .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
1828 .expect("the reader reports the mismatch as a typed cause");
1829 assert_eq!(
1830 mismatch.to_string(),
1831 format!(
1832 "Mjolnir database schema {} is not the supported schema {SCHEMA_VERSION}; \
1833 start the Mjolnir daemon to migrate it",
1834 SCHEMA_VERSION - 1
1835 )
1836 );
1837 }
1838
1839 #[test]
1840 fn strict_reader_rejects_mutation() {
1841 let directory = tempfile::tempdir().unwrap();
1842 let path = directory.path().join("mj.sqlite3");
1843 drop(open_writer(&path).unwrap());
1844
1845 let reader = open_reader_strict(&path).unwrap();
1846 let error = reader
1847 .execute("CREATE TABLE forbidden(value TEXT)", [])
1848 .unwrap_err();
1849 assert!(
1850 matches!(
1851 error.sqlite_error_code(),
1852 Some(rusqlite::ErrorCode::ReadOnly)
1853 ),
1854 "unexpected mutation error: {error}"
1855 );
1856 }
1857}