watershed Collaborative data structures for Gleam

← watershed · Build guide / Step 05

Add presence

Show who is looking at each note, and clear it when they leave.

Surface presence.config · watershed_lustre.presence · update_presence

Some facts should be allowed to expire

Notes and votes both had to survive — that was the whole argument behind how they’re built. “Ada is reading this card right now” is the opposite kind of fact. Put it in the document, and it becomes permanent right along with everything else: ordered by the server, folded into the board’s saved state, and played back to whoever opens the board next Tuesday — long after it stopped being true.

watershed’s answer is a second system that runs alongside your channels, built for facts that shouldn’t stick around. Ripples are the raw form: a message broadcast to every other client connected to this document right now, with no ordering guarantee and nothing saved anywhere — on purpose. Presence is the roster built on that same ephemeral tier, and it’s the one you want here, because a raw ripple would leave a peer’s last marker stuck on screen even after they close the tab; presence knows how to clear itself when someone leaves.

Declare what a teammate broadcasts

A presence payload is a record you own, with an encoder and a decoder. The tutorial’s is three fields: a colour, a short name, and the note this person is looking at — None when they are looking at the board as a whole.

src/retro_tutorial_lustre.gleam
pub type BoardPresence {
  BoardPresence(color: String, name: String, focused_note: Option(String))
}

fn encode_presence(presence: BoardPresence) -> json.Json {
  json.object([
    #("color", json.string(presence.color)),
    #("name", json.string(presence.name)),
    #("focused_note", case presence.focused_note {
      Some(id) -> json.string(id)
      None -> json.null()
    }),
  ])
}

fn presence_decoder() -> decode.Decoder(BoardPresence) {
  use color <- decode.field("color", decode.string)
  use name <- decode.field("name", decode.string)
  use focused_note <- decode.optional_field(
    "focused_note",
    None,
    decode.optional(decode.string),
  )
  decode.success(BoardPresence(color:, name:, focused_note:))
}

Note the optional_field on the way in. Someone running last week’s version of the app might not send focused_note at all, and the roster should shrug that off instead of dropping them entirely.

One effect handles all of it

watershed_lustre.presence is a single effect that starts everything running, hands back a Handle to keep in your model, and dispatches roster events as they arrive. You don’t write any of the plumbing yourself: no heartbeat (a periodic “I’m still here” ping), no expiry timer, and no cleanup process to remove people who disappeared without saying goodbye.

fn presence_effect(
  model: Model,
  document: Document(document_schema.BoardDocument),
) -> Effect(Msg) {
  watershed_lustre.presence(
    document: document,
    config: presence.config(encode_presence, presence_decoder()),
    initial: current_presence(model),
    started: PresenceStarted,
    on_event: PresenceEvent,
  )
}

fn current_presence(model: Model) -> BoardPresence {
  BoardPresence(
    color: model.color,
    name: presence.short_name(model.user_id),
    focused_note: model.focus,
  )
}

Two implementations, one API. Where the server can track each connection directly, it does: a late joiner gets the whole roster at once, and closing a tab removes that entry immediately. Where it can’t, presence falls back to sending its own heartbeat over ripples and expiring anyone who’s gone quiet for too long. The runtime note covers which one you get and what changes when it’s the fallback.

Publishing a change is republishing the whole payload

Presence is state, not a stream of edits. Focusing a note updates the local model, then re-announces the current payload in full — so there is exactly one function that says what this client looks like to everyone else, and no way for the announced state and the real state to drift.

fn announce_focus(model: Model) -> Effect(Msg) {
  case model.presence {
    Some(handle) ->
      watershed_lustre.update_presence(handle, current_presence(model))
    None -> effect.none()
  }
}
    FocusClicked(id) -> {
      let focus = case model.focus {
        Some(current) if current == id -> None
        Some(_) | None -> Some(id)
      }
      let model = Model(..model, focus: focus)
      #(model, announce_focus(model))
    }

    FocusCleared -> {
      let model = Model(..model, focus: None)
      #(model, announce_focus(model))
    }

Clicking a note toggles that id in model.focus; the clear branch drops it back to None. Both republish through announce_focus, which is why the UI can flip between Focus and Focused without a second presence path.

The roster arrives as events, including the bad ones

State is the full roster; Changed carries what changed plus the full roster anyway, and the tutorial re-renders from the whole roster either way, instead of trying to patch it in place. Redrawing the whole list is cheap, and it can never end up out of sync with reality.

    PresenceEvent(event) ->
      case event {
        presence.State(entries) | presence.Changed(_, entries) -> #(
          Model(..model, peers: remote_peers(model, entries)),
          effect.none(),
        )
        presence.Failed(presence.DecodeFailed(_, _)) -> #(model, effect.none())
        presence.Failed(presence.UnsupportedPresence) -> #(
          Model(
            ..model,
            last_error: Some("presence unavailable on this server"),
          ),
          effect.none(),
        )
        presence.Failed(presence.Rejected(_, message)) -> #(
          Model(..model, last_error: Some("presence rejected: " <> message)),
          effect.none(),
        )
      }

The interesting part is what happens when something goes wrong. If one teammate’s presence data doesn’t decode, that single entry is ignored — a person on a broken build shouldn’t wipe out everyone else’s roster. If the server doesn’t support presence at all, or rejects your attempt to join, you get a message on screen instead of a crash. Either way, the board itself keeps working: notes and votes don’t depend on presence, so losing presence never takes them down.

fn remote_peers(
  model: Model,
  entries: List(presence.PresenceEntry(BoardPresence)),
) -> List(presence.PresenceEntry(BoardPresence)) {
  case model.presence {
    Some(handle) ->
      case presence_js.local_session(handle) {
        Some(session) -> presence.remote_entries(entries, session)
        None -> entries
      }
    None -> entries
  }
}

Your own entry is in that roster too. Filtering it out by session means the chip row can render “you” once, deliberately, instead of showing you twice and calling it a bug report.

Field note The minimal presence idiom ↓

Deepening: one highlight, built from two systems

The highlight you see on a card is where the two systems meet. The note’s id comes from the permanent channel — the document. Who’s looking at it comes from the temporary one — presence. The card combines both, freshly, every time it’s drawn.

fn focus_names(model: Model, id: String) -> List(String) {
  let local = case model.focus {
    Some(current) if current == id -> [
      presence.short_name(model.user_id) <> " (you)",
    ]
    Some(_) | None -> []
  }
  let peers =
    model.peers
    |> list.filter_map(fn(peer) {
      case peer.meta.focused_note {
        Some(current) if current == id -> Ok(peer.meta.name)
        Some(_) | None -> Error(Nil)
      }
    })
  list.append(local, peers)
}

Same idea, now with the actual function: focus_names combines your own local focus with anyone else’s whose focused note matches this card, at render time. If you take one habit from this sheet, take that one — let the temporary system point at permanent state, never copy it.

Fast-changing signals — cursor position, a typing indicator — stay as raw ripples instead. Presence is for who’s here and the slower-changing status attached to that, not for anything that updates many times a second.

Field note Ride an application protocol on ripples ↓

You can now

Click Focus in one tab and watch the card light up in the other, with the roster naming who is on it — then close the tab and watch the marker leave on its own.

How the examples do it

Each note connects an implementation practice to a checked-in example. Open it for the code and reasoning.

Keep latency-critical loops out of the update path

Let a real-time loop read a plain snapshot. Do not make it wait on the application.

The audio engine runs in an FFI module. Every 25 ms, it schedules the steps due in the next 100 ms against the audio clock. Gleam pushes pattern updates into a plain array, so document delays cannot become audio jitter.

Background tabs create another trap: browsers slow timers while the audio clock keeps moving. When the tab returns, the scheduler resets its timing instead of playing every missed step at once.

src/drum_machine_lustre/audio_ffi.mjs
function tick(engine) {
  const ctx = engine.ctx;
  if (ctx === null || !engine.playing) return;

  // Browsers throttle `setInterval` to about once a second in a background
  // tab, while the audio clock keeps running. Without this the next tick would
  // "catch up" by scheduling a second of steps whose times have already
  // passed, and Web Audio plays a past-dated start immediately — so returning
  // to the tab is greeted by a burst of every step it missed. Resync instead,
  // keeping the step index continuous so the pattern resumes in place.
  if (engine.nextStepTime < ctx.currentTime) {
    engine.nextStepTime = ctx.currentTime;
    engine.queue = [];
  }

  const horizon = ctx.currentTime + SCHEDULE_AHEAD_S;
  while (engine.nextStepTime < horizon) {
    scheduleStep(engine, engine.nextStep, engine.nextStepTime);
    engine.queue.push({ step: engine.nextStep, time: engine.nextStepTime });
    // Read the duration per step, so a tempo change mid-bar takes effect on
    // the next step rather than at the end of the loop.
    engine.nextStepTime += stepDuration(engine);
    engine.nextStep = (engine.nextStep + 1) % STEP_COUNT;
  }
}
Drum machine

Demonstrated by Drum machine Source ↗

The minimal presence idiom

Declare one presence effect, use one typed payload, and remove the local session before the roster enters your model.

The tutorial retro board shows the smallest complete presence setup. One effect starts the driver with an encoder and decoder. A helper removes the local session from the roster before the app stores it.

watershed includes the local session on purpose; each app decides whether to show it. The richer cursors and avatar lists in other examples use the same setup with more data.

src/retro_tutorial_lustre.gleam
fn presence_effect(
  model: Model,
  document: Document(document_schema.BoardDocument),
) -> Effect(Msg) {
  watershed_lustre.presence(
    document: document,
    config: presence.config(encode_presence, presence_decoder()),
    initial: current_presence(model),
    started: PresenceStarted,
    on_event: PresenceEvent,
  )
}

fn remote_peers(
  model: Model,
  entries: List(presence.PresenceEntry(BoardPresence)),
) -> List(presence.PresenceEntry(BoardPresence)) {
  case model.presence {
    Some(handle) ->
      case presence_js.local_session(handle) {
        Some(session) -> presence.remote_entries(entries, session)
        None -> entries
      }
    None -> entries
  }
}
Retro board (tutorial)

Demonstrated by Retro board (tutorial) Source ↗

Ride an application protocol on ripples

Send short-lived coordination over ripples, not through a document channel.

The triptych's guided scenarios ask two tabs to choose a driver and exchange invitations, acknowledgements, and status updates. Ripples deliver those messages with a run id, and the document stores none of them.

Plain functions match runs, ignore the sender's own messages, choose acknowledgements, and reject unknown message types. That keeps the protocol testable without a server.

src/grocery_triptych_lustre/scenario_protocol.gleam
pub fn matches_run(expected_run_id: String, inbound: Inbound) -> Bool {
  run_id(inbound.message) == expected_run_id
}

pub fn from_self(self_id: String, inbound: Inbound) -> Bool {
  inbound.from_peer == self_id
}

pub fn should_acknowledge(
  self_id: String,
  ready: Bool,
  busy: Bool,
  already_seen: Bool,
  inbound: Inbound,
) -> Bool {
  case inbound.message {
    Invitation(_) ->
      ready && !busy && !already_seen && !from_self(self_id, inbound)
    Acknowledgement(_) -> False
    Go(_, _) -> False
    Status(_, _, _) -> False
  }
}
Grocery triptych

Demonstrated by Grocery triptych Source ↗