atagen

macltok

public
Star0
Watch0
1fe480c16a3c6db3270dce9e11f0afe67ebc17fc
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
docs/design-decisions.mdMarkdown192 lines8.9 KB

macltok design decisions

the contract the working tree actually implements. if the code disagrees with this, one of them is lying and it should be fixed

two ways in, one way out

state changes are commands. ClientHandle::send queues one and hands back a Pending, execute queues and waits, and there’s an awaited convenience method for every command. each command answers exactly once with Result<CommandOutput, Error>. drop the Pending if you don’t care, the command still runs

pure reads skip the engine and ask the sdk directly through the handle. rooms, members, power levels, account data, devices, push notification resolution, and media bytes all live here

the event stream carries state and only state. timelines, history, typing, rooms, connection, encryption, verification, upload progress, session. no command results ride on it. drain it from its own task, it’s bounded and the engine waits for you

one error type for everything, Error { kind, message, challenge }. match on kind. Interrupted means the engine stopped, the room closed, or the task doing the work died before it could answer. challenge turns up with InteractiveAuthRequired

engine

the run loop routes and does nothing slow. every task it spawns is registered with a context. a command task that panics costs its caller an Interrupted and gets logged. a watcher or a room actor that panics takes the engine down, emitting Failed then Stopped

sync restarts itself with backoff after an error or an unexpected stop, and ConnectionChanged shows Offline meanwhile. a run that drops again inside thirty seconds counts as a failure too, or a server that answers /versions but refuses sync would bounce us between online and offline forever. every return to Online re-arms the send queues, since the sdk parks a room’s queue on any send failure and never unparks it. a recoverable failure while online gets the room another go after a few seconds

logout and a rejected token both stop the engine. Shutdown answers once Stopped has gone out, every one of them if several were queued

Engine::run hands back a boxed future. the real one nests deep enough that every crate spawning it would trip the compiler’s query depth

rooms

one actor per open room. the engine holds a slot per room, Active with a sender or Closing with a fifo of opens and closes that arrived while it wound down. those replay in order once the actor exits. a room you leave, or get kicked or banned from, closes itself. it tells the engine first, and anything already queued to it comes back to be answered or replayed, so an open racing a leave still gets a fresh actor

opening sends TimelineReset on the stream before it answers. opening an already open room sends a fresh reset, so a second frontend gets a snapshot

sends, edits and replies run on the actor in order, so they reach the send queue in the order they were asked for. redactions, reactions and read receipts are network calls, so they run beside the actor and a slow one doesn’t queue the room behind it. closing lets them finish rather than lie

threads belong to their room’s actor. commands for a thread that’s still opening wait behind the open, up to the actor’s queue capacity

pagination runs one request at a time per timeline and emits HistoryChanged around each. the sdk remembers where history starts, we don’t, because a gappy sync can unload it again

the actor’s watchers are supervised, and a panic in any of them panics the actor. its command work isn’t, a panicking redaction just answers Interrupted and gets logged

timelines

TimelineDiff::try_apply refuses a diff whose indices don’t fit your snapshot and leaves the snapshot alone. stop applying, resync, and carry on from the next TimelineReset. resync kills the old watcher before the new reset goes out, so no stale diff can land after it

TimelineId tells a room from a thread by its root event

every item is an event, a day divider, the read marker or the start. an event carries its sender with avatar and name ambiguity, and a TimelineContent saying what it actually is. messages keep their msgtype, membership and profile changes and the common room state changes get their own variants, and anything else is OtherState or Other with its type. nothing is flattened into english in a body field

thread receipts for a thread that isn’t open go straight to the room, so marking a thread read works the same whether its room is on screen or not

replies carry the replied-to event once it’s loaded. the watcher asks the sdk for any it hasn’t got, once per event, since the sdk won’t on its own

edit, redact and react take an EventRef, so your own message can be edited or reacted to before the server has echoed it back. redacting an unsent one just cancels it

mutations

membership, moderation, power levels, room creation and account data writes go through one keyed fifo scheduler. same room, same alias, same dm partner or same account data type run in order, everything else runs at once. create_direct hands back the existing dm when there is one

notifications and encryption each get one serial worker. the encryption worker owns the verification controller outright

power level changes build one state event from the local mapping, since the sdk’s own patcher is private

media

media is a read. ClientHandle::media takes a MediaId a retained timeline handed you, mxc_media takes any plain mxc uri. both share one bounded download path with the byte cap the sdk’s own download lacks, an hour’s total deadline instead of the sdk client’s thirty seconds, and a minute’s patience for a stalled stream. concurrent asks for the same bytes share one flight. a thumbnail the server won’t make falls back to the file itself. MediaId parses from a string, since a web client has to round trip it

the cache holds plaintext, keyed like the sdk keys it, so the sdk and macltok can read each other’s entries. that’s how your own encrypted uploads come back. the sdk keys plain and encrypted sources alike, so a plain event pointing at someone’s encrypted file can leave ciphertext in the cache. an encrypted read checks the cached bytes against the file’s hash and decrypts them if they match

media ids are registered as timelines render and forgotten when their timeline closes, least recently seen first once the cap is hit

uploads are commands that answer once the send queue has them. progress only goes out on a room’s own send queue channel, so each room that uploads gets a forwarder, subscribed before the upload is queued and restarted for uploads the sdk resumes from last time. failures do reach the client-wide channel, so one watcher there reports them

sessions and secrets

Session::login_password, begin_oauth and restore share one client builder with encrypted sqlite, refresh tokens and threads. secret-bearing inputs don’t derive Clone

the caller owns the saved record. save Session::record() after login and replace it atomically with ClientHandle::session_record() on every SessionTokensChanged. logout and a hard rejection kill the record and the store. a soft logout keeps the store for a same-device login, or the e2ee keys go with it. a bare Stopped kills nothing. one store directory per login

room lists, spaces and profiles

RoomSummary has what a room list row needs without a second query. topic, alias, member counts, heroes for nameless rooms, the inviter, where an upgraded room went, a latest event preview, the joined spaces listing it, and for a space the children it lists

parents come from one lazily built SpaceService shared by every handle. children come straight from the space’s local state, joined or not. space_hierarchy pages through the server’s view for browsing rooms you haven’t joined, and holds its place between pages

own_profile and user_profile read profiles. name and avatar changes are commands, and uploading an avatar gets the same byte cap as any upload

notifications and room data

the notification client is built lazily and shares the engine’s sync service. sdk ui types stay behind the boundary

rooms normalises on demand with bounded concurrency and sorts by latest event, then name, then id. RoomsChanged is an invalidation, coalesced so one sync touching fifty rooms is one event

verification

Accept, Confirm, Reject and Cancel. a request another device sends shows up as Incoming and waits for Accept, or Cancel to turn it down. one is only taken up while nothing else is in flight. reject tells the other side the emoji didn’t match. the watchers subscribe before they look at the current state, since the sdk only reports what changes after you ask. the watcher and the action paths claim the terminal event, so a racing cancel can’t turn Done into Cancelled

public model

owned ruma ids and typed data wherever there’s a stable shape, Raw where arbitrary content has to survive. anything expected to grow is #[non_exhaustive]. Engine::from_client stays public for people who built their own client