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> {
492 if !ledger_table_exists(conn)? {
493 return Ok(None);
494 }
495 validate_ledger_shape(conn)?;
496 read_version(conn, domain)
497}
498
499pub fn preflight_schema_eligibility(
514 conn: &Connection,
515 domain: &SchemaDomain,
516) -> Result<(), SqliteStoreError> {
517 domain.validate()?;
518 let supported = domain.supported_version();
519 match domain_version(conn, domain.name)? {
520 Some(found) if found > supported => {
521 return Err(SqliteStoreError::SchemaFromTheFuture {
522 domain: domain.name.to_string(),
523 found,
524 supported,
525 });
526 }
527 Some(found) if !domain.accepts_existing_version(found) => {
528 return Err(unsupported_predecessor(domain, found));
529 }
530 Some(found) => domain.verify_stamped_version(conn, found)?,
531 None => {
532 let objects = find_owned_objects(conn, domain)?;
533 if !objects.is_empty() {
534 return Err(SqliteStoreError::UnledgeredDomainObjects {
535 domain: domain.name.to_string(),
536 objects,
537 bridgeable: domain.bridge_eligibility(conn),
538 });
539 }
540 }
541 }
542 Ok(())
543}
544
545pub fn try_preflight_current_schema(
553 conn: &Connection,
554 domain: &SchemaDomain,
555) -> Result<(), SqliteStoreError> {
556 domain.validate()?;
557 let supported = domain.supported_version();
558 match domain_version(conn, domain.name)? {
559 Some(found) if found > supported => {
560 return Err(SqliteStoreError::SchemaFromTheFuture {
561 domain: domain.name.to_string(),
562 found,
563 supported,
564 });
565 }
566 Some(found) if found == supported => {}
567 _ => {
568 return Err(SqliteStoreError::OpenRefused {
569 path: std::path::PathBuf::from(conn.path().unwrap_or(":memory:")),
570 profile: "online-existing-writer",
571 detail: "administration requires the installed current schema".to_string(),
572 });
573 }
574 }
575 let expected = try_cached_current_catalog(
576 EXPECTED_CURRENT_CATALOGS.get(),
577 ¤t_catalog_cache_key(domain),
578 )
579 .map_err(|cause| SqliteStoreError::CurrentCatalogUnavailable {
580 domain: domain.name.to_string(),
581 cause,
582 })?
583 .map_err(|detail| SqliteStoreError::SchemaFingerprintMismatch {
584 domain: domain.name.to_string(),
585 version: supported,
586 detail,
587 })?;
588 let actual = compact_catalog_fingerprint(conn, domain, domain.owned_objects);
589 if actual.as_ref().is_ok_and(|actual| actual == &expected) {
590 return Ok(());
591 }
592 let detail = actual.map_or_else(
593 |error| error,
594 |actual| format!("current owned catalog differs: expected {expected}, found {actual}"),
595 );
596 Err(match current_catalog_diff(conn, domain) {
599 Ok(diff) if !diff.is_empty() => SqliteStoreError::CurrentSchemaMismatch {
600 domain: domain.name.to_string(),
601 version: supported,
602 missing_objects: diff.missing,
603 unexpected_objects: diff.unexpected,
604 changed_objects: diff.changed,
605 },
606 _ => SqliteStoreError::SchemaFingerprintMismatch {
607 domain: domain.name.to_string(),
608 version: supported,
609 detail,
610 },
611 })
612}
613
614pub fn apply_domain_migrations(
622 conn: &mut Connection,
623 domain: &SchemaDomain,
624) -> Result<LedgerReport, SqliteStoreError> {
625 domain.validate()?;
626 let supported = domain.supported_version();
627
628 {
629 let read = conn.transaction()?;
630 let is_current = domain_version(&read, domain.name)? == Some(supported);
631 if is_current {
632 domain.verify_stamped_version(&read, supported)?;
633 }
634 read.rollback()?;
635 if is_current {
636 return Ok(LedgerReport {
637 from_version: supported,
638 to_version: supported,
639 });
640 }
641 }
642
643 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
644 let current = if ledger_table_exists(&tx)? {
647 validate_ledger_shape(&tx)?;
648 read_version(&tx, domain.name)?
649 } else {
650 None
651 };
652 if let Some(found) = current {
653 if found > supported {
654 return Err(SqliteStoreError::SchemaFromTheFuture {
655 domain: domain.name.to_string(),
656 found,
657 supported,
658 });
659 }
660 if !domain.accepts_existing_version(found) {
661 return Err(unsupported_predecessor(domain, found));
662 }
663 domain.verify_stamped_version(&tx, found)?;
664 } else {
665 let objects = find_owned_objects(&tx, domain)?;
666 if !objects.is_empty() {
667 return Err(SqliteStoreError::UnledgeredDomainObjects {
668 domain: domain.name.to_string(),
669 objects,
670 bridgeable: domain.bridge_eligibility(&tx),
671 });
672 }
673 }
674 let current = current.unwrap_or(0);
675 if current == supported {
676 tx.rollback()?;
677 return Ok(LedgerReport {
678 from_version: current,
679 to_version: current,
680 });
681 }
682
683 if !ledger_table_exists(&tx)? {
686 tx.execute_batch(CREATE_LEDGER_SQL)?;
687 validate_ledger_shape(&tx)?;
688 }
689
690 if current == 0 {
691 tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
692 (domain.initialize_current)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
693 domain: domain.name.to_string(),
694 version: supported,
695 name: "initialize-current".to_string(),
696 source,
697 })?;
698 if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
699 return Err(SqliteStoreError::MigrationBrokeTransaction {
700 domain: domain.name.to_string(),
701 version: supported,
702 name: "initialize-current".to_string(),
703 });
704 }
705 } else {
706 for migration in domain.migrations.iter().filter(|m| m.version > current) {
707 tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
708 (migration.apply)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
709 domain: domain.name.to_string(),
710 version: migration.version,
711 name: migration.name.to_string(),
712 source,
713 })?;
714 if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
724 return Err(SqliteStoreError::MigrationBrokeTransaction {
725 domain: domain.name.to_string(),
726 version: migration.version,
727 name: migration.name.to_string(),
728 });
729 }
730 }
731 }
732 verify_current_schema_fingerprint(&tx, domain).map_err(|detail| {
733 SqliteStoreError::SchemaFingerprintMismatch {
734 domain: domain.name.to_string(),
735 version: supported,
736 detail,
737 }
738 })?;
739 tx.execute(
740 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, ?2)
741 ON CONFLICT(domain) DO UPDATE SET version = excluded.version",
742 rusqlite::params![domain.name, supported],
743 )?;
744 verify_ledger_stamp(&tx, domain.name, supported)?;
745 tx.commit()?;
746
747 Ok(LedgerReport {
748 from_version: current,
749 to_version: supported,
750 })
751}
752
753pub type MaintenancePrepareFn =
755 for<'connection> fn(
756 &Transaction<'connection>,
757 ) -> Result<MaintenancePrepareReport, rusqlite::Error>;
758
759pub fn bridge_unledgered_domain(
784 conn: &mut Connection,
785 domain: &SchemaDomain,
786 target_version: i64,
787 recoverable_source_versions: &[i64],
788 prepare: Option<MaintenancePrepareFn>,
789) -> Result<MaintenanceBridgeReport, SqliteStoreError> {
790 domain.validate()?;
791 let supported = domain.supported_version();
792 if target_version > supported {
793 return Err(SqliteStoreError::SchemaFromTheFuture {
794 domain: domain.name.to_string(),
795 found: target_version,
796 supported,
797 });
798 }
799 if !domain.accepts_existing_version(target_version) {
800 return Err(unsupported_predecessor(domain, target_version));
801 }
802 validate_recoverable_source_versions(domain, target_version, recoverable_source_versions)?;
803
804 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
805 let current = if ledger_table_exists(&tx)? {
806 validate_ledger_shape(&tx)?;
807 read_version(&tx, domain.name)?
808 } else {
809 None
810 };
811
812 let mut inferred_oracles = None;
813 let from_version = if let Some(found) = current {
814 if found > target_version {
815 return Err(SqliteStoreError::SchemaFromTheFuture {
816 domain: domain.name.to_string(),
817 found,
818 supported: target_version,
819 });
820 }
821 if !domain.accepts_existing_version(found) {
822 return Err(unsupported_predecessor(domain, found));
823 }
824 domain.verify_stamped_version(&tx, found)?;
825 if found == target_version && prepare.is_none() {
826 return Ok(MaintenanceBridgeReport {
827 from_version: found,
828 to_version: found,
829 prepared: 0,
830 refused: Vec::new(),
831 });
832 }
833 found
834 } else {
835 let objects = find_owned_objects(&tx, domain)?;
836 if objects.is_empty() {
837 return Ok(MaintenanceBridgeReport {
838 from_version: 0,
839 to_version: 0,
840 prepared: 0,
841 refused: Vec::new(),
842 });
843 }
844
845 let matched = authenticate_unledgered_source_version(
846 &tx,
847 domain,
848 target_version,
849 recoverable_source_versions,
850 || objects.clone(),
851 )?;
852 inferred_oracles = Some(matched.oracles);
853 matched.version
854 };
855
856 let oracles = match inferred_oracles {
857 Some(oracles) => oracles,
858 None => build_migration_prefix_oracles(domain, target_version)?,
859 };
860 validate_domain_trigger_isolation(&tx, domain, from_version)?;
861
862 let (prepared, refused) = match prepare {
863 Some(prepare) => {
864 let report =
865 run_with_custody(&tx, domain, from_version, "maintenance-prepare", prepare)?;
866 (report.changed, report.refused)
867 }
868 None => (0, Vec::new()),
869 };
870 for migration in domain
871 .migrations
872 .iter()
873 .filter(|migration| migration.version > from_version && migration.version <= target_version)
874 {
875 run_with_custody(
876 &tx,
877 domain,
878 migration.version,
879 migration.name,
880 migration.apply,
881 )?;
882 }
883
884 let target = oracles
885 .iter()
886 .find_map(|(version, fingerprint)| (*version == target_version).then_some(fingerprint))
887 .ok_or_else(|| SqliteStoreError::InvalidMigrationList {
888 domain: domain.name.to_string(),
889 detail: format!(
890 "migration-prefix oracle did not produce requested target version {target_version}"
891 ),
892 })?;
893 let converged = domain_catalog_fingerprint(&tx, domain).map_err(|detail| {
894 SqliteStoreError::SchemaFingerprintMismatch {
895 domain: domain.name.to_string(),
896 version: target_version,
897 detail,
898 }
899 })?;
900 if &converged != target {
901 return Err(SqliteStoreError::SchemaFingerprintMismatch {
902 domain: domain.name.to_string(),
903 version: target_version,
904 detail: format!(
905 "migration-prefix catalog differs: expected {target:?}, found {converged:?}"
906 ),
907 });
908 }
909 domain.verify_predecessor(&tx, target_version)?;
910
911 if current != Some(target_version) {
912 if !ledger_table_exists(&tx)? {
913 tx.execute_batch(CREATE_LEDGER_SQL)?;
914 validate_ledger_shape(&tx)?;
915 }
916 tx.execute(
917 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, ?2)
918 ON CONFLICT(domain) DO UPDATE SET version = excluded.version",
919 rusqlite::params![domain.name, target_version],
920 )?;
921 }
922 verify_ledger_stamp(&tx, domain.name, target_version)?;
923 tx.commit()?;
924
925 Ok(MaintenanceBridgeReport {
926 from_version,
927 to_version: target_version,
928 prepared,
929 refused,
930 })
931}
932
933fn validate_recoverable_source_versions(
934 domain: &SchemaDomain,
935 target_version: i64,
936 versions: &[i64],
937) -> Result<(), SqliteStoreError> {
938 let mut previous = None;
939 for &version in versions {
940 if version <= 0 || version > target_version {
941 return Err(SqliteStoreError::InvalidMigrationList {
942 domain: domain.name.to_string(),
943 detail: format!(
944 "recoverable source version {version} is outside 1..={target_version}"
945 ),
946 });
947 }
948 if previous.is_some_and(|prior| prior >= version) {
949 return Err(SqliteStoreError::InvalidMigrationList {
950 domain: domain.name.to_string(),
951 detail: "recoverable source versions must be strictly increasing".to_string(),
952 });
953 }
954 previous = Some(version);
955 }
956 Ok(())
957}
958
959fn verify_ledger_stamp(
960 conn: &Connection,
961 domain: &str,
962 expected: i64,
963) -> Result<(), SqliteStoreError> {
964 let found = read_version(conn, domain)?;
965 if found != Some(expected) {
966 return Err(malformed(format!(
967 "domain `{domain}` stamp did not persist exact version {expected}; found {found:?}"
968 )));
969 }
970 Ok(())
971}
972
973fn validate_domain_trigger_isolation(
974 conn: &Connection,
975 domain: &SchemaDomain,
976 source_version: i64,
977) -> Result<(), SqliteStoreError> {
978 let all_objects = all_domain_objects(domain);
979 let allowed_triggers = all_objects
980 .iter()
981 .filter(|object| object.kind == SchemaObjectKind::Trigger)
982 .map(|object| object.name)
983 .collect::<Vec<_>>();
984 let mut statement = conn
985 .prepare(
986 "SELECT 'main', name FROM main.sqlite_schema
987 WHERE type = 'trigger' AND tbl_name = ?1 COLLATE NOCASE
988 UNION ALL
989 SELECT 'temp', name FROM temp.sqlite_schema
990 WHERE type = 'trigger' AND tbl_name = ?1 COLLATE NOCASE
991 ORDER BY 1, 2",
992 )
993 .map_err(SqliteStoreError::Sqlite)?;
994 let mut refused = Vec::new();
995 for target in all_objects.iter().filter(|object| {
996 matches!(
997 object.kind,
998 SchemaObjectKind::Table | SchemaObjectKind::View
999 )
1000 }) {
1001 let rows = statement
1002 .query_map([target.name], |row| {
1003 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1004 })
1005 .map_err(SqliteStoreError::Sqlite)?;
1006 for row in rows {
1007 let (schema, trigger) = row.map_err(SqliteStoreError::Sqlite)?;
1008 let declared = schema == "main"
1009 && allowed_triggers
1010 .iter()
1011 .any(|allowed| allowed.eq_ignore_ascii_case(&trigger));
1012 if !declared {
1013 refused.push(format!("{schema}.{trigger} on {}", target.name));
1014 }
1015 }
1016 }
1017 refused.sort();
1018 refused.dedup();
1019 if !refused.is_empty() {
1020 return Err(SqliteStoreError::SchemaFingerprintMismatch {
1021 domain: domain.name.to_string(),
1022 version: source_version,
1023 detail: format!(
1024 "undeclared or TEMP triggers can intercept maintenance writes: {refused:?}"
1025 ),
1026 });
1027 }
1028 Ok(())
1029}
1030
1031fn run_with_custody<T>(
1032 tx: &Transaction<'_>,
1033 domain: &SchemaDomain,
1034 version: i64,
1035 name: &str,
1036 body: fn(&Transaction<'_>) -> Result<T, rusqlite::Error>,
1037) -> Result<T, SqliteStoreError> {
1038 tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
1039 let body_result = body(tx);
1040 if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
1041 return Err(SqliteStoreError::MigrationBrokeTransaction {
1042 domain: domain.name.to_string(),
1043 version,
1044 name: name.to_string(),
1045 });
1046 }
1047 body_result.map_err(|source| SqliteStoreError::MigrationFailed {
1048 domain: domain.name.to_string(),
1049 version,
1050 name: name.to_string(),
1051 source,
1052 })
1053}
1054
1055struct AuthenticatedUnledgeredSource {
1058 version: i64,
1059 oracles: Vec<(i64, DomainCatalogFingerprint)>,
1060}
1061
1062fn authenticate_unledgered_source_version(
1077 conn: &Connection,
1078 domain: &SchemaDomain,
1079 target_version: i64,
1080 recoverable_source_versions: &[i64],
1081 objects: impl FnOnce() -> Vec<String>,
1082) -> Result<AuthenticatedUnledgeredSource, SqliteStoreError> {
1083 if recoverable_source_versions.is_empty() {
1084 return Err(SqliteStoreError::UnledgeredSchemaNoMatch {
1088 domain: domain.name.to_string(),
1089 target_version,
1090 objects: objects(),
1091 });
1092 }
1093 let oracles = build_migration_prefix_oracles(domain, target_version)?;
1094 let actual = domain_catalog_fingerprint(conn, domain).map_err(|detail| {
1095 SqliteStoreError::UnledgeredSchemaNoMatch {
1096 domain: domain.name.to_string(),
1097 target_version,
1098 objects: vec![detail],
1099 }
1100 })?;
1101 let mut matches = oracles
1102 .iter()
1103 .filter_map(|(version, fingerprint)| {
1104 (recoverable_source_versions.contains(version) && fingerprint == &actual)
1105 .then_some(*version)
1106 })
1107 .collect::<Vec<_>>();
1108 for predecessor in domain.released_predecessors.iter().filter(|predecessor| {
1116 recoverable_source_versions.contains(&predecessor.version)
1117 && predecessor.version <= target_version
1118 }) {
1119 if (predecessor.verify)(conn).is_ok() && !matches.contains(&predecessor.version) {
1120 matches.push(predecessor.version);
1121 }
1122 }
1123 matches.sort_unstable();
1124 match matches.as_slice() {
1125 [version] => Ok(AuthenticatedUnledgeredSource {
1126 version: *version,
1127 oracles,
1128 }),
1129 [] => Err(SqliteStoreError::UnledgeredSchemaNoMatch {
1130 domain: domain.name.to_string(),
1131 target_version,
1132 objects: objects(),
1133 }),
1134 _ => Err(SqliteStoreError::UnledgeredSchemaAmbiguous {
1135 domain: domain.name.to_string(),
1136 target_version,
1137 matches,
1138 }),
1139 }
1140}
1141
1142fn build_migration_prefix_oracles(
1143 domain: &SchemaDomain,
1144 target_version: i64,
1145) -> Result<Vec<(i64, DomainCatalogFingerprint)>, SqliteStoreError> {
1146 let mut expected = Connection::open_in_memory().map_err(SqliteStoreError::Sqlite)?;
1147 let tx = expected
1148 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1149 .map_err(SqliteStoreError::Sqlite)?;
1150 let mut oracles = Vec::with_capacity(target_version as usize);
1151 for migration in domain
1152 .migrations
1153 .iter()
1154 .filter(|migration| migration.version <= target_version)
1155 {
1156 (migration.apply)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
1157 domain: domain.name.to_string(),
1158 version: migration.version,
1159 name: format!("migration-prefix-oracle:{}", migration.name),
1160 source,
1161 })?;
1162 let fingerprint = domain_catalog_fingerprint(&tx, domain).map_err(|detail| {
1163 SqliteStoreError::InvalidMigrationList {
1164 domain: domain.name.to_string(),
1165 detail: format!(
1166 "migration-prefix oracle version {} is inconsistent with ownership: {detail}",
1167 migration.version
1168 ),
1169 }
1170 })?;
1171 oracles.push((migration.version, fingerprint));
1172 }
1173 Ok(oracles)
1174}
1175
1176#[derive(Debug, PartialEq, Eq)]
1177struct DomainCatalogFingerprint {
1178 names: Vec<(String, String)>,
1179 objects: Vec<CatalogObjectFingerprint>,
1180}
1181
1182fn domain_catalog_fingerprint(
1183 conn: &Connection,
1184 domain: &SchemaDomain,
1185) -> Result<DomainCatalogFingerprint, String> {
1186 let all_objects = all_domain_objects(domain);
1187 let names = catalog_names(conn, &all_objects).map_err(|error| error.to_string())?;
1188 let declared = all_objects
1189 .iter()
1190 .map(|object| (object.name, object))
1191 .collect::<BTreeMap<_, _>>();
1192 let mut objects = Vec::with_capacity(names.len());
1193 for (kind, name) in &names {
1194 let Some(object) = declared.get(name.as_str()) else {
1195 return Err(format!("undeclared owned object `{name}`"));
1196 };
1197 if kind != object.kind.sqlite_name() {
1198 return Err(format!(
1199 "owned object `{name}` has kind `{kind}`, expected `{}`",
1200 object.kind.sqlite_name()
1201 ));
1202 }
1203 objects.push(
1204 catalog_fingerprint(conn, object)
1205 .map_err(|error| format!("fingerprint object `{name}`: {error}"))?,
1206 );
1207 }
1208 Ok(DomainCatalogFingerprint { names, objects })
1209}
1210
1211type CurrentCatalogCache = Mutex<BTreeMap<String, Result<String, String>>>;
1212
1213static EXPECTED_CURRENT_CATALOGS: OnceLock<CurrentCatalogCache> = OnceLock::new();
1214
1215fn try_cached_current_catalog(
1226 cache: Option<&CurrentCatalogCache>,
1227 key: &str,
1228) -> Result<Result<String, String>, CurrentCatalogUnavailability> {
1229 let cache = cache.ok_or(CurrentCatalogUnavailability::NotPrepared)?;
1230 let entries = cache
1231 .lock()
1232 .map_err(|_| CurrentCatalogUnavailability::Poisoned)?;
1233 entries
1234 .get(key)
1235 .cloned()
1236 .ok_or(CurrentCatalogUnavailability::NotPrepared)
1237}
1238
1239fn verify_current_schema_fingerprint(
1243 actual: &Connection,
1244 domain: &SchemaDomain,
1245) -> Result<(), String> {
1246 let expected = {
1247 let cache = EXPECTED_CURRENT_CATALOGS.get_or_init(|| Mutex::new(BTreeMap::new()));
1248 let key = current_catalog_cache_key(domain);
1249 let cached = cache
1250 .lock()
1251 .map_err(|_| "current catalog cache lock is poisoned".to_string())?
1252 .get(&key)
1253 .cloned();
1254 if let Some(cached) = cached {
1255 cached?
1256 } else {
1257 let built = build_current_catalog_fingerprint(domain);
1258 cache
1259 .lock()
1260 .map_err(|_| "current catalog cache lock is poisoned".to_string())?
1261 .insert(key, built.clone());
1262 built?
1263 }
1264 };
1265 let actual = compact_catalog_fingerprint(actual, domain, domain.owned_objects)?;
1266 if actual != expected {
1267 return Err(format!(
1268 "current owned catalog differs: expected {expected}, found {actual}"
1269 ));
1270 }
1271 Ok(())
1272}
1273
1274fn current_catalog_cache_key(domain: &SchemaDomain) -> String {
1279 let mut key = format!(
1280 "{}\u{1f}{}\u{1f}{:x}",
1281 domain.name,
1282 domain.supported_version(),
1283 domain.initialize_current as usize
1284 );
1285 for object in domain.owned_objects {
1286 key.push_str(&format!(
1287 "\u{1e}current:{}:{}",
1288 object.kind.sqlite_name(),
1289 object.name
1290 ));
1291 }
1292 for object in domain.retired_objects {
1293 key.push_str(&format!(
1294 "\u{1e}retired:{}:{}",
1295 object.kind.sqlite_name(),
1296 object.name
1297 ));
1298 }
1299 key
1300}
1301
1302#[derive(Debug, Default)]
1305struct CurrentCatalogDiff {
1306 missing: Vec<String>,
1307 unexpected: Vec<String>,
1308 changed: Vec<String>,
1309}
1310
1311impl CurrentCatalogDiff {
1312 fn is_empty(&self) -> bool {
1313 self.missing.is_empty() && self.unexpected.is_empty() && self.changed.is_empty()
1314 }
1315}
1316
1317fn current_catalog_diff(
1322 actual: &Connection,
1323 domain: &SchemaDomain,
1324) -> Result<CurrentCatalogDiff, String> {
1325 let mut oracle =
1326 Connection::open_in_memory().map_err(|error| format!("open current oracle: {error}"))?;
1327 let tx = oracle
1328 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1329 .map_err(|error| format!("begin current oracle: {error}"))?;
1330 (domain.initialize_current)(&tx).map_err(|error| format!("build current oracle: {error}"))?;
1331 tx.commit()
1332 .map_err(|error| format!("commit current oracle: {error}"))?;
1333 let expected = domain_catalog_entries(&oracle, domain)?;
1334 let found = domain_catalog_entries(actual, domain)?;
1335 let mut diff = CurrentCatalogDiff::default();
1336 for (object, entry) in &expected {
1337 match found.get(object) {
1338 None => diff.missing.push(object.clone()),
1339 Some(found_entry) if found_entry != entry => diff.changed.push(object.clone()),
1340 Some(_) => {}
1341 }
1342 }
1343 for object in found.keys() {
1344 if !expected.contains_key(object) {
1345 diff.unexpected.push(object.clone());
1346 }
1347 }
1348 Ok(diff)
1349}
1350
1351fn domain_catalog_entries(
1354 conn: &Connection,
1355 domain: &SchemaDomain,
1356) -> Result<BTreeMap<String, String>, String> {
1357 let names = all_domain_objects(domain)
1358 .into_iter()
1359 .map(|object| object.name)
1360 .collect::<std::collections::BTreeSet<_>>();
1361 let mut statement = conn
1362 .prepare(
1363 "SELECT type, name, tbl_name, sql
1364 FROM main.sqlite_schema
1365 WHERE name NOT LIKE 'sqlite_%'
1366 ORDER BY type, name",
1367 )
1368 .map_err(|error| error.to_string())?;
1369 let rows = statement
1370 .query_map([], |row| {
1371 Ok((
1372 row.get::<_, String>(0)?,
1373 row.get::<_, String>(1)?,
1374 row.get::<_, String>(2)?,
1375 row.get::<_, Option<String>>(3)?,
1376 ))
1377 })
1378 .map_err(|error| error.to_string())?;
1379 let mut entries = BTreeMap::new();
1380 for row in rows {
1381 let (kind, name, table_name, sql) = row.map_err(|error| error.to_string())?;
1382 if names.contains(name.as_str()) {
1383 entries.insert(
1384 format!("{kind}:{name}"),
1385 format!(
1386 "{table_name}\u{1f}{}",
1387 sql.map(|sql| normalize_schema_sql(&sql))
1388 .unwrap_or_default()
1389 ),
1390 );
1391 }
1392 }
1393 Ok(entries)
1394}
1395
1396fn build_current_catalog_fingerprint(domain: &SchemaDomain) -> Result<String, String> {
1397 let mut expected =
1398 Connection::open_in_memory().map_err(|error| format!("open current oracle: {error}"))?;
1399 let tx = expected
1400 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1401 .map_err(|error| format!("begin current oracle: {error}"))?;
1402 (domain.initialize_current)(&tx).map_err(|error| format!("build current oracle: {error}"))?;
1403 tx.commit()
1404 .map_err(|error| format!("commit current oracle: {error}"))?;
1405 compact_catalog_fingerprint(&expected, domain, domain.owned_objects)
1406}
1407
1408fn compact_catalog_fingerprint(
1409 conn: &Connection,
1410 domain: &SchemaDomain,
1411 expected_objects: &[SchemaObject],
1412) -> Result<String, String> {
1413 let all_objects = all_domain_objects(domain);
1414 let owned_by_name = all_objects
1415 .iter()
1416 .map(|object| (object.name, object))
1417 .collect::<BTreeMap<_, _>>();
1418 let current_by_name = expected_objects
1419 .iter()
1420 .map(|object| (object.name, object))
1421 .collect::<BTreeMap<_, _>>();
1422 let mut actual_names = Vec::new();
1423 let mut entries = Vec::with_capacity(expected_objects.len());
1424 let mut statement = conn
1425 .prepare(
1426 "SELECT type, name, tbl_name, sql
1427 FROM main.sqlite_schema
1428 WHERE name NOT LIKE 'sqlite_%'
1429 ORDER BY type, name",
1430 )
1431 .map_err(|error| error.to_string())?;
1432 let rows = statement
1433 .query_map([], |row| {
1434 Ok((
1435 row.get::<_, String>(0)?,
1436 row.get::<_, String>(1)?,
1437 row.get::<_, String>(2)?,
1438 row.get::<_, Option<String>>(3)?,
1439 ))
1440 })
1441 .map_err(|error| error.to_string())?;
1442 for row in rows {
1443 let (kind, name, table_name, sql) = row.map_err(|error| error.to_string())?;
1444 if owned_by_name.contains_key(name.as_str()) {
1445 actual_names.push((kind.clone(), name.clone()));
1446 }
1447 if current_by_name.contains_key(name.as_str()) {
1448 entries.push(format!(
1449 "{kind}\u{1f}{name}\u{1f}{table_name}\u{1f}{}",
1450 sql.map(|sql| normalize_schema_sql(&sql))
1451 .unwrap_or_default()
1452 ));
1453 }
1454 }
1455 actual_names.sort();
1456 let mut expected_names = expected_objects
1457 .iter()
1458 .map(|object| {
1459 (
1460 object.kind.sqlite_name().to_string(),
1461 object.name.to_string(),
1462 )
1463 })
1464 .collect::<Vec<_>>();
1465 expected_names.sort();
1466 if actual_names != expected_names {
1467 return Err(format!(
1468 "owned object set differs: expected {expected_names:?}, found {actual_names:?}"
1469 ));
1470 }
1471
1472 entries.sort();
1473 Ok(entries.join("\u{1e}"))
1474}
1475
1476fn all_domain_objects(domain: &SchemaDomain) -> Vec<SchemaObject> {
1477 domain
1478 .owned_objects
1479 .iter()
1480 .chain(domain.retired_objects)
1481 .copied()
1482 .collect()
1483}
1484
1485pub fn verify_released_schema_fingerprint(
1495 actual: &Connection,
1496 domain: &SchemaDomain,
1497 released_objects: &[SchemaObject],
1498 build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
1499) -> Result<(), String> {
1500 verify_released_schema(
1501 actual,
1502 domain,
1503 released_objects,
1504 build_released,
1505 ReleasedSchemaComparison::ExactText,
1506 )
1507}
1508
1509pub fn verify_released_schema_structure(
1526 actual: &Connection,
1527 domain: &SchemaDomain,
1528 released_objects: &[SchemaObject],
1529 build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
1530) -> Result<(), String> {
1531 verify_released_schema(
1532 actual,
1533 domain,
1534 released_objects,
1535 build_released,
1536 ReleasedSchemaComparison::StructureOnly,
1537 )
1538}
1539
1540#[derive(Clone, Copy)]
1542enum ReleasedSchemaComparison {
1543 ExactText,
1545 StructureOnly,
1547}
1548
1549fn verify_released_schema(
1550 actual: &Connection,
1551 domain: &SchemaDomain,
1552 released_objects: &[SchemaObject],
1553 build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
1554 comparison: ReleasedSchemaComparison,
1555) -> Result<(), String> {
1556 let mut expected = Connection::open_in_memory()
1557 .map_err(|error| format!("open fingerprint oracle: {error}"))?;
1558 let tx = expected
1559 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1560 .map_err(|error| format!("begin fingerprint oracle: {error}"))?;
1561 build_released(&tx).map_err(|error| format!("build fingerprint oracle: {error}"))?;
1562 tx.commit()
1563 .map_err(|error| format!("commit fingerprint oracle: {error}"))?;
1564
1565 let expected_names = catalog_names(&expected, released_objects)
1566 .map_err(|error| format!("read fingerprint oracle: {error}"))?;
1567 let mut declared_expected = released_objects
1568 .iter()
1569 .map(|object| {
1570 (
1571 object.kind.sqlite_name().to_string(),
1572 object.name.to_string(),
1573 )
1574 })
1575 .collect::<Vec<_>>();
1576 declared_expected.sort();
1577 if expected_names != declared_expected {
1578 return Err(format!(
1579 "frozen builder produced {expected_names:?}, manifest declares {declared_expected:?}"
1580 ));
1581 }
1582
1583 let actual_names = catalog_names(actual, &all_domain_objects(domain))
1584 .map_err(|error| format!("read actual catalog: {error}"))?;
1585 if actual_names != declared_expected {
1586 return Err(format!(
1587 "owned object set differs: expected {declared_expected:?}, found {actual_names:?}"
1588 ));
1589 }
1590
1591 for object in released_objects {
1592 let mut wanted = catalog_fingerprint(&expected, object)
1593 .map_err(|error| format!("fingerprint oracle {}: {error}", object.name))?;
1594 let mut found = catalog_fingerprint(actual, object)
1595 .map_err(|error| format!("fingerprint actual {}: {error}", object.name))?;
1596 if matches!(comparison, ReleasedSchemaComparison::StructureOnly) {
1597 wanted.normalized_sql = None;
1598 found.normalized_sql = None;
1599 }
1600 if found != wanted {
1601 return Err(format!(
1602 "object `{}` differs: expected {wanted:?}, found {found:?}",
1603 object.name
1604 ));
1605 }
1606 }
1607 Ok(())
1608}
1609
1610#[derive(Debug, PartialEq, Eq)]
1611struct CatalogObjectFingerprint {
1612 kind: String,
1613 name: String,
1614 table_name: String,
1615 normalized_sql: Option<String>,
1616 table_columns: Vec<TableColumnFingerprint>,
1617 foreign_keys: Vec<ForeignKeyFingerprint>,
1618 index: Option<IndexFingerprint>,
1619}
1620
1621#[derive(Debug, PartialEq, Eq)]
1622struct TableColumnFingerprint {
1623 cid: i64,
1624 name: String,
1625 declared_type: String,
1626 not_null: bool,
1627 default_value: Option<String>,
1628 primary_key_position: i64,
1629 hidden: i64,
1630}
1631
1632#[derive(Debug, PartialEq, Eq)]
1633struct ForeignKeyFingerprint {
1634 id: i64,
1635 sequence: i64,
1636 target_table: String,
1637 from_column: String,
1638 to_column: Option<String>,
1639 on_update: String,
1640 on_delete: String,
1641 match_clause: String,
1642}
1643
1644#[derive(Debug, PartialEq, Eq)]
1645struct IndexFingerprint {
1646 unique: bool,
1647 origin: String,
1648 partial: bool,
1649 columns: Vec<IndexColumnFingerprint>,
1650}
1651
1652#[derive(Debug, PartialEq, Eq)]
1653struct IndexColumnFingerprint {
1654 sequence: i64,
1655 column_id: i64,
1656 name: Option<String>,
1657 descending: bool,
1658 collation: Option<String>,
1659 key: bool,
1660}
1661
1662fn catalog_names(
1663 conn: &Connection,
1664 objects: &[SchemaObject],
1665) -> Result<Vec<(String, String)>, rusqlite::Error> {
1666 let mut found = Vec::new();
1667 let mut statement = conn.prepare(
1668 "SELECT type, name FROM main.sqlite_schema
1669 WHERE name = ?1 AND name NOT LIKE 'sqlite_%'
1670 ORDER BY type, name",
1671 )?;
1672 for object in objects {
1673 let rows = statement.query_map([object.name], |row| Ok((row.get(0)?, row.get(1)?)))?;
1674 found.extend(rows.collect::<Result<Vec<_>, _>>()?);
1675 }
1676 found.sort();
1677 found.dedup();
1678 Ok(found)
1679}
1680
1681fn catalog_fingerprint(
1682 conn: &Connection,
1683 object: &SchemaObject,
1684) -> Result<CatalogObjectFingerprint, rusqlite::Error> {
1685 let (kind, name, table_name, sql): (String, String, String, Option<String>) = conn.query_row(
1686 "SELECT type, name, tbl_name, sql FROM main.sqlite_schema WHERE name = ?1",
1687 [object.name],
1688 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1689 )?;
1690 let (table_columns, foreign_keys) = if kind == "table" || kind == "view" {
1691 (
1692 table_columns(conn, object.name)?,
1693 foreign_keys(conn, object.name)?,
1694 )
1695 } else {
1696 (Vec::new(), Vec::new())
1697 };
1698 let index = if kind == "index" {
1699 Some(index_fingerprint(conn, &table_name, object.name)?)
1700 } else {
1701 None
1702 };
1703 Ok(CatalogObjectFingerprint {
1704 kind,
1705 name,
1706 table_name,
1707 normalized_sql: sql.map(|sql| normalize_schema_sql(&sql)),
1708 table_columns,
1709 foreign_keys,
1710 index,
1711 })
1712}
1713
1714fn table_columns(
1715 conn: &Connection,
1716 table: &str,
1717) -> Result<Vec<TableColumnFingerprint>, rusqlite::Error> {
1718 let mut statement = conn.prepare(
1719 "SELECT cid, name, type, \"notnull\", dflt_value, pk, hidden
1720 FROM pragma_table_xinfo(?1, 'main')
1721 ORDER BY cid",
1722 )?;
1723 let rows = statement.query_map([table], |row| {
1724 Ok(TableColumnFingerprint {
1725 cid: row.get(0)?,
1726 name: row.get(1)?,
1727 declared_type: row.get(2)?,
1728 not_null: row.get(3)?,
1729 default_value: row.get(4)?,
1730 primary_key_position: row.get(5)?,
1731 hidden: row.get(6)?,
1732 })
1733 })?;
1734 rows.collect()
1735}
1736
1737fn foreign_keys(
1738 conn: &Connection,
1739 table: &str,
1740) -> Result<Vec<ForeignKeyFingerprint>, rusqlite::Error> {
1741 let mut statement = conn.prepare(
1742 "SELECT id, seq, \"table\", \"from\", \"to\", on_update, on_delete, \"match\"
1743 FROM pragma_foreign_key_list(?1, 'main')
1744 ORDER BY id, seq",
1745 )?;
1746 let rows = statement.query_map([table], |row| {
1747 Ok(ForeignKeyFingerprint {
1748 id: row.get(0)?,
1749 sequence: row.get(1)?,
1750 target_table: row.get(2)?,
1751 from_column: row.get(3)?,
1752 to_column: row.get(4)?,
1753 on_update: row.get(5)?,
1754 on_delete: row.get(6)?,
1755 match_clause: row.get(7)?,
1756 })
1757 })?;
1758 rows.collect()
1759}
1760
1761fn index_fingerprint(
1762 conn: &Connection,
1763 table: &str,
1764 index: &str,
1765) -> Result<IndexFingerprint, rusqlite::Error> {
1766 let (unique, origin, partial): (bool, String, bool) = conn.query_row(
1767 "SELECT \"unique\", origin, partial
1768 FROM pragma_index_list(?1, 'main')
1769 WHERE name = ?2",
1770 rusqlite::params![table, index],
1771 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1772 )?;
1773 let mut statement = conn.prepare(
1774 "SELECT seqno, cid, name, desc, coll, key
1775 FROM pragma_index_xinfo(?1, 'main')
1776 ORDER BY seqno",
1777 )?;
1778 let columns = statement
1779 .query_map([index], |row| {
1780 Ok(IndexColumnFingerprint {
1781 sequence: row.get(0)?,
1782 column_id: row.get(1)?,
1783 name: row.get(2)?,
1784 descending: row.get(3)?,
1785 collation: row.get(4)?,
1786 key: row.get(5)?,
1787 })
1788 })?
1789 .collect::<Result<Vec<_>, _>>()?;
1790 Ok(IndexFingerprint {
1791 unique,
1792 origin,
1793 partial,
1794 columns,
1795 })
1796}
1797
1798fn normalize_schema_sql(sql: &str) -> String {
1799 #[derive(Clone, Copy)]
1800 enum LexState {
1801 Normal,
1802 SingleQuoted,
1803 DoubleQuoted,
1804 BacktickQuoted,
1805 BracketQuoted,
1806 LineComment,
1807 BlockComment,
1808 }
1809
1810 let bytes = sql.as_bytes();
1811 let mut collapsed = Vec::with_capacity(bytes.len());
1812 let mut state = LexState::Normal;
1813 let mut index = 0;
1814 while index < bytes.len() {
1815 let byte = bytes[index];
1816 match state {
1817 LexState::Normal => {
1818 if byte.is_ascii_whitespace() {
1819 if collapsed.last().is_some_and(|last| *last != b' ') {
1820 collapsed.push(b' ');
1821 }
1822 } else {
1823 collapsed.push(byte);
1824 state = match byte {
1825 b'\'' => LexState::SingleQuoted,
1826 b'"' => LexState::DoubleQuoted,
1827 b'`' => LexState::BacktickQuoted,
1828 b'[' => LexState::BracketQuoted,
1829 b'-' if bytes.get(index + 1) == Some(&b'-') => LexState::LineComment,
1830 b'/' if bytes.get(index + 1) == Some(&b'*') => LexState::BlockComment,
1831 _ => LexState::Normal,
1832 };
1833 }
1834 }
1835 LexState::SingleQuoted | LexState::DoubleQuoted | LexState::BacktickQuoted => {
1836 collapsed.push(byte);
1837 let delimiter = match state {
1838 LexState::SingleQuoted => b'\'',
1839 LexState::DoubleQuoted => b'"',
1840 LexState::BacktickQuoted => b'`',
1841 _ => unreachable!(),
1842 };
1843 if byte == delimiter {
1844 if bytes.get(index + 1) == Some(&delimiter) {
1845 index += 1;
1846 collapsed.push(delimiter);
1847 } else {
1848 state = LexState::Normal;
1849 }
1850 }
1851 }
1852 LexState::BracketQuoted => {
1853 collapsed.push(byte);
1854 if byte == b']' {
1855 if bytes.get(index + 1) == Some(&b']') {
1856 index += 1;
1857 collapsed.push(b']');
1858 } else {
1859 state = LexState::Normal;
1860 }
1861 }
1862 }
1863 LexState::LineComment => {
1864 collapsed.push(byte);
1865 if byte == b'\n' || byte == b'\r' {
1866 state = LexState::Normal;
1867 }
1868 }
1869 LexState::BlockComment => {
1870 collapsed.push(byte);
1871 if byte == b'*' && bytes.get(index + 1) == Some(&b'/') {
1872 index += 1;
1873 collapsed.push(b'/');
1874 state = LexState::Normal;
1875 }
1876 }
1877 }
1878 index += 1;
1879 }
1880
1881 const IF_NOT_EXISTS: &[u8] = b"IF NOT EXISTS";
1882 let mut normalized = Vec::with_capacity(collapsed.len());
1883 let mut state = LexState::Normal;
1884 let mut index = 0;
1885 while index < collapsed.len() {
1886 let byte = collapsed[index];
1887 if matches!(state, LexState::Normal)
1888 && collapsed
1889 .get(index..index + IF_NOT_EXISTS.len())
1890 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(IF_NOT_EXISTS))
1891 && (index == 0 || !is_sql_identifier_byte(collapsed[index - 1]))
1892 && collapsed
1893 .get(index + IF_NOT_EXISTS.len())
1894 .is_none_or(|after| !is_sql_identifier_byte(*after))
1895 {
1896 index += IF_NOT_EXISTS.len();
1897 if collapsed.get(index) == Some(&b' ') {
1898 index += 1;
1899 }
1900 continue;
1901 }
1902 normalized.push(byte);
1903 match state {
1904 LexState::Normal => {
1905 state = match byte {
1906 b'\'' => LexState::SingleQuoted,
1907 b'"' => LexState::DoubleQuoted,
1908 b'`' => LexState::BacktickQuoted,
1909 b'[' => LexState::BracketQuoted,
1910 b'-' if collapsed.get(index + 1) == Some(&b'-') => LexState::LineComment,
1911 b'/' if collapsed.get(index + 1) == Some(&b'*') => LexState::BlockComment,
1912 _ => LexState::Normal,
1913 };
1914 }
1915 LexState::SingleQuoted | LexState::DoubleQuoted | LexState::BacktickQuoted => {
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 collapsed.get(index + 1) == Some(&delimiter) {
1924 index += 1;
1925 normalized.push(delimiter);
1926 } else {
1927 state = LexState::Normal;
1928 }
1929 }
1930 }
1931 LexState::BracketQuoted => {
1932 if byte == b']' {
1933 if collapsed.get(index + 1) == Some(&b']') {
1934 index += 1;
1935 normalized.push(b']');
1936 } else {
1937 state = LexState::Normal;
1938 }
1939 }
1940 }
1941 LexState::LineComment => {
1942 if byte == b'\n' || byte == b'\r' {
1943 state = LexState::Normal;
1944 }
1945 }
1946 LexState::BlockComment => {
1947 if byte == b'*' && collapsed.get(index + 1) == Some(&b'/') {
1948 index += 1;
1949 normalized.push(b'/');
1950 state = LexState::Normal;
1951 }
1952 }
1953 }
1954 index += 1;
1955 }
1956 String::from_utf8(normalized).unwrap_or_else(|_| sql.to_string())
1957}
1958
1959fn is_sql_identifier_byte(byte: u8) -> bool {
1960 byte.is_ascii_alphanumeric() || byte == b'_'
1961}
1962
1963fn unsupported_predecessor(domain: &SchemaDomain, found: i64) -> SqliteStoreError {
1964 SqliteStoreError::UnsupportedSchemaPredecessor {
1965 domain: domain.name.to_string(),
1966 found,
1967 supported: domain.supported_version(),
1968 allowed: domain.allowed_existing_versions.to_vec(),
1969 }
1970}
1971
1972fn find_owned_objects(
1977 conn: &Connection,
1978 domain: &SchemaDomain,
1979) -> Result<Vec<String>, SqliteStoreError> {
1980 let mut found = Vec::new();
1981 let mut stmt = conn.prepare(
1982 "SELECT type, name FROM main.sqlite_schema
1983 WHERE name = ?1 AND name NOT LIKE 'sqlite_%'
1984 ORDER BY type, name",
1985 )?;
1986 for expected in domain.owned_objects.iter().chain(domain.retired_objects) {
1987 let rows = stmt.query_map([expected.name], |row| {
1988 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1989 })?;
1990 for row in rows {
1991 let (actual_kind, name) = row?;
1992 found.push(format!(
1993 "{actual_kind}:{name} (expected {})",
1994 expected.kind.sqlite_name()
1995 ));
1996 }
1997 }
1998 found.sort();
1999 found.dedup();
2000 Ok(found)
2001}
2002
2003fn ledger_table_exists(conn: &Connection) -> Result<bool, SqliteStoreError> {
2004 let exists = conn
2005 .query_row(
2006 "SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'meerkat_schema'",
2007 [],
2008 |_| Ok(()),
2009 )
2010 .optional()?
2011 .is_some();
2012 Ok(exists)
2013}
2014
2015fn malformed(detail: String) -> SqliteStoreError {
2016 SqliteStoreError::LedgerMalformed { detail }
2017}
2018
2019fn validate_ledger_shape(conn: &Connection) -> Result<(), SqliteStoreError> {
2028 let mut stmt = conn.prepare(
2029 "SELECT name, type, \"notnull\", pk FROM pragma_table_info('meerkat_schema', 'main')",
2030 )?;
2031 let mut rows = stmt.query([])?;
2032 let mut domain_ok = false;
2033 let mut version_ok = false;
2034 while let Some(row) = rows.next()? {
2035 let name: String = row.get(0)?;
2036 let decl_type: String = row.get(1)?;
2037 let notnull: bool = row.get(2)?;
2038 let pk: i64 = row.get(3)?;
2039 match name.as_str() {
2040 "domain" => {
2041 if !decl_type.eq_ignore_ascii_case("TEXT") || pk != 1 {
2042 return Err(malformed(format!(
2043 "column `domain` must be `TEXT PRIMARY KEY`, found type `{decl_type}` \
2044 with pk position {pk}"
2045 )));
2046 }
2047 domain_ok = true;
2048 }
2049 "version" => {
2050 if !decl_type.eq_ignore_ascii_case("INTEGER") || !notnull || pk != 0 {
2051 return Err(malformed(format!(
2052 "column `version` must be non-key `INTEGER NOT NULL`, found type \
2053 `{decl_type}` notnull={notnull} pk position {pk}"
2054 )));
2055 }
2056 version_ok = true;
2057 }
2058 other => {
2059 if pk != 0 {
2060 return Err(malformed(format!(
2061 "unexpected primary-key column `{other}`"
2062 )));
2063 }
2064 }
2065 }
2066 }
2067 if !domain_ok || !version_ok {
2068 return Err(malformed(
2069 "table lacks the pinned `domain`/`version` columns".to_string(),
2070 ));
2071 }
2072 let mut trigger_stmt = conn.prepare(
2073 "SELECT 'main', name FROM main.sqlite_schema
2074 WHERE type = 'trigger' AND tbl_name = 'meerkat_schema' COLLATE NOCASE
2075 UNION ALL
2076 SELECT 'temp', name FROM temp.sqlite_schema
2077 WHERE type = 'trigger' AND tbl_name = 'meerkat_schema' COLLATE NOCASE
2078 ORDER BY 1, 2",
2079 )?;
2080 let triggers = trigger_stmt
2081 .query_map([], |row| {
2082 Ok(format!(
2083 "{}.{}",
2084 row.get::<_, String>(0)?,
2085 row.get::<_, String>(1)?
2086 ))
2087 })?
2088 .collect::<Result<Vec<_>, _>>()?;
2089 if !triggers.is_empty() {
2090 return Err(malformed(format!(
2091 "table has attached triggers {triggers:?}; ledger writes must be isolated"
2092 )));
2093 }
2094 Ok(())
2095}
2096
2097fn read_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
2104 let mut stmt = conn.prepare("SELECT version FROM main.meerkat_schema WHERE domain = ?1")?;
2105 let mut rows = stmt.query([domain])?;
2106 let Some(row) = rows.next()? else {
2107 return Ok(None);
2108 };
2109 let version: i64 = row.get(0)?;
2110 if rows.next()?.is_some() {
2111 return Err(malformed(format!(
2112 "multiple ledger rows for domain `{domain}`"
2113 )));
2114 }
2115 if version <= 0 {
2116 return Err(malformed(format!(
2117 "domain `{domain}` records non-positive version {version}"
2118 )));
2119 }
2120 Ok(Some(version))
2121}
2122
2123#[cfg(test)]
2124#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
2125mod tests {
2126 use super::*;
2127 use crate::profile::{ConnectionProfile, open};
2128
2129 #[test]
2130 fn catalog_lookup_keeps_unprepared_and_poisoned_apart() {
2131 assert_eq!(
2132 try_cached_current_catalog(None, "domain"),
2133 Err(CurrentCatalogUnavailability::NotPrepared)
2134 );
2135 let cache = CurrentCatalogCache::default();
2136 assert_eq!(
2137 try_cached_current_catalog(Some(&cache), "domain"),
2138 Err(CurrentCatalogUnavailability::NotPrepared)
2139 );
2140 cache
2141 .lock()
2142 .unwrap()
2143 .insert("domain".to_string(), Ok("catalog".to_string()));
2144 assert_eq!(
2145 try_cached_current_catalog(Some(&cache), "domain"),
2146 Ok(Ok("catalog".to_string()))
2147 );
2148 let poisoned = std::sync::Arc::new(CurrentCatalogCache::default());
2149 let holder = std::sync::Arc::clone(&poisoned);
2150 let _ = std::thread::spawn(move || {
2151 let _held = holder.lock().unwrap();
2152 panic!("poison the catalog cache");
2153 })
2154 .join();
2155 assert_eq!(
2156 try_cached_current_catalog(Some(&poisoned), "domain"),
2157 Err(CurrentCatalogUnavailability::Poisoned)
2158 );
2159 }
2160
2161 fn create_t1(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2162 tx.execute_batch("CREATE TABLE IF NOT EXISTS t1 (x INTEGER)")
2163 }
2164
2165 fn add_column_guarded(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2166 let has_column = tx
2167 .prepare("PRAGMA table_info(t1)")?
2168 .query_map([], |row| row.get::<_, String>(1))?
2169 .collect::<Result<Vec<_>, _>>()?
2170 .iter()
2171 .any(|name| name == "y");
2172 if !has_column {
2173 tx.execute_batch("ALTER TABLE t1 ADD COLUMN y TEXT")?;
2174 }
2175 Ok(())
2176 }
2177
2178 fn initialize_v2(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2179 create_t1(tx)?;
2180 add_column_guarded(tx)
2181 }
2182
2183 fn initialize_v2_alt(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2184 tx.execute_batch("CREATE TABLE t1 (x INTEGER, z BLOB)")
2185 }
2186
2187 const RELEASED_V1_OBJECTS: &[SchemaObject] = &[SchemaObject {
2188 kind: SchemaObjectKind::Table,
2189 name: "t1",
2190 }];
2191
2192 fn verify_v1(conn: &Connection) -> Result<(), String> {
2193 verify_released_schema_fingerprint(conn, &DOMAIN_V2, RELEASED_V1_OBJECTS, create_t1)
2194 }
2195
2196 const DOMAIN_V1: SchemaDomain = SchemaDomain {
2197 name: "test-domain",
2198 migrations: &[Migration {
2199 version: 1,
2200 name: "base",
2201 apply: create_t1,
2202 }],
2203 initialize_current: create_t1,
2204 allowed_existing_versions: &[1],
2205 bridge_recoverable_versions: &[1],
2206 released_predecessors: &[],
2207 owned_objects: &[SchemaObject {
2208 kind: SchemaObjectKind::Table,
2209 name: "t1",
2210 }],
2211 retired_objects: &[],
2212 };
2213
2214 const DOMAIN_V2: SchemaDomain = SchemaDomain {
2215 name: "test-domain",
2216 migrations: &[
2217 Migration {
2218 version: 1,
2219 name: "base",
2220 apply: create_t1,
2221 },
2222 Migration {
2223 version: 2,
2224 name: "add-y",
2225 apply: add_column_guarded,
2226 },
2227 ],
2228 initialize_current: initialize_v2,
2229 allowed_existing_versions: &[1, 2],
2230 bridge_recoverable_versions: &[1],
2231 released_predecessors: &[SchemaPredecessor {
2232 version: 1,
2233 verify: verify_v1,
2234 }],
2235 owned_objects: &[SchemaObject {
2236 kind: SchemaObjectKind::Table,
2237 name: "t1",
2238 }],
2239 retired_objects: &[],
2240 };
2241
2242 const DOMAIN_V2_ALT_INITIALIZER: SchemaDomain = SchemaDomain {
2243 name: "test-domain",
2244 migrations: &[
2245 Migration {
2246 version: 1,
2247 name: "base",
2248 apply: create_t1,
2249 },
2250 Migration {
2251 version: 2,
2252 name: "alt-current",
2253 apply: add_column_guarded,
2254 },
2255 ],
2256 initialize_current: initialize_v2_alt,
2257 allowed_existing_versions: &[2],
2258 bridge_recoverable_versions: &[1],
2259 released_predecessors: &[],
2260 owned_objects: &[SchemaObject {
2261 kind: SchemaObjectKind::Table,
2262 name: "t1",
2263 }],
2264 retired_objects: &[],
2265 };
2266
2267 fn temp_conn(dir: &tempfile::TempDir) -> Connection {
2268 open(&dir.path().join("db.sqlite3"), ConnectionProfile::PRIMARY).expect("open")
2269 }
2270
2271 #[test]
2272 fn structure_verifier_ignores_create_text_but_rejects_shape_drift() {
2273 const STRUCTURE_OBJECTS: &[SchemaObject] = &[
2274 SchemaObject {
2275 kind: SchemaObjectKind::Table,
2276 name: "t1",
2277 },
2278 SchemaObject {
2279 kind: SchemaObjectKind::Index,
2280 name: "t1_idx",
2281 },
2282 ];
2283 fn build_frozen(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2284 tx.execute_batch(
2285 "CREATE TABLE IF NOT EXISTS t1 (x INTEGER NOT NULL);
2286 CREATE INDEX IF NOT EXISTS t1_idx ON t1(x DESC);",
2287 )
2288 }
2289 const STRUCTURE_DOMAIN: SchemaDomain = SchemaDomain {
2290 name: "structure-domain",
2291 migrations: &[Migration {
2292 version: 1,
2293 name: "base",
2294 apply: build_frozen,
2295 }],
2296 initialize_current: build_frozen,
2297 allowed_existing_versions: &[1],
2298 bridge_recoverable_versions: &[1],
2299 released_predecessors: &[],
2300 owned_objects: STRUCTURE_OBJECTS,
2301 retired_objects: &[],
2302 };
2303 let make = |ddl: &str| {
2304 let conn = Connection::open_in_memory().expect("in-memory fixture");
2305 conn.execute_batch(ddl).expect("fixture ddl");
2306 conn
2307 };
2308
2309 let quoted = make(
2313 "CREATE TABLE \"t1\" (x INTEGER NOT NULL);
2314 CREATE INDEX t1_idx ON t1(x DESC);",
2315 );
2316 verify_released_schema_structure(
2317 "ed,
2318 &STRUCTURE_DOMAIN,
2319 STRUCTURE_OBJECTS,
2320 build_frozen,
2321 )
2322 .expect("CREATE-text drift alone must pass the structural verifier");
2323 verify_released_schema_fingerprint(
2324 "ed,
2325 &STRUCTURE_DOMAIN,
2326 STRUCTURE_OBJECTS,
2327 build_frozen,
2328 )
2329 .expect_err("the exact-text verifier must still reject the same catalog");
2330
2331 let wrong_type = make(
2332 "CREATE TABLE t1 (x TEXT NOT NULL);
2333 CREATE INDEX t1_idx ON t1(x DESC);",
2334 );
2335 verify_released_schema_structure(
2336 &wrong_type,
2337 &STRUCTURE_DOMAIN,
2338 STRUCTURE_OBJECTS,
2339 build_frozen,
2340 )
2341 .expect_err("column type drift must fail structurally");
2342
2343 let wrong_index = make(
2344 "CREATE TABLE t1 (x INTEGER NOT NULL);
2345 CREATE INDEX t1_idx ON t1(x ASC);",
2346 );
2347 verify_released_schema_structure(
2348 &wrong_index,
2349 &STRUCTURE_DOMAIN,
2350 STRUCTURE_OBJECTS,
2351 build_frozen,
2352 )
2353 .expect_err("index direction drift must fail structurally");
2354
2355 let missing_index = make("CREATE TABLE t1 (x INTEGER NOT NULL);");
2356 verify_released_schema_structure(
2357 &missing_index,
2358 &STRUCTURE_DOMAIN,
2359 STRUCTURE_OBJECTS,
2360 build_frozen,
2361 )
2362 .expect_err("a missing owned index must fail the object-set check");
2363 }
2364
2365 #[test]
2366 fn fresh_file_initializes_current_and_stamps() {
2367 let dir = tempfile::tempdir().expect("tempdir");
2368 let mut conn = temp_conn(&dir);
2369 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply");
2370 assert_eq!(
2371 report,
2372 LedgerReport {
2373 from_version: 0,
2374 to_version: 2
2375 }
2376 );
2377 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
2378 conn.execute("INSERT INTO t1 (x, y) VALUES (1, 'a')", [])
2379 .expect("schema converged");
2380 }
2381
2382 #[test]
2383 fn second_open_is_current_noop() {
2384 let dir = tempfile::tempdir().expect("tempdir");
2385 let mut conn = temp_conn(&dir);
2386 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("first");
2387 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("second");
2388 assert!(!report.migrated());
2389 }
2390
2391 #[test]
2392 fn current_oracle_cache_binds_initializer_and_manifest_not_only_name_version() {
2393 let first_dir = tempfile::tempdir().expect("first tempdir");
2394 let mut first = temp_conn(&first_dir);
2395 apply_domain_migrations(&mut first, &DOMAIN_V2).expect("first current");
2396
2397 let second_dir = tempfile::tempdir().expect("second tempdir");
2398 let mut second = temp_conn(&second_dir);
2399 apply_domain_migrations(&mut second, &DOMAIN_V2_ALT_INITIALIZER).expect("alt current");
2400 let columns: Vec<String> = second
2401 .prepare("PRAGMA table_info(t1)")
2402 .expect("prepare")
2403 .query_map([], |row| row.get(1))
2404 .expect("query")
2405 .collect::<Result<_, _>>()
2406 .expect("columns");
2407 assert_eq!(columns, vec!["x", "z"]);
2408 }
2409
2410 #[test]
2411 fn current_row_with_partial_catalog_is_refused_before_noop() {
2412 let dir = tempfile::tempdir().expect("tempdir");
2413 let mut conn = temp_conn(&dir);
2414 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("current");
2415 conn.execute_batch("ALTER TABLE t1 ADD COLUMN candidate_partial TEXT")
2416 .expect("partial candidate mutation");
2417 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse current shape");
2418 match err {
2419 SqliteStoreError::CurrentSchemaMismatch {
2420 version: 2,
2421 missing_objects,
2422 unexpected_objects,
2423 changed_objects,
2424 ..
2425 } => {
2426 assert!(missing_objects.is_empty(), "{missing_objects:?}");
2427 assert!(unexpected_objects.is_empty(), "{unexpected_objects:?}");
2428 assert_eq!(changed_objects, vec!["table:t1".to_string()]);
2429 }
2430 other => panic!("expected a typed current-schema mismatch, got {other:?}"),
2431 }
2432 assert_eq!(
2433 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2434 Some(2)
2435 );
2436 }
2437
2438 #[test]
2439 fn current_row_without_its_owned_objects_names_them() {
2440 let dir = tempfile::tempdir().expect("tempdir");
2443 let mut conn = temp_conn(&dir);
2444 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("current");
2445 conn.execute_batch("DROP TABLE t1; CREATE TABLE unrelated_candidate (x INTEGER)")
2446 .expect("foreign-shaped current");
2447 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse current row");
2448 match err {
2449 SqliteStoreError::CurrentSchemaMismatch {
2450 version: 2,
2451 missing_objects,
2452 unexpected_objects,
2453 changed_objects,
2454 ..
2455 } => {
2456 assert_eq!(missing_objects, vec!["table:t1".to_string()]);
2457 assert!(unexpected_objects.is_empty(), "{unexpected_objects:?}");
2459 assert!(changed_objects.is_empty(), "{changed_objects:?}");
2460 }
2461 other => panic!("expected a typed current-schema mismatch, got {other:?}"),
2462 }
2463 }
2464
2465 #[test]
2466 fn upgrade_applies_only_pending() {
2467 let dir = tempfile::tempdir().expect("tempdir");
2468 let mut conn = temp_conn(&dir);
2469 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
2470 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("v2");
2471 assert_eq!(
2472 report,
2473 LedgerReport {
2474 from_version: 1,
2475 to_version: 2
2476 }
2477 );
2478 }
2479
2480 #[test]
2481 fn allowed_version_with_wrong_catalog_is_refused_without_migration() {
2482 let dir = tempfile::tempdir().expect("tempdir");
2483 let mut conn = temp_conn(&dir);
2484 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
2485 conn.execute_batch("ALTER TABLE t1 ADD COLUMN candidate_only TEXT")
2486 .expect("candidate shape");
2487 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse fingerprint");
2488 assert!(matches!(
2489 err,
2490 SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
2491 ));
2492 assert_eq!(
2493 domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
2494 Some(1),
2495 "fingerprint refusal advanced the ledger"
2496 );
2497 let columns: Vec<String> = conn
2498 .prepare("PRAGMA table_info(t1)")
2499 .expect("prepare")
2500 .query_map([], |row| row.get(1))
2501 .expect("query")
2502 .collect::<Result<_, _>>()
2503 .expect("columns");
2504 assert_eq!(columns, vec!["x", "candidate_only"]);
2505 }
2506
2507 #[test]
2508 fn pre_floor_and_gap_versions_are_refused_without_mutation() {
2509 fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2510 Ok(())
2511 }
2512 fn verify_v2(_conn: &Connection) -> Result<(), String> {
2513 Ok(())
2514 }
2515 const DOMAIN_V3_FLOOR_2: SchemaDomain = SchemaDomain {
2516 name: "floor-domain",
2517 migrations: &[
2518 Migration {
2519 version: 1,
2520 name: "base",
2521 apply: no_op,
2522 },
2523 Migration {
2524 version: 2,
2525 name: "released-floor",
2526 apply: no_op,
2527 },
2528 Migration {
2529 version: 3,
2530 name: "current",
2531 apply: no_op,
2532 },
2533 ],
2534 initialize_current: no_op,
2535 allowed_existing_versions: &[2, 3],
2536 bridge_recoverable_versions: &[1],
2537 released_predecessors: &[SchemaPredecessor {
2538 version: 2,
2539 verify: verify_v2,
2540 }],
2541 owned_objects: &[],
2542 retired_objects: &[],
2543 };
2544 const DOMAIN_V4_GAP_3: SchemaDomain = SchemaDomain {
2545 name: "gap-domain",
2546 migrations: &[
2547 Migration {
2548 version: 1,
2549 name: "old",
2550 apply: no_op,
2551 },
2552 Migration {
2553 version: 2,
2554 name: "released-floor",
2555 apply: no_op,
2556 },
2557 Migration {
2558 version: 3,
2559 name: "unreleased-candidate",
2560 apply: no_op,
2561 },
2562 Migration {
2563 version: 4,
2564 name: "current",
2565 apply: no_op,
2566 },
2567 ],
2568 initialize_current: no_op,
2569 allowed_existing_versions: &[2, 4],
2570 bridge_recoverable_versions: &[1],
2571 released_predecessors: &[SchemaPredecessor {
2572 version: 2,
2573 verify: verify_v2,
2574 }],
2575 owned_objects: &[],
2576 retired_objects: &[],
2577 };
2578 let dir = tempfile::tempdir().expect("tempdir");
2579 let mut conn = temp_conn(&dir);
2580 conn.execute_batch(CREATE_LEDGER_SQL).expect("ledger");
2581 conn.execute(
2582 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 1)",
2583 [DOMAIN_V3_FLOOR_2.name],
2584 )
2585 .expect("pre-floor row");
2586 let err =
2587 apply_domain_migrations(&mut conn, &DOMAIN_V3_FLOOR_2).expect_err("refuse pre-floor");
2588 assert!(matches!(
2589 err,
2590 SqliteStoreError::UnsupportedSchemaPredecessor { found: 1, .. }
2591 ));
2592 assert_eq!(
2593 domain_version(&conn, DOMAIN_V3_FLOOR_2.name).expect("ledger"),
2594 Some(1)
2595 );
2596
2597 conn.execute(
2598 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 3)",
2599 [DOMAIN_V4_GAP_3.name],
2600 )
2601 .expect("gap row");
2602 let err = apply_domain_migrations(&mut conn, &DOMAIN_V4_GAP_3).expect_err("refuse gap");
2603 assert!(matches!(
2604 err,
2605 SqliteStoreError::UnsupportedSchemaPredecessor { found: 3, .. }
2606 ));
2607 assert_eq!(
2608 domain_version(&conn, DOMAIN_V4_GAP_3.name).expect("ledger"),
2609 Some(3)
2610 );
2611 }
2612
2613 #[test]
2614 fn unledgered_owned_objects_are_refused_without_mutation() {
2615 let dir = tempfile::tempdir().expect("tempdir");
2616 let mut conn = temp_conn(&dir);
2617 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2618 .expect("unknown unledgered ddl");
2619 let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse");
2620 assert!(matches!(
2621 err,
2622 SqliteStoreError::UnledgeredDomainObjects { .. }
2623 ));
2624 assert!(
2625 !ledger_table_exists(&conn).expect("ledger presence"),
2626 "eligibility refusal must not create the ledger"
2627 );
2628 let columns: Vec<String> = conn
2629 .prepare("PRAGMA table_info(t1)")
2630 .expect("prepare")
2631 .query_map([], |row| row.get(1))
2632 .expect("query")
2633 .collect::<Result<_, _>>()
2634 .expect("columns");
2635 assert_eq!(columns, vec!["x"], "refusal mutated unknown schema");
2636 }
2637
2638 #[test]
2639 fn maintenance_bridge_authenticates_exact_v1_and_migrates_to_v2() {
2640 let dir = tempfile::tempdir().expect("tempdir");
2641 let mut conn = temp_conn(&dir);
2642 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2643 .expect("historical unledgered v1");
2644
2645 let report =
2646 bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None).expect("bridge");
2647 assert_eq!(
2648 report,
2649 MaintenanceBridgeReport {
2650 from_version: 1,
2651 to_version: 2,
2652 prepared: 0,
2653 refused: Vec::new(),
2654 }
2655 );
2656 assert_eq!(
2657 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2658 Some(2)
2659 );
2660 let columns = conn
2661 .prepare("PRAGMA table_info(t1)")
2662 .expect("prepare")
2663 .query_map([], |row| row.get::<_, String>(1))
2664 .expect("query")
2665 .collect::<Result<Vec<_>, _>>()
2666 .expect("columns");
2667 assert_eq!(columns, vec!["x", "y"]);
2668
2669 let second = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
2670 .expect("idempotent bridge");
2671 assert_eq!(
2672 second,
2673 MaintenanceBridgeReport {
2674 from_version: 2,
2675 to_version: 2,
2676 prepared: 0,
2677 refused: Vec::new(),
2678 }
2679 );
2680 }
2681
2682 #[test]
2683 fn maintenance_bridge_prepares_and_upgrades_existing_v1_row() {
2684 fn normalize_existing(
2685 tx: &Transaction<'_>,
2686 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2687 let changed = tx.execute("UPDATE t1 SET x = x + 1", [])?;
2688 Ok(MaintenancePrepareReport::rewrote(changed))
2689 }
2690
2691 let dir = tempfile::tempdir().expect("tempdir");
2692 let mut conn = temp_conn(&dir);
2693 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("ledgered v1");
2694 conn.execute("INSERT INTO t1 (x) VALUES (7)", [])
2695 .expect("historical data");
2696
2697 let report =
2698 bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(normalize_existing))
2699 .expect("bridge ledgered predecessor");
2700 assert_eq!(
2701 report,
2702 MaintenanceBridgeReport {
2703 from_version: 1,
2704 to_version: 2,
2705 prepared: 1,
2706 refused: Vec::new(),
2707 }
2708 );
2709 assert_eq!(
2710 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2711 Some(2)
2712 );
2713 assert_eq!(
2714 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2715 .expect("prepared row"),
2716 8
2717 );
2718 conn.execute("INSERT INTO t1 (x, y) VALUES (9, 'migrated')", [])
2719 .expect("v2 shape");
2720 }
2721
2722 #[test]
2723 fn maintenance_bridge_prepares_existing_target_and_reports_durable_changes() {
2724 fn normalize_target(
2725 tx: &Transaction<'_>,
2726 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2727 let changed = tx.execute("UPDATE t1 SET x = x + 1", [])?;
2728 Ok(MaintenancePrepareReport::rewrote(changed))
2729 }
2730 fn mutate_then_fail(
2731 tx: &Transaction<'_>,
2732 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2733 tx.execute("UPDATE t1 SET x = 99", [])?;
2734 tx.execute_batch("THIS IS NOT SQL")?;
2735 Ok(MaintenancePrepareReport::rewrote(1))
2736 }
2737 fn rolls_back_then_begins_and_fails(
2738 tx: &Transaction<'_>,
2739 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2740 tx.execute_batch("UPDATE t1 SET x = 99; ROLLBACK; BEGIN; THIS IS NOT SQL")?;
2741 Ok(MaintenancePrepareReport::rewrote(1))
2742 }
2743
2744 let dir = tempfile::tempdir().expect("tempdir");
2745 let mut conn = temp_conn(&dir);
2746 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("ledgered target");
2747 conn.execute("INSERT INTO t1 (x, y) VALUES (7, 'target')", [])
2748 .expect("target data");
2749
2750 let report =
2751 bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(normalize_target))
2752 .expect("prepare target");
2753 assert_eq!(
2754 report,
2755 MaintenanceBridgeReport {
2756 from_version: 2,
2757 to_version: 2,
2758 prepared: 1,
2759 refused: Vec::new(),
2760 }
2761 );
2762 assert!(!report.migrated());
2763 assert!(report.changed());
2764 assert_eq!(
2765 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2766 .expect("prepared target row"),
2767 8
2768 );
2769 assert_eq!(
2770 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2771 Some(2)
2772 );
2773
2774 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(mutate_then_fail))
2775 .expect_err("target prepare failure");
2776 assert!(matches!(
2777 err,
2778 SqliteStoreError::MigrationFailed { ref name, .. }
2779 if name == "maintenance-prepare"
2780 ));
2781 assert_eq!(
2782 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2783 .expect("row after rollback"),
2784 8
2785 );
2786
2787 let err = bridge_unledgered_domain(
2788 &mut conn,
2789 &DOMAIN_V2,
2790 2,
2791 &[1],
2792 Some(rolls_back_then_begins_and_fails),
2793 )
2794 .expect_err("target custody loss");
2795 assert!(matches!(
2796 err,
2797 SqliteStoreError::MigrationBrokeTransaction { ref name, .. }
2798 if name == "maintenance-prepare"
2799 ));
2800 assert_eq!(
2801 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2802 .expect("row after custody refusal"),
2803 8
2804 );
2805 assert_eq!(
2806 domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2807 Some(2)
2808 );
2809 }
2810
2811 #[test]
2812 fn maintenance_bridge_refuses_target_that_only_matches_migration_oracle() {
2813 let dir = tempfile::tempdir().expect("tempdir");
2814 let mut conn = temp_conn(&dir);
2815 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2816 .expect("historical unledgered v1");
2817
2818 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2_ALT_INITIALIZER, 2, &[1], None)
2819 .expect_err("ordinary target verifier must reject drift");
2820 assert!(matches!(
2821 err,
2822 SqliteStoreError::SchemaFingerprintMismatch { version: 2, .. }
2823 ));
2824 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2825 let columns = conn
2826 .prepare("PRAGMA table_info(t1)")
2827 .expect("prepare")
2828 .query_map([], |row| row.get::<_, String>(1))
2829 .expect("query")
2830 .collect::<Result<Vec<_>, _>>()
2831 .expect("columns");
2832 assert_eq!(
2833 columns,
2834 vec!["x"],
2835 "target-verifier refusal did not roll back"
2836 );
2837 }
2838
2839 #[test]
2840 fn maintenance_bridge_refuses_catalog_outside_source_allowlist_across_data_only_gap() {
2841 fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2842 Ok(())
2843 }
2844 const DATA_ONLY_GAP: SchemaDomain = SchemaDomain {
2845 name: "data-only-gap-domain",
2846 migrations: &[
2847 Migration {
2848 version: 1,
2849 name: "base",
2850 apply: create_t1,
2851 },
2852 Migration {
2853 version: 2,
2854 name: "data-only",
2855 apply: no_op,
2856 },
2857 Migration {
2858 version: 3,
2859 name: "add-y",
2860 apply: add_column_guarded,
2861 },
2862 ],
2863 initialize_current: initialize_v2,
2864 allowed_existing_versions: &[3],
2865 bridge_recoverable_versions: &[1],
2866 released_predecessors: &[],
2867 owned_objects: &[SchemaObject {
2868 kind: SchemaObjectKind::Table,
2869 name: "t1",
2870 }],
2871 retired_objects: &[],
2872 };
2873
2874 let dir = tempfile::tempdir().expect("tempdir");
2875 let mut conn = temp_conn(&dir);
2876 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2877 .expect("v1-or-v2 catalog");
2878 let err = bridge_unledgered_domain(&mut conn, &DATA_ONLY_GAP, 3, &[3], None)
2879 .expect_err("excluded historical prefixes must not be inferred");
2880 assert!(matches!(
2881 err,
2882 SqliteStoreError::UnledgeredSchemaNoMatch { .. }
2883 ));
2884 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2885 let columns = conn
2886 .prepare("PRAGMA table_info(t1)")
2887 .expect("prepare")
2888 .query_map([], |row| row.get::<_, String>(1))
2889 .expect("query")
2890 .collect::<Result<Vec<_>, _>>()
2891 .expect("columns");
2892 assert_eq!(columns, vec!["x"]);
2893
2894 let err = bridge_unledgered_domain(&mut conn, &DATA_ONLY_GAP, 3, &[2, 1], None)
2895 .expect_err("unordered source authority must be refused");
2896 assert!(matches!(err, SqliteStoreError::InvalidMigrationList { .. }));
2897 }
2898
2899 #[test]
2900 fn maintenance_bridge_leaves_fresh_domain_for_normal_initializer() {
2901 let dir = tempfile::tempdir().expect("tempdir");
2902 let mut conn = temp_conn(&dir);
2903 let report =
2904 bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None).expect("fresh no-op");
2905 assert_eq!(
2906 report,
2907 MaintenanceBridgeReport {
2908 from_version: 0,
2909 to_version: 0,
2910 prepared: 0,
2911 refused: Vec::new(),
2912 }
2913 );
2914 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2915 assert!(
2916 find_owned_objects(&conn, &DOMAIN_V2)
2917 .expect("objects")
2918 .is_empty()
2919 );
2920 }
2921
2922 #[test]
2923 fn maintenance_bridge_refuses_malformed_historical_shape_without_mutation() {
2924 let dir = tempfile::tempdir().expect("tempdir");
2925 let mut conn = temp_conn(&dir);
2926 conn.execute_batch("CREATE TABLE t1 (x INTEGER, candidate_only BLOB)")
2927 .expect("candidate schema");
2928
2929 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
2930 .expect_err("refuse unauthenticated shape");
2931 assert!(matches!(
2932 err,
2933 SqliteStoreError::UnledgeredSchemaNoMatch {
2934 target_version: 2,
2935 ..
2936 }
2937 ));
2938 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2939 let columns = conn
2940 .prepare("PRAGMA table_info(t1)")
2941 .expect("prepare")
2942 .query_map([], |row| row.get::<_, String>(1))
2943 .expect("query")
2944 .collect::<Result<Vec<_>, _>>()
2945 .expect("columns");
2946 assert_eq!(columns, vec!["x", "candidate_only"]);
2947 }
2948
2949 #[test]
2950 fn maintenance_bridge_fingerprint_preserves_sql_literal_whitespace() {
2951 fn create_exact(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2952 tx.execute_batch("CREATE TABLE literal_t (x TEXT CHECK(x <> 'a b'))")
2953 }
2954 const LITERAL_DOMAIN: SchemaDomain = SchemaDomain {
2955 name: "literal-fingerprint-domain",
2956 migrations: &[Migration {
2957 version: 1,
2958 name: "base",
2959 apply: create_exact,
2960 }],
2961 initialize_current: create_exact,
2962 allowed_existing_versions: &[1],
2963 bridge_recoverable_versions: &[1],
2964 released_predecessors: &[],
2965 owned_objects: &[SchemaObject {
2966 kind: SchemaObjectKind::Table,
2967 name: "literal_t",
2968 }],
2969 retired_objects: &[],
2970 };
2971
2972 let dir = tempfile::tempdir().expect("tempdir");
2973 let mut conn = temp_conn(&dir);
2974 conn.execute_batch("CREATE TABLE literal_t (x TEXT CHECK(x <> 'a b'))")
2975 .expect("semantic mismatch");
2976 let err = bridge_unledgered_domain(&mut conn, &LITERAL_DOMAIN, 1, &[1], None)
2977 .expect_err("literal whitespace must remain fingerprint-significant");
2978 assert!(matches!(
2979 err,
2980 SqliteStoreError::UnledgeredSchemaNoMatch { .. }
2981 ));
2982 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2983
2984 assert_eq!(
2985 normalize_schema_sql("CREATE TABLE IF NOT EXISTS t (x CHECK(x <> 'IF NOT EXISTS'))"),
2986 "CREATE TABLE t (x CHECK(x <> 'IF NOT EXISTS'))"
2987 );
2988 }
2989
2990 #[test]
2991 fn maintenance_bridge_rolls_back_prepare_failure() {
2992 fn mutate_then_fail(
2993 tx: &Transaction<'_>,
2994 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2995 tx.execute("UPDATE t1 SET x = 99", [])?;
2996 tx.execute_batch("THIS IS NOT SQL")?;
2997 Ok(MaintenancePrepareReport::rewrote(1))
2998 }
2999
3000 let dir = tempfile::tempdir().expect("tempdir");
3001 let mut conn = temp_conn(&dir);
3002 conn.execute_batch("CREATE TABLE t1 (x INTEGER); INSERT INTO t1 VALUES (7)")
3003 .expect("historical unledgered v1");
3004
3005 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(mutate_then_fail))
3006 .expect_err("prepare must fail");
3007 assert!(matches!(
3008 err,
3009 SqliteStoreError::MigrationFailed { ref name, .. }
3010 if name == "maintenance-prepare"
3011 ));
3012 assert_eq!(
3013 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
3014 .expect("original row"),
3015 7
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"]);
3026 }
3027
3028 #[test]
3029 fn maintenance_bridge_reports_custody_loss_before_callback_error() {
3030 fn commits_then_fails(
3031 tx: &Transaction<'_>,
3032 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3033 tx.execute_batch("UPDATE t1 SET x = 99; COMMIT; THIS IS NOT SQL")?;
3034 Ok(MaintenancePrepareReport::rewrote(1))
3035 }
3036
3037 let dir = tempfile::tempdir().expect("tempdir");
3038 let mut conn = temp_conn(&dir);
3039 conn.execute_batch("CREATE TABLE t1 (x INTEGER); INSERT INTO t1 VALUES (7)")
3040 .expect("historical unledgered v1");
3041 let err =
3042 bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(commits_then_fails))
3043 .expect_err("custody must dominate callback error");
3044 assert!(matches!(
3045 err,
3046 SqliteStoreError::MigrationBrokeTransaction { ref name, .. }
3047 if name == "maintenance-prepare"
3048 ));
3049 assert_eq!(domain_version(&conn, DOMAIN_V2.name).expect("ledger"), None);
3050 }
3051
3052 #[test]
3053 fn maintenance_bridge_refuses_undeclared_trigger_on_owned_table_before_prepare() {
3054 fn prepare_successor(
3055 tx: &Transaction<'_>,
3056 ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3057 let changed = tx.execute("UPDATE t1 SET x = 8 WHERE x = 7", [])?;
3058 Ok(MaintenancePrepareReport::rewrote(changed))
3059 }
3060
3061 let dir = tempfile::tempdir().expect("tempdir");
3062 let mut conn = temp_conn(&dir);
3063 conn.execute_batch(
3064 "CREATE TABLE t1 (x INTEGER);
3065 INSERT INTO t1 VALUES (7);
3066 CREATE TRIGGER replace_prepared_successor
3067 AFTER UPDATE ON t1
3068 BEGIN
3069 UPDATE t1 SET x = 99 WHERE rowid = NEW.rowid;
3070 END",
3071 )
3072 .expect("intercepting trigger");
3073
3074 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(prepare_successor))
3075 .expect_err("undeclared trigger must be refused before prepare");
3076 assert!(matches!(
3077 err,
3078 SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
3079 ));
3080 assert_eq!(
3081 conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
3082 .expect("original row"),
3083 7
3084 );
3085 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3086 }
3087
3088 #[test]
3089 fn maintenance_bridge_refuses_undeclared_instead_of_trigger_on_owned_view_before_prepare() {
3090 fn create_owned_view(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3091 tx.execute_batch(
3092 "CREATE TABLE view_base (x INTEGER);
3093 CREATE VIEW owned_view AS SELECT x FROM view_base",
3094 )
3095 }
3096 fn prepare_view(tx: &Transaction<'_>) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3097 let changed = tx.execute("UPDATE owned_view SET x = 8 WHERE x = 7", [])?;
3098 Ok(MaintenancePrepareReport::rewrote(changed))
3099 }
3100 const VIEW_DOMAIN: SchemaDomain = SchemaDomain {
3101 name: "view-bridge-domain",
3102 migrations: &[Migration {
3103 version: 1,
3104 name: "base",
3105 apply: create_owned_view,
3106 }],
3107 initialize_current: create_owned_view,
3108 allowed_existing_versions: &[1],
3109 bridge_recoverable_versions: &[1],
3110 released_predecessors: &[],
3111 owned_objects: &[
3112 SchemaObject {
3113 kind: SchemaObjectKind::Table,
3114 name: "view_base",
3115 },
3116 SchemaObject {
3117 kind: SchemaObjectKind::View,
3118 name: "owned_view",
3119 },
3120 ],
3121 retired_objects: &[],
3122 };
3123
3124 let dir = tempfile::tempdir().expect("tempdir");
3125 let mut conn = temp_conn(&dir);
3126 conn.execute_batch(
3127 "CREATE TABLE view_base (x INTEGER);
3128 CREATE VIEW owned_view AS SELECT x FROM view_base;
3129 INSERT INTO view_base VALUES (7);
3130 CREATE TRIGGER replace_view_update
3131 INSTEAD OF UPDATE ON owned_view
3132 BEGIN
3133 UPDATE view_base SET x = 99 WHERE x = OLD.x;
3134 END",
3135 )
3136 .expect("intercepting view trigger");
3137
3138 let err = bridge_unledgered_domain(&mut conn, &VIEW_DOMAIN, 1, &[1], Some(prepare_view))
3139 .expect_err("undeclared view trigger must be refused before prepare");
3140 assert!(matches!(
3141 err,
3142 SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
3143 ));
3144 assert_eq!(
3145 conn.query_row("SELECT x FROM view_base", [], |row| row.get::<_, i64>(0))
3146 .expect("original row"),
3147 7
3148 );
3149 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3150 }
3151
3152 #[test]
3153 fn maintenance_bridge_refuses_ambiguous_prefix_without_mutation() {
3154 fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3155 Ok(())
3156 }
3157 const AMBIGUOUS: SchemaDomain = SchemaDomain {
3158 name: "ambiguous-bridge-domain",
3159 migrations: &[
3160 Migration {
3161 version: 1,
3162 name: "base",
3163 apply: create_t1,
3164 },
3165 Migration {
3166 version: 2,
3167 name: "data-only",
3168 apply: no_op,
3169 },
3170 ],
3171 initialize_current: create_t1,
3172 allowed_existing_versions: &[2],
3173 bridge_recoverable_versions: &[1],
3174 released_predecessors: &[],
3175 owned_objects: &[SchemaObject {
3176 kind: SchemaObjectKind::Table,
3177 name: "t1",
3178 }],
3179 retired_objects: &[],
3180 };
3181
3182 let dir = tempfile::tempdir().expect("tempdir");
3183 let mut conn = temp_conn(&dir);
3184 conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
3185 .expect("ambiguous schema");
3186 let err = bridge_unledgered_domain(&mut conn, &AMBIGUOUS, 2, &[1, 2], None)
3187 .expect_err("refuse ambiguity");
3188 assert!(matches!(
3189 err,
3190 SqliteStoreError::UnledgeredSchemaAmbiguous { ref matches, .. }
3191 if matches == &[1, 2]
3192 ));
3193 assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3194 }
3195
3196 #[test]
3197 fn maintenance_bridge_refuses_malformed_ledger_before_owned_schema_contact() {
3198 let dir = tempfile::tempdir().expect("tempdir");
3199 let mut conn = temp_conn(&dir);
3200 conn.execute_batch(
3201 "CREATE TABLE t1 (x INTEGER);
3202 CREATE TABLE meerkat_schema (domain TEXT, version INTEGER)",
3203 )
3204 .expect("malformed ledger");
3205 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
3206 .expect_err("refuse malformed ledger");
3207 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3208 let columns = conn
3209 .prepare("PRAGMA table_info(t1)")
3210 .expect("prepare")
3211 .query_map([], |row| row.get::<_, String>(1))
3212 .expect("query")
3213 .collect::<Result<Vec<_>, _>>()
3214 .expect("columns");
3215 assert_eq!(columns, vec!["x"]);
3216 }
3217
3218 #[test]
3219 fn maintenance_bridge_refuses_mixed_case_trigger_that_mutates_foreign_ledger_row() {
3220 let dir = tempfile::tempdir().expect("tempdir");
3221 let mut conn = temp_conn(&dir);
3222 conn.execute_batch(
3223 "CREATE TABLE t1 (x INTEGER);
3224 CREATE TABLE meerkat_schema (
3225 domain TEXT PRIMARY KEY,
3226 version INTEGER NOT NULL
3227 );
3228 INSERT INTO meerkat_schema (domain, version) VALUES ('foreign-domain', 7);
3229 CREATE TRIGGER mutate_foreign_schema_row
3230 AFTER INSERT ON MEERKAT_SCHEMA
3231 BEGIN
3232 UPDATE MEERKAT_SCHEMA
3233 SET version = 999
3234 WHERE domain = 'foreign-domain';
3235 END",
3236 )
3237 .expect("hostile ledger trigger");
3238
3239 let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
3240 .expect_err("mixed-case ledger trigger must be refused");
3241 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3242 let row_count = conn
3243 .query_row(
3244 "SELECT COUNT(*) FROM main.meerkat_schema WHERE domain = ?1",
3245 [DOMAIN_V2.name],
3246 |row| row.get::<_, i64>(0),
3247 )
3248 .expect("raw ledger count");
3249 assert_eq!(row_count, 0);
3250 let foreign_version = conn
3251 .query_row(
3252 "SELECT version FROM main.meerkat_schema WHERE domain = 'foreign-domain'",
3253 [],
3254 |row| row.get::<_, i64>(0),
3255 )
3256 .expect("foreign ledger row");
3257 assert_eq!(foreign_version, 7);
3258 let columns = conn
3259 .prepare("PRAGMA table_info(t1)")
3260 .expect("prepare")
3261 .query_map([], |row| row.get::<_, String>(1))
3262 .expect("query")
3263 .collect::<Result<Vec<_>, _>>()
3264 .expect("columns");
3265 assert_eq!(
3266 columns,
3267 vec!["x"],
3268 "failed stamp did not roll back migration"
3269 );
3270 }
3271
3272 #[test]
3273 fn fresh_domain_ignores_foreign_cotenant_objects() {
3274 let dir = tempfile::tempdir().expect("tempdir");
3275 let mut conn = temp_conn(&dir);
3276 conn.execute_batch("CREATE TABLE foreign_table (value TEXT)")
3277 .expect("foreign ddl");
3278 let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("fresh domain");
3279 assert_eq!(
3280 report,
3281 LedgerReport {
3282 from_version: 0,
3283 to_version: 2
3284 }
3285 );
3286 conn.execute("INSERT INTO foreign_table VALUES ('kept')", [])
3287 .expect("foreign object survives");
3288 }
3289
3290 #[test]
3291 fn future_version_is_refused_before_any_mutation() {
3292 let dir = tempfile::tempdir().expect("tempdir");
3293 let mut conn = temp_conn(&dir);
3294 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
3295 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse");
3297 match err {
3298 SqliteStoreError::SchemaFromTheFuture {
3299 domain,
3300 found,
3301 supported,
3302 } => {
3303 assert_eq!(domain, "test-domain");
3304 assert_eq!(found, 2);
3305 assert_eq!(supported, 1);
3306 }
3307 other => panic!("wrong error: {other}"),
3308 }
3309 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
3311 }
3312
3313 #[test]
3314 fn foreign_domain_rows_are_untouched() {
3315 let dir = tempfile::tempdir().expect("tempdir");
3316 let mut conn = temp_conn(&dir);
3317 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("mine");
3318 conn.execute(
3319 "INSERT INTO meerkat_schema (domain, version) VALUES ('foreign-domain', 7)",
3320 [],
3321 )
3322 .expect("foreign row");
3323 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("noop");
3324 let foreign: i64 = conn
3325 .query_row(
3326 "SELECT version FROM meerkat_schema WHERE domain = 'foreign-domain'",
3327 [],
3328 |r| r.get(0),
3329 )
3330 .expect("foreign row survives");
3331 assert_eq!(foreign, 7);
3332 }
3333
3334 #[test]
3335 fn invalid_migration_list_is_refused_without_touching_the_file() {
3336 const BAD: SchemaDomain = SchemaDomain {
3337 name: "bad-domain",
3338 migrations: &[Migration {
3339 version: 3,
3340 name: "gap",
3341 apply: create_t1,
3342 }],
3343 initialize_current: create_t1,
3344 allowed_existing_versions: &[3],
3345 bridge_recoverable_versions: &[1],
3346 released_predecessors: &[],
3347 owned_objects: &[],
3348 retired_objects: &[],
3349 };
3350 let dir = tempfile::tempdir().expect("tempdir");
3351 let mut conn = temp_conn(&dir);
3352 let err = apply_domain_migrations(&mut conn, &BAD).expect_err("refuse");
3353 assert!(matches!(err, SqliteStoreError::InvalidMigrationList { .. }));
3354 assert!(!ledger_table_exists(&conn).expect("check"));
3355 }
3356
3357 #[test]
3358 fn failed_migration_rolls_back_atomically() {
3359 fn fail(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3360 tx.execute_batch("CREATE TABLE half_done (x INTEGER)")?;
3361 tx.execute_batch("THIS IS NOT SQL")
3362 }
3363 fn initialize_failing(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3364 create_t1(tx)?;
3365 fail(tx)
3366 }
3367 const FAILING: SchemaDomain = SchemaDomain {
3368 name: "failing-domain",
3369 migrations: &[
3370 Migration {
3371 version: 1,
3372 name: "base",
3373 apply: create_t1,
3374 },
3375 Migration {
3376 version: 2,
3377 name: "explodes",
3378 apply: fail,
3379 },
3380 ],
3381 initialize_current: initialize_failing,
3382 allowed_existing_versions: &[1, 2],
3383 bridge_recoverable_versions: &[1],
3384 released_predecessors: &[SchemaPredecessor {
3385 version: 1,
3386 verify: verify_v1,
3387 }],
3388 owned_objects: &[
3389 SchemaObject {
3390 kind: SchemaObjectKind::Table,
3391 name: "t1",
3392 },
3393 SchemaObject {
3394 kind: SchemaObjectKind::Table,
3395 name: "half_done",
3396 },
3397 ],
3398 retired_objects: &[],
3399 };
3400 let dir = tempfile::tempdir().expect("tempdir");
3401 let mut conn = temp_conn(&dir);
3402 let err = apply_domain_migrations(&mut conn, &FAILING).expect_err("must fail");
3403 assert!(matches!(
3404 err,
3405 SqliteStoreError::MigrationFailed { version: 2, .. }
3406 ));
3407 assert_eq!(domain_version(&conn, "failing-domain").expect("read"), None);
3410 let tables: Vec<String> = conn
3411 .prepare(
3412 "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('t1','half_done')",
3413 )
3414 .expect("prepare")
3415 .query_map([], |r| r.get(0))
3416 .expect("query")
3417 .collect::<Result<_, _>>()
3418 .expect("rows");
3419 assert!(tables.is_empty(), "rollback left tables behind: {tables:?}");
3420 }
3421
3422 #[test]
3423 fn malformed_ledger_shape_is_refused_not_healed() {
3424 let dir = tempfile::tempdir().expect("tempdir");
3425 let mut conn = temp_conn(&dir);
3426 conn.execute_batch("CREATE TABLE meerkat_schema (x INTEGER)")
3428 .expect("foreign ddl");
3429 let err = domain_version(&conn, "test-domain").expect_err("refuse read");
3430 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3431 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
3432 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3433 let count: i64 = conn
3435 .query_row("SELECT COUNT(*) FROM meerkat_schema", [], |r| r.get(0))
3436 .expect("foreign table survives");
3437 assert_eq!(count, 0);
3438 }
3439
3440 #[test]
3441 fn non_positive_versions_are_refused_not_healed() {
3442 for bad_version in [0i64, -3] {
3443 let dir = tempfile::tempdir().expect("tempdir");
3444 let mut conn = temp_conn(&dir);
3445 conn.execute_batch(
3446 "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL)",
3447 )
3448 .expect("ledger ddl");
3449 conn.execute(
3450 "INSERT INTO meerkat_schema (domain, version) VALUES ('test-domain', ?1)",
3451 [bad_version],
3452 )
3453 .expect("seed bad version");
3454 let err = domain_version(&conn, "test-domain").expect_err("refuse read");
3455 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3456 let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
3457 assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3458 let stored: i64 = conn
3460 .query_row(
3461 "SELECT version FROM meerkat_schema WHERE domain = 'test-domain'",
3462 [],
3463 |r| r.get(0),
3464 )
3465 .expect("row survives");
3466 assert_eq!(stored, bad_version);
3467 }
3468 }
3469
3470 #[test]
3471 fn duplicate_domain_rows_are_refused() {
3472 let dir = tempfile::tempdir().expect("tempdir");
3473 let conn = temp_conn(&dir);
3474 conn.execute_batch(
3478 "CREATE TABLE meerkat_schema (domain TEXT, version INTEGER NOT NULL);
3479 INSERT INTO meerkat_schema VALUES ('dup-domain', 1);
3480 INSERT INTO meerkat_schema VALUES ('dup-domain', 2);",
3481 )
3482 .expect("seed duplicates");
3483 let err = read_version(&conn, "dup-domain").expect_err("refuse duplicates");
3484 match err {
3485 SqliteStoreError::LedgerMalformed { detail } => {
3486 assert!(detail.contains("multiple ledger rows"), "{detail}");
3487 }
3488 other => panic!("wrong error: {other}"),
3489 }
3490 }
3491
3492 #[test]
3493 fn temp_shadowing_cannot_hijack_the_ledger() {
3494 let dir = tempfile::tempdir().expect("tempdir");
3495 let mut conn = temp_conn(&dir);
3496 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("stamp v1");
3497 conn.execute_batch(
3500 "CREATE TEMP TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
3501 INSERT INTO temp.meerkat_schema VALUES ('test-domain', 999);",
3502 )
3503 .expect("temp shadow");
3504 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(1));
3505 let report = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("noop against main");
3506 assert!(!report.migrated());
3507 }
3508
3509 #[test]
3510 fn migration_that_ends_the_transaction_is_refused_unstamped() {
3511 fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3512 Ok(())
3513 }
3514 fn verify_empty_predecessor(_conn: &Connection) -> Result<(), String> {
3515 Ok(())
3516 }
3517 fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3518 tx.execute_batch("CREATE TABLE escaped_commit (x INTEGER); COMMIT")
3519 }
3520 fn rolls_back_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3521 tx.execute_batch("ROLLBACK")
3522 }
3523 fn commits_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3527 tx.execute_batch("CREATE TABLE escaped_commit_begin (x INTEGER); COMMIT; BEGIN")
3528 }
3529 fn rolls_back_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3530 tx.execute_batch("ROLLBACK; BEGIN")
3531 }
3532 const COMMITS: SchemaDomain = SchemaDomain {
3533 name: "custody-commit",
3534 migrations: &[
3535 Migration {
3536 version: 1,
3537 name: "base",
3538 apply: no_op,
3539 },
3540 Migration {
3541 version: 2,
3542 name: "commits-underneath",
3543 apply: commits_underneath,
3544 },
3545 ],
3546 initialize_current: no_op,
3547 allowed_existing_versions: &[1, 2],
3548 bridge_recoverable_versions: &[1],
3549 released_predecessors: &[SchemaPredecessor {
3550 version: 1,
3551 verify: verify_empty_predecessor,
3552 }],
3553 owned_objects: &[],
3554 retired_objects: &[],
3555 };
3556 const ROLLS_BACK: SchemaDomain = SchemaDomain {
3557 name: "custody-rollback",
3558 migrations: &[
3559 Migration {
3560 version: 1,
3561 name: "base",
3562 apply: no_op,
3563 },
3564 Migration {
3565 version: 2,
3566 name: "rolls-back-underneath",
3567 apply: rolls_back_underneath,
3568 },
3569 ],
3570 initialize_current: no_op,
3571 allowed_existing_versions: &[1, 2],
3572 bridge_recoverable_versions: &[1],
3573 released_predecessors: &[SchemaPredecessor {
3574 version: 1,
3575 verify: verify_empty_predecessor,
3576 }],
3577 owned_objects: &[],
3578 retired_objects: &[],
3579 };
3580 const COMMITS_THEN_BEGINS: SchemaDomain = SchemaDomain {
3581 name: "custody-commit-begin",
3582 migrations: &[
3583 Migration {
3584 version: 1,
3585 name: "base",
3586 apply: no_op,
3587 },
3588 Migration {
3589 version: 2,
3590 name: "commits-then-begins",
3591 apply: commits_then_begins,
3592 },
3593 ],
3594 initialize_current: no_op,
3595 allowed_existing_versions: &[1, 2],
3596 bridge_recoverable_versions: &[1],
3597 released_predecessors: &[SchemaPredecessor {
3598 version: 1,
3599 verify: verify_empty_predecessor,
3600 }],
3601 owned_objects: &[],
3602 retired_objects: &[],
3603 };
3604 const ROLLS_BACK_THEN_BEGINS: SchemaDomain = SchemaDomain {
3605 name: "custody-rollback-begin",
3606 migrations: &[
3607 Migration {
3608 version: 1,
3609 name: "base",
3610 apply: no_op,
3611 },
3612 Migration {
3613 version: 2,
3614 name: "rolls-back-then-begins",
3615 apply: rolls_back_then_begins,
3616 },
3617 ],
3618 initialize_current: no_op,
3619 allowed_existing_versions: &[1, 2],
3620 bridge_recoverable_versions: &[1],
3621 released_predecessors: &[SchemaPredecessor {
3622 version: 1,
3623 verify: verify_empty_predecessor,
3624 }],
3625 owned_objects: &[],
3626 retired_objects: &[],
3627 };
3628 for (domain, expected_name) in [
3629 (&COMMITS, "commits-underneath"),
3630 (&ROLLS_BACK, "rolls-back-underneath"),
3631 (&COMMITS_THEN_BEGINS, "commits-then-begins"),
3632 (&ROLLS_BACK_THEN_BEGINS, "rolls-back-then-begins"),
3633 ] {
3634 let dir = tempfile::tempdir().expect("tempdir");
3635 let mut conn = temp_conn(&dir);
3636 conn.execute_batch(CREATE_LEDGER_SQL).expect("ledger");
3637 conn.execute(
3638 "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 1)",
3639 [domain.name],
3640 )
3641 .expect("released predecessor");
3642 let err = apply_domain_migrations(&mut conn, domain).expect_err("custody violation");
3643 match err {
3644 SqliteStoreError::MigrationBrokeTransaction {
3645 domain: err_domain,
3646 version,
3647 name,
3648 } => {
3649 assert_eq!(err_domain, domain.name);
3650 assert_eq!(version, 2);
3651 assert_eq!(name, expected_name);
3652 }
3653 other => panic!("wrong error: {other}"),
3654 }
3655 assert_eq!(domain_version(&conn, domain.name).expect("read"), Some(1));
3658 }
3659 }
3660
3661 #[test]
3662 fn initializer_that_ends_the_transaction_is_refused_unstamped() {
3663 fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3664 tx.execute_batch("CREATE TABLE escaped_initializer (x INTEGER); COMMIT")
3665 }
3666 const COMMITS: SchemaDomain = SchemaDomain {
3667 name: "initializer-custody-commit",
3668 migrations: &[Migration {
3669 version: 1,
3670 name: "base",
3671 apply: commits_underneath,
3672 }],
3673 initialize_current: commits_underneath,
3674 allowed_existing_versions: &[1],
3675 bridge_recoverable_versions: &[1],
3676 released_predecessors: &[],
3677 owned_objects: &[SchemaObject {
3678 kind: SchemaObjectKind::Table,
3679 name: "escaped_initializer",
3680 }],
3681 retired_objects: &[],
3682 };
3683 let dir = tempfile::tempdir().expect("tempdir");
3684 let mut conn = temp_conn(&dir);
3685 let err = apply_domain_migrations(&mut conn, &COMMITS).expect_err("custody violation");
3686 match err {
3687 SqliteStoreError::MigrationBrokeTransaction {
3688 domain,
3689 version,
3690 name,
3691 } => {
3692 assert_eq!(domain, COMMITS.name);
3693 assert_eq!(version, 1);
3694 assert_eq!(name, "initialize-current");
3695 }
3696 other => panic!("wrong error: {other}"),
3697 }
3698 assert_eq!(domain_version(&conn, COMMITS.name).expect("read"), None);
3699 }
3700
3701 #[test]
3702 fn initializer_using_its_own_savepoints_keeps_custody() {
3703 fn nests_savepoints(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3707 tx.execute_batch(
3708 "SAVEPOINT body_sp;
3709 CREATE TABLE sp_t (x INTEGER);
3710 RELEASE SAVEPOINT body_sp",
3711 )
3712 }
3713 const NESTED: SchemaDomain = SchemaDomain {
3714 name: "custody-nested-savepoint",
3715 migrations: &[Migration {
3716 version: 1,
3717 name: "nests-savepoints",
3718 apply: nests_savepoints,
3719 }],
3720 initialize_current: nests_savepoints,
3721 allowed_existing_versions: &[1],
3722 bridge_recoverable_versions: &[1],
3723 released_predecessors: &[],
3724 owned_objects: &[SchemaObject {
3725 kind: SchemaObjectKind::Table,
3726 name: "sp_t",
3727 }],
3728 retired_objects: &[],
3729 };
3730 let dir = tempfile::tempdir().expect("tempdir");
3731 let mut conn = temp_conn(&dir);
3732 let report = apply_domain_migrations(&mut conn, &NESTED).expect("apply");
3733 assert_eq!(report.to_version, 1);
3734 assert_eq!(domain_version(&conn, NESTED.name).expect("read"), Some(1));
3735 }
3736
3737 #[test]
3738 fn schema_preflight_passes_fresh_and_current_refuses_future() {
3739 let dir = tempfile::tempdir().expect("tempdir");
3740 let mut conn = temp_conn(&dir);
3741 preflight_schema_eligibility(&conn, &DOMAIN_V1).expect("no ledger yet");
3742 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
3743 preflight_schema_eligibility(&conn, &DOMAIN_V2).expect("current");
3744 let err =
3745 preflight_schema_eligibility(&conn, &DOMAIN_V1).expect_err("future for old binary");
3746 assert!(matches!(
3747 err,
3748 SqliteStoreError::SchemaFromTheFuture {
3749 found: 2,
3750 supported: 1,
3751 ..
3752 }
3753 ));
3754 }
3755
3756 #[test]
3757 fn current_schema_verification_does_not_take_the_wal_writer() {
3758 let dir = tempfile::tempdir().expect("tempdir");
3759 let path = dir.path().join("db.sqlite3");
3760 let mut writer = open(&path, ConnectionProfile::PRIMARY).expect("writer");
3761 apply_domain_migrations(&mut writer, &DOMAIN_V2).expect("initialize");
3762 let mut reader = open(&path, ConnectionProfile::PRIMARY).expect("reader");
3763 reader
3766 .busy_timeout(std::time::Duration::ZERO)
3767 .expect("busy policy");
3768 let tx = writer
3769 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
3770 .expect("reserve unrelated writer");
3771 let report = apply_domain_migrations(&mut reader, &DOMAIN_V2)
3772 .expect("current schema verification must be a read-only snapshot");
3773 assert!(!report.migrated());
3774 assert!(reader.is_autocommit());
3775 drop(tx);
3776 }
3777
3778 #[test]
3779 fn concurrent_opens_race_safely() {
3780 let dir = tempfile::tempdir().expect("tempdir");
3781 let path = dir.path().join("db.sqlite3");
3782 let mut handles = Vec::new();
3783 for _ in 0..8 {
3784 let path = path.clone();
3785 handles.push(std::thread::spawn(move || {
3786 let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
3787 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply")
3788 }));
3789 }
3790 let mut migrated = 0;
3791 for handle in handles {
3792 let report = handle.join().expect("thread");
3793 assert_eq!(report.to_version, 2);
3794 if report.migrated() {
3795 migrated += 1;
3796 }
3797 }
3798 assert_eq!(migrated, 1, "only the winning writer may initialize");
3799 let conn = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
3800 assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
3801 }
3802
3803 #[test]
3804 fn concurrent_predecessor_upgrade_rechecks_after_read_snapshot() {
3805 let dir = tempfile::tempdir().expect("tempdir");
3806 let path = dir.path().join("db.sqlite3");
3807 let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("seed");
3808 apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("version 1");
3809 let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
3810 let handles = (0..8)
3811 .map(|_| {
3812 let path = path.clone();
3813 let barrier = barrier.clone();
3814 std::thread::spawn(move || {
3815 let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
3816 barrier.wait();
3817 apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("upgrade")
3818 })
3819 })
3820 .collect::<Vec<_>>();
3821 let reports = handles
3822 .into_iter()
3823 .map(|thread| thread.join().expect("thread"))
3824 .collect::<Vec<_>>();
3825 assert_eq!(reports.iter().filter(|report| report.migrated()).count(), 1);
3826 assert!(reports.iter().all(|report| report.to_version == 2));
3827 preflight_schema_eligibility(&conn, &DOMAIN_V2).expect("verified upgraded catalog");
3828 }
3829}