1use rusqlite::{Connection, OptionalExtension, Transaction};
56use std::collections::BTreeMap;
57use std::sync::{Mutex, OnceLock};
58
59use crate::error::SqliteStoreError;
60
61const CREATE_LEDGER_SQL: &str = "CREATE TABLE IF NOT EXISTS main.meerkat_schema (
62 domain TEXT PRIMARY KEY,
63 version INTEGER NOT NULL
64)";
65
66const CUSTODY_SAVEPOINT_SQL: &str = "SAVEPOINT meerkat_migration_custody";
71const CUSTODY_RELEASE_SQL: &str = "RELEASE SAVEPOINT meerkat_migration_custody";
72
73#[derive(Debug)]
75pub struct Migration {
76 pub version: i64,
79 pub name: &'static str,
81 pub apply: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
86}
87
88#[derive(Debug)]
90pub struct SchemaPredecessor {
91 pub version: i64,
92 pub verify: fn(&Connection) -> Result<(), String>,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum SchemaObjectKind {
98 Table,
99 Index,
100 Trigger,
101 View,
102}
103
104impl SchemaObjectKind {
105 fn sqlite_name(self) -> &'static str {
106 match self {
107 Self::Table => "table",
108 Self::Index => "index",
109 Self::Trigger => "trigger",
110 Self::View => "view",
111 }
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct SchemaObject {
123 pub kind: SchemaObjectKind,
124 pub name: &'static str,
125}
126
127#[derive(Debug)]
129pub struct SchemaDomain {
130 pub name: &'static str,
132 pub migrations: &'static [Migration],
134 pub initialize_current: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
142 pub allowed_existing_versions: &'static [i64],
146 pub released_predecessors: &'static [SchemaPredecessor],
148 pub owned_objects: &'static [SchemaObject],
151 pub retired_objects: &'static [SchemaObject],
155}
156
157impl SchemaDomain {
158 pub fn supported_version(&self) -> i64 {
160 self.migrations.last().map_or(0, |m| m.version)
161 }
162
163 fn validate(&self) -> Result<(), SqliteStoreError> {
164 for (idx, migration) in self.migrations.iter().enumerate() {
165 let expected = idx as i64 + 1;
166 if migration.version != expected {
167 return Err(SqliteStoreError::InvalidMigrationList {
168 domain: self.name.to_string(),
169 detail: format!(
170 "migration at position {idx} has version {}, expected {expected} \
171 (versions must be contiguous from 1)",
172 migration.version
173 ),
174 });
175 }
176 }
177 let supported = self.supported_version();
178 let mut previous = None;
179 for &version in self.allowed_existing_versions {
180 if version <= 0 || version > supported {
181 return Err(SqliteStoreError::InvalidMigrationList {
182 domain: self.name.to_string(),
183 detail: format!(
184 "allowed existing version {version} is outside 1..={supported}"
185 ),
186 });
187 }
188 if previous.is_some_and(|value| value >= version) {
189 return Err(SqliteStoreError::InvalidMigrationList {
190 domain: self.name.to_string(),
191 detail: "allowed existing versions must be strictly increasing".to_string(),
192 });
193 }
194 previous = Some(version);
195 }
196 if !self.allowed_existing_versions.contains(&supported) {
197 return Err(SqliteStoreError::InvalidMigrationList {
198 domain: self.name.to_string(),
199 detail: format!(
200 "allowed existing versions must explicitly include current version {supported}"
201 ),
202 });
203 }
204 for &version in self
205 .allowed_existing_versions
206 .iter()
207 .filter(|&&version| version < supported)
208 {
209 let matches = self
210 .released_predecessors
211 .iter()
212 .filter(|predecessor| predecessor.version == version)
213 .count();
214 if matches != 1 {
215 return Err(SqliteStoreError::InvalidMigrationList {
216 domain: self.name.to_string(),
217 detail: format!(
218 "allowed predecessor version {version} must have exactly one frozen \
219 verifier, found {matches}"
220 ),
221 });
222 }
223 }
224 for predecessor in self.released_predecessors {
225 if predecessor.version >= supported
226 || !self
227 .allowed_existing_versions
228 .contains(&predecessor.version)
229 {
230 return Err(SqliteStoreError::InvalidMigrationList {
231 domain: self.name.to_string(),
232 detail: format!(
233 "fingerprint verifier for version {} is not an allowed predecessor",
234 predecessor.version
235 ),
236 });
237 }
238 }
239 for (idx, object) in self
240 .owned_objects
241 .iter()
242 .chain(self.retired_objects)
243 .enumerate()
244 {
245 if object.name.is_empty() || object.name == "meerkat_schema" {
246 return Err(SqliteStoreError::InvalidMigrationList {
247 domain: self.name.to_string(),
248 detail: format!(
249 "owned object at position {idx} has reserved or empty name `{}`",
250 object.name
251 ),
252 });
253 }
254 if self
255 .owned_objects
256 .iter()
257 .chain(self.retired_objects)
258 .take(idx)
259 .any(|prior| prior.name == object.name)
260 {
261 return Err(SqliteStoreError::InvalidMigrationList {
262 domain: self.name.to_string(),
263 detail: format!("owned object name `{}` is duplicated", object.name),
264 });
265 }
266 }
267 Ok(())
268 }
269
270 fn accepts_existing_version(&self, version: i64) -> bool {
271 self.allowed_existing_versions.contains(&version)
272 }
273
274 fn verify_predecessor(&self, conn: &Connection, version: i64) -> Result<(), SqliteStoreError> {
275 if version == self.supported_version() {
276 return verify_current_schema_fingerprint(conn, self).map_err(|detail| {
277 SqliteStoreError::SchemaFingerprintMismatch {
278 domain: self.name.to_string(),
279 version,
280 detail,
281 }
282 });
283 }
284 let predecessor = self
285 .released_predecessors
286 .iter()
287 .find(|predecessor| predecessor.version == version)
288 .ok_or_else(|| unsupported_predecessor(self, version))?;
289 (predecessor.verify)(conn).map_err(|detail| SqliteStoreError::SchemaFingerprintMismatch {
290 domain: self.name.to_string(),
291 version,
292 detail,
293 })
294 }
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub struct LedgerReport {
300 pub from_version: i64,
302 pub to_version: i64,
304}
305
306impl LedgerReport {
307 pub fn migrated(&self) -> bool {
309 self.to_version > self.from_version
310 }
311}
312
313pub fn domain_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
321 if !ledger_table_exists(conn)? {
322 return Ok(None);
323 }
324 validate_ledger_shape(conn)?;
325 read_version(conn, domain)
326}
327
328pub fn preflight_schema_eligibility(
343 conn: &Connection,
344 domain: &SchemaDomain,
345) -> Result<(), SqliteStoreError> {
346 domain.validate()?;
347 let supported = domain.supported_version();
348 match domain_version(conn, domain.name)? {
349 Some(found) if found > supported => {
350 return Err(SqliteStoreError::SchemaFromTheFuture {
351 domain: domain.name.to_string(),
352 found,
353 supported,
354 });
355 }
356 Some(found) if !domain.accepts_existing_version(found) => {
357 return Err(unsupported_predecessor(domain, found));
358 }
359 Some(found) => domain.verify_predecessor(conn, found)?,
360 None => {
361 let objects = find_owned_objects(conn, domain)?;
362 if !objects.is_empty() {
363 return Err(SqliteStoreError::UnledgeredDomainObjects {
364 domain: domain.name.to_string(),
365 objects,
366 });
367 }
368 }
369 }
370 Ok(())
371}
372
373pub fn apply_domain_migrations(
380 conn: &mut Connection,
381 domain: &SchemaDomain,
382) -> Result<LedgerReport, SqliteStoreError> {
383 domain.validate()?;
384 let supported = domain.supported_version();
385
386 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
387 let current = if ledger_table_exists(&tx)? {
390 validate_ledger_shape(&tx)?;
391 read_version(&tx, domain.name)?
392 } else {
393 None
394 };
395 if let Some(found) = current {
396 if found > supported {
397 return Err(SqliteStoreError::SchemaFromTheFuture {
398 domain: domain.name.to_string(),
399 found,
400 supported,
401 });
402 }
403 if !domain.accepts_existing_version(found) {
404 return Err(unsupported_predecessor(domain, found));
405 }
406 domain.verify_predecessor(&tx, found)?;
407 } else {
408 let objects = find_owned_objects(&tx, domain)?;
409 if !objects.is_empty() {
410 return Err(SqliteStoreError::UnledgeredDomainObjects {
411 domain: domain.name.to_string(),
412 objects,
413 });
414 }
415 }
416 let current = current.unwrap_or(0);
417 if current == supported {
418 return Ok(LedgerReport {
419 from_version: current,
420 to_version: current,
421 });
422 }
423
424 if !ledger_table_exists(&tx)? {
427 tx.execute_batch(CREATE_LEDGER_SQL)?;
428 validate_ledger_shape(&tx)?;
429 }
430
431 if current == 0 {
432 tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
433 (domain.initialize_current)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
434 domain: domain.name.to_string(),
435 version: supported,
436 name: "initialize-current".to_string(),
437 source,
438 })?;
439 if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
440 return Err(SqliteStoreError::MigrationBrokeTransaction {
441 domain: domain.name.to_string(),
442 version: supported,
443 name: "initialize-current".to_string(),
444 });
445 }
446 } else {
447 for migration in domain.migrations.iter().filter(|m| m.version > current) {
448 tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
449 (migration.apply)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
450 domain: domain.name.to_string(),
451 version: migration.version,
452 name: migration.name.to_string(),
453 source,
454 })?;
455 if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
465 return Err(SqliteStoreError::MigrationBrokeTransaction {
466 domain: domain.name.to_string(),
467 version: migration.version,
468 name: migration.name.to_string(),
469 });
470 }
471 }
472 }
473 verify_current_schema_fingerprint(&tx, domain).map_err(|detail| {
474 SqliteStoreError::SchemaFingerprintMismatch {
475 domain: domain.name.to_string(),
476 version: supported,
477 detail,
478 }
479 })?;
480 tx.execute(
481 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, ?2)
482 ON CONFLICT(domain) DO UPDATE SET version = excluded.version",
483 rusqlite::params![domain.name, supported],
484 )?;
485 tx.commit()?;
486
487 Ok(LedgerReport {
488 from_version: current,
489 to_version: supported,
490 })
491}
492
493static EXPECTED_CURRENT_CATALOGS: OnceLock<Mutex<BTreeMap<String, Result<String, String>>>> =
494 OnceLock::new();
495
496fn verify_current_schema_fingerprint(
500 actual: &Connection,
501 domain: &SchemaDomain,
502) -> Result<(), String> {
503 let expected = {
504 let cache = EXPECTED_CURRENT_CATALOGS.get_or_init(|| Mutex::new(BTreeMap::new()));
505 let key = current_catalog_cache_key(domain);
506 let cached = cache
507 .lock()
508 .map_err(|_| "current catalog cache lock is poisoned".to_string())?
509 .get(&key)
510 .cloned();
511 if let Some(cached) = cached {
512 cached?
513 } else {
514 let built = build_current_catalog_fingerprint(domain);
515 cache
516 .lock()
517 .map_err(|_| "current catalog cache lock is poisoned".to_string())?
518 .insert(key, built.clone());
519 built?
520 }
521 };
522 let actual = compact_catalog_fingerprint(actual, domain, domain.owned_objects)?;
523 if actual != expected {
524 return Err(format!(
525 "current owned catalog differs: expected {expected}, found {actual}"
526 ));
527 }
528 Ok(())
529}
530
531fn current_catalog_cache_key(domain: &SchemaDomain) -> String {
536 let mut key = format!(
537 "{}\u{1f}{}\u{1f}{:x}",
538 domain.name,
539 domain.supported_version(),
540 domain.initialize_current as usize
541 );
542 for object in domain.owned_objects {
543 key.push_str(&format!(
544 "\u{1e}current:{}:{}",
545 object.kind.sqlite_name(),
546 object.name
547 ));
548 }
549 for object in domain.retired_objects {
550 key.push_str(&format!(
551 "\u{1e}retired:{}:{}",
552 object.kind.sqlite_name(),
553 object.name
554 ));
555 }
556 key
557}
558
559fn build_current_catalog_fingerprint(domain: &SchemaDomain) -> Result<String, String> {
560 let mut expected =
561 Connection::open_in_memory().map_err(|error| format!("open current oracle: {error}"))?;
562 let tx = expected
563 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
564 .map_err(|error| format!("begin current oracle: {error}"))?;
565 (domain.initialize_current)(&tx).map_err(|error| format!("build current oracle: {error}"))?;
566 tx.commit()
567 .map_err(|error| format!("commit current oracle: {error}"))?;
568 compact_catalog_fingerprint(&expected, domain, domain.owned_objects)
569}
570
571fn compact_catalog_fingerprint(
572 conn: &Connection,
573 domain: &SchemaDomain,
574 expected_objects: &[SchemaObject],
575) -> Result<String, String> {
576 let all_objects = all_domain_objects(domain);
577 let owned_by_name = all_objects
578 .iter()
579 .map(|object| (object.name, object))
580 .collect::<BTreeMap<_, _>>();
581 let current_by_name = expected_objects
582 .iter()
583 .map(|object| (object.name, object))
584 .collect::<BTreeMap<_, _>>();
585 let mut actual_names = Vec::new();
586 let mut entries = Vec::with_capacity(expected_objects.len());
587 let mut statement = conn
588 .prepare(
589 "SELECT type, name, tbl_name, sql
590 FROM main.sqlite_schema
591 WHERE name NOT LIKE 'sqlite_%'
592 ORDER BY type, name",
593 )
594 .map_err(|error| error.to_string())?;
595 let rows = statement
596 .query_map([], |row| {
597 Ok((
598 row.get::<_, String>(0)?,
599 row.get::<_, String>(1)?,
600 row.get::<_, String>(2)?,
601 row.get::<_, Option<String>>(3)?,
602 ))
603 })
604 .map_err(|error| error.to_string())?;
605 for row in rows {
606 let (kind, name, table_name, sql) = row.map_err(|error| error.to_string())?;
607 if owned_by_name.contains_key(name.as_str()) {
608 actual_names.push((kind.clone(), name.clone()));
609 }
610 if current_by_name.contains_key(name.as_str()) {
611 entries.push(format!(
612 "{kind}\u{1f}{name}\u{1f}{table_name}\u{1f}{}",
613 sql.map(|sql| normalize_schema_sql(&sql))
614 .unwrap_or_default()
615 ));
616 }
617 }
618 actual_names.sort();
619 let mut expected_names = expected_objects
620 .iter()
621 .map(|object| {
622 (
623 object.kind.sqlite_name().to_string(),
624 object.name.to_string(),
625 )
626 })
627 .collect::<Vec<_>>();
628 expected_names.sort();
629 if actual_names != expected_names {
630 return Err(format!(
631 "owned object set differs: expected {expected_names:?}, found {actual_names:?}"
632 ));
633 }
634
635 entries.sort();
636 Ok(entries.join("\u{1e}"))
637}
638
639fn all_domain_objects(domain: &SchemaDomain) -> Vec<SchemaObject> {
640 domain
641 .owned_objects
642 .iter()
643 .chain(domain.retired_objects)
644 .copied()
645 .collect()
646}
647
648pub fn verify_released_schema_fingerprint(
658 actual: &Connection,
659 domain: &SchemaDomain,
660 released_objects: &[SchemaObject],
661 build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
662) -> Result<(), String> {
663 let mut expected = Connection::open_in_memory()
664 .map_err(|error| format!("open fingerprint oracle: {error}"))?;
665 let tx = expected
666 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
667 .map_err(|error| format!("begin fingerprint oracle: {error}"))?;
668 build_released(&tx).map_err(|error| format!("build fingerprint oracle: {error}"))?;
669 tx.commit()
670 .map_err(|error| format!("commit fingerprint oracle: {error}"))?;
671
672 let expected_names = catalog_names(&expected, released_objects)
673 .map_err(|error| format!("read fingerprint oracle: {error}"))?;
674 let mut declared_expected = released_objects
675 .iter()
676 .map(|object| {
677 (
678 object.kind.sqlite_name().to_string(),
679 object.name.to_string(),
680 )
681 })
682 .collect::<Vec<_>>();
683 declared_expected.sort();
684 if expected_names != declared_expected {
685 return Err(format!(
686 "frozen builder produced {expected_names:?}, manifest declares {declared_expected:?}"
687 ));
688 }
689
690 let actual_names = catalog_names(actual, &all_domain_objects(domain))
691 .map_err(|error| format!("read actual catalog: {error}"))?;
692 if actual_names != declared_expected {
693 return Err(format!(
694 "owned object set differs: expected {declared_expected:?}, found {actual_names:?}"
695 ));
696 }
697
698 for object in released_objects {
699 let wanted = catalog_fingerprint(&expected, object)
700 .map_err(|error| format!("fingerprint oracle {}: {error}", object.name))?;
701 let found = catalog_fingerprint(actual, object)
702 .map_err(|error| format!("fingerprint actual {}: {error}", object.name))?;
703 if found != wanted {
704 return Err(format!(
705 "object `{}` differs: expected {wanted:?}, found {found:?}",
706 object.name
707 ));
708 }
709 }
710 Ok(())
711}
712
713#[derive(Debug, PartialEq, Eq)]
714struct CatalogObjectFingerprint {
715 kind: String,
716 name: String,
717 table_name: String,
718 normalized_sql: Option<String>,
719 table_columns: Vec<TableColumnFingerprint>,
720 foreign_keys: Vec<ForeignKeyFingerprint>,
721 index: Option<IndexFingerprint>,
722}
723
724#[derive(Debug, PartialEq, Eq)]
725struct TableColumnFingerprint {
726 cid: i64,
727 name: String,
728 declared_type: String,
729 not_null: bool,
730 default_value: Option<String>,
731 primary_key_position: i64,
732 hidden: i64,
733}
734
735#[derive(Debug, PartialEq, Eq)]
736struct ForeignKeyFingerprint {
737 id: i64,
738 sequence: i64,
739 target_table: String,
740 from_column: String,
741 to_column: Option<String>,
742 on_update: String,
743 on_delete: String,
744 match_clause: String,
745}
746
747#[derive(Debug, PartialEq, Eq)]
748struct IndexFingerprint {
749 unique: bool,
750 origin: String,
751 partial: bool,
752 columns: Vec<IndexColumnFingerprint>,
753}
754
755#[derive(Debug, PartialEq, Eq)]
756struct IndexColumnFingerprint {
757 sequence: i64,
758 column_id: i64,
759 name: Option<String>,
760 descending: bool,
761 collation: Option<String>,
762 key: bool,
763}
764
765fn catalog_names(
766 conn: &Connection,
767 objects: &[SchemaObject],
768) -> Result<Vec<(String, String)>, rusqlite::Error> {
769 let mut found = Vec::new();
770 let mut statement = conn.prepare(
771 "SELECT type, name FROM main.sqlite_schema
772 WHERE name = ?1 AND name NOT LIKE 'sqlite_%'
773 ORDER BY type, name",
774 )?;
775 for object in objects {
776 let rows = statement.query_map([object.name], |row| Ok((row.get(0)?, row.get(1)?)))?;
777 found.extend(rows.collect::<Result<Vec<_>, _>>()?);
778 }
779 found.sort();
780 found.dedup();
781 Ok(found)
782}
783
784fn catalog_fingerprint(
785 conn: &Connection,
786 object: &SchemaObject,
787) -> Result<CatalogObjectFingerprint, rusqlite::Error> {
788 let (kind, name, table_name, sql): (String, String, String, Option<String>) = conn.query_row(
789 "SELECT type, name, tbl_name, sql FROM main.sqlite_schema WHERE name = ?1",
790 [object.name],
791 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
792 )?;
793 let (table_columns, foreign_keys) = if kind == "table" || kind == "view" {
794 (
795 table_columns(conn, object.name)?,
796 foreign_keys(conn, object.name)?,
797 )
798 } else {
799 (Vec::new(), Vec::new())
800 };
801 let index = if kind == "index" {
802 Some(index_fingerprint(conn, &table_name, object.name)?)
803 } else {
804 None
805 };
806 Ok(CatalogObjectFingerprint {
807 kind,
808 name,
809 table_name,
810 normalized_sql: sql.map(|sql| normalize_schema_sql(&sql)),
811 table_columns,
812 foreign_keys,
813 index,
814 })
815}
816
817fn table_columns(
818 conn: &Connection,
819 table: &str,
820) -> Result<Vec<TableColumnFingerprint>, rusqlite::Error> {
821 let mut statement = conn.prepare(
822 "SELECT cid, name, type, \"notnull\", dflt_value, pk, hidden
823 FROM pragma_table_xinfo(?1, 'main')
824 ORDER BY cid",
825 )?;
826 let rows = statement.query_map([table], |row| {
827 Ok(TableColumnFingerprint {
828 cid: row.get(0)?,
829 name: row.get(1)?,
830 declared_type: row.get(2)?,
831 not_null: row.get(3)?,
832 default_value: row.get(4)?,
833 primary_key_position: row.get(5)?,
834 hidden: row.get(6)?,
835 })
836 })?;
837 rows.collect()
838}
839
840fn foreign_keys(
841 conn: &Connection,
842 table: &str,
843) -> Result<Vec<ForeignKeyFingerprint>, rusqlite::Error> {
844 let mut statement = conn.prepare(
845 "SELECT id, seq, \"table\", \"from\", \"to\", on_update, on_delete, \"match\"
846 FROM pragma_foreign_key_list(?1, 'main')
847 ORDER BY id, seq",
848 )?;
849 let rows = statement.query_map([table], |row| {
850 Ok(ForeignKeyFingerprint {
851 id: row.get(0)?,
852 sequence: row.get(1)?,
853 target_table: row.get(2)?,
854 from_column: row.get(3)?,
855 to_column: row.get(4)?,
856 on_update: row.get(5)?,
857 on_delete: row.get(6)?,
858 match_clause: row.get(7)?,
859 })
860 })?;
861 rows.collect()
862}
863
864fn index_fingerprint(
865 conn: &Connection,
866 table: &str,
867 index: &str,
868) -> Result<IndexFingerprint, rusqlite::Error> {
869 let (unique, origin, partial): (bool, String, bool) = conn.query_row(
870 "SELECT \"unique\", origin, partial
871 FROM pragma_index_list(?1, 'main')
872 WHERE name = ?2",
873 rusqlite::params![table, index],
874 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
875 )?;
876 let mut statement = conn.prepare(
877 "SELECT seqno, cid, name, desc, coll, key
878 FROM pragma_index_xinfo(?1, 'main')
879 ORDER BY seqno",
880 )?;
881 let columns = statement
882 .query_map([index], |row| {
883 Ok(IndexColumnFingerprint {
884 sequence: row.get(0)?,
885 column_id: row.get(1)?,
886 name: row.get(2)?,
887 descending: row.get(3)?,
888 collation: row.get(4)?,
889 key: row.get(5)?,
890 })
891 })?
892 .collect::<Result<Vec<_>, _>>()?;
893 Ok(IndexFingerprint {
894 unique,
895 origin,
896 partial,
897 columns,
898 })
899}
900
901fn normalize_schema_sql(sql: &str) -> String {
902 sql.split_whitespace()
903 .collect::<Vec<_>>()
904 .join(" ")
905 .replace(" IF NOT EXISTS ", " ")
906}
907
908fn unsupported_predecessor(domain: &SchemaDomain, found: i64) -> SqliteStoreError {
909 SqliteStoreError::UnsupportedSchemaPredecessor {
910 domain: domain.name.to_string(),
911 found,
912 supported: domain.supported_version(),
913 allowed: domain.allowed_existing_versions.to_vec(),
914 }
915}
916
917fn find_owned_objects(
922 conn: &Connection,
923 domain: &SchemaDomain,
924) -> Result<Vec<String>, SqliteStoreError> {
925 let mut found = Vec::new();
926 let mut stmt = conn.prepare(
927 "SELECT type, name FROM main.sqlite_schema
928 WHERE name = ?1 AND name NOT LIKE 'sqlite_%'
929 ORDER BY type, name",
930 )?;
931 for expected in domain.owned_objects.iter().chain(domain.retired_objects) {
932 let rows = stmt.query_map([expected.name], |row| {
933 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
934 })?;
935 for row in rows {
936 let (actual_kind, name) = row?;
937 found.push(format!(
938 "{actual_kind}:{name} (expected {})",
939 expected.kind.sqlite_name()
940 ));
941 }
942 }
943 found.sort();
944 found.dedup();
945 Ok(found)
946}
947
948fn ledger_table_exists(conn: &Connection) -> Result<bool, SqliteStoreError> {
949 let exists = conn
950 .query_row(
951 "SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'meerkat_schema'",
952 [],
953 |_| Ok(()),
954 )
955 .optional()?
956 .is_some();
957 Ok(exists)
958}
959
960fn malformed(detail: String) -> SqliteStoreError {
961 SqliteStoreError::LedgerMalformed { detail }
962}
963
964fn validate_ledger_shape(conn: &Connection) -> Result<(), SqliteStoreError> {
973 let mut stmt = conn.prepare(
974 "SELECT name, type, \"notnull\", pk FROM pragma_table_info('meerkat_schema', 'main')",
975 )?;
976 let mut rows = stmt.query([])?;
977 let mut domain_ok = false;
978 let mut version_ok = false;
979 while let Some(row) = rows.next()? {
980 let name: String = row.get(0)?;
981 let decl_type: String = row.get(1)?;
982 let notnull: bool = row.get(2)?;
983 let pk: i64 = row.get(3)?;
984 match name.as_str() {
985 "domain" => {
986 if !decl_type.eq_ignore_ascii_case("TEXT") || pk != 1 {
987 return Err(malformed(format!(
988 "column `domain` must be `TEXT PRIMARY KEY`, found type `{decl_type}` \
989 with pk position {pk}"
990 )));
991 }
992 domain_ok = true;
993 }
994 "version" => {
995 if !decl_type.eq_ignore_ascii_case("INTEGER") || !notnull || pk != 0 {
996 return Err(malformed(format!(
997 "column `version` must be non-key `INTEGER NOT NULL`, found type \
998 `{decl_type}` notnull={notnull} pk position {pk}"
999 )));
1000 }
1001 version_ok = true;
1002 }
1003 other => {
1004 if pk != 0 {
1005 return Err(malformed(format!(
1006 "unexpected primary-key column `{other}`"
1007 )));
1008 }
1009 }
1010 }
1011 }
1012 if !domain_ok || !version_ok {
1013 return Err(malformed(
1014 "table lacks the pinned `domain`/`version` columns".to_string(),
1015 ));
1016 }
1017 Ok(())
1018}
1019
1020fn read_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
1027 let mut stmt = conn.prepare("SELECT version FROM main.meerkat_schema WHERE domain = ?1")?;
1028 let mut rows = stmt.query([domain])?;
1029 let Some(row) = rows.next()? else {
1030 return Ok(None);
1031 };
1032 let version: i64 = row.get(0)?;
1033 if rows.next()?.is_some() {
1034 return Err(malformed(format!(
1035 "multiple ledger rows for domain `{domain}`"
1036 )));
1037 }
1038 if version <= 0 {
1039 return Err(malformed(format!(
1040 "domain `{domain}` records non-positive version {version}"
1041 )));
1042 }
1043 Ok(Some(version))
1044}
1045
1046#[cfg(test)]
1047#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1048mod tests {
1049 use super::*;
1050 use crate::profile::{ConnectionProfile, open};
1051
1052 fn create_t1(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1053 tx.execute_batch("CREATE TABLE IF NOT EXISTS t1 (x INTEGER)")
1054 }
1055
1056 fn add_column_guarded(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1057 let has_column = tx
1058 .prepare("PRAGMA table_info(t1)")?
1059 .query_map([], |row| row.get::<_, String>(1))?
1060 .collect::<Result<Vec<_>, _>>()?
1061 .iter()
1062 .any(|name| name == "y");
1063 if !has_column {
1064 tx.execute_batch("ALTER TABLE t1 ADD COLUMN y TEXT")?;
1065 }
1066 Ok(())
1067 }
1068
1069 fn initialize_v2(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1070 create_t1(tx)?;
1071 add_column_guarded(tx)
1072 }
1073
1074 fn initialize_v2_alt(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1075 tx.execute_batch("CREATE TABLE t1 (x INTEGER, z BLOB)")
1076 }
1077
1078 const RELEASED_V1_OBJECTS: &[SchemaObject] = &[SchemaObject {
1079 kind: SchemaObjectKind::Table,
1080 name: "t1",
1081 }];
1082
1083 fn verify_v1(conn: &Connection) -> Result<(), String> {
1084 verify_released_schema_fingerprint(conn, &DOMAIN_V2, RELEASED_V1_OBJECTS, create_t1)
1085 }
1086
1087 const DOMAIN_V1: SchemaDomain = SchemaDomain {
1088 name: "test-domain",
1089 migrations: &[Migration {
1090 version: 1,
1091 name: "base",
1092 apply: create_t1,
1093 }],
1094 initialize_current: create_t1,
1095 allowed_existing_versions: &[1],
1096 released_predecessors: &[],
1097 owned_objects: &[SchemaObject {
1098 kind: SchemaObjectKind::Table,
1099 name: "t1",
1100 }],
1101 retired_objects: &[],
1102 };
1103
1104 const DOMAIN_V2: SchemaDomain = SchemaDomain {
1105 name: "test-domain",
1106 migrations: &[
1107 Migration {
1108 version: 1,
1109 name: "base",
1110 apply: create_t1,
1111 },
1112 Migration {
1113 version: 2,
1114 name: "add-y",
1115 apply: add_column_guarded,
1116 },
1117 ],
1118 initialize_current: initialize_v2,
1119 allowed_existing_versions: &[1, 2],
1120 released_predecessors: &[SchemaPredecessor {
1121 version: 1,
1122 verify: verify_v1,
1123 }],
1124 owned_objects: &[SchemaObject {
1125 kind: SchemaObjectKind::Table,
1126 name: "t1",
1127 }],
1128 retired_objects: &[],
1129 };
1130
1131 const DOMAIN_V2_ALT_INITIALIZER: SchemaDomain = SchemaDomain {
1132 name: "test-domain",
1133 migrations: &[
1134 Migration {
1135 version: 1,
1136 name: "base",
1137 apply: create_t1,
1138 },
1139 Migration {
1140 version: 2,
1141 name: "alt-current",
1142 apply: add_column_guarded,
1143 },
1144 ],
1145 initialize_current: initialize_v2_alt,
1146 allowed_existing_versions: &[2],
1147 released_predecessors: &[],
1148 owned_objects: &[SchemaObject {
1149 kind: SchemaObjectKind::Table,
1150 name: "t1",
1151 }],
1152 retired_objects: &[],
1153 };
1154
1155 fn temp_conn(dir: &tempfile::TempDir) -> Connection {
1156 open(&dir.path().join("db.sqlite3"), ConnectionProfile::PRIMARY).expect("open")
1157 }
1158
1159 #[test]
1160 fn fresh_file_initializes_current_and_stamps() {
1161 let dir = tempfile::tempdir().expect("tempdir");
1162 let mut conn = temp_conn(&dir);
1163 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply");
1164 assert_eq!(
1165 report,
1166 LedgerReport {
1167 from_version: 0,
1168 to_version: 2
1169 }
1170 );
1171 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
1172 conn.execute("INSERT INTO t1 (x, y) VALUES (1, 'a')", [])
1173 .expect("schema converged");
1174 }
1175
1176 #[test]
1177 fn second_open_is_current_noop() {
1178 let dir = tempfile::tempdir().expect("tempdir");
1179 let mut conn = temp_conn(&dir);
1180 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("first");
1181 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("second");
1182 assert!(!report.migrated());
1183 }
1184
1185 #[test]
1186 fn current_oracle_cache_binds_initializer_and_manifest_not_only_name_version() {
1187 let first_dir = tempfile::tempdir().expect("first tempdir");
1188 let mut first = temp_conn(&first_dir);
1189 apply_domain_migrations(&mut first, &DOMAIN_V2).expect("first current");
1190
1191 let second_dir = tempfile::tempdir().expect("second tempdir");
1192 let mut second = temp_conn(&second_dir);
1193 apply_domain_migrations(&mut second, &DOMAIN_V2_ALT_INITIALIZER).expect("alt current");
1194 let columns: Vec<String> = second
1195 .prepare("PRAGMA table_info(t1)")
1196 .expect("prepare")
1197 .query_map([], |row| row.get(1))
1198 .expect("query")
1199 .collect::<Result<_, _>>()
1200 .expect("columns");
1201 assert_eq!(columns, vec!["x", "z"]);
1202 }
1203
1204 #[test]
1205 fn current_row_with_partial_catalog_is_refused_before_noop() {
1206 let dir = tempfile::tempdir().expect("tempdir");
1207 let mut conn = temp_conn(&dir);
1208 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("current");
1209 conn.execute_batch("ALTER TABLE t1 ADD COLUMN candidate_partial TEXT")
1210 .expect("partial candidate mutation");
1211 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse current shape");
1212 assert!(matches!(
1213 err,
1214 SqliteStoreError::SchemaFingerprintMismatch { version: 2, .. }
1215 ));
1216 assert_eq!(
1217 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
1218 Some(2)
1219 );
1220 }
1221
1222 #[test]
1223 fn upgrade_applies_only_pending() {
1224 let dir = tempfile::tempdir().expect("tempdir");
1225 let mut conn = temp_conn(&dir);
1226 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
1227 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("v2");
1228 assert_eq!(
1229 report,
1230 LedgerReport {
1231 from_version: 1,
1232 to_version: 2
1233 }
1234 );
1235 }
1236
1237 #[test]
1238 fn allowed_version_with_wrong_catalog_is_refused_without_migration() {
1239 let dir = tempfile::tempdir().expect("tempdir");
1240 let mut conn = temp_conn(&dir);
1241 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
1242 conn.execute_batch("ALTER TABLE t1 ADD COLUMN candidate_only TEXT")
1243 .expect("candidate shape");
1244 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse fingerprint");
1245 assert!(matches!(
1246 err,
1247 SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
1248 ));
1249 assert_eq!(
1250 domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
1251 Some(1),
1252 "fingerprint refusal advanced the ledger"
1253 );
1254 let columns: Vec<String> = conn
1255 .prepare("PRAGMA table_info(t1)")
1256 .expect("prepare")
1257 .query_map([], |row| row.get(1))
1258 .expect("query")
1259 .collect::<Result<_, _>>()
1260 .expect("columns");
1261 assert_eq!(columns, vec!["x", "candidate_only"]);
1262 }
1263
1264 #[test]
1265 fn pre_floor_and_gap_versions_are_refused_without_mutation() {
1266 fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1267 Ok(())
1268 }
1269 fn verify_v2(_conn: &Connection) -> Result<(), String> {
1270 Ok(())
1271 }
1272 const DOMAIN_V3_FLOOR_2: SchemaDomain = SchemaDomain {
1273 name: "floor-domain",
1274 migrations: &[
1275 Migration {
1276 version: 1,
1277 name: "base",
1278 apply: no_op,
1279 },
1280 Migration {
1281 version: 2,
1282 name: "released-floor",
1283 apply: no_op,
1284 },
1285 Migration {
1286 version: 3,
1287 name: "current",
1288 apply: no_op,
1289 },
1290 ],
1291 initialize_current: no_op,
1292 allowed_existing_versions: &[2, 3],
1293 released_predecessors: &[SchemaPredecessor {
1294 version: 2,
1295 verify: verify_v2,
1296 }],
1297 owned_objects: &[],
1298 retired_objects: &[],
1299 };
1300 const DOMAIN_V4_GAP_3: SchemaDomain = SchemaDomain {
1301 name: "gap-domain",
1302 migrations: &[
1303 Migration {
1304 version: 1,
1305 name: "old",
1306 apply: no_op,
1307 },
1308 Migration {
1309 version: 2,
1310 name: "released-floor",
1311 apply: no_op,
1312 },
1313 Migration {
1314 version: 3,
1315 name: "unreleased-candidate",
1316 apply: no_op,
1317 },
1318 Migration {
1319 version: 4,
1320 name: "current",
1321 apply: no_op,
1322 },
1323 ],
1324 initialize_current: no_op,
1325 allowed_existing_versions: &[2, 4],
1326 released_predecessors: &[SchemaPredecessor {
1327 version: 2,
1328 verify: verify_v2,
1329 }],
1330 owned_objects: &[],
1331 retired_objects: &[],
1332 };
1333 let dir = tempfile::tempdir().expect("tempdir");
1334 let mut conn = temp_conn(&dir);
1335 conn.execute_batch(CREATE_LEDGER_SQL).expect("ledger");
1336 conn.execute(
1337 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 1)",
1338 [DOMAIN_V3_FLOOR_2.name],
1339 )
1340 .expect("pre-floor row");
1341 let err =
1342 apply_domain_migrations(&mut conn, &DOMAIN_V3_FLOOR_2).expect_err("refuse pre-floor");
1343 assert!(matches!(
1344 err,
1345 SqliteStoreError::UnsupportedSchemaPredecessor { found: 1, .. }
1346 ));
1347 assert_eq!(
1348 domain_version(&conn, DOMAIN_V3_FLOOR_2.name).expect("ledger"),
1349 Some(1)
1350 );
1351
1352 conn.execute(
1353 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 3)",
1354 [DOMAIN_V4_GAP_3.name],
1355 )
1356 .expect("gap row");
1357 let err = apply_domain_migrations(&mut conn, &DOMAIN_V4_GAP_3).expect_err("refuse gap");
1358 assert!(matches!(
1359 err,
1360 SqliteStoreError::UnsupportedSchemaPredecessor { found: 3, .. }
1361 ));
1362 assert_eq!(
1363 domain_version(&conn, DOMAIN_V4_GAP_3.name).expect("ledger"),
1364 Some(3)
1365 );
1366 }
1367
1368 #[test]
1369 fn unledgered_owned_objects_are_refused_without_mutation() {
1370 let dir = tempfile::tempdir().expect("tempdir");
1371 let mut conn = temp_conn(&dir);
1372 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
1373 .expect("unknown unledgered ddl");
1374 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse");
1375 assert!(matches!(
1376 err,
1377 SqliteStoreError::UnledgeredDomainObjects { .. }
1378 ));
1379 assert!(
1380 !ledger_table_exists(&conn).expect("ledger presence"),
1381 "eligibility refusal must not create the ledger"
1382 );
1383 let columns: Vec<String> = conn
1384 .prepare("PRAGMA table_info(t1)")
1385 .expect("prepare")
1386 .query_map([], |row| row.get(1))
1387 .expect("query")
1388 .collect::<Result<_, _>>()
1389 .expect("columns");
1390 assert_eq!(columns, vec!["x"], "refusal mutated unknown schema");
1391 }
1392
1393 #[test]
1394 fn fresh_domain_ignores_foreign_cotenant_objects() {
1395 let dir = tempfile::tempdir().expect("tempdir");
1396 let mut conn = temp_conn(&dir);
1397 conn.execute_batch("CREATE TABLE foreign_table (value TEXT)")
1398 .expect("foreign ddl");
1399 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("fresh domain");
1400 assert_eq!(
1401 report,
1402 LedgerReport {
1403 from_version: 0,
1404 to_version: 2
1405 }
1406 );
1407 conn.execute("INSERT INTO foreign_table VALUES ('kept')", [])
1408 .expect("foreign object survives");
1409 }
1410
1411 #[test]
1412 fn future_version_is_refused_before_any_mutation() {
1413 let dir = tempfile::tempdir().expect("tempdir");
1414 let mut conn = temp_conn(&dir);
1415 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
1416 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse");
1418 match err {
1419 SqliteStoreError::SchemaFromTheFuture {
1420 domain,
1421 found,
1422 supported,
1423 } => {
1424 assert_eq!(domain, "test-domain");
1425 assert_eq!(found, 2);
1426 assert_eq!(supported, 1);
1427 }
1428 other => panic!("wrong error: {other}"),
1429 }
1430 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
1432 }
1433
1434 #[test]
1435 fn foreign_domain_rows_are_untouched() {
1436 let dir = tempfile::tempdir().expect("tempdir");
1437 let mut conn = temp_conn(&dir);
1438 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("mine");
1439 conn.execute(
1440 "INSERT INTO meerkat_schema (domain, version) VALUES ('foreign-domain', 7)",
1441 [],
1442 )
1443 .expect("foreign row");
1444 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("noop");
1445 let foreign: i64 = conn
1446 .query_row(
1447 "SELECT version FROM meerkat_schema WHERE domain = 'foreign-domain'",
1448 [],
1449 |r| r.get(0),
1450 )
1451 .expect("foreign row survives");
1452 assert_eq!(foreign, 7);
1453 }
1454
1455 #[test]
1456 fn invalid_migration_list_is_refused_without_touching_the_file() {
1457 const BAD: SchemaDomain = SchemaDomain {
1458 name: "bad-domain",
1459 migrations: &[Migration {
1460 version: 3,
1461 name: "gap",
1462 apply: create_t1,
1463 }],
1464 initialize_current: create_t1,
1465 allowed_existing_versions: &[3],
1466 released_predecessors: &[],
1467 owned_objects: &[],
1468 retired_objects: &[],
1469 };
1470 let dir = tempfile::tempdir().expect("tempdir");
1471 let mut conn = temp_conn(&dir);
1472 let err = apply_domain_migrations(&mut conn, &BAD).expect_err("refuse");
1473 assert!(matches!(err, SqliteStoreError::InvalidMigrationList { .. }));
1474 assert!(!ledger_table_exists(&conn).expect("check"));
1475 }
1476
1477 #[test]
1478 fn failed_migration_rolls_back_atomically() {
1479 fn fail(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1480 tx.execute_batch("CREATE TABLE half_done (x INTEGER)")?;
1481 tx.execute_batch("THIS IS NOT SQL")
1482 }
1483 fn initialize_failing(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1484 create_t1(tx)?;
1485 fail(tx)
1486 }
1487 const FAILING: SchemaDomain = SchemaDomain {
1488 name: "failing-domain",
1489 migrations: &[
1490 Migration {
1491 version: 1,
1492 name: "base",
1493 apply: create_t1,
1494 },
1495 Migration {
1496 version: 2,
1497 name: "explodes",
1498 apply: fail,
1499 },
1500 ],
1501 initialize_current: initialize_failing,
1502 allowed_existing_versions: &[1, 2],
1503 released_predecessors: &[SchemaPredecessor {
1504 version: 1,
1505 verify: verify_v1,
1506 }],
1507 owned_objects: &[
1508 SchemaObject {
1509 kind: SchemaObjectKind::Table,
1510 name: "t1",
1511 },
1512 SchemaObject {
1513 kind: SchemaObjectKind::Table,
1514 name: "half_done",
1515 },
1516 ],
1517 retired_objects: &[],
1518 };
1519 let dir = tempfile::tempdir().expect("tempdir");
1520 let mut conn = temp_conn(&dir);
1521 let err = apply_domain_migrations(&mut conn, &FAILING).expect_err("must fail");
1522 assert!(matches!(
1523 err,
1524 SqliteStoreError::MigrationFailed { version: 2, .. }
1525 ));
1526 assert_eq!(domain_version(&conn, "failing-domain").expect("read"), None);
1529 let tables: Vec<String> = conn
1530 .prepare(
1531 "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('t1','half_done')",
1532 )
1533 .expect("prepare")
1534 .query_map([], |r| r.get(0))
1535 .expect("query")
1536 .collect::<Result<_, _>>()
1537 .expect("rows");
1538 assert!(tables.is_empty(), "rollback left tables behind: {tables:?}");
1539 }
1540
1541 #[test]
1542 fn malformed_ledger_shape_is_refused_not_healed() {
1543 let dir = tempfile::tempdir().expect("tempdir");
1544 let mut conn = temp_conn(&dir);
1545 conn.execute_batch("CREATE TABLE meerkat_schema (x INTEGER)")
1547 .expect("foreign ddl");
1548 let err = domain_version(&conn, "test-domain").expect_err("refuse read");
1549 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
1550 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
1551 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
1552 let count: i64 = conn
1554 .query_row("SELECT COUNT(*) FROM meerkat_schema", [], |r| r.get(0))
1555 .expect("foreign table survives");
1556 assert_eq!(count, 0);
1557 }
1558
1559 #[test]
1560 fn non_positive_versions_are_refused_not_healed() {
1561 for bad_version in [0i64, -3] {
1562 let dir = tempfile::tempdir().expect("tempdir");
1563 let mut conn = temp_conn(&dir);
1564 conn.execute_batch(
1565 "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL)",
1566 )
1567 .expect("ledger ddl");
1568 conn.execute(
1569 "INSERT INTO meerkat_schema (domain, version) VALUES ('test-domain', ?1)",
1570 [bad_version],
1571 )
1572 .expect("seed bad version");
1573 let err = domain_version(&conn, "test-domain").expect_err("refuse read");
1574 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
1575 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
1576 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
1577 let stored: i64 = conn
1579 .query_row(
1580 "SELECT version FROM meerkat_schema WHERE domain = 'test-domain'",
1581 [],
1582 |r| r.get(0),
1583 )
1584 .expect("row survives");
1585 assert_eq!(stored, bad_version);
1586 }
1587 }
1588
1589 #[test]
1590 fn duplicate_domain_rows_are_refused() {
1591 let dir = tempfile::tempdir().expect("tempdir");
1592 let conn = temp_conn(&dir);
1593 conn.execute_batch(
1597 "CREATE TABLE meerkat_schema (domain TEXT, version INTEGER NOT NULL);
1598 INSERT INTO meerkat_schema VALUES ('dup-domain', 1);
1599 INSERT INTO meerkat_schema VALUES ('dup-domain', 2);",
1600 )
1601 .expect("seed duplicates");
1602 let err = read_version(&conn, "dup-domain").expect_err("refuse duplicates");
1603 match err {
1604 SqliteStoreError::LedgerMalformed { detail } => {
1605 assert!(detail.contains("multiple ledger rows"), "{detail}");
1606 }
1607 other => panic!("wrong error: {other}"),
1608 }
1609 }
1610
1611 #[test]
1612 fn temp_shadowing_cannot_hijack_the_ledger() {
1613 let dir = tempfile::tempdir().expect("tempdir");
1614 let mut conn = temp_conn(&dir);
1615 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("stamp v1");
1616 conn.execute_batch(
1619 "CREATE TEMP TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
1620 INSERT INTO temp.meerkat_schema VALUES ('test-domain', 999);",
1621 )
1622 .expect("temp shadow");
1623 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(1));
1624 let report = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("noop against main");
1625 assert!(!report.migrated());
1626 }
1627
1628 #[test]
1629 fn migration_that_ends_the_transaction_is_refused_unstamped() {
1630 fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1631 Ok(())
1632 }
1633 fn verify_empty_predecessor(_conn: &Connection) -> Result<(), String> {
1634 Ok(())
1635 }
1636 fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1637 tx.execute_batch("CREATE TABLE escaped_commit (x INTEGER); COMMIT")
1638 }
1639 fn rolls_back_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1640 tx.execute_batch("ROLLBACK")
1641 }
1642 fn commits_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1646 tx.execute_batch("CREATE TABLE escaped_commit_begin (x INTEGER); COMMIT; BEGIN")
1647 }
1648 fn rolls_back_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1649 tx.execute_batch("ROLLBACK; BEGIN")
1650 }
1651 const COMMITS: SchemaDomain = SchemaDomain {
1652 name: "custody-commit",
1653 migrations: &[
1654 Migration {
1655 version: 1,
1656 name: "base",
1657 apply: no_op,
1658 },
1659 Migration {
1660 version: 2,
1661 name: "commits-underneath",
1662 apply: commits_underneath,
1663 },
1664 ],
1665 initialize_current: no_op,
1666 allowed_existing_versions: &[1, 2],
1667 released_predecessors: &[SchemaPredecessor {
1668 version: 1,
1669 verify: verify_empty_predecessor,
1670 }],
1671 owned_objects: &[],
1672 retired_objects: &[],
1673 };
1674 const ROLLS_BACK: SchemaDomain = SchemaDomain {
1675 name: "custody-rollback",
1676 migrations: &[
1677 Migration {
1678 version: 1,
1679 name: "base",
1680 apply: no_op,
1681 },
1682 Migration {
1683 version: 2,
1684 name: "rolls-back-underneath",
1685 apply: rolls_back_underneath,
1686 },
1687 ],
1688 initialize_current: no_op,
1689 allowed_existing_versions: &[1, 2],
1690 released_predecessors: &[SchemaPredecessor {
1691 version: 1,
1692 verify: verify_empty_predecessor,
1693 }],
1694 owned_objects: &[],
1695 retired_objects: &[],
1696 };
1697 const COMMITS_THEN_BEGINS: SchemaDomain = SchemaDomain {
1698 name: "custody-commit-begin",
1699 migrations: &[
1700 Migration {
1701 version: 1,
1702 name: "base",
1703 apply: no_op,
1704 },
1705 Migration {
1706 version: 2,
1707 name: "commits-then-begins",
1708 apply: commits_then_begins,
1709 },
1710 ],
1711 initialize_current: no_op,
1712 allowed_existing_versions: &[1, 2],
1713 released_predecessors: &[SchemaPredecessor {
1714 version: 1,
1715 verify: verify_empty_predecessor,
1716 }],
1717 owned_objects: &[],
1718 retired_objects: &[],
1719 };
1720 const ROLLS_BACK_THEN_BEGINS: SchemaDomain = SchemaDomain {
1721 name: "custody-rollback-begin",
1722 migrations: &[
1723 Migration {
1724 version: 1,
1725 name: "base",
1726 apply: no_op,
1727 },
1728 Migration {
1729 version: 2,
1730 name: "rolls-back-then-begins",
1731 apply: rolls_back_then_begins,
1732 },
1733 ],
1734 initialize_current: no_op,
1735 allowed_existing_versions: &[1, 2],
1736 released_predecessors: &[SchemaPredecessor {
1737 version: 1,
1738 verify: verify_empty_predecessor,
1739 }],
1740 owned_objects: &[],
1741 retired_objects: &[],
1742 };
1743 for (domain, expected_name) in [
1744 (&COMMITS, "commits-underneath"),
1745 (&ROLLS_BACK, "rolls-back-underneath"),
1746 (&COMMITS_THEN_BEGINS, "commits-then-begins"),
1747 (&ROLLS_BACK_THEN_BEGINS, "rolls-back-then-begins"),
1748 ] {
1749 let dir = tempfile::tempdir().expect("tempdir");
1750 let mut conn = temp_conn(&dir);
1751 conn.execute_batch(CREATE_LEDGER_SQL).expect("ledger");
1752 conn.execute(
1753 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 1)",
1754 [domain.name],
1755 )
1756 .expect("released predecessor");
1757 let err = apply_domain_migrations(&mut conn, domain).expect_err("custody violation");
1758 match err {
1759 SqliteStoreError::MigrationBrokeTransaction {
1760 domain: err_domain,
1761 version,
1762 name,
1763 } => {
1764 assert_eq!(err_domain, domain.name);
1765 assert_eq!(version, 2);
1766 assert_eq!(name, expected_name);
1767 }
1768 other => panic!("wrong error: {other}"),
1769 }
1770 assert_eq!(domain_version(&conn, domain.name).expect("read"), Some(1));
1773 }
1774 }
1775
1776 #[test]
1777 fn initializer_that_ends_the_transaction_is_refused_unstamped() {
1778 fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1779 tx.execute_batch("CREATE TABLE escaped_initializer (x INTEGER); COMMIT")
1780 }
1781 const COMMITS: SchemaDomain = SchemaDomain {
1782 name: "initializer-custody-commit",
1783 migrations: &[Migration {
1784 version: 1,
1785 name: "base",
1786 apply: commits_underneath,
1787 }],
1788 initialize_current: commits_underneath,
1789 allowed_existing_versions: &[1],
1790 released_predecessors: &[],
1791 owned_objects: &[SchemaObject {
1792 kind: SchemaObjectKind::Table,
1793 name: "escaped_initializer",
1794 }],
1795 retired_objects: &[],
1796 };
1797 let dir = tempfile::tempdir().expect("tempdir");
1798 let mut conn = temp_conn(&dir);
1799 let err = apply_domain_migrations(&mut conn, &COMMITS).expect_err("custody violation");
1800 match err {
1801 SqliteStoreError::MigrationBrokeTransaction {
1802 domain,
1803 version,
1804 name,
1805 } => {
1806 assert_eq!(domain, COMMITS.name);
1807 assert_eq!(version, 1);
1808 assert_eq!(name, "initialize-current");
1809 }
1810 other => panic!("wrong error: {other}"),
1811 }
1812 assert_eq!(domain_version(&conn, COMMITS.name).expect("read"), None);
1813 }
1814
1815 #[test]
1816 fn initializer_using_its_own_savepoints_keeps_custody() {
1817 fn nests_savepoints(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1821 tx.execute_batch(
1822 "SAVEPOINT body_sp;
1823 CREATE TABLE sp_t (x INTEGER);
1824 RELEASE SAVEPOINT body_sp",
1825 )
1826 }
1827 const NESTED: SchemaDomain = SchemaDomain {
1828 name: "custody-nested-savepoint",
1829 migrations: &[Migration {
1830 version: 1,
1831 name: "nests-savepoints",
1832 apply: nests_savepoints,
1833 }],
1834 initialize_current: nests_savepoints,
1835 allowed_existing_versions: &[1],
1836 released_predecessors: &[],
1837 owned_objects: &[SchemaObject {
1838 kind: SchemaObjectKind::Table,
1839 name: "sp_t",
1840 }],
1841 retired_objects: &[],
1842 };
1843 let dir = tempfile::tempdir().expect("tempdir");
1844 let mut conn = temp_conn(&dir);
1845 let report = apply_domain_migrations(&mut conn, &NESTED).expect("apply");
1846 assert_eq!(report.to_version, 1);
1847 assert_eq!(domain_version(&conn, NESTED.name).expect("read"), Some(1));
1848 }
1849
1850 #[test]
1851 fn schema_preflight_passes_fresh_and_current_refuses_future() {
1852 let dir = tempfile::tempdir().expect("tempdir");
1853 let mut conn = temp_conn(&dir);
1854 preflight_schema_eligibility(&conn, &DOMAIN_V1).expect("no ledger yet");
1855 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
1856 preflight_schema_eligibility(&conn, &DOMAIN_V2).expect("current");
1857 let err =
1858 preflight_schema_eligibility(&conn, &DOMAIN_V1).expect_err("future for old binary");
1859 assert!(matches!(
1860 err,
1861 SqliteStoreError::SchemaFromTheFuture {
1862 found: 2,
1863 supported: 1,
1864 ..
1865 }
1866 ));
1867 }
1868
1869 #[test]
1870 fn concurrent_opens_race_safely() {
1871 let dir = tempfile::tempdir().expect("tempdir");
1872 let path = dir.path().join("db.sqlite3");
1873 let mut handles = Vec::new();
1874 for _ in 0..8 {
1875 let path = path.clone();
1876 handles.push(std::thread::spawn(move || {
1877 let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
1878 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply")
1879 }));
1880 }
1881 let mut migrated = 0;
1882 for handle in handles {
1883 let report = handle.join().expect("thread");
1884 assert_eq!(report.to_version, 2);
1885 if report.migrated() {
1886 migrated += 1;
1887 }
1888 }
1889 assert!(migrated >= 1, "someone must have migrated");
1890 let conn = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
1891 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
1892 }
1893}