Skip to main content

reifydb_sqlite/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2025 ReifyDB
3
4#[cfg(not(target_os = "linux"))]
5use std::env;
6use std::path::{Path, PathBuf};
7
8use uuid::Uuid;
9
10#[cfg(not(target_arch = "wasm32"))]
11pub mod connection;
12#[cfg(not(target_arch = "wasm32"))]
13pub mod error;
14#[cfg(not(target_arch = "wasm32"))]
15pub mod pragma;
16
17/// Where the SQLite database file lives on disk.
18#[derive(Debug, Clone, Eq, PartialEq)]
19pub enum DbPath {
20	/// A regular file path.
21	File(PathBuf),
22	/// Tmpfs-backed file for WAL support + automatic cleanup.
23	Tmpfs(PathBuf),
24	/// RAM-backed file for storage with WAL support + automatic cleanup.
25	Memory(PathBuf),
26}
27
28fn memory_dir() -> PathBuf {
29	#[cfg(target_os = "linux")]
30	{
31		PathBuf::from("/dev/shm")
32	}
33	#[cfg(not(target_os = "linux"))]
34	{
35		env::temp_dir()
36	}
37}
38
39/// Configuration for a SQLite storage backend.
40#[derive(Debug, Clone)]
41pub struct SqliteConfig {
42	pub path: DbPath,
43	pub flags: OpenFlags,
44	pub journal_mode: JournalMode,
45	pub synchronous_mode: SynchronousMode,
46	pub temp_store: TempStore,
47	pub cache_size: u32,
48	pub wal_autocheckpoint: u32,
49	pub page_size: u32,
50	pub mmap_size: u64,
51}
52
53impl SqliteConfig {
54	/// Balanced production defaults.
55	/// - WAL journal + NORMAL synchronous: durable across crashes, one fsync per commit
56	/// - 8 MiB page cache (2000 pages * 4 KiB) covers a typical hot working set
57	/// - 64 MiB mmap window for fast cold reads
58	/// - 4 KiB pages match the kernel page size
59	/// - Override `cache_size` / `mmap_size` via the fluent builder for unusual workloads.
60	pub fn new<P: AsRef<Path>>(path: P) -> Self {
61		Self {
62			path: DbPath::File(path.as_ref().to_path_buf()),
63			flags: OpenFlags::default(),
64			journal_mode: JournalMode::Wal,
65			synchronous_mode: SynchronousMode::Normal,
66			temp_store: TempStore::Memory,
67			cache_size: 2000,
68			wal_autocheckpoint: 1000,
69			page_size: 4096,
70			mmap_size: 64 * 1024 * 1024,
71		}
72	}
73
74	/// Safety-first configuration optimized for data integrity.
75	/// - WAL journal mode for crash recovery
76	/// - FULL synchronous mode forces fsync on every commit
77	/// - FILE temp store so a big sort cannot blow up RSS
78	/// - mmap disabled: reads must go through the fsync-respecting page cache
79	pub fn safe<P: AsRef<Path>>(path: P) -> Self {
80		Self {
81			path: DbPath::File(path.as_ref().to_path_buf()),
82			flags: OpenFlags::default(),
83			journal_mode: JournalMode::Wal,
84			synchronous_mode: SynchronousMode::Full,
85			temp_store: TempStore::File,
86			cache_size: 2000,
87			wal_autocheckpoint: 1000,
88			page_size: 4096,
89			mmap_size: 0,
90		}
91	}
92
93	/// High-performance configuration optimized for throughput.
94	/// - WAL journal so a crash can still replay batched writes
95	/// - OFF synchronous mode skips fsync entirely (data may be lost on power loss)
96	/// - 16 KiB pages cut per-page metadata overhead for large tables
97	/// - 160 MiB page cache (10000 pages * 16 KiB) and 256 MiB mmap window
98	/// - WAL allowed to grow up to 10000 pages before checkpoint
99	pub fn fast<P: AsRef<Path>>(path: P) -> Self {
100		Self {
101			path: DbPath::File(path.as_ref().to_path_buf()),
102			flags: OpenFlags::default(),
103			journal_mode: JournalMode::Wal,
104			synchronous_mode: SynchronousMode::Off,
105			temp_store: TempStore::Memory,
106			cache_size: 10000,
107			wal_autocheckpoint: 10000,
108			page_size: 16384,
109			mmap_size: 256 * 1024 * 1024,
110		}
111	}
112
113	/// Tmpfs-backed configuration for ephemeral database storage.
114	/// Uses /tmp (often tmpfs). The DB file already lives in RAM, so mmap is
115	/// disabled; mmap'ing a tmpfs file would just give the process a second
116	/// resident copy of every page.
117	pub fn tmpfs() -> Self {
118		Self {
119			path: DbPath::Tmpfs(PathBuf::from(format!("/tmp/reifydb_{}.db", Uuid::new_v4()))),
120			flags: OpenFlags::default(),
121			journal_mode: JournalMode::Wal,
122			synchronous_mode: SynchronousMode::Off,
123			temp_store: TempStore::Memory,
124			cache_size: 2000,
125			wal_autocheckpoint: 10000,
126			page_size: 16384,
127			mmap_size: 0,
128		}
129	}
130
131	/// In-memory configuration backed by /dev/shm on Linux, temp dir elsewhere.
132	/// Same reasoning as `tmpfs`: the file lives in RAM, so mmap is disabled
133	/// to avoid the second resident copy.
134	pub fn in_memory() -> Self {
135		Self {
136			path: DbPath::Memory(memory_dir().join(format!("reifydb_{}.db", Uuid::new_v4()))),
137			flags: OpenFlags::default(),
138			journal_mode: JournalMode::Wal,
139			synchronous_mode: SynchronousMode::Off,
140			temp_store: TempStore::Memory,
141			cache_size: 2000,
142			wal_autocheckpoint: 10000,
143			page_size: 16384,
144			mmap_size: 0,
145		}
146	}
147
148	/// Test configuration with an in-memory database and minimal cache.
149	/// Uses /dev/shm on Linux, temp dir on other platforms.
150	pub fn test() -> Self {
151		Self {
152			path: DbPath::Memory(memory_dir().join(format!("reifydb_{}.db", Uuid::new_v4()))),
153			flags: OpenFlags::default(),
154			journal_mode: JournalMode::Wal,
155			synchronous_mode: SynchronousMode::Off,
156			temp_store: TempStore::Memory,
157			cache_size: 1000,
158			wal_autocheckpoint: 10000,
159			page_size: 4096,
160			mmap_size: 0,
161		}
162	}
163
164	pub fn path<P: AsRef<Path>>(mut self, path: P) -> Self {
165		self.path = DbPath::File(path.as_ref().to_path_buf());
166		self
167	}
168
169	pub fn flags(mut self, flags: OpenFlags) -> Self {
170		self.flags = flags;
171		self
172	}
173
174	pub fn journal_mode(mut self, mode: JournalMode) -> Self {
175		self.journal_mode = mode;
176		self
177	}
178
179	pub fn synchronous_mode(mut self, mode: SynchronousMode) -> Self {
180		self.synchronous_mode = mode;
181		self
182	}
183
184	pub fn temp_store(mut self, store: TempStore) -> Self {
185		self.temp_store = store;
186		self
187	}
188
189	pub fn cache_size(mut self, size_kb: u32) -> Self {
190		self.cache_size = size_kb;
191		self
192	}
193
194	pub fn wal_autocheckpoint(mut self, pages: u32) -> Self {
195		self.wal_autocheckpoint = pages;
196		self
197	}
198
199	/// Set the page size in bytes (must be a power of 2 between 512 and 65536).
200	/// Must be set before the database is created; changing the page size
201	/// on an existing database requires a VACUUM.
202	pub fn page_size(mut self, size: u32) -> Self {
203		self.page_size = size;
204		self
205	}
206
207	/// Memory-mapped I/O size in bytes (0 = disabled).
208	pub fn mmap_size(mut self, size: u64) -> Self {
209		self.mmap_size = size;
210		self
211	}
212}
213
214impl Default for SqliteConfig {
215	fn default() -> Self {
216		Self::new("reifydb.db")
217	}
218}
219
220/// SQLite database open flags.
221#[derive(Debug, Clone)]
222pub struct OpenFlags {
223	pub read_write: bool,
224	pub create: bool,
225	pub full_mutex: bool,
226	pub no_mutex: bool,
227	pub shared_cache: bool,
228	pub private_cache: bool,
229	pub uri: bool,
230}
231
232impl OpenFlags {
233	pub fn new() -> Self {
234		Self::default()
235	}
236
237	pub fn read_write(mut self, enabled: bool) -> Self {
238		self.read_write = enabled;
239		self
240	}
241
242	pub fn create(mut self, enabled: bool) -> Self {
243		self.create = enabled;
244		self
245	}
246
247	pub fn full_mutex(mut self, enabled: bool) -> Self {
248		self.full_mutex = enabled;
249		self.no_mutex = !enabled;
250		self
251	}
252
253	pub fn no_mutex(mut self, enabled: bool) -> Self {
254		self.no_mutex = enabled;
255		self.full_mutex = !enabled;
256		self
257	}
258
259	pub fn shared_cache(mut self, enabled: bool) -> Self {
260		self.shared_cache = enabled;
261		self.private_cache = !enabled;
262		self
263	}
264
265	pub fn private_cache(mut self, enabled: bool) -> Self {
266		self.private_cache = enabled;
267		self.shared_cache = !enabled;
268		self
269	}
270
271	pub fn uri(mut self, enabled: bool) -> Self {
272		self.uri = enabled;
273		self
274	}
275}
276
277impl Default for OpenFlags {
278	fn default() -> Self {
279		Self {
280			read_write: true,
281			create: true,
282			full_mutex: true,
283			no_mutex: false,
284			shared_cache: false,
285			private_cache: false,
286			uri: false,
287		}
288	}
289}
290
291/// SQLite journal mode options.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum JournalMode {
294	Delete,
295	Truncate,
296	Persist,
297	Memory,
298	Wal,
299	Off,
300}
301
302impl JournalMode {
303	pub fn as_str(&self) -> &'static str {
304		match self {
305			JournalMode::Delete => "DELETE",
306			JournalMode::Truncate => "TRUNCATE",
307			JournalMode::Persist => "PERSIST",
308			JournalMode::Memory => "MEMORY",
309			JournalMode::Wal => "WAL",
310			JournalMode::Off => "OFF",
311		}
312	}
313}
314
315/// SQLite synchronous mode options.
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum SynchronousMode {
318	Off,
319	Normal,
320	Full,
321	Extra,
322}
323
324impl SynchronousMode {
325	pub fn as_str(&self) -> &'static str {
326		match self {
327			SynchronousMode::Off => "OFF",
328			SynchronousMode::Normal => "NORMAL",
329			SynchronousMode::Full => "FULL",
330			SynchronousMode::Extra => "EXTRA",
331		}
332	}
333}
334
335/// SQLite temporary storage location.
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub enum TempStore {
338	Default,
339	File,
340	Memory,
341}
342
343impl TempStore {
344	pub fn as_str(&self) -> &'static str {
345		match self {
346			TempStore::Default => "DEFAULT",
347			TempStore::File => "FILE",
348			TempStore::Memory => "MEMORY",
349		}
350	}
351}
352
353#[cfg(test)]
354mod tests {
355	use reifydb_testing::tempdir::temp_dir;
356
357	use super::*;
358
359	#[test]
360	fn test_config_fluent_api() {
361		let config = SqliteConfig::new("/tmp/test.reifydb")
362			.journal_mode(JournalMode::Wal)
363			.synchronous_mode(SynchronousMode::Normal)
364			.temp_store(TempStore::Memory)
365			.cache_size(30000)
366			.flags(OpenFlags::new().read_write(true).create(true).full_mutex(true));
367
368		assert_eq!(config.path, DbPath::File(PathBuf::from("/tmp/test.reifydb")));
369		assert_eq!(config.journal_mode, JournalMode::Wal);
370		assert_eq!(config.synchronous_mode, SynchronousMode::Normal);
371		assert_eq!(config.temp_store, TempStore::Memory);
372		assert_eq!(config.cache_size, 30000);
373		assert!(config.flags.read_write);
374		assert!(config.flags.create);
375		assert!(config.flags.full_mutex);
376	}
377
378	#[test]
379	fn test_enum_string_conversion() {
380		assert_eq!(JournalMode::Wal.as_str(), "WAL");
381		assert_eq!(SynchronousMode::Normal.as_str(), "NORMAL");
382		assert_eq!(TempStore::Memory.as_str(), "MEMORY");
383	}
384
385	#[test]
386	fn test_all_journal_modes() {
387		assert_eq!(JournalMode::Delete.as_str(), "DELETE");
388		assert_eq!(JournalMode::Truncate.as_str(), "TRUNCATE");
389		assert_eq!(JournalMode::Persist.as_str(), "PERSIST");
390		assert_eq!(JournalMode::Memory.as_str(), "MEMORY");
391		assert_eq!(JournalMode::Wal.as_str(), "WAL");
392		assert_eq!(JournalMode::Off.as_str(), "OFF");
393	}
394
395	#[test]
396	fn test_all_synchronous_modes() {
397		assert_eq!(SynchronousMode::Off.as_str(), "OFF");
398		assert_eq!(SynchronousMode::Normal.as_str(), "NORMAL");
399		assert_eq!(SynchronousMode::Full.as_str(), "FULL");
400		assert_eq!(SynchronousMode::Extra.as_str(), "EXTRA");
401	}
402
403	#[test]
404	fn test_all_temp_store_modes() {
405		assert_eq!(TempStore::Default.as_str(), "DEFAULT");
406		assert_eq!(TempStore::File.as_str(), "FILE");
407		assert_eq!(TempStore::Memory.as_str(), "MEMORY");
408	}
409
410	#[test]
411	fn test_default_config() {
412		let config = SqliteConfig::default();
413		assert_eq!(config.path, DbPath::File(PathBuf::from("reifydb.db")));
414		assert_eq!(config.journal_mode, JournalMode::Wal);
415		assert_eq!(config.synchronous_mode, SynchronousMode::Normal);
416		assert_eq!(config.temp_store, TempStore::Memory);
417	}
418
419	#[test]
420	fn test_safe_config() {
421		temp_dir(|db_path| {
422			let db_file = db_path.join("safe.reifydb");
423			let config = SqliteConfig::safe(&db_file);
424
425			assert_eq!(config.path, DbPath::File(db_file));
426			assert_eq!(config.journal_mode, JournalMode::Wal);
427			assert_eq!(config.synchronous_mode, SynchronousMode::Full);
428			assert_eq!(config.temp_store, TempStore::File);
429			Ok(())
430		})
431		.expect("test failed");
432	}
433
434	#[test]
435	fn test_fast_config() {
436		temp_dir(|db_path| {
437			let db_file = db_path.join("fast.reifydb");
438			let config = SqliteConfig::fast(&db_file);
439
440			assert_eq!(config.path, DbPath::File(db_file));
441			assert_eq!(config.journal_mode, JournalMode::Wal);
442			assert_eq!(config.synchronous_mode, SynchronousMode::Off);
443			assert_eq!(config.temp_store, TempStore::Memory);
444			Ok(())
445		})
446		.expect("test failed");
447	}
448
449	#[test]
450	fn test_tmpfs_config() {
451		let config = SqliteConfig::tmpfs();
452
453		match config.path {
454			DbPath::Tmpfs(path) => {
455				assert!(path.to_string_lossy().starts_with("/tmp/reifydb_"));
456				assert!(path.to_string_lossy().ends_with(".db"));
457			}
458			_ => panic!("Expected DbPath::Tmpfs variant"),
459		}
460
461		assert_eq!(config.journal_mode, JournalMode::Wal);
462		assert_eq!(config.synchronous_mode, SynchronousMode::Off);
463		assert_eq!(config.temp_store, TempStore::Memory);
464		assert_eq!(config.cache_size, 2000);
465		assert_eq!(config.wal_autocheckpoint, 10000);
466	}
467
468	#[test]
469	fn test_config_chaining() {
470		temp_dir(|db_path| {
471			let db_file = db_path.join("chain.reifydb");
472
473			let config = SqliteConfig::new(&db_file)
474				.journal_mode(JournalMode::Delete)
475				.synchronous_mode(SynchronousMode::Extra)
476				.temp_store(TempStore::File)
477				.flags(OpenFlags::new().read_write(false).create(false).shared_cache(true));
478
479			assert_eq!(config.journal_mode, JournalMode::Delete);
480			assert_eq!(config.synchronous_mode, SynchronousMode::Extra);
481			assert_eq!(config.temp_store, TempStore::File);
482			assert!(!config.flags.read_write);
483			assert!(!config.flags.create);
484			assert!(config.flags.shared_cache);
485			Ok(())
486		})
487		.expect("test failed");
488	}
489
490	#[test]
491	fn test_open_flags_mutex_exclusivity() {
492		let flags = OpenFlags::new().full_mutex(true);
493		assert!(flags.full_mutex);
494		assert!(!flags.no_mutex);
495
496		let flags = OpenFlags::new().no_mutex(true);
497		assert!(!flags.full_mutex);
498		assert!(flags.no_mutex);
499	}
500
501	#[test]
502	fn test_open_flags_cache_exclusivity() {
503		let flags = OpenFlags::new().shared_cache(true);
504		assert!(flags.shared_cache);
505		assert!(!flags.private_cache);
506
507		let flags = OpenFlags::new().private_cache(true);
508		assert!(!flags.shared_cache);
509		assert!(flags.private_cache);
510	}
511
512	#[test]
513	fn test_open_flags_all_combinations() {
514		let flags =
515			OpenFlags::new().read_write(true).create(true).full_mutex(true).shared_cache(true).uri(true);
516
517		assert!(flags.read_write);
518		assert!(flags.create);
519		assert!(flags.full_mutex);
520		assert!(!flags.no_mutex);
521		assert!(flags.shared_cache);
522		assert!(!flags.private_cache);
523		assert!(flags.uri);
524	}
525
526	#[test]
527	fn test_path_handling() {
528		temp_dir(|db_path| {
529			let file_path = db_path.join("test.reifydb");
530			let config = SqliteConfig::new(&file_path);
531			assert_eq!(config.path, DbPath::File(file_path));
532
533			let config = SqliteConfig::new(db_path);
534			assert_eq!(config.path, DbPath::File(db_path.to_path_buf()));
535			Ok(())
536		})
537		.expect("test failed");
538	}
539}