1use rusqlite::{Connection, OptionalExtension, Transaction};
59use std::collections::BTreeMap;
60use std::sync::{Mutex, OnceLock};
61
62use crate::error::{BridgeEligibility, CurrentCatalogUnavailability, SqliteStoreError};
63
64const CREATE_LEDGER_SQL: &str = "CREATE TABLE IF NOT EXISTS main.meerkat_schema (
65 domain TEXT PRIMARY KEY,
66 version INTEGER NOT NULL
67)";
68
69const CUSTODY_SAVEPOINT_SQL: &str = "SAVEPOINT meerkat_migration_custody";
74const CUSTODY_RELEASE_SQL: &str = "RELEASE SAVEPOINT meerkat_migration_custody";
75
76#[derive(Debug)]
78pub struct Migration {
79 pub version: i64,
82 pub name: &'static str,
84 pub apply: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
89}
90
91#[derive(Debug)]
93pub struct SchemaPredecessor {
94 pub version: i64,
95 pub verify: fn(&Connection) -> Result<(), String>,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum SchemaObjectKind {
101 Table,
102 Index,
103 Trigger,
104 View,
105}
106
107impl SchemaObjectKind {
108 fn sqlite_name(self) -> &'static str {
109 match self {
110 Self::Table => "table",
111 Self::Index => "index",
112 Self::Trigger => "trigger",
113 Self::View => "view",
114 }
115 }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct SchemaObject {
126 pub kind: SchemaObjectKind,
127 pub name: &'static str,
128}
129
130#[derive(Debug)]
132pub struct SchemaDomain {
133 pub name: &'static str,
135 pub migrations: &'static [Migration],
137 pub initialize_current: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
145 pub allowed_existing_versions: &'static [i64],
149 pub released_predecessors: &'static [SchemaPredecessor],
151 pub bridge_recoverable_versions: &'static [i64],
163 pub owned_objects: &'static [SchemaObject],
166 pub retired_objects: &'static [SchemaObject],
170}
171
172impl SchemaDomain {
173 pub fn supported_version(&self) -> i64 {
175 self.migrations.last().map_or(0, |m| m.version)
176 }
177
178 fn validate(&self) -> Result<(), SqliteStoreError> {
179 for (idx, migration) in self.migrations.iter().enumerate() {
180 let expected = idx as i64 + 1;
181 if migration.version != expected {
182 return Err(SqliteStoreError::InvalidMigrationList {
183 domain: self.name.to_string(),
184 detail: format!(
185 "migration at position {idx} has version {}, expected {expected} \
186 (versions must be contiguous from 1)",
187 migration.version
188 ),
189 });
190 }
191 }
192 let supported = self.supported_version();
193 let mut previous = None;
194 for &version in self.allowed_existing_versions {
195 if version <= 0 || version > supported {
196 return Err(SqliteStoreError::InvalidMigrationList {
197 domain: self.name.to_string(),
198 detail: format!(
199 "allowed existing version {version} is outside 1..={supported}"
200 ),
201 });
202 }
203 if previous.is_some_and(|value| value >= version) {
204 return Err(SqliteStoreError::InvalidMigrationList {
205 domain: self.name.to_string(),
206 detail: "allowed existing versions must be strictly increasing".to_string(),
207 });
208 }
209 previous = Some(version);
210 }
211 if !self.allowed_existing_versions.contains(&supported) {
212 return Err(SqliteStoreError::InvalidMigrationList {
213 domain: self.name.to_string(),
214 detail: format!(
215 "allowed existing versions must explicitly include current version {supported}"
216 ),
217 });
218 }
219 for &version in self
220 .allowed_existing_versions
221 .iter()
222 .filter(|&&version| version < supported)
223 {
224 let matches = self
225 .released_predecessors
226 .iter()
227 .filter(|predecessor| predecessor.version == version)
228 .count();
229 if matches != 1 {
230 return Err(SqliteStoreError::InvalidMigrationList {
231 domain: self.name.to_string(),
232 detail: format!(
233 "allowed predecessor version {version} must have exactly one frozen \
234 verifier, found {matches}"
235 ),
236 });
237 }
238 }
239 validate_recoverable_source_versions(self, supported, self.bridge_recoverable_versions)?;
250 for predecessor in self.released_predecessors {
251 if predecessor.version >= supported
252 || !self
253 .allowed_existing_versions
254 .contains(&predecessor.version)
255 {
256 return Err(SqliteStoreError::InvalidMigrationList {
257 domain: self.name.to_string(),
258 detail: format!(
259 "fingerprint verifier for version {} is not an allowed predecessor",
260 predecessor.version
261 ),
262 });
263 }
264 }
265 for (idx, object) in self
266 .owned_objects
267 .iter()
268 .chain(self.retired_objects)
269 .enumerate()
270 {
271 if object.name.is_empty() || object.name == "meerkat_schema" {
272 return Err(SqliteStoreError::InvalidMigrationList {
273 domain: self.name.to_string(),
274 detail: format!(
275 "owned object at position {idx} has reserved or empty name `{}`",
276 object.name
277 ),
278 });
279 }
280 if self
281 .owned_objects
282 .iter()
283 .chain(self.retired_objects)
284 .take(idx)
285 .any(|prior| prior.name == object.name)
286 {
287 return Err(SqliteStoreError::InvalidMigrationList {
288 domain: self.name.to_string(),
289 detail: format!("owned object name `{}` is duplicated", object.name),
290 });
291 }
292 }
293 Ok(())
294 }
295
296 fn accepts_existing_version(&self, version: i64) -> bool {
297 self.allowed_existing_versions.contains(&version)
298 }
299
300 pub fn bridgeable_source_version(&self, conn: &Connection) -> Option<i64> {
324 if self.validate().is_err() {
325 return None;
326 }
327 authenticate_unledgered_source_version(
328 conn,
329 self,
330 self.supported_version(),
331 self.bridge_recoverable_versions,
332 Vec::new,
333 )
334 .ok()
335 .map(|matched| matched.version)
336 }
337
338 pub fn bridge_eligibility(&self, conn: &Connection) -> BridgeEligibility {
343 match self.bridgeable_source_version(conn) {
344 Some(_) => BridgeEligibility::CatalogAuthenticated,
345 None => BridgeEligibility::Unrecognized,
346 }
347 }
348
349 fn verify_stamped_version(
355 &self,
356 conn: &Connection,
357 version: i64,
358 ) -> Result<(), SqliteStoreError> {
359 if version != self.supported_version() {
360 return self.verify_predecessor(conn, version);
361 }
362 verify_current_schema_fingerprint(conn, self).map_err(|detail| {
363 match current_catalog_diff(conn, self) {
364 Ok(diff) if !diff.is_empty() => SqliteStoreError::CurrentSchemaMismatch {
365 domain: self.name.to_string(),
366 version,
367 missing_objects: diff.missing,
368 unexpected_objects: diff.unexpected,
369 changed_objects: diff.changed,
370 },
371 _ => SqliteStoreError::SchemaFingerprintMismatch {
372 domain: self.name.to_string(),
373 version,
374 detail,
375 },
376 }
377 })
378 }
379
380 fn verify_predecessor(&self, conn: &Connection, version: i64) -> Result<(), SqliteStoreError> {
381 if version == self.supported_version() {
382 return verify_current_schema_fingerprint(conn, self).map_err(|detail| {
383 SqliteStoreError::SchemaFingerprintMismatch {
384 domain: self.name.to_string(),
385 version,
386 detail,
387 }
388 });
389 }
390 let predecessor = self
391 .released_predecessors
392 .iter()
393 .find(|predecessor| predecessor.version == version)
394 .ok_or_else(|| unsupported_predecessor(self, version))?;
395 (predecessor.verify)(conn).map_err(|detail| SqliteStoreError::SchemaFingerprintMismatch {
396 domain: self.name.to_string(),
397 version,
398 detail,
399 })
400 }
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub struct LedgerReport {
406 pub from_version: i64,
408 pub to_version: i64,
410}
411
412impl LedgerReport {
413 pub fn migrated(&self) -> bool {
415 self.to_version > self.from_version
416 }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct MaintenanceRecordRefusal {
430 pub record: String,
432 pub reason: String,
434}
435
436#[derive(Debug, Clone, Default, PartialEq, Eq)]
438pub struct MaintenancePrepareReport {
439 pub changed: usize,
441 pub refused: Vec<MaintenanceRecordRefusal>,
445}
446
447impl MaintenancePrepareReport {
448 pub fn rewrote(changed: usize) -> Self {
450 Self {
451 changed,
452 refused: Vec::new(),
453 }
454 }
455}
456
457#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct MaintenanceBridgeReport {
460 pub from_version: i64,
462 pub to_version: i64,
464 pub prepared: usize,
466 pub refused: Vec<MaintenanceRecordRefusal>,
470}
471
472impl MaintenanceBridgeReport {
473 pub fn migrated(&self) -> bool {
475 self.to_version > self.from_version
476 }
477
478 pub fn changed(&self) -> bool {
480 self.migrated() || self.prepared > 0
481 }
482}
483
484pub fn domain_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
496 if !ledger_table_exists(conn)? {
497 return Ok(None);
498 }
499 validate_ledger_shape(conn)?;
500 read_version(conn, domain)
501}
502
503fn with_read_snapshot<T>(
513 conn: &Connection,
514 read: impl FnOnce(&Connection) -> Result<T, SqliteStoreError>,
515) -> Result<T, SqliteStoreError> {
516 if !conn.is_autocommit() {
517 return read(conn);
518 }
519 let snapshot = conn.unchecked_transaction()?;
520 let result = read(&snapshot);
521 let ended = snapshot.rollback();
522 let value = result?;
523 ended?;
524 Ok(value)
525}
526
527pub fn preflight_schema_eligibility(
546 conn: &Connection,
547 domain: &SchemaDomain,
548) -> Result<(), SqliteStoreError> {
549 preflight_domain_version(conn, domain).map(|_| ())
550}
551
552pub fn preflight_domain_version(
559 conn: &Connection,
560 domain: &SchemaDomain,
561) -> Result<Option<i64>, SqliteStoreError> {
562 domain.validate()?;
563 let supported = domain.supported_version();
564 with_read_snapshot(conn, |conn| {
565 let found = domain_version(conn, domain.name)?;
566 after_preflight_ledger_read();
567 match found {
568 Some(found) if found > supported => Err(SqliteStoreError::SchemaFromTheFuture {
569 domain: domain.name.to_string(),
570 found,
571 supported,
572 }),
573 Some(found) if !domain.accepts_existing_version(found) => {
574 Err(unsupported_predecessor(domain, found))
575 }
576 Some(found) => domain
577 .verify_stamped_version(conn, found)
578 .map(|()| Some(found)),
579 None => {
580 let objects = find_owned_objects(conn, domain)?;
581 if objects.is_empty() {
582 Ok(None)
583 } else {
584 Err(SqliteStoreError::UnledgeredDomainObjects {
585 domain: domain.name.to_string(),
586 objects,
587 bridgeable: domain.bridge_eligibility(conn),
588 })
589 }
590 }
591 }
592 })
593}
594
595#[cfg(any(test, feature = "test-support"))]
596std::thread_local! {
597 static PREFLIGHT_PROBE: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
598 const { std::cell::RefCell::new(None) };
599}
600
601#[cfg(any(test, feature = "test-support"))]
606#[doc(hidden)]
607pub fn arm_preflight_probe_for_test(probe: impl FnOnce() + 'static) {
608 PREFLIGHT_PROBE.with(|slot| *slot.borrow_mut() = Some(Box::new(probe)));
609}
610
611fn after_preflight_ledger_read() {
612 #[cfg(any(test, feature = "test-support"))]
613 if let Some(probe) = PREFLIGHT_PROBE.with(|slot| slot.borrow_mut().take()) {
614 probe();
615 }
616}
617
618pub fn try_preflight_current_schema(
628 conn: &Connection,
629 domain: &SchemaDomain,
630) -> Result<(), SqliteStoreError> {
631 domain.validate()?;
632 let supported = domain.supported_version();
633 with_read_snapshot(conn, |conn| {
634 let found = domain_version(conn, domain.name)?;
635 after_preflight_ledger_read();
636 match found {
637 Some(found) if found > supported => {
638 return Err(SqliteStoreError::SchemaFromTheFuture {
639 domain: domain.name.to_string(),
640 found,
641 supported,
642 });
643 }
644 Some(found) if found == supported => {}
645 _ => {
646 return Err(SqliteStoreError::OpenRefused {
647 path: std::path::PathBuf::from(conn.path().unwrap_or(":memory:")),
648 profile: "online-existing-writer",
649 detail: "administration requires the installed current schema".to_string(),
650 });
651 }
652 }
653 let expected = try_cached_current_catalog(
654 EXPECTED_CURRENT_CATALOGS.get(),
655 ¤t_catalog_cache_key(domain),
656 )
657 .map_err(|cause| SqliteStoreError::CurrentCatalogUnavailable {
658 domain: domain.name.to_string(),
659 cause,
660 })?
661 .map_err(|detail| SqliteStoreError::SchemaFingerprintMismatch {
662 domain: domain.name.to_string(),
663 version: supported,
664 detail,
665 })?;
666 let actual = compact_catalog_fingerprint(conn, domain, domain.owned_objects);
667 if actual.as_ref().is_ok_and(|actual| actual == &expected) {
668 return Ok(());
669 }
670 let detail = actual.map_or_else(
671 |error| error,
672 |actual| format!("current owned catalog differs: expected {expected}, found {actual}"),
673 );
674 Err(match current_catalog_diff(conn, domain) {
677 Ok(diff) if !diff.is_empty() => SqliteStoreError::CurrentSchemaMismatch {
678 domain: domain.name.to_string(),
679 version: supported,
680 missing_objects: diff.missing,
681 unexpected_objects: diff.unexpected,
682 changed_objects: diff.changed,
683 },
684 _ => SqliteStoreError::SchemaFingerprintMismatch {
685 domain: domain.name.to_string(),
686 version: supported,
687 detail,
688 },
689 })
690 })
691}
692
693pub fn apply_domain_migrations(
701 conn: &mut Connection,
702 domain: &SchemaDomain,
703) -> Result<LedgerReport, SqliteStoreError> {
704 domain.validate()?;
705 let supported = domain.supported_version();
706
707 {
708 let read = conn.transaction()?;
709 let is_current = domain_version(&read, domain.name)? == Some(supported);
710 if is_current {
711 domain.verify_stamped_version(&read, supported)?;
712 }
713 read.rollback()?;
714 if is_current {
715 return Ok(LedgerReport {
716 from_version: supported,
717 to_version: supported,
718 });
719 }
720 }
721
722 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
723 let current = if ledger_table_exists(&tx)? {
726 validate_ledger_shape(&tx)?;
727 read_version(&tx, domain.name)?
728 } else {
729 None
730 };
731 if let Some(found) = current {
732 if found > supported {
733 return Err(SqliteStoreError::SchemaFromTheFuture {
734 domain: domain.name.to_string(),
735 found,
736 supported,
737 });
738 }
739 if !domain.accepts_existing_version(found) {
740 return Err(unsupported_predecessor(domain, found));
741 }
742 domain.verify_stamped_version(&tx, found)?;
743 } else {
744 let objects = find_owned_objects(&tx, domain)?;
745 if !objects.is_empty() {
746 return Err(SqliteStoreError::UnledgeredDomainObjects {
747 domain: domain.name.to_string(),
748 objects,
749 bridgeable: domain.bridge_eligibility(&tx),
750 });
751 }
752 }
753 let current = current.unwrap_or(0);
754 if current == supported {
755 tx.rollback()?;
756 return Ok(LedgerReport {
757 from_version: current,
758 to_version: current,
759 });
760 }
761
762 if !ledger_table_exists(&tx)? {
765 tx.execute_batch(CREATE_LEDGER_SQL)?;
766 validate_ledger_shape(&tx)?;
767 }
768
769 if current == 0 {
770 tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
771 (domain.initialize_current)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
772 domain: domain.name.to_string(),
773 version: supported,
774 name: "initialize-current".to_string(),
775 source,
776 })?;
777 if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
778 return Err(SqliteStoreError::MigrationBrokeTransaction {
779 domain: domain.name.to_string(),
780 version: supported,
781 name: "initialize-current".to_string(),
782 });
783 }
784 } else {
785 for migration in domain.migrations.iter().filter(|m| m.version > current) {
786 tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
787 (migration.apply)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
788 domain: domain.name.to_string(),
789 version: migration.version,
790 name: migration.name.to_string(),
791 source,
792 })?;
793 if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
803 return Err(SqliteStoreError::MigrationBrokeTransaction {
804 domain: domain.name.to_string(),
805 version: migration.version,
806 name: migration.name.to_string(),
807 });
808 }
809 }
810 }
811 verify_current_schema_fingerprint(&tx, domain).map_err(|detail| {
812 SqliteStoreError::SchemaFingerprintMismatch {
813 domain: domain.name.to_string(),
814 version: supported,
815 detail,
816 }
817 })?;
818 tx.execute(
819 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, ?2)
820 ON CONFLICT(domain) DO UPDATE SET version = excluded.version",
821 rusqlite::params![domain.name, supported],
822 )?;
823 verify_ledger_stamp(&tx, domain.name, supported)?;
824 tx.commit()?;
825
826 Ok(LedgerReport {
827 from_version: current,
828 to_version: supported,
829 })
830}
831
832pub type MaintenancePrepareFn =
834 for<'connection> fn(
835 &Transaction<'connection>,
836 ) -> Result<MaintenancePrepareReport, rusqlite::Error>;
837
838pub fn bridge_unledgered_domain(
863 conn: &mut Connection,
864 domain: &SchemaDomain,
865 target_version: i64,
866 recoverable_source_versions: &[i64],
867 prepare: Option<MaintenancePrepareFn>,
868) -> Result<MaintenanceBridgeReport, SqliteStoreError> {
869 domain.validate()?;
870 let supported = domain.supported_version();
871 if target_version > supported {
872 return Err(SqliteStoreError::SchemaFromTheFuture {
873 domain: domain.name.to_string(),
874 found: target_version,
875 supported,
876 });
877 }
878 if !domain.accepts_existing_version(target_version) {
879 return Err(unsupported_predecessor(domain, target_version));
880 }
881 validate_recoverable_source_versions(domain, target_version, recoverable_source_versions)?;
882
883 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
884 let current = if ledger_table_exists(&tx)? {
885 validate_ledger_shape(&tx)?;
886 read_version(&tx, domain.name)?
887 } else {
888 None
889 };
890
891 let mut inferred_oracles = None;
892 let from_version = if let Some(found) = current {
893 if found > target_version {
894 return Err(SqliteStoreError::SchemaFromTheFuture {
895 domain: domain.name.to_string(),
896 found,
897 supported: target_version,
898 });
899 }
900 if !domain.accepts_existing_version(found) {
901 return Err(unsupported_predecessor(domain, found));
902 }
903 domain.verify_stamped_version(&tx, found)?;
904 if found == target_version && prepare.is_none() {
905 return Ok(MaintenanceBridgeReport {
906 from_version: found,
907 to_version: found,
908 prepared: 0,
909 refused: Vec::new(),
910 });
911 }
912 found
913 } else {
914 let objects = find_owned_objects(&tx, domain)?;
915 if objects.is_empty() {
916 return Ok(MaintenanceBridgeReport {
917 from_version: 0,
918 to_version: 0,
919 prepared: 0,
920 refused: Vec::new(),
921 });
922 }
923
924 let matched = authenticate_unledgered_source_version(
925 &tx,
926 domain,
927 target_version,
928 recoverable_source_versions,
929 || objects.clone(),
930 )?;
931 inferred_oracles = Some(matched.oracles);
932 matched.version
933 };
934
935 let oracles = match inferred_oracles {
936 Some(oracles) => oracles,
937 None => build_migration_prefix_oracles(domain, target_version)?,
938 };
939 validate_domain_trigger_isolation(&tx, domain, from_version)?;
940
941 let (prepared, refused) = match prepare {
942 Some(prepare) => {
943 let report =
944 run_with_custody(&tx, domain, from_version, "maintenance-prepare", prepare)?;
945 (report.changed, report.refused)
946 }
947 None => (0, Vec::new()),
948 };
949 for migration in domain
950 .migrations
951 .iter()
952 .filter(|migration| migration.version > from_version && migration.version <= target_version)
953 {
954 run_with_custody(
955 &tx,
956 domain,
957 migration.version,
958 migration.name,
959 migration.apply,
960 )?;
961 }
962
963 let target = oracles
964 .iter()
965 .find_map(|(version, fingerprint)| (*version == target_version).then_some(fingerprint))
966 .ok_or_else(|| SqliteStoreError::InvalidMigrationList {
967 domain: domain.name.to_string(),
968 detail: format!(
969 "migration-prefix oracle did not produce requested target version {target_version}"
970 ),
971 })?;
972 let converged = domain_catalog_fingerprint(&tx, domain).map_err(|detail| {
973 SqliteStoreError::SchemaFingerprintMismatch {
974 domain: domain.name.to_string(),
975 version: target_version,
976 detail,
977 }
978 })?;
979 if &converged != target {
980 return Err(SqliteStoreError::SchemaFingerprintMismatch {
981 domain: domain.name.to_string(),
982 version: target_version,
983 detail: format!(
984 "migration-prefix catalog differs: expected {target:?}, found {converged:?}"
985 ),
986 });
987 }
988 domain.verify_predecessor(&tx, target_version)?;
989
990 if current != Some(target_version) {
991 if !ledger_table_exists(&tx)? {
992 tx.execute_batch(CREATE_LEDGER_SQL)?;
993 validate_ledger_shape(&tx)?;
994 }
995 tx.execute(
996 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, ?2)
997 ON CONFLICT(domain) DO UPDATE SET version = excluded.version",
998 rusqlite::params![domain.name, target_version],
999 )?;
1000 }
1001 verify_ledger_stamp(&tx, domain.name, target_version)?;
1002 tx.commit()?;
1003
1004 Ok(MaintenanceBridgeReport {
1005 from_version,
1006 to_version: target_version,
1007 prepared,
1008 refused,
1009 })
1010}
1011
1012fn validate_recoverable_source_versions(
1013 domain: &SchemaDomain,
1014 target_version: i64,
1015 versions: &[i64],
1016) -> Result<(), SqliteStoreError> {
1017 let mut previous = None;
1018 for &version in versions {
1019 if version <= 0 || version > target_version {
1020 return Err(SqliteStoreError::InvalidMigrationList {
1021 domain: domain.name.to_string(),
1022 detail: format!(
1023 "recoverable source version {version} is outside 1..={target_version}"
1024 ),
1025 });
1026 }
1027 if previous.is_some_and(|prior| prior >= version) {
1028 return Err(SqliteStoreError::InvalidMigrationList {
1029 domain: domain.name.to_string(),
1030 detail: "recoverable source versions must be strictly increasing".to_string(),
1031 });
1032 }
1033 previous = Some(version);
1034 }
1035 Ok(())
1036}
1037
1038fn verify_ledger_stamp(
1039 conn: &Connection,
1040 domain: &str,
1041 expected: i64,
1042) -> Result<(), SqliteStoreError> {
1043 let found = read_version(conn, domain)?;
1044 if found != Some(expected) {
1045 return Err(malformed(format!(
1046 "domain `{domain}` stamp did not persist exact version {expected}; found {found:?}"
1047 )));
1048 }
1049 Ok(())
1050}
1051
1052fn validate_domain_trigger_isolation(
1053 conn: &Connection,
1054 domain: &SchemaDomain,
1055 source_version: i64,
1056) -> Result<(), SqliteStoreError> {
1057 let all_objects = all_domain_objects(domain);
1058 let allowed_triggers = all_objects
1059 .iter()
1060 .filter(|object| object.kind == SchemaObjectKind::Trigger)
1061 .map(|object| object.name)
1062 .collect::<Vec<_>>();
1063 let mut statement = conn
1064 .prepare(
1065 "SELECT 'main', name FROM main.sqlite_schema
1066 WHERE type = 'trigger' AND tbl_name = ?1 COLLATE NOCASE
1067 UNION ALL
1068 SELECT 'temp', name FROM temp.sqlite_schema
1069 WHERE type = 'trigger' AND tbl_name = ?1 COLLATE NOCASE
1070 ORDER BY 1, 2",
1071 )
1072 .map_err(SqliteStoreError::Sqlite)?;
1073 let mut refused = Vec::new();
1074 for target in all_objects.iter().filter(|object| {
1075 matches!(
1076 object.kind,
1077 SchemaObjectKind::Table | SchemaObjectKind::View
1078 )
1079 }) {
1080 let rows = statement
1081 .query_map([target.name], |row| {
1082 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1083 })
1084 .map_err(SqliteStoreError::Sqlite)?;
1085 for row in rows {
1086 let (schema, trigger) = row.map_err(SqliteStoreError::Sqlite)?;
1087 let declared = schema == "main"
1088 && allowed_triggers
1089 .iter()
1090 .any(|allowed| allowed.eq_ignore_ascii_case(&trigger));
1091 if !declared {
1092 refused.push(format!("{schema}.{trigger} on {}", target.name));
1093 }
1094 }
1095 }
1096 refused.sort();
1097 refused.dedup();
1098 if !refused.is_empty() {
1099 return Err(SqliteStoreError::SchemaFingerprintMismatch {
1100 domain: domain.name.to_string(),
1101 version: source_version,
1102 detail: format!(
1103 "undeclared or TEMP triggers can intercept maintenance writes: {refused:?}"
1104 ),
1105 });
1106 }
1107 Ok(())
1108}
1109
1110fn run_with_custody<T>(
1111 tx: &Transaction<'_>,
1112 domain: &SchemaDomain,
1113 version: i64,
1114 name: &str,
1115 body: fn(&Transaction<'_>) -> Result<T, rusqlite::Error>,
1116) -> Result<T, SqliteStoreError> {
1117 tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
1118 let body_result = body(tx);
1119 if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
1120 return Err(SqliteStoreError::MigrationBrokeTransaction {
1121 domain: domain.name.to_string(),
1122 version,
1123 name: name.to_string(),
1124 });
1125 }
1126 body_result.map_err(|source| SqliteStoreError::MigrationFailed {
1127 domain: domain.name.to_string(),
1128 version,
1129 name: name.to_string(),
1130 source,
1131 })
1132}
1133
1134struct AuthenticatedUnledgeredSource {
1137 version: i64,
1138 oracles: Vec<(i64, DomainCatalogFingerprint)>,
1139}
1140
1141fn authenticate_unledgered_source_version(
1156 conn: &Connection,
1157 domain: &SchemaDomain,
1158 target_version: i64,
1159 recoverable_source_versions: &[i64],
1160 objects: impl FnOnce() -> Vec<String>,
1161) -> Result<AuthenticatedUnledgeredSource, SqliteStoreError> {
1162 if recoverable_source_versions.is_empty() {
1163 return Err(SqliteStoreError::UnledgeredSchemaNoMatch {
1167 domain: domain.name.to_string(),
1168 target_version,
1169 objects: objects(),
1170 });
1171 }
1172 let oracles = build_migration_prefix_oracles(domain, target_version)?;
1173 let actual = domain_catalog_fingerprint(conn, domain).map_err(|detail| {
1174 SqliteStoreError::UnledgeredSchemaNoMatch {
1175 domain: domain.name.to_string(),
1176 target_version,
1177 objects: vec![detail],
1178 }
1179 })?;
1180 let mut matches = oracles
1181 .iter()
1182 .filter_map(|(version, fingerprint)| {
1183 (recoverable_source_versions.contains(version) && fingerprint == &actual)
1184 .then_some(*version)
1185 })
1186 .collect::<Vec<_>>();
1187 for predecessor in domain.released_predecessors.iter().filter(|predecessor| {
1195 recoverable_source_versions.contains(&predecessor.version)
1196 && predecessor.version <= target_version
1197 }) {
1198 if (predecessor.verify)(conn).is_ok() && !matches.contains(&predecessor.version) {
1199 matches.push(predecessor.version);
1200 }
1201 }
1202 matches.sort_unstable();
1203 match matches.as_slice() {
1204 [version] => Ok(AuthenticatedUnledgeredSource {
1205 version: *version,
1206 oracles,
1207 }),
1208 [] => Err(SqliteStoreError::UnledgeredSchemaNoMatch {
1209 domain: domain.name.to_string(),
1210 target_version,
1211 objects: objects(),
1212 }),
1213 _ => Err(SqliteStoreError::UnledgeredSchemaAmbiguous {
1214 domain: domain.name.to_string(),
1215 target_version,
1216 matches,
1217 }),
1218 }
1219}
1220
1221fn build_migration_prefix_oracles(
1222 domain: &SchemaDomain,
1223 target_version: i64,
1224) -> Result<Vec<(i64, DomainCatalogFingerprint)>, SqliteStoreError> {
1225 let mut expected = Connection::open_in_memory().map_err(SqliteStoreError::Sqlite)?;
1226 let tx = expected
1227 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1228 .map_err(SqliteStoreError::Sqlite)?;
1229 let mut oracles = Vec::with_capacity(target_version as usize);
1230 for migration in domain
1231 .migrations
1232 .iter()
1233 .filter(|migration| migration.version <= target_version)
1234 {
1235 (migration.apply)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
1236 domain: domain.name.to_string(),
1237 version: migration.version,
1238 name: format!("migration-prefix-oracle:{}", migration.name),
1239 source,
1240 })?;
1241 let fingerprint = domain_catalog_fingerprint(&tx, domain).map_err(|detail| {
1242 SqliteStoreError::InvalidMigrationList {
1243 domain: domain.name.to_string(),
1244 detail: format!(
1245 "migration-prefix oracle version {} is inconsistent with ownership: {detail}",
1246 migration.version
1247 ),
1248 }
1249 })?;
1250 oracles.push((migration.version, fingerprint));
1251 }
1252 Ok(oracles)
1253}
1254
1255#[derive(Debug, PartialEq, Eq)]
1256struct DomainCatalogFingerprint {
1257 names: Vec<(String, String)>,
1258 objects: Vec<CatalogObjectFingerprint>,
1259}
1260
1261fn domain_catalog_fingerprint(
1262 conn: &Connection,
1263 domain: &SchemaDomain,
1264) -> Result<DomainCatalogFingerprint, String> {
1265 let all_objects = all_domain_objects(domain);
1266 let names = catalog_names(conn, &all_objects).map_err(|error| error.to_string())?;
1267 let declared = all_objects
1268 .iter()
1269 .map(|object| (object.name, object))
1270 .collect::<BTreeMap<_, _>>();
1271 let mut objects = Vec::with_capacity(names.len());
1272 for (kind, name) in &names {
1273 let Some(object) = declared.get(name.as_str()) else {
1274 return Err(format!("undeclared owned object `{name}`"));
1275 };
1276 if kind != object.kind.sqlite_name() {
1277 return Err(format!(
1278 "owned object `{name}` has kind `{kind}`, expected `{}`",
1279 object.kind.sqlite_name()
1280 ));
1281 }
1282 objects.push(
1283 catalog_fingerprint(conn, object)
1284 .map_err(|error| format!("fingerprint object `{name}`: {error}"))?,
1285 );
1286 }
1287 Ok(DomainCatalogFingerprint { names, objects })
1288}
1289
1290type CurrentCatalogCache = Mutex<BTreeMap<String, Result<String, String>>>;
1291
1292static EXPECTED_CURRENT_CATALOGS: OnceLock<CurrentCatalogCache> = OnceLock::new();
1293
1294fn try_cached_current_catalog(
1305 cache: Option<&CurrentCatalogCache>,
1306 key: &str,
1307) -> Result<Result<String, String>, CurrentCatalogUnavailability> {
1308 let cache = cache.ok_or(CurrentCatalogUnavailability::NotPrepared)?;
1309 let entries = cache
1310 .lock()
1311 .map_err(|_| CurrentCatalogUnavailability::Poisoned)?;
1312 entries
1313 .get(key)
1314 .cloned()
1315 .ok_or(CurrentCatalogUnavailability::NotPrepared)
1316}
1317
1318fn verify_current_schema_fingerprint(
1322 actual: &Connection,
1323 domain: &SchemaDomain,
1324) -> Result<(), String> {
1325 let expected = {
1326 let cache = EXPECTED_CURRENT_CATALOGS.get_or_init(|| Mutex::new(BTreeMap::new()));
1327 let key = current_catalog_cache_key(domain);
1328 let cached = cache
1329 .lock()
1330 .map_err(|_| "current catalog cache lock is poisoned".to_string())?
1331 .get(&key)
1332 .cloned();
1333 if let Some(cached) = cached {
1334 cached?
1335 } else {
1336 let built = build_current_catalog_fingerprint(domain);
1337 cache
1338 .lock()
1339 .map_err(|_| "current catalog cache lock is poisoned".to_string())?
1340 .insert(key, built.clone());
1341 built?
1342 }
1343 };
1344 let actual = compact_catalog_fingerprint(actual, domain, domain.owned_objects)?;
1345 if actual != expected {
1346 return Err(format!(
1347 "current owned catalog differs: expected {expected}, found {actual}"
1348 ));
1349 }
1350 Ok(())
1351}
1352
1353fn current_catalog_cache_key(domain: &SchemaDomain) -> String {
1358 let mut key = format!(
1359 "{}\u{1f}{}\u{1f}{:x}",
1360 domain.name,
1361 domain.supported_version(),
1362 domain.initialize_current as usize
1363 );
1364 for object in domain.owned_objects {
1365 key.push_str(&format!(
1366 "\u{1e}current:{}:{}",
1367 object.kind.sqlite_name(),
1368 object.name
1369 ));
1370 }
1371 for object in domain.retired_objects {
1372 key.push_str(&format!(
1373 "\u{1e}retired:{}:{}",
1374 object.kind.sqlite_name(),
1375 object.name
1376 ));
1377 }
1378 key
1379}
1380
1381#[derive(Debug, Default)]
1384struct CurrentCatalogDiff {
1385 missing: Vec<String>,
1386 unexpected: Vec<String>,
1387 changed: Vec<String>,
1388}
1389
1390impl CurrentCatalogDiff {
1391 fn is_empty(&self) -> bool {
1392 self.missing.is_empty() && self.unexpected.is_empty() && self.changed.is_empty()
1393 }
1394}
1395
1396fn current_catalog_diff(
1401 actual: &Connection,
1402 domain: &SchemaDomain,
1403) -> Result<CurrentCatalogDiff, String> {
1404 let mut oracle =
1405 Connection::open_in_memory().map_err(|error| format!("open current oracle: {error}"))?;
1406 let tx = oracle
1407 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1408 .map_err(|error| format!("begin current oracle: {error}"))?;
1409 (domain.initialize_current)(&tx).map_err(|error| format!("build current oracle: {error}"))?;
1410 tx.commit()
1411 .map_err(|error| format!("commit current oracle: {error}"))?;
1412 let expected = domain_catalog_entries(&oracle, domain)?;
1413 let found = domain_catalog_entries(actual, domain)?;
1414 let mut diff = CurrentCatalogDiff::default();
1415 for (object, entry) in &expected {
1416 match found.get(object) {
1417 None => diff.missing.push(object.clone()),
1418 Some(found_entry) if found_entry != entry => diff.changed.push(object.clone()),
1419 Some(_) => {}
1420 }
1421 }
1422 for object in found.keys() {
1423 if !expected.contains_key(object) {
1424 diff.unexpected.push(object.clone());
1425 }
1426 }
1427 Ok(diff)
1428}
1429
1430fn domain_catalog_entries(
1433 conn: &Connection,
1434 domain: &SchemaDomain,
1435) -> Result<BTreeMap<String, String>, String> {
1436 let names = all_domain_objects(domain)
1437 .into_iter()
1438 .map(|object| object.name)
1439 .collect::<std::collections::BTreeSet<_>>();
1440 let mut statement = conn
1441 .prepare(
1442 "SELECT type, name, tbl_name, sql
1443 FROM main.sqlite_schema
1444 WHERE name NOT LIKE 'sqlite_%'
1445 ORDER BY type, name",
1446 )
1447 .map_err(|error| error.to_string())?;
1448 let rows = statement
1449 .query_map([], |row| {
1450 Ok((
1451 row.get::<_, String>(0)?,
1452 row.get::<_, String>(1)?,
1453 row.get::<_, String>(2)?,
1454 row.get::<_, Option<String>>(3)?,
1455 ))
1456 })
1457 .map_err(|error| error.to_string())?;
1458 let mut entries = BTreeMap::new();
1459 for row in rows {
1460 let (kind, name, table_name, sql) = row.map_err(|error| error.to_string())?;
1461 if names.contains(name.as_str()) {
1462 entries.insert(
1463 format!("{kind}:{name}"),
1464 format!(
1465 "{table_name}\u{1f}{}",
1466 sql.map(|sql| normalize_schema_sql(&sql))
1467 .unwrap_or_default()
1468 ),
1469 );
1470 }
1471 }
1472 Ok(entries)
1473}
1474
1475fn build_current_catalog_fingerprint(domain: &SchemaDomain) -> Result<String, String> {
1476 let mut expected =
1477 Connection::open_in_memory().map_err(|error| format!("open current oracle: {error}"))?;
1478 let tx = expected
1479 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1480 .map_err(|error| format!("begin current oracle: {error}"))?;
1481 (domain.initialize_current)(&tx).map_err(|error| format!("build current oracle: {error}"))?;
1482 tx.commit()
1483 .map_err(|error| format!("commit current oracle: {error}"))?;
1484 compact_catalog_fingerprint(&expected, domain, domain.owned_objects)
1485}
1486
1487fn compact_catalog_fingerprint(
1488 conn: &Connection,
1489 domain: &SchemaDomain,
1490 expected_objects: &[SchemaObject],
1491) -> Result<String, String> {
1492 let all_objects = all_domain_objects(domain);
1493 let owned_by_name = all_objects
1494 .iter()
1495 .map(|object| (object.name, object))
1496 .collect::<BTreeMap<_, _>>();
1497 let current_by_name = expected_objects
1498 .iter()
1499 .map(|object| (object.name, object))
1500 .collect::<BTreeMap<_, _>>();
1501 let mut actual_names = Vec::new();
1502 let mut entries = Vec::with_capacity(expected_objects.len());
1503 let mut statement = conn
1504 .prepare(
1505 "SELECT type, name, tbl_name, sql
1506 FROM main.sqlite_schema
1507 WHERE name NOT LIKE 'sqlite_%'
1508 ORDER BY type, name",
1509 )
1510 .map_err(|error| error.to_string())?;
1511 let rows = statement
1512 .query_map([], |row| {
1513 Ok((
1514 row.get::<_, String>(0)?,
1515 row.get::<_, String>(1)?,
1516 row.get::<_, String>(2)?,
1517 row.get::<_, Option<String>>(3)?,
1518 ))
1519 })
1520 .map_err(|error| error.to_string())?;
1521 for row in rows {
1522 let (kind, name, table_name, sql) = row.map_err(|error| error.to_string())?;
1523 if owned_by_name.contains_key(name.as_str()) {
1524 actual_names.push((kind.clone(), name.clone()));
1525 }
1526 if current_by_name.contains_key(name.as_str()) {
1527 entries.push(format!(
1528 "{kind}\u{1f}{name}\u{1f}{table_name}\u{1f}{}",
1529 sql.map(|sql| normalize_schema_sql(&sql))
1530 .unwrap_or_default()
1531 ));
1532 }
1533 }
1534 actual_names.sort();
1535 let mut expected_names = expected_objects
1536 .iter()
1537 .map(|object| {
1538 (
1539 object.kind.sqlite_name().to_string(),
1540 object.name.to_string(),
1541 )
1542 })
1543 .collect::<Vec<_>>();
1544 expected_names.sort();
1545 if actual_names != expected_names {
1546 return Err(format!(
1547 "owned object set differs: expected {expected_names:?}, found {actual_names:?}"
1548 ));
1549 }
1550
1551 entries.sort();
1552 Ok(entries.join("\u{1e}"))
1553}
1554
1555fn all_domain_objects(domain: &SchemaDomain) -> Vec<SchemaObject> {
1556 domain
1557 .owned_objects
1558 .iter()
1559 .chain(domain.retired_objects)
1560 .copied()
1561 .collect()
1562}
1563
1564pub fn verify_released_schema_fingerprint(
1574 actual: &Connection,
1575 domain: &SchemaDomain,
1576 released_objects: &[SchemaObject],
1577 build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
1578) -> Result<(), String> {
1579 verify_released_schema(
1580 actual,
1581 domain,
1582 released_objects,
1583 build_released,
1584 ReleasedSchemaComparison::ExactText,
1585 )
1586}
1587
1588pub fn verify_released_schema_structure(
1605 actual: &Connection,
1606 domain: &SchemaDomain,
1607 released_objects: &[SchemaObject],
1608 build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
1609) -> Result<(), String> {
1610 verify_released_schema(
1611 actual,
1612 domain,
1613 released_objects,
1614 build_released,
1615 ReleasedSchemaComparison::StructureOnly,
1616 )
1617}
1618
1619#[derive(Clone, Copy)]
1621enum ReleasedSchemaComparison {
1622 ExactText,
1624 StructureOnly,
1626}
1627
1628fn verify_released_schema(
1629 actual: &Connection,
1630 domain: &SchemaDomain,
1631 released_objects: &[SchemaObject],
1632 build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
1633 comparison: ReleasedSchemaComparison,
1634) -> Result<(), String> {
1635 let mut expected = Connection::open_in_memory()
1636 .map_err(|error| format!("open fingerprint oracle: {error}"))?;
1637 let tx = expected
1638 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1639 .map_err(|error| format!("begin fingerprint oracle: {error}"))?;
1640 build_released(&tx).map_err(|error| format!("build fingerprint oracle: {error}"))?;
1641 tx.commit()
1642 .map_err(|error| format!("commit fingerprint oracle: {error}"))?;
1643
1644 let expected_names = catalog_names(&expected, released_objects)
1645 .map_err(|error| format!("read fingerprint oracle: {error}"))?;
1646 let mut declared_expected = released_objects
1647 .iter()
1648 .map(|object| {
1649 (
1650 object.kind.sqlite_name().to_string(),
1651 object.name.to_string(),
1652 )
1653 })
1654 .collect::<Vec<_>>();
1655 declared_expected.sort();
1656 if expected_names != declared_expected {
1657 return Err(format!(
1658 "frozen builder produced {expected_names:?}, manifest declares {declared_expected:?}"
1659 ));
1660 }
1661
1662 let actual_names = catalog_names(actual, &all_domain_objects(domain))
1663 .map_err(|error| format!("read actual catalog: {error}"))?;
1664 if actual_names != declared_expected {
1665 return Err(format!(
1666 "owned object set differs: expected {declared_expected:?}, found {actual_names:?}"
1667 ));
1668 }
1669
1670 for object in released_objects {
1671 let mut wanted = catalog_fingerprint(&expected, object)
1672 .map_err(|error| format!("fingerprint oracle {}: {error}", object.name))?;
1673 let mut found = catalog_fingerprint(actual, object)
1674 .map_err(|error| format!("fingerprint actual {}: {error}", object.name))?;
1675 if matches!(comparison, ReleasedSchemaComparison::StructureOnly) {
1676 wanted.normalized_sql = None;
1677 found.normalized_sql = None;
1678 }
1679 if found != wanted {
1680 return Err(format!(
1681 "object `{}` differs: expected {wanted:?}, found {found:?}",
1682 object.name
1683 ));
1684 }
1685 }
1686 Ok(())
1687}
1688
1689#[derive(Debug, PartialEq, Eq)]
1690struct CatalogObjectFingerprint {
1691 kind: String,
1692 name: String,
1693 table_name: String,
1694 normalized_sql: Option<String>,
1695 table_columns: Vec<TableColumnFingerprint>,
1696 foreign_keys: Vec<ForeignKeyFingerprint>,
1697 index: Option<IndexFingerprint>,
1698}
1699
1700#[derive(Debug, PartialEq, Eq)]
1701struct TableColumnFingerprint {
1702 cid: i64,
1703 name: String,
1704 declared_type: String,
1705 not_null: bool,
1706 default_value: Option<String>,
1707 primary_key_position: i64,
1708 hidden: i64,
1709}
1710
1711#[derive(Debug, PartialEq, Eq)]
1712struct ForeignKeyFingerprint {
1713 id: i64,
1714 sequence: i64,
1715 target_table: String,
1716 from_column: String,
1717 to_column: Option<String>,
1718 on_update: String,
1719 on_delete: String,
1720 match_clause: String,
1721}
1722
1723#[derive(Debug, PartialEq, Eq)]
1724struct IndexFingerprint {
1725 unique: bool,
1726 origin: String,
1727 partial: bool,
1728 columns: Vec<IndexColumnFingerprint>,
1729}
1730
1731#[derive(Debug, PartialEq, Eq)]
1732struct IndexColumnFingerprint {
1733 sequence: i64,
1734 column_id: i64,
1735 name: Option<String>,
1736 descending: bool,
1737 collation: Option<String>,
1738 key: bool,
1739}
1740
1741fn catalog_names(
1742 conn: &Connection,
1743 objects: &[SchemaObject],
1744) -> Result<Vec<(String, String)>, rusqlite::Error> {
1745 let mut found = Vec::new();
1746 let mut statement = conn.prepare(
1747 "SELECT type, name FROM main.sqlite_schema
1748 WHERE name = ?1 AND name NOT LIKE 'sqlite_%'
1749 ORDER BY type, name",
1750 )?;
1751 for object in objects {
1752 let rows = statement.query_map([object.name], |row| Ok((row.get(0)?, row.get(1)?)))?;
1753 found.extend(rows.collect::<Result<Vec<_>, _>>()?);
1754 }
1755 found.sort();
1756 found.dedup();
1757 Ok(found)
1758}
1759
1760fn catalog_fingerprint(
1761 conn: &Connection,
1762 object: &SchemaObject,
1763) -> Result<CatalogObjectFingerprint, rusqlite::Error> {
1764 let (kind, name, table_name, sql): (String, String, String, Option<String>) = conn.query_row(
1765 "SELECT type, name, tbl_name, sql FROM main.sqlite_schema WHERE name = ?1",
1766 [object.name],
1767 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1768 )?;
1769 let (table_columns, foreign_keys) = if kind == "table" || kind == "view" {
1770 (
1771 table_columns(conn, object.name)?,
1772 foreign_keys(conn, object.name)?,
1773 )
1774 } else {
1775 (Vec::new(), Vec::new())
1776 };
1777 let index = if kind == "index" {
1778 Some(index_fingerprint(conn, &table_name, object.name)?)
1779 } else {
1780 None
1781 };
1782 Ok(CatalogObjectFingerprint {
1783 kind,
1784 name,
1785 table_name,
1786 normalized_sql: sql.map(|sql| normalize_schema_sql(&sql)),
1787 table_columns,
1788 foreign_keys,
1789 index,
1790 })
1791}
1792
1793fn table_columns(
1794 conn: &Connection,
1795 table: &str,
1796) -> Result<Vec<TableColumnFingerprint>, rusqlite::Error> {
1797 let mut statement = conn.prepare(
1798 "SELECT cid, name, type, \"notnull\", dflt_value, pk, hidden
1799 FROM pragma_table_xinfo(?1, 'main')
1800 ORDER BY cid",
1801 )?;
1802 let rows = statement.query_map([table], |row| {
1803 Ok(TableColumnFingerprint {
1804 cid: row.get(0)?,
1805 name: row.get(1)?,
1806 declared_type: row.get(2)?,
1807 not_null: row.get(3)?,
1808 default_value: row.get(4)?,
1809 primary_key_position: row.get(5)?,
1810 hidden: row.get(6)?,
1811 })
1812 })?;
1813 rows.collect()
1814}
1815
1816fn foreign_keys(
1817 conn: &Connection,
1818 table: &str,
1819) -> Result<Vec<ForeignKeyFingerprint>, rusqlite::Error> {
1820 let mut statement = conn.prepare(
1821 "SELECT id, seq, \"table\", \"from\", \"to\", on_update, on_delete, \"match\"
1822 FROM pragma_foreign_key_list(?1, 'main')
1823 ORDER BY id, seq",
1824 )?;
1825 let rows = statement.query_map([table], |row| {
1826 Ok(ForeignKeyFingerprint {
1827 id: row.get(0)?,
1828 sequence: row.get(1)?,
1829 target_table: row.get(2)?,
1830 from_column: row.get(3)?,
1831 to_column: row.get(4)?,
1832 on_update: row.get(5)?,
1833 on_delete: row.get(6)?,
1834 match_clause: row.get(7)?,
1835 })
1836 })?;
1837 rows.collect()
1838}
1839
1840fn index_fingerprint(
1841 conn: &Connection,
1842 table: &str,
1843 index: &str,
1844) -> Result<IndexFingerprint, rusqlite::Error> {
1845 let (unique, origin, partial): (bool, String, bool) = conn.query_row(
1846 "SELECT \"unique\", origin, partial
1847 FROM pragma_index_list(?1, 'main')
1848 WHERE name = ?2",
1849 rusqlite::params![table, index],
1850 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1851 )?;
1852 let mut statement = conn.prepare(
1853 "SELECT seqno, cid, name, desc, coll, key
1854 FROM pragma_index_xinfo(?1, 'main')
1855 ORDER BY seqno",
1856 )?;
1857 let columns = statement
1858 .query_map([index], |row| {
1859 Ok(IndexColumnFingerprint {
1860 sequence: row.get(0)?,
1861 column_id: row.get(1)?,
1862 name: row.get(2)?,
1863 descending: row.get(3)?,
1864 collation: row.get(4)?,
1865 key: row.get(5)?,
1866 })
1867 })?
1868 .collect::<Result<Vec<_>, _>>()?;
1869 Ok(IndexFingerprint {
1870 unique,
1871 origin,
1872 partial,
1873 columns,
1874 })
1875}
1876
1877fn normalize_schema_sql(sql: &str) -> String {
1878 #[derive(Clone, Copy)]
1879 enum LexState {
1880 Normal,
1881 SingleQuoted,
1882 DoubleQuoted,
1883 BacktickQuoted,
1884 BracketQuoted,
1885 LineComment,
1886 BlockComment,
1887 }
1888
1889 let bytes = sql.as_bytes();
1890 let mut collapsed = Vec::with_capacity(bytes.len());
1891 let mut state = LexState::Normal;
1892 let mut index = 0;
1893 while index < bytes.len() {
1894 let byte = bytes[index];
1895 match state {
1896 LexState::Normal => {
1897 if byte.is_ascii_whitespace() {
1898 if collapsed.last().is_some_and(|last| *last != b' ') {
1899 collapsed.push(b' ');
1900 }
1901 } else {
1902 collapsed.push(byte);
1903 state = match byte {
1904 b'\'' => LexState::SingleQuoted,
1905 b'"' => LexState::DoubleQuoted,
1906 b'`' => LexState::BacktickQuoted,
1907 b'[' => LexState::BracketQuoted,
1908 b'-' if bytes.get(index + 1) == Some(&b'-') => LexState::LineComment,
1909 b'/' if bytes.get(index + 1) == Some(&b'*') => LexState::BlockComment,
1910 _ => LexState::Normal,
1911 };
1912 }
1913 }
1914 LexState::SingleQuoted | LexState::DoubleQuoted | LexState::BacktickQuoted => {
1915 collapsed.push(byte);
1916 let delimiter = match state {
1917 LexState::SingleQuoted => b'\'',
1918 LexState::DoubleQuoted => b'"',
1919 LexState::BacktickQuoted => b'`',
1920 _ => unreachable!(),
1921 };
1922 if byte == delimiter {
1923 if bytes.get(index + 1) == Some(&delimiter) {
1924 index += 1;
1925 collapsed.push(delimiter);
1926 } else {
1927 state = LexState::Normal;
1928 }
1929 }
1930 }
1931 LexState::BracketQuoted => {
1932 collapsed.push(byte);
1933 if byte == b']' {
1934 if bytes.get(index + 1) == Some(&b']') {
1935 index += 1;
1936 collapsed.push(b']');
1937 } else {
1938 state = LexState::Normal;
1939 }
1940 }
1941 }
1942 LexState::LineComment => {
1943 collapsed.push(byte);
1944 if byte == b'\n' || byte == b'\r' {
1945 state = LexState::Normal;
1946 }
1947 }
1948 LexState::BlockComment => {
1949 collapsed.push(byte);
1950 if byte == b'*' && bytes.get(index + 1) == Some(&b'/') {
1951 index += 1;
1952 collapsed.push(b'/');
1953 state = LexState::Normal;
1954 }
1955 }
1956 }
1957 index += 1;
1958 }
1959
1960 const IF_NOT_EXISTS: &[u8] = b"IF NOT EXISTS";
1961 let mut normalized = Vec::with_capacity(collapsed.len());
1962 let mut state = LexState::Normal;
1963 let mut index = 0;
1964 while index < collapsed.len() {
1965 let byte = collapsed[index];
1966 if matches!(state, LexState::Normal)
1967 && collapsed
1968 .get(index..index + IF_NOT_EXISTS.len())
1969 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(IF_NOT_EXISTS))
1970 && (index == 0 || !is_sql_identifier_byte(collapsed[index - 1]))
1971 && collapsed
1972 .get(index + IF_NOT_EXISTS.len())
1973 .is_none_or(|after| !is_sql_identifier_byte(*after))
1974 {
1975 index += IF_NOT_EXISTS.len();
1976 if collapsed.get(index) == Some(&b' ') {
1977 index += 1;
1978 }
1979 continue;
1980 }
1981 normalized.push(byte);
1982 match state {
1983 LexState::Normal => {
1984 state = match byte {
1985 b'\'' => LexState::SingleQuoted,
1986 b'"' => LexState::DoubleQuoted,
1987 b'`' => LexState::BacktickQuoted,
1988 b'[' => LexState::BracketQuoted,
1989 b'-' if collapsed.get(index + 1) == Some(&b'-') => LexState::LineComment,
1990 b'/' if collapsed.get(index + 1) == Some(&b'*') => LexState::BlockComment,
1991 _ => LexState::Normal,
1992 };
1993 }
1994 LexState::SingleQuoted | LexState::DoubleQuoted | LexState::BacktickQuoted => {
1995 let delimiter = match state {
1996 LexState::SingleQuoted => b'\'',
1997 LexState::DoubleQuoted => b'"',
1998 LexState::BacktickQuoted => b'`',
1999 _ => unreachable!(),
2000 };
2001 if byte == delimiter {
2002 if collapsed.get(index + 1) == Some(&delimiter) {
2003 index += 1;
2004 normalized.push(delimiter);
2005 } else {
2006 state = LexState::Normal;
2007 }
2008 }
2009 }
2010 LexState::BracketQuoted => {
2011 if byte == b']' {
2012 if collapsed.get(index + 1) == Some(&b']') {
2013 index += 1;
2014 normalized.push(b']');
2015 } else {
2016 state = LexState::Normal;
2017 }
2018 }
2019 }
2020 LexState::LineComment => {
2021 if byte == b'\n' || byte == b'\r' {
2022 state = LexState::Normal;
2023 }
2024 }
2025 LexState::BlockComment => {
2026 if byte == b'*' && collapsed.get(index + 1) == Some(&b'/') {
2027 index += 1;
2028 normalized.push(b'/');
2029 state = LexState::Normal;
2030 }
2031 }
2032 }
2033 index += 1;
2034 }
2035 String::from_utf8(normalized).unwrap_or_else(|_| sql.to_string())
2036}
2037
2038fn is_sql_identifier_byte(byte: u8) -> bool {
2039 byte.is_ascii_alphanumeric() || byte == b'_'
2040}
2041
2042fn unsupported_predecessor(domain: &SchemaDomain, found: i64) -> SqliteStoreError {
2043 SqliteStoreError::UnsupportedSchemaPredecessor {
2044 domain: domain.name.to_string(),
2045 found,
2046 supported: domain.supported_version(),
2047 allowed: domain.allowed_existing_versions.to_vec(),
2048 }
2049}
2050
2051fn find_owned_objects(
2056 conn: &Connection,
2057 domain: &SchemaDomain,
2058) -> Result<Vec<String>, SqliteStoreError> {
2059 let mut found = Vec::new();
2060 let mut stmt = conn.prepare(
2061 "SELECT type, name FROM main.sqlite_schema
2062 WHERE name = ?1 AND name NOT LIKE 'sqlite_%'
2063 ORDER BY type, name",
2064 )?;
2065 for expected in domain.owned_objects.iter().chain(domain.retired_objects) {
2066 let rows = stmt.query_map([expected.name], |row| {
2067 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2068 })?;
2069 for row in rows {
2070 let (actual_kind, name) = row?;
2071 found.push(format!(
2072 "{actual_kind}:{name} (expected {})",
2073 expected.kind.sqlite_name()
2074 ));
2075 }
2076 }
2077 found.sort();
2078 found.dedup();
2079 Ok(found)
2080}
2081
2082fn ledger_table_exists(conn: &Connection) -> Result<bool, SqliteStoreError> {
2083 let exists = conn
2084 .query_row(
2085 "SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'meerkat_schema'",
2086 [],
2087 |_| Ok(()),
2088 )
2089 .optional()?
2090 .is_some();
2091 Ok(exists)
2092}
2093
2094fn malformed(detail: String) -> SqliteStoreError {
2095 SqliteStoreError::LedgerMalformed { detail }
2096}
2097
2098fn validate_ledger_shape(conn: &Connection) -> Result<(), SqliteStoreError> {
2107 let mut stmt = conn.prepare(
2108 "SELECT name, type, \"notnull\", pk FROM pragma_table_info('meerkat_schema', 'main')",
2109 )?;
2110 let mut rows = stmt.query([])?;
2111 let mut domain_ok = false;
2112 let mut version_ok = false;
2113 while let Some(row) = rows.next()? {
2114 let name: String = row.get(0)?;
2115 let decl_type: String = row.get(1)?;
2116 let notnull: bool = row.get(2)?;
2117 let pk: i64 = row.get(3)?;
2118 match name.as_str() {
2119 "domain" => {
2120 if !decl_type.eq_ignore_ascii_case("TEXT") || pk != 1 {
2121 return Err(malformed(format!(
2122 "column `domain` must be `TEXT PRIMARY KEY`, found type `{decl_type}` \
2123 with pk position {pk}"
2124 )));
2125 }
2126 domain_ok = true;
2127 }
2128 "version" => {
2129 if !decl_type.eq_ignore_ascii_case("INTEGER") || !notnull || pk != 0 {
2130 return Err(malformed(format!(
2131 "column `version` must be non-key `INTEGER NOT NULL`, found type \
2132 `{decl_type}` notnull={notnull} pk position {pk}"
2133 )));
2134 }
2135 version_ok = true;
2136 }
2137 other => {
2138 if pk != 0 {
2139 return Err(malformed(format!(
2140 "unexpected primary-key column `{other}`"
2141 )));
2142 }
2143 }
2144 }
2145 }
2146 if !domain_ok || !version_ok {
2147 return Err(malformed(
2148 "table lacks the pinned `domain`/`version` columns".to_string(),
2149 ));
2150 }
2151 let mut trigger_stmt = conn.prepare(
2152 "SELECT 'main', name FROM main.sqlite_schema
2153 WHERE type = 'trigger' AND tbl_name = 'meerkat_schema' COLLATE NOCASE
2154 UNION ALL
2155 SELECT 'temp', name FROM temp.sqlite_schema
2156 WHERE type = 'trigger' AND tbl_name = 'meerkat_schema' COLLATE NOCASE
2157 ORDER BY 1, 2",
2158 )?;
2159 let triggers = trigger_stmt
2160 .query_map([], |row| {
2161 Ok(format!(
2162 "{}.{}",
2163 row.get::<_, String>(0)?,
2164 row.get::<_, String>(1)?
2165 ))
2166 })?
2167 .collect::<Result<Vec<_>, _>>()?;
2168 if !triggers.is_empty() {
2169 return Err(malformed(format!(
2170 "table has attached triggers {triggers:?}; ledger writes must be isolated"
2171 )));
2172 }
2173 Ok(())
2174}
2175
2176fn read_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
2183 let mut stmt = conn.prepare("SELECT version FROM main.meerkat_schema WHERE domain = ?1")?;
2184 let mut rows = stmt.query([domain])?;
2185 let Some(row) = rows.next()? else {
2186 return Ok(None);
2187 };
2188 let version: i64 = row.get(0)?;
2189 if rows.next()?.is_some() {
2190 return Err(malformed(format!(
2191 "multiple ledger rows for domain `{domain}`"
2192 )));
2193 }
2194 if version <= 0 {
2195 return Err(malformed(format!(
2196 "domain `{domain}` records non-positive version {version}"
2197 )));
2198 }
2199 Ok(Some(version))
2200}
2201
2202#[cfg(test)]
2203#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
2204mod tests {
2205 use super::*;
2206 use crate::profile::{ConnectionProfile, open};
2207
2208 #[test]
2209 fn catalog_lookup_keeps_unprepared_and_poisoned_apart() {
2210 assert_eq!(
2211 try_cached_current_catalog(None, "domain"),
2212 Err(CurrentCatalogUnavailability::NotPrepared)
2213 );
2214 let cache = CurrentCatalogCache::default();
2215 assert_eq!(
2216 try_cached_current_catalog(Some(&cache), "domain"),
2217 Err(CurrentCatalogUnavailability::NotPrepared)
2218 );
2219 cache
2220 .lock()
2221 .unwrap()
2222 .insert("domain".to_string(), Ok("catalog".to_string()));
2223 assert_eq!(
2224 try_cached_current_catalog(Some(&cache), "domain"),
2225 Ok(Ok("catalog".to_string()))
2226 );
2227 let poisoned = std::sync::Arc::new(CurrentCatalogCache::default());
2228 let holder = std::sync::Arc::clone(&poisoned);
2229 let _ = std::thread::spawn(move || {
2230 let _held = holder.lock().unwrap();
2231 panic!("poison the catalog cache");
2232 })
2233 .join();
2234 assert_eq!(
2235 try_cached_current_catalog(Some(&poisoned), "domain"),
2236 Err(CurrentCatalogUnavailability::Poisoned)
2237 );
2238 }
2239
2240 fn create_t1(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2241 tx.execute_batch("CREATE TABLE IF NOT EXISTS t1 (x INTEGER)")
2242 }
2243
2244 fn add_column_guarded(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2245 let has_column = tx
2246 .prepare("PRAGMA table_info(t1)")?
2247 .query_map([], |row| row.get::<_, String>(1))?
2248 .collect::<Result<Vec<_>, _>>()?
2249 .iter()
2250 .any(|name| name == "y");
2251 if !has_column {
2252 tx.execute_batch("ALTER TABLE t1 ADD COLUMN y TEXT")?;
2253 }
2254 Ok(())
2255 }
2256
2257 fn initialize_v2(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2258 create_t1(tx)?;
2259 add_column_guarded(tx)
2260 }
2261
2262 fn initialize_v2_alt(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2263 tx.execute_batch("CREATE TABLE t1 (x INTEGER, z BLOB)")
2264 }
2265
2266 const RELEASED_V1_OBJECTS: &[SchemaObject] = &[SchemaObject {
2267 kind: SchemaObjectKind::Table,
2268 name: "t1",
2269 }];
2270
2271 fn verify_v1(conn: &Connection) -> Result<(), String> {
2272 verify_released_schema_fingerprint(conn, &DOMAIN_V2, RELEASED_V1_OBJECTS, create_t1)
2273 }
2274
2275 const DOMAIN_V1: SchemaDomain = SchemaDomain {
2276 name: "test-domain",
2277 migrations: &[Migration {
2278 version: 1,
2279 name: "base",
2280 apply: create_t1,
2281 }],
2282 initialize_current: create_t1,
2283 allowed_existing_versions: &[1],
2284 bridge_recoverable_versions: &[1],
2285 released_predecessors: &[],
2286 owned_objects: &[SchemaObject {
2287 kind: SchemaObjectKind::Table,
2288 name: "t1",
2289 }],
2290 retired_objects: &[],
2291 };
2292
2293 const DOMAIN_V2: SchemaDomain = SchemaDomain {
2294 name: "test-domain",
2295 migrations: &[
2296 Migration {
2297 version: 1,
2298 name: "base",
2299 apply: create_t1,
2300 },
2301 Migration {
2302 version: 2,
2303 name: "add-y",
2304 apply: add_column_guarded,
2305 },
2306 ],
2307 initialize_current: initialize_v2,
2308 allowed_existing_versions: &[1, 2],
2309 bridge_recoverable_versions: &[1],
2310 released_predecessors: &[SchemaPredecessor {
2311 version: 1,
2312 verify: verify_v1,
2313 }],
2314 owned_objects: &[SchemaObject {
2315 kind: SchemaObjectKind::Table,
2316 name: "t1",
2317 }],
2318 retired_objects: &[],
2319 };
2320
2321 const DOMAIN_V2_ALT_INITIALIZER: SchemaDomain = SchemaDomain {
2322 name: "test-domain",
2323 migrations: &[
2324 Migration {
2325 version: 1,
2326 name: "base",
2327 apply: create_t1,
2328 },
2329 Migration {
2330 version: 2,
2331 name: "alt-current",
2332 apply: add_column_guarded,
2333 },
2334 ],
2335 initialize_current: initialize_v2_alt,
2336 allowed_existing_versions: &[2],
2337 bridge_recoverable_versions: &[1],
2338 released_predecessors: &[],
2339 owned_objects: &[SchemaObject {
2340 kind: SchemaObjectKind::Table,
2341 name: "t1",
2342 }],
2343 retired_objects: &[],
2344 };
2345
2346 fn temp_conn(dir: &tempfile::TempDir) -> Connection {
2347 open(&dir.path().join("db.sqlite3"), ConnectionProfile::PRIMARY).expect("open")
2348 }
2349
2350 #[test]
2351 fn structure_verifier_ignores_create_text_but_rejects_shape_drift() {
2352 const STRUCTURE_OBJECTS: &[SchemaObject] = &[
2353 SchemaObject {
2354 kind: SchemaObjectKind::Table,
2355 name: "t1",
2356 },
2357 SchemaObject {
2358 kind: SchemaObjectKind::Index,
2359 name: "t1_idx",
2360 },
2361 ];
2362 fn build_frozen(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2363 tx.execute_batch(
2364 "CREATE TABLE IF NOT EXISTS t1 (x INTEGER NOT NULL);
2365 CREATE INDEX IF NOT EXISTS t1_idx ON t1(x DESC);",
2366 )
2367 }
2368 const STRUCTURE_DOMAIN: SchemaDomain = SchemaDomain {
2369 name: "structure-domain",
2370 migrations: &[Migration {
2371 version: 1,
2372 name: "base",
2373 apply: build_frozen,
2374 }],
2375 initialize_current: build_frozen,
2376 allowed_existing_versions: &[1],
2377 bridge_recoverable_versions: &[1],
2378 released_predecessors: &[],
2379 owned_objects: STRUCTURE_OBJECTS,
2380 retired_objects: &[],
2381 };
2382 let make = |ddl: &str| {
2383 let conn = Connection::open_in_memory().expect("in-memory fixture");
2384 conn.execute_batch(ddl).expect("fixture ddl");
2385 conn
2386 };
2387
2388 let quoted = make(
2392 "CREATE TABLE \"t1\" (x INTEGER NOT NULL);
2393 CREATE INDEX t1_idx ON t1(x DESC);",
2394 );
2395 verify_released_schema_structure(
2396 "ed,
2397 &STRUCTURE_DOMAIN,
2398 STRUCTURE_OBJECTS,
2399 build_frozen,
2400 )
2401 .expect("CREATE-text drift alone must pass the structural verifier");
2402 verify_released_schema_fingerprint(
2403 "ed,
2404 &STRUCTURE_DOMAIN,
2405 STRUCTURE_OBJECTS,
2406 build_frozen,
2407 )
2408 .expect_err("the exact-text verifier must still reject the same catalog");
2409
2410 let wrong_type = make(
2411 "CREATE TABLE t1 (x TEXT NOT NULL);
2412 CREATE INDEX t1_idx ON t1(x DESC);",
2413 );
2414 verify_released_schema_structure(
2415 &wrong_type,
2416 &STRUCTURE_DOMAIN,
2417 STRUCTURE_OBJECTS,
2418 build_frozen,
2419 )
2420 .expect_err("column type drift must fail structurally");
2421
2422 let wrong_index = make(
2423 "CREATE TABLE t1 (x INTEGER NOT NULL);
2424 CREATE INDEX t1_idx ON t1(x ASC);",
2425 );
2426 verify_released_schema_structure(
2427 &wrong_index,
2428 &STRUCTURE_DOMAIN,
2429 STRUCTURE_OBJECTS,
2430 build_frozen,
2431 )
2432 .expect_err("index direction drift must fail structurally");
2433
2434 let missing_index = make("CREATE TABLE t1 (x INTEGER NOT NULL);");
2435 verify_released_schema_structure(
2436 &missing_index,
2437 &STRUCTURE_DOMAIN,
2438 STRUCTURE_OBJECTS,
2439 build_frozen,
2440 )
2441 .expect_err("a missing owned index must fail the object-set check");
2442 }
2443
2444 #[test]
2445 fn fresh_file_initializes_current_and_stamps() {
2446 let dir = tempfile::tempdir().expect("tempdir");
2447 let mut conn = temp_conn(&dir);
2448 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply");
2449 assert_eq!(
2450 report,
2451 LedgerReport {
2452 from_version: 0,
2453 to_version: 2
2454 }
2455 );
2456 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
2457 conn.execute("INSERT INTO t1 (x, y) VALUES (1, 'a')", [])
2458 .expect("schema converged");
2459 }
2460
2461 #[test]
2462 fn second_open_is_current_noop() {
2463 let dir = tempfile::tempdir().expect("tempdir");
2464 let mut conn = temp_conn(&dir);
2465 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("first");
2466 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("second");
2467 assert!(!report.migrated());
2468 }
2469
2470 #[test]
2471 fn current_oracle_cache_binds_initializer_and_manifest_not_only_name_version() {
2472 let first_dir = tempfile::tempdir().expect("first tempdir");
2473 let mut first = temp_conn(&first_dir);
2474 apply_domain_migrations(&mut first, &DOMAIN_V2).expect("first current");
2475
2476 let second_dir = tempfile::tempdir().expect("second tempdir");
2477 let mut second = temp_conn(&second_dir);
2478 apply_domain_migrations(&mut second, &DOMAIN_V2_ALT_INITIALIZER).expect("alt current");
2479 let columns: Vec<String> = second
2480 .prepare("PRAGMA table_info(t1)")
2481 .expect("prepare")
2482 .query_map([], |row| row.get(1))
2483 .expect("query")
2484 .collect::<Result<_, _>>()
2485 .expect("columns");
2486 assert_eq!(columns, vec!["x", "z"]);
2487 }
2488
2489 #[test]
2490 fn current_row_with_partial_catalog_is_refused_before_noop() {
2491 let dir = tempfile::tempdir().expect("tempdir");
2492 let mut conn = temp_conn(&dir);
2493 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("current");
2494 conn.execute_batch("ALTER TABLE t1 ADD COLUMN candidate_partial TEXT")
2495 .expect("partial candidate mutation");
2496 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse current shape");
2497 match err {
2498 SqliteStoreError::CurrentSchemaMismatch {
2499 version: 2,
2500 missing_objects,
2501 unexpected_objects,
2502 changed_objects,
2503 ..
2504 } => {
2505 assert!(missing_objects.is_empty(), "{missing_objects:?}");
2506 assert!(unexpected_objects.is_empty(), "{unexpected_objects:?}");
2507 assert_eq!(changed_objects, vec!["table:t1".to_string()]);
2508 }
2509 other => panic!("expected a typed current-schema mismatch, got {other:?}"),
2510 }
2511 assert_eq!(
2512 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2513 Some(2)
2514 );
2515 }
2516
2517 #[test]
2518 fn current_row_without_its_owned_objects_names_them() {
2519 let dir = tempfile::tempdir().expect("tempdir");
2522 let mut conn = temp_conn(&dir);
2523 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("current");
2524 conn.execute_batch("DROP TABLE t1; CREATE TABLE unrelated_candidate (x INTEGER)")
2525 .expect("foreign-shaped current");
2526 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse current row");
2527 match err {
2528 SqliteStoreError::CurrentSchemaMismatch {
2529 version: 2,
2530 missing_objects,
2531 unexpected_objects,
2532 changed_objects,
2533 ..
2534 } => {
2535 assert_eq!(missing_objects, vec!["table:t1".to_string()]);
2536 assert!(unexpected_objects.is_empty(), "{unexpected_objects:?}");
2538 assert!(changed_objects.is_empty(), "{changed_objects:?}");
2539 }
2540 other => panic!("expected a typed current-schema mismatch, got {other:?}"),
2541 }
2542 }
2543
2544 #[test]
2545 fn upgrade_applies_only_pending() {
2546 let dir = tempfile::tempdir().expect("tempdir");
2547 let mut conn = temp_conn(&dir);
2548 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
2549 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("v2");
2550 assert_eq!(
2551 report,
2552 LedgerReport {
2553 from_version: 1,
2554 to_version: 2
2555 }
2556 );
2557 }
2558
2559 #[test]
2560 fn allowed_version_with_wrong_catalog_is_refused_without_migration() {
2561 let dir = tempfile::tempdir().expect("tempdir");
2562 let mut conn = temp_conn(&dir);
2563 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
2564 conn.execute_batch("ALTER TABLE t1 ADD COLUMN candidate_only TEXT")
2565 .expect("candidate shape");
2566 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse fingerprint");
2567 assert!(matches!(
2568 err,
2569 SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
2570 ));
2571 assert_eq!(
2572 domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
2573 Some(1),
2574 "fingerprint refusal advanced the ledger"
2575 );
2576 let columns: Vec<String> = conn
2577 .prepare("PRAGMA table_info(t1)")
2578 .expect("prepare")
2579 .query_map([], |row| row.get(1))
2580 .expect("query")
2581 .collect::<Result<_, _>>()
2582 .expect("columns");
2583 assert_eq!(columns, vec!["x", "candidate_only"]);
2584 }
2585
2586 #[test]
2587 fn pre_floor_and_gap_versions_are_refused_without_mutation() {
2588 fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2589 Ok(())
2590 }
2591 fn verify_v2(_conn: &Connection) -> Result<(), String> {
2592 Ok(())
2593 }
2594 const DOMAIN_V3_FLOOR_2: SchemaDomain = SchemaDomain {
2595 name: "floor-domain",
2596 migrations: &[
2597 Migration {
2598 version: 1,
2599 name: "base",
2600 apply: no_op,
2601 },
2602 Migration {
2603 version: 2,
2604 name: "released-floor",
2605 apply: no_op,
2606 },
2607 Migration {
2608 version: 3,
2609 name: "current",
2610 apply: no_op,
2611 },
2612 ],
2613 initialize_current: no_op,
2614 allowed_existing_versions: &[2, 3],
2615 bridge_recoverable_versions: &[1],
2616 released_predecessors: &[SchemaPredecessor {
2617 version: 2,
2618 verify: verify_v2,
2619 }],
2620 owned_objects: &[],
2621 retired_objects: &[],
2622 };
2623 const DOMAIN_V4_GAP_3: SchemaDomain = SchemaDomain {
2624 name: "gap-domain",
2625 migrations: &[
2626 Migration {
2627 version: 1,
2628 name: "old",
2629 apply: no_op,
2630 },
2631 Migration {
2632 version: 2,
2633 name: "released-floor",
2634 apply: no_op,
2635 },
2636 Migration {
2637 version: 3,
2638 name: "unreleased-candidate",
2639 apply: no_op,
2640 },
2641 Migration {
2642 version: 4,
2643 name: "current",
2644 apply: no_op,
2645 },
2646 ],
2647 initialize_current: no_op,
2648 allowed_existing_versions: &[2, 4],
2649 bridge_recoverable_versions: &[1],
2650 released_predecessors: &[SchemaPredecessor {
2651 version: 2,
2652 verify: verify_v2,
2653 }],
2654 owned_objects: &[],
2655 retired_objects: &[],
2656 };
2657 let dir = tempfile::tempdir().expect("tempdir");
2658 let mut conn = temp_conn(&dir);
2659 conn.execute_batch(CREATE_LEDGER_SQL).expect("ledger");
2660 conn.execute(
2661 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 1)",
2662 [DOMAIN_V3_FLOOR_2.name],
2663 )
2664 .expect("pre-floor row");
2665 let err =
2666 apply_domain_migrations(&mut conn, &DOMAIN_V3_FLOOR_2).expect_err("refuse pre-floor");
2667 assert!(matches!(
2668 err,
2669 SqliteStoreError::UnsupportedSchemaPredecessor { found: 1, .. }
2670 ));
2671 assert_eq!(
2672 domain_version(&conn, DOMAIN_V3_FLOOR_2.name).expect("ledger"),
2673 Some(1)
2674 );
2675
2676 conn.execute(
2677 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 3)",
2678 [DOMAIN_V4_GAP_3.name],
2679 )
2680 .expect("gap row");
2681 let err = apply_domain_migrations(&mut conn, &DOMAIN_V4_GAP_3).expect_err("refuse gap");
2682 assert!(matches!(
2683 err,
2684 SqliteStoreError::UnsupportedSchemaPredecessor { found: 3, .. }
2685 ));
2686 assert_eq!(
2687 domain_version(&conn, DOMAIN_V4_GAP_3.name).expect("ledger"),
2688 Some(3)
2689 );
2690 }
2691
2692 #[test]
2693 fn unledgered_owned_objects_are_refused_without_mutation() {
2694 let dir = tempfile::tempdir().expect("tempdir");
2695 let mut conn = temp_conn(&dir);
2696 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2697 .expect("unknown unledgered ddl");
2698 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse");
2699 assert!(matches!(
2700 err,
2701 SqliteStoreError::UnledgeredDomainObjects { .. }
2702 ));
2703 assert!(
2704 !ledger_table_exists(&conn).expect("ledger presence"),
2705 "eligibility refusal must not create the ledger"
2706 );
2707 let columns: Vec<String> = conn
2708 .prepare("PRAGMA table_info(t1)")
2709 .expect("prepare")
2710 .query_map([], |row| row.get(1))
2711 .expect("query")
2712 .collect::<Result<_, _>>()
2713 .expect("columns");
2714 assert_eq!(columns, vec!["x"], "refusal mutated unknown schema");
2715 }
2716
2717 #[test]
2718 fn maintenance_bridge_authenticates_exact_v1_and_migrates_to_v2() {
2719 let dir = tempfile::tempdir().expect("tempdir");
2720 let mut conn = temp_conn(&dir);
2721 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2722 .expect("historical unledgered v1");
2723
2724 let report =
2725 bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None).expect("bridge");
2726 assert_eq!(
2727 report,
2728 MaintenanceBridgeReport {
2729 from_version: 1,
2730 to_version: 2,
2731 prepared: 0,
2732 refused: Vec::new(),
2733 }
2734 );
2735 assert_eq!(
2736 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2737 Some(2)
2738 );
2739 let columns = conn
2740 .prepare("PRAGMA table_info(t1)")
2741 .expect("prepare")
2742 .query_map([], |row| row.get::<_, String>(1))
2743 .expect("query")
2744 .collect::<Result<Vec<_>, _>>()
2745 .expect("columns");
2746 assert_eq!(columns, vec!["x", "y"]);
2747
2748 let second = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
2749 .expect("idempotent bridge");
2750 assert_eq!(
2751 second,
2752 MaintenanceBridgeReport {
2753 from_version: 2,
2754 to_version: 2,
2755 prepared: 0,
2756 refused: Vec::new(),
2757 }
2758 );
2759 }
2760
2761 #[test]
2762 fn maintenance_bridge_prepares_and_upgrades_existing_v1_row() {
2763 fn normalize_existing(
2764 tx: &Transaction<'_>,
2765 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2766 let changed = tx.execute("UPDATE t1 SET x = x + 1", [])?;
2767 Ok(MaintenancePrepareReport::rewrote(changed))
2768 }
2769
2770 let dir = tempfile::tempdir().expect("tempdir");
2771 let mut conn = temp_conn(&dir);
2772 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("ledgered v1");
2773 conn.execute("INSERT INTO t1 (x) VALUES (7)", [])
2774 .expect("historical data");
2775
2776 let report =
2777 bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(normalize_existing))
2778 .expect("bridge ledgered predecessor");
2779 assert_eq!(
2780 report,
2781 MaintenanceBridgeReport {
2782 from_version: 1,
2783 to_version: 2,
2784 prepared: 1,
2785 refused: Vec::new(),
2786 }
2787 );
2788 assert_eq!(
2789 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2790 Some(2)
2791 );
2792 assert_eq!(
2793 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2794 .expect("prepared row"),
2795 8
2796 );
2797 conn.execute("INSERT INTO t1 (x, y) VALUES (9, 'migrated')", [])
2798 .expect("v2 shape");
2799 }
2800
2801 #[test]
2802 fn maintenance_bridge_prepares_existing_target_and_reports_durable_changes() {
2803 fn normalize_target(
2804 tx: &Transaction<'_>,
2805 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2806 let changed = tx.execute("UPDATE t1 SET x = x + 1", [])?;
2807 Ok(MaintenancePrepareReport::rewrote(changed))
2808 }
2809 fn mutate_then_fail(
2810 tx: &Transaction<'_>,
2811 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2812 tx.execute("UPDATE t1 SET x = 99", [])?;
2813 tx.execute_batch("THIS IS NOT SQL")?;
2814 Ok(MaintenancePrepareReport::rewrote(1))
2815 }
2816 fn rolls_back_then_begins_and_fails(
2817 tx: &Transaction<'_>,
2818 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2819 tx.execute_batch("UPDATE t1 SET x = 99; ROLLBACK; BEGIN; THIS IS NOT SQL")?;
2820 Ok(MaintenancePrepareReport::rewrote(1))
2821 }
2822
2823 let dir = tempfile::tempdir().expect("tempdir");
2824 let mut conn = temp_conn(&dir);
2825 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("ledgered target");
2826 conn.execute("INSERT INTO t1 (x, y) VALUES (7, 'target')", [])
2827 .expect("target data");
2828
2829 let report =
2830 bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(normalize_target))
2831 .expect("prepare target");
2832 assert_eq!(
2833 report,
2834 MaintenanceBridgeReport {
2835 from_version: 2,
2836 to_version: 2,
2837 prepared: 1,
2838 refused: Vec::new(),
2839 }
2840 );
2841 assert!(!report.migrated());
2842 assert!(report.changed());
2843 assert_eq!(
2844 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2845 .expect("prepared target row"),
2846 8
2847 );
2848 assert_eq!(
2849 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2850 Some(2)
2851 );
2852
2853 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(mutate_then_fail))
2854 .expect_err("target prepare failure");
2855 assert!(matches!(
2856 err,
2857 SqliteStoreError::MigrationFailed { ref name, .. }
2858 if name == "maintenance-prepare"
2859 ));
2860 assert_eq!(
2861 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2862 .expect("row after rollback"),
2863 8
2864 );
2865
2866 let err = bridge_unledgered_domain(
2867 &mut conn,
2868 &DOMAIN_V2,
2869 2,
2870 &[1],
2871 Some(rolls_back_then_begins_and_fails),
2872 )
2873 .expect_err("target custody loss");
2874 assert!(matches!(
2875 err,
2876 SqliteStoreError::MigrationBrokeTransaction { ref name, .. }
2877 if name == "maintenance-prepare"
2878 ));
2879 assert_eq!(
2880 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2881 .expect("row after custody refusal"),
2882 8
2883 );
2884 assert_eq!(
2885 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2886 Some(2)
2887 );
2888 }
2889
2890 #[test]
2891 fn maintenance_bridge_refuses_target_that_only_matches_migration_oracle() {
2892 let dir = tempfile::tempdir().expect("tempdir");
2893 let mut conn = temp_conn(&dir);
2894 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2895 .expect("historical unledgered v1");
2896
2897 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2_ALT_INITIALIZER, 2, &[1], None)
2898 .expect_err("ordinary target verifier must reject drift");
2899 assert!(matches!(
2900 err,
2901 SqliteStoreError::SchemaFingerprintMismatch { version: 2, .. }
2902 ));
2903 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2904 let columns = conn
2905 .prepare("PRAGMA table_info(t1)")
2906 .expect("prepare")
2907 .query_map([], |row| row.get::<_, String>(1))
2908 .expect("query")
2909 .collect::<Result<Vec<_>, _>>()
2910 .expect("columns");
2911 assert_eq!(
2912 columns,
2913 vec!["x"],
2914 "target-verifier refusal did not roll back"
2915 );
2916 }
2917
2918 #[test]
2919 fn maintenance_bridge_refuses_catalog_outside_source_allowlist_across_data_only_gap() {
2920 fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2921 Ok(())
2922 }
2923 const DATA_ONLY_GAP: SchemaDomain = SchemaDomain {
2924 name: "data-only-gap-domain",
2925 migrations: &[
2926 Migration {
2927 version: 1,
2928 name: "base",
2929 apply: create_t1,
2930 },
2931 Migration {
2932 version: 2,
2933 name: "data-only",
2934 apply: no_op,
2935 },
2936 Migration {
2937 version: 3,
2938 name: "add-y",
2939 apply: add_column_guarded,
2940 },
2941 ],
2942 initialize_current: initialize_v2,
2943 allowed_existing_versions: &[3],
2944 bridge_recoverable_versions: &[1],
2945 released_predecessors: &[],
2946 owned_objects: &[SchemaObject {
2947 kind: SchemaObjectKind::Table,
2948 name: "t1",
2949 }],
2950 retired_objects: &[],
2951 };
2952
2953 let dir = tempfile::tempdir().expect("tempdir");
2954 let mut conn = temp_conn(&dir);
2955 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2956 .expect("v1-or-v2 catalog");
2957 let err = bridge_unledgered_domain(&mut conn, &DATA_ONLY_GAP, 3, &[3], None)
2958 .expect_err("excluded historical prefixes must not be inferred");
2959 assert!(matches!(
2960 err,
2961 SqliteStoreError::UnledgeredSchemaNoMatch { .. }
2962 ));
2963 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2964 let columns = conn
2965 .prepare("PRAGMA table_info(t1)")
2966 .expect("prepare")
2967 .query_map([], |row| row.get::<_, String>(1))
2968 .expect("query")
2969 .collect::<Result<Vec<_>, _>>()
2970 .expect("columns");
2971 assert_eq!(columns, vec!["x"]);
2972
2973 let err = bridge_unledgered_domain(&mut conn, &DATA_ONLY_GAP, 3, &[2, 1], None)
2974 .expect_err("unordered source authority must be refused");
2975 assert!(matches!(err, SqliteStoreError::InvalidMigrationList { .. }));
2976 }
2977
2978 #[test]
2979 fn maintenance_bridge_leaves_fresh_domain_for_normal_initializer() {
2980 let dir = tempfile::tempdir().expect("tempdir");
2981 let mut conn = temp_conn(&dir);
2982 let report =
2983 bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None).expect("fresh no-op");
2984 assert_eq!(
2985 report,
2986 MaintenanceBridgeReport {
2987 from_version: 0,
2988 to_version: 0,
2989 prepared: 0,
2990 refused: Vec::new(),
2991 }
2992 );
2993 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2994 assert!(
2995 find_owned_objects(&conn, &DOMAIN_V2)
2996 .expect("objects")
2997 .is_empty()
2998 );
2999 }
3000
3001 #[test]
3002 fn maintenance_bridge_refuses_malformed_historical_shape_without_mutation() {
3003 let dir = tempfile::tempdir().expect("tempdir");
3004 let mut conn = temp_conn(&dir);
3005 conn.execute_batch("CREATE TABLE t1 (x INTEGER, candidate_only BLOB)")
3006 .expect("candidate schema");
3007
3008 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
3009 .expect_err("refuse unauthenticated shape");
3010 assert!(matches!(
3011 err,
3012 SqliteStoreError::UnledgeredSchemaNoMatch {
3013 target_version: 2,
3014 ..
3015 }
3016 ));
3017 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3018 let columns = conn
3019 .prepare("PRAGMA table_info(t1)")
3020 .expect("prepare")
3021 .query_map([], |row| row.get::<_, String>(1))
3022 .expect("query")
3023 .collect::<Result<Vec<_>, _>>()
3024 .expect("columns");
3025 assert_eq!(columns, vec!["x", "candidate_only"]);
3026 }
3027
3028 #[test]
3029 fn maintenance_bridge_fingerprint_preserves_sql_literal_whitespace() {
3030 fn create_exact(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3031 tx.execute_batch("CREATE TABLE literal_t (x TEXT CHECK(x <> 'a b'))")
3032 }
3033 const LITERAL_DOMAIN: SchemaDomain = SchemaDomain {
3034 name: "literal-fingerprint-domain",
3035 migrations: &[Migration {
3036 version: 1,
3037 name: "base",
3038 apply: create_exact,
3039 }],
3040 initialize_current: create_exact,
3041 allowed_existing_versions: &[1],
3042 bridge_recoverable_versions: &[1],
3043 released_predecessors: &[],
3044 owned_objects: &[SchemaObject {
3045 kind: SchemaObjectKind::Table,
3046 name: "literal_t",
3047 }],
3048 retired_objects: &[],
3049 };
3050
3051 let dir = tempfile::tempdir().expect("tempdir");
3052 let mut conn = temp_conn(&dir);
3053 conn.execute_batch("CREATE TABLE literal_t (x TEXT CHECK(x <> 'a b'))")
3054 .expect("semantic mismatch");
3055 let err = bridge_unledgered_domain(&mut conn, &LITERAL_DOMAIN, 1, &[1], None)
3056 .expect_err("literal whitespace must remain fingerprint-significant");
3057 assert!(matches!(
3058 err,
3059 SqliteStoreError::UnledgeredSchemaNoMatch { .. }
3060 ));
3061 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3062
3063 assert_eq!(
3064 normalize_schema_sql("CREATE TABLE IF NOT EXISTS t (x CHECK(x <> 'IF NOT EXISTS'))"),
3065 "CREATE TABLE t (x CHECK(x <> 'IF NOT EXISTS'))"
3066 );
3067 }
3068
3069 #[test]
3070 fn maintenance_bridge_rolls_back_prepare_failure() {
3071 fn mutate_then_fail(
3072 tx: &Transaction<'_>,
3073 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3074 tx.execute("UPDATE t1 SET x = 99", [])?;
3075 tx.execute_batch("THIS IS NOT SQL")?;
3076 Ok(MaintenancePrepareReport::rewrote(1))
3077 }
3078
3079 let dir = tempfile::tempdir().expect("tempdir");
3080 let mut conn = temp_conn(&dir);
3081 conn.execute_batch("CREATE TABLE t1 (x INTEGER); INSERT INTO t1 VALUES (7)")
3082 .expect("historical unledgered v1");
3083
3084 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(mutate_then_fail))
3085 .expect_err("prepare must fail");
3086 assert!(matches!(
3087 err,
3088 SqliteStoreError::MigrationFailed { ref name, .. }
3089 if name == "maintenance-prepare"
3090 ));
3091 assert_eq!(
3092 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
3093 .expect("original row"),
3094 7
3095 );
3096 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3097 let columns = conn
3098 .prepare("PRAGMA table_info(t1)")
3099 .expect("prepare")
3100 .query_map([], |row| row.get::<_, String>(1))
3101 .expect("query")
3102 .collect::<Result<Vec<_>, _>>()
3103 .expect("columns");
3104 assert_eq!(columns, vec!["x"]);
3105 }
3106
3107 #[test]
3108 fn maintenance_bridge_reports_custody_loss_before_callback_error() {
3109 fn commits_then_fails(
3110 tx: &Transaction<'_>,
3111 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3112 tx.execute_batch("UPDATE t1 SET x = 99; COMMIT; THIS IS NOT SQL")?;
3113 Ok(MaintenancePrepareReport::rewrote(1))
3114 }
3115
3116 let dir = tempfile::tempdir().expect("tempdir");
3117 let mut conn = temp_conn(&dir);
3118 conn.execute_batch("CREATE TABLE t1 (x INTEGER); INSERT INTO t1 VALUES (7)")
3119 .expect("historical unledgered v1");
3120 let err =
3121 bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(commits_then_fails))
3122 .expect_err("custody must dominate callback error");
3123 assert!(matches!(
3124 err,
3125 SqliteStoreError::MigrationBrokeTransaction { ref name, .. }
3126 if name == "maintenance-prepare"
3127 ));
3128 assert_eq!(domain_version(&conn, DOMAIN_V2.name).expect("ledger"), None);
3129 }
3130
3131 #[test]
3132 fn maintenance_bridge_refuses_undeclared_trigger_on_owned_table_before_prepare() {
3133 fn prepare_successor(
3134 tx: &Transaction<'_>,
3135 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3136 let changed = tx.execute("UPDATE t1 SET x = 8 WHERE x = 7", [])?;
3137 Ok(MaintenancePrepareReport::rewrote(changed))
3138 }
3139
3140 let dir = tempfile::tempdir().expect("tempdir");
3141 let mut conn = temp_conn(&dir);
3142 conn.execute_batch(
3143 "CREATE TABLE t1 (x INTEGER);
3144 INSERT INTO t1 VALUES (7);
3145 CREATE TRIGGER replace_prepared_successor
3146 AFTER UPDATE ON t1
3147 BEGIN
3148 UPDATE t1 SET x = 99 WHERE rowid = NEW.rowid;
3149 END",
3150 )
3151 .expect("intercepting trigger");
3152
3153 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(prepare_successor))
3154 .expect_err("undeclared trigger must be refused before prepare");
3155 assert!(matches!(
3156 err,
3157 SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
3158 ));
3159 assert_eq!(
3160 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
3161 .expect("original row"),
3162 7
3163 );
3164 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3165 }
3166
3167 #[test]
3168 fn maintenance_bridge_refuses_undeclared_instead_of_trigger_on_owned_view_before_prepare() {
3169 fn create_owned_view(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3170 tx.execute_batch(
3171 "CREATE TABLE view_base (x INTEGER);
3172 CREATE VIEW owned_view AS SELECT x FROM view_base",
3173 )
3174 }
3175 fn prepare_view(tx: &Transaction<'_>) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3176 let changed = tx.execute("UPDATE owned_view SET x = 8 WHERE x = 7", [])?;
3177 Ok(MaintenancePrepareReport::rewrote(changed))
3178 }
3179 const VIEW_DOMAIN: SchemaDomain = SchemaDomain {
3180 name: "view-bridge-domain",
3181 migrations: &[Migration {
3182 version: 1,
3183 name: "base",
3184 apply: create_owned_view,
3185 }],
3186 initialize_current: create_owned_view,
3187 allowed_existing_versions: &[1],
3188 bridge_recoverable_versions: &[1],
3189 released_predecessors: &[],
3190 owned_objects: &[
3191 SchemaObject {
3192 kind: SchemaObjectKind::Table,
3193 name: "view_base",
3194 },
3195 SchemaObject {
3196 kind: SchemaObjectKind::View,
3197 name: "owned_view",
3198 },
3199 ],
3200 retired_objects: &[],
3201 };
3202
3203 let dir = tempfile::tempdir().expect("tempdir");
3204 let mut conn = temp_conn(&dir);
3205 conn.execute_batch(
3206 "CREATE TABLE view_base (x INTEGER);
3207 CREATE VIEW owned_view AS SELECT x FROM view_base;
3208 INSERT INTO view_base VALUES (7);
3209 CREATE TRIGGER replace_view_update
3210 INSTEAD OF UPDATE ON owned_view
3211 BEGIN
3212 UPDATE view_base SET x = 99 WHERE x = OLD.x;
3213 END",
3214 )
3215 .expect("intercepting view trigger");
3216
3217 let err = bridge_unledgered_domain(&mut conn, &VIEW_DOMAIN, 1, &[1], Some(prepare_view))
3218 .expect_err("undeclared view trigger must be refused before prepare");
3219 assert!(matches!(
3220 err,
3221 SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
3222 ));
3223 assert_eq!(
3224 conn.query_row("SELECT x FROM view_base", [], |row| row.get::<_, i64>(0))
3225 .expect("original row"),
3226 7
3227 );
3228 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3229 }
3230
3231 #[test]
3232 fn maintenance_bridge_refuses_ambiguous_prefix_without_mutation() {
3233 fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3234 Ok(())
3235 }
3236 const AMBIGUOUS: SchemaDomain = SchemaDomain {
3237 name: "ambiguous-bridge-domain",
3238 migrations: &[
3239 Migration {
3240 version: 1,
3241 name: "base",
3242 apply: create_t1,
3243 },
3244 Migration {
3245 version: 2,
3246 name: "data-only",
3247 apply: no_op,
3248 },
3249 ],
3250 initialize_current: create_t1,
3251 allowed_existing_versions: &[2],
3252 bridge_recoverable_versions: &[1],
3253 released_predecessors: &[],
3254 owned_objects: &[SchemaObject {
3255 kind: SchemaObjectKind::Table,
3256 name: "t1",
3257 }],
3258 retired_objects: &[],
3259 };
3260
3261 let dir = tempfile::tempdir().expect("tempdir");
3262 let mut conn = temp_conn(&dir);
3263 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
3264 .expect("ambiguous schema");
3265 let err = bridge_unledgered_domain(&mut conn, &AMBIGUOUS, 2, &[1, 2], None)
3266 .expect_err("refuse ambiguity");
3267 assert!(matches!(
3268 err,
3269 SqliteStoreError::UnledgeredSchemaAmbiguous { ref matches, .. }
3270 if matches == &[1, 2]
3271 ));
3272 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3273 }
3274
3275 #[test]
3276 fn maintenance_bridge_refuses_malformed_ledger_before_owned_schema_contact() {
3277 let dir = tempfile::tempdir().expect("tempdir");
3278 let mut conn = temp_conn(&dir);
3279 conn.execute_batch(
3280 "CREATE TABLE t1 (x INTEGER);
3281 CREATE TABLE meerkat_schema (domain TEXT, version INTEGER)",
3282 )
3283 .expect("malformed ledger");
3284 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
3285 .expect_err("refuse malformed ledger");
3286 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3287 let columns = conn
3288 .prepare("PRAGMA table_info(t1)")
3289 .expect("prepare")
3290 .query_map([], |row| row.get::<_, String>(1))
3291 .expect("query")
3292 .collect::<Result<Vec<_>, _>>()
3293 .expect("columns");
3294 assert_eq!(columns, vec!["x"]);
3295 }
3296
3297 #[test]
3298 fn maintenance_bridge_refuses_mixed_case_trigger_that_mutates_foreign_ledger_row() {
3299 let dir = tempfile::tempdir().expect("tempdir");
3300 let mut conn = temp_conn(&dir);
3301 conn.execute_batch(
3302 "CREATE TABLE t1 (x INTEGER);
3303 CREATE TABLE meerkat_schema (
3304 domain TEXT PRIMARY KEY,
3305 version INTEGER NOT NULL
3306 );
3307 INSERT INTO meerkat_schema (domain, version) VALUES ('foreign-domain', 7);
3308 CREATE TRIGGER mutate_foreign_schema_row
3309 AFTER INSERT ON MEERKAT_SCHEMA
3310 BEGIN
3311 UPDATE MEERKAT_SCHEMA
3312 SET version = 999
3313 WHERE domain = 'foreign-domain';
3314 END",
3315 )
3316 .expect("hostile ledger trigger");
3317
3318 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
3319 .expect_err("mixed-case ledger trigger must be refused");
3320 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3321 let row_count = conn
3322 .query_row(
3323 "SELECT COUNT(*) FROM main.meerkat_schema WHERE domain = ?1",
3324 [DOMAIN_V2.name],
3325 |row| row.get::<_, i64>(0),
3326 )
3327 .expect("raw ledger count");
3328 assert_eq!(row_count, 0);
3329 let foreign_version = conn
3330 .query_row(
3331 "SELECT version FROM main.meerkat_schema WHERE domain = 'foreign-domain'",
3332 [],
3333 |row| row.get::<_, i64>(0),
3334 )
3335 .expect("foreign ledger row");
3336 assert_eq!(foreign_version, 7);
3337 let columns = conn
3338 .prepare("PRAGMA table_info(t1)")
3339 .expect("prepare")
3340 .query_map([], |row| row.get::<_, String>(1))
3341 .expect("query")
3342 .collect::<Result<Vec<_>, _>>()
3343 .expect("columns");
3344 assert_eq!(
3345 columns,
3346 vec!["x"],
3347 "failed stamp did not roll back migration"
3348 );
3349 }
3350
3351 #[test]
3352 fn fresh_domain_ignores_foreign_cotenant_objects() {
3353 let dir = tempfile::tempdir().expect("tempdir");
3354 let mut conn = temp_conn(&dir);
3355 conn.execute_batch("CREATE TABLE foreign_table (value TEXT)")
3356 .expect("foreign ddl");
3357 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("fresh domain");
3358 assert_eq!(
3359 report,
3360 LedgerReport {
3361 from_version: 0,
3362 to_version: 2
3363 }
3364 );
3365 conn.execute("INSERT INTO foreign_table VALUES ('kept')", [])
3366 .expect("foreign object survives");
3367 }
3368
3369 #[test]
3370 fn future_version_is_refused_before_any_mutation() {
3371 let dir = tempfile::tempdir().expect("tempdir");
3372 let mut conn = temp_conn(&dir);
3373 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
3374 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse");
3376 match err {
3377 SqliteStoreError::SchemaFromTheFuture {
3378 domain,
3379 found,
3380 supported,
3381 } => {
3382 assert_eq!(domain, "test-domain");
3383 assert_eq!(found, 2);
3384 assert_eq!(supported, 1);
3385 }
3386 other => panic!("wrong error: {other}"),
3387 }
3388 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
3390 }
3391
3392 #[test]
3393 fn foreign_domain_rows_are_untouched() {
3394 let dir = tempfile::tempdir().expect("tempdir");
3395 let mut conn = temp_conn(&dir);
3396 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("mine");
3397 conn.execute(
3398 "INSERT INTO meerkat_schema (domain, version) VALUES ('foreign-domain', 7)",
3399 [],
3400 )
3401 .expect("foreign row");
3402 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("noop");
3403 let foreign: i64 = conn
3404 .query_row(
3405 "SELECT version FROM meerkat_schema WHERE domain = 'foreign-domain'",
3406 [],
3407 |r| r.get(0),
3408 )
3409 .expect("foreign row survives");
3410 assert_eq!(foreign, 7);
3411 }
3412
3413 #[test]
3414 fn invalid_migration_list_is_refused_without_touching_the_file() {
3415 const BAD: SchemaDomain = SchemaDomain {
3416 name: "bad-domain",
3417 migrations: &[Migration {
3418 version: 3,
3419 name: "gap",
3420 apply: create_t1,
3421 }],
3422 initialize_current: create_t1,
3423 allowed_existing_versions: &[3],
3424 bridge_recoverable_versions: &[1],
3425 released_predecessors: &[],
3426 owned_objects: &[],
3427 retired_objects: &[],
3428 };
3429 let dir = tempfile::tempdir().expect("tempdir");
3430 let mut conn = temp_conn(&dir);
3431 let err = apply_domain_migrations(&mut conn, &BAD).expect_err("refuse");
3432 assert!(matches!(err, SqliteStoreError::InvalidMigrationList { .. }));
3433 assert!(!ledger_table_exists(&conn).expect("check"));
3434 }
3435
3436 #[test]
3437 fn failed_migration_rolls_back_atomically() {
3438 fn fail(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3439 tx.execute_batch("CREATE TABLE half_done (x INTEGER)")?;
3440 tx.execute_batch("THIS IS NOT SQL")
3441 }
3442 fn initialize_failing(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3443 create_t1(tx)?;
3444 fail(tx)
3445 }
3446 const FAILING: SchemaDomain = SchemaDomain {
3447 name: "failing-domain",
3448 migrations: &[
3449 Migration {
3450 version: 1,
3451 name: "base",
3452 apply: create_t1,
3453 },
3454 Migration {
3455 version: 2,
3456 name: "explodes",
3457 apply: fail,
3458 },
3459 ],
3460 initialize_current: initialize_failing,
3461 allowed_existing_versions: &[1, 2],
3462 bridge_recoverable_versions: &[1],
3463 released_predecessors: &[SchemaPredecessor {
3464 version: 1,
3465 verify: verify_v1,
3466 }],
3467 owned_objects: &[
3468 SchemaObject {
3469 kind: SchemaObjectKind::Table,
3470 name: "t1",
3471 },
3472 SchemaObject {
3473 kind: SchemaObjectKind::Table,
3474 name: "half_done",
3475 },
3476 ],
3477 retired_objects: &[],
3478 };
3479 let dir = tempfile::tempdir().expect("tempdir");
3480 let mut conn = temp_conn(&dir);
3481 let err = apply_domain_migrations(&mut conn, &FAILING).expect_err("must fail");
3482 assert!(matches!(
3483 err,
3484 SqliteStoreError::MigrationFailed { version: 2, .. }
3485 ));
3486 assert_eq!(domain_version(&conn, "failing-domain").expect("read"), None);
3489 let tables: Vec<String> = conn
3490 .prepare(
3491 "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('t1','half_done')",
3492 )
3493 .expect("prepare")
3494 .query_map([], |r| r.get(0))
3495 .expect("query")
3496 .collect::<Result<_, _>>()
3497 .expect("rows");
3498 assert!(tables.is_empty(), "rollback left tables behind: {tables:?}");
3499 }
3500
3501 #[test]
3502 fn malformed_ledger_shape_is_refused_not_healed() {
3503 let dir = tempfile::tempdir().expect("tempdir");
3504 let mut conn = temp_conn(&dir);
3505 conn.execute_batch("CREATE TABLE meerkat_schema (x INTEGER)")
3507 .expect("foreign ddl");
3508 let err = domain_version(&conn, "test-domain").expect_err("refuse read");
3509 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3510 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
3511 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3512 let count: i64 = conn
3514 .query_row("SELECT COUNT(*) FROM meerkat_schema", [], |r| r.get(0))
3515 .expect("foreign table survives");
3516 assert_eq!(count, 0);
3517 }
3518
3519 #[test]
3520 fn non_positive_versions_are_refused_not_healed() {
3521 for bad_version in [0i64, -3] {
3522 let dir = tempfile::tempdir().expect("tempdir");
3523 let mut conn = temp_conn(&dir);
3524 conn.execute_batch(
3525 "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL)",
3526 )
3527 .expect("ledger ddl");
3528 conn.execute(
3529 "INSERT INTO meerkat_schema (domain, version) VALUES ('test-domain', ?1)",
3530 [bad_version],
3531 )
3532 .expect("seed bad version");
3533 let err = domain_version(&conn, "test-domain").expect_err("refuse read");
3534 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3535 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
3536 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3537 let stored: i64 = conn
3539 .query_row(
3540 "SELECT version FROM meerkat_schema WHERE domain = 'test-domain'",
3541 [],
3542 |r| r.get(0),
3543 )
3544 .expect("row survives");
3545 assert_eq!(stored, bad_version);
3546 }
3547 }
3548
3549 #[test]
3550 fn duplicate_domain_rows_are_refused() {
3551 let dir = tempfile::tempdir().expect("tempdir");
3552 let conn = temp_conn(&dir);
3553 conn.execute_batch(
3557 "CREATE TABLE meerkat_schema (domain TEXT, version INTEGER NOT NULL);
3558 INSERT INTO meerkat_schema VALUES ('dup-domain', 1);
3559 INSERT INTO meerkat_schema VALUES ('dup-domain', 2);",
3560 )
3561 .expect("seed duplicates");
3562 let err = read_version(&conn, "dup-domain").expect_err("refuse duplicates");
3563 match err {
3564 SqliteStoreError::LedgerMalformed { detail } => {
3565 assert!(detail.contains("multiple ledger rows"), "{detail}");
3566 }
3567 other => panic!("wrong error: {other}"),
3568 }
3569 }
3570
3571 #[test]
3572 fn temp_shadowing_cannot_hijack_the_ledger() {
3573 let dir = tempfile::tempdir().expect("tempdir");
3574 let mut conn = temp_conn(&dir);
3575 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("stamp v1");
3576 conn.execute_batch(
3579 "CREATE TEMP TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
3580 INSERT INTO temp.meerkat_schema VALUES ('test-domain', 999);",
3581 )
3582 .expect("temp shadow");
3583 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(1));
3584 let report = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("noop against main");
3585 assert!(!report.migrated());
3586 }
3587
3588 #[test]
3589 fn migration_that_ends_the_transaction_is_refused_unstamped() {
3590 fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3591 Ok(())
3592 }
3593 fn verify_empty_predecessor(_conn: &Connection) -> Result<(), String> {
3594 Ok(())
3595 }
3596 fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3597 tx.execute_batch("CREATE TABLE escaped_commit (x INTEGER); COMMIT")
3598 }
3599 fn rolls_back_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3600 tx.execute_batch("ROLLBACK")
3601 }
3602 fn commits_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3606 tx.execute_batch("CREATE TABLE escaped_commit_begin (x INTEGER); COMMIT; BEGIN")
3607 }
3608 fn rolls_back_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3609 tx.execute_batch("ROLLBACK; BEGIN")
3610 }
3611 const COMMITS: SchemaDomain = SchemaDomain {
3612 name: "custody-commit",
3613 migrations: &[
3614 Migration {
3615 version: 1,
3616 name: "base",
3617 apply: no_op,
3618 },
3619 Migration {
3620 version: 2,
3621 name: "commits-underneath",
3622 apply: commits_underneath,
3623 },
3624 ],
3625 initialize_current: no_op,
3626 allowed_existing_versions: &[1, 2],
3627 bridge_recoverable_versions: &[1],
3628 released_predecessors: &[SchemaPredecessor {
3629 version: 1,
3630 verify: verify_empty_predecessor,
3631 }],
3632 owned_objects: &[],
3633 retired_objects: &[],
3634 };
3635 const ROLLS_BACK: SchemaDomain = SchemaDomain {
3636 name: "custody-rollback",
3637 migrations: &[
3638 Migration {
3639 version: 1,
3640 name: "base",
3641 apply: no_op,
3642 },
3643 Migration {
3644 version: 2,
3645 name: "rolls-back-underneath",
3646 apply: rolls_back_underneath,
3647 },
3648 ],
3649 initialize_current: no_op,
3650 allowed_existing_versions: &[1, 2],
3651 bridge_recoverable_versions: &[1],
3652 released_predecessors: &[SchemaPredecessor {
3653 version: 1,
3654 verify: verify_empty_predecessor,
3655 }],
3656 owned_objects: &[],
3657 retired_objects: &[],
3658 };
3659 const COMMITS_THEN_BEGINS: SchemaDomain = SchemaDomain {
3660 name: "custody-commit-begin",
3661 migrations: &[
3662 Migration {
3663 version: 1,
3664 name: "base",
3665 apply: no_op,
3666 },
3667 Migration {
3668 version: 2,
3669 name: "commits-then-begins",
3670 apply: commits_then_begins,
3671 },
3672 ],
3673 initialize_current: no_op,
3674 allowed_existing_versions: &[1, 2],
3675 bridge_recoverable_versions: &[1],
3676 released_predecessors: &[SchemaPredecessor {
3677 version: 1,
3678 verify: verify_empty_predecessor,
3679 }],
3680 owned_objects: &[],
3681 retired_objects: &[],
3682 };
3683 const ROLLS_BACK_THEN_BEGINS: SchemaDomain = SchemaDomain {
3684 name: "custody-rollback-begin",
3685 migrations: &[
3686 Migration {
3687 version: 1,
3688 name: "base",
3689 apply: no_op,
3690 },
3691 Migration {
3692 version: 2,
3693 name: "rolls-back-then-begins",
3694 apply: rolls_back_then_begins,
3695 },
3696 ],
3697 initialize_current: no_op,
3698 allowed_existing_versions: &[1, 2],
3699 bridge_recoverable_versions: &[1],
3700 released_predecessors: &[SchemaPredecessor {
3701 version: 1,
3702 verify: verify_empty_predecessor,
3703 }],
3704 owned_objects: &[],
3705 retired_objects: &[],
3706 };
3707 for (domain, expected_name) in [
3708 (&COMMITS, "commits-underneath"),
3709 (&ROLLS_BACK, "rolls-back-underneath"),
3710 (&COMMITS_THEN_BEGINS, "commits-then-begins"),
3711 (&ROLLS_BACK_THEN_BEGINS, "rolls-back-then-begins"),
3712 ] {
3713 let dir = tempfile::tempdir().expect("tempdir");
3714 let mut conn = temp_conn(&dir);
3715 conn.execute_batch(CREATE_LEDGER_SQL).expect("ledger");
3716 conn.execute(
3717 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 1)",
3718 [domain.name],
3719 )
3720 .expect("released predecessor");
3721 let err = apply_domain_migrations(&mut conn, domain).expect_err("custody violation");
3722 match err {
3723 SqliteStoreError::MigrationBrokeTransaction {
3724 domain: err_domain,
3725 version,
3726 name,
3727 } => {
3728 assert_eq!(err_domain, domain.name);
3729 assert_eq!(version, 2);
3730 assert_eq!(name, expected_name);
3731 }
3732 other => panic!("wrong error: {other}"),
3733 }
3734 assert_eq!(domain_version(&conn, domain.name).expect("read"), Some(1));
3737 }
3738 }
3739
3740 #[test]
3741 fn initializer_that_ends_the_transaction_is_refused_unstamped() {
3742 fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3743 tx.execute_batch("CREATE TABLE escaped_initializer (x INTEGER); COMMIT")
3744 }
3745 const COMMITS: SchemaDomain = SchemaDomain {
3746 name: "initializer-custody-commit",
3747 migrations: &[Migration {
3748 version: 1,
3749 name: "base",
3750 apply: commits_underneath,
3751 }],
3752 initialize_current: commits_underneath,
3753 allowed_existing_versions: &[1],
3754 bridge_recoverable_versions: &[1],
3755 released_predecessors: &[],
3756 owned_objects: &[SchemaObject {
3757 kind: SchemaObjectKind::Table,
3758 name: "escaped_initializer",
3759 }],
3760 retired_objects: &[],
3761 };
3762 let dir = tempfile::tempdir().expect("tempdir");
3763 let mut conn = temp_conn(&dir);
3764 let err = apply_domain_migrations(&mut conn, &COMMITS).expect_err("custody violation");
3765 match err {
3766 SqliteStoreError::MigrationBrokeTransaction {
3767 domain,
3768 version,
3769 name,
3770 } => {
3771 assert_eq!(domain, COMMITS.name);
3772 assert_eq!(version, 1);
3773 assert_eq!(name, "initialize-current");
3774 }
3775 other => panic!("wrong error: {other}"),
3776 }
3777 assert_eq!(domain_version(&conn, COMMITS.name).expect("read"), None);
3778 }
3779
3780 #[test]
3781 fn initializer_using_its_own_savepoints_keeps_custody() {
3782 fn nests_savepoints(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3786 tx.execute_batch(
3787 "SAVEPOINT body_sp;
3788 CREATE TABLE sp_t (x INTEGER);
3789 RELEASE SAVEPOINT body_sp",
3790 )
3791 }
3792 const NESTED: SchemaDomain = SchemaDomain {
3793 name: "custody-nested-savepoint",
3794 migrations: &[Migration {
3795 version: 1,
3796 name: "nests-savepoints",
3797 apply: nests_savepoints,
3798 }],
3799 initialize_current: nests_savepoints,
3800 allowed_existing_versions: &[1],
3801 bridge_recoverable_versions: &[1],
3802 released_predecessors: &[],
3803 owned_objects: &[SchemaObject {
3804 kind: SchemaObjectKind::Table,
3805 name: "sp_t",
3806 }],
3807 retired_objects: &[],
3808 };
3809 let dir = tempfile::tempdir().expect("tempdir");
3810 let mut conn = temp_conn(&dir);
3811 let report = apply_domain_migrations(&mut conn, &NESTED).expect("apply");
3812 assert_eq!(report.to_version, 1);
3813 assert_eq!(domain_version(&conn, NESTED.name).expect("read"), Some(1));
3814 }
3815
3816 #[test]
3817 fn schema_preflight_passes_fresh_and_current_refuses_future() {
3818 let dir = tempfile::tempdir().expect("tempdir");
3819 let mut conn = temp_conn(&dir);
3820 preflight_schema_eligibility(&conn, &DOMAIN_V1).expect("no ledger yet");
3821 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
3822 preflight_schema_eligibility(&conn, &DOMAIN_V2).expect("current");
3823 let err =
3824 preflight_schema_eligibility(&conn, &DOMAIN_V1).expect_err("future for old binary");
3825 assert!(matches!(
3826 err,
3827 SqliteStoreError::SchemaFromTheFuture {
3828 found: 2,
3829 supported: 1,
3830 ..
3831 }
3832 ));
3833 }
3834
3835 #[test]
3836 fn current_schema_verification_does_not_take_the_wal_writer() {
3837 let dir = tempfile::tempdir().expect("tempdir");
3838 let path = dir.path().join("db.sqlite3");
3839 let mut writer = open(&path, ConnectionProfile::PRIMARY).expect("writer");
3840 apply_domain_migrations(&mut writer, &DOMAIN_V2).expect("initialize");
3841 let mut reader = open(&path, ConnectionProfile::PRIMARY).expect("reader");
3842 reader
3845 .busy_timeout(std::time::Duration::ZERO)
3846 .expect("busy policy");
3847 let tx = writer
3848 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
3849 .expect("reserve unrelated writer");
3850 let report = apply_domain_migrations(&mut reader, &DOMAIN_V2)
3851 .expect("current schema verification must be a read-only snapshot");
3852 assert!(!report.migrated());
3853 assert!(reader.is_autocommit());
3854 drop(tx);
3855 }
3856
3857 #[test]
3858 fn concurrent_opens_race_safely() {
3859 let dir = tempfile::tempdir().expect("tempdir");
3860 let path = dir.path().join("db.sqlite3");
3861 let mut handles = Vec::new();
3862 for _ in 0..8 {
3863 let path = path.clone();
3864 handles.push(std::thread::spawn(move || {
3865 let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
3866 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply")
3867 }));
3868 }
3869 let mut migrated = 0;
3870 for handle in handles {
3871 let report = handle.join().expect("thread");
3872 assert_eq!(report.to_version, 2);
3873 if report.migrated() {
3874 migrated += 1;
3875 }
3876 }
3877 assert_eq!(migrated, 1, "only the winning writer may initialize");
3878 let conn = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
3879 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
3880 }
3881
3882 #[test]
3883 fn concurrent_predecessor_upgrade_rechecks_after_read_snapshot() {
3884 let dir = tempfile::tempdir().expect("tempdir");
3885 let path = dir.path().join("db.sqlite3");
3886 let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("seed");
3887 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("version 1");
3888 let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
3889 let handles = (0..8)
3890 .map(|_| {
3891 let path = path.clone();
3892 let barrier = barrier.clone();
3893 std::thread::spawn(move || {
3894 let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
3895 barrier.wait();
3896 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("upgrade")
3897 })
3898 })
3899 .collect::<Vec<_>>();
3900 let reports = handles
3901 .into_iter()
3902 .map(|thread| thread.join().expect("thread"))
3903 .collect::<Vec<_>>();
3904 assert_eq!(reports.iter().filter(|report| report.migrated()).count(), 1);
3905 assert!(reports.iter().all(|report| report.to_version == 2));
3906 preflight_schema_eligibility(&conn, &DOMAIN_V2).expect("verified upgraded catalog");
3907 }
3908
3909 mod preflight_snapshot {
3914 use std::cell::{Cell, RefCell};
3915 use std::path::Path;
3916 use std::rc::Rc;
3917 use std::sync::mpsc;
3918 use std::thread::JoinHandle;
3919 use std::time::Duration;
3920
3921 use super::*;
3922 use crate::profile::{OpenOptions, open_with};
3923
3924 const FOREIGN_DOMAIN: SchemaDomain = SchemaDomain {
3928 name: "foreign-domain",
3929 migrations: &[Migration {
3930 version: 1,
3931 name: "base",
3932 apply: create_foreign,
3933 }],
3934 initialize_current: create_foreign,
3935 allowed_existing_versions: &[1],
3936 bridge_recoverable_versions: &[],
3937 released_predecessors: &[],
3938 owned_objects: &[SchemaObject {
3939 kind: SchemaObjectKind::Table,
3940 name: "foreign_t",
3941 }],
3942 retired_objects: &[],
3943 };
3944
3945 fn create_foreign(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3946 tx.execute_batch("CREATE TABLE IF NOT EXISTS foreign_t (x INTEGER)")
3947 }
3948
3949 type PendingWriter = (JoinHandle<Result<LedgerReport, SqliteStoreError>>, bool);
3950
3951 fn open_preflighted(
3952 path: &Path,
3953 profile: ConnectionProfile,
3954 ) -> Result<Connection, SqliteStoreError> {
3955 open_with(
3956 path,
3957 profile,
3958 OpenOptions {
3959 schema_preflight: &[&DOMAIN_V1],
3960 ..OpenOptions::default()
3961 },
3962 )
3963 }
3964
3965 fn arm_concurrent_create(path: &Path) -> Rc<Cell<bool>> {
3968 let ran = Rc::new(Cell::new(false));
3969 let seen = Rc::clone(&ran);
3970 let path = path.to_path_buf();
3971 arm_preflight_probe_for_test(move || {
3972 let mut writer =
3973 open(&path, ConnectionProfile::PRIMARY).expect("concurrent writer");
3974 let report = apply_domain_migrations(&mut writer, &DOMAIN_V1)
3975 .expect("the concurrent create commits");
3976 assert!(
3977 report.migrated(),
3978 "the concurrent writer creates the domain"
3979 );
3980 seen.set(true);
3981 });
3982 ran
3983 }
3984
3985 #[test]
3986 fn a_domain_created_between_the_ledger_and_catalog_reads_is_not_half_seen() {
3987 let dir = tempfile::tempdir().expect("tempdir");
3988 let path = dir.path().join("db.sqlite3");
3989 let mut seed = open(&path, ConnectionProfile::PRIMARY).expect("WAL file");
3990 apply_domain_migrations(&mut seed, &FOREIGN_DOMAIN).expect("co-tenant stamped");
3991 drop(seed);
3992 let ran = arm_concurrent_create(&path);
3993
3994 let conn = open_preflighted(&path, ConnectionProfile::Primary { create: false })
3995 .expect("the preflight sees the concurrent domain whole or not at all");
3996
3997 assert!(ran.get(), "the probe committed inside the preflight");
3998 assert_eq!(
3999 domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
4000 Some(1),
4001 "the concurrent create is visible once the preflight's snapshot ends"
4002 );
4003 }
4004
4005 #[test]
4006 fn a_domain_created_before_any_ledger_table_exists_is_not_half_seen() {
4007 let dir = tempfile::tempdir().expect("tempdir");
4008 let path = dir.path().join("db.sqlite3");
4009 drop(open(&path, ConnectionProfile::PRIMARY).expect("empty WAL file"));
4010 let ran = arm_concurrent_create(&path);
4011
4012 let conn = open_preflighted(&path, ConnectionProfile::Primary { create: false })
4013 .expect("the preflight sees the concurrent domain whole or not at all");
4014
4015 assert!(ran.get(), "the probe committed inside the preflight");
4016 assert_eq!(
4017 domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
4018 Some(1),
4019 "the concurrent create is visible once the preflight's snapshot ends"
4020 );
4021 }
4022
4023 #[test]
4024 fn a_rollback_journal_preflight_holds_a_concurrent_commit_until_it_ends() {
4025 let dir = tempfile::tempdir().expect("tempdir");
4026 let path = dir.path().join("db.sqlite3");
4027 let mut seed = Connection::open(&path).expect("rollback-journal file");
4028 apply_domain_migrations(&mut seed, &FOREIGN_DOMAIN).expect("co-tenant stamped");
4029 let mode: String = seed
4030 .query_row("PRAGMA journal_mode", [], |row| row.get(0))
4031 .expect("journal mode");
4032 assert_eq!(mode, "delete");
4033 drop(seed);
4034 let pending: Rc<RefCell<Option<PendingWriter>>> = Rc::default();
4035 let slot = Rc::clone(&pending);
4036 let writer_path = path.clone();
4037 arm_preflight_probe_for_test(move || {
4038 let (done, finished) = mpsc::channel();
4039 let writer = std::thread::spawn(move || {
4042 let mut writer = Connection::open(&writer_path)?;
4043 writer.busy_timeout(Duration::from_secs(30))?;
4044 let report = apply_domain_migrations(&mut writer, &DOMAIN_V1);
4045 let _ = done.send(());
4046 report
4047 });
4048 let committed_inside = finished.recv_timeout(Duration::from_millis(1500)).is_ok();
4049 *slot.borrow_mut() = Some((writer, committed_inside));
4050 });
4051
4052 let conn = open_preflighted(&path, ConnectionProfile::ReadOnly)
4053 .expect("the preflight sees the concurrent domain whole or not at all");
4054
4055 let (writer, committed_inside) = pending
4056 .borrow_mut()
4057 .take()
4058 .expect("the probe ran inside the preflight");
4059 assert!(
4060 !committed_inside,
4061 "the writer committed while the preflight was between its reads"
4062 );
4063 let report = writer
4064 .join()
4065 .expect("writer thread")
4066 .expect("the writer commits once the preflight has ended");
4067 assert!(report.migrated());
4068 drop(conn);
4069 let reader = Connection::open(&path).expect("reopen");
4070 assert_eq!(
4071 domain_version(&reader, DOMAIN_V1.name).expect("ledger"),
4072 Some(1)
4073 );
4074 }
4075
4076 #[test]
4081 fn an_online_check_never_judges_a_version_against_a_later_catalog() {
4082 let dir = tempfile::tempdir().expect("tempdir");
4083 let path = dir.path().join("db.sqlite3");
4084 let mut seed = open(&path, ConnectionProfile::PRIMARY).expect("WAL file");
4085 apply_domain_migrations(&mut seed, &DOMAIN_V1).expect("current domain");
4086 drop(seed);
4087 drop(
4090 open_preflighted(&path, ConnectionProfile::Primary { create: false })
4091 .expect("ordinary open"),
4092 );
4093 let ran = Rc::new(Cell::new(false));
4094 let seen = Rc::clone(&ran);
4095 let upgrade_path = path.clone();
4096 arm_preflight_probe_for_test(move || {
4097 let mut writer =
4098 open(&upgrade_path, ConnectionProfile::PRIMARY).expect("concurrent writer");
4099 let report = apply_domain_migrations(&mut writer, &DOMAIN_V2)
4100 .expect("a newer binary upgrades the domain");
4101 assert!(report.migrated(), "the concurrent writer upgrades");
4102 seen.set(true);
4103 });
4104
4105 let conn = open_preflighted(&path, ConnectionProfile::OnlineExistingWriter)
4106 .expect("the online check sees one version with its own catalog");
4107
4108 assert!(ran.get(), "the probe committed inside the online check");
4109 assert_eq!(
4110 domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
4111 Some(2),
4112 "the upgrade is visible once the check's snapshot ends"
4113 );
4114 }
4115
4116 #[test]
4117 fn a_preflight_inside_a_callers_transaction_reads_that_transaction() {
4118 let dir = tempfile::tempdir().expect("tempdir");
4119 let path = dir.path().join("db.sqlite3");
4120 let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
4121 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("current domain");
4122 let tx = conn.transaction().expect("the caller's transaction");
4123
4124 assert_eq!(
4125 preflight_domain_version(&tx, &DOMAIN_V1).expect("checked in that transaction"),
4126 Some(1)
4127 );
4128 preflight_schema_eligibility(&tx, &DOMAIN_V1).expect("checked again in it");
4129 assert!(
4130 !tx.is_autocommit(),
4131 "the caller's transaction is still open"
4132 );
4133 tx.commit().expect("the caller ends its own transaction");
4134 }
4135 }
4136}