Skip to main content

max / synckit

9.3 KB · 248 lines History Blame Raw
1 //! The local key/value store: a SQLite table an app reads and writes.
2 //!
3 //! No network, no posture in sight — posture governs what *syncs*, and this is
4 //! the store that exists whether or not an app syncs at all. It creates one
5 //! table, `key TEXT PRIMARY KEY, value TEXT NOT NULL`, and offers get/set/unset
6 //! over it. A no-sync consumer (the Alloy console) uses only this; a syncing one
7 //! writes through the same store and lets `synckit-client` observe the table.
8
9 use rusqlite::Connection;
10
11 use crate::spec::ConfigSpec;
12
13 /// Anything that can go wrong reading or writing config.
14 ///
15 /// One variant, wrapping rusqlite, because there is one failure mode: the
16 /// database. A key that is not set is [`get`](ConfigStore::get) returning `None`,
17 /// not an error.
18 #[derive(Debug, thiserror::Error)]
19 pub enum ConfigError {
20 #[error("config store: {0}")]
21 Db(#[from] rusqlite::Error),
22 }
23
24 pub type Result<T> = std::result::Result<T, ConfigError>;
25
26 /// A config's local key/value store, over one SQLite table.
27 ///
28 /// Holds the table name and borrows the connection per call rather than owning
29 /// it, because the syncing apps keep config in their existing database next to
30 /// synced tables, and the store must not assume the connection is only its own.
31 /// A no-sync app opens a connection for this store alone; either works.
32 pub struct ConfigStore {
33 table: &'static str,
34 }
35
36 impl ConfigStore {
37 /// Create the config table if it is absent, and return a handle to it.
38 ///
39 /// Idempotent: safe to call at every startup. Takes the [`ConfigSpec`] so the
40 /// table name is the spec's and not restated, which keeps the store and the
41 /// sync side pointed at the same table.
42 pub fn open(conn: &Connection, spec: &ConfigSpec) -> Result<Self> {
43 let table = spec.table();
44 // The table name is a compile-time `&'static str` from a spec, never user
45 // input, so the format is safe; values always go through bound
46 // parameters below.
47 conn.execute_batch(&format!(
48 "CREATE TABLE IF NOT EXISTS {table} (\
49 key TEXT PRIMARY KEY,\
50 value TEXT NOT NULL\
51 ) WITHOUT ROWID;"
52 ))?;
53 Ok(Self { table })
54 }
55
56 /// A handle to a config table the caller already created, without touching
57 /// the schema.
58 ///
59 /// [`open`](Self::open) creates the table; this trusts that it exists. For an
60 /// app whose migrations own the config table (audiofiles' `user_config` is
61 /// created and versioned by its own migration set, alongside the sync
62 /// triggers that read it) rather than letting the store create a fresh one.
63 /// `const` and connection-free: it only records the table name, so the app
64 /// builds the handle once and the get/set SQL runs against the table its
65 /// migrations already stood up. A get/set against a table that does not exist
66 /// is a plain rusqlite error, the caller's contract to uphold.
67 #[must_use]
68 pub const fn attached(spec: &ConfigSpec) -> Self {
69 Self {
70 table: spec.table(),
71 }
72 }
73
74 /// The value stored at `key`, or `None` if nothing is.
75 ///
76 /// An unset key is `None`, not an error: "the user has not chosen a theme" is
77 /// a normal state, and the caller supplies the default (see the theme
78 /// resolver, which reads `None` as "follow the system").
79 pub fn get(&self, conn: &Connection, key: &str) -> Result<Option<String>> {
80 let value = conn
81 .query_row(
82 &format!("SELECT value FROM {} WHERE key = ?1", self.table),
83 [key],
84 |row| row.get::<_, String>(0),
85 )
86 .map(Some)
87 .or_else(|error| match error {
88 rusqlite::Error::QueryReturnedNoRows => Ok(None),
89 other => Err(other),
90 })?;
91 Ok(value)
92 }
93
94 /// Set `key` to `value`, replacing any previous value.
95 ///
96 /// One upsert, so a syncing app's change-capture trigger sees a single INSERT
97 /// or UPDATE rather than a delete-then-insert that would enqueue two changes
98 /// for one edit.
99 pub fn set(&self, conn: &Connection, key: &str, value: &str) -> Result<()> {
100 conn.execute(
101 &format!(
102 "INSERT INTO {} (key, value) VALUES (?1, ?2) \
103 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
104 self.table
105 ),
106 (key, value),
107 )?;
108 Ok(())
109 }
110
111 /// Remove `key`. Absent already is not an error.
112 pub fn unset(&self, conn: &Connection, key: &str) -> Result<()> {
113 conn.execute(&format!("DELETE FROM {} WHERE key = ?1", self.table), [key])?;
114 Ok(())
115 }
116
117 /// Every key and value, ordered by key for a stable read.
118 ///
119 /// For an app that wants to show or export the whole config; the common path
120 /// is [`get`](Self::get) of one key.
121 pub fn all(&self, conn: &Connection) -> Result<Vec<(String, String)>> {
122 let mut stmt = conn.prepare(&format!(
123 "SELECT key, value FROM {} ORDER BY key",
124 self.table
125 ))?;
126 let rows = stmt
127 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
128 .collect::<rusqlite::Result<Vec<_>>>()?;
129 Ok(rows)
130 }
131 }
132
133 #[cfg(test)]
134 mod tests {
135 use super::*;
136 use crate::spec::Posture;
137
138 const SPEC: ConfigSpec = ConfigSpec::new(
139 "app_config",
140 &[("theme", Posture::Synced), ("mirror_path", Posture::Local)],
141 );
142
143 fn store() -> (Connection, ConfigStore) {
144 let conn = Connection::open_in_memory().unwrap();
145 let store = ConfigStore::open(&conn, &SPEC).unwrap();
146 (conn, store)
147 }
148
149 #[test]
150 fn a_value_round_trips() {
151 let (conn, store) = store();
152 assert_eq!(store.get(&conn, "theme").unwrap(), None, "unset reads None");
153
154 store.set(&conn, "theme", "akari-night").unwrap();
155 assert_eq!(
156 store.get(&conn, "theme").unwrap().as_deref(),
157 Some("akari-night"),
158 );
159 }
160
161 #[test]
162 fn set_replaces_rather_than_duplicating() {
163 let (conn, store) = store();
164 store.set(&conn, "theme", "flatwhite").unwrap();
165 store.set(&conn, "theme", "akari-night").unwrap();
166 assert_eq!(
167 store.get(&conn, "theme").unwrap().as_deref(),
168 Some("akari-night"),
169 );
170 assert_eq!(store.all(&conn).unwrap().len(), 1, "one row, not two");
171 }
172
173 #[test]
174 fn unset_removes_and_is_idempotent() {
175 let (conn, store) = store();
176 store.set(&conn, "theme", "flatwhite").unwrap();
177 store.unset(&conn, "theme").unwrap();
178 assert_eq!(store.get(&conn, "theme").unwrap(), None);
179 // Removing what is already gone is not an error.
180 store.unset(&conn, "theme").unwrap();
181 }
182
183 #[test]
184 fn open_is_idempotent() {
185 let (conn, _store) = store();
186 // A second open over the same connection must not fail on the existing
187 // table; every startup calls this.
188 ConfigStore::open(&conn, &SPEC).unwrap();
189 }
190
191 // An `attached` handle drives a table the caller stood up, without creating
192 // one of its own. The get/set path is identical to `open`'s once the table
193 // exists.
194 #[test]
195 fn attached_uses_an_existing_table() {
196 let conn = Connection::open_in_memory().unwrap();
197 // The caller's own DDL, standing in for an app migration. Deliberately a
198 // plain rowid table, not the WITHOUT ROWID one `open` would create, to
199 // prove `attached` does not care how the table was made.
200 conn.execute_batch("CREATE TABLE app_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);")
201 .unwrap();
202 let store = ConfigStore::attached(&SPEC);
203 assert_eq!(store.get(&conn, "theme").unwrap(), None);
204 store.set(&conn, "theme", "akari-night").unwrap();
205 assert_eq!(
206 store.get(&conn, "theme").unwrap().as_deref(),
207 Some("akari-night"),
208 );
209 }
210
211 // The contract's sharp edge: `attached` to a table nobody created is a
212 // rusqlite error on first use, not a panic and not a silent success.
213 #[test]
214 fn attached_to_a_missing_table_errors_on_use() {
215 let conn = Connection::open_in_memory().unwrap();
216 let store = ConfigStore::attached(&SPEC);
217 assert!(store.get(&conn, "theme").is_err(), "no such table");
218 }
219
220 #[test]
221 fn all_is_ordered_by_key() {
222 let (conn, store) = store();
223 store.set(&conn, "theme", "flatwhite").unwrap();
224 store.set(&conn, "mirror_path", "/mnt/x").unwrap();
225 assert_eq!(
226 store.all(&conn).unwrap(),
227 vec![
228 ("mirror_path".to_string(), "/mnt/x".to_string()),
229 ("theme".to_string(), "flatwhite".to_string()),
230 ],
231 );
232 }
233
234 // The store is posture-blind: it holds a local path the same as a synced
235 // preference. Keeping that path off the wire is the sync side's job, driven
236 // by the spec; the local store simply stores.
237 #[test]
238 fn the_store_holds_local_and_synced_keys_alike() {
239 let (conn, store) = store();
240 store.set(&conn, "mirror_path", "/mnt/samples").unwrap();
241 assert_eq!(
242 store.get(&conn, "mirror_path").unwrap().as_deref(),
243 Some("/mnt/samples"),
244 "a Local key is still stored locally; posture governs sync, not storage",
245 );
246 }
247 }
248