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 serde::{Deserialize, Serialize};
use soda::model::{clean, fit, valid_id};

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, soda::Shape)]
#[serde(tag = "type")]
#[shape(events)] // checked against events.lock as it compiles
pub enum Event {
    CardAdded { card: String, title: String, column: Column },
    CardMoved { card: String, column: Column },
    CardDeleted { card: String },
}

pub enum Command {
    AddCard { card: String, title: String, column: Column },
    MoveCard { card: String, column: Column },
    DeleteCard { card: String },
}

#[derive(Clone, Debug, Default)]
pub struct Board {
    pub cards: BTreeMap<String, Card>,
}

impl soda::Model for Board {
    type Event = Event;
    type Command = Command;
    type Step = Step; // or (), for a model without undo

    fn decide(&self, by: &str, now: u64, command: Command) -> Result<Vec<Event>, String> {
        match command {
            Command::AddCard { card, title, column } => Ok(vec![Event::CardAdded { card, title: clean(&title)?, column }]),
            Command::MoveCard { card, column } if self.cards.contains_key(&card) => Ok(vec![Event::CardMoved { card, column }]),
            Command::DeleteCard { card } if self.cards.contains_key(&card) => Ok(vec![Event::CardDeleted { card }]),
            _ => Err("That card was deleted.".into()),
        }
    }

    fn evolve(&mut self, by: &str, at: u64, event: &Event) {
        match event {
            Event::CardAdded { card, title, column } => {
                if valid_id(card) && let Some(title) = fit(title) {
                    self.cards.insert(card.clone(), Card { title, column: *column, changed_by: by.into() });
                }
            }
            // …
        }
    }

    fn describe(&self, who: &str, event: &Event) -> String {
        match event {
            Event::CardAdded { title, column, .. } => format!("{who} added “{title}” to {}", column.label()),
            // …
        }
    }
}
  • decide reads 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.
  • evolve folds one event into the state. Every event ever kept goes through it again as the app starts, so it can't fail: what decide would never have made (an id that's taken, a title too long) is ignored, not trusted. soda::model has the checks both use (clean and fit for text, valid_id).
  • describe says 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 fn page(cx: &Cx) -> View {
    view! {
        <ul>{cx.app.cards.values().map(|c| view! { <li>{&c.title}</li> })}</ul>
        <History stops=&cx.history() at=cx.looking_back() />
    }
}

#[action]
pub async fn add_card(cx: &mut Act, form: NewCard) -> Result {
    cx.act(Command::AddCard { card: new_id(), title: form.title, column: Column::Todo }).await?;
    Ok(())
}

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.appThe model: as it stands, or as it stood where this page is looking back to
cx.meWho'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.meWho 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:

fn step(&self, by: &str, events: &[Event]) -> Option<Step> {
    let [Event::CardMoved { card, column }] = events else { return None };
    let before = self.cards.get(card)?.column;
    Some(Step { card: card.clone(), before, after: *column })
}

fn travel(&self, by: &str, step: &Step, forward: bool) -> Result<Vec<Event>, Stale> {
    let (from, to) = if forward { (step.before, step.after) } else { (step.after, step.before) };
    match self.cards.get(&step.card) {
        None => Err(Stale::gone()),
        Some(c) if c.column != from => Err(Stale::changed_by(c.changed_by.clone())),
        Some(_) => Ok(vec![Event::CardMoved { card: step.card.clone(), column: to }]),
    }
}

fn what(step: &Step) -> String {
    format!("moving the card to {}", step.after.label())
}

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
seqWhere it is in the log: one order for everything, never reused
idIts own id, nobody else's
stream, versionWhich stream (app, the model's; people, names) and where in it
changeThe change it was made in: the seq of that change's first event
name, dataWhat it's called (CardAdded) and what it holds, as JSON
author, tsWho made it, and when (ms)
shapeThe registry's version for how it's written
metaWhat the change says of itself ({"kind": "undo"})
soda_shapes
category, versionWhich events (app), and which way of writing them
lines, tsHow 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 against events.lock as 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, beside Cargo.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 test adds 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 soda::scenario::Scenario;

#[test]
fn someone_else_s_later_change_is_never_overwritten() {
    Scenario::<Board>::new()
        .when("ann", Command::AddCard { card: "c".into(), title: "Fix login".into(), column: Column::Todo })
        .when("ann", Command::MoveCard { card: "c".into(), column: Column::Done, index: 0 })
        .when("bo", Command::MoveCard { card: "c".into(), column: Column::Doing, index: 0 })
        .undo("ann")
        .then_refused("Can't undo moving “Fix login” to Done: bo has changed it since.");
}
.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:

impl soda_local::LocalFirst for Board {
    const NOUN: &'static str = "board";

    // Two people renaming one card at once is a conflict its person is told of, rather than the
    // later one quietly winning (here, where changes are made apart and meet later).
    fn target(&self, _by: &str, event: &Event) -> Option<String> {
        match event {
            Event::CardRenamed { card, .. } => Some(format!("card:{card}/title")),
            _ => None,
        }
    }
}

soda_local::app!(board::Board);

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.

Esc