Skip to main content

max / synckit

6.5 KB · 179 lines History Blame Raw
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 }
179