Skip to main content

koan_core/db/
connection.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::path::Path;
4use std::rc::Rc;
5
6use rusqlite::Connection;
7use rusqlite::functions::FunctionFlags;
8use thiserror::Error;
9
10use super::schema;
11use crate::config;
12
13#[derive(Debug, Error)]
14pub enum DbError {
15    #[error("sqlite error: {0}")]
16    Sqlite(#[from] rusqlite::Error),
17    #[error("io error: {0}")]
18    Io(#[from] std::io::Error),
19    /// A bulk delete looked like a mount failure rather than an intentional
20    /// deletion, so it was refused. The library is untouched.
21    #[error("refused unsafe bulk delete: {0}")]
22    UnsafeBulkDelete(String),
23}
24
25/// Wrapper around a SQLite connection with koan's schema applied.
26pub struct Database {
27    pub conn: Connection,
28}
29
30impl Database {
31    /// Open (or create) a database at the given path, applying the schema and
32    /// pending migrations.
33    ///
34    /// This is the once-per-process path: it creates the parent directory,
35    /// tightens file permissions, checkpoints the WAL and runs the ~30-statement
36    /// DDL batch. Anything opening a connection per request wants
37    /// [`Database::open_existing`] instead.
38    pub fn open(path: &Path) -> Result<Self, DbError> {
39        if let Some(parent) = path.parent() {
40            std::fs::create_dir_all(parent)?;
41        }
42
43        let conn = Connection::open(path)?;
44
45        #[cfg(unix)]
46        {
47            use std::os::unix::fs::PermissionsExt;
48            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
49        }
50
51        configure(&conn)?;
52
53        // Attempt a passive WAL checkpoint on open. This is non-blocking — it
54        // moves WAL pages back to the main DB file only if no readers/writers
55        // are active, preventing unbounded WAL growth across sessions.
56        let _ = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE)");
57
58        schema::create_tables(&conn)?;
59
60        Ok(Self { conn })
61    }
62
63    /// Open an additional connection to a database whose schema is already
64    /// applied — pragmas only, no DDL, no checkpoint, no permission syscall.
65    ///
66    /// Callers are responsible for having run [`Database::open`] at least once
67    /// against the same path first.
68    pub fn open_existing(path: &Path) -> Result<Self, DbError> {
69        let conn = Connection::open(path)?;
70        configure(&conn)?;
71        Ok(Self { conn })
72    }
73
74    /// Open the default database at the standard data directory.
75    pub fn open_default() -> Result<Self, DbError> {
76        Self::open(&config::db_path())
77    }
78}
79
80/// Connection-scoped pragmas. Every connection needs these; none of them touch
81/// the file on disk, so they are cheap enough to repeat per connection.
82fn configure(conn: &Connection) -> Result<(), DbError> {
83    // WAL mode for concurrent reads + single writer.
84    conn.pragma_update(None, "journal_mode", "wal")?;
85    conn.pragma_update(None, "foreign_keys", "on")?;
86    // Long enough to outlast a scan chunk: a writer that gives up mid-scan
87    // silently loses favourites, queue state and play counts.
88    conn.pragma_update(None, "busy_timeout", 30000)?;
89    // Slightly faster at the cost of durability on power loss (acceptable for a media DB).
90    conn.pragma_update(None, "synchronous", "normal")?;
91    // Map the whole library. A library this size fits well inside this, so
92    // reads become dereferences into a mapped region rather than syscalls —
93    // which is the useful sense in which a database can be "in memory". The
94    // page cache was already holding it; this stops copying it out per read.
95    conn.pragma_update(None, "mmap_size", 268_435_456i64)?;
96    // 32 MiB of pages, per connection. Negative means KiB rather than pages,
97    // so the figure does not change meaning with the page size.
98    conn.pragma_update(None, "cache_size", -32_000i64)?;
99    // Sorts and intermediate tables in memory. FTS and the ORDER BYs behind
100    // every library listing make temporary tables constantly.
101    conn.pragma_update(None, "temp_store", "memory")?;
102    // Here rather than with the schema: `open_existing` skips the DDL, and a
103    // connection without this collation fails every ORDER BY that uses it.
104    register_library_collation(conn)?;
105    register_shuffle_function(conn)?;
106    Ok(())
107}
108
109/// `koan_shuffle(id, seed)` — a stable pseudo-random ordering key.
110///
111/// A shuffled listing that is read a page at a time cannot shuffle in the
112/// client: page two would be drawn from a different shuffle than page one, and
113/// records would repeat or vanish as you scrolled. Ordering by a hash of the
114/// row id and a seed gives one order that every page of the same seed agrees
115/// on, and a new seed gives a different one.
116///
117/// Registered next to the collation, and for the same reason: a connection
118/// without it fails the query outright rather than sorting some other way.
119pub(crate) fn register_shuffle_function(conn: &Connection) -> rusqlite::Result<()> {
120    conn.create_scalar_function(
121        "koan_shuffle",
122        2,
123        FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
124        |ctx| {
125            let id = ctx.get::<i64>(0)? as u64;
126            let seed = ctx.get::<i64>(1)? as u64;
127            Ok(splitmix64(id ^ splitmix64(seed)) as i64)
128        },
129    )
130}
131
132/// SplitMix64. Cheap, and it scatters consecutive ids — which matters, because
133/// a library's ids are consecutive in the order it was scanned.
134fn splitmix64(x: u64) -> u64 {
135    let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
136    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
137    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
138    z ^ (z >> 31)
139}
140
141/// A collation for names the way a person reads them.
142///
143/// SQLite's default is a byte comparison, which sorts every capital before
144/// every lowercase and every accented letter after the whole ASCII range — so
145/// an artist list ran `Zebra`, then `aphex twin`, and put `Âme` at the end
146/// where nobody would look for it.
147///
148/// Case is folded, accents are folded onto their base letter (`Âme` sorts with
149/// `Ame`), and runs of digits compare by value so `Track 2` precedes
150/// `Track 10`. Ties fall back to the raw bytes, so two names that differ only
151/// in case or accent still have a stable order rather than being treated as
152/// equal.
153/// Registered by `create_tables`, so every connection has it — a query using
154/// `COLLATE LIBRARY` on a connection that skipped this fails outright rather
155/// than quietly sorting some other way.
156pub(crate) fn register_library_collation(conn: &Connection) -> rusqlite::Result<()> {
157    conn.create_collation("LIBRARY", |a, b| {
158        cached_sort_key(a).cmp(&cached_sort_key(b)).then(a.cmp(b))
159    })
160}
161
162thread_local! {
163    /// Sort keys, kept for the life of the thread.
164    ///
165    /// A collation sees the same name once per level of the sort — around two
166    /// dozen times in a five-thousand-row list — and building a key means an
167    /// NFD pass and a `Vec` of freshly allocated `String`s. Cached, each name is
168    /// folded once per thread instead of once per comparison.
169    static SORT_KEYS: RefCell<HashMap<Box<str>, Rc<[Chunk]>>> = RefCell::new(HashMap::new());
170}
171
172fn cached_sort_key(s: &str) -> Rc<[Chunk]> {
173    SORT_KEYS.with_borrow_mut(|cache| {
174        if let Some(key) = cache.get(s) {
175            return Rc::clone(key);
176        }
177        // A library's worth of names is tens of thousands of entries. Anything
178        // beyond that is a query sorting something other than names, and it
179        // should not grow this without bound.
180        if cache.len() >= 50_000 {
181            cache.clear();
182        }
183        let key: Rc<[Chunk]> = sort_key(s).into();
184        cache.insert(s.into(), Rc::clone(&key));
185        key
186    })
187}
188
189/// One comparable chunk of a name: either a run of digits, as a number, or a
190/// run of folded characters.
191#[derive(PartialEq, Eq, PartialOrd, Ord)]
192enum Chunk {
193    Number(u128),
194    Text(String),
195}
196
197fn sort_key(s: &str) -> Vec<Chunk> {
198    use unicode_normalization::UnicodeNormalization;
199
200    // NFD splits an accented letter into its base plus a combining mark; dropping
201    // the marks leaves the base letter to sort on.
202    let folded: String = s
203        .nfd()
204        .filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
205        .flat_map(char::to_lowercase)
206        .collect();
207
208    let mut chunks = Vec::new();
209    let mut rest = folded.as_str();
210    while !rest.is_empty() {
211        let digits = rest
212            .find(|c: char| !c.is_ascii_digit())
213            .unwrap_or(rest.len());
214        if digits > 0 && rest.starts_with(|c: char| c.is_ascii_digit()) {
215            // Absurdly long digit runs are not numbers anyone sorts by.
216            match rest[..digits].parse::<u128>() {
217                Ok(n) => chunks.push(Chunk::Number(n)),
218                Err(_) => chunks.push(Chunk::Text(rest[..digits].to_string())),
219            }
220            rest = &rest[digits..];
221            continue;
222        }
223        let text = rest
224            .find(|c: char| c.is_ascii_digit())
225            .unwrap_or(rest.len())
226            .max(1);
227        chunks.push(Chunk::Text(rest[..text].to_string()));
228        rest = &rest[text..];
229    }
230    chunks
231}
232
233#[cfg(test)]
234mod collation_tests {
235    use super::*;
236
237    fn sorted(names: &[&str]) -> Vec<String> {
238        let conn = Connection::open_in_memory().unwrap();
239        crate::db::schema::create_tables(&conn).unwrap();
240        conn.execute_batch("CREATE TABLE t (name TEXT)").unwrap();
241        for n in names {
242            conn.execute("INSERT INTO t VALUES (?1)", [n]).unwrap();
243        }
244        let mut stmt = conn
245            .prepare("SELECT name FROM t ORDER BY name COLLATE LIBRARY")
246            .unwrap();
247        let rows = stmt.query_map([], |r| r.get::<_, String>(0)).unwrap();
248        rows.map(Result::unwrap).collect()
249    }
250
251    #[test]
252    fn lowercase_does_not_sort_after_everything() {
253        assert_eq!(
254            sorted(&["Zebra", "aphex twin", "Boards of Canada"]),
255            ["aphex twin", "Boards of Canada", "Zebra"]
256        );
257    }
258
259    #[test]
260    fn accents_sort_with_their_base_letter() {
261        // Byte order puts every non-ASCII name after `z`, which is where nobody
262        // looks for Âme.
263        assert_eq!(
264            sorted(&["Zomby", "Âme", "Alva Noto"]),
265            ["Alva Noto", "Âme", "Zomby"]
266        );
267    }
268
269    #[test]
270    fn digit_runs_compare_as_numbers() {
271        assert_eq!(
272            sorted(&["Track 10", "Track 2", "Track 1"]),
273            ["Track 1", "Track 2", "Track 10"]
274        );
275    }
276
277    #[test]
278    fn names_differing_only_in_case_keep_a_stable_order() {
279        // Folding must not make them equal, or the order flips between runs.
280        assert_eq!(
281            sorted(&["kraftwerk", "Kraftwerk"]),
282            ["Kraftwerk", "kraftwerk"]
283        );
284    }
285}