1use rusqlite::{Connection, OptionalExtension, Transaction};
51
52use crate::error::SqliteStoreError;
53
54const CREATE_LEDGER_SQL: &str = "CREATE TABLE IF NOT EXISTS main.meerkat_schema (
55 domain TEXT PRIMARY KEY,
56 version INTEGER NOT NULL
57)";
58
59const CUSTODY_SAVEPOINT_SQL: &str = "SAVEPOINT meerkat_migration_custody";
64const CUSTODY_RELEASE_SQL: &str = "RELEASE SAVEPOINT meerkat_migration_custody";
65
66#[derive(Debug)]
68pub struct Migration {
69 pub version: i64,
72 pub name: &'static str,
74 pub apply: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
79}
80
81#[derive(Debug)]
83pub struct SchemaDomain {
84 pub name: &'static str,
86 pub migrations: &'static [Migration],
88}
89
90impl SchemaDomain {
91 pub fn supported_version(&self) -> i64 {
93 self.migrations.last().map_or(0, |m| m.version)
94 }
95
96 fn validate(&self) -> Result<(), SqliteStoreError> {
97 for (idx, migration) in self.migrations.iter().enumerate() {
98 let expected = idx as i64 + 1;
99 if migration.version != expected {
100 return Err(SqliteStoreError::InvalidMigrationList {
101 domain: self.name.to_string(),
102 detail: format!(
103 "migration at position {idx} has version {}, expected {expected} \
104 (versions must be contiguous from 1)",
105 migration.version
106 ),
107 });
108 }
109 }
110 Ok(())
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct LedgerReport {
117 pub from_version: i64,
119 pub to_version: i64,
121}
122
123impl LedgerReport {
124 pub fn migrated(&self) -> bool {
126 self.to_version > self.from_version
127 }
128}
129
130pub fn domain_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
137 if !ledger_table_exists(conn)? {
138 return Ok(None);
139 }
140 validate_ledger_shape(conn)?;
141 read_version(conn, domain)
142}
143
144pub fn refuse_future_schema(
157 conn: &Connection,
158 domain: &SchemaDomain,
159) -> Result<(), SqliteStoreError> {
160 if let Some(found) = domain_version(conn, domain.name)? {
161 let supported = domain.supported_version();
162 if found > supported {
163 return Err(SqliteStoreError::SchemaFromTheFuture {
164 domain: domain.name.to_string(),
165 found,
166 supported,
167 });
168 }
169 }
170 Ok(())
171}
172
173pub fn apply_domain_migrations(
179 conn: &mut Connection,
180 domain: &SchemaDomain,
181) -> Result<LedgerReport, SqliteStoreError> {
182 domain.validate()?;
183 let supported = domain.supported_version();
184
185 if ledger_table_exists(conn)? {
187 validate_ledger_shape(conn)?;
188 if let Some(version) = read_version(conn, domain.name)? {
189 if version == supported {
190 return Ok(LedgerReport {
191 from_version: version,
192 to_version: version,
193 });
194 }
195 if version > supported {
196 return Err(SqliteStoreError::SchemaFromTheFuture {
197 domain: domain.name.to_string(),
198 found: version,
199 supported,
200 });
201 }
202 }
203 }
204
205 conn.execute_batch(CREATE_LEDGER_SQL)?;
208
209 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
210 validate_ledger_shape(&tx)?;
214 let current = read_version(&tx, domain.name)?.unwrap_or(0);
217 if current > supported {
218 return Err(SqliteStoreError::SchemaFromTheFuture {
220 domain: domain.name.to_string(),
221 found: current,
222 supported,
223 });
224 }
225 if current == supported {
226 return Ok(LedgerReport {
227 from_version: current,
228 to_version: current,
229 });
230 }
231
232 for migration in domain.migrations.iter().filter(|m| m.version > current) {
233 tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
234 (migration.apply)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
235 domain: domain.name.to_string(),
236 version: migration.version,
237 name: migration.name.to_string(),
238 source,
239 })?;
240 if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
250 return Err(SqliteStoreError::MigrationBrokeTransaction {
251 domain: domain.name.to_string(),
252 version: migration.version,
253 name: migration.name.to_string(),
254 });
255 }
256 }
257 tx.execute(
258 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, ?2)
259 ON CONFLICT(domain) DO UPDATE SET version = excluded.version",
260 rusqlite::params![domain.name, supported],
261 )?;
262 tx.commit()?;
263
264 Ok(LedgerReport {
265 from_version: current,
266 to_version: supported,
267 })
268}
269
270fn ledger_table_exists(conn: &Connection) -> Result<bool, SqliteStoreError> {
271 let exists = conn
272 .query_row(
273 "SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'meerkat_schema'",
274 [],
275 |_| Ok(()),
276 )
277 .optional()?
278 .is_some();
279 Ok(exists)
280}
281
282fn malformed(detail: String) -> SqliteStoreError {
283 SqliteStoreError::LedgerMalformed { detail }
284}
285
286fn validate_ledger_shape(conn: &Connection) -> Result<(), SqliteStoreError> {
295 let mut stmt = conn.prepare(
296 "SELECT name, type, \"notnull\", pk FROM pragma_table_info('meerkat_schema', 'main')",
297 )?;
298 let mut rows = stmt.query([])?;
299 let mut domain_ok = false;
300 let mut version_ok = false;
301 while let Some(row) = rows.next()? {
302 let name: String = row.get(0)?;
303 let decl_type: String = row.get(1)?;
304 let notnull: bool = row.get(2)?;
305 let pk: i64 = row.get(3)?;
306 match name.as_str() {
307 "domain" => {
308 if !decl_type.eq_ignore_ascii_case("TEXT") || pk != 1 {
309 return Err(malformed(format!(
310 "column `domain` must be `TEXT PRIMARY KEY`, found type `{decl_type}` \
311 with pk position {pk}"
312 )));
313 }
314 domain_ok = true;
315 }
316 "version" => {
317 if !decl_type.eq_ignore_ascii_case("INTEGER") || !notnull || pk != 0 {
318 return Err(malformed(format!(
319 "column `version` must be non-key `INTEGER NOT NULL`, found type \
320 `{decl_type}` notnull={notnull} pk position {pk}"
321 )));
322 }
323 version_ok = true;
324 }
325 other => {
326 if pk != 0 {
327 return Err(malformed(format!(
328 "unexpected primary-key column `{other}`"
329 )));
330 }
331 }
332 }
333 }
334 if !domain_ok || !version_ok {
335 return Err(malformed(
336 "table lacks the pinned `domain`/`version` columns".to_string(),
337 ));
338 }
339 Ok(())
340}
341
342fn read_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
349 let mut stmt = conn.prepare("SELECT version FROM main.meerkat_schema WHERE domain = ?1")?;
350 let mut rows = stmt.query([domain])?;
351 let Some(row) = rows.next()? else {
352 return Ok(None);
353 };
354 let version: i64 = row.get(0)?;
355 if rows.next()?.is_some() {
356 return Err(malformed(format!(
357 "multiple ledger rows for domain `{domain}`"
358 )));
359 }
360 if version <= 0 {
361 return Err(malformed(format!(
362 "domain `{domain}` records non-positive version {version}"
363 )));
364 }
365 Ok(Some(version))
366}
367
368#[cfg(test)]
369#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
370mod tests {
371 use super::*;
372 use crate::profile::{ConnectionProfile, open};
373
374 fn create_t1(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
375 tx.execute_batch("CREATE TABLE IF NOT EXISTS t1 (x INTEGER)")
376 }
377
378 fn add_column_guarded(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
379 let has_column = tx
380 .prepare("PRAGMA table_info(t1)")?
381 .query_map([], |row| row.get::<_, String>(1))?
382 .collect::<Result<Vec<_>, _>>()?
383 .iter()
384 .any(|name| name == "y");
385 if !has_column {
386 tx.execute_batch("ALTER TABLE t1 ADD COLUMN y TEXT")?;
387 }
388 Ok(())
389 }
390
391 const DOMAIN_V1: SchemaDomain = SchemaDomain {
392 name: "test-domain",
393 migrations: &[Migration {
394 version: 1,
395 name: "base",
396 apply: create_t1,
397 }],
398 };
399
400 const DOMAIN_V2: SchemaDomain = SchemaDomain {
401 name: "test-domain",
402 migrations: &[
403 Migration {
404 version: 1,
405 name: "base",
406 apply: create_t1,
407 },
408 Migration {
409 version: 2,
410 name: "add-y",
411 apply: add_column_guarded,
412 },
413 ],
414 };
415
416 fn temp_conn(dir: &tempfile::TempDir) -> Connection {
417 open(&dir.path().join("db.sqlite3"), ConnectionProfile::PRIMARY).expect("open")
418 }
419
420 #[test]
421 fn fresh_file_applies_all_and_stamps() {
422 let dir = tempfile::tempdir().expect("tempdir");
423 let mut conn = temp_conn(&dir);
424 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply");
425 assert_eq!(
426 report,
427 LedgerReport {
428 from_version: 0,
429 to_version: 2
430 }
431 );
432 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
433 conn.execute("INSERT INTO t1 (x, y) VALUES (1, 'a')", [])
434 .expect("schema converged");
435 }
436
437 #[test]
438 fn second_open_is_noop_fast_path() {
439 let dir = tempfile::tempdir().expect("tempdir");
440 let mut conn = temp_conn(&dir);
441 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("first");
442 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("second");
443 assert!(!report.migrated());
444 }
445
446 #[test]
447 fn upgrade_applies_only_pending() {
448 let dir = tempfile::tempdir().expect("tempdir");
449 let mut conn = temp_conn(&dir);
450 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
451 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("v2");
452 assert_eq!(
453 report,
454 LedgerReport {
455 from_version: 1,
456 to_version: 2
457 }
458 );
459 }
460
461 #[test]
462 fn pre_ledger_file_with_existing_tables_converges_and_stamps() {
463 let dir = tempfile::tempdir().expect("tempdir");
464 let mut conn = temp_conn(&dir);
465 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
468 .expect("legacy ddl");
469 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("baseline");
470 assert_eq!(
471 report,
472 LedgerReport {
473 from_version: 0,
474 to_version: 2
475 }
476 );
477 conn.execute("INSERT INTO t1 (x, y) VALUES (1, 'a')", [])
478 .expect("guarded alter converged the legacy table");
479 }
480
481 #[test]
482 fn future_version_is_refused_before_any_mutation() {
483 let dir = tempfile::tempdir().expect("tempdir");
484 let mut conn = temp_conn(&dir);
485 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
486 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse");
488 match err {
489 SqliteStoreError::SchemaFromTheFuture {
490 domain,
491 found,
492 supported,
493 } => {
494 assert_eq!(domain, "test-domain");
495 assert_eq!(found, 2);
496 assert_eq!(supported, 1);
497 }
498 other => panic!("wrong error: {other}"),
499 }
500 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
502 }
503
504 #[test]
505 fn foreign_domain_rows_are_untouched() {
506 let dir = tempfile::tempdir().expect("tempdir");
507 let mut conn = temp_conn(&dir);
508 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("mine");
509 conn.execute(
510 "INSERT INTO meerkat_schema (domain, version) VALUES ('foreign-domain', 7)",
511 [],
512 )
513 .expect("foreign row");
514 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("noop");
515 let foreign: i64 = conn
516 .query_row(
517 "SELECT version FROM meerkat_schema WHERE domain = 'foreign-domain'",
518 [],
519 |r| r.get(0),
520 )
521 .expect("foreign row survives");
522 assert_eq!(foreign, 7);
523 }
524
525 #[test]
526 fn invalid_migration_list_is_refused_without_touching_the_file() {
527 const BAD: SchemaDomain = SchemaDomain {
528 name: "bad-domain",
529 migrations: &[Migration {
530 version: 3,
531 name: "gap",
532 apply: create_t1,
533 }],
534 };
535 let dir = tempfile::tempdir().expect("tempdir");
536 let mut conn = temp_conn(&dir);
537 let err = apply_domain_migrations(&mut conn, &BAD).expect_err("refuse");
538 assert!(matches!(err, SqliteStoreError::InvalidMigrationList { .. }));
539 assert!(!ledger_table_exists(&conn).expect("check"));
540 }
541
542 #[test]
543 fn failed_migration_rolls_back_atomically() {
544 fn fail(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
545 tx.execute_batch("CREATE TABLE half_done (x INTEGER)")?;
546 tx.execute_batch("THIS IS NOT SQL")
547 }
548 const FAILING: SchemaDomain = SchemaDomain {
549 name: "failing-domain",
550 migrations: &[
551 Migration {
552 version: 1,
553 name: "base",
554 apply: create_t1,
555 },
556 Migration {
557 version: 2,
558 name: "explodes",
559 apply: fail,
560 },
561 ],
562 };
563 let dir = tempfile::tempdir().expect("tempdir");
564 let mut conn = temp_conn(&dir);
565 let err = apply_domain_migrations(&mut conn, &FAILING).expect_err("must fail");
566 assert!(matches!(
567 err,
568 SqliteStoreError::MigrationFailed { version: 2, .. }
569 ));
570 assert_eq!(domain_version(&conn, "failing-domain").expect("read"), None);
573 let tables: Vec<String> = conn
574 .prepare(
575 "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('t1','half_done')",
576 )
577 .expect("prepare")
578 .query_map([], |r| r.get(0))
579 .expect("query")
580 .collect::<Result<_, _>>()
581 .expect("rows");
582 assert!(tables.is_empty(), "rollback left tables behind: {tables:?}");
583 }
584
585 #[test]
586 fn malformed_ledger_shape_is_refused_not_healed() {
587 let dir = tempfile::tempdir().expect("tempdir");
588 let mut conn = temp_conn(&dir);
589 conn.execute_batch("CREATE TABLE meerkat_schema (x INTEGER)")
591 .expect("foreign ddl");
592 let err = domain_version(&conn, "test-domain").expect_err("refuse read");
593 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
594 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
595 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
596 let count: i64 = conn
598 .query_row("SELECT COUNT(*) FROM meerkat_schema", [], |r| r.get(0))
599 .expect("foreign table survives");
600 assert_eq!(count, 0);
601 }
602
603 #[test]
604 fn non_positive_versions_are_refused_not_healed() {
605 for bad_version in [0i64, -3] {
606 let dir = tempfile::tempdir().expect("tempdir");
607 let mut conn = temp_conn(&dir);
608 conn.execute_batch(
609 "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL)",
610 )
611 .expect("ledger ddl");
612 conn.execute(
613 "INSERT INTO meerkat_schema (domain, version) VALUES ('test-domain', ?1)",
614 [bad_version],
615 )
616 .expect("seed bad version");
617 let err = domain_version(&conn, "test-domain").expect_err("refuse read");
618 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
619 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
620 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
621 let stored: i64 = conn
623 .query_row(
624 "SELECT version FROM meerkat_schema WHERE domain = 'test-domain'",
625 [],
626 |r| r.get(0),
627 )
628 .expect("row survives");
629 assert_eq!(stored, bad_version);
630 }
631 }
632
633 #[test]
634 fn duplicate_domain_rows_are_refused() {
635 let dir = tempfile::tempdir().expect("tempdir");
636 let conn = temp_conn(&dir);
637 conn.execute_batch(
641 "CREATE TABLE meerkat_schema (domain TEXT, version INTEGER NOT NULL);
642 INSERT INTO meerkat_schema VALUES ('dup-domain', 1);
643 INSERT INTO meerkat_schema VALUES ('dup-domain', 2);",
644 )
645 .expect("seed duplicates");
646 let err = read_version(&conn, "dup-domain").expect_err("refuse duplicates");
647 match err {
648 SqliteStoreError::LedgerMalformed { detail } => {
649 assert!(detail.contains("multiple ledger rows"), "{detail}");
650 }
651 other => panic!("wrong error: {other}"),
652 }
653 }
654
655 #[test]
656 fn temp_shadowing_cannot_hijack_the_ledger() {
657 let dir = tempfile::tempdir().expect("tempdir");
658 let mut conn = temp_conn(&dir);
659 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("stamp v1");
660 conn.execute_batch(
663 "CREATE TEMP TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
664 INSERT INTO temp.meerkat_schema VALUES ('test-domain', 999);",
665 )
666 .expect("temp shadow");
667 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(1));
668 let report = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("noop against main");
669 assert!(!report.migrated());
670 }
671
672 #[test]
673 fn migration_that_ends_the_transaction_is_refused_unstamped() {
674 fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
675 tx.execute_batch("CREATE TABLE escaped_commit (x INTEGER); COMMIT")
676 }
677 fn rolls_back_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
678 tx.execute_batch("ROLLBACK")
679 }
680 fn commits_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
684 tx.execute_batch("CREATE TABLE escaped_commit_begin (x INTEGER); COMMIT; BEGIN")
685 }
686 fn rolls_back_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
687 tx.execute_batch("ROLLBACK; BEGIN")
688 }
689 const COMMITS: SchemaDomain = SchemaDomain {
690 name: "custody-commit",
691 migrations: &[Migration {
692 version: 1,
693 name: "commits-underneath",
694 apply: commits_underneath,
695 }],
696 };
697 const ROLLS_BACK: SchemaDomain = SchemaDomain {
698 name: "custody-rollback",
699 migrations: &[Migration {
700 version: 1,
701 name: "rolls-back-underneath",
702 apply: rolls_back_underneath,
703 }],
704 };
705 const COMMITS_THEN_BEGINS: SchemaDomain = SchemaDomain {
706 name: "custody-commit-begin",
707 migrations: &[Migration {
708 version: 1,
709 name: "commits-then-begins",
710 apply: commits_then_begins,
711 }],
712 };
713 const ROLLS_BACK_THEN_BEGINS: SchemaDomain = SchemaDomain {
714 name: "custody-rollback-begin",
715 migrations: &[Migration {
716 version: 1,
717 name: "rolls-back-then-begins",
718 apply: rolls_back_then_begins,
719 }],
720 };
721 for (domain, expected_name) in [
722 (&COMMITS, "commits-underneath"),
723 (&ROLLS_BACK, "rolls-back-underneath"),
724 (&COMMITS_THEN_BEGINS, "commits-then-begins"),
725 (&ROLLS_BACK_THEN_BEGINS, "rolls-back-then-begins"),
726 ] {
727 let dir = tempfile::tempdir().expect("tempdir");
728 let mut conn = temp_conn(&dir);
729 let err = apply_domain_migrations(&mut conn, domain).expect_err("custody violation");
730 match err {
731 SqliteStoreError::MigrationBrokeTransaction {
732 domain: err_domain,
733 version,
734 name,
735 } => {
736 assert_eq!(err_domain, domain.name);
737 assert_eq!(version, 1);
738 assert_eq!(name, expected_name);
739 }
740 other => panic!("wrong error: {other}"),
741 }
742 assert_eq!(domain_version(&conn, domain.name).expect("read"), None);
744 }
745 }
746
747 #[test]
748 fn migration_using_its_own_savepoints_keeps_custody() {
749 fn nests_savepoints(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
753 tx.execute_batch(
754 "SAVEPOINT body_sp;
755 CREATE TABLE sp_t (x INTEGER);
756 RELEASE SAVEPOINT body_sp",
757 )
758 }
759 const NESTED: SchemaDomain = SchemaDomain {
760 name: "custody-nested-savepoint",
761 migrations: &[Migration {
762 version: 1,
763 name: "nests-savepoints",
764 apply: nests_savepoints,
765 }],
766 };
767 let dir = tempfile::tempdir().expect("tempdir");
768 let mut conn = temp_conn(&dir);
769 let report = apply_domain_migrations(&mut conn, &NESTED).expect("apply");
770 assert_eq!(report.to_version, 1);
771 assert_eq!(domain_version(&conn, NESTED.name).expect("read"), Some(1));
772 }
773
774 #[test]
775 fn refuse_future_schema_passes_fresh_and_current_refuses_future() {
776 let dir = tempfile::tempdir().expect("tempdir");
777 let mut conn = temp_conn(&dir);
778 refuse_future_schema(&conn, &DOMAIN_V1).expect("no ledger yet");
779 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
780 refuse_future_schema(&conn, &DOMAIN_V2).expect("current");
781 let err = refuse_future_schema(&conn, &DOMAIN_V1).expect_err("future for old binary");
782 assert!(matches!(
783 err,
784 SqliteStoreError::SchemaFromTheFuture {
785 found: 2,
786 supported: 1,
787 ..
788 }
789 ));
790 }
791
792 #[test]
793 fn concurrent_opens_race_safely() {
794 let dir = tempfile::tempdir().expect("tempdir");
795 let path = dir.path().join("db.sqlite3");
796 let mut handles = Vec::new();
797 for _ in 0..8 {
798 let path = path.clone();
799 handles.push(std::thread::spawn(move || {
800 let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
801 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply")
802 }));
803 }
804 let mut migrated = 0;
805 for handle in handles {
806 let report = handle.join().expect("thread");
807 assert_eq!(report.to_version, 2);
808 if report.migrated() {
809 migrated += 1;
810 }
811 }
812 assert!(migrated >= 1, "someone must have migrated");
813 let conn = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
814 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
815 }
816}