1use std::path::Path;
58use std::time::Duration;
59
60use rusqlite::{Connection, OpenFlags, Transaction, TransactionBehavior};
61
62use crate::error::SqliteStoreError;
63use crate::ledger::SchemaDomain;
64
65pub const SHARED_BUSY_TIMEOUT: Duration = Duration::from_millis(60_000);
74
75const MAX_BUSY_TIMEOUT: Duration = Duration::from_millis(i32::MAX as u64);
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ConnectionProfile {
86 Primary { create: bool },
91 ReadOnly,
97 Maintenance { write: bool },
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum JournalPolicy {
110 EstablishWal,
115 PreserveExisting,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum WriteContact {
124 ReadWrite,
127 ReadOnlyWalSidecars,
136}
137
138impl ConnectionProfile {
139 pub const PRIMARY: Self = Self::Primary { create: true };
141
142 fn name(self) -> &'static str {
143 match self {
144 Self::Primary { create: true } => "primary",
145 Self::Primary { create: false } => "primary(no-create)",
146 Self::ReadOnly => "read-only",
147 Self::Maintenance { write: true } => "maintenance(write)",
148 Self::Maintenance { write: false } => "maintenance(read)",
149 }
150 }
151
152 fn default_busy_timeout(self) -> Duration {
153 match self {
154 Self::Primary { .. } | Self::ReadOnly => SHARED_BUSY_TIMEOUT,
155 Self::Maintenance { .. } => Duration::ZERO,
156 }
157 }
158
159 pub fn journal_policy(self) -> JournalPolicy {
172 match self {
173 Self::Primary { .. } => JournalPolicy::EstablishWal,
174 Self::ReadOnly | Self::Maintenance { .. } => JournalPolicy::PreserveExisting,
175 }
176 }
177
178 pub fn write_contact(self) -> WriteContact {
180 match self {
181 Self::Primary { .. } | Self::Maintenance { write: true } => WriteContact::ReadWrite,
182 Self::ReadOnly | Self::Maintenance { write: false } => {
183 WriteContact::ReadOnlyWalSidecars
184 }
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, Default)]
192pub struct OpenOptions {
193 pub busy_timeout: Option<Duration>,
199 pub schema_preflight: &'static [&'static SchemaDomain],
214}
215
216pub fn open(path: &Path, profile: ConnectionProfile) -> Result<Connection, SqliteStoreError> {
218 open_with(path, profile, OpenOptions::default())
219}
220
221pub fn open_with(
223 path: &Path,
224 profile: ConnectionProfile,
225 options: OpenOptions,
226) -> Result<Connection, SqliteStoreError> {
227 let expected = validate_database_file(path)?;
231 let conn = match profile {
232 ConnectionProfile::Primary { create: true } => {
233 if let Some(parent) = path.parent() {
234 std::fs::create_dir_all(parent)?;
235 }
236 Connection::open(path)?
237 }
238 ConnectionProfile::Primary { create: false } => {
239 open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_WRITE)?
240 }
241 ConnectionProfile::ReadOnly => {
242 open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_ONLY)?
243 }
244 ConnectionProfile::Maintenance { write } => {
245 let base = if write {
246 OpenFlags::SQLITE_OPEN_READ_WRITE
247 } else {
248 OpenFlags::SQLITE_OPEN_READ_ONLY
249 };
250 open_existing(path, profile, base)?
251 }
252 };
253
254 let busy = options
255 .busy_timeout
256 .unwrap_or_else(|| profile.default_busy_timeout())
257 .min(MAX_BUSY_TIMEOUT);
258 conn.busy_timeout(busy)?;
259 let opened = validate_database_file(path)?;
260 if let (Some(expected), Some(opened)) = (expected, opened)
261 && expected != opened
262 {
263 return Err(SqliteStoreError::UnsupportedDatabaseFile {
264 path: path.to_path_buf(),
265 detail: "the database file was replaced while it was being opened".to_string(),
266 });
267 }
268
269 for domain in options.schema_preflight {
277 crate::ledger::preflight_schema_eligibility(&conn, domain)?;
278 }
279
280 match profile.journal_policy() {
281 JournalPolicy::EstablishWal => {
282 set_wal_journal_mode(&conn, path, busy)?;
283 conn.pragma_update(None, "synchronous", "FULL")?;
286 }
287 JournalPolicy::PreserveExisting => {}
288 }
289
290 Ok(conn)
291}
292
293fn set_wal_journal_mode(
307 conn: &Connection,
308 path: &Path,
309 busy_timeout: Duration,
310) -> Result<(), SqliteStoreError> {
311 let budget = busy_timeout.max(Duration::from_millis(250));
314 let deadline = std::time::Instant::now() + budget;
315 loop {
316 let result = conn
322 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0));
323 match result {
324 Ok(mode) if mode.eq_ignore_ascii_case("wal") || mode.eq_ignore_ascii_case("memory") => {
325 return Ok(());
326 }
327 Ok(mode) => {
328 return Err(SqliteStoreError::WalNotEstablished {
329 path: path.to_path_buf(),
330 actual: mode,
331 });
332 }
333 Err(error) if crate::error::is_busy_or_locked(&error) => {
334 if std::time::Instant::now() < deadline {
335 std::thread::sleep(Duration::from_millis(5));
336 continue;
337 }
338 return Err(SqliteStoreError::WalConversionContended {
344 path: path.to_path_buf(),
345 waited_ms: u64::try_from(budget.as_millis()).unwrap_or(u64::MAX),
346 source: error,
347 });
348 }
349 Err(error) => return Err(error.into()),
350 }
351 }
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub struct DatabaseFileIdentity {
357 device: u64,
358 inode: u64,
359}
360
361pub fn validate_database_file(
376 path: &Path,
377) -> Result<Option<DatabaseFileIdentity>, SqliteStoreError> {
378 let refuse = |detail: String| SqliteStoreError::UnsupportedDatabaseFile {
379 path: path.to_path_buf(),
380 detail,
381 };
382 let metadata = match std::fs::metadata(path) {
383 Ok(metadata) => metadata,
384 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
385 Err(error) => return Err(error.into()),
386 };
387 if !metadata.is_file() {
388 return Err(refuse("not a regular file".to_string()));
389 }
390 #[cfg(unix)]
391 {
392 use std::os::unix::fs::MetadataExt as _;
393 if metadata.nlink() > 1 {
394 return Err(refuse(format!(
395 "the file has {} hard links; SQLite needs exactly one name per database",
396 metadata.nlink()
397 )));
398 }
399 Ok(Some(DatabaseFileIdentity {
400 device: metadata.dev(),
401 inode: metadata.ino(),
402 }))
403 }
404 #[cfg(not(unix))]
405 {
406 Ok(None)
407 }
408}
409
410fn open_existing(
411 path: &Path,
412 profile: ConnectionProfile,
413 base: OpenFlags,
414) -> Result<Connection, SqliteStoreError> {
415 if !path.is_file() {
416 return Err(SqliteStoreError::OpenRefused {
417 path: path.to_path_buf(),
418 profile: profile.name(),
419 detail: "database file does not exist".to_string(),
420 });
421 }
422 Ok(Connection::open_with_flags(
423 path,
424 base | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX,
425 )?)
426}
427
428pub fn begin_immediate(conn: &mut Connection) -> Result<Transaction<'_>, SqliteStoreError> {
432 Ok(conn.transaction_with_behavior(TransactionBehavior::Immediate)?)
433}
434
435#[cfg(test)]
436#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
437mod tests {
438 use std::path::PathBuf;
439
440 use super::*;
441
442 #[test]
444 fn database_file_validation_accepts_one_name_and_refuses_others() {
445 let dir = tempfile::tempdir().unwrap();
446 let missing = dir.path().join("missing.sqlite3");
447 assert_eq!(validate_database_file(&missing).unwrap(), None);
448
449 let directory = dir.path().join("directory.sqlite3");
450 std::fs::create_dir(&directory).unwrap();
451 assert!(matches!(
452 validate_database_file(&directory),
453 Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
454 ));
455 assert!(matches!(
456 open_with(
457 &directory,
458 ConnectionProfile::PRIMARY,
459 OpenOptions::default()
460 ),
461 Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
462 ));
463
464 let single = dir.path().join("single.sqlite3");
465 drop(open_with(&single, ConnectionProfile::PRIMARY, OpenOptions::default()).unwrap());
466 #[cfg(unix)]
467 assert!(validate_database_file(&single).unwrap().is_some());
468 drop(open_with(&single, ConnectionProfile::PRIMARY, OpenOptions::default()).unwrap());
469 }
470
471 #[cfg(unix)]
475 #[test]
476 fn hard_linked_database_is_refused_before_any_sqlite_open() {
477 let dir = tempfile::tempdir().unwrap();
478 let original = dir.path().join("original.sqlite3");
479 {
480 let conn = Connection::open(&original).unwrap();
481 conn.execute_batch(
482 "CREATE TABLE canary (value INTEGER); INSERT INTO canary VALUES (1);",
483 )
484 .unwrap();
485 }
486 let linked = dir.path().join("linked.sqlite3");
487 std::fs::hard_link(&original, &linked).unwrap();
488 let bytes = std::fs::read(&original).unwrap();
489 for name in [&original, &linked] {
490 for profile in [
491 ConnectionProfile::PRIMARY,
492 ConnectionProfile::ReadOnly,
493 ConnectionProfile::Maintenance { write: true },
494 ] {
495 assert!(
496 matches!(
497 open_with(name, profile, OpenOptions::default()),
498 Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
499 ),
500 "{} through {}",
501 profile.name(),
502 name.display()
503 );
504 }
505 assert!(matches!(
506 crate::fence::OperationGuard::for_database(name),
507 Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
508 ));
509 let (wal, shm) = sidecar_paths(name);
510 assert!(
511 !wal.exists() && !shm.exists(),
512 "no sidecar for {}",
513 name.display()
514 );
515 }
516 assert_eq!(std::fs::read(&original).unwrap(), bytes);
517 }
518
519 fn sidecar_paths(path: &Path) -> (PathBuf, PathBuf) {
520 let mut wal = path.as_os_str().to_os_string();
521 wal.push("-wal");
522 let mut shm = path.as_os_str().to_os_string();
523 shm.push("-shm");
524 (PathBuf::from(wal), PathBuf::from(shm))
525 }
526
527 fn journal_mode(conn: &Connection) -> String {
528 conn.pragma_query_value(None, "journal_mode", |row| row.get(0))
529 .expect("journal_mode")
530 }
531
532 fn seed_delete_mode_database(path: &Path) {
535 let conn = Connection::open(path).expect("create");
536 conn.execute_batch("CREATE TABLE legacy (x INTEGER); INSERT INTO legacy VALUES (7);")
537 .expect("seed");
538 assert_eq!(journal_mode(&conn), "delete", "fixture must start non-WAL");
539 }
540
541 #[test]
542 fn primary_creates_dirs_sets_wal_and_full() {
543 let dir = tempfile::tempdir().expect("tempdir");
544 let path = dir.path().join("nested/sub/test.sqlite3");
545 let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
546 let journal: String = conn
547 .pragma_query_value(None, "journal_mode", |r| r.get(0))
548 .expect("journal_mode");
549 assert_eq!(journal, "wal");
550 let sync: i64 = conn
551 .pragma_query_value(None, "synchronous", |r| r.get(0))
552 .expect("synchronous");
553 assert_eq!(sync, 2, "synchronous=FULL");
554 }
555
556 #[test]
557 fn non_creating_profiles_refuse_missing_file() {
558 let dir = tempfile::tempdir().expect("tempdir");
559 let path = dir.path().join("missing.sqlite3");
560 for profile in [
561 ConnectionProfile::Primary { create: false },
562 ConnectionProfile::ReadOnly,
563 ConnectionProfile::Maintenance { write: true },
564 ConnectionProfile::Maintenance { write: false },
565 ] {
566 let err = open(&path, profile).expect_err("must refuse missing file");
567 assert!(
568 matches!(err, SqliteStoreError::OpenRefused { .. }),
569 "{profile:?}"
570 );
571 }
572 assert!(
573 !path.exists(),
574 "no profile may create the file as a side effect"
575 );
576 }
577
578 #[test]
579 fn read_only_profile_rejects_writes_and_preserves_journal_mode() {
580 let dir = tempfile::tempdir().expect("tempdir");
581 let path = dir.path().join("db.sqlite3");
582 {
583 let conn = Connection::open(&path).expect("create");
584 conn.execute_batch("CREATE TABLE t (x INTEGER)")
585 .expect("ddl");
586 }
587 let conn = open(&path, ConnectionProfile::ReadOnly).expect("open ro");
588 let journal: String = conn
589 .pragma_query_value(None, "journal_mode", |r| r.get(0))
590 .expect("journal_mode");
591 assert_eq!(
592 journal, "delete",
593 "reader must not convert the journal mode"
594 );
595 conn.execute("INSERT INTO t VALUES (1)", [])
596 .expect_err("read-only connection must reject writes");
597 }
598
599 #[test]
600 fn maintenance_profile_fails_fast_on_held_lock() {
601 let dir = tempfile::tempdir().expect("tempdir");
602 let path = dir.path().join("db.sqlite3");
603 let mut writer = open(&path, ConnectionProfile::PRIMARY).expect("writer");
604 writer
605 .execute_batch("CREATE TABLE t (x INTEGER)")
606 .expect("ddl");
607 let tx = begin_immediate(&mut writer).expect("hold write lock");
608
609 let maint = open(&path, ConnectionProfile::Maintenance { write: true }).expect("open");
610 let err = maint
611 .execute_batch("BEGIN IMMEDIATE")
612 .expect_err("zero busy timeout must surface the held lock immediately");
613 assert!(crate::error::is_busy_or_locked(&match err {
614 rusqlite::Error::SqliteFailure(..) => err,
615 other => panic!("unexpected error shape: {other}"),
616 }));
617 drop(tx);
618 }
619
620 #[test]
621 fn busy_timeout_override_applies() {
622 let dir = tempfile::tempdir().expect("tempdir");
623 let path = dir.path().join("db.sqlite3");
624 let conn = open_with(
625 &path,
626 ConnectionProfile::PRIMARY,
627 OpenOptions {
628 busy_timeout: Some(Duration::from_millis(1234)),
629 ..OpenOptions::default()
630 },
631 )
632 .expect("open");
633 let timeout: i64 = conn
634 .pragma_query_value(None, "busy_timeout", |r| r.get(0))
635 .expect("busy_timeout");
636 assert_eq!(timeout, 1234);
637 }
638
639 #[test]
640 fn oversized_busy_timeout_is_clamped_not_panicking() {
641 let dir = tempfile::tempdir().expect("tempdir");
642 let path = dir.path().join("db.sqlite3");
643 let conn = open_with(
644 &path,
645 ConnectionProfile::PRIMARY,
646 OpenOptions {
647 busy_timeout: Some(Duration::MAX),
648 ..OpenOptions::default()
649 },
650 )
651 .expect("oversized timeout must clamp, not panic");
652 let timeout: i64 = conn
653 .pragma_query_value(None, "busy_timeout", |r| r.get(0))
654 .expect("busy_timeout");
655 assert_eq!(timeout, i64::from(i32::MAX));
656 }
657
658 fn preflight_base(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
659 tx.execute_batch("CREATE TABLE IF NOT EXISTS preflight_t (x INTEGER)")
660 }
661
662 const PREFLIGHT_DOMAIN: SchemaDomain = SchemaDomain {
663 name: "preflight-domain",
664 migrations: &[crate::ledger::Migration {
665 version: 1,
666 name: "base",
667 apply: preflight_base,
668 }],
669 initialize_current: preflight_base,
670 allowed_existing_versions: &[1],
671 bridge_recoverable_versions: &[1],
672 released_predecessors: &[],
673 owned_objects: &[crate::ledger::SchemaObject {
674 kind: crate::ledger::SchemaObjectKind::Table,
675 name: "preflight_t",
676 }],
677 retired_objects: &[],
678 };
679
680 #[test]
681 fn schema_preflight_refuses_future_file_before_wal_conversion() {
682 let dir = tempfile::tempdir().expect("tempdir");
683 let path = dir.path().join("db.sqlite3");
684 {
685 let conn = Connection::open(&path).expect("create raw");
692 conn.execute_batch(
693 "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
694 INSERT INTO meerkat_schema VALUES ('preflight-domain', 999);",
695 )
696 .expect("seed future ledger");
697 }
698 let err = open_with(
699 &path,
700 ConnectionProfile::PRIMARY,
701 OpenOptions {
702 schema_preflight: &[&PREFLIGHT_DOMAIN],
703 ..OpenOptions::default()
704 },
705 )
706 .expect_err("future file must be refused");
707 assert!(matches!(
708 err,
709 SqliteStoreError::SchemaFromTheFuture {
710 found: 999,
711 supported: 1,
712 ..
713 }
714 ));
715 let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
718 .expect("reopen raw");
719 let journal: String = conn
720 .pragma_query_value(None, "journal_mode", |r| r.get(0))
721 .expect("journal_mode");
722 assert_eq!(journal, "delete");
723 }
724
725 #[test]
726 fn schema_preflight_allows_fresh_create_and_current_files() {
727 let dir = tempfile::tempdir().expect("tempdir");
728 let path = dir.path().join("fresh.sqlite3");
729 let options = OpenOptions {
730 schema_preflight: &[&PREFLIGHT_DOMAIN],
731 ..OpenOptions::default()
732 };
733 let mut conn = open_with(&path, ConnectionProfile::PRIMARY, options)
736 .expect("fresh create carries no ledger table and passes preflight");
737 crate::ledger::apply_domain_migrations(&mut conn, &PREFLIGHT_DOMAIN).expect("stamp");
738 drop(conn);
739 open_with(&path, ConnectionProfile::PRIMARY, options)
740 .expect("current file passes preflight");
741 }
742
743 #[test]
744 fn schema_preflight_refuses_current_fingerprint_mismatch_before_wal_conversion() {
745 let dir = tempfile::tempdir().expect("tempdir");
746 let path = dir.path().join("partial.sqlite3");
747 {
748 let conn = Connection::open(&path).expect("create raw");
749 conn.execute_batch(
750 "CREATE TABLE preflight_t (x INTEGER, candidate_only TEXT);
751 CREATE TABLE meerkat_schema (
752 domain TEXT PRIMARY KEY,
753 version INTEGER NOT NULL
754 );
755 INSERT INTO meerkat_schema VALUES ('preflight-domain', 1);",
756 )
757 .expect("seed partial current");
758 }
759 let err = open_with(
760 &path,
761 ConnectionProfile::PRIMARY,
762 OpenOptions {
763 schema_preflight: &[&PREFLIGHT_DOMAIN],
764 ..OpenOptions::default()
765 },
766 )
767 .expect_err("partial current must be refused");
768 assert!(matches!(
769 err,
770 SqliteStoreError::CurrentSchemaMismatch { version: 1, ref changed_objects, .. }
771 if changed_objects == &vec!["table:preflight_t".to_string()]
772 ));
773 let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
774 .expect("reopen raw");
775 let journal: String = conn
776 .pragma_query_value(None, "journal_mode", |row| row.get(0))
777 .expect("journal mode");
778 assert_eq!(journal, "delete");
779 }
780
781 #[test]
782 fn primary_in_memory_reports_memory_journal_and_opens() {
783 let conn = open(Path::new(":memory:"), ConnectionProfile::PRIMARY).expect("open");
786 let journal: String = conn
787 .pragma_query_value(None, "journal_mode", |r| r.get(0))
788 .expect("journal_mode");
789 assert_eq!(journal, "memory");
790 }
791
792 #[test]
793 fn write_contact_names_the_wal_sidecar_caveat() {
794 assert_eq!(
795 ConnectionProfile::PRIMARY.write_contact(),
796 WriteContact::ReadWrite
797 );
798 assert_eq!(
799 ConnectionProfile::Primary { create: false }.write_contact(),
800 WriteContact::ReadWrite
801 );
802 assert_eq!(
803 ConnectionProfile::Maintenance { write: true }.write_contact(),
804 WriteContact::ReadWrite
805 );
806 assert_eq!(
807 ConnectionProfile::ReadOnly.write_contact(),
808 WriteContact::ReadOnlyWalSidecars
809 );
810 assert_eq!(
811 ConnectionProfile::Maintenance { write: false }.write_contact(),
812 WriteContact::ReadOnlyWalSidecars
813 );
814 }
815
816 #[test]
817 fn journal_policy_is_pinned_for_every_profile() {
818 for (profile, expected) in [
823 (ConnectionProfile::PRIMARY, JournalPolicy::EstablishWal),
824 (
825 ConnectionProfile::Primary { create: false },
826 JournalPolicy::EstablishWal,
827 ),
828 (ConnectionProfile::ReadOnly, JournalPolicy::PreserveExisting),
829 (
830 ConnectionProfile::Maintenance { write: true },
831 JournalPolicy::PreserveExisting,
832 ),
833 (
834 ConnectionProfile::Maintenance { write: false },
835 JournalPolicy::PreserveExisting,
836 ),
837 ] {
838 assert_eq!(profile.journal_policy(), expected, "{profile:?}");
839 }
840 }
841
842 #[test]
843 fn primary_open_converts_an_existing_delete_mode_database_and_keeps_its_content() {
844 let dir = tempfile::tempdir().expect("tempdir");
847 let path = dir.path().join("legacy.sqlite3");
848 seed_delete_mode_database(&path);
849
850 let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
851 assert_eq!(journal_mode(&conn), "wal");
852 let row: i64 = conn
853 .query_row("SELECT x FROM legacy", [], |row| row.get(0))
854 .expect("content survives the conversion");
855 assert_eq!(row, 7);
856 drop(conn);
857
858 let reopened = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
861 assert_eq!(journal_mode(&reopened), "wal");
862 }
863
864 #[test]
865 fn non_wal_profiles_leave_an_existing_delete_mode_file_alone() {
866 let dir = tempfile::tempdir().expect("tempdir");
870 let path = dir.path().join("legacy.sqlite3");
871 seed_delete_mode_database(&path);
872
873 for profile in [
874 ConnectionProfile::Maintenance { write: true },
875 ConnectionProfile::Maintenance { write: false },
876 ConnectionProfile::ReadOnly,
877 ] {
878 let conn = open(&path, profile).expect("open");
879 assert_eq!(journal_mode(&conn), "delete", "{profile:?}");
880 }
881 }
882
883 #[test]
884 fn wal_database_at_rest_has_no_sidecars_and_still_reports_wal() {
885 let dir = tempfile::tempdir().expect("tempdir");
890 let path = dir.path().join("at-rest.sqlite3");
891 {
892 let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
893 conn.execute_batch("CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);")
894 .expect("write");
895 assert_eq!(journal_mode(&conn), "wal");
896 }
897
898 let (wal, shm) = sidecar_paths(&path);
899 assert!(!wal.exists(), "a closed WAL database keeps no -wal");
900 assert!(!shm.exists(), "a closed WAL database keeps no -shm");
901
902 let header = std::fs::read(&path).expect("read header");
905 assert_eq!(header.get(18).copied(), Some(2), "read version");
906 assert_eq!(header.get(19).copied(), Some(2), "write version");
907
908 assert_eq!(
910 journal_mode(&open(&path, ConnectionProfile::ReadOnly).expect("read-only reopen")),
911 "wal"
912 );
913 assert_eq!(
914 journal_mode(&open(&path, ConnectionProfile::PRIMARY).expect("primary reopen")),
915 "wal"
916 );
917 }
918
919 #[test]
920 fn wal_conversion_that_cannot_win_the_lock_fails_typed() {
921 let dir = tempfile::tempdir().expect("tempdir");
922 let path = dir.path().join("contended.sqlite3");
923 seed_delete_mode_database(&path);
924
925 let reader = Connection::open(&path).expect("reader");
930 reader
931 .execute_batch("BEGIN; SELECT count(*) FROM legacy;")
932 .expect("hold the shared lock");
933
934 let err = open_with(
935 &path,
936 ConnectionProfile::PRIMARY,
937 OpenOptions {
938 busy_timeout: Some(Duration::ZERO),
939 ..OpenOptions::default()
940 },
941 )
942 .expect_err("a durable read-write open must fail closed, not run in delete mode");
943 assert!(
944 matches!(
945 err,
946 SqliteStoreError::WalConversionContended { path: ref refused, waited_ms, .. }
947 if refused == &path && waited_ms >= 250
948 ),
949 "{err}"
950 );
951
952 reader.execute_batch("COMMIT").expect("release");
954 assert_eq!(journal_mode(&reader), "delete");
955 let row: i64 = reader
956 .query_row("SELECT x FROM legacy", [], |row| row.get(0))
957 .expect("content untouched");
958 assert_eq!(row, 7);
959 }
960}