Skip to main content

max / makenotwork

synckit-config: the local settings store, step 1 of portable config New crate, the store half of the portable-config design (wiki synckit-config-design). Four apps stored settings four ways and the theme extraction was about to make it five; this is the shared substrate, local by default, with a per-key sync posture the sync side reads. SQLite key/value, not a text file, because config syncs per key: two devices editing different keys must both survive (row-level merge), and some keys must never sync (posture). A file buys neither and costs a projection layer plus a sidecar for the merge metadata. This crate is the local store an app touches whether or not it syncs, so a no-sync consumer — the Alloy console remembering a theme — links it without the sync runtime. Posture descends from audiofiles' DeviceLocal/Replicated and keeps its two load-bearing rules: default Local, and an undeclared key is Local, never synced. That fail-closed default is why a forgotten local path cannot leak — the omission keeps data home rather than sending it. It is the property the audiofiles fuzzes (a hostile server steering a local mirror path) were about. Standalone: rusqlite and thiserror, nothing else. No consumers yet, so it cannot break anything. The client adapter that turns a ConfigSpec into a SyncTable is step 2.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 21:01 UTC
Commit: 66e1a715690ed9572c4552a9a0566c9d9fcc16f0
Parent: d5c85b6
6 files changed, +863 insertions, -0 deletions
@@ -0,0 +1,286 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "bitflags"
7 + version = "2.13.1"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
10 +
11 + [[package]]
12 + name = "bumpalo"
13 + version = "3.20.3"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
16 +
17 + [[package]]
18 + name = "cc"
19 + version = "1.4.0"
20 + source = "registry+https://github.com/rust-lang/crates.io-index"
21 + checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
22 + dependencies = [
23 + "find-msvc-tools",
24 + "shlex",
25 + ]
26 +
27 + [[package]]
28 + name = "cfg-if"
29 + version = "1.0.4"
30 + source = "registry+https://github.com/rust-lang/crates.io-index"
31 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
32 +
33 + [[package]]
34 + name = "fallible-iterator"
35 + version = "0.3.0"
36 + source = "registry+https://github.com/rust-lang/crates.io-index"
37 + checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
38 +
39 + [[package]]
40 + name = "fallible-streaming-iterator"
41 + version = "0.1.9"
42 + source = "registry+https://github.com/rust-lang/crates.io-index"
43 + checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
44 +
45 + [[package]]
46 + name = "find-msvc-tools"
47 + version = "0.1.9"
48 + source = "registry+https://github.com/rust-lang/crates.io-index"
49 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
50 +
51 + [[package]]
52 + name = "foldhash"
53 + version = "0.2.0"
54 + source = "registry+https://github.com/rust-lang/crates.io-index"
55 + checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
56 +
57 + [[package]]
58 + name = "hashbrown"
59 + version = "0.16.1"
60 + source = "registry+https://github.com/rust-lang/crates.io-index"
61 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
62 + dependencies = [
63 + "foldhash",
64 + ]
65 +
66 + [[package]]
67 + name = "hashlink"
68 + version = "0.11.1"
69 + source = "registry+https://github.com/rust-lang/crates.io-index"
70 + checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
71 + dependencies = [
72 + "hashbrown",
73 + ]
74 +
75 + [[package]]
76 + name = "js-sys"
77 + version = "0.3.103"
78 + source = "registry+https://github.com/rust-lang/crates.io-index"
79 + checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
80 + dependencies = [
81 + "cfg-if",
82 + "wasm-bindgen",
83 + ]
84 +
85 + [[package]]
86 + name = "libsqlite3-sys"
87 + version = "0.37.0"
88 + source = "registry+https://github.com/rust-lang/crates.io-index"
89 + checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
90 + dependencies = [
91 + "cc",
92 + "pkg-config",
93 + "vcpkg",
94 + ]
95 +
96 + [[package]]
97 + name = "once_cell"
98 + version = "1.21.4"
99 + source = "registry+https://github.com/rust-lang/crates.io-index"
100 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
101 +
102 + [[package]]
103 + name = "pkg-config"
104 + version = "0.3.33"
105 + source = "registry+https://github.com/rust-lang/crates.io-index"
106 + checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
107 +
108 + [[package]]
109 + name = "proc-macro2"
110 + version = "1.0.107"
111 + source = "registry+https://github.com/rust-lang/crates.io-index"
112 + checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
113 + dependencies = [
114 + "unicode-ident",
115 + ]
116 +
117 + [[package]]
118 + name = "quote"
119 + version = "1.0.47"
120 + source = "registry+https://github.com/rust-lang/crates.io-index"
121 + checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
122 + dependencies = [
123 + "proc-macro2",
124 + ]
125 +
126 + [[package]]
127 + name = "rsqlite-vfs"
128 + version = "0.1.1"
129 + source = "registry+https://github.com/rust-lang/crates.io-index"
130 + checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
131 + dependencies = [
132 + "hashbrown",
133 + "thiserror",
134 + ]
135 +
136 + [[package]]
137 + name = "rusqlite"
138 + version = "0.39.0"
139 + source = "registry+https://github.com/rust-lang/crates.io-index"
140 + checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
141 + dependencies = [
142 + "bitflags",
143 + "fallible-iterator",
144 + "fallible-streaming-iterator",
145 + "hashlink",
146 + "libsqlite3-sys",
147 + "smallvec",
148 + "sqlite-wasm-rs",
149 + ]
150 +
151 + [[package]]
152 + name = "rustversion"
153 + version = "1.0.23"
154 + source = "registry+https://github.com/rust-lang/crates.io-index"
155 + checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
156 +
157 + [[package]]
158 + name = "shlex"
159 + version = "2.0.1"
160 + source = "registry+https://github.com/rust-lang/crates.io-index"
161 + checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
162 +
163 + [[package]]
164 + name = "smallvec"
165 + version = "1.15.2"
166 + source = "registry+https://github.com/rust-lang/crates.io-index"
167 + checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
168 +
169 + [[package]]
170 + name = "sqlite-wasm-rs"
171 + version = "0.5.5"
172 + source = "registry+https://github.com/rust-lang/crates.io-index"
173 + checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
174 + dependencies = [
175 + "cc",
176 + "js-sys",
177 + "rsqlite-vfs",
178 + "wasm-bindgen",
179 + ]
180 +
181 + [[package]]
182 + name = "syn"
183 + version = "2.0.119"
184 + source = "registry+https://github.com/rust-lang/crates.io-index"
185 + checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
186 + dependencies = [
187 + "proc-macro2",
188 + "quote",
189 + "unicode-ident",
190 + ]
191 +
192 + [[package]]
193 + name = "syn"
194 + version = "3.0.3"
195 + source = "registry+https://github.com/rust-lang/crates.io-index"
196 + checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
197 + dependencies = [
198 + "proc-macro2",
199 + "quote",
200 + "unicode-ident",
201 + ]
202 +
203 + [[package]]
204 + name = "synckit-config"
205 + version = "0.1.0"
206 + dependencies = [
207 + "rusqlite",
208 + "thiserror",
209 + ]
210 +
211 + [[package]]
212 + name = "thiserror"
213 + version = "2.0.19"
214 + source = "registry+https://github.com/rust-lang/crates.io-index"
215 + checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
216 + dependencies = [
217 + "thiserror-impl",
218 + ]
219 +
220 + [[package]]
221 + name = "thiserror-impl"
222 + version = "2.0.19"
223 + source = "registry+https://github.com/rust-lang/crates.io-index"
224 + checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
225 + dependencies = [
226 + "proc-macro2",
227 + "quote",
228 + "syn 3.0.3",
229 + ]
230 +
231 + [[package]]
232 + name = "unicode-ident"
233 + version = "1.0.24"
234 + source = "registry+https://github.com/rust-lang/crates.io-index"
235 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
236 +
237 + [[package]]
238 + name = "vcpkg"
239 + version = "0.2.15"
240 + source = "registry+https://github.com/rust-lang/crates.io-index"
241 + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
242 +
243 + [[package]]
244 + name = "wasm-bindgen"
245 + version = "0.2.126"
246 + source = "registry+https://github.com/rust-lang/crates.io-index"
247 + checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
248 + dependencies = [
249 + "cfg-if",
250 + "once_cell",
251 + "rustversion",
252 + "wasm-bindgen-macro",
253 + "wasm-bindgen-shared",
254 + ]
255 +
256 + [[package]]
257 + name = "wasm-bindgen-macro"
258 + version = "0.2.126"
259 + source = "registry+https://github.com/rust-lang/crates.io-index"
260 + checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
261 + dependencies = [
262 + "quote",
263 + "wasm-bindgen-macro-support",
264 + ]
265 +
266 + [[package]]
267 + name = "wasm-bindgen-macro-support"
268 + version = "0.2.126"
269 + source = "registry+https://github.com/rust-lang/crates.io-index"
270 + checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
271 + dependencies = [
272 + "bumpalo",
273 + "proc-macro2",
274 + "quote",
275 + "syn 2.0.119",
276 + "wasm-bindgen-shared",
277 + ]
278 +
279 + [[package]]
280 + name = "wasm-bindgen-shared"
281 + version = "0.2.126"
282 + source = "registry+https://github.com/rust-lang/crates.io-index"
283 + checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
284 + dependencies = [
285 + "unicode-ident",
286 + ]
@@ -0,0 +1,37 @@
1 + [package]
2 + name = "synckit-config"
3 + version = "0.1.0"
4 + edition = "2024"
5 + license-file = "LICENSE"
6 + description = "Local key/value app settings with sync postures. The store half of SyncKit's config sync, usable with no network."
7 +
8 + [dependencies]
9 + rusqlite = { version = "0.39", features = ["bundled"] }
10 + thiserror = "2"
11 +
12 + [lints.rust]
13 + unused = "warn"
14 + unreachable_pub = "warn"
15 +
16 + [lints.clippy]
17 + pedantic = { level = "warn", priority = -1 }
18 + # Allow-list tuned from a measured breakdown across the ecosystem (2026-07-22).
19 + # Keep this block identical across repos.
20 + module_name_repetitions = "allow"
21 + missing_errors_doc = "allow"
22 + missing_panics_doc = "allow"
23 + doc_markdown = "allow"
24 + cast_possible_truncation = "allow"
25 + cast_sign_loss = "allow"
26 + cast_precision_loss = "allow"
27 + cast_possible_wrap = "allow"
28 + cast_lossless = "allow"
29 + must_use_candidate = "allow"
30 + too_many_lines = "allow"
31 + struct_excessive_bools = "allow"
32 + similar_names = "allow"
33 + items_after_statements = "allow"
34 + single_match_else = "allow"
35 + match_same_arms = "allow"
36 + unnecessary_wraps = "allow"
37 + type_complexity = "allow"
@@ -0,0 +1,120 @@
1 + Required Notice: Copyright 2026 Make Creative, LLC (https://makenot.work)
2 +
3 + PolyForm Noncommercial License 1.0.0
4 +
5 + <https://polyformproject.org/licenses/noncommercial/1.0.0>
6 +
7 + Acceptance
8 +
9 + In order to get any license under these terms, you must agree to them as
10 + both strict obligations and conditions to all your licenses.
11 +
12 + Copyright License
13 +
14 + The licensor grants you a copyright license for the software to do
15 + everything you might do with the software that would otherwise infringe
16 + the licensor's copyright in it for any permitted purpose. However, you
17 + may only distribute the software according to Distribution License and
18 + make changes or new works based on the software according to Changes and
19 + New Works License.
20 +
21 + Distribution License
22 +
23 + The licensor grants you an additional copyright license to distribute
24 + copies of the software. Your license to distribute covers distributing
25 + the software with changes and new works permitted by Changes and New
26 + Works License.
27 +
28 + Notices
29 +
30 + You must ensure that anyone who gets a copy of any part of the software
31 + from you also gets a copy of these terms or the URL for them above, as
32 + well as copies of any plain-text lines beginning with "Required Notice:"
33 + that the licensor provided with the software. For example:
34 +
35 + Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
36 +
37 + Changes and New Works License
38 +
39 + The licensor grants you an additional copyright license to make changes
40 + and new works based on the software for any permitted purpose.
41 +
42 + Patent License
43 +
44 + The licensor grants you a patent license for the software that covers
45 + patent claims the licensor can license, or becomes able to license, that
46 + you would infringe by using the software.
47 +
48 + Noncommercial Purposes
49 +
50 + Any noncommercial purpose is a permitted purpose.
51 +
52 + Personal Uses
53 +
54 + Personal use for research, experiment, and testing for the benefit of
55 + public knowledge, personal study, private entertainment, hobby projects,
56 + amateur pursuits, or religious observance, without any anticipated
57 + commercial application, is use for a permitted purpose.
58 +
59 + Noncommercial Organizations
60 +
61 + Use by any charitable organization, educational institution, public
62 + research organization, public safety or health organization,
63 + environmental protection organization, or government institution is use
64 + for a permitted purpose regardless of the source of funding or
65 + obligations resulting from the funding.
66 +
67 + Fair Use
68 +
69 + You may have "fair use" rights for the software under the law. These
70 + terms do not limit them.
71 +
72 + No Other Rights
73 +
74 + These terms do not allow you to sublicense or transfer any of your
75 + licenses to anyone else, or prevent the licensor from granting licenses
76 + to anyone else. These terms do not imply any other licenses.
77 +
78 + Patent Defense
79 +
80 + If you make any written claim that the software infringes or contributes
81 + to infringement of any patent, your patent license for the software
82 + granted under these terms ends immediately. If your company makes such a
83 + claim, your patent license ends immediately for work on behalf of your
84 + company.
85 +
86 + Violations
87 +
88 + The first time you are notified in writing that you have violated any of
89 + these terms, or done anything with the software not covered by your
90 + licenses, your licenses can nonetheless continue if you come into full
91 + compliance with these terms, and take practical steps to correct past
92 + violations, within 32 days of receiving notice. Otherwise, all your
93 + licenses end immediately.
94 +
95 + No Liability
96 +
97 + As far as the law allows, the software comes as is, without any warranty
98 + or condition, and the licensor will not be liable to you for any damages
99 + arising out of these terms or the use or nature of the software, under
100 + any kind of legal claim.
101 +
102 + Definitions
103 +
104 + The licensor is the individual or entity offering these terms, and the
105 + software is the software the licensor makes available under these terms.
106 +
107 + You refers to the individual or entity agreeing to these terms.
108 +
109 + Your company is any legal entity, sole proprietorship, or other kind of
110 + organization that you work for, plus all organizations that have control
111 + over, are under the control of, or are under common control with that
112 + organization. Control means ownership of substantially all the assets of
113 + an entity, or the power to direct its management and policies by vote,
114 + contract, or otherwise. Control can be direct or indirect.
115 +
116 + Your licenses are all the licenses granted to you for the software under
117 + these terms.
118 +
119 + Use means anything you do with the software requiring one of your
120 + licenses.
@@ -0,0 +1,42 @@
1 + //! Local key/value app settings, with a per-key sync posture.
2 + //!
3 + //! The store half of SyncKit's config sync, and the whole of it for an app that
4 + //! does not sync. A [`ConfigStore`] is a SQLite key/value table an app reads and
5 + //! writes with no network in sight; a [`ConfigSpec`] declares which of those keys
6 + //! may cross the sync boundary. `synckit-client` turns a spec into a
7 + //! `SyncTable`, but nothing here depends on it, so a no-sync consumer — the Alloy
8 + //! console remembering a theme — links this crate and not the sync runtime.
9 + //!
10 + //! ## Why this is its own crate, and why it is SQLite
11 + //!
12 + //! Four apps stored settings four ways (a synced SQLite table, an
13 + //! unconditionally-synced one, `localStorage`, a TOML file) and the theme
14 + //! extraction was about to make it five. The unifying substrate is SQLite rows,
15 + //! not a text file, because config syncs **per key**: two devices editing
16 + //! different keys must both survive (row-level HLC merge, which a whole-file blob
17 + //! cannot do), and some keys must never sync at all (see posture). Rows are what
18 + //! buy both. A text file would need a projection layer and a sidecar for the
19 + //! per-key merge metadata — SQLite by the back door. Design note:
20 + //! `synckit-config-design` in the wiki.
21 + //!
22 + //! ## Posture, and why it fails closed
23 + //!
24 + //! [`Posture`] is the one security-load-bearing type here. It descends from
25 + //! audiofiles' `DeviceLocal`/`Replicated`, which exists because a hostile server
26 + //! steered a local filesystem path across the sync boundary through an
27 + //! unclassified config key (fuzz-2026-07-06 #2, -07-20 #1, -07-21 #3). Two rules
28 + //! carried over intact:
29 + //!
30 + //! - The default is [`Posture::Local`]. A key crosses only when a spec declares
31 + //! it [`Posture::Synced`].
32 + //! - An **undeclared** key is `Local`, never `Synced`. So the sync filter a spec
33 + //! drives fails closed: a key nobody classified stays on the device rather than
34 + //! leaking because someone forgot to name it.
35 +
36 + #![forbid(unsafe_code)]
37 +
38 + mod spec;
39 + mod store;
40 +
41 + pub use spec::{ConfigSpec, PolicyRow, Posture};
42 + pub use store::{ConfigError, ConfigStore, Result};
@@ -0,0 +1,178 @@
1 + //! What a config's keys are, and which of them may sync.
2 + //!
3 + //! A [`ConfigSpec`] is the source of truth for postures: a static list of the
4 + //! keys an app knows, each tagged [`Synced`] or [`Local`]. It is pure data with
5 + //! no connection and no I/O, so it can be a `const` an app declares once. The
6 + //! sync side ([`synckit-client`]) reads [`policy_rows`](ConfigSpec::policy_rows)
7 + //! to seed the filter that keeps `Local` keys off the wire.
8 + //!
9 + //! [`Synced`]: Posture::Synced
10 + //! [`Local`]: Posture::Local
11 +
12 + /// Whether a config key may be replicated to the user's other devices.
13 + ///
14 + /// The default is [`Local`](Posture::Local), and so is the answer for any key a
15 + /// spec does not mention: see [`ConfigSpec::posture`]. That is the whole safety
16 + /// property — a key crosses the sync boundary only when someone wrote down that
17 + /// it should.
18 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19 + pub enum Posture {
20 + /// Stays on this device. Filesystem paths, local safety gates, and anything
21 + /// not explicitly cleared to sync. The default.
22 + #[default]
23 + Local,
24 + /// A user preference worth carrying across the user's devices.
25 + Synced,
26 + }
27 +
28 + impl Posture {
29 + /// Whether a key of this posture is replicated. What the policy table stores.
30 + pub const fn replicated(self) -> bool {
31 + matches!(self, Posture::Synced)
32 + }
33 + }
34 +
35 + /// One row of the policy the sync filter reads: a key and whether it replicates.
36 + ///
37 + /// [`ConfigSpec::policy_rows`] yields these for the sync side to seed. A plain
38 + /// pair rather than a richer type because that is all the SQL filter needs, and
39 + /// keeping it flat means the seeding query is an obvious upsert.
40 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
41 + pub struct PolicyRow {
42 + pub key: &'static str,
43 + pub replicated: bool,
44 + }
45 +
46 + /// The keys a config knows, and their postures.
47 + ///
48 + /// Declared as a `const` per app: the table it lives in, and every key with its
49 + /// posture. Only [`Synced`](Posture::Synced) keys need listing for correctness —
50 + /// an unlisted key reads as [`Local`](Posture::Local) — but listing the local
51 + /// ones too documents the surface and lets a test assert the set is complete.
52 + #[derive(Debug, Clone, Copy)]
53 + pub struct ConfigSpec {
54 + table: &'static str,
55 + keys: &'static [(&'static str, Posture)],
56 + }
57 +
58 + impl ConfigSpec {
59 + /// A spec over `table`, with `keys` declaring each known key's posture.
60 + ///
61 + /// Duplicate keys are a programming error the caller controls (the list is a
62 + /// `const`); the last one wins in [`posture`](Self::posture), matching how a
63 + /// reader would expect a later override to read, but a spec should not carry
64 + /// duplicates and [`policy_rows`](Self::policy_rows) emits them as written.
65 + pub const fn new(table: &'static str, keys: &'static [(&'static str, Posture)]) -> Self {
66 + Self { table, keys }
67 + }
68 +
69 + /// The SQLite table the config lives in.
70 + pub const fn table(&self) -> &'static str {
71 + self.table
72 + }
73 +
74 + /// The declared keys and their postures, in declaration order.
75 + pub fn keys(&self) -> impl Iterator<Item = (&'static str, Posture)> + '_ {
76 + self.keys.iter().copied()
77 + }
78 +
79 + /// The posture of `key`.
80 + ///
81 + /// **Fail-closed: an undeclared key is [`Local`](Posture::Local).** This is
82 + /// the rule the whole sync boundary rests on. A key nobody classified never
83 + /// replicates, so forgetting to name a new local path does not leak it — the
84 + /// failure of omission keeps data home rather than sending it.
85 + pub fn posture(&self, key: &str) -> Posture {
86 + self.keys
87 + .iter()
88 + .rev()
89 + .find(|(name, _)| *name == key)
90 + .map_or(Posture::Local, |(_, posture)| *posture)
91 + }
92 +
93 + /// Whether `key` may cross the sync boundary. Shorthand for a `Synced`
94 + /// posture, and the question the sync filter actually asks.
95 + pub fn is_synced(&self, key: &str) -> bool {
96 + self.posture(key).replicated()
97 + }
98 +
99 + /// The policy rows the sync side seeds, one per declared key.
100 + ///
101 + /// Only these rows exist in the policy table, and the sync filter admits a
102 + /// key only if its row says `replicated`. A key absent here is absent there,
103 + /// which is the same fail-closed rule as [`posture`](Self::posture) enforced
104 + /// in SQL rather than in Rust.
105 + pub fn policy_rows(&self) -> impl Iterator<Item = PolicyRow> + '_ {
106 + self.keys.iter().map(|(key, posture)| PolicyRow {
107 + key,
108 + replicated: posture.replicated(),
109 + })
110 + }
111 + }
112 +
113 + #[cfg(test)]
114 + mod tests {
115 + use super::*;
116 +
117 + const SPEC: ConfigSpec = ConfigSpec::new(
118 + "app_config",
119 + &[
120 + ("theme", Posture::Synced),
121 + ("sidebar_visible", Posture::Synced),
122 + ("mirror_path", Posture::Local),
123 + ],
124 + );
125 +
126 + #[test]
127 + fn the_default_posture_is_local() {
128 + assert_eq!(Posture::default(), Posture::Local);
129 + assert!(!Posture::Local.replicated());
130 + assert!(Posture::Synced.replicated());
131 + }
132 +
133 + // The rule the sync boundary rests on: a key nobody classified stays home.
134 + // A forgotten local path must not leak because it was never named.
135 + #[test]
136 + fn an_undeclared_key_is_local_not_synced() {
137 + assert_eq!(SPEC.posture("never_heard_of_it"), Posture::Local);
138 + assert!(!SPEC.is_synced("never_heard_of_it"));
139 + }
140 +
141 + #[test]
142 + fn declared_keys_report_their_posture() {
143 + assert!(SPEC.is_synced("theme"));
144 + assert!(SPEC.is_synced("sidebar_visible"));
145 + assert!(!SPEC.is_synced("mirror_path"), "a local path never syncs");
146 + }
147 +
148 + // policy_rows is what the sync filter seeds; a key absent here is absent in
149 + // the filter, which is the same fail-closed rule enforced in SQL.
150 + #[test]
151 + fn policy_rows_carry_every_declared_key_and_its_replication() {
152 + let rows: Vec<PolicyRow> = SPEC.policy_rows().collect();
153 + assert_eq!(rows.len(), 3);
154 + assert_eq!(
155 + rows[0],
156 + PolicyRow {
157 + key: "theme",
158 + replicated: true
159 + },
160 + );
161 + assert_eq!(
162 + rows[2],
163 + PolicyRow {
164 + key: "mirror_path",
165 + replicated: false,
166 + },
167 + );
168 + // No row for an unclassified key, so the SQL filter cannot admit one.
169 + assert!(!rows.iter().any(|r| r.key == "never_heard_of_it"));
170 + }
171 +
172 + #[test]
173 + fn the_table_and_key_set_are_readable() {
174 + assert_eq!(SPEC.table(), "app_config");
175 + let keys: Vec<&str> = SPEC.keys().map(|(k, _)| k).collect();
176 + assert_eq!(keys, ["theme", "sidebar_visible", "mirror_path"]);
177 + }
178 + }
@@ -0,0 +1,200 @@
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 + /// The value stored at `key`, or `None` if nothing is.
57 + ///
58 + /// An unset key is `None`, not an error: "the user has not chosen a theme" is
59 + /// a normal state, and the caller supplies the default (see the theme
60 + /// resolver, which reads `None` as "follow the system").
61 + pub fn get(&self, conn: &Connection, key: &str) -> Result<Option<String>> {
62 + let value = conn
63 + .query_row(
64 + &format!("SELECT value FROM {} WHERE key = ?1", self.table),
65 + [key],
66 + |row| row.get::<_, String>(0),
67 + )
68 + .map(Some)
69 + .or_else(|error| match error {
70 + rusqlite::Error::QueryReturnedNoRows => Ok(None),
71 + other => Err(other),
72 + })?;
73 + Ok(value)
74 + }
75 +
76 + /// Set `key` to `value`, replacing any previous value.
77 + ///
78 + /// One upsert, so a syncing app's change-capture trigger sees a single INSERT
79 + /// or UPDATE rather than a delete-then-insert that would enqueue two changes
80 + /// for one edit.
81 + pub fn set(&self, conn: &Connection, key: &str, value: &str) -> Result<()> {
82 + conn.execute(
83 + &format!(
84 + "INSERT INTO {} (key, value) VALUES (?1, ?2) \
85 + ON CONFLICT(key) DO UPDATE SET value = excluded.value",
86 + self.table
87 + ),
88 + (key, value),
89 + )?;
90 + Ok(())
91 + }
92 +
93 + /// Remove `key`. Absent already is not an error.
94 + pub fn unset(&self, conn: &Connection, key: &str) -> Result<()> {
95 + conn.execute(&format!("DELETE FROM {} WHERE key = ?1", self.table), [key])?;
96 + Ok(())
97 + }
98 +
99 + /// Every key and value, ordered by key for a stable read.
100 + ///
101 + /// For an app that wants to show or export the whole config; the common path
102 + /// is [`get`](Self::get) of one key.
103 + pub fn all(&self, conn: &Connection) -> Result<Vec<(String, String)>> {
104 + let mut stmt = conn.prepare(&format!(
105 + "SELECT key, value FROM {} ORDER BY key",
106 + self.table
107 + ))?;
108 + let rows = stmt
109 + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
110 + .collect::<rusqlite::Result<Vec<_>>>()?;
111 + Ok(rows)
112 + }
113 + }
114 +
115 + #[cfg(test)]
116 + mod tests {
117 + use super::*;
118 + use crate::spec::Posture;
119 +
120 + const SPEC: ConfigSpec = ConfigSpec::new(
121 + "app_config",
122 + &[("theme", Posture::Synced), ("mirror_path", Posture::Local)],
123 + );
124 +
125 + fn store() -> (Connection, ConfigStore) {
126 + let conn = Connection::open_in_memory().unwrap();
127 + let store = ConfigStore::open(&conn, &SPEC).unwrap();
128 + (conn, store)
129 + }
130 +
131 + #[test]
132 + fn a_value_round_trips() {
133 + let (conn, store) = store();
134 + assert_eq!(store.get(&conn, "theme").unwrap(), None, "unset reads None");
135 +
136 + store.set(&conn, "theme", "akari-night").unwrap();
137 + assert_eq!(
138 + store.get(&conn, "theme").unwrap().as_deref(),
139 + Some("akari-night"),
140 + );
141 + }
142 +
143 + #[test]
144 + fn set_replaces_rather_than_duplicating() {
145 + let (conn, store) = store();
146 + store.set(&conn, "theme", "flatwhite").unwrap();
147 + store.set(&conn, "theme", "akari-night").unwrap();
148 + assert_eq!(
149 + store.get(&conn, "theme").unwrap().as_deref(),
150 + Some("akari-night"),
151 + );
152 + assert_eq!(store.all(&conn).unwrap().len(), 1, "one row, not two");
153 + }
154 +
155 + #[test]
156 + fn unset_removes_and_is_idempotent() {
157 + let (conn, store) = store();
158 + store.set(&conn, "theme", "flatwhite").unwrap();
159 + store.unset(&conn, "theme").unwrap();
160 + assert_eq!(store.get(&conn, "theme").unwrap(), None);
161 + // Removing what is already gone is not an error.
162 + store.unset(&conn, "theme").unwrap();
163 + }
164 +
165 + #[test]
166 + fn open_is_idempotent() {
167 + let (conn, _store) = store();
168 + // A second open over the same connection must not fail on the existing
169 + // table; every startup calls this.
170 + ConfigStore::open(&conn, &SPEC).unwrap();
171 + }
172 +
173 + #[test]
174 + fn all_is_ordered_by_key() {
175 + let (conn, store) = store();
176 + store.set(&conn, "theme", "flatwhite").unwrap();
177 + store.set(&conn, "mirror_path", "/mnt/x").unwrap();
178 + assert_eq!(
179 + store.all(&conn).unwrap(),
180 + vec![
181 + ("mirror_path".to_string(), "/mnt/x".to_string()),
182 + ("theme".to_string(), "flatwhite".to_string()),
183 + ],
184 + );
185 + }
186 +
187 + // The store is posture-blind: it holds a local path the same as a synced
188 + // preference. Keeping that path off the wire is the sync side's job, driven
189 + // by the spec; the local store simply stores.
190 + #[test]
191 + fn the_store_holds_local_and_synced_keys_alike() {
192 + let (conn, store) = store();
193 + store.set(&conn, "mirror_path", "/mnt/samples").unwrap();
194 + assert_eq!(
195 + store.get(&conn, "mirror_path").unwrap().as_deref(),
196 + Some("/mnt/samples"),
197 + "a Local key is still stored locally; posture governs sync, not storage",
198 + );
199 + }
200 + }