koan_core/db/connection.rs
1use std::path::Path;
2
3use rusqlite::Connection;
4use thiserror::Error;
5
6use super::schema;
7use crate::config;
8
9#[derive(Debug, Error)]
10pub enum DbError {
11 #[error("sqlite error: {0}")]
12 Sqlite(#[from] rusqlite::Error),
13 #[error("io error: {0}")]
14 Io(#[from] std::io::Error),
15 /// A bulk delete looked like a mount failure rather than an intentional
16 /// deletion, so it was refused. The library is untouched.
17 #[error("refused unsafe bulk delete: {0}")]
18 UnsafeBulkDelete(String),
19}
20
21/// Wrapper around a SQLite connection with koan's schema applied.
22pub struct Database {
23 pub conn: Connection,
24}
25
26impl Database {
27 /// Open (or create) a database at the given path, applying the schema and
28 /// pending migrations.
29 ///
30 /// This is the once-per-process path: it creates the parent directory,
31 /// tightens file permissions, checkpoints the WAL and runs the ~30-statement
32 /// DDL batch. Anything opening a connection per request wants
33 /// [`Database::open_existing`] instead.
34 pub fn open(path: &Path) -> Result<Self, DbError> {
35 if let Some(parent) = path.parent() {
36 std::fs::create_dir_all(parent)?;
37 }
38
39 let conn = Connection::open(path)?;
40
41 #[cfg(unix)]
42 {
43 use std::os::unix::fs::PermissionsExt;
44 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
45 }
46
47 configure(&conn)?;
48
49 // Attempt a passive WAL checkpoint on open. This is non-blocking — it
50 // moves WAL pages back to the main DB file only if no readers/writers
51 // are active, preventing unbounded WAL growth across sessions.
52 let _ = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE)");
53
54 schema::create_tables(&conn)?;
55
56 Ok(Self { conn })
57 }
58
59 /// Open an additional connection to a database whose schema is already
60 /// applied — pragmas only, no DDL, no checkpoint, no permission syscall.
61 ///
62 /// Callers are responsible for having run [`Database::open`] at least once
63 /// against the same path first.
64 pub fn open_existing(path: &Path) -> Result<Self, DbError> {
65 let conn = Connection::open(path)?;
66 configure(&conn)?;
67 Ok(Self { conn })
68 }
69
70 /// Open the default database at the standard data directory.
71 pub fn open_default() -> Result<Self, DbError> {
72 Self::open(&config::db_path())
73 }
74}
75
76/// Connection-scoped pragmas. Every connection needs these; none of them touch
77/// the file on disk, so they are cheap enough to repeat per connection.
78fn configure(conn: &Connection) -> Result<(), DbError> {
79 // WAL mode for concurrent reads + single writer.
80 conn.pragma_update(None, "journal_mode", "wal")?;
81 conn.pragma_update(None, "foreign_keys", "on")?;
82 // Long enough to outlast a scan chunk: a writer that gives up mid-scan
83 // silently loses favourites, queue state and play counts.
84 conn.pragma_update(None, "busy_timeout", 30000)?;
85 // Slightly faster at the cost of durability on power loss (acceptable for a media DB).
86 conn.pragma_update(None, "synchronous", "normal")?;
87 Ok(())
88}