Skip to main content

max / synckit

Give each directory list its own staleness reader The directory has three tables refreshing on three schedules and one reader, MIN(refreshed_at) over sync_groups. sync_group_members and sync_invitations each carry a refreshed_at nothing read, so a screen drawing either had one number available and it was about a different list. That matters on the cycle where it matters. write_members and write_invitations are scoped per group so one group's failed fetch does not blank the others, which is exactly when the group list is current and an invitation list is not. Adds members_refreshed_at, invitations_refreshed_at and pending_confirmations_refreshed_at, each the freshness of exactly what its sibling read returns. Per-group where the failure is per-group; across groups, reporting the stalest, for the confirmations list, which is drawn across groups.
Author: Max Johnson <me@maxj.phd> · 2026-08-24 22:32 UTC
Signed with PGP, not checked
Commit: 63e9b9e663a72107155ab168e88113478036dbf6
Parent: 5b5892f
2 files changed, +192 insertions, -10 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "synckit-client"
3 - version = "0.9.0"
3 + version = "0.9.1"
4 4 edition = "2024"
5 5 license = "MIT"
6 6 description = "SyncKit client SDK with end-to-end encryption"
@@ -58,7 +58,16 @@
58 58 //!
59 59 //! # Staleness is a fact, not a failure
60 60 //!
61 - //! [`refreshed_at`] is what a screen states when it says how current the list is.
61 + //! A screen that draws a list states how current that list is, and the three
62 + //! tables here refresh on three schedules: the group list arrives every cycle,
63 + //! while members and invitations are separate per-group fetches made only for
64 + //! the groups this user administers. So there is a reader per list rather than
65 + //! one for the directory -- [`refreshed_at`], [`members_refreshed_at`],
66 + //! [`invitations_refreshed_at`], [`pending_confirmations_refreshed_at`] -- and
67 + //! each is the freshness of exactly what its sibling read returns. One number
68 + //! standing for all three would be a claim about a different list on the cycle
69 + //! where it matters, which is the cycle where a fetch failed.
70 + //!
62 71 //! A directory that has never been refreshed is empty, which is a true statement
63 72 //! about what this device knows rather than a claim that the user has no groups.
64 73
@@ -661,21 +670,84 @@
661 670 Ok(rows)
662 671 }
663 672
664 - /// When the directory was last written, RFC 3339, or `None` if it never has
673 + /// When the group list was last written, RFC 3339, or `None` if it never has
665 674 /// been.
666 675 ///
667 - /// What a screen states when it says how current its list is. The oldest row
668 - /// wins: a directory is only as fresh as its stalest entry, and reporting the
669 - /// newest would call a list current on the strength of the one group that
670 - /// refreshed.
676 + /// What a screen drawing [`groups`] states when it says how current its list is.
677 + /// The oldest row wins: a directory is only as fresh as its stalest entry, and
678 + /// reporting the newest would call a list current on the strength of the one
679 + /// group that refreshed.
680 + ///
681 + /// This is about the group list and nothing else. The member list and the
682 + /// invitation list are separate fetches on separate schedules and come apart
683 + /// from it on a bad cycle; [`members_refreshed_at`],
684 + /// [`invitations_refreshed_at`] and [`pending_confirmations_refreshed_at`] are
685 + /// what those lists state.
671 686 pub fn refreshed_at(conn: &Connection) -> Result<Option<String>> {
687 + stalest(conn, "SELECT MIN(refreshed_at) FROM sync_groups", params![])
688 + }
689 +
690 + /// When a group's member list was last written, RFC 3339.
691 + ///
692 + /// The freshness of exactly what [`members`] returns for the same group, and it
693 + /// is per-group because the failure being reported is per-group: `write_members`
694 + /// is scoped to one group so a fetch that failed for one does not blank the
695 + /// others, and that is the case where this and [`refreshed_at`] disagree.
696 + ///
697 + /// `None` for a list with no rows, which covers both "never fetched" and
698 + /// "fetched, and the group has no members this device may see". Neither has a
699 + /// date to state, so a screen states nothing rather than a number about a
700 + /// different list.
701 + pub fn members_refreshed_at(conn: &Connection, group_id: GroupId) -> Result<Option<String>> {
702 + stalest(
703 + conn,
704 + "SELECT MIN(refreshed_at) FROM sync_group_members WHERE group_id = ?1",
705 + params![group_id.to_string()],
706 + )
707 + }
708 +
709 + /// When a group's invitation list was last written, RFC 3339.
710 + ///
711 + /// The freshness of exactly what [`invitations`] returns for the same group, on
712 + /// the same per-group terms as [`members_refreshed_at`] and for the same reason.
713 + pub fn invitations_refreshed_at(conn: &Connection, group_id: GroupId) -> Result<Option<String>> {
714 + stalest(
715 + conn,
716 + "SELECT MIN(refreshed_at) FROM sync_invitations WHERE group_id = ?1",
717 + params![group_id.to_string()],
718 + )
719 + }
720 +
721 + /// When the invitations waiting on this admin were last written, RFC 3339.
722 + ///
723 + /// The freshness of exactly what [`pending_confirmations`] returns, so it spans
724 + /// groups the way that read does and reports the stalest of them. That is the
725 + /// honest number for a cross-group list: one group's invitation fetch can fail
726 + /// while the rest succeed, and the section is only as current as the group that
727 + /// missed.
728 + ///
729 + /// Worth its own reader rather than leaving the caller to walk groups, for the
730 + /// reason [`pending_confirmations`] is: comparing a fingerprint and admitting
731 + /// somebody is the security of the invite flow, and a section doing that should
732 + /// be able to say how old its list is without assembling the answer itself.
733 + pub fn pending_confirmations_refreshed_at(conn: &Connection) -> Result<Option<String>> {
734 + stalest(
735 + conn,
736 + "SELECT MIN(refreshed_at) FROM sync_invitations WHERE state = 'accepted'",
737 + params![],
738 + )
739 + }
740 +
741 + /// Shared body for the staleness reads: the stalest row a query selects.
742 + ///
743 + /// `MIN` in every case, and the aggregate is why the `Option` is doubled: a
744 + /// `MIN` over no rows is one row holding `NULL`, not no rows at all.
745 + fn stalest<P: rusqlite::Params>(conn: &Connection, sql: &str, args: P) -> Result<Option<String>> {
672 746 if !present(conn)? {
673 747 return Ok(None);
674 748 }
675 749 Ok(conn
676 - .query_row("SELECT MIN(refreshed_at) FROM sync_groups", [], |row| {
677 - row.get::<_, Option<String>>(0)
678 - })
750 + .query_row(sql, args, |row| row.get::<_, Option<String>>(0))
679 751 .optional()?
680 752 .flatten())
681 753 }
@@ -955,6 +1027,116 @@
955 1027 assert!(refreshed_at(&conn).unwrap().is_some());
956 1028 }
957 1029
1030 + /// The reason the per-list readers exist. `write_invitations` is scoped to
1031 + /// one group so a failed fetch there does not blank the others, and this is
1032 + /// what that looks like from a screen: the group list refreshed on the cycle
1033 + /// the invitation list missed, and the two numbers disagree.
1034 + #[test]
1035 + fn the_invitation_list_can_be_staler_than_the_group_list() {
1036 + let mut conn = db();
1037 + let one = GroupId::new(uuid::Uuid::from_u128(1));
1038 + write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
1039 + write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap();
1040 +
1041 + // The cycle where the invitation fetch failed: the group list is written
1042 + // again, the invitation list is left standing.
1043 + conn.execute(
1044 + "UPDATE sync_invitations SET refreshed_at = '2026-01-01T00:00:00.000Z'",
1045 + [],
1046 + )
1047 + .unwrap();
1048 + write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
1049 +
1050 + let groups_at = refreshed_at(&conn).unwrap().expect("a group timestamp");
1051 + let invites_at = invitations_refreshed_at(&conn, one)
1052 + .unwrap()
1053 + .expect("an invitation timestamp");
1054 + assert_eq!(invites_at, "2026-01-01T00:00:00.000Z");
1055 + assert!(
1056 + invites_at < groups_at,
1057 + "the invitation list is the stale one: {invites_at} vs {groups_at}"
1058 + );
1059 + }
1060 +
1061 + /// Per-group, because the failure is per-group: one group refreshing tells a
1062 + /// screen drawing another group's list nothing.
1063 + #[test]
1064 + fn a_groups_member_and_invitation_timestamps_are_its_own() {
1065 + let mut conn = db();
1066 + let one = GroupId::new(uuid::Uuid::from_u128(1));
1067 + let two = GroupId::new(uuid::Uuid::from_u128(2));
1068 + write_groups(&mut conn, &[group(1, "One", true), group(2, "Two", true)]).unwrap();
1069 + write_members(&mut conn, one, &[member("a@example.com", "2026-01-01")]).unwrap();
1070 + write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap();
1071 +
1072 + assert!(members_refreshed_at(&conn, one).unwrap().is_some());
1073 + assert!(invitations_refreshed_at(&conn, one).unwrap().is_some());
1074 + assert_eq!(members_refreshed_at(&conn, two).unwrap(), None);
1075 + assert_eq!(invitations_refreshed_at(&conn, two).unwrap(), None);
1076 + }
1077 +
1078 + /// The confirmations reader spans groups exactly as `pending_confirmations`
1079 + /// does, and reports the stalest of them: a section is only as current as the
1080 + /// group whose fetch missed.
1081 + #[test]
1082 + fn the_confirmations_timestamp_is_the_stalest_group_it_draws_from() {
1083 + let mut conn = db();
1084 + let one = GroupId::new(uuid::Uuid::from_u128(1));
1085 + let two = GroupId::new(uuid::Uuid::from_u128(2));
1086 + write_groups(&mut conn, &[group(1, "One", true), group(2, "Two", true)]).unwrap();
1087 + write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Accepted)]).unwrap();
1088 + conn.execute(
1089 + "UPDATE sync_invitations SET refreshed_at = '2026-01-01T00:00:00.000Z'",
1090 + [],
1091 + )
1092 + .unwrap();
1093 + write_invitations(&mut conn, two, &[invite(22, 2, InvitationState::Accepted)]).unwrap();
1094 +
1095 + assert_eq!(pending_confirmations(&conn).unwrap().len(), 2);
1096 + assert_eq!(
1097 + pending_confirmations_refreshed_at(&conn).unwrap(),
1098 + Some("2026-01-01T00:00:00.000Z".to_owned()),
1099 + );
1100 + }
1101 +
1102 + /// Only the invitations the section draws. A pending invitation is not
1103 + /// waiting on the admin, so its age is not the confirmations list's age.
1104 + #[test]
1105 + fn the_confirmations_timestamp_ignores_invitations_the_section_does_not_draw() {
1106 + let mut conn = db();
1107 + let one = GroupId::new(uuid::Uuid::from_u128(1));
1108 + write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
1109 + write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap();
1110 +
1111 + assert!(invitations_refreshed_at(&conn, one).unwrap().is_some());
1112 + assert_eq!(pending_confirmations_refreshed_at(&conn).unwrap(), None);
1113 + }
1114 +
1115 + /// Both halves of an empty answer: never fetched, and fetched with nothing
1116 + /// in it. Neither has a date to state, and a screen states nothing rather
1117 + /// than a number about a different list.
1118 + #[test]
1119 + fn an_empty_list_has_no_timestamp_to_state() {
1120 + let mut conn = db();
1121 + let one = GroupId::new(uuid::Uuid::from_u128(1));
1122 + write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
1123 + assert_eq!(members_refreshed_at(&conn, one).unwrap(), None);
1124 +
1125 + write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap();
1126 + write_invitations(&mut conn, one, &[]).unwrap();
1127 + assert_eq!(invitations_refreshed_at(&conn, one).unwrap(), None);
1128 + }
1129 +
1130 + #[test]
1131 + fn a_store_with_no_directory_has_no_timestamp_for_any_list() {
1132 + let conn = Connection::open_in_memory().expect("a database");
1133 + let one = GroupId::new(uuid::Uuid::from_u128(1));
1134 + assert_eq!(refreshed_at(&conn).unwrap(), None);
1135 + assert_eq!(members_refreshed_at(&conn, one).unwrap(), None);
1136 + assert_eq!(invitations_refreshed_at(&conn, one).unwrap(), None);
1137 + assert_eq!(pending_confirmations_refreshed_at(&conn).unwrap(), None);
1138 + }
1139 +
958 1140 /// Local-only DDL, so the gate must not see it. A table absent from every
959 1141 /// `SyncSchema` crosses no wire, and the storage version describes the wire.
960 1142 #[test]