Skip to main content

max / synckit

9.0 KB · 244 lines History Blame Raw
1 //! The storage-version gate: refuse to sync across a breaking manifest change.
2 //!
3 //! Standing policy. When a storage change is breaking, a client refuses to sync
4 //! rather than degrading, and the local migration runs first. The alternative
5 //! failure is silent: a client that syncs everything it recognises and ignores
6 //! the rest looks like it worked, and the user learns about the gap much later
7 //! from missing data rather than immediately from a refusal.
8 //!
9 //! There are two comparisons, and they answer different questions.
10 //!
11 //! **This device against its own store** ([`enforce_local`]). The store carries
12 //! the version of the manifest that last shaped it, in `sync_state`. A build
13 //! whose manifest is *older* than the store is a downgrade meeting data it does
14 //! not fully understand; a build whose manifest is *newer* has not run its local
15 //! migration yet, and migration is not a thing the sync does, it is a thing that
16 //! has to have already happened for the sync to be allowed. Either way the cycle
17 //! stops before it registers a device or reads a row.
18 //!
19 //! **This device against a peer** ([`check_peer`], driven from the pull loop).
20 //! Clients share one changelog, so a peer on a different manifest is the case the
21 //! policy actually exists for. Every pushed change seals its storage version into
22 //! the (end-to-end encrypted) HLC envelope, so a pull can read the other side's
23 //! number and refuse before anything is applied.
24 //!
25 //! An app that has not declared a version leaves both gates off, which is what
26 //! every consumer predating this got. Adopting the gate is
27 //! [`SyncSchema::storage_version`](super::schema::SyncSchema::storage_version).
28 //!
29 //! <!-- wiki: synckit-overview -->
30
31 use rusqlite::Connection;
32
33 use super::db::{get_sync_state, set_sync_state};
34 use super::schema::SyncSchema;
35 use crate::error::{Result, StorageVersionRefusal, SyncKitError, VersionSource};
36
37 /// The `sync_state` key holding the storage version this store was last shaped
38 /// by.
39 pub const STORAGE_VERSION_KEY: &str = "storage_version";
40
41 /// Read the store's stamped storage version, or `None` if it has never been
42 /// stamped (every store predating the gate).
43 pub fn stored_version(conn: &Connection) -> Result<Option<u32>> {
44 match get_sync_state(conn, STORAGE_VERSION_KEY)? {
45 None => Ok(None),
46 Some(raw) if raw.is_empty() => Ok(None),
47 Some(raw) => raw.parse::<u32>().map(Some).map_err(|_| {
48 SyncKitError::Database(format!(
49 "sync_state.{STORAGE_VERSION_KEY} is not an integer: {raw:?}"
50 ))
51 }),
52 }
53 }
54
55 /// Stamp the store with `version`.
56 ///
57 /// An app calls this at the end of its own local migration, in the same
58 /// transaction where it can: the stamp is the claim that the store now matches
59 /// the manifest, so writing it before the migration finishes is what would make a
60 /// half-migrated store observable to a peer.
61 pub fn stamp_version(conn: &Connection, version: u32) -> Result<()> {
62 set_sync_state(conn, STORAGE_VERSION_KEY, &version.to_string())
63 }
64
65 /// Gate this device against its own store, and adopt the stamp on a store that
66 /// has never carried one.
67 ///
68 /// Returns the declared version when the gate is on, `None` when the manifest has
69 /// not adopted it.
70 ///
71 /// Adoption is deliberate rather than a refusal: a store with no stamp is one
72 /// written before the gate existed, not one written by a version we disagree
73 /// with, and there is nothing to migrate. Refusing there would break every
74 /// existing install on the upgrade that turns the gate on.
75 pub fn enforce_local(conn: &Connection, schema: &SyncSchema) -> Result<Option<u32>> {
76 let Some(mine) = schema.declared_storage_version() else {
77 return Ok(None);
78 };
79 match stored_version(conn)? {
80 None => {
81 stamp_version(conn, mine)?;
82 Ok(Some(mine))
83 }
84 Some(theirs) if theirs == mine => Ok(Some(mine)),
85 Some(theirs) => Err(SyncKitError::StorageVersion(StorageVersionRefusal {
86 mine,
87 theirs,
88 source: VersionSource::LocalStore,
89 })),
90 }
91 }
92
93 /// Gate this device against one peer's stamp.
94 ///
95 /// `theirs` is `None` for a change pushed by a build that predates the stamp, or
96 /// by one whose manifest declares no version. There is nothing to compare, so it
97 /// passes: the gate protects from the version it lands in forward, and treating
98 /// an absent stamp as a mismatch would refuse every pre-adoption row already on
99 /// the changelog.
100 pub fn check_peer(mine: Option<u32>, theirs: Option<u32>) -> Result<()> {
101 let (Some(mine), Some(theirs)) = (mine, theirs) else {
102 return Ok(());
103 };
104 if mine == theirs {
105 return Ok(());
106 }
107 Err(SyncKitError::StorageVersion(StorageVersionRefusal {
108 mine,
109 theirs,
110 source: VersionSource::Peer,
111 }))
112 }
113
114 #[cfg(test)]
115 mod tests {
116 use super::*;
117 use crate::error::SyncKitError;
118 use crate::store::schema::SyncTable;
119
120 fn schema_at(version: u32) -> SyncSchema {
121 SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]).storage_version(version)
122 }
123
124 fn undeclared() -> SyncSchema {
125 SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])])
126 }
127
128 fn db(schema: &SyncSchema) -> Connection {
129 let conn = Connection::open_in_memory().unwrap();
130 conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);")
131 .unwrap();
132 conn.execute_batch(&schema.migration_sql()).unwrap();
133 conn
134 }
135
136 fn refusal(err: SyncKitError) -> StorageVersionRefusal {
137 match err {
138 SyncKitError::StorageVersion(r) => r,
139 other => panic!("expected a storage-version refusal, got {other:?}"),
140 }
141 }
142
143 #[test]
144 fn a_store_that_has_never_been_stamped_adopts_the_declared_version() {
145 let s = schema_at(4);
146 let conn = db(&s);
147 assert_eq!(stored_version(&conn).unwrap(), None);
148 assert_eq!(enforce_local(&conn, &s).unwrap(), Some(4));
149 assert_eq!(
150 stored_version(&conn).unwrap(),
151 Some(4),
152 "adoption stamps, so the next open compares against a real number"
153 );
154 }
155
156 #[test]
157 fn an_undeclared_manifest_leaves_the_gate_off_and_stamps_nothing() {
158 let s = undeclared();
159 let conn = db(&s);
160 assert_eq!(enforce_local(&conn, &s).unwrap(), None);
161 assert_eq!(stored_version(&conn).unwrap(), None);
162 }
163
164 #[test]
165 fn an_older_build_meeting_a_newer_store_refuses() {
166 let conn = db(&schema_at(5));
167 stamp_version(&conn, 5).unwrap();
168
169 let r = refusal(enforce_local(&conn, &schema_at(4)).unwrap_err());
170 assert_eq!((r.mine, r.theirs), (4, 5));
171 assert_eq!(r.source, VersionSource::LocalStore);
172 assert!(r.local_is_older());
173 assert_eq!(
174 r.message(),
175 "This store uses a newer format. Update to sync."
176 );
177 assert!(r.to_string().contains("nothing was written"));
178 }
179
180 #[test]
181 fn a_newer_build_meeting_an_unmigrated_store_refuses_with_the_other_message() {
182 let conn = db(&schema_at(4));
183 stamp_version(&conn, 4).unwrap();
184
185 let r = refusal(enforce_local(&conn, &schema_at(5)).unwrap_err());
186 assert_eq!((r.mine, r.theirs), (5, 4));
187 assert!(!r.local_is_older());
188 assert_eq!(
189 r.message(),
190 "This store has not been migrated to the current format yet."
191 );
192 }
193
194 #[test]
195 fn stamping_after_the_migration_reopens_the_gate() {
196 let s = schema_at(5);
197 let conn = db(&s);
198 stamp_version(&conn, 4).unwrap();
199 assert!(enforce_local(&conn, &s).is_err());
200
201 // What an app calls at the end of its own local migration.
202 stamp_version(&conn, 5).unwrap();
203 assert_eq!(enforce_local(&conn, &s).unwrap(), Some(5));
204 }
205
206 #[test]
207 fn a_non_integer_stamp_is_an_error_rather_than_a_silent_zero() {
208 let s = schema_at(1);
209 let conn = db(&s);
210 set_sync_state(&conn, STORAGE_VERSION_KEY, "four").unwrap();
211 assert!(matches!(
212 stored_version(&conn),
213 Err(SyncKitError::Database(_))
214 ));
215 }
216
217 #[test]
218 fn the_peer_gate_passes_only_on_equality() {
219 assert!(check_peer(Some(4), Some(4)).is_ok());
220
221 let r = refusal(check_peer(Some(4), Some(5)).unwrap_err());
222 assert_eq!(r.source, VersionSource::Peer);
223 assert_eq!(r.message(), "Update this device.");
224
225 let r = refusal(check_peer(Some(5), Some(4)).unwrap_err());
226 assert_eq!(r.message(), "Another device is out of date.");
227 }
228
229 /// Equality, not a floor: an additive change is still breaking for the older
230 /// peer, because clients share one changelog.
231 #[test]
232 fn a_higher_peer_version_is_not_forward_compatible() {
233 assert!(check_peer(Some(4), Some(5)).is_err());
234 assert!(check_peer(Some(5), Some(4)).is_err());
235 }
236
237 #[test]
238 fn an_absent_stamp_on_either_side_passes() {
239 assert!(check_peer(None, Some(5)).is_ok());
240 assert!(check_peer(Some(5), None).is_ok());
241 assert!(check_peer(None, None).is_ok());
242 }
243 }
244