Skip to main content

max / alloy

Drop synckit-config for rusqlite directly synckit-config is PolyForm Noncommercial and this binary is handed out under a permissive license, so a recipient could not do the commercial things that license promises. It was found while compiling the credits manifest, where the console's own entry sat two lines above a noncommercial dependency. Nothing of synckit's purpose was in use. The console does not sync, posture is inert here, and both keys were declared Posture::Local. What the crate was providing was about forty lines of open, read and upsert over one table, which is now here. The schema is synckit-config's character for character, WITHOUT ROWID included, so a console.db written before this opens unchanged and a console that ever rejoins the family store can hand the file back without a migration. The table keeps its name for the same reason: renaming it would strand the settings on every installed machine for nothing. No build cost. synckit-config already pinned rusqlite 0.39 with `bundled`, so the lock only shrank. The two tests that asserted through ConfigSpec are replaced by three that cover what can now actually break: that CREATE TABLE IF NOT EXISTS on every read does not wipe an existing store, that the upsert replaces rather than growing a second row, and that a set still points at the console's own db. The old first-boot test asserted !CONFIG.is_synced(SETUP), which no longer has a type to ask; its reasoning moved onto the SETUP constant so the next person to add syncing finds it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 19:11 UTC
Signed with PGP, not checked
Commit: 1bc1fbb2de35786eaf5d648c679ff93e024bbc7f
Parent: d9a3c60
5 files changed, +149 insertions, -72 deletions
M Cargo.lock +1 -10
@@ -27,10 +27,10 @@
27 27 "getrandom 0.4.3",
28 28 "makeover",
29 29 "ratatui",
30 + "rusqlite",
30 31 "serde",
31 32 "serde_json",
32 33 "sha-crypt",
33 - "synckit-config",
34 34 "toml",
35 35 "toml_edit",
36 36 ]
@@ -1693,15 +1693,6 @@
1693 1693 "unicode-ident",
1694 1694 ]
1695 1695
1696 - [[package]]
1697 - name = "synckit-config"
1698 - version = "0.1.1"
1699 - source = "git+https://makenot.work/git/max/synckit#698c801d3c22008217209e09d2088c96cbbf7533"
1700 - dependencies = [
1701 - "rusqlite",
1702 - "thiserror 2.0.18",
1703 - ]
1704 -
1705 1696 [[package]]
1706 1697 name = "termina"
1707 1698 version = "0.3.3"
@@ -24,7 +24,22 @@
24 24 sha-crypt = "0.6.0"
25 25 getrandom = "0.4.3"
26 26 toml_edit = "0.25.13"
27 - synckit-config = { git = "https://makenot.work/git/max/synckit" }
27 + # The console's settings store, and the only reason SQLite is here at all: two
28 + # keys in one table. `bundled` so the image does not have to carry a sqlite-devel
29 + # and the console links the version it was tested against.
30 + #
31 + # This was `synckit-config`, the family's shared config crate, until 2026-07-30.
32 + # That crate is PolyForm Noncommercial, and a noncommercial dependency does not
33 + # belong in a binary offered under a permissive license: whatever this repo's
34 + # LICENSE says, a recipient who links it cannot do the commercial things that
35 + # license promises. It was dropped while Alloy was GPL, where the conflict was
36 + # formal (GPL section 7 forbids the added restriction outright); Alloy is MIT
37 + # now, which makes the conflict quieter and no less real. Do not take it back.
38 + #
39 + # Nothing of synckit's purpose was in use here — the console does not sync, and
40 + # both keys were `Posture::Local` — so the fix was to keep the table and drop
41 + # the crate.
42 + rusqlite = { version = "0.39", features = ["bundled"] }
28 43
29 44 [lints]
30 45 workspace = true
@@ -471,13 +471,15 @@
471 471 /// Set a key in the console's config store.
472 472 ///
473 473 /// The theme preference is neither a command nor a file the way a wrapper
474 - /// script is — it is a row in the SQLite settings store the shared config
475 - /// crate owns. Modelled as its own effect so the log still shows it: the
476 - /// pane renders `set theme = akari-night`, which is what happened even
477 - /// though no command ran and no file was written by hand.
474 + /// script is — it is a row in the console's SQLite settings store. Modelled
475 + /// as its own effect so the log still shows it: the pane renders
476 + /// `set theme = akari-night`, which is what happened even though no command
477 + /// ran and no file was written by hand.
478 + ///
479 + /// The table is [`store`](crate::store)'s, so this carries only where and
480 + /// what, not the schema.
478 481 Config {
479 482 db: PathBuf,
480 - spec: synckit_config::ConfigSpec,
481 483 key: String,
482 484 value: String,
483 485 },
@@ -518,13 +520,8 @@
518 520 );
519 521 result
520 522 }
521 - Effect::Config {
522 - db,
523 - spec,
524 - key,
525 - value,
526 - } => {
527 - let result = write_config(db, *spec, key, value);
523 + Effect::Config { db, key, value } => {
524 + let result = write_config(db, key, value);
528 525 log.record(
529 526 self.display(),
530 527 if result.is_ok() {
@@ -546,8 +543,8 @@
546 543 /// file write. Opening the store runs `CREATE TABLE IF NOT EXISTS`, so the first
547 544 /// set is also what brings the store into existence — a console never told
548 545 /// anything leaves no `console.db` behind.
549 - fn write_config(db: &Path, spec: synckit_config::ConfigSpec, key: &str, value: &str) -> Result<()> {
550 - use synckit_config::{ConfigStore, rusqlite::Connection};
546 + fn write_config(db: &Path, key: &str, value: &str) -> Result<()> {
547 + use rusqlite::Connection;
551 548
552 549 if let Some(parent) = db.parent() {
553 550 std::fs::create_dir_all(parent)
@@ -555,10 +552,9 @@
555 552 }
556 553 let conn =
557 554 Connection::open(db).with_context(|| format!("failed to open {}", contract_home(db)))?;
558 - let store = ConfigStore::open(&conn, &spec)
555 + crate::store::open(&conn)
559 556 .with_context(|| format!("failed to open the config store in {}", contract_home(db)))?;
560 - store
561 - .set(&conn, key, value)
557 + crate::store::write(&conn, key, value)
562 558 .with_context(|| format!("failed to set `{key}` in {}", contract_home(db)))?;
563 559 Ok(())
564 560 }
@@ -1,12 +1,22 @@
1 1 //! Where the console remembers what it is told.
2 2 //!
3 - //! `$XDG_CONFIG_HOME/alloy/console.db`, a SQLite store the shared config crate
4 - //! owns. The console already owns that directory — custom themes live in
5 - //! `alloy/themes/`, user schemas in `alloy/schemas/` — so the store joins them
6 - //! rather than opening a new root. SQLite rather than a `console.toml` because
7 - //! every app in the family now keeps settings in one store, so a preference can
8 - //! carry across devices where the app syncs; the console does not sync, but it
9 - //! reads and writes the same shape.
3 + //! `$XDG_CONFIG_HOME/alloy/console.db`, a SQLite table of key/value pairs. The
4 + //! console already owns that directory — custom themes live in `alloy/themes/`,
5 + //! user schemas in `alloy/schemas/` — so the store joins them rather than
6 + //! opening a new root.
7 + //!
8 + //! The table used to belong to `synckit-config`, the family's shared config
9 + //! crate, and the schema here is deliberately the one that crate creates: same
10 + //! table name, same two columns, same `WITHOUT ROWID`. An installed machine's
11 + //! `console.db` keeps working across the change, and a console that ever wants
12 + //! to rejoin the family store can hand this file back without a migration.
13 + //!
14 + //! The crate itself is gone (2026-07-30) because it is PolyForm Noncommercial
15 + //! and a noncommercial dependency does not belong in a binary this project hands
16 + //! out under a permissive license. Nothing was lost with it: syncing is what that
17 + //! crate is for, the console does not sync, and both keys below were declared
18 + //! `Posture::Local`. Do not take the dependency back. See
19 + //! `crates/alloy/Cargo.toml` for the full reasoning.
10 20 //!
11 21 //! Deliberately not a system-wide file. Which theme a terminal renders in is
12 22 //! the person's, not the machine's, and two accounts on one machine should not
@@ -20,34 +30,48 @@
20 30 use std::path::PathBuf;
21 31
22 32 use anyhow::{Context, Result};
23 - use synckit_config::rusqlite::Connection;
24 - use synckit_config::{ConfigSpec, ConfigStore, Posture};
33 + use rusqlite::Connection;
25 34
26 35 use crate::cli::Effect;
27 36
28 - /// The console's config store: one table, every key the console remembers.
37 + /// The table every key the console remembers lives in.
29 38 ///
30 - /// Declared for the family's shared config store even though the console never
31 - /// syncs, so posture is inert here and both keys take the safe default,
32 - /// [`Posture::Local`]. [`THEME`]'s name is the convention's, not this crate's:
33 - /// every app in the family stores the chosen theme under `theme`.
34 - pub(crate) const CONFIG: ConfigSpec = ConfigSpec::new(
35 - "console_config",
36 - &[(THEME, Posture::Local), (SETUP, Posture::Local)],
37 - );
39 + /// Named for the crate that used to own it rather than renamed on the way out,
40 + /// because renaming it would strand the settings on every machine already
41 + /// installed for no gain.
42 + pub(crate) const TABLE: &str = "console_config";
38 43
39 44 /// The chosen theme, or `follow` to track the terminal.
45 + ///
46 + /// The name is the family's convention, not this crate's: every app in the
47 + /// family stores the chosen theme under `theme`.
40 48 pub(crate) const THEME: &str = "theme";
41 49
42 50 /// Set once the first-boot screen has been shown.
43 51 ///
44 - /// [`Posture::Local`] is load-bearing here in a way it is not for [`THEME`],
45 - /// which would be harmless to sync. This key must never replicate: it says
46 - /// something about *this* machine's first boot, and a synced copy would suppress
47 - /// the screen on the next machine the user sets up — which is exactly the
48 - /// machine that needs it.
52 + /// Local to one machine in a way [`THEME`] is not. It says something about
53 + /// *this* machine's first boot, so a copy of it arriving from somewhere else
54 + /// would suppress the screen on the next machine the user sets up — which is
55 + /// exactly the machine that needs it. Nothing replicates this file today; the
56 + /// note is here for whoever changes that.
49 57 pub(crate) const SETUP: &str = "first_boot";
50 58
59 + /// Create the table if it is not there, and hand back the connection's blessing
60 + /// to read and write it.
61 + ///
62 + /// The schema is `synckit-config`'s, character for character, so a `console.db`
63 + /// written before that crate was dropped opens here unchanged. `WITHOUT ROWID`
64 + /// included: it is part of what the file already is, and changing it would mean
65 + /// rewriting every existing store to gain nothing.
66 + pub(crate) fn open(conn: &Connection) -> rusqlite::Result<()> {
67 + conn.execute_batch(&format!(
68 + "CREATE TABLE IF NOT EXISTS {TABLE} (\
69 + key TEXT PRIMARY KEY,\
70 + value TEXT NOT NULL\
71 + ) WITHOUT ROWID;"
72 + ))
73 + }
74 +
51 75 /// The config directory the store lives under.
52 76 pub(crate) fn config_home() -> Option<PathBuf> {
53 77 // XDG_CONFIG_HOME wins when set and absolute; the spec says a relative
@@ -79,8 +103,40 @@
79 103 return None;
80 104 }
81 105 let conn = Connection::open(&path).ok()?;
82 - let store = ConfigStore::open(&conn, &CONFIG).ok()?;
83 - store.get(&conn, key).ok()?
106 + open(&conn).ok()?;
107 + read(&conn, key).ok()?
108 + }
109 +
110 + /// One key out of an open store, with a missing row read as `None`.
111 + ///
112 + /// Absent is a normal state, not an error: the caller supplies the default (see
113 + /// the theme resolver, which reads `None` as "follow the system").
114 + fn read(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
115 + conn.query_row(
116 + &format!("SELECT value FROM {TABLE} WHERE key = ?1"),
117 + [key],
118 + |row| row.get::<_, String>(0),
119 + )
120 + .map(Some)
121 + .or_else(|error| match error {
122 + rusqlite::Error::QueryReturnedNoRows => Ok(None),
123 + other => Err(other),
124 + })
125 + }
126 +
127 + /// Set `key` to `value`, replacing any previous value.
128 + ///
129 + /// One upsert rather than a delete and an insert, which is what keeps a single
130 + /// edit looking like a single edit to anything watching the table.
131 + pub(crate) fn write(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
132 + conn.execute(
133 + &format!(
134 + "INSERT INTO {TABLE} (key, value) VALUES (?1, ?2) \
135 + ON CONFLICT(key) DO UPDATE SET value = excluded.value"
136 + ),
137 + (key, value),
138 + )?;
139 + Ok(())
84 140 }
85 141
86 142 /// The write that remembers one key.
@@ -93,7 +149,6 @@
93 149 let db = path().context("no config directory to remember settings in")?;
94 150 Ok(Effect::Config {
95 151 db,
96 - spec: CONFIG,
97 152 key: key.to_string(),
98 153 value: value.to_string(),
99 154 })
@@ -109,31 +164,55 @@
109 164 #[test]
110 165 fn every_key_round_trips_through_the_store() {
111 166 let conn = Connection::open_in_memory().unwrap();
112 - let store = ConfigStore::open(&conn, &CONFIG).unwrap();
167 + open(&conn).unwrap();
113 168 for (key, value) in [(THEME, "akari-night"), (SETUP, "done")] {
114 - assert_eq!(store.get(&conn, key).unwrap(), None, "{key} starts unset");
115 - store.set(&conn, key, value).unwrap();
116 - assert_eq!(store.get(&conn, key).unwrap().as_deref(), Some(value));
169 + assert_eq!(read(&conn, key).unwrap(), None, "{key} starts unset");
170 + write(&conn, key, value).unwrap();
171 + assert_eq!(read(&conn, key).unwrap().as_deref(), Some(value));
117 172 }
118 173 }
119 174
120 - // The first-boot marker is about one machine's first boot. Syncing it would
121 - // suppress the screen on the next machine the user sets up.
175 + // Opening an existing store must not disturb it. `CREATE TABLE IF NOT
176 + // EXISTS` is what makes the read path safe to run on every launch, and the
177 + // day that becomes a plain CREATE the console stops reading its own
178 + // settings.
122 179 #[test]
123 - fn the_first_boot_marker_never_replicates() {
124 - assert!(!CONFIG.is_synced(SETUP));
180 + fn opening_a_store_twice_keeps_what_is_in_it() {
181 + let conn = Connection::open_in_memory().unwrap();
182 + open(&conn).unwrap();
183 + write(&conn, THEME, "akari-night").unwrap();
184 + open(&conn).unwrap();
185 + assert_eq!(read(&conn, THEME).unwrap().as_deref(), Some("akari-night"));
186 + }
187 +
188 + // Writing again replaces rather than accumulating. Without the upsert this
189 + // is a UNIQUE violation on the second theme a user picks.
190 + #[test]
191 + fn a_second_write_replaces_the_first() {
192 + let conn = Connection::open_in_memory().unwrap();
193 + open(&conn).unwrap();
194 + write(&conn, THEME, "akari-night").unwrap();
195 + write(&conn, THEME, "follow").unwrap();
196 + assert_eq!(read(&conn, THEME).unwrap().as_deref(), Some("follow"));
197 + let rows: u32 = conn
198 + .query_row(&format!("SELECT count(*) FROM {TABLE}"), [], |row| {
199 + row.get(0)
200 + })
201 + .unwrap();
202 + assert_eq!(rows, 1, "the upsert grew a second row");
125 203 }
126 204
127 205 #[test]
128 206 fn a_set_is_a_config_effect_on_the_consoles_store() {
129 - let Effect::Config {
130 - key, value, spec, ..
131 - } = set(THEME, "akari-night").unwrap()
132 - else {
207 + let Effect::Config { key, value, db } = set(THEME, "akari-night").unwrap() else {
133 208 panic!("a setting is a config write, not a command or a file");
134 209 };
135 210 assert_eq!(key, THEME);
136 211 assert_eq!(value, "akari-night");
137 - assert_eq!(spec.table(), "console_config");
212 + assert!(
213 + db.ends_with("alloy/console.db"),
214 + "the effect points somewhere else: {}",
215 + db.display()
216 + );
138 217 }
139 218 }
@@ -306,15 +306,11 @@
306 306 ThemeSelection::parse(Some("akari-night")),
307 307 ThemeSelection::Follow,
308 308 ] {
309 - let Effect::Config {
310 - key, value, spec, ..
311 - } = remember(&selection).unwrap()
312 - else {
309 + let Effect::Config { key, value, .. } = remember(&selection).unwrap() else {
313 310 panic!("a theme is a config set, not a command or file write");
314 311 };
315 312 assert_eq!(key, store::THEME, "the family convention's key name");
316 313 assert_eq!(value, selection.as_str());
317 - assert_eq!(spec.table(), "console_config");
318 314 }
319 315 }
320 316