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