Skip to main content

max / synckit

Let an app hold a directory without holding a SyncStore Two gaps the first consumer found. add_group writes one group without touching the rest of the directory, for the moment a group is created: the server knows it, the sync loop has not run since, and a directory read would call the creator a non-member of their own group. Additive on purpose, since the loop's whole replace is the authoritative write and this fills in ahead of it. DDL and ensure_tables make the directory's two tables available on their own. An app that has never been signed in never builds a SyncStore, so the sync DDL has never run and the tables are absent; the screens that read a directory do not care whether sync was ever configured. Nothing here is synced, so there is no manifest to stay consistent with and no ordering against the rest of the sync DDL to respect. Every read now answers an absent table as an empty directory, which is the same fact stated earlier: this device knows of no groups. Checked against sqlite_master rather than inferred from an error string, so a real database fault still surfaces as one instead of being swallowed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 18:33 UTC
Signed with PGP, not checked
Commit: 95ab84d94bee2b5619743ad078c46308a10ce842
Parent: b87b2b6
1 file changed, +157 insertions, -1 deletion
@@ -45,7 +45,7 @@
45 45
46 46 use rusqlite::{Connection, OptionalExtension, params};
47 47
48 - use super::sync::{KnownGroup, KnownMember};
48 + pub use super::sync::{KnownGroup, KnownMember};
49 49 use crate::GroupId;
50 50 use crate::error::Result;
51 51
@@ -75,6 +75,31 @@
75 75 Ok(())
76 76 }
77 77
78 + /// Add or update one group, without touching the rest of the directory.
79 + ///
80 + /// For the moment a group is created: the server knows it, the sync loop has not
81 + /// run since, and a screen that reads the directory would call the creator a
82 + /// non-member of their own group. Additive on purpose, since the loop's whole
83 + /// replace is the authoritative write and this is filling in ahead of it.
84 + pub fn add_group(conn: &mut Connection, group: &KnownGroup) -> Result<()> {
85 + conn.execute(
86 + "INSERT INTO sync_groups (group_id, name, gck_version, is_admin) \
87 + VALUES (?1, ?2, ?3, ?4) \
88 + ON CONFLICT(group_id) DO UPDATE SET \
89 + name = excluded.name, \
90 + gck_version = excluded.gck_version, \
91 + is_admin = excluded.is_admin, \
92 + refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
93 + params![
94 + group.id.to_string(),
95 + group.name,
96 + group.gck_version,
97 + i32::from(group.is_admin),
98 + ],
99 + )?;
100 + Ok(())
101 + }
102 +
78 103 /// Replace one group's member list.
79 104 ///
80 105 /// Scoped to the one group, unlike [`write_groups`]: a cycle where the member
@@ -107,11 +132,70 @@
107 132 Ok(())
108 133 }
109 134
135 + /// The directory's own DDL, idempotent.
136 + ///
137 + /// The same two `CREATE TABLE IF NOT EXISTS` statements
138 + /// [`SyncSchema::migration_sql`](super::schema::SyncSchema::migration_sql) emits,
139 + /// available on their own so an app can have a directory without building a
140 + /// `SyncStore`. Nothing here is synced, so there is no manifest to be consistent
141 + /// with and no ordering against the rest of the sync DDL to respect.
142 + pub const DDL: &str = "\
143 + CREATE TABLE IF NOT EXISTS sync_groups (
144 + group_id TEXT PRIMARY KEY NOT NULL,
145 + name TEXT NOT NULL,
146 + gck_version INTEGER NOT NULL,
147 + is_admin INTEGER NOT NULL DEFAULT 0,
148 + refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
149 + );
150 + CREATE TABLE IF NOT EXISTS sync_group_members (
151 + group_id TEXT NOT NULL,
152 + user_id TEXT NOT NULL,
153 + email TEXT NOT NULL,
154 + role TEXT NOT NULL,
155 + added_at TEXT NOT NULL,
156 + refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
157 + PRIMARY KEY (group_id, user_id)
158 + ) WITHOUT ROWID;
159 + ";
160 +
161 + /// Create the directory tables if they are not there.
162 + ///
163 + /// Safe to call on every start. An app that also builds a `SyncStore` gets these
164 + /// from the sync DDL anyway; calling this as well costs two no-op statements and
165 + /// means the reads below never depend on whether sync has been configured yet.
166 + pub fn ensure_tables(conn: &Connection) -> Result<()> {
167 + conn.execute_batch(DDL)?;
168 + Ok(())
169 + }
170 +
171 + /// Whether the directory tables exist on this connection.
172 + ///
173 + /// A device that has never configured sync has no sync tables at all: the DDL
174 + /// runs when a `SyncStore` is built, and an app that has never been signed in
175 + /// never builds one. Every read below treats that as an empty directory, because
176 + /// it is the same fact stated earlier: this device knows of no groups.
177 + ///
178 + /// Checked rather than inferred from an error string, so a genuine database
179 + /// fault still surfaces as one instead of being swallowed as "no groups".
180 + fn present(conn: &Connection) -> Result<bool> {
181 + let found: Option<i64> = conn
182 + .query_row(
183 + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sync_groups'",
184 + [],
185 + |row| row.get(0),
186 + )
187 + .optional()?;
188 + Ok(found.is_some())
189 + }
190 +
110 191 /// The groups this device knows about, by name.
111 192 ///
112 193 /// Synchronous and cheap, which is the point: a described handler calls this
113 194 /// with a connection from the app's own pool.
114 195 pub fn groups(conn: &Connection) -> Result<Vec<KnownGroup>> {
196 + if !present(conn)? {
197 + return Ok(Vec::new());
198 + }
115 199 let mut stmt = conn.prepare(
116 200 "SELECT group_id, name, gck_version, is_admin FROM sync_groups ORDER BY name COLLATE NOCASE",
117 201 )?;
@@ -151,6 +235,9 @@
151 235 /// that goes nowhere, so the check is worth making; making it against the
152 236 /// directory means it can be made from a described handler.
153 237 pub fn is_member(conn: &Connection, group_id: GroupId) -> Result<bool> {
238 + if !present(conn)? {
239 + return Ok(false);
240 + }
154 241 let found: Option<i64> = conn
155 242 .query_row(
156 243 "SELECT 1 FROM sync_groups WHERE group_id = ?1",
@@ -168,6 +255,9 @@
168 255 /// [`KnownGroup::is_admin`] rather than by finding this empty: those are
169 256 /// different facts and only one of them is about permission.
170 257 pub fn members(conn: &Connection, group_id: GroupId) -> Result<Vec<KnownMember>> {
258 + if !present(conn)? {
259 + return Ok(Vec::new());
260 + }
171 261 let mut stmt = conn.prepare(
172 262 "SELECT user_id, email, role, added_at FROM sync_group_members \
173 263 WHERE group_id = ?1 ORDER BY added_at",
@@ -193,6 +283,9 @@
193 283 /// newest would call a list current on the strength of the one group that
194 284 /// refreshed.
195 285 pub fn refreshed_at(conn: &Connection) -> Result<Option<String>> {
286 + if !present(conn)? {
287 + return Ok(None);
288 + }
196 289 Ok(conn
197 290 .query_row("SELECT MIN(refreshed_at) FROM sync_groups", [], |row| {
198 291 row.get::<_, Option<String>>(0)
@@ -369,6 +462,69 @@
369 462 );
370 463 }
371 464
465 + /// The moment a group is created: the server knows it, no cycle has run, and
466 + /// a directory read must not call the creator a non-member of their own
467 + /// group.
468 + #[test]
469 + fn a_group_added_on_creation_is_usable_before_the_next_sync() {
470 + let mut conn = db();
471 + write_groups(&mut conn, &[group(1, "Existing", false)]).unwrap();
472 + add_group(&mut conn, &group(2, "Just made", true)).unwrap();
473 +
474 + assert!(is_member(&conn, GroupId::new(uuid::Uuid::from_u128(2))).unwrap());
475 + assert_eq!(
476 + groups(&conn).unwrap().len(),
477 + 2,
478 + "additive: it did not replace the directory"
479 + );
480 + }
481 +
482 + #[test]
483 + fn adding_a_group_that_is_already_known_updates_it() {
484 + let mut conn = db();
485 + add_group(&mut conn, &group(1, "Old name", false)).unwrap();
486 + add_group(&mut conn, &group(1, "New name", true)).unwrap();
487 +
488 + let listed = groups(&conn).unwrap();
489 + assert_eq!(listed.len(), 1);
490 + assert_eq!(listed[0].name, "New name");
491 + assert!(listed[0].is_admin);
492 + }
493 +
494 + /// An app that has never been signed in never builds a `SyncStore`, so the
495 + /// sync DDL has never run and the tables are absent. Every read answers as
496 + /// an empty directory, because that is the same fact: this device knows of
497 + /// no groups.
498 + /// The DDL const and what `migration_sql` emits are the same statements, so
499 + /// an app that takes either route gets the same tables.
500 + #[test]
501 + fn the_standalone_ddl_agrees_with_the_migration() {
502 + let conn = Connection::open_in_memory().expect("a database");
503 + ensure_tables(&conn).expect("the directory tables");
504 + assert!(present(&conn).unwrap());
505 +
506 + let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]);
507 + for table in ["sync_groups", "sync_group_members"] {
508 + assert!(schema.migration_sql().contains(table), "{table}");
509 + }
510 + }
511 +
512 + #[test]
513 + fn a_store_with_no_sync_tables_at_all_reads_as_an_empty_directory() {
514 + let conn = Connection::open_in_memory().expect("a database");
515 + conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);")
516 + .expect("the app table");
517 +
518 + assert!(groups(&conn).unwrap().is_empty());
519 + assert!(!is_member(&conn, GroupId::new(uuid::Uuid::from_u128(1))).unwrap());
520 + assert!(
521 + members(&conn, GroupId::new(uuid::Uuid::from_u128(1)))
522 + .unwrap()
523 + .is_empty()
524 + );
525 + assert_eq!(refreshed_at(&conn).unwrap(), None);
526 + }
527 +
372 528 #[test]
373 529 fn a_written_directory_reports_when_it_was_written() {
374 530 let mut conn = db();