Start here
Events
An app on Soda keeps what happened, not just what is. Its model says what can happen to it (its
events), and its state is what they add up to. On a server, that's the default:
soda_server::app!(board::Board) runs the app on its model's events, kept in its store. What you
get for it:
- History. Every change is kept, said in words, and anyone can ride it back to see the app as it stood at any moment, then come back to now.
- Undo. Each person undoes their own changes, and only while what they touched is as they left it: someone else's work is never overwritten.
- Multiplayer. Every open page, on every server of the app, draws each change the moment it's kept, and says who's here and what they're doing.
- Nothing to migrate. The state is made from the events as the app starts, so changing what it looks like is changing code. What an event holds can only grow, and the store checks that it does, every time the app starts.
soda new makes an app like this: a kanban board (templates/server). This guide is what's in it.
A model#
A model is a type with three things to say: what can happen (Event), what someone can ask for
(Command), and its rules, as pure functions.
use ;
use ;
// checked against events.lock as it compiles
decidereads the state as it stands and turns a command into events, or refuses it in words for whoever asked. It's the only place a change is judged.evolvefolds one event into the state. Every event ever kept goes through it again as the app starts, so it can't fail: whatdecidewould never have made (an id that's taken, a title too long) is ignored, not trusted.soda::modelhas the checks both use (cleanandfitfor text,valid_id).describesays what an event did, past tense, for the history, read against the state just before it. A command that makes several events is one change, said by what they say together.
A command is one change: its events are kept together, are one stop on the history line, and are undone together.
Pages and actions#
A page reads the model as cx.app, and an action asks it for a change:
pub async cx.act runs decide against everything kept so far (by any server of the app), keeps the events,
and every open page is drawn again. A refusal comes back as the Err, which ? shows its person.
| A page reads | |
|---|---|
cx.app | The model: as it stands, or as it stood where this page is looking back to |
cx.me | Who's looking: id (what their changes are kept under), name, hue, and can_edit (not while looking back) |
cx.here() | Who has the app open right now, each with what they're doing (focus: a card they're editing) |
cx.history() | Every change, said in words, for <History> |
cx.looking_back(), cx.looking_at() | How many changes in this page is looking back to, and when that was |
cx.undo(), cx.redo() | What this person's Undo (or Redo) would do ("moving “Fix login” to Done"), for its button |
cx.name_of(id) | Someone's name |
cx.timeline() | Every change, for a page that reads them its own way (which change last touched each card) |
cx.kept_in() | Where the events are kept, for people: "SQLite, data/events.db" |
| An action does | |
|---|---|
cx.act(command).await? | Asks the model for a change |
cx.app() | The model as it stands now |
cx.say_with_undo("Deleted “Fix login”.") | Says what it did, with Undo beside it |
cx.me | Who posted it |
Soda's own requests do the rest, and its components and keys post them: /api/undo and
/api/redo (⌘Z, ⇧⌘Z), /api/scrub (<History>), /api/me ({ name }: what someone calls
themselves) and /api/focus ({ on, doing }: what they're doing, for everyone else's page).
History#
<History stops=&cx.history() at=cx.looking_back() /> draws every change as a stop on a line, in
its author's color. Dragging along it, or its arrow keys, posts where to look to /api/scrub, and
that browser's pages show the model as it stood then, for that person alone; nothing can be changed
there until they come back to now ({ live: true }, or the end of the line). The server keeps the
state every 64 changes, so any moment is a few folds away.
Undo#
Undo is each person's own, and it's a change like any other: kept, and shown in the history ("Ann undid moving “Fix login” to Done"). A model that has it says what each change did, and how to take it back:
step is read against the state just before the change; travel makes the events that take the
state from one side of it to the other, but only while the side it starts from still holds. If
someone has changed it since, theirs stands, the step is dropped, and its person is told why ("Can't
undo moving “Fix login” to Done: Ben has changed it since."). Each person's undo and redo are
worked out from the changes themselves, so they're the same after the server starts again, and on
every server.
People#
Each browser is a person: an id made from its session (the session itself is a secret, never shown), a color from that id, and a name, made for them ("Teal Heron") until they choose one. A name is kept as their own event, in the store, so the history says the name they have now. Who's here, and what they're doing to what, the server keeps while it runs. An app with accounts of its own keeps its own notion of who's who beside this.
The store#
The events are kept by soda-store: SQLite in the app's data folder (data/events.db) by default,
for development, tests and an app on one server; Postgres when SODA_STORE=postgres://…, for a
database several servers share (or a host without a disk). Every server folds every change any of
them keeps, and draws it at once (Postgres tells them: LISTEN soda_events). Two changes decided
at the same moment on two servers can't both go in: the second is refused, to be made again
against the first.
It's two tables, and nothing else is kept:
soda_events | |
|---|---|
seq | Where it is in the log: one order for everything, never reused |
id | Its own id, nobody else's |
stream, version | Which stream (app, the model's; people, names) and where in it |
change | The change it was made in: the seq of that change's first event |
name, data | What it's called (CardAdded) and what it holds, as JSON |
author, ts | Who made it, and when (ms) |
shape | The registry's version for how it's written |
meta | What the change says of itself ({"kind": "undo"}) |
soda_shapes | |
|---|---|
category, version | Which events (app), and which way of writing them |
lines, ts | How they're written (as events.lock says it), and when that was first seen |
Changing what events hold#
An event, once kept, stays, and every later version of the app reads every one. So what events hold
can only grow: a new event, a new variant, a new field that may be missing (Option, or
#[serde(default)]). Nothing kept can be renamed, removed or read differently.
Three checks keep it so:
-
As it compiles. Mark the model's events
#[shape(events)], and they're checked againstevents.lockas they compile: a variant or a field that's gone, a new field that can't be missing, or a plain field written another way, is an error on the line that did it.error: `CardRenamed.title` is in events.lock, and `CardRenamed`s already kept have it: keep the field (give it a default to stop needing it), or, renamed, say what it was called with #[serde(alias = "title")] --> src/board.rs:81:5 -
As it's tested.
events.lock, besideCargo.lock, records how events are written (one line per field or variant, by the names in the stored data, so renaming a Rust type is fine), all the way down.cargo testadds what's new to it (commit it with the change), and fails on anything that would leave a kept event unreadable, saying what to do instead.app!adds the test. -
As it starts. The store keeps the same lines beside the log: the way the app writes its events is checked against what's there. Grown, it's kept as the next version; the same, it carries on; anything else, the app doesn't start, and says why. An older version of the app may still run against a store a newer one has registered with, so long as no event of the newer kind has been kept. Every event appended is checked against it too.
Renaming. A field or a variant can be renamed by saying what it was called, as serde reads it:
#[serde(alias = "title")] name: String. Events kept with the old name read as the new, every check
takes the old name's lines as the new name's, and the lock keeps both (an event kept before still
has the old one). Anything else (a field that holds something else now, units that changed) is a
new field beside the old, read from whichever an event has.
Not deployed yet, so nothing kept matters? soda events reset deletes data/events.db; delete
events.lock too, and start over.
Testing a model#
A model is pure functions, so its tests need nothing running. soda::scenario::Scenario is given,
when, then, folded as a server folds it, history, undo and all:
use Scenario;
.given(by, [events]) | What's happened already, as one change |
.when(by, command) | Someone asks: what decide makes is kept |
.undo(by), .redo(by) | Their latest undone (or redone), as Soda's Undo does it |
.then([events]), .then_nothing(), .then_refused("…") | What the last of those made, or why it was refused |
.then_state(|m| …), .then_history(&["…"]) | What the model is now, and what its history says |
.named(id, "Ann"), .at(ms) | What someone's called in what's said, and the clock |
soda::timeline::Timeline underneath is the whole fold, for a test that needs it: the state after
any change, each person's undo.
Looking at what's kept#
soda events says what the app has kept: where, how many, in which streams, by how many people,
the ways they've been written (the store's registry) beside events.lock, and the latest changes,
in people's names. soda events log is every change (--last 20, --stream app), and soda events export the log as CloudEvents, one a line, for whatever's outside the app (a warehouse, a queue,
another service): type is <app>.<name> (kanban.CardAdded), subject the stream, time when it
was made, data the event, and Soda's own beside them (sodaseq, sodachange, sodaby,
sodashape, sodakind). CloudEvents is for the boundary: the log itself is Soda's own.
Under soda dev, a pill in the corner of every page says how many events there are, and opens a
panel of them as they're kept, newest first, each event as it's written: a change made on the page
shows there at once, and any of them rides the page back to just after it.
Local-first, the same model#
A model is the rules alone, so the local-first engine runs it too: there, each person's device
decides and signs the events, seals them with the space's key, and every member's device folds
every member's, in the same order, so all of them reach the same state. soda new --local is the
same board, its src/board.rs the server's word for word, and one thing more:
app!;A LocalFirst model is the engine's App: who's in a space, its name, its keys and its sharing are
the platform's, and the rest is the model's. Its state is kept beside the space's own (so it's
Serialize, and its fields leave the space's names alone: name, members, creator), and its
events beside the platform's (whose are BoardCreated, BoardRenamed, MemberJoined,
KeyChanged, EpochOpened, HistoryClosed). An app whose rules read the space itself (who's a
member, who owns it, what it offers another) is an App of its own, as Care and IR are.
Without events#
An app that keeps its own state its own way (a database of its own, an API in front of something)
is soda_server::app!(plain book::Book): a page reads cx.state, an action changes it however
the app keeps it. soda new --plain starts one (a checklist, in SQLite), and
tutorial.md builds one.
This page is docs/events.md in the SDK, built into this site as it is.