Skip to main content

max / goingson

The group-admin writes, queued rather than performed create_group, add_member and remove_member each open a conversation with a server, and a described handler is synchronous by quasi_router's Decision 6, which exists so egui and a terminal need no runtime. So Settings > Sharing could show the groups and could not change them. Queueing is a local write, so it can be described. This is the outbox's argument applied a second time, under Max's ruling behind that one (a3c76a24), which is what makes it a pattern rather than a workaround repeated. group_queue is deliberately shaped like outbox: same interval, same backoff curve (shared, not re-derived), same split of the loop from drain_once so a test needs neither a Tauri handle nor a minute. WHAT A QUEUE BUYS HERE, and it is not what it bought for mail. There is no send-later value in creating a group. What there is: the write survives being offline instead of failing at the instant somebody pressed the button, which matters more for these because they are rare and deliberate and nobody retries them by habit; a failure is a row with the server's own words on it, sitting in the section that caused it, rather than a toast that has already gone; and add_member needs the master key loaded to seal the group key, so a queue turns "you cannot do this right now" into "this happens once you unlock", which is honest and is not something a button could offer. The queue is shown, with what each row will do and why it failed. A control whose effect is a minute away has to be visible, or the section looks like it lost the request. A queued row can be taken back out, which is why a failure is held rather than deleted: the person who queued it decides, not the drainer. A pass with no client leaves rows untouched rather than counting attempts against them: a device that has never signed in will never succeed, and counting would back the row off to never while the reason stays the same. A key that is not a key is refused at the form rather than queued to fail later, since the person who pasted it is on screen now. Everything else the server judges, and the queue reports. The drainer writes a created group into the directory on its way past, the same as commands::group::group_create does, so the section that queued it sees it land rather than waiting a cycle. group_admin_queue is EXCLUDED from backups, deliberately: restoring one would perform it again, re-admitting somebody to a group possibly long after they were removed. The server is the authority on membership. Still not here: the invitation flow, six commands and a state machine. They queue as cleanly as these three; what they need first is a decision about what the section shows for an invitation in flight.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 19:18 UTC
Signed with PGP, not checked
Commit: 292877c6a458a29d576e78256a24bb3ffff9ee7b
Parent: 8c1a555
9 files changed, +1040 insertions, -32 deletions
M Cargo.lock +4 -4
@@ -8493,6 +8493,10 @@
8493 8493 "winnow 1.0.4",
8494 8494 ]
8495 8495
8496 + [[patch.unused]]
8497 + name = "ops-status"
8498 + version = "0.1.0"
8499 +
8496 8500 [[patch.unused]]
8497 8501 name = "quasi-axum"
8498 8502 version = "0.56.0"
@@ -8508,7 +8512,3 @@
8508 8512 [[patch.unused]]
8509 8513 name = "quasi-store"
8510 8514 version = "0.1.0"
8511 -
8512 - [[patch.unused]]
8513 - name = "ops-status"
8514 - version = "0.1.0"
@@ -70,3 +70,4 @@
70 70 067 6ac04d5280e01472180139ba6a5ae83be242dbcae8ca5b1adf90c15ad01507f734047262fd987bc8eb50c238f96621d8
71 71 068 ccdddaa4176f36169bb90b1eec191ae776a87ee1cd56ba2bd789f905fb212bcd2b04b884e820498fab8b6d03fb7e5c6d
72 72 069 afc5492dfbda7dde625f1ca00e06e8ae0c26ad10e3a26399aec7bf0fee66d4beaabb0e4b00ee8fab1ddb695790194c46
73 + 070 b95a1c7f6c7c3103de2d952f9571b8d284b5fbc1679c70757e1a322023f2a4d6f0c213dd0e8b95136483d931b290e896
@@ -12,6 +12,9 @@
12 12 pub mod email_sync_scheduler;
13 13 pub mod export;
14 14 pub mod external_sync;
15 + /// The group-admin queue drainer. See the module header, and `outbox` for the
16 + /// pattern it is the second of.
17 + pub mod group_queue;
15 18 pub mod jmap;
16 19 pub mod notifs;
17 20 pub mod oauth;
@@ -466,6 +469,19 @@
466 469 crate::outbox::start_outbox_drainer(outbox_handle, outbox_cancel).await;
467 470 });
468 471
472 + // The group-admin queue drainer: what actually creates a group or
473 + // admits a member. Same shape and same reason as the outbox above,
474 + // one layer along. See `group_queue`.
475 + let group_queue_handle = app.handle().clone();
476 + let group_queue_cancel = cancel_token.clone();
477 + tauri::async_runtime::spawn(async move {
478 + crate::group_queue::start_group_queue_drainer(
479 + group_queue_handle,
480 + group_queue_cancel,
481 + )
482 + .await;
483 + });
484 +
469 485 // Cloud sync runs through the SyncStore engine's own scheduler now
470 486 // (its own timer + SSE race + gate chain), reporting via GoSyncObserver.
471 487 let cloud_sync_handle = app.handle().clone();
@@ -617,6 +617,36 @@
617 617 .toast(quasi_router::layout::Tone::Success, said))
618 618 }
619 619
620 + /// The Sharing pane, re-read, with a word about what just happened.
621 + ///
622 + /// Whole-pane rather than a narrower region, because every one of these writes
623 + /// lands in the queue list and one of them lands in the group list too.
624 + fn sharing_pane(state: &AppState, said: &str) -> Result<Response, RouteError> {
625 + Ok(Response::fragment(
626 + SECTION_REGION,
627 + Node::Region(Slot::new(SECTION_REGION, RegionKind::Pane).extend(sharing::pane(state)?)),
628 + )
629 + .toast(quasi_router::layout::Tone::Success, said))
630 + }
631 +
632 + /// Queue a new group.
633 + fn queue_group(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
634 + let said = sharing::create_group(state, &request)?;
635 + sharing_pane(state, said)
636 + }
637 +
638 + /// Queue an add-member.
639 + fn queue_member(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
640 + let said = sharing::add_member(state, &request)?;
641 + sharing_pane(state, said)
642 + }
643 +
644 + /// Take a queued admin write back out.
645 + fn cancel_queued(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
646 + let said = sharing::cancel(state, &request)?;
647 + sharing_pane(state, said)
648 + }
649 +
620 650 /// Turn automatic syncing on or off.
621 651 fn set_sync_auto(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
622 652 let on = request.payload.get(sync::AUTO_SYNC).unwrap_or_default() != "disabled";
@@ -747,6 +777,11 @@
747 777 .post("/settings/about/update-check", set_update_check)
748 778 .post("/settings/sync/auto", set_sync_auto)
749 779 .post("/settings/sync/interval", set_sync_interval)
750 - .post("/settings/sync/disconnect", disconnect_sync);
780 + .post("/settings/sync/disconnect", disconnect_sync)
781 + // Every one of these is a local insert. What talks to the server is
782 + // `group_queue`'s drainer; see `sharing`.
783 + .post("/settings/sharing/groups", queue_group)
784 + .post("/settings/sharing/members", queue_member)
785 + .post("/settings/sharing/queue/{id}/cancel", cancel_queued);
751 786 router.get("/settings/{section}", section)
752 787 }
@@ -67,6 +67,14 @@
67 67 "hlc_state", // hybrid logical clock, device-local sync internal
68 68 "sync_committed_hlc", // per-row committed HLC clock store, device-local sync internal
69 69 "user_config", // app settings; synced keys recover via cloud sync, device-local keys re-derive per device
70 + // A queue of admin writes that have not reached the server. Excluded
71 + // because restoring one would PERFORM it again: a backup taken while an
72 + // add-member was queued would, on restore, re-admit that person to the
73 + // group, possibly long after they were deliberately removed. The server is
74 + // the authority on membership and the queue is only ever a list of what has
75 + // not reached it yet, so a restored device should start with an empty one
76 + // and read the truth from the directory.
77 + "group_admin_queue",
70 78 ];
71 79
72 80 /// Columns a backup deliberately does not carry, each with the reason.
@@ -35,25 +35,32 @@
35 35 //! local crypto rather than a request. `my_group_pubkey` and
36 36 //! `my_group_fingerprint` are `async` by declaration only; neither body awaits.
37 37 //!
38 - //! # What is not here, and it is absent rather than drawn dead
38 + //! # The admin writes are here, and they are queued rather than performed
39 39 //!
40 - //! Creating a group, adding a member, removing one, and the invitation flow.
41 - //! Every one is a conversation with a server, which is the half that stays
42 - //! host-bound, the same arrangement as Email's OAuth handshake and Sync's
43 - //! Connect. A control that is drawn and does nothing is worse than one that is
44 - //! not drawn.
40 + //! Creating a group, adding a member and removing one each open a conversation
41 + //! with a server, and a handler cannot await. So the description says "queue
42 + //! this" and [`crate::group_queue`] drains it a minute later, which is the
43 + //! outbox's answer applied a second time under Max's ruling behind it
44 + //! (`a3c76a24`).
45 45 //!
46 - //! That leaves the section honest rather than whole, and the line is worth
47 - //! keeping in view: **a person can see their groups and be added to one from
48 - //! here, and cannot create one or admit anybody.** The admin half is what
49 - //! remains of goingson `7f36900b`.
46 + //! What that buys, beyond making the controls sayable at all: the write survives
47 + //! being offline, a failure is a row with the server's reason on it sitting in
48 + //! this section rather than a toast that has gone, and `add_member` can be asked
49 + //! for before the master key is loaded, because the queue turns "not right now"
50 + //! into "once you unlock".
50 51 //!
51 - //! One write does reach a group without being here: `group_create` writes its new
52 - //! group into the directory on the way past, so a group made through the command
53 - //! is nameable immediately rather than at the next cycle.
52 + //! The queue is shown. A control whose effect is a minute away has to be, or the
53 + //! section looks like it lost the request.
54 + //!
55 + //! # What is still not here
56 + //!
57 + //! The invitation flow, which is six commands and a state machine rather than
58 + //! one call. `group_create_invite` and its five siblings queue as cleanly as
59 + //! these three; what they need first is a decision about what the section shows
60 + //! for an invitation in flight, and that is not this screen's to make alone.
54 61
55 - use quasi_router::screen::{Field, Row, Tag};
56 - use quasi_router::{Node, RouteError};
62 + use quasi_router::screen::{Act, Choice, Field, Row, Tag};
63 + use quasi_router::{Action, Node, RouteError};
57 64 use synckit_client::store::directory;
58 65
59 66 use crate::state::AppState;
@@ -164,6 +171,108 @@
164 171 ]
165 172 }
166 173
174 + /// What is queued and has not landed yet.
175 + ///
176 + /// Shown rather than hidden: a control whose effect is a minute away has to be
177 + /// visible, or the section looks like it lost the request. A failed row carries
178 + /// the server's own words, which is the whole reason a queue beats a toast here.
179 + fn queue(app: &AppState) -> Result<Vec<Node>, RouteError> {
180 + let queued = crate::group_queue::pending(app).map_err(RouteError::internal)?;
181 + if queued.is_empty() {
182 + return Ok(Vec::new());
183 + }
184 +
185 + let rows = queued.into_iter().map(|op| {
186 + let mut row = Row::new(op.describe());
187 + row = if op.done_at.is_some() {
188 + row.token(Tag::badge("Done").tone(makeover_layout::Tone::Success))
189 + } else if let Some(error) = op.last_error.as_deref() {
190 + // The server's own words. A queue that reported "failed" and kept
191 + // the reason would be worse than the toast it replaced.
192 + row.token(Tag::badge("Failed").tone(makeover_layout::Tone::Danger))
193 + .secondary(format!("{error} Tried {} times so far.", op.attempts))
194 + } else {
195 + row.token(Tag::badge("Waiting"))
196 + };
197 +
198 + if op.done_at.is_none() {
199 + row.menu = vec![
200 + Act::new(
201 + "Cancel",
202 + Action::post(format!("/settings/sharing/queue/{}/cancel", op.id)),
203 + )
204 + .tone(makeover_layout::Tone::Danger),
205 + ];
206 + }
207 + row
208 + });
209 +
210 + Ok(vec![
211 + Node::section("Waiting to reach the server"),
212 + Node::list(rows),
213 + ])
214 + }
215 +
216 + /// The controls that queue an admin write.
217 + ///
218 + /// Offered only where they can mean something. Creating a group needs sync
219 + /// configured; adding and removing need a group this user administers. A control
220 + /// drawn where it cannot act is the thing this section spent a commit not doing.
221 + fn admin_acts(app: &AppState, groups: &[directory::KnownGroup]) -> Vec<Node> {
222 + if app.read_recovering().is_none() {
223 + return Vec::new();
224 + }
225 +
226 + let mut nodes = vec![
227 + Node::section("Make a group"),
228 + Node::Form {
229 + action: Action::post("/settings/sharing/groups"),
230 + submit: "Queue it".to_owned(),
231 + fields: vec![
232 + Field::new(makeover_layout::FieldKind::Text, "name", "Group name")
233 + .required()
234 + .hint("It is created within a minute, and waits if you are offline."),
235 + ],
236 + },
237 + ];
238 +
239 + let administered: Vec<&directory::KnownGroup> =
240 + groups.iter().filter(|group| group.is_admin).collect();
241 + if administered.is_empty() {
242 + return nodes;
243 + }
244 +
245 + nodes.push(Node::section("Admit somebody"));
246 + nodes.push(Node::text(
247 + "Ask them for the public key their own Sharing section shows, and check its fingerprint with them out of band. The key admits them to nothing until you seal the group key to it, which is what this does.",
248 + ));
249 + nodes.push(Node::Form {
250 + action: Action::post("/settings/sharing/members"),
251 + submit: "Queue it".to_owned(),
252 + fields: vec![
253 + Field::select(
254 + "group_id",
255 + "Group",
256 + administered
257 + .iter()
258 + .map(|group| Choice::new(group.id.to_string(), &group.name))
259 + .collect(),
260 + )
261 + .required(),
262 + Field::new(makeover_layout::FieldKind::Email, "email", "Their address").required(),
263 + Field::new(
264 + makeover_layout::FieldKind::Text,
265 + "pubkey",
266 + "Their public key",
267 + )
268 + .required()
269 + .hint("The long value from their Sharing section, pasted whole."),
270 + ],
271 + });
272 +
273 + nodes
274 + }
275 +
167 276 /// The Sharing pane.
168 277 pub(super) fn pane(app: &AppState) -> Result<Vec<Node>, RouteError> {
169 278 let (groups, refreshed) = known(app)?;
@@ -188,13 +297,123 @@
188 297 }
189 298 }
190 299
300 + nodes.extend(queue(app)?);
301 + nodes.extend(admin_acts(app, &groups));
302 +
191 303 nodes.push(Node::section("Your identity key"));
192 304 nodes.extend(identity(app));
193 305
194 - nodes.push(Node::text(
195 - "Creating a group, adding a member and removing one are done elsewhere: \
196 - each is a conversation with a server, which this screen does not hold.",
197 - ));
198 -
199 306 Ok(nodes)
200 307 }
308 +
309 + /// Queue a new group.
310 + ///
311 + /// The whole of what the handler does is a local insert, which is the point: the
312 + /// conversation with the server happens in [`crate::group_queue`], a minute
313 + /// later, where there is a runtime.
314 + pub(super) fn create_group(
315 + app: &AppState,
316 + request: &quasi_router::Request,
317 + ) -> Result<&'static str, RouteError> {
318 + let name = request
319 + .payload
320 + .get("name")
321 + .unwrap_or_default()
322 + .trim()
323 + .to_owned();
324 + if name.is_empty() {
325 + return Err(RouteError::conflict("A group needs a name."));
326 + }
327 +
328 + crate::group_queue::enqueue(
329 + app,
330 + &crate::group_queue::QueuedOp {
331 + id: uuid::Uuid::new_v4().to_string(),
332 + kind: "create_group".to_owned(),
333 + group_id: None,
334 + name: Some(name),
335 + email: None,
336 + pubkey: None,
337 + member_user_id: None,
338 + attempts: 0,
339 + last_error: None,
340 + done_at: None,
341 + },
342 + )
343 + .map_err(RouteError::internal)?;
344 +
345 + Ok("Queued. The group is created within a minute, or when you are next online.")
346 + }
347 +
348 + /// Queue an add-member.
349 + ///
350 + /// The three values are checked for shape and not for truth: whether the key is
351 + /// really theirs is a question only the fingerprint they read out can answer, and
352 + /// whether the server accepts it is the drainer's to report.
353 + pub(super) fn add_member(
354 + app: &AppState,
355 + request: &quasi_router::Request,
356 + ) -> Result<&'static str, RouteError> {
357 + let field = |name: &str| {
358 + request
359 + .payload
360 + .get(name)
361 + .unwrap_or_default()
362 + .trim()
363 + .to_owned()
364 + };
365 + let (group_id, email, pubkey) = (field("group_id"), field("email"), field("pubkey"));
366 +
367 + if group_id.is_empty() || email.is_empty() || pubkey.is_empty() {
368 + return Err(RouteError::conflict(
369 + "A group, an address and a public key are all needed.",
370 + ));
371 + }
372 + // Refused here rather than queued to fail later: a key that is not a key can
373 + // never be sealed to, and the person who pasted it is on screen now.
374 + if synckit_client::identity::IdentityPublicKey::fingerprint_of_base64(&pubkey).is_err() {
375 + return Err(RouteError::conflict(
376 + "That does not look like a public key.",
377 + ));
378 + }
379 +
380 + crate::group_queue::enqueue(
381 + app,
382 + &crate::group_queue::QueuedOp {
383 + id: uuid::Uuid::new_v4().to_string(),
384 + kind: "add_member".to_owned(),
385 + group_id: Some(group_id),
386 + name: None,
387 + email: Some(email),
388 + pubkey: Some(pubkey),
389 + member_user_id: None,
390 + attempts: 0,
391 + last_error: None,
392 + done_at: None,
393 + },
394 + )
395 + .map_err(RouteError::internal)?;
396 +
397 + Ok("Queued. They are admitted within a minute, once your encryption key is loaded.")
398 + }
399 +
400 + /// Take a queued write back out.
401 + pub(super) fn cancel(
402 + app: &AppState,
403 + request: &quasi_router::Request,
404 + ) -> Result<&'static str, RouteError> {
405 + let id = request
406 + .captures
407 + .get("id")
408 + .ok_or_else(|| RouteError::not_found("no queued action"))?;
409 + if crate::group_queue::cancel(app, id).map_err(RouteError::internal)? {
410 + Ok("Taken back out of the queue.")
411 + } else {
412 + // Either it never existed or the drainer got to it first, and the second
413 + // is the interesting one: a cancel that raced a success must not report
414 + // that it undid anything, because it did not.
415 + Err(RouteError::not_found(
416 + "That action is not in the queue any more.",
417 + ))
418 + }
419 + }
@@ -440,18 +440,180 @@
440 440 assert!(pane.contains("Group list last updated"), "{pane}");
441 441 }
442 442
443 - /// Absent rather than drawn dead. A control that does nothing is worse than one
444 - /// that is not there, and the section says where the missing half went.
443 + /// Offered only where they can mean something. Without sync configured there is
444 + /// no client to reach a server with, so a create form would be a control that
445 + /// cannot act, which is the thing this section spends its design not doing.
445 446 #[tokio::test]
446 - async fn the_admin_writes_are_absent_and_the_section_says_so() {
447 + async fn the_admin_forms_are_withheld_when_there_is_no_sync_to_use_them() {
447 448 let state = state().await;
448 449 known_group(&state, 1, "The Firm", true);
449 450 let pane = sharing(&state);
451 + assert!(!pane.contains("Make a group"), "{pane}");
452 + }
450 453
451 - for absent in ["Create group", "Add member", "Remove member"] {
452 - assert!(!pane.contains(absent), "{absent} is drawn: {pane}");
453 - }
454 - assert!(pane.contains("conversation with a server"), "{pane}");
454 + /// A described handler cannot await, so the act is a local insert and the
455 + /// drainer does the talking. What the user is told has to say so.
456 + #[tokio::test]
457 + async fn queueing_a_group_writes_a_row_and_says_it_is_not_immediate() {
458 + let state = state().await;
459 + let mut params = Params::new();
460 + params.insert("name".to_owned(), "The Firm".to_owned());
461 + let response = router()
462 + .handle(
463 + &state,
464 + Request::post("/settings/sharing/groups").sending(params),
465 + )
466 + .expect("the route answers");
467 +
468 + let queued = crate::group_queue::pending(&state).unwrap();
469 + assert_eq!(queued.len(), 1);
470 + assert_eq!(queued[0].kind, "create_group");
471 + assert_eq!(queued[0].name.as_deref(), Some("The Firm"));
472 + assert!(queued[0].done_at.is_none(), "nothing reached a server");
473 +
474 + let said = format!("{:?}", response.notice);
475 + assert!(said.contains("within a minute"), "{said}");
476 + }
477 +
478 + #[tokio::test]
479 + async fn a_group_with_no_name_is_refused_rather_than_queued() {
480 + let state = state().await;
481 + let mut params = Params::new();
482 + params.insert("name".to_owned(), " ".to_owned());
483 + let error = router()
484 + .handle(
485 + &state,
486 + Request::post("/settings/sharing/groups").sending(params),
487 + )
488 + .expect_err("a nameless group is refused");
489 + assert_eq!(error.class, quasi_router::Class::Conflict);
490 + assert!(crate::group_queue::pending(&state).unwrap().is_empty());
491 + }
492 +
493 + /// A key that is not a key can never be sealed to, and the person who pasted it
494 + /// is on screen now. Refused here rather than queued to fail a minute later.
495 + #[tokio::test]
496 + async fn a_public_key_that_is_not_one_is_refused_before_it_is_queued() {
497 + let state = state().await;
498 + let mut params = Params::new();
499 + params.insert(
500 + "group_id".to_owned(),
501 + "00000000-0000-0000-0000-000000000001".to_owned(),
502 + );
503 + params.insert("email".to_owned(), "them@localhost".to_owned());
504 + params.insert("pubkey".to_owned(), "not a key".to_owned());
505 +
506 + let error = router()
507 + .handle(
508 + &state,
509 + Request::post("/settings/sharing/members").sending(params),
510 + )
511 + .expect_err("a bad key is refused");
512 + assert_eq!(error.class, quasi_router::Class::Conflict);
513 + assert!(crate::group_queue::pending(&state).unwrap().is_empty());
514 + }
515 +
516 + /// A control whose effect is a minute away has to be visible, or the section
517 + /// looks like it lost the request.
518 + #[tokio::test]
519 + async fn the_queue_is_shown_with_what_each_row_will_do() {
520 + let state = state().await;
521 + crate::group_queue::enqueue(
522 + &state,
523 + &crate::group_queue::QueuedOp {
524 + id: "q1".to_owned(),
525 + kind: "create_group".to_owned(),
526 + group_id: None,
527 + name: Some("The Firm".to_owned()),
528 + email: None,
529 + pubkey: None,
530 + member_user_id: None,
531 + attempts: 0,
532 + last_error: None,
533 + done_at: None,
534 + },
535 + )
536 + .unwrap();
537 +
538 + let pane = sharing(&state);
539 + assert!(pane.contains("Waiting to reach the server"), "{pane}");
540 + assert!(pane.contains("Create the group The Firm"), "{pane}");
541 + assert!(pane.contains("Waiting"), "{pane}");
542 + }
543 +
544 + /// The whole reason a queue beats a toast here: the server's own words, still on
545 + /// screen, where the person who caused them will look.
546 + #[tokio::test]
547 + async fn a_failed_row_carries_the_reason_it_failed() {
548 + let state = state().await;
549 + crate::group_queue::enqueue(
550 + &state,
551 + &crate::group_queue::QueuedOp {
552 + id: "q1".to_owned(),
553 + kind: "add_member".to_owned(),
554 + group_id: Some("00000000-0000-0000-0000-000000000001".to_owned()),
555 + name: None,
556 + email: Some("them@localhost".to_owned()),
557 + pubkey: Some("k".to_owned()),
558 + member_user_id: None,
559 + attempts: 0,
560 + last_error: None,
561 + done_at: None,
562 + },
563 + )
564 + .unwrap();
565 + state
566 + .db
567 + .conn()
568 + .unwrap()
569 + .execute(
570 + "UPDATE group_admin_queue SET attempts = 3, last_error = 'No such account.'",
571 + [],
572 + )
573 + .unwrap();
574 +
575 + let pane = sharing(&state);
576 + assert!(pane.contains("Add them@localhost to a group"), "{pane}");
577 + assert!(pane.contains("No such account."), "{pane}");
578 + assert!(pane.contains("Tried 3 times"), "{pane}");
579 + }
580 +
581 + /// The way out of a row that will never succeed, which is why a failure is held
582 + /// rather than deleted: the person who queued it decides, not the drainer.
583 + #[tokio::test]
584 + async fn a_queued_action_can_be_taken_back_out() {
585 + let state = state().await;
586 + crate::group_queue::enqueue(
587 + &state,
588 + &crate::group_queue::QueuedOp {
589 + id: "q1".to_owned(),
590 + kind: "create_group".to_owned(),
591 + group_id: None,
592 + name: Some("Mistake".to_owned()),
593 + email: None,
594 + pubkey: None,
595 + member_user_id: None,
596 + attempts: 0,
597 + last_error: None,
598 + done_at: None,
599 + },
600 + )
601 + .unwrap();
602 +
603 + router()
604 + .handle(&state, Request::post("/settings/sharing/queue/q1/cancel"))
605 + .expect("the route answers");
606 + assert!(crate::group_queue::pending(&state).unwrap().is_empty());
607 + }
608 +
609 + /// A cancel that raced the drainer must not claim it undid anything.
610 + #[tokio::test]
611 + async fn cancelling_something_that_already_went_is_a_not_found() {
612 + let state = state().await;
613 + let error = router()
614 + .handle(&state, Request::post("/settings/sharing/queue/gone/cancel"))
615 + .expect_err("there is nothing to cancel");
616 + assert_eq!(error.class, quasi_router::Class::NotFound);
455 617 }
456 618
457 619 /// Being added to a group takes no write from the person being added, so the
@@ -1,0 +1,67 @@
1 + -- A queue for the group-admin writes, so a described screen can offer them.
2 + --
3 + -- WHAT LED HERE, the same road the outbox took. `create_group`, `add_member`
4 + -- and `remove_member` each open a conversation with a server. A described route
5 + -- handler is synchronous, by `quasi_router`'s Decision 6, which exists so egui
6 + -- and a terminal do not need a runtime. So Settings > Sharing could show the
7 + -- groups and could not change them: goingson `7f36900b`, and the reads were
8 + -- only unblocked at all by synckit 0.9.0 writing the directory down.
9 + --
10 + -- Queueing is a local write, so it can be described. This is migration 069's
11 + -- argument applied a second time, and Max's ruling behind that one ("have
12 + -- GoingsOn use an outbox model explicitly", task a3c76a24) is what makes it a
13 + -- pattern rather than a workaround repeated.
14 + --
15 + -- WHY A QUEUE IS BETTER HERE TOO, and it is not the same reason as the mail
16 + -- one. There is no send-later value in creating a group. What there is:
17 + --
18 + -- - an admin write survives being offline instead of failing at the instant
19 + -- somebody pressed the button, which matters more for these than for mail
20 + -- because they are rare and deliberate and nobody retries them by habit;
21 + -- - a failure is a row with a reason on it, sitting where the person who
22 + -- caused it will look, rather than a toast that has already gone;
23 + -- - `add_member` needs the master key loaded to seal the group key. A queue
24 + -- turns "you cannot do this right now" into "this will happen once you
25 + -- unlock", which is the honest behaviour and not one a button could offer.
26 + --
27 + -- A SEPARATE TABLE, unlike the outbox. A queued message is a draft that has
28 + -- been committed to send, so it stays in `emails` and the outbox is a query.
29 + -- These have no such home: there is no local `groups` table this app owns, and
30 + -- `sync_groups` is synckit's copy of the server's answer, which this must not
31 + -- write into. So the queue is its own store and the directory stays a mirror.
32 + --
33 + -- LOCAL-ONLY, and deliberately absent from `syncstore::manifest`. An intention
34 + -- to add somebody to a group is this device's, and replicating it would have a
35 + -- second device perform the same admin write again. The server is the authority
36 + -- on membership; this table is only ever a list of what has not reached it yet.
37 + CREATE TABLE IF NOT EXISTS group_admin_queue (
38 + id TEXT PRIMARY KEY NOT NULL,
39 + user_id TEXT NOT NULL,
40 + -- 'create_group' | 'add_member' | 'remove_member'. Not a CHECK constraint:
41 + -- a kind this build does not know is held rather than refused, the same way
42 + -- an unknown table is on the sync side, so a downgrade does not destroy a
43 + -- queued intention it merely cannot perform.
44 + kind TEXT NOT NULL,
45 + -- The target group, NULL for 'create_group' which is what makes one.
46 + group_id TEXT,
47 + -- What the kind needs. `create_group` reads `name`; `add_member` reads
48 + -- `email` and `pubkey`; `remove_member` reads `member_user_id`. Columns
49 + -- rather than a JSON blob, because there are three kinds and eleven fields
50 + -- between them would be worse than four nullable ones.
51 + name TEXT,
52 + email TEXT,
53 + pubkey TEXT,
54 + member_user_id TEXT,
55 + queued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
56 + -- Counted rather than capped: the drainer backs off on this, and a row that
57 + -- can never succeed sits with its reason on it rather than being deleted.
58 + attempts INTEGER NOT NULL DEFAULT 0,
59 + last_error TEXT,
60 + -- Set when the server has accepted it. A done row is kept until the
61 + -- directory refresh that proves it landed, then swept, so the screen can
62 + -- say "created" for the moment between the two.
63 + done_at TEXT
64 + );
65 +
66 + CREATE INDEX IF NOT EXISTS idx_group_admin_queue_pending
67 + ON group_admin_queue(user_id) WHERE done_at IS NULL;
@@ -1,0 +1,525 @@
1 + //! The group-admin queue, and the drainer that empties it.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! # Why the app has one
6 + //!
7 + //! `create_group`, `add_member` and `remove_member` each open a conversation
8 + //! with a server. A described route handler is synchronous, by
9 + //! `quasi_router`'s Decision 6, which exists so egui and a terminal need no
10 + //! runtime. So Settings > Sharing could show the groups and could not change
11 + //! them.
12 + //!
13 + //! Queueing is a local write, so it can be described. This is the outbox's
14 + //! argument applied a second time, and Max's ruling behind that one ("have
15 + //! GoingsOn use an outbox model explicitly", `a3c76a24`) is what makes it a
16 + //! pattern rather than a workaround repeated. See [`crate::outbox`], which this
17 + //! is deliberately shaped like: a reader who has read that one already knows how
18 + //! this starts, stops, backs off and survives a failing tick.
19 + //!
20 + //! # What a queue buys here, which is not what it bought for mail
21 + //!
22 + //! There is no send-later value in creating a group. What there is:
23 + //!
24 + //! - the write survives being offline, instead of failing at the instant
25 + //! somebody pressed the button. That matters more for these than for mail,
26 + //! because they are rare and deliberate and nobody retries them by habit;
27 + //! - a failure is a row with a reason on it, sitting in the section that caused
28 + //! it, rather than a toast that has already gone. For `add_member` that is the
29 + //! difference between "it did not work" and "it did not work, and here is
30 + //! what the server said";
31 + //! - `add_member` needs the master key loaded to seal the group key to the new
32 + //! member. A queue turns "you cannot do this right now" into "this happens
33 + //! once you unlock", which is honest and is not something a button could
34 + //! offer.
35 + //!
36 + //! # Nothing here is described, and that is the design
37 + //!
38 + //! The description says "queue this". What drains the queue is not a screen and
39 + //! has no address. That division is why a queue answers the async problem rather
40 + //! than moving it: the async lives out here, where there has always been a
41 + //! runtime.
42 + //!
43 + //! # What a failed attempt does
44 + //!
45 + //! Stamps the error, counts the attempt, and leaves the row queued, backing off
46 + //! on the count exactly as the outbox does. A row that can never succeed sits
47 + //! with its reason on it rather than being deleted, because an intention to add
48 + //! somebody to a group that silently disappeared is worse than one still visible
49 + //! and failing.
50 +
51 + use std::sync::Arc;
52 +
53 + use tauri::Manager;
54 + use tokio::time::{Duration, interval};
55 + use tokio_util::sync::CancellationToken;
56 + use tracing::{debug, error, info};
57 +
58 + use crate::state::{AppState, DESKTOP_USER_ID};
59 +
60 + /// How often the drainer wakes.
61 + ///
62 + /// Matches [`crate::outbox`], and for the same reason: it is what makes "queue"
63 + /// acceptable as the only way to act. A group is created within a minute of
64 + /// being asked for, which is not immediate and is not a wait anybody watches.
65 + const CHECK_INTERVAL_SECS: u64 = 60;
66 +
67 + /// One queued admin write.
68 + #[derive(Debug, Clone)]
69 + pub struct QueuedOp {
70 + pub id: String,
71 + pub kind: String,
72 + pub group_id: Option<String>,
73 + pub name: Option<String>,
74 + pub email: Option<String>,
75 + pub pubkey: Option<String>,
76 + pub member_user_id: Option<String>,
77 + pub attempts: i32,
78 + pub last_error: Option<String>,
79 + pub done_at: Option<String>,
80 + }
81 +
82 + impl QueuedOp {
83 + /// How this reads in the section that queued it.
84 + ///
85 + /// Written here rather than in the screen because the screen draws a row and
86 + /// this is what the row says: a person who queued three of these wants to
87 + /// know which is which, and the payload is the only thing that tells them
88 + /// apart.
89 + #[must_use]
90 + pub fn describe(&self) -> String {
91 + match self.kind.as_str() {
92 + "create_group" => format!(
93 + "Create the group {}",
94 + self.name.as_deref().unwrap_or("(unnamed)")
95 + ),
96 + "add_member" => format!(
97 + "Add {} to a group",
98 + self.email.as_deref().unwrap_or("(no address)")
99 + ),
100 + "remove_member" => "Remove a member from a group".to_owned(),
101 + // A kind this build does not know, held rather than refused. It can
102 + // only come from a newer build that queued it, and saying so is
103 + // better than drawing a blank row.
104 + other => format!("An action this version does not understand ({other})"),
105 + }
106 + }
107 + }
108 +
109 + /// Queue an admin write. The whole of what a described handler does.
110 + pub fn enqueue(state: &AppState, op: &QueuedOp) -> Result<(), String> {
111 + let conn = state.db.conn().map_err(|e| e.to_string())?;
112 + conn.execute(
113 + "INSERT INTO group_admin_queue \
114 + (id, user_id, kind, group_id, name, email, pubkey, member_user_id) \
115 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
116 + rusqlite::params![
117 + op.id,
118 + DESKTOP_USER_ID.to_string(),
119 + op.kind,
120 + op.group_id,
121 + op.name,
122 + op.email,
123 + op.pubkey,
124 + op.member_user_id,
125 + ],
126 + )
127 + .map_err(|e| e.to_string())?;
128 + Ok(())
129 + }
130 +
131 + /// Everything still waiting, oldest first, plus anything done that the directory
132 + /// has not caught up with yet.
133 + ///
134 + /// Both, because the moment between "the server accepted it" and "a sync brought
135 + /// the group back" is real and a screen that showed neither would look like it
136 + /// lost the request.
137 + pub fn pending(state: &AppState) -> Result<Vec<QueuedOp>, String> {
138 + let conn = state.db.conn().map_err(|e| e.to_string())?;
139 + let mut stmt = conn
140 + .prepare(
141 + "SELECT id, kind, group_id, name, email, pubkey, member_user_id, \
142 + attempts, last_error, done_at \
143 + FROM group_admin_queue WHERE user_id = ?1 ORDER BY queued_at",
144 + )
145 + .map_err(|e| e.to_string())?;
146 + let rows = stmt
147 + .query_map(rusqlite::params![DESKTOP_USER_ID.to_string()], |row| {
148 + Ok(QueuedOp {
149 + id: row.get(0)?,
150 + kind: row.get(1)?,
151 + group_id: row.get(2)?,
152 + name: row.get(3)?,
153 + email: row.get(4)?,
154 + pubkey: row.get(5)?,
155 + member_user_id: row.get(6)?,
156 + attempts: row.get(7)?,
157 + last_error: row.get(8)?,
158 + done_at: row.get(9)?,
159 + })
160 + })
161 + .map_err(|e| e.to_string())?
162 + .collect::<Result<Vec<_>, _>>()
163 + .map_err(|e| e.to_string())?;
164 + Ok(rows)
165 + }
166 +
167 + /// Take a queued write back out.
168 + ///
169 + /// The way out of a row that will never succeed, and the reason a failure is
170 + /// held rather than deleted: the person who queued it decides, not the drainer.
171 + pub fn cancel(state: &AppState, id: &str) -> Result<bool, String> {
172 + let conn = state.db.conn().map_err(|e| e.to_string())?;
173 + let changed = conn
174 + .execute(
175 + "DELETE FROM group_admin_queue WHERE id = ?1 AND user_id = ?2 AND done_at IS NULL",
176 + rusqlite::params![id, DESKTOP_USER_ID.to_string()],
177 + )
178 + .map_err(|e| e.to_string())?;
179 + Ok(changed > 0)
180 + }
181 +
182 + /// Sweep the rows the server has accepted and the directory has caught up with.
183 + ///
184 + /// A done row is kept until the group it made is in the directory, so the
185 + /// section can say "created" for the moment between the two. Once the directory
186 + /// has it, the row has nothing left to say.
187 + fn sweep_settled(state: &AppState) {
188 + let Ok(conn) = state.db.conn() else { return };
189 + // `create_group` is the only kind whose landing is observable in the
190 + // directory. The other two change a member list, which is only fetched for
191 + // groups this user administers and may legitimately not have refreshed yet,
192 + // so they are swept on age instead.
193 + let _ = conn.execute(
194 + "DELETE FROM group_admin_queue \
195 + WHERE done_at IS NOT NULL \
196 + AND (kind = 'create_group' \
197 + AND EXISTS (SELECT 1 FROM sync_groups WHERE name = group_admin_queue.name) \
198 + OR done_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour'))",
199 + [],
200 + );
201 + }
202 +
203 + fn record_failure(state: &AppState, id: &str, message: &str) {
204 + let Ok(conn) = state.db.conn() else { return };
205 + if let Err(error) = conn.execute(
206 + "UPDATE group_admin_queue SET attempts = attempts + 1, last_error = ?2 WHERE id = ?1",
207 + rusqlite::params![id, message],
208 + ) {
209 + error!("Group queue: could not record the failure: {error}");
210 + }
211 + }
212 +
213 + fn record_done(state: &AppState, id: &str) {
214 + let Ok(conn) = state.db.conn() else { return };
215 + if let Err(error) = conn.execute(
216 + "UPDATE group_admin_queue SET done_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), \
217 + last_error = NULL WHERE id = ?1",
218 + rusqlite::params![id],
219 + ) {
220 + error!("Group queue: could not record the success: {error}");
221 + }
222 + }
223 +
224 + /// Start the drainer. Runs until cancelled.
225 + pub async fn start_group_queue_drainer(app: tauri::AppHandle, cancel: CancellationToken) {
226 + let mut check_interval = interval(Duration::from_secs(CHECK_INTERVAL_SECS));
227 + let mut tick: u64 = 0;
228 +
229 + info!("Group queue drainer started (checking every {CHECK_INTERVAL_SECS} seconds)");
230 +
231 + loop {
232 + tokio::select! {
233 + () = cancel.cancelled() => {
234 + info!("Group queue drainer shutting down");
235 + break;
236 + }
237 + _ = check_interval.tick() => {}
238 + }
239 + tick = tick.wrapping_add(1);
240 +
241 + let Some(state) = app.try_state::<Arc<AppState>>() else {
242 + debug!("Group queue drainer: state not ready yet");
243 + continue;
244 + };
245 + let state: Arc<AppState> = state.inner().clone();
246 +
247 + drain_once(&state, tick).await;
248 + }
249 + }
250 +
251 + /// One pass over the queue.
252 + ///
253 + /// Split from the loop so a test can run a pass without a Tauri handle or a
254 + /// minute of waiting, exactly as [`crate::outbox::drain_once`] is.
255 + pub async fn drain_once(state: &Arc<AppState>, tick: u64) {
256 + sweep_settled(state);
257 +
258 + let queued = match pending(state) {
259 + Ok(queued) => queued,
260 + Err(error) => {
261 + error!("Group queue drainer: could not read the queue: {error}");
262 + return;
263 + }
264 + };
265 +
266 + for op in queued {
267 + if op.done_at.is_some() {
268 + continue;
269 + }
270 + // The outbox's curve, shared rather than re-derived: a row that has
271 + // failed once is retried on the next wake, one that has failed six times
272 + // every half hour or so, and the cap stops a long failure from becoming
273 + // a silent drop wearing a backoff's clothes.
274 + if !crate::outbox::due_on_tick(op.attempts, tick) {
275 + continue;
276 + }
277 +
278 + // Not configured is not a failure to count: a device that has never
279 + // signed in will never succeed at any of these, and counting attempts
280 + // against it would back the row off to never while the reason stays the
281 + // same. Left untouched, so it goes as soon as sync is set up.
282 + let Some(client) = state.read_recovering() else {
283 + continue;
284 + };
285 +
286 + // Opened per row rather than held across the loop: keeping a pooled
287 + // connection in hand across an await takes it out of the pool for the
288 + // length of a network call, which is the shape that starves a pool.
289 + let mut conn = state.db.conn().ok();
290 + let outcome = perform(&client, &op, conn.as_deref_mut()).await;
291 + drop(conn);
292 +
293 + match outcome {
294 + Ok(()) => record_done(state, &op.id),
295 + Err(message) => record_failure(state, &op.id, &message),
296 + }
297 + }
298 + }
299 +
300 + /// Perform one queued write against the server.
301 + async fn perform(
302 + client: &synckit_client::SyncKitClient,
303 + op: &QueuedOp,
304 + conn: Option<&mut rusqlite::Connection>,
305 + ) -> Result<(), String> {
306 + let group = |raw: &Option<String>| -> Result<synckit_client::GroupId, String> {
307 + raw.as_deref()
308 + .ok_or_else(|| "No group on the queued action.".to_owned())?
309 + .parse::<uuid::Uuid>()
310 + .map(synckit_client::GroupId::new)
311 + .map_err(|_| "The queued action names a group id that will not parse.".to_owned())
312 + };
313 +
314 + match op.kind.as_str() {
315 + "create_group" => {
316 + let name = op
317 + .name
318 + .as_deref()
319 + .ok_or_else(|| "No name on the queued action.".to_owned())?;
320 + let group = client.create_group(name).await.map_err(|e| e.to_string())?;
321 + // Into the directory here, the same as `commands::group::group_create`
322 + // does on its own path: the group is real on the server, and this
323 + // device would otherwise not know its name until the next cycle, so
324 + // the section that queued it would show nothing landing.
325 + if let Some(conn) = conn {
326 + let known = synckit_client::store::directory::KnownGroup {
327 + id: group.id,
328 + name: group.name,
329 + gck_version: group.gck_version,
330 + is_admin: true,
331 + };
332 + if let Err(error) = synckit_client::store::directory::add_group(conn, &known) {
333 + // Not a failure of the write: the group exists. The next
334 + // cycle writes the whole directory anyway.
335 + error!("Group queue: could not record the new group: {error}");
336 + }
337 + }
338 + Ok(())
339 + }
340 + "add_member" => {
341 + let email = op
342 + .email
343 + .as_deref()
344 + .ok_or_else(|| "No address on the queued action.".to_owned())?;
345 + let pubkey = op
346 + .pubkey
347 + .as_deref()
348 + .ok_or_else(|| "No public key on the queued action.".to_owned())?;
349 + client
350 + .add_member(group(&op.group_id)?, email, pubkey)
351 + .await
352 + .map_err(|e| e.to_string())
353 + }
354 + "remove_member" => {
355 + let member = op
356 + .member_user_id
357 + .as_deref()
358 + .ok_or_else(|| "No member on the queued action.".to_owned())?
359 + .parse::<uuid::Uuid>()
360 + .map(synckit_client::UserId::new)
361 + .map_err(|_| "The queued action names a user id that will not parse.".to_owned())?;
362 + client
363 + .remove_member(group(&op.group_id)?, member)
364 + .await
365 + .map_err(|e| e.to_string())
366 + }
367 + // Held rather than refused, and the message says why so it does not read
368 + // as a bug. Only a newer build could have written it.
369 + other => Err(format!(
370 + "This version does not know how to perform `{other}`. It is kept, not lost."
371 + )),
372 + }
373 + }
374 +
375 + #[cfg(test)]
376 + mod tests {
377 + use super::*;
378 +
379 + async fn state() -> Arc<AppState> {
380 + let (state, _) = crate::test_utils::setup_test_state().await;
381 + state
382 + }
383 +
384 + fn op(kind: &str, id: &str) -> QueuedOp {
385 + QueuedOp {
386 + id: id.to_owned(),
387 + kind: kind.to_owned(),
388 + group_id: Some("00000000-0000-0000-0000-000000000001".to_owned()),
389 + name: Some("The Firm".to_owned()),
390 + email: Some("them@localhost".to_owned()),
391 + pubkey: Some("k".to_owned()),
392 + member_user_id: Some("00000000-0000-0000-0000-000000000002".to_owned()),
393 + attempts: 0,
394 + last_error: None,
395 + done_at: None,
396 + }
397 + }
398 +
399 + /// A person who queued three of these wants to know which is which, and the
400 + /// payload is the only thing that tells them apart.
401 + #[test]
402 + fn each_kind_says_what_it_will_do() {
403 + assert_eq!(
404 + op("create_group", "a").describe(),
405 + "Create the group The Firm"
406 + );
407 + assert_eq!(
408 + op("add_member", "a").describe(),
409 + "Add them@localhost to a group"
410 + );
411 + assert_eq!(
412 + op("remove_member", "a").describe(),
413 + "Remove a member from a group"
414 + );
415 + }
416 +
417 + /// Only a newer build could have written it, so saying so beats a blank row
418 + /// and beats refusing to draw the queue at all.
419 + #[test]
420 + fn a_kind_from_a_newer_build_still_reads_as_something() {
421 + let described = op("invite_member", "a").describe();
422 + assert!(described.contains("does not understand"), "{described}");
423 + assert!(described.contains("invite_member"), "{described}");
424 + }
425 +
426 + #[tokio::test]
427 + async fn a_queued_action_comes_back_out_in_the_order_it_went_in() {
428 + let state = state().await;
429 + enqueue(&state, &op("create_group", "first")).unwrap();
430 + enqueue(&state, &op("add_member", "second")).unwrap();
431 +
432 + let queued = pending(&state).unwrap();
433 + assert_eq!(queued.len(), 2);
434 + assert_eq!(queued[0].id, "first");
435 + assert_eq!(queued[1].id, "second");
436 + }
437 +
438 + /// A device that has never signed in will never succeed at any of these, and
439 + /// counting attempts against it would back the row off to never while the
440 + /// reason stays the same. It has to go as soon as sync is set up.
441 + #[tokio::test]
442 + async fn a_pass_with_no_client_leaves_the_row_untouched() {
443 + let state = state().await;
444 + enqueue(&state, &op("create_group", "waiting")).unwrap();
445 +
446 + drain_once(&state, 1).await;
447 +
448 + let queued = pending(&state).unwrap();
449 + assert_eq!(queued.len(), 1, "still queued");
450 + assert_eq!(queued[0].attempts, 0, "and not counted against");
451 + assert!(queued[0].last_error.is_none());
452 + }
453 +
454 + #[tokio::test]
455 + async fn cancelling_takes_it_out_and_says_whether_it_did() {
456 + let state = state().await;
457 + enqueue(&state, &op("create_group", "mistake")).unwrap();
458 +
459 + assert!(cancel(&state, "mistake").unwrap());
460 + assert!(pending(&state).unwrap().is_empty());
461 + assert!(!cancel(&state, "mistake").unwrap(), "twice is not a lie");
462 + }
463 +
464 + /// A cancel that raced the drainer must not report that it undid anything,
465 + /// because it did not: the server already has it.
466 + #[tokio::test]
467 + async fn a_row_the_server_accepted_cannot_be_cancelled() {
468 + let state = state().await;
469 + enqueue(&state, &op("create_group", "gone")).unwrap();
470 + record_done(&state, "gone");
471 +
472 + assert!(!cancel(&state, "gone").unwrap());
473 + let queued = pending(&state).unwrap();
474 + assert_eq!(queued.len(), 1);
475 + assert!(queued[0].done_at.is_some());
476 + }
477 +
478 + /// The moment between "the server accepted it" and "a sync brought the group
479 + /// back" is real, and a section that showed neither would look like it lost
480 + /// the request.
481 + #[tokio::test]
482 + async fn a_done_row_survives_until_the_directory_catches_up() {
483 + let state = state().await;
484 + let conn = state.db.conn().unwrap();
485 + synckit_client::store::directory::ensure_tables(&conn).unwrap();
486 + drop(conn);
487 +
488 + enqueue(&state, &op("create_group", "landed")).unwrap();
489 + record_done(&state, "landed");
490 +
491 + drain_once(&state, 1).await;
492 + assert_eq!(pending(&state).unwrap().len(), 1, "the directory has not");
493 +
494 + let mut conn = state.db.conn().unwrap();
495 + synckit_client::store::directory::add_group(
496 + &mut conn,
497 + &synckit_client::store::directory::KnownGroup {
498 + id: synckit_client::GroupId::new(uuid::Uuid::from_u128(1)),
499 + name: "The Firm".to_owned(),
500 + gck_version: 1,
Lines truncated