Skip to main content

max / synckit

config: add ConfigStore::attached for migration-owned tables open() creates the config table; attached() trusts that the caller's own migrations already did. audiofiles versions its user_config table (and the sync triggers that read it) in its own migration set, so it needs a store handle over that table without a second CREATE. const and connection-free: it records the table name and nothing else.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 22:11 UTC
Commit: 2711c9d809fdf95a22484e8d787d9ee55358b3ad
Parent: 698c801
2 files changed, +48 insertions, -1 deletion
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "synckit-config"
3 - version = "0.1.1"
3 + version = "0.1.2"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6 description = "Local key/value app settings with sync postures. The store half of SyncKit's config sync, usable with no network."
@@ -53,6 +53,24 @@ impl ConfigStore {
53 53 Ok(Self { table })
54 54 }
55 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 +
56 74 /// The value stored at `key`, or `None` if nothing is.
57 75 ///
58 76 /// An unset key is `None`, not an error: "the user has not chosen a theme" is
@@ -170,6 +188,35 @@ mod tests {
170 188 ConfigStore::open(&conn, &SPEC).unwrap();
171 189 }
172 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 +
173 220 #[test]
174 221 fn all_is_ordered_by_key() {
175 222 let (conn, store) = store();