atagen

macltok

public
Star0
Watch0

Commit d5d8097c3afd

tarball

atagen <boss@atagen.co>2026-09-27 01:42 UTC

9 files changed+113-42
timeline: share snapshots as imbl vectors

A state mirror hands out a timeline with every update, which as a Vec is a deep copy of every item each time. TimelineReset now carries an imbl::Vector, the type matrix-sdk-ui already keeps its own timelines in. try_apply_all checks a whole batch against lengths before touching anything, so a misfit halfway through no longer leaves the snapshot half applied.
Modified file: Cargo.lock+1 additions
@@ -1827,6 +1827,7 @@ dependencies = [
18271827 "getrandom 0.3.4",
18281828 "getrandom 0.4.3",
18291829 "http",
1830+ "imbl",
18301831 "matrix-sdk",
18311832 "matrix-sdk-crypto",
18321833 "matrix-sdk-test",
Modified file: Cargo.toml+2 additions−1 deletions
@@ -6,7 +6,7 @@ description = "A compact application backend on top of the Matrix Rust SDK"
66
77 [features]
88 default = []
9-serde = ["dep:serde"]
9+serde = ["dep:serde", "imbl/serde"]
1010 sqlite = ["matrix-sdk/sqlite", "serde", "dep:url"]
1111 bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"]
1212 indexeddb = ["matrix-sdk/indexeddb", "serde", "dep:url"]
@@ -14,6 +14,7 @@ indexeddb = ["matrix-sdk/indexeddb", "serde", "dep:url"]
1414 [dependencies]
1515 futures-util = { version = "0.3.31", default-features = false, features = ["std"] }
1616 http = "1"
17+imbl = { version = "6.1.0", default-features = false }
1718 matrix-sdk = { version = "0.18.0", default-features = false, features = ["e2e-encryption"] }
1819 matrix-sdk-crypto = { version = "0.18.0", default-features = false }
1920 matrix-sdk-ui = { version = "0.18.0", default-features = false }
Modified file: README.md+4 additions−3 deletions
@@ -29,9 +29,10 @@ drop. a sent message answers when the sdk send queue has it, delivery shows up
2929 as the timeline item's send state. reads like `rooms`, `members` and `media`
3030 go straight to the sdk. the stream only carries state
3131
32-start a timeline from `TimelineReset`, then apply `TimelineChanged` diffs with
33-`TimelineDiff::try_apply`. when one doesn't fit, stop applying, call `resync`,
34-and pick up from the next `TimelineReset`
32+start a timeline from `TimelineReset`, then apply each `TimelineChanged` whole
33+with `TimelineDiff::try_apply_all`. snapshots are `imbl::Vector`s, re-exported
34+as `macltok::imbl`, so a copy costs nothing. when a batch doesn't fit none of it
35+lands. stop applying, call `resync`, and pick up from the next `TimelineReset`
3536
3637 With `sqlite` or `indexeddb`, persist the initial `Session::record()`
3738 separately from its encrypted store. On `SessionTokensChanged`, obtain a new record from
Modified file: docs/design-decisions.md+6 additions−3 deletions
@@ -84,9 +84,12 @@ and gets logged
8484 ## timelines
8585
8686 `TimelineDiff::try_apply` refuses a diff whose indices don't fit your snapshot
87-and leaves the snapshot alone. stop applying, `resync`, and carry on from the
88-next `TimelineReset`. resync kills the old watcher before the new reset goes
89-out, so no stale diff can land after it
87+and leaves the snapshot alone. `try_apply_all` does the same for a whole batch,
88+checking every diff against lengths alone first, since that's all a diff's fit
89+depends on. snapshots are `imbl::Vector`s so keeping one is a pointer copy.
90+stop applying, `resync`, and carry on from the next `TimelineReset`. resync
91+kills the old watcher before the new reset goes out, so no stale diff can land
92+after it
9093
9194 `TimelineId` tells a room from a thread by its root event
9295
Modified file: examples/headless.rs+4 additions−4 deletions
@@ -1,6 +1,6 @@
11 use macltok::{
22 EngineConfig, Event, PasswordLogin, Retain, RoomMembership, Session, StoreConfig,
3- TimelineContent, TimelineId, TimelineItem,
3+ TimelineContent, TimelineDiff, TimelineId, TimelineItem, imbl::Vector,
44 };
55
66 #[tokio::main]
@@ -30,7 +30,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
3030 let (engine, handle, mut events) = session.build(EngineConfig::default()).await?;
3131 let running = tokio::spawn(engine.run());
3232 let mut opened: Option<TimelineId> = None;
33- let mut items = Vec::new();
33+ let mut items = Vector::new();
3434 let mut stale = false;
3535
3636 loop {
@@ -66,7 +66,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6666 Event::TimelineChanged { timeline_id, diffs }
6767 if !stale && opened.as_ref() == Some(&timeline_id) =>
6868 {
69- if diffs.into_iter().any(|diff| diff.try_apply(&mut items).is_err()) {
69+ if TimelineDiff::try_apply_all(diffs, &mut items).is_err() {
7070 stale = true;
7171 let handle = handle.clone();
7272 tokio::spawn(async move { handle.resync(timeline_id).await });
@@ -96,7 +96,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9696 Ok(())
9797 }
9898
99-fn print_messages(items: &[TimelineItem]) {
99+fn print_messages(items: &Vector<TimelineItem>) {
100100 for item in items {
101101 if let TimelineItem::Event(event) = item
102102 && let TimelineContent::Message(message) = &event.content
Modified file: src/lib.rs+1 additions
@@ -25,6 +25,7 @@ pub use account::{
2525
2626 pub use engine::{Engine, EngineConfig, EngineError};
2727 pub use error::{Error, ErrorKind};
28+pub use imbl;
2829 pub use model::{
2930 AccountCommand, AttachmentUpload, Change, Command, CommandOutput, ConnectionState,
3031 CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind, EngineFailure, Event,
Modified file: src/model.rs+93 additions−29 deletions
@@ -1,5 +1,6 @@
11 use std::{fmt, sync::Arc};
22
3+use imbl::Vector;
34 use ruma::{
45 OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedRoomOrAliasId,
56 OwnedServerName, OwnedTransactionId, OwnedUserId,
@@ -648,7 +649,7 @@ pub enum Event {
648649 /// replaces whatever you had for this timeline. every diff after it applies to it
649650 TimelineReset {
650651 timeline_id: TimelineId,
651- items: Vec<TimelineItem>,
652+ items: Vector<TimelineItem>,
652653 },
653654 TimelineChanged {
654655 timeline_id: TimelineId,
@@ -1151,43 +1152,75 @@ impl TimelineDiff {
11511152 /// Apply a diff only when its indices match the current snapshot.
11521153 /// On error the snapshot is unchanged; request `TimelineCommand::Resync`
11531154 /// and ignore further diffs until the corresponding `TimelineReset`.
1154- pub fn try_apply(self, items: &mut Vec<TimelineItem>) -> Result<(), TimelineDiffError> {
1155+ pub fn try_apply(self, items: &mut Vector<TimelineItem>) -> Result<(), TimelineDiffError> {
11551156 let length = items.len();
1157+ if self.length_after(length).is_none() {
1158+ return Err(TimelineDiffError { length });
1159+ }
1160+ self.apply(items);
1161+ Ok(())
1162+ }
1163+
1164+ /// a whole `TimelineChanged` or none of it. whether a diff fits depends only on the
1165+ /// length before it, so the batch is checked against lengths alone before anything
1166+ /// moves. the error carries the length the snapshot would have had at the misfit
1167+ pub fn try_apply_all(
1168+ diffs: Vec<Self>,
1169+ items: &mut Vector<TimelineItem>,
1170+ ) -> Result<(), TimelineDiffError> {
1171+ diffs.iter().try_fold(items.len(), |length, diff| {
1172+ diff.length_after(length)
1173+ .ok_or(TimelineDiffError { length })
1174+ })?;
1175+ for diff in diffs {
1176+ diff.apply(items);
1177+ }
1178+ Ok(())
1179+ }
1180+
1181+ fn length_after(&self, length: usize) -> Option<usize> {
1182+ match self {
1183+ Self::Append { values } => Some(length + values.len()),
1184+ Self::Clear => Some(0),
1185+ Self::PushFront { .. } | Self::PushBack { .. } => Some(length + 1),
1186+ Self::PopFront | Self::PopBack => length.checked_sub(1),
1187+ Self::Insert { index, .. } => (*index <= length).then_some(length + 1),
1188+ Self::Set { index, .. } => (*index < length).then_some(length),
1189+ Self::Remove { index } => (*index < length).then(|| length - 1),
1190+ Self::Truncate { length: target } => (*target <= length).then_some(*target),
1191+ Self::Reset { values } => Some(values.len()),
1192+ }
1193+ }
1194+
1195+ fn apply(self, items: &mut Vector<TimelineItem>) {
11561196 match self {
11571197 Self::Append { values } => items.extend(values),
11581198 Self::Clear => items.clear(),
1159- Self::PushFront { value } => items.insert(0, value),
1160- Self::PushBack { value } => items.push(value),
1161- Self::PopFront if length == 0 => return Err(TimelineDiffError { length }),
1199+ Self::PushFront { value } => items.push_front(value),
1200+ Self::PushBack { value } => items.push_back(value),
11621201 Self::PopFront => {
1163- items.remove(0);
1202+ items.pop_front();
11641203 }
1165- Self::PopBack if length == 0 => return Err(TimelineDiffError { length }),
11661204 Self::PopBack => {
1167- items.pop();
1168- }
1169- Self::Insert { index, .. } if index > length => {
1170- return Err(TimelineDiffError { length });
1205+ items.pop_back();
11711206 }
11721207 Self::Insert { index, value } => items.insert(index, value),
1173- Self::Set { index, .. } if index >= length => return Err(TimelineDiffError { length }),
1174- Self::Set { index, value } => items[index] = value,
1175- Self::Remove { index } if index >= length => return Err(TimelineDiffError { length }),
1208+ Self::Set { index, value } => {
1209+ items.set(index, value);
1210+ }
11761211 Self::Remove { index } => {
11771212 items.remove(index);
11781213 }
1179- Self::Truncate { length: target } if target > length => {
1180- return Err(TimelineDiffError { length });
1181- }
11821214 Self::Truncate { length } => items.truncate(length),
1183- Self::Reset { values } => *items = values,
1215+ Self::Reset { values } => *items = values.into_iter().collect(),
11841216 }
1185- Ok(())
11861217 }
11871218 }
11881219
11891220 #[cfg(test)]
11901221 mod tests {
1222+ use imbl::{Vector, vector};
1223+
11911224 use super::{TimelineDiff, TimelineItem};
11921225
11931226 fn item(timestamp_ms: u64) -> TimelineItem {
@@ -1196,14 +1229,14 @@ mod tests {
11961229
11971230 #[test]
11981231 fn timeline_diff_try_apply_handles_every_variant() {
1199- let mut items = vec![item(1), item(2)];
1232+ let mut items = vector![item(1), item(2)];
12001233
12011234 TimelineDiff::Append {
12021235 values: vec![item(3)],
12031236 }
12041237 .try_apply(&mut items)
12051238 .unwrap();
1206- assert_eq!(items, vec![item(1), item(2), item(3)]);
1239+ assert_eq!(items, vector![item(1), item(2), item(3)]);
12071240
12081241 TimelineDiff::Clear.try_apply(&mut items).unwrap();
12091242 assert!(items.is_empty());
@@ -1214,7 +1247,7 @@ mod tests {
12141247 TimelineDiff::PushBack { value: item(3) }
12151248 .try_apply(&mut items)
12161249 .unwrap();
1217- assert_eq!(items, vec![item(2), item(3)]);
1250+ assert_eq!(items, vector![item(2), item(3)]);
12181251
12191252 TimelineDiff::PopFront.try_apply(&mut items).unwrap();
12201253 TimelineDiff::PopBack.try_apply(&mut items).unwrap();
@@ -1231,7 +1264,7 @@ mod tests {
12311264 }
12321265 .try_apply(&mut items)
12331266 .unwrap();
1234- assert_eq!(items, vec![item(1), item(2), item(3)]);
1267+ assert_eq!(items, vector![item(1), item(2), item(3)]);
12351268
12361269 TimelineDiff::Set {
12371270 index: 1,
@@ -1239,17 +1272,17 @@ mod tests {
12391272 }
12401273 .try_apply(&mut items)
12411274 .unwrap();
1242- assert_eq!(items, vec![item(1), item(4), item(3)]);
1275+ assert_eq!(items, vector![item(1), item(4), item(3)]);
12431276
12441277 TimelineDiff::Remove { index: 1 }
12451278 .try_apply(&mut items)
12461279 .unwrap();
1247- assert_eq!(items, vec![item(1), item(3)]);
1280+ assert_eq!(items, vector![item(1), item(3)]);
12481281
12491282 TimelineDiff::Truncate { length: 1 }
12501283 .try_apply(&mut items)
12511284 .unwrap();
1252- assert_eq!(items, vec![item(1)]);
1285+ assert_eq!(items, vector![item(1)]);
12531286 }
12541287
12551288 #[test]
@@ -1267,16 +1300,47 @@ mod tests {
12671300 TimelineDiff::Truncate { length: 3 },
12681301 ];
12691302 for diff in invalid {
1270- let mut items = vec![item(1), item(2)];
1303+ let mut items = vector![item(1), item(2)];
12711304 assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 2);
1272- assert_eq!(items, vec![item(1), item(2)]);
1305+ assert_eq!(items, vector![item(1), item(2)]);
12731306 }
12741307 for diff in [TimelineDiff::PopFront, TimelineDiff::PopBack] {
1275- let mut items = Vec::new();
1308+ let mut items = Vector::new();
12761309 assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 0);
12771310 assert!(items.is_empty());
12781311 }
12791312 }
1313+
1314+ #[test]
1315+ fn a_batch_that_goes_wrong_halfway_changes_nothing() {
1316+ let mut items = vector![item(1), item(2)];
1317+ let batch = vec![
1318+ TimelineDiff::PushBack { value: item(3) },
1319+ TimelineDiff::Remove { index: 0 },
1320+ TimelineDiff::Set {
1321+ index: 2,
1322+ value: item(4),
1323+ },
1324+ ];
1325+ assert_eq!(
1326+ TimelineDiff::try_apply_all(batch, &mut items)
1327+ .unwrap_err()
1328+ .length,
1329+ 2
1330+ );
1331+ assert_eq!(items, vector![item(1), item(2)]);
1332+
1333+ let batch = vec![
1334+ TimelineDiff::PushBack { value: item(3) },
1335+ TimelineDiff::Remove { index: 0 },
1336+ TimelineDiff::Set {
1337+ index: 1,
1338+ value: item(4),
1339+ },
1340+ ];
1341+ TimelineDiff::try_apply_all(batch, &mut items).unwrap();
1342+ assert_eq!(items, vector![item(2), item(4)]);
1343+ }
12801344 }
12811345
12821346 #[derive(Clone, Debug, Eq, Hash, PartialEq)]
Modified file: src/normalise/timeline.rs+1 additions−1 deletions
@@ -31,7 +31,7 @@ use super::media::{
3131
3232 #[derive(Default)]
3333 pub(crate) struct NormalisedTimeline {
34- pub(crate) items: Vec<TimelineItem>,
34+ pub(crate) items: imbl::Vector<TimelineItem>,
3535 pub(crate) media: Vec<MediaRegistration>,
3636 /// replies whose target isn't loaded, so somebody should go and ask for it
3737 pub(crate) missing_replies: Vec<OwnedEventId>,
Modified file: tests/engine.rs+1 additions−1 deletions
@@ -518,7 +518,7 @@ async fn resync_and_confirm_reset_first(
518518 handle: &ClientHandle,
519519 events: &mut EventStream,
520520 timeline_id: TimelineId,
521-) -> Vec<TimelineItem> {
521+) -> macltok::imbl::Vector<TimelineItem> {
522522 let mut pending = handle
523523 .send(Command::Timeline(TimelineCommand::Resync {
524524 timeline_id: timeline_id.clone(),