//! The local key/value store: a SQLite table an app reads and writes. //! //! No network, no posture in sight — posture governs what *syncs*, and this is //! the store that exists whether or not an app syncs at all. It creates one //! table, `key TEXT PRIMARY KEY, value TEXT NOT NULL`, and offers get/set/unset //! over it. A no-sync consumer (the Alloy console) uses only this; a syncing one //! writes through the same store and lets `synckit-client` observe the table. use rusqlite::Connection; use crate::spec::ConfigSpec; /// Anything that can go wrong reading or writing config. /// /// One variant, wrapping rusqlite, because there is one failure mode: the /// database. A key that is not set is [`get`](ConfigStore::get) returning `None`, /// not an error. #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("config store: {0}")] Db(#[from] rusqlite::Error), } pub type Result = std::result::Result; /// A config's local key/value store, over one SQLite table. /// /// Holds the table name and borrows the connection per call rather than owning /// it, because the syncing apps keep config in their existing database next to /// synced tables, and the store must not assume the connection is only its own. /// A no-sync app opens a connection for this store alone; either works. pub struct ConfigStore { table: &'static str, } impl ConfigStore { /// Create the config table if it is absent, and return a handle to it. /// /// Idempotent: safe to call at every startup. Takes the [`ConfigSpec`] so the /// table name is the spec's and not restated, which keeps the store and the /// sync side pointed at the same table. pub fn open(conn: &Connection, spec: &ConfigSpec) -> Result { let table = spec.table(); // The table name is a compile-time `&'static str` from a spec, never user // input, so the format is safe; values always go through bound // parameters below. conn.execute_batch(&format!( "CREATE TABLE IF NOT EXISTS {table} (\ key TEXT PRIMARY KEY,\ value TEXT NOT NULL\ ) WITHOUT ROWID;" ))?; Ok(Self { table }) } /// A handle to a config table the caller already created, without touching /// the schema. /// /// [`open`](Self::open) creates the table; this trusts that it exists. For an /// app whose migrations own the config table (audiofiles' `user_config` is /// created and versioned by its own migration set, alongside the sync /// triggers that read it) rather than letting the store create a fresh one. /// `const` and connection-free: it only records the table name, so the app /// builds the handle once and the get/set SQL runs against the table its /// migrations already stood up. A get/set against a table that does not exist /// is a plain rusqlite error, the caller's contract to uphold. #[must_use] pub const fn attached(spec: &ConfigSpec) -> Self { Self { table: spec.table(), } } /// The value stored at `key`, or `None` if nothing is. /// /// An unset key is `None`, not an error: "the user has not chosen a theme" is /// a normal state, and the caller supplies the default (see the theme /// resolver, which reads `None` as "follow the system"). pub fn get(&self, conn: &Connection, key: &str) -> Result> { let value = conn .query_row( &format!("SELECT value FROM {} WHERE key = ?1", self.table), [key], |row| row.get::<_, String>(0), ) .map(Some) .or_else(|error| match error { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), })?; Ok(value) } /// Set `key` to `value`, replacing any previous value. /// /// One upsert, so a syncing app's change-capture trigger sees a single INSERT /// or UPDATE rather than a delete-then-insert that would enqueue two changes /// for one edit. pub fn set(&self, conn: &Connection, key: &str, value: &str) -> Result<()> { conn.execute( &format!( "INSERT INTO {} (key, value) VALUES (?1, ?2) \ ON CONFLICT(key) DO UPDATE SET value = excluded.value", self.table ), (key, value), )?; Ok(()) } /// Remove `key`. Absent already is not an error. pub fn unset(&self, conn: &Connection, key: &str) -> Result<()> { conn.execute(&format!("DELETE FROM {} WHERE key = ?1", self.table), [key])?; Ok(()) } /// Every key and value, ordered by key for a stable read. /// /// For an app that wants to show or export the whole config; the common path /// is [`get`](Self::get) of one key. pub fn all(&self, conn: &Connection) -> Result> { let mut stmt = conn.prepare(&format!( "SELECT key, value FROM {} ORDER BY key", self.table ))?; let rows = stmt .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .collect::>>()?; Ok(rows) } } #[cfg(test)] mod tests { use super::*; use crate::spec::Posture; const SPEC: ConfigSpec = ConfigSpec::new( "app_config", &[("theme", Posture::Synced), ("mirror_path", Posture::Local)], ); fn store() -> (Connection, ConfigStore) { let conn = Connection::open_in_memory().unwrap(); let store = ConfigStore::open(&conn, &SPEC).unwrap(); (conn, store) } #[test] fn a_value_round_trips() { let (conn, store) = store(); assert_eq!(store.get(&conn, "theme").unwrap(), None, "unset reads None"); store.set(&conn, "theme", "akari-night").unwrap(); assert_eq!( store.get(&conn, "theme").unwrap().as_deref(), Some("akari-night"), ); } #[test] fn set_replaces_rather_than_duplicating() { let (conn, store) = store(); store.set(&conn, "theme", "flatwhite").unwrap(); store.set(&conn, "theme", "akari-night").unwrap(); assert_eq!( store.get(&conn, "theme").unwrap().as_deref(), Some("akari-night"), ); assert_eq!(store.all(&conn).unwrap().len(), 1, "one row, not two"); } #[test] fn unset_removes_and_is_idempotent() { let (conn, store) = store(); store.set(&conn, "theme", "flatwhite").unwrap(); store.unset(&conn, "theme").unwrap(); assert_eq!(store.get(&conn, "theme").unwrap(), None); // Removing what is already gone is not an error. store.unset(&conn, "theme").unwrap(); } #[test] fn open_is_idempotent() { let (conn, _store) = store(); // A second open over the same connection must not fail on the existing // table; every startup calls this. ConfigStore::open(&conn, &SPEC).unwrap(); } // An `attached` handle drives a table the caller stood up, without creating // one of its own. The get/set path is identical to `open`'s once the table // exists. #[test] fn attached_uses_an_existing_table() { let conn = Connection::open_in_memory().unwrap(); // The caller's own DDL, standing in for an app migration. Deliberately a // plain rowid table, not the WITHOUT ROWID one `open` would create, to // prove `attached` does not care how the table was made. conn.execute_batch("CREATE TABLE app_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);") .unwrap(); let store = ConfigStore::attached(&SPEC); assert_eq!(store.get(&conn, "theme").unwrap(), None); store.set(&conn, "theme", "akari-night").unwrap(); assert_eq!( store.get(&conn, "theme").unwrap().as_deref(), Some("akari-night"), ); } // The contract's sharp edge: `attached` to a table nobody created is a // rusqlite error on first use, not a panic and not a silent success. #[test] fn attached_to_a_missing_table_errors_on_use() { let conn = Connection::open_in_memory().unwrap(); let store = ConfigStore::attached(&SPEC); assert!(store.get(&conn, "theme").is_err(), "no such table"); } #[test] fn all_is_ordered_by_key() { let (conn, store) = store(); store.set(&conn, "theme", "flatwhite").unwrap(); store.set(&conn, "mirror_path", "/mnt/x").unwrap(); assert_eq!( store.all(&conn).unwrap(), vec![ ("mirror_path".to_string(), "/mnt/x".to_string()), ("theme".to_string(), "flatwhite".to_string()), ], ); } // The store is posture-blind: it holds a local path the same as a synced // preference. Keeping that path off the wire is the sync side's job, driven // by the spec; the local store simply stores. #[test] fn the_store_holds_local_and_synced_keys_alike() { let (conn, store) = store(); store.set(&conn, "mirror_path", "/mnt/samples").unwrap(); assert_eq!( store.get(&conn, "mirror_path").unwrap().as_deref(), Some("/mnt/samples"), "a Local key is still stored locally; posture governs sync, not storage", ); } }