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