Skip to main content

max / synckit

The group directory: stop discarding what the sync loop already fetches A described screen's handler is a synchronous function. That is the property that lets one description serve a webview, a terminal and an egui window, and it means a screen cannot fetch anything: whatever it draws has to be on this side of the network first. Every other thing a consuming app draws already is. Groups were the exception, so two screens in goingson could not be described at all (quasicoherent 82273265, goingson 7f36900b). They did not have to be. The sync loop has always asked the server which groups to sync, once per cycle, and the answer has always carried the name and the admin. list_group_scopes mapped each record down to (GroupId, i32) and dropped the rest on the floor. So the remote read was already happening, on a schedule, outside any request loop; what was missing was writing it down. - SyncTransport::list_group_scopes becomes list_group_directory, returning KnownGroup { id, name, gck_version, is_admin }. is_admin costs no request: it is admin_user_id against the session's own id, both already in hand. - SyncTransport::list_group_members is new and is the one real request added. It is made only for groups the user administers, since the server refuses a non-admin, and a failure leaves the previous answer in place rather than blanking a list or failing the sync. - sync_groups and sync_group_members hold the answer. store::directory has the synchronous reads a described handler calls with a connection from the app's own pool: groups(), members(), is_member(), refreshed_at(). is_member() is the one worth naming. share_project asked the server whether the user belongs to a group before stamping a scope, which is a check worth making (a scope the engine holds no key for routes a whole subtree into a changelog that goes nowhere) and which made the write unreachable from a description. It is a local read now. NOT SYNCED STATE, and it does not move the storage version. Both tables are absent from every SyncSchema, so nothing here crosses the wire, which is exactly the local-only DDL the gate's own rule exempts. Asserted, rather than reasoned about, by the_directory_is_absent_from_the_wire_manifest. The server is the authority and this is a copy of its answer, so a refresh replaces rather than merges: a group the user was removed from is absent from the answer, and leaving it behind would offer a scope they cannot reach. Members are scoped to their own group for the opposite reason, so one failed fetch does not blank another group's list. Breaking (the trait method changed shape), so 0.8.1 to 0.9.0 with all five consumers forward-fixed in the same pass. None implements SyncTransport, so every one is a pin move. internal-deps.py clean across 42 requirements.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 18:22 UTC
Signed with PGP, not checked
Commit: b87b2b65e5d4d0a972422e3d31b9de760ffcf690
Parent: 0822859
6 files changed, +614 insertions, -14 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "synckit-client"
3 - version = "0.8.1"
3 + version = "0.9.0"
4 4 edition = "2024"
5 5 license = "MIT"
6 6 description = "SyncKit client SDK with end-to-end encryption"
@@ -194,12 +194,48 @@
194 194 // Group scopes: sync each group the user belongs to, under its own key and
195 195 // cursor. A transport without groups reports none (the default), so this
196 196 // loop is a no-op for personal-only apps.
197 - for (id, gck_version) in self.client.list_group_scopes().await? {
198 - let scope = SyncScope::Group { id, gck_version };
197 + let directory = self.client.list_group_directory().await?;
198 +
199 + // Written down before the scopes are walked, so a cycle that fails
200 + // partway still leaves the directory current. It is the server's answer
201 + // either way, and a screen reading a list of groups is not waiting on
202 + // whether their changelogs pulled cleanly.
203 + {
204 + let db = self.db.clone();
205 + let groups = directory.clone();
206 + tokio::task::spawn_blocking(move || -> Result<()> {
207 + super::directory::write_groups(&mut db.open()?, &groups)
208 + })
209 + .await
210 + .map_err(|e| join_err(&e))??;
211 + }
212 +
213 + for group in &directory {
214 + let scope = SyncScope::Group {
215 + id: group.id,
216 + gck_version: group.gck_version,
217 + };
199 218 pushed += push_scope(&self.db, &*self.client, &self.schema, device_id, scope).await?;
200 219 let gp = pull_scope(&self.db, &*self.client, &self.schema, device_id, scope).await?;
201 220 pulled += gp.applied;
202 221 changed_tables.extend(gp.changed_tables);
222 +
223 + // Members, for the groups this user administers. Best-effort in
224 + // both directions: the server refuses a non-admin, so it is not
225 + // asked, and a failure leaves the previous answer in place rather
226 + // than blanking a list or failing the sync. A member list is
227 + // directory data, and no sync correctness rests on it.
228 + if group.is_admin
229 + && let Ok(members) = self.client.list_group_members(group.id).await
230 + {
231 + let db = self.db.clone();
232 + let id = group.id;
233 + tokio::task::spawn_blocking(move || -> Result<()> {
234 + super::directory::write_members(&mut db.open()?, id, &members)
235 + })
236 + .await
237 + .map_err(|e| join_err(&e))??;
238 + }
203 239 }
204 240
205 241 let blobs = match &self.blob_policy {
@@ -454,7 +490,7 @@
454 490 device_id: DeviceId,
455 491 log: Arc<Mutex<Vec<(DeviceId, ChangeEntry)>>>,
456 492 blobs: Arc<Mutex<HashMap<String, Vec<u8>>>>,
457 - /// Groups this member belongs to, reported by `list_group_scopes`.
493 + /// Groups this member belongs to, reported by `list_group_directory`.
458 494 groups: Vec<(crate::ids::GroupId, i32)>,
459 495 /// Shared group changelog (plaintext, the fake bypasses encryption, which
460 496 /// is covered by the `client::groups` tests).
@@ -535,13 +571,38 @@
535 571 }
536 572 }
537 573
538 - fn list_group_scopes(
574 + fn list_group_directory(
539 575 &self,
540 - ) -> impl Future<Output = Result<Vec<(crate::ids::GroupId, i32)>>> + Send {
541 - let groups = self.groups.clone();
576 + ) -> impl Future<Output = Result<Vec<super::super::sync::KnownGroup>>> + Send {
577 + // Named for the id, and admin, so the member fetch below is
578 + // exercised: the fake is the only place the members path is driven.
579 + let groups: Vec<_> = self
580 + .groups
581 + .iter()
582 + .map(|(id, gck_version)| super::super::sync::KnownGroup {
583 + id: *id,
584 + name: format!("Group {id}"),
585 + gck_version: *gck_version,
586 + is_admin: true,
587 + })
588 + .collect();
542 589 async move { Ok(groups) }
543 590 }
544 591
592 + fn list_group_members(
593 + &self,
594 + group_id: crate::ids::GroupId,
595 + ) -> impl Future<Output = Result<Vec<super::super::sync::KnownMember>>> + Send {
596 + async move {
597 + Ok(vec![super::super::sync::KnownMember {
598 + user_id: group_id.to_string(),
599 + email: "member@localhost".to_owned(),
600 + role: "admin".to_owned(),
601 + added_at: "2026-08-24T00:00:00Z".to_owned(),
602 + }])
603 + }
604 + }
605 +
545 606 fn group_scope_push(
546 607 &self,
547 608 group_id: crate::ids::GroupId,
@@ -725,6 +786,23 @@
725 786 )
726 787 .unwrap();
727 788 assert_eq!(personal_rows, 0);
789 +
790 + // And the cycle wrote the directory down, which is the whole point of
791 + // `list_group_directory` carrying a record rather than a tuple: a
792 + // synchronous reader can now name this group without a request.
793 + let conn = dbb.open().unwrap();
794 + let known = super::super::directory::groups(&conn).unwrap();
795 + assert_eq!(known.len(), 1, "the group B belongs to");
796 + assert_eq!(known[0].id, gid);
797 + assert_eq!(known[0].name, format!("Group {gid}"));
798 + assert!(
799 + super::super::directory::is_member(&conn, gid).unwrap(),
800 + "membership is answerable locally"
801 + );
802 + // The fake reports the user as admin, so the members path ran too.
803 + let people = super::super::directory::members(&conn, gid).unwrap();
804 + assert_eq!(people.len(), 1);
805 + assert_eq!(people[0].email, "member@localhost");
728 806 }
729 807
730 808 /// Pre-stamp a device's pending edit at a controlled time so cross-device HLC
@@ -59,6 +59,55 @@
59 59 INSERT OR IGNORE INTO sync_scope_cursor (scope, cursor)
60 60 VALUES ('', CAST((SELECT value FROM sync_state WHERE key = 'pull_cursor') AS INTEGER));
61 61
62 + -- The groups this user belongs to, and who is in them.
63 + --
64 + -- A DIRECTORY, not synced state. Both tables are the server's answer written
65 + -- down: the sync loop already asks the server which groups to sync on every
66 + -- cycle, and until 2026-08-24 it kept `(id, gck_version)` and dropped the rest
67 + -- of each record on the floor. Keeping it costs no request that was not already
68 + -- being made.
69 + --
70 + -- WHY IT HAS TO BE LOCAL. A described screen's handler is a synchronous
71 + -- function, so a screen that needs the group list cannot fetch it. That is not a
72 + -- quasi limitation to work around but the property that lets one description
73 + -- serve a webview, a terminal and an egui window: nothing in a description
74 + -- blocks. The directory is how the answer gets to this side of the network
75 + -- before the question is asked.
76 + --
77 + -- Local-only by construction, like `sync_conflict_stash`: absent from every
78 + -- `SyncSchema`, so it is never group-scoped, never pushed, never on a shared
79 + -- changelog, and it does NOT move the storage version. It is a cache of the
80 + -- server's own state, so the server always wins and there is nothing to merge.
81 + --
82 + -- STALENESS IS A FACT, NOT A FAILURE. `refreshed_at` is what a screen states
83 + -- when it says how current the list is. A directory that has never been
84 + -- refreshed is empty rather than wrong.
85 + CREATE TABLE IF NOT EXISTS sync_groups (
86 + group_id TEXT PRIMARY KEY NOT NULL,
87 + name TEXT NOT NULL,
88 + gck_version INTEGER NOT NULL,
89 + -- Whether this user administers the group, which decides whether the
90 + -- member list below can be fetched at all.
91 + is_admin INTEGER NOT NULL DEFAULT 0,
92 + refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
93 + );
94 +
95 + -- Who is in each group, for the groups this user administers.
96 + --
97 + -- Only an admin may list members, so for a group this user merely belongs to
98 + -- there are no rows and that is correct rather than missing. A screen reads
99 + -- whether the user administers a group from `sync_groups.is_admin` rather than
100 + -- by finding the member list empty, since those are different facts.
101 + CREATE TABLE IF NOT EXISTS sync_group_members (
102 + group_id TEXT NOT NULL,
103 + user_id TEXT NOT NULL,
104 + email TEXT NOT NULL,
105 + role TEXT NOT NULL,
106 + added_at TEXT NOT NULL,
107 + refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
108 + PRIMARY KEY (group_id, user_id)
109 + ) WITHOUT ROWID;
110 +
62 111 -- Committed HLC per row (conflict gating).
63 112 CREATE TABLE IF NOT EXISTS sync_committed_hlc (
64 113 table_name TEXT NOT NULL,
@@ -23,6 +23,7 @@
23 23 pub mod config;
24 24 pub mod db;
25 25 pub mod deferred;
26 + pub mod directory;
26 27 pub mod facade;
27 28 pub mod hlc;
28 29 pub mod migrate;
@@ -63,6 +63,41 @@
63 63 /// Maximum changes sent in one push batch.
64 64 pub const PUSH_BATCH_LIMIT: usize = 500;
65 65
66 + /// One group this user belongs to, as the sync loop learns it.
67 + ///
68 + /// What [`SyncTransport::list_group_directory`] returns, and what the store
69 + /// writes into `sync_groups`. Everything the scope loop needs plus the two facts
70 + /// a screen needs and the old `(GroupId, i32)` tuple threw away: the name, and
71 + /// whether this user administers it.
72 + #[derive(Debug, Clone, PartialEq, Eq)]
73 + pub struct KnownGroup {
74 + /// Server-assigned group id.
75 + pub id: GroupId,
76 + /// Human-readable group name.
77 + pub name: String,
78 + /// Current GCK generation.
79 + pub gck_version: i32,
80 + /// Whether this user is the group's admin, which decides whether its member
81 + /// list can be fetched at all.
82 + pub is_admin: bool,
83 + }
84 +
85 + /// One member of a group this user administers.
86 + ///
87 + /// What [`SyncTransport::list_group_members`] returns, and what the store writes
88 + /// into `sync_group_members`.
89 + #[derive(Debug, Clone, PartialEq, Eq)]
90 + pub struct KnownMember {
91 + /// The member's account id.
92 + pub user_id: String,
93 + /// The member's account email.
94 + pub email: String,
95 + /// `"admin"` or `"member"`.
96 + pub role: String,
97 + /// When they were added, RFC 3339.
98 + pub added_at: String,
99 + }
100 +
66 101 /// The two operations the sync loops need from a server.
67 102 ///
68 103 /// A seam for testing (an in-memory fake) and the reference point a future
@@ -99,14 +134,36 @@
99 134 /// the same position every client was in before the gate existed.
100 135 fn set_storage_version(&self, _version: Option<u32>) {}
101 136
102 - /// The groups this user belongs to, as `(group_id, current_gck_version)`,
103 - /// the scopes to sync beyond personal. Defaults to none, so a transport that
104 - /// does not support groups (a test double, a future minimal SDK) never syncs
105 - /// any group scope.
137 + /// The groups this user belongs to: the scopes to sync beyond personal, and
138 + /// the directory the store writes down.
139 + ///
140 + /// Defaults to none, so a transport that does not support groups (a test
141 + /// double, a future minimal SDK) never syncs any group scope.
142 + ///
143 + /// This returned `Vec<(GroupId, i32)>` until 2026-08-24 and was called
144 + /// `list_group_scopes`. The server's answer always carried the name and the
145 + /// admin, and the mapping down to a tuple discarded them on every cycle,
146 + /// which left a consuming app with no way to name a group without asking the
147 + /// server again from somewhere that could await. See `sync_groups` in
148 + /// [`migrate`](super::migrate).
106 149 // `-> impl Future + Send` (not `async fn`) keeps the explicit Send bound the
107 150 // store's spawned tasks need; that is the whole trait's convention.
108 151 #[allow(clippy::manual_async_fn)]
109 - fn list_group_scopes(&self) -> impl Future<Output = Result<Vec<(GroupId, i32)>>> + Send {
152 + fn list_group_directory(&self) -> impl Future<Output = Result<Vec<KnownGroup>>> + Send {
153 + async move { Ok(Vec::new()) }
154 + }
155 +
156 + /// Who is in one group this user administers.
157 + ///
158 + /// Best-effort and admin-only: the server refuses a non-admin, so the store
159 + /// asks only for groups whose [`KnownGroup::is_admin`] is set and treats a
160 + /// refusal as "no answer this cycle" rather than as a sync failure. Defaults
161 + /// to none, for the same reason the directory does.
162 + #[allow(clippy::manual_async_fn)]
163 + fn list_group_members(
164 + &self,
165 + _group_id: GroupId,
166 + ) -> impl Future<Output = Result<Vec<KnownMember>>> + Send {
110 167 async move { Ok(Vec::new()) }
111 168 }
112 169
@@ -180,13 +237,40 @@
180 237 SyncKitClient::set_storage_version(self, version);
181 238 }
182 239
183 - fn list_group_scopes(&self) -> impl Future<Output = Result<Vec<(GroupId, i32)>>> + Send {
240 + fn list_group_directory(&self) -> impl Future<Output = Result<Vec<KnownGroup>>> + Send {
184 241 async move {
242 + // `admin_user_id` against the session's own id, so `is_admin` costs
243 + // no request: the answer is already in the record the server sent.
244 + let me = self.session_info().map(|s| s.user_id);
185 245 Ok(self
186 246 .list_groups()
187 247 .await?
188 248 .into_iter()
189 - .map(|g| (g.id, g.gck_version))
249 + .map(|g| KnownGroup {
250 + is_admin: me.is_some_and(|me| me == g.admin_user_id),
251 + id: g.id,
252 + name: g.name,
253 + gck_version: g.gck_version,
254 + })
255 + .collect())
256 + }
257 + }
258 +
259 + fn list_group_members(
260 + &self,
261 + group_id: GroupId,
262 + ) -> impl Future<Output = Result<Vec<KnownMember>>> + Send {
263 + async move {
264 + Ok(self
265 + .list_members(group_id)
266 + .await?
267 + .into_iter()
268 + .map(|m| KnownMember {
269 + user_id: m.user_id.to_string(),
270 + email: m.email,
271 + role: m.role,
272 + added_at: m.added_at.to_rfc3339(),
273 + })
190 274 .collect())
191 275 }
192 276 }
@@ -1,0 +1,388 @@
1 + //! The group directory: the server's answer about groups, written down locally.
2 + //!
3 + //! # Why it exists
4 + //!
5 + //! A described screen's handler is a synchronous function. That is not a
6 + //! limitation to work around; it is the property that lets one description serve
7 + //! a webview, a terminal and an egui window, because nothing in a description
8 + //! blocks. It does mean a screen cannot fetch anything, so any data a screen
9 + //! needs has to be on this side of the network before the screen is drawn.
10 + //!
11 + //! Everything else a consuming app draws already is: rows come out of its own
12 + //! tables. Groups were the exception. `list_groups` is an HTTP GET, membership
13 + //! lives on the server, and the only local trace of a group was its id in
14 + //! `sync_scope_cursor`, which is a cursor rather than a directory and carries no
15 + //! name.
16 + //!
17 + //! # It costs no request
18 + //!
19 + //! The sync loop has always asked the server which groups to sync, once per
20 + //! cycle, and the answer has always carried the name and the admin. Until
21 + //! 2026-08-24 [`SyncTransport::list_group_directory`](super::sync::SyncTransport::list_group_directory)
22 + //! was `list_group_scopes` and mapped each record down to `(GroupId, i32)`,
23 + //! dropping the rest. Writing it down instead is the whole mechanism: the read
24 + //! already happens, on a schedule, outside any request loop.
25 + //!
26 + //! Members are the one part that is a new request, and it is made only for
27 + //! groups this user administers, since the server refuses a non-admin.
28 + //!
29 + //! # What it is not
30 + //!
31 + //! Not synced state. Both tables are absent from every
32 + //! [`SyncSchema`](super::schema::SyncSchema), so they are never group-scoped,
33 + //! never pushed, never on a shared changelog, and they **do not move the storage
34 + //! version**: nothing here crosses the wire, which is exactly the local-only DDL
35 + //! the gate's own rule exempts.
36 + //!
37 + //! The server is the authority and this is a copy of its answer, so there is
38 + //! nothing to merge and no conflict to resolve. A refresh replaces.
39 + //!
40 + //! # Staleness is a fact, not a failure
41 + //!
42 + //! [`refreshed_at`] is what a screen states when it says how current the list is.
43 + //! A directory that has never been refreshed is empty, which is a true statement
44 + //! about what this device knows rather than a claim that the user has no groups.
45 +
46 + use rusqlite::{Connection, OptionalExtension, params};
47 +
48 + use super::sync::{KnownGroup, KnownMember};
49 + use crate::GroupId;
50 + use crate::error::Result;
51 +
52 + /// Replace the group directory with what the server just reported.
53 + ///
54 + /// A whole replace rather than an upsert: a group the user has been removed from
55 + /// is absent from the answer, and leaving it behind would offer a scope they can
56 + /// no longer reach. Members of a departed group go with it, which is why this
57 + /// takes the transaction rather than leaving the caller to remember.
58 + pub fn write_groups(conn: &mut Connection, groups: &[KnownGroup]) -> Result<()> {
59 + let tx = conn.transaction()?;
60 + tx.execute("DELETE FROM sync_group_members", [])?;
61 + tx.execute("DELETE FROM sync_groups", [])?;
62 + for group in groups {
63 + tx.execute(
64 + "INSERT INTO sync_groups (group_id, name, gck_version, is_admin) \
65 + VALUES (?1, ?2, ?3, ?4)",
66 + params![
67 + group.id.to_string(),
68 + group.name,
69 + group.gck_version,
70 + i32::from(group.is_admin),
71 + ],
72 + )?;
73 + }
74 + tx.commit()?;
75 + Ok(())
76 + }
77 +
78 + /// Replace one group's member list.
79 + ///
80 + /// Scoped to the one group, unlike [`write_groups`]: a cycle where the member
81 + /// fetch failed for one group should not blank the others, and a refusal is
82 + /// "no answer this time" rather than "nobody is in it".
83 + pub fn write_members(
84 + conn: &mut Connection,
85 + group_id: GroupId,
86 + members: &[KnownMember],
87 + ) -> Result<()> {
88 + let tx = conn.transaction()?;
89 + tx.execute(
90 + "DELETE FROM sync_group_members WHERE group_id = ?1",
91 + params![group_id.to_string()],
92 + )?;
93 + for member in members {
94 + tx.execute(
95 + "INSERT INTO sync_group_members (group_id, user_id, email, role, added_at) \
96 + VALUES (?1, ?2, ?3, ?4, ?5)",
97 + params![
98 + group_id.to_string(),
99 + member.user_id,
100 + member.email,
101 + member.role,
102 + member.added_at,
103 + ],
104 + )?;
105 + }
106 + tx.commit()?;
107 + Ok(())
108 + }
109 +
110 + /// The groups this device knows about, by name.
111 + ///
112 + /// Synchronous and cheap, which is the point: a described handler calls this
113 + /// with a connection from the app's own pool.
114 + pub fn groups(conn: &Connection) -> Result<Vec<KnownGroup>> {
115 + let mut stmt = conn.prepare(
116 + "SELECT group_id, name, gck_version, is_admin FROM sync_groups ORDER BY name COLLATE NOCASE",
117 + )?;
118 + let rows = stmt
119 + .query_map([], |row| {
120 + let raw: String = row.get(0)?;
121 + Ok((
122 + raw,
123 + row.get::<_, String>(1)?,
124 + row.get::<_, i32>(2)?,
125 + row.get::<_, i32>(3)?,
126 + ))
127 + })?
128 + .collect::<std::result::Result<Vec<_>, _>>()?;
129 +
130 + Ok(rows
131 + .into_iter()
132 + // A row whose id will not parse is skipped rather than failing the read.
133 + // It can only come from a build that wrote a different id shape, and a
134 + // screen that cannot list its groups is worse than one missing a row it
135 + // could not have addressed anyway.
136 + .filter_map(|(raw, name, gck_version, is_admin)| {
137 + Some(KnownGroup {
138 + id: GroupId::new(raw.parse().ok()?),
139 + name,
140 + gck_version,
141 + is_admin: is_admin != 0,
142 + })
143 + })
144 + .collect())
145 + }
146 +
147 + /// Whether this device knows the user to be a member of `group_id`.
148 + ///
149 + /// The local answer to the question `share_project` used to ask the server. A
150 + /// scope the sync engine holds no key for routes a whole subtree into a changelog
151 + /// that goes nowhere, so the check is worth making; making it against the
152 + /// directory means it can be made from a described handler.
153 + pub fn is_member(conn: &Connection, group_id: GroupId) -> Result<bool> {
154 + let found: Option<i64> = conn
155 + .query_row(
156 + "SELECT 1 FROM sync_groups WHERE group_id = ?1",
157 + params![group_id.to_string()],
158 + |row| row.get(0),
159 + )
160 + .optional()?;
161 + Ok(found.is_some())
162 + }
163 +
164 + /// Who is in a group this user administers, earliest first.
165 + ///
166 + /// Empty for a group the user merely belongs to, because the server refuses a
167 + /// non-admin the member list. A screen says "you administer this group" from
168 + /// [`KnownGroup::is_admin`] rather than by finding this empty: those are
169 + /// different facts and only one of them is about permission.
170 + pub fn members(conn: &Connection, group_id: GroupId) -> Result<Vec<KnownMember>> {
171 + let mut stmt = conn.prepare(
172 + "SELECT user_id, email, role, added_at FROM sync_group_members \
173 + WHERE group_id = ?1 ORDER BY added_at",
174 + )?;
175 + let rows = stmt
176 + .query_map(params![group_id.to_string()], |row| {
177 + Ok(KnownMember {
178 + user_id: row.get(0)?,
179 + email: row.get(1)?,
180 + role: row.get(2)?,
181 + added_at: row.get(3)?,
182 + })
183 + })?
184 + .collect::<std::result::Result<Vec<_>, _>>()?;
185 + Ok(rows)
186 + }
187 +
188 + /// When the directory was last written, RFC 3339, or `None` if it never has
189 + /// been.
190 + ///
191 + /// What a screen states when it says how current its list is. The oldest row
192 + /// wins: a directory is only as fresh as its stalest entry, and reporting the
193 + /// newest would call a list current on the strength of the one group that
194 + /// refreshed.
195 + pub fn refreshed_at(conn: &Connection) -> Result<Option<String>> {
196 + Ok(conn
197 + .query_row("SELECT MIN(refreshed_at) FROM sync_groups", [], |row| {
198 + row.get::<_, Option<String>>(0)
199 + })
200 + .optional()?
201 + .flatten())
202 + }
203 +
204 + #[cfg(test)]
205 + mod tests {
206 + use super::*;
207 + use crate::store::schema::{SyncSchema, SyncTable};
208 +
209 + fn db() -> Connection {
210 + let conn = Connection::open_in_memory().expect("a database");
211 + let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]);
212 + conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);")
213 + .expect("the app table");
214 + conn.execute_batch(&schema.migration_sql())
215 + .expect("the sync tables");
216 + conn
217 + }
218 +
219 + fn group(id: u128, name: &str, is_admin: bool) -> KnownGroup {
220 + KnownGroup {
221 + id: GroupId::new(uuid::Uuid::from_u128(id)),
222 + name: name.to_owned(),
223 + gck_version: 1,
224 + is_admin,
225 + }
226 + }
227 +
228 + fn member(email: &str, added_at: &str) -> KnownMember {
229 + KnownMember {
230 + user_id: format!("user-{email}"),
231 + email: email.to_owned(),
232 + role: "member".to_owned(),
233 + added_at: added_at.to_owned(),
234 + }
235 + }
236 +
237 + #[test]
238 + fn a_store_that_has_never_synced_knows_no_groups() {
239 + let conn = db();
240 + assert!(groups(&conn).unwrap().is_empty());
241 + assert_eq!(refreshed_at(&conn).unwrap(), None);
242 + }
243 +
244 + #[test]
245 + fn the_directory_comes_back_by_name() {
246 + let mut conn = db();
247 + write_groups(
248 + &mut conn,
249 + &[group(2, "Zebra", false), group(1, "Aardvark", true)],
250 + )
251 + .unwrap();
252 +
253 + let listed = groups(&conn).unwrap();
254 + assert_eq!(listed.len(), 2);
255 + assert_eq!(listed[0].name, "Aardvark", "ordered by name, not by id");
256 + assert_eq!(listed[1].name, "Zebra");
257 + assert!(listed[0].is_admin);
258 + assert!(!listed[1].is_admin);
259 + }
260 +
261 + /// A group the user has been removed from is absent from the server's
262 + /// answer, and leaving it behind would offer a scope they cannot reach.
263 + #[test]
264 + fn a_refresh_replaces_rather_than_merges() {
265 + let mut conn = db();
266 + write_groups(
267 + &mut conn,
268 + &[group(1, "Kept", true), group(2, "Removed", true)],
269 + )
270 + .unwrap();
271 + write_groups(&mut conn, &[group(1, "Kept", true)]).unwrap();
272 +
273 + let listed = groups(&conn).unwrap();
274 + assert_eq!(listed.len(), 1);
275 + assert_eq!(listed[0].name, "Kept");
276 + }
277 +
278 + #[test]
279 + fn losing_a_group_takes_its_members_with_it() {
280 + let mut conn = db();
281 + write_groups(&mut conn, &[group(1, "Gone", true)]).unwrap();
282 + write_members(
283 + &mut conn,
284 + GroupId::new(uuid::Uuid::from_u128(1)),
285 + &[member("a@localhost", "2026-01-01T00:00:00Z")],
286 + )
287 + .unwrap();
288 +
289 + write_groups(&mut conn, &[]).unwrap();
290 + assert!(
291 + members(&conn, GroupId::new(uuid::Uuid::from_u128(1)))
292 + .unwrap()
293 + .is_empty(),
294 + "a member list outlived the group it belonged to"
295 + );
296 + }
297 +
298 + /// Scoped, unlike the group write: a cycle where one group's member fetch
299 + /// failed must not blank another group's list.
300 + #[test]
301 + fn writing_one_groups_members_leaves_another_groups_alone() {
302 + let mut conn = db();
303 + write_groups(&mut conn, &[group(1, "One", true), group(2, "Two", true)]).unwrap();
304 + let one = GroupId::new(uuid::Uuid::from_u128(1));
305 + let two = GroupId::new(uuid::Uuid::from_u128(2));
306 +
307 + write_members(
308 + &mut conn,
309 + one,
310 + &[member("a@localhost", "2026-01-01T00:00:00Z")],
311 + )
312 + .unwrap();
313 + write_members(
314 + &mut conn,
315 + two,
316 + &[member("b@localhost", "2026-01-01T00:00:00Z")],
317 + )
318 + .unwrap();
319 +
320 + assert_eq!(members(&conn, one).unwrap().len(), 1);
321 + assert_eq!(members(&conn, two).unwrap()[0].email, "b@localhost");
322 + }
323 +
324 + #[test]
325 + fn members_come_back_oldest_first() {
326 + let mut conn = db();
327 + write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
328 + let one = GroupId::new(uuid::Uuid::from_u128(1));
329 + write_members(
330 + &mut conn,
331 + one,
332 + &[
333 + member("late@localhost", "2026-06-01T00:00:00Z"),
334 + member("early@localhost", "2026-01-01T00:00:00Z"),
335 + ],
336 + )
337 + .unwrap();
338 +
339 + let listed = members(&conn, one).unwrap();
340 + assert_eq!(listed[0].email, "early@localhost");
341 + assert_eq!(listed[1].email, "late@localhost");
342 + }
343 +
344 + /// The question `share_project` used to ask the server, answered locally.
345 + #[test]
346 + fn membership_is_answerable_without_a_request() {
347 + let mut conn = db();
348 + write_groups(&mut conn, &[group(1, "Mine", false)]).unwrap();
349 +
350 + assert!(is_member(&conn, GroupId::new(uuid::Uuid::from_u128(1))).unwrap());
351 + assert!(!is_member(&conn, GroupId::new(uuid::Uuid::from_u128(9))).unwrap());
352 + }
353 +
354 + /// Belonging to a group and administering it are different facts, and only
355 + /// one of them is about permission. A screen must not read the empty member
356 + /// list as "nobody is in it".
357 + #[test]
358 + fn a_group_you_only_belong_to_has_no_members_and_says_so_separately() {
359 + let mut conn = db();
360 + write_groups(&mut conn, &[group(1, "Someone else's", false)]).unwrap();
361 +
362 + let listed = groups(&conn).unwrap();
363 + assert!(!listed[0].is_admin, "the fact that gates the member list");
364 + assert!(
365 + members(&conn, GroupId::new(uuid::Uuid::from_u128(1)))
366 + .unwrap()
367 + .is_empty(),
368 + "and the list nobody asked the server for"
369 + );
370 + }
371 +
372 + #[test]
373 + fn a_written_directory_reports_when_it_was_written() {
374 + let mut conn = db();
375 + write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
376 + assert!(refreshed_at(&conn).unwrap().is_some());
377 + }
378 +
379 + /// Local-only DDL, so the gate must not see it. A table absent from every
380 + /// `SyncSchema` crosses no wire, and the storage version describes the wire.
381 + #[test]
382 + fn the_directory_is_absent_from_the_wire_manifest() {
383 + let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]);
384 + let wire = schema.wire_manifest();
385 + assert!(!wire.contains("sync_groups"), "{wire}");
386 + assert!(!wire.contains("sync_group_members"), "{wire}");
387 + }
388 + }