| 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 |
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 |
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]
|