1use std::path::Path;
62use std::time::Duration;
63
64use rusqlite::{Connection, OpenFlags, Transaction, TransactionBehavior};
65
66use crate::error::SqliteStoreError;
67use crate::ledger::SchemaDomain;
68
69pub const SHARED_BUSY_TIMEOUT: Duration = Duration::from_millis(60_000);
78
79const MAX_BUSY_TIMEOUT: Duration = Duration::from_millis(i32::MAX as u64);
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum ConnectionProfile {
90 Primary { create: bool },
95 ReadOnly,
101 Maintenance { write: bool },
105 OnlineExistingWriter,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum JournalPolicy {
118 EstablishWal,
123 PreserveExisting,
125 RequireExistingWal,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum WriteContact {
134 ReadWrite,
137 ReadOnlyWalSidecars,
146}
147
148impl ConnectionProfile {
149 pub const PRIMARY: Self = Self::Primary { create: true };
151
152 fn name(self) -> &'static str {
153 match self {
154 Self::Primary { create: true } => "primary",
155 Self::Primary { create: false } => "primary(no-create)",
156 Self::ReadOnly => "read-only",
157 Self::Maintenance { write: true } => "maintenance(write)",
158 Self::Maintenance { write: false } => "maintenance(read)",
159 Self::OnlineExistingWriter => "online-existing-writer",
160 }
161 }
162
163 fn default_busy_timeout(self) -> Duration {
164 match self {
165 Self::Primary { .. } | Self::ReadOnly => SHARED_BUSY_TIMEOUT,
166 Self::Maintenance { .. } | Self::OnlineExistingWriter => Duration::ZERO,
167 }
168 }
169
170 pub fn journal_policy(self) -> JournalPolicy {
183 match self {
184 Self::Primary { .. } => JournalPolicy::EstablishWal,
185 Self::OnlineExistingWriter => JournalPolicy::RequireExistingWal,
186 Self::ReadOnly | Self::Maintenance { .. } => JournalPolicy::PreserveExisting,
187 }
188 }
189
190 pub fn write_contact(self) -> WriteContact {
192 match self {
193 Self::Primary { .. }
194 | Self::Maintenance { write: true }
195 | Self::OnlineExistingWriter => WriteContact::ReadWrite,
196 Self::ReadOnly | Self::Maintenance { write: false } => {
197 WriteContact::ReadOnlyWalSidecars
198 }
199 }
200 }
201}
202
203#[derive(Debug, Clone, Copy, Default)]
206pub struct OpenOptions {
207 pub busy_timeout: Option<Duration>,
213 pub schema_preflight: &'static [&'static SchemaDomain],
228}
229
230pub fn open(path: &Path, profile: ConnectionProfile) -> Result<Connection, SqliteStoreError> {
232 open_with(path, profile, OpenOptions::default())
233}
234
235pub fn open_with(
237 path: &Path,
238 profile: ConnectionProfile,
239 options: OpenOptions,
240) -> Result<Connection, SqliteStoreError> {
241 if profile == ConnectionProfile::OnlineExistingWriter
242 && options
243 .busy_timeout
244 .is_some_and(|timeout| !timeout.is_zero())
245 {
246 return Err(SqliteStoreError::Io(std::io::Error::new(
247 std::io::ErrorKind::InvalidInput,
248 "online existing writer requires zero busy timeout",
249 )));
250 }
251 let expected = validate_database_file(path)?;
255 let conn = match profile {
256 ConnectionProfile::Primary { create: true } => {
257 if let Some(parent) = path.parent() {
258 std::fs::create_dir_all(parent)?;
259 }
260 Connection::open(path)?
261 }
262 ConnectionProfile::Primary { create: false } | ConnectionProfile::OnlineExistingWriter => {
263 open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_WRITE)?
264 }
265 ConnectionProfile::ReadOnly => {
266 open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_ONLY)?
267 }
268 ConnectionProfile::Maintenance { write } => {
269 let base = if write {
270 OpenFlags::SQLITE_OPEN_READ_WRITE
271 } else {
272 OpenFlags::SQLITE_OPEN_READ_ONLY
273 };
274 open_existing(path, profile, base)?
275 }
276 };
277
278 let busy = options
279 .busy_timeout
280 .unwrap_or_else(|| profile.default_busy_timeout())
281 .min(MAX_BUSY_TIMEOUT);
282 conn.busy_timeout(busy)?;
283 let opened = validate_database_file(path)?;
284 if let (Some(expected), Some(opened)) = (expected, opened)
285 && expected != opened
286 {
287 return Err(SqliteStoreError::UnsupportedDatabaseFile {
288 path: path.to_path_buf(),
289 detail: "the database file was replaced while it was being opened".to_string(),
290 });
291 }
292
293 for domain in options.schema_preflight {
301 if profile == ConnectionProfile::OnlineExistingWriter {
302 crate::ledger::try_preflight_current_schema(&conn, domain)?;
303 } else {
304 crate::ledger::preflight_schema_eligibility(&conn, domain)?;
305 }
306 }
307
308 match profile.journal_policy() {
309 JournalPolicy::EstablishWal => {
310 set_wal_journal_mode(&conn, path, busy)?;
311 conn.pragma_update(None, "synchronous", "FULL")?;
314 }
315 JournalPolicy::PreserveExisting => {}
316 JournalPolicy::RequireExistingWal => {
317 let mode =
318 conn.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))?;
319 if !mode.eq_ignore_ascii_case("wal") {
320 return Err(SqliteStoreError::WalNotEstablished {
321 path: path.to_path_buf(),
322 actual: mode,
323 });
324 }
325 conn.pragma_update(None, "synchronous", "FULL")?;
326 }
327 }
328
329 Ok(conn)
330}
331
332fn set_wal_journal_mode(
346 conn: &Connection,
347 path: &Path,
348 busy_timeout: Duration,
349) -> Result<(), SqliteStoreError> {
350 let budget = busy_timeout.max(Duration::from_millis(250));
353 let deadline = std::time::Instant::now() + budget;
354 loop {
355 let result = conn
361 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0));
362 match result {
363 Ok(mode) if mode.eq_ignore_ascii_case("wal") || mode.eq_ignore_ascii_case("memory") => {
364 return Ok(());
365 }
366 Ok(mode) => {
367 return Err(SqliteStoreError::WalNotEstablished {
368 path: path.to_path_buf(),
369 actual: mode,
370 });
371 }
372 Err(error) if crate::error::is_busy_or_locked(&error) => {
373 if std::time::Instant::now() < deadline {
374 std::thread::sleep(Duration::from_millis(5));
375 continue;
376 }
377 return Err(SqliteStoreError::WalConversionContended {
383 path: path.to_path_buf(),
384 waited_ms: u64::try_from(budget.as_millis()).unwrap_or(u64::MAX),
385 source: error,
386 });
387 }
388 Err(error) => return Err(error.into()),
389 }
390 }
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub struct DatabaseFileIdentity {
396 device: u64,
397 inode: u64,
398}
399
400pub fn validate_database_file(
415 path: &Path,
416) -> Result<Option<DatabaseFileIdentity>, SqliteStoreError> {
417 let refuse = |detail: String| SqliteStoreError::UnsupportedDatabaseFile {
418 path: path.to_path_buf(),
419 detail,
420 };
421 let metadata = match std::fs::metadata(path) {
422 Ok(metadata) => metadata,
423 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
424 Err(error) => return Err(error.into()),
425 };
426 if !metadata.is_file() {
427 return Err(refuse("not a regular file".to_string()));
428 }
429 #[cfg(unix)]
430 {
431 use std::os::unix::fs::MetadataExt as _;
432 if metadata.nlink() > 1 {
433 return Err(refuse(format!(
434 "the file has {} hard links; SQLite needs exactly one name per database",
435 metadata.nlink()
436 )));
437 }
438 Ok(Some(DatabaseFileIdentity {
439 device: metadata.dev(),
440 inode: metadata.ino(),
441 }))
442 }
443 #[cfg(not(unix))]
444 {
445 Ok(None)
446 }
447}
448
449fn open_existing(
450 path: &Path,
451 profile: ConnectionProfile,
452 base: OpenFlags,
453) -> Result<Connection, SqliteStoreError> {
454 if !path.is_file() {
455 return Err(SqliteStoreError::OpenRefused {
456 path: path.to_path_buf(),
457 profile: profile.name(),
458 detail: "database file does not exist".to_string(),
459 });
460 }
461 Ok(Connection::open_with_flags(
462 path,
463 base | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX,
464 )?)
465}
466
467pub fn begin_immediate(conn: &mut Connection) -> Result<Transaction<'_>, SqliteStoreError> {
471 Ok(conn.transaction_with_behavior(TransactionBehavior::Immediate)?)
472}
473
474#[cfg(test)]
475#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
476mod tests {
477 use std::path::PathBuf;
478
479 use super::*;
480
481 #[test]
483 fn database_file_validation_accepts_one_name_and_refuses_others() {
484 let dir = tempfile::tempdir().unwrap();
485 let missing = dir.path().join("missing.sqlite3");
486 assert_eq!(validate_database_file(&missing).unwrap(), None);
487
488 let directory = dir.path().join("directory.sqlite3");
489 std::fs::create_dir(&directory).unwrap();
490 assert!(matches!(
491 validate_database_file(&directory),
492 Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
493 ));
494 assert!(matches!(
495 open_with(
496 &directory,
497 ConnectionProfile::PRIMARY,
498 OpenOptions::default()
499 ),
500 Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
501 ));
502
503 let single = dir.path().join("single.sqlite3");
504 drop(open_with(&single, ConnectionProfile::PRIMARY, OpenOptions::default()).unwrap());
505 #[cfg(unix)]
506 assert!(validate_database_file(&single).unwrap().is_some());
507 drop(open_with(&single, ConnectionProfile::PRIMARY, OpenOptions::default()).unwrap());
508 }
509
510 #[cfg(unix)]
514 #[test]
515 fn hard_linked_database_is_refused_before_any_sqlite_open() {
516 let dir = tempfile::tempdir().unwrap();
517 let original = dir.path().join("original.sqlite3");
518 {
519 let conn = Connection::open(&original).unwrap();
520 conn.execute_batch(
521 "CREATE TABLE canary (value INTEGER); INSERT INTO canary VALUES (1);",
522 )
523 .unwrap();
524 }
525 let linked = dir.path().join("linked.sqlite3");
526 std::fs::hard_link(&original, &linked).unwrap();
527 let bytes = std::fs::read(&original).unwrap();
528 for name in [&original, &linked] {
529 for profile in [
530 ConnectionProfile::PRIMARY,
531 ConnectionProfile::ReadOnly,
532 ConnectionProfile::Maintenance { write: true },
533 ] {
534 assert!(
535 matches!(
536 open_with(name, profile, OpenOptions::default()),
537 Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
538 ),
539 "{} through {}",
540 profile.name(),
541 name.display()
542 );
543 }
544 assert!(matches!(
545 crate::fence::OperationGuard::for_database(name),
546 Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
547 ));
548 let (wal, shm) = sidecar_paths(name);
549 assert!(
550 !wal.exists() && !shm.exists(),
551 "no sidecar for {}",
552 name.display()
553 );
554 }
555 assert_eq!(std::fs::read(&original).unwrap(), bytes);
556 }
557
558 fn sidecar_paths(path: &Path) -> (PathBuf, PathBuf) {
559 let mut wal = path.as_os_str().to_os_string();
560 wal.push("-wal");
561 let mut shm = path.as_os_str().to_os_string();
562 shm.push("-shm");
563 (PathBuf::from(wal), PathBuf::from(shm))
564 }
565
566 fn journal_mode(conn: &Connection) -> String {
567 conn.pragma_query_value(None, "journal_mode", |row| row.get(0))
568 .expect("journal_mode")
569 }
570
571 fn seed_delete_mode_database(path: &Path) {
574 let conn = Connection::open(path).expect("create");
575 conn.execute_batch("CREATE TABLE legacy (x INTEGER); INSERT INTO legacy VALUES (7);")
576 .expect("seed");
577 assert_eq!(journal_mode(&conn), "delete", "fixture must start non-WAL");
578 }
579
580 #[test]
581 fn primary_creates_dirs_sets_wal_and_full() {
582 let dir = tempfile::tempdir().expect("tempdir");
583 let path = dir.path().join("nested/sub/test.sqlite3");
584 let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
585 let journal: String = conn
586 .pragma_query_value(None, "journal_mode", |r| r.get(0))
587 .expect("journal_mode");
588 assert_eq!(journal, "wal");
589 let sync: i64 = conn
590 .pragma_query_value(None, "synchronous", |r| r.get(0))
591 .expect("synchronous");
592 assert_eq!(sync, 2, "synchronous=FULL");
593 }
594
595 #[test]
596 fn non_creating_profiles_refuse_missing_file() {
597 let dir = tempfile::tempdir().expect("tempdir");
598 let path = dir.path().join("missing.sqlite3");
599 for profile in [
600 ConnectionProfile::Primary { create: false },
601 ConnectionProfile::OnlineExistingWriter,
602 ConnectionProfile::ReadOnly,
603 ConnectionProfile::Maintenance { write: true },
604 ConnectionProfile::Maintenance { write: false },
605 ] {
606 let err = open(&path, profile).expect_err("must refuse missing file");
607 assert!(
608 matches!(err, SqliteStoreError::OpenRefused { .. }),
609 "{profile:?}"
610 );
611 }
612 assert!(
613 !path.exists(),
614 "no profile may create the file as a side effect"
615 );
616 }
617
618 #[test]
619 fn read_only_profile_rejects_writes_and_preserves_journal_mode() {
620 let dir = tempfile::tempdir().expect("tempdir");
621 let path = dir.path().join("db.sqlite3");
622 {
623 let conn = Connection::open(&path).expect("create");
624 conn.execute_batch("CREATE TABLE t (x INTEGER)")
625 .expect("ddl");
626 }
627 let conn = open(&path, ConnectionProfile::ReadOnly).expect("open ro");
628 let journal: String = conn
629 .pragma_query_value(None, "journal_mode", |r| r.get(0))
630 .expect("journal_mode");
631 assert_eq!(
632 journal, "delete",
633 "reader must not convert the journal mode"
634 );
635 conn.execute("INSERT INTO t VALUES (1)", [])
636 .expect_err("read-only connection must reject writes");
637 }
638
639 #[test]
640 fn online_existing_writer_requires_wal_without_converting_or_waiting() {
641 let dir = tempfile::tempdir().expect("tempdir");
642 let path = dir.path().join("online.sqlite3");
643 seed_delete_mode_database(&path);
644 assert!(matches!(
645 open(&path, ConnectionProfile::OnlineExistingWriter),
646 Err(SqliteStoreError::WalNotEstablished { actual, .. }) if actual == "delete"
647 ));
648 let observed = open(&path, ConnectionProfile::ReadOnly).expect("unchanged file");
649 assert_eq!(journal_mode(&observed), "delete");
650 drop(observed);
651 drop(open(&path, ConnectionProfile::PRIMARY).expect("ordinary owner establishes WAL"));
652 let online = open(&path, ConnectionProfile::OnlineExistingWriter).expect("existing WAL");
653 assert_eq!(journal_mode(&online), "wal");
654 let timeout: u64 = online
655 .pragma_query_value(None, "busy_timeout", |row| row.get(0))
656 .expect("busy timeout");
657 assert_eq!(timeout, 0);
658 let retained: i64 = online
659 .query_row("SELECT x FROM legacy", [], |row| row.get(0))
660 .expect("original contents");
661 assert_eq!(retained, 7);
662 assert!(matches!(
663 open_with(&path, ConnectionProfile::OnlineExistingWriter, OpenOptions {
664 busy_timeout: Some(Duration::from_millis(1)),
665 ..OpenOptions::default()
666 }),
667 Err(SqliteStoreError::Io(error)) if error.kind() == std::io::ErrorKind::InvalidInput
668 ));
669 }
670
671 #[test]
672 fn maintenance_profile_fails_fast_on_held_lock() {
673 let dir = tempfile::tempdir().expect("tempdir");
674 let path = dir.path().join("db.sqlite3");
675 let mut writer = open(&path, ConnectionProfile::PRIMARY).expect("writer");
676 writer
677 .execute_batch("CREATE TABLE t (x INTEGER)")
678 .expect("ddl");
679 let tx = begin_immediate(&mut writer).expect("hold write lock");
680
681 let maint = open(&path, ConnectionProfile::Maintenance { write: true }).expect("open");
682 let err = maint
683 .execute_batch("BEGIN IMMEDIATE")
684 .expect_err("zero busy timeout must surface the held lock immediately");
685 assert!(crate::error::is_busy_or_locked(&match err {
686 rusqlite::Error::SqliteFailure(..) => err,
687 other => panic!("unexpected error shape: {other}"),
688 }));
689 drop(tx);
690 }
691
692 #[test]
693 fn busy_timeout_override_applies() {
694 let dir = tempfile::tempdir().expect("tempdir");
695 let path = dir.path().join("db.sqlite3");
696 let conn = open_with(
697 &path,
698 ConnectionProfile::PRIMARY,
699 OpenOptions {
700 busy_timeout: Some(Duration::from_millis(1234)),
701 ..OpenOptions::default()
702 },
703 )
704 .expect("open");
705 let timeout: i64 = conn
706 .pragma_query_value(None, "busy_timeout", |r| r.get(0))
707 .expect("busy_timeout");
708 assert_eq!(timeout, 1234);
709 }
710
711 #[test]
712 fn oversized_busy_timeout_is_clamped_not_panicking() {
713 let dir = tempfile::tempdir().expect("tempdir");
714 let path = dir.path().join("db.sqlite3");
715 let conn = open_with(
716 &path,
717 ConnectionProfile::PRIMARY,
718 OpenOptions {
719 busy_timeout: Some(Duration::MAX),
720 ..OpenOptions::default()
721 },
722 )
723 .expect("oversized timeout must clamp, not panic");
724 let timeout: i64 = conn
725 .pragma_query_value(None, "busy_timeout", |r| r.get(0))
726 .expect("busy_timeout");
727 assert_eq!(timeout, i64::from(i32::MAX));
728 }
729
730 fn preflight_base(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
731 tx.execute_batch("CREATE TABLE IF NOT EXISTS preflight_t (x INTEGER)")
732 }
733
734 const PREFLIGHT_DOMAIN: SchemaDomain = SchemaDomain {
735 name: "preflight-domain",
736 migrations: &[crate::ledger::Migration {
737 version: 1,
738 name: "base",
739 apply: preflight_base,
740 }],
741 initialize_current: preflight_base,
742 allowed_existing_versions: &[1],
743 bridge_recoverable_versions: &[1],
744 released_predecessors: &[],
745 owned_objects: &[crate::ledger::SchemaObject {
746 kind: crate::ledger::SchemaObjectKind::Table,
747 name: "preflight_t",
748 }],
749 retired_objects: &[],
750 };
751
752 #[test]
753 fn schema_preflight_refuses_future_file_before_wal_conversion() {
754 let dir = tempfile::tempdir().expect("tempdir");
755 let path = dir.path().join("db.sqlite3");
756 {
757 let conn = Connection::open(&path).expect("create raw");
764 conn.execute_batch(
765 "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
766 INSERT INTO meerkat_schema VALUES ('preflight-domain', 999);",
767 )
768 .expect("seed future ledger");
769 }
770 let err = open_with(
771 &path,
772 ConnectionProfile::PRIMARY,
773 OpenOptions {
774 schema_preflight: &[&PREFLIGHT_DOMAIN],
775 ..OpenOptions::default()
776 },
777 )
778 .expect_err("future file must be refused");
779 assert!(matches!(
780 err,
781 SqliteStoreError::SchemaFromTheFuture {
782 found: 999,
783 supported: 1,
784 ..
785 }
786 ));
787 let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
790 .expect("reopen raw");
791 let journal: String = conn
792 .pragma_query_value(None, "journal_mode", |r| r.get(0))
793 .expect("journal_mode");
794 assert_eq!(journal, "delete");
795 }
796
797 #[test]
798 fn schema_preflight_allows_fresh_create_and_current_files() {
799 let dir = tempfile::tempdir().expect("tempdir");
800 let path = dir.path().join("fresh.sqlite3");
801 let options = OpenOptions {
802 schema_preflight: &[&PREFLIGHT_DOMAIN],
803 ..OpenOptions::default()
804 };
805 let mut conn = open_with(&path, ConnectionProfile::PRIMARY, options)
808 .expect("fresh create carries no ledger table and passes preflight");
809 crate::ledger::apply_domain_migrations(&mut conn, &PREFLIGHT_DOMAIN).expect("stamp");
810 drop(conn);
811 open_with(&path, ConnectionProfile::PRIMARY, options)
812 .expect("current file passes preflight");
813 }
814
815 #[test]
816 fn schema_preflight_refuses_current_fingerprint_mismatch_before_wal_conversion() {
817 let dir = tempfile::tempdir().expect("tempdir");
818 let path = dir.path().join("partial.sqlite3");
819 {
820 let conn = Connection::open(&path).expect("create raw");
821 conn.execute_batch(
822 "CREATE TABLE preflight_t (x INTEGER, candidate_only TEXT);
823 CREATE TABLE meerkat_schema (
824 domain TEXT PRIMARY KEY,
825 version INTEGER NOT NULL
826 );
827 INSERT INTO meerkat_schema VALUES ('preflight-domain', 1);",
828 )
829 .expect("seed partial current");
830 }
831 let err = open_with(
832 &path,
833 ConnectionProfile::PRIMARY,
834 OpenOptions {
835 schema_preflight: &[&PREFLIGHT_DOMAIN],
836 ..OpenOptions::default()
837 },
838 )
839 .expect_err("partial current must be refused");
840 assert!(matches!(
841 err,
842 SqliteStoreError::CurrentSchemaMismatch { version: 1, ref changed_objects, .. }
843 if changed_objects == &vec!["table:preflight_t".to_string()]
844 ));
845 let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
846 .expect("reopen raw");
847 let journal: String = conn
848 .pragma_query_value(None, "journal_mode", |row| row.get(0))
849 .expect("journal mode");
850 assert_eq!(journal, "delete");
851 }
852
853 #[test]
854 fn primary_in_memory_reports_memory_journal_and_opens() {
855 let conn = open(Path::new(":memory:"), ConnectionProfile::PRIMARY).expect("open");
858 let journal: String = conn
859 .pragma_query_value(None, "journal_mode", |r| r.get(0))
860 .expect("journal_mode");
861 assert_eq!(journal, "memory");
862 }
863
864 #[test]
865 fn write_contact_names_the_wal_sidecar_caveat() {
866 assert_eq!(
867 ConnectionProfile::PRIMARY.write_contact(),
868 WriteContact::ReadWrite
869 );
870 assert_eq!(
871 ConnectionProfile::Primary { create: false }.write_contact(),
872 WriteContact::ReadWrite
873 );
874 assert_eq!(
875 ConnectionProfile::Maintenance { write: true }.write_contact(),
876 WriteContact::ReadWrite
877 );
878 assert_eq!(
879 ConnectionProfile::OnlineExistingWriter.write_contact(),
880 WriteContact::ReadWrite
881 );
882 assert_eq!(
883 ConnectionProfile::ReadOnly.write_contact(),
884 WriteContact::ReadOnlyWalSidecars
885 );
886 assert_eq!(
887 ConnectionProfile::Maintenance { write: false }.write_contact(),
888 WriteContact::ReadOnlyWalSidecars
889 );
890 }
891
892 #[test]
893 fn journal_policy_is_pinned_for_every_profile() {
894 for (profile, expected) in [
899 (ConnectionProfile::PRIMARY, JournalPolicy::EstablishWal),
900 (
901 ConnectionProfile::OnlineExistingWriter,
902 JournalPolicy::RequireExistingWal,
903 ),
904 (
905 ConnectionProfile::Primary { create: false },
906 JournalPolicy::EstablishWal,
907 ),
908 (ConnectionProfile::ReadOnly, JournalPolicy::PreserveExisting),
909 (
910 ConnectionProfile::Maintenance { write: true },
911 JournalPolicy::PreserveExisting,
912 ),
913 (
914 ConnectionProfile::Maintenance { write: false },
915 JournalPolicy::PreserveExisting,
916 ),
917 ] {
918 assert_eq!(profile.journal_policy(), expected, "{profile:?}");
919 }
920 }
921
922 #[test]
923 fn primary_open_converts_an_existing_delete_mode_database_and_keeps_its_content() {
924 let dir = tempfile::tempdir().expect("tempdir");
927 let path = dir.path().join("legacy.sqlite3");
928 seed_delete_mode_database(&path);
929
930 let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
931 assert_eq!(journal_mode(&conn), "wal");
932 let row: i64 = conn
933 .query_row("SELECT x FROM legacy", [], |row| row.get(0))
934 .expect("content survives the conversion");
935 assert_eq!(row, 7);
936 drop(conn);
937
938 let reopened = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
941 assert_eq!(journal_mode(&reopened), "wal");
942 }
943
944 #[test]
945 fn non_wal_profiles_leave_an_existing_delete_mode_file_alone() {
946 let dir = tempfile::tempdir().expect("tempdir");
950 let path = dir.path().join("legacy.sqlite3");
951 seed_delete_mode_database(&path);
952
953 for profile in [
954 ConnectionProfile::Maintenance { write: true },
955 ConnectionProfile::Maintenance { write: false },
956 ConnectionProfile::ReadOnly,
957 ] {
958 let conn = open(&path, profile).expect("open");
959 assert_eq!(journal_mode(&conn), "delete", "{profile:?}");
960 }
961 }
962
963 #[test]
964 fn wal_database_at_rest_has_no_sidecars_and_still_reports_wal() {
965 let dir = tempfile::tempdir().expect("tempdir");
970 let path = dir.path().join("at-rest.sqlite3");
971 {
972 let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
973 conn.execute_batch("CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);")
974 .expect("write");
975 assert_eq!(journal_mode(&conn), "wal");
976 }
977
978 let (wal, shm) = sidecar_paths(&path);
979 assert!(!wal.exists(), "a closed WAL database keeps no -wal");
980 assert!(!shm.exists(), "a closed WAL database keeps no -shm");
981
982 let header = std::fs::read(&path).expect("read header");
985 assert_eq!(header.get(18).copied(), Some(2), "read version");
986 assert_eq!(header.get(19).copied(), Some(2), "write version");
987
988 assert_eq!(
990 journal_mode(&open(&path, ConnectionProfile::ReadOnly).expect("read-only reopen")),
991 "wal"
992 );
993 assert_eq!(
994 journal_mode(&open(&path, ConnectionProfile::PRIMARY).expect("primary reopen")),
995 "wal"
996 );
997 }
998
999 #[test]
1000 fn wal_conversion_that_cannot_win_the_lock_fails_typed() {
1001 let dir = tempfile::tempdir().expect("tempdir");
1002 let path = dir.path().join("contended.sqlite3");
1003 seed_delete_mode_database(&path);
1004
1005 let reader = Connection::open(&path).expect("reader");
1010 reader
1011 .execute_batch("BEGIN; SELECT count(*) FROM legacy;")
1012 .expect("hold the shared lock");
1013
1014 let err = open_with(
1015 &path,
1016 ConnectionProfile::PRIMARY,
1017 OpenOptions {
1018 busy_timeout: Some(Duration::ZERO),
1019 ..OpenOptions::default()
1020 },
1021 )
1022 .expect_err("a durable read-write open must fail closed, not run in delete mode");
1023 assert!(
1024 matches!(
1025 err,
1026 SqliteStoreError::WalConversionContended { path: ref refused, waited_ms, .. }
1027 if refused == &path && waited_ms >= 250
1028 ),
1029 "{err}"
1030 );
1031
1032 reader.execute_batch("COMMIT").expect("release");
1034 assert_eq!(journal_mode(&reader), "delete");
1035 let row: i64 = reader
1036 .query_row("SELECT x FROM legacy", [], |row| row.get(0))
1037 .expect("content untouched");
1038 assert_eq!(row, 7);
1039 }
1040}