Each note gets its own id
The notes channel is an OR-map
running in RegisterMode, keyed by note id,
not by column. Every key holds exactly one note, so adding one never
touches another — even if two people add notes at the same instant.
The whole note is one register
Every key in a RegisterMode map holds exactly one string —
that’s the whole constraint this mode gives you, and the tutorial takes
it literally: a note gets turned into JSON on the way in, and parsed back
on the way out. The text, the column, the author, and a creation
timestamp all travel together as one value.
Which means the column is stored inside the note itself. That’s the tradeoff: moving a note between columns means rewriting the whole note, so if two people move the same note at the same moment, the register keeps the write with the later clock value and the other disappears. The tutorial doesn’t offer moves at all. The advanced retro board keeps the column on the note and adds a sequence per column, then treats the note’s column register as authoritative when it reconciles the two.
Field note When a move is not atomic, crown one channel authoritative ↓
src/retro_tutorial_lustre/note.gleampub type Note {
Note(
text: String,
/// The column id. Unknown values stay visible in `unfiled`.
column: String,
author: String,
/// Client wall-clock ms. Used only as a stable render tiebreaker.
created: Int,
)
}
/// Stable note identity. Edits and votes use this id for the life of the note.
/// The nonce keeps two notes from one author distinct in one ms tick.
pub fn id(author: String, created: Int, nonce: Int) -> String {
"note-"
<> author
<> "-"
<> int.to_string(created)
<> "-"
<> int.to_string(nonce)
} pub fn to_json(note: Note) -> Json {
json.object([
#("text", json.string(note.text)),
#("column", json.string(note.column)),
#("author", json.string(note.author)),
#("created", json.int(note.created)),
])
}
/// Decode a raw register value. A bad payload stays visible and does not crash.
pub fn from_register(value: String) -> Note {
case json.parse(value, decoder()) {
Ok(note) -> note
Error(_) ->
Note(text: "(unreadable note)", column: "", author: "—", created: 0)
}
} Anyone can write anything. The typed Note
record is a place where you decode incoming data, not a schema the
server enforces — an older version of the app, or a stale copy, could
put any string into that field. So from_register doesn’t
have an error case to forget about: a value that fails to decode
becomes a visible placeholder card instead. A card you can see and
recognize as broken beats a crash that takes the whole board down.
Field note Stamp the schema; refuse bad reads ↓
Adding a note is one map write
Mint an id, encode the note, write it to the map. The nonce — a small random number added just to keep ids unique — is there because two notes from one author can land in the same millisecond.
src/retro_tutorial_lustre/board.gleam/// Shared board writes for the tutorial example.
///
/// The UI and the deterministic tests call these same map writes.
pub fn add_note(
notes: OrMap,
author: String,
text: String,
column: Column,
created: Int,
nonce: Int,
) -> String {
let id = note.id(author, created, nonce)
let entry =
Note(
text: text,
column: column_id(column),
author: author,
created: created,
)
watershed.or_map_set_json(notes, id, note.to_json(entry))
id
} board keeps the UI and the tests on the exact same write
function, not two separate copies of the same logic.
If a test reimplemented the write instead of calling it, all that would
prove is that the reimplementation agrees with itself. Step 06 depends on
this.
// These watershed mutations apply synchronously.
// The subscriptions deliver the render message `SharedChanged`.
// These branches do not need Lustre effect wrappers.
AddClicked(column) -> {
let text = string.trim(draft_for(model, column))
case text, model.shared {
"", _ -> #(model, effect.none())
_, None -> #(model, effect.none())
_, Some(shared) -> {
let created = transport_js.now_milliseconds()
let _ =
board.add_note(
shared.notes,
model.user_id,
text,
column,
created,
int.random(10_000),
)
let model =
Model(
..model,
drafts: dict.delete(model.drafts, board.column_id(column)),
)
#(snapshot(model), effect.none())
}
}
} Reading back: entries in, board out
or_map_entries hands you every live key together with its
value. The tutorial creates this map in RegisterMode, so
every value should be a Register. The case
below checks that assumption for each entry and returns an explicit error
if it finds a Tally. That would mean the channel was set up
in the wrong mode: a bug in your own startup code, not messy user data to
defend against.
fn note_entries(notes: OrMap) -> Result(List(#(String, Note)), String) {
watershed.or_map_entries(notes)
|> list.try_map(fn(entry) {
case entry.1 {
or_map_kernel.Register(value) -> Ok(#(entry.0, note.from_register(value)))
or_map_kernel.Tally(_) ->
Error("notes channel has wrong mode; expected RegisterMode")
}
})
}
From there, turning that list into a board is just a plain function: no
side effects, no network calls, data in and data out. Group notes by
their column field, sort by (created, id), and every
replica — every open tab, running the same code — renders the same
order. If two notes ever get the same creation timestamp, the id breaks
the tie, identically, everywhere.
fn notes_in_column(
notes: List(#(String, Note)),
votes_by_id: Dict(String, Int),
column: Column,
) -> List(NoteCard) {
let wanted = column_id(column)
notes
|> list.filter(fn(entry) { entry.1.column == wanted })
|> list.sort(by_created_then_id)
|> list.map(fn(entry) { card(entry.0, entry.1, votes_by_id) })
}
fn by_created_then_id(a: #(String, Note), b: #(String, Note)) -> Order {
case int.compare(a.1.created, b.1.created) {
order.Eq -> string.compare(a.0, b.0)
other -> other
}
} Sort by content, never by arrival. Two tabs receive the same edits in the same server-assigned order, but the order things happened to arrive in isn’t something you should render directly. Deriving the display order from the note’s own fields, instead of from arrival order, is what makes “both clients show the same board” a thing you can actually guarantee.
Deepening: the column you don’t recognise
Three columns are hardcoded, so what happens when a note arrives filed
under parking_lot — a column a newer build added, or a
register that came back as the placeholder? Dropping it would be the worst
option available: the note is real, someone typed it, and it would vanish
with no trace.
fn unfiled(
notes: List(#(String, Note)),
votes_by_id: Dict(String, Int),
) -> List(NoteCard) {
notes
|> list.filter(fn(entry) { result.is_error(from_id(entry.1.column)) })
|> list.sort(by_created_then_id)
|> list.map(fn(entry) { card(entry.0, entry.1, votes_by_id) })
}
Unknown columns land in unfiled and render in their own
section. It costs one small function and it is the difference between a
forward compatible board and a lossy one.
Open the board in two tabs, add a note in one, and watch it appear in
the other, with votes 0 under it. The buttons are already
wired; step 04 explains why their tally updates don’t lose concurrent
votes.
How the examples do it
Each note connects an implementation practice to a checked-in example. Open it for the code and reasoning.
Fallible edits render; never assert on a mutation
Handle every index-based edit as fallible. A peer may change the list between render and click.
A remote insert or delete can make a rendered index stale before the user clicks. The playlist sends every sequence edit through one helper and shows the runtime error in a banner instead of asserting success.
The runtime refuses an index outside the list. It does not clamp the index, because that could move or delete the wrong track.
src/playlist_lustre/component.gleamMoveDownClicked(index) -> #(
mutate(model, "move", fn(sequence) {
watershed.sequence_move(sequence, index, index + 1)
}),
effect.none(),
)
/// Run a sequence edit against the resolved channel, recording any index error.
fn mutate(
model: Model,
verb: String,
edit: fn(SharedSequence) -> Result(Nil, String),
) -> Model {
case model.tracks_channel {
None -> model
Some(sequence) -> record(model, edit(sequence), verb)
}
}
/// Fold an edit result into the model: clear the banner on success, surface the
/// runtime's own message on failure.
fn record(model: Model, result: Result(Nil, String), verb: String) -> Model {
case result {
Ok(Nil) -> Model(..model, last_error: None)
Error(reason) ->
Model(..model, last_error: Some(verb <> " failed: " <> reason))
}
} Demonstrated by Collaborative playlist Source ↗
Stamp the schema; refuse bad reads
Fill and stamp a typed map before attaching it, so an incompatible reader gets an error.
The scoreboard has a root map, a roster, and one typed child map for each player. It fills a new player map in one write, stamps the schema version, then attaches the map to the roster.
That stamp protects future readers from decoding the map with the wrong schema. Child lookup also retries because a remote handle may arrive before the operation that attaches its map.
src/scoreboard_cli.gleam// Our own player map: populated while detached (local-only), then
// attached — snapshot and all — by storing its handle in the roster.
// A single `write` fills every key; `stamp` records the schema version.
use me <- result.try(watershed_beam.create_typed_map(document))
use schema <- result.try(
player_schema() |> result.map_error(fn(_) { "Schema build failed" }),
)
watershed_beam.write(
me,
schema,
PlayerState(name: player_id, last_roll: None, total: 0, rolls: 0),
)
watershed_beam.stamp(me, schema)
watershed_beam.set_child(roster, player_slot(player_id), me)
let roll_due = process.new_subject()
let selector =
process.new_selector()
|> process.select_map(watershed_beam.subscribe_typed(roster), RosterChanged)
|> process.select_map(watershed_beam.subscribe_typed(me), ScoreChanged)
|> process.select_map(roll_due, fn(_) { RollDue }) Demonstrated by Scoreboard CLI Source ↗
Anchors, not offsets
Store an anchor instead of a text offset, then resolve its current position after each edit.
The editor turns each input into a small insert, delete, or replace. Remote edits would make saved integer positions stale, so bookmarks, carets, and shared cursors all use anchors that resolve to the current grapheme index.
Anchor bias decides which nearby text a caret or selection follows as edits arrive. If an anchor no longer resolves, the app removes the marker instead of guessing.
src/text_lustre/component.gleam/// Resolve the pinned anchor to its current grapheme position, or drop it to
/// `None` if it has gone stale/unknown.
fn refresh_anchor(model: Model) -> Model {
case model.editor, model.anchor {
Some(editor), Some(anchor) ->
case watershed.text_resolve_anchor(textarea.channel(editor), anchor) {
Ok(position) -> Model(..model, anchor_pos: Some(position))
Error(_) -> Model(..model, anchor_pos: None)
}
_, _ -> Model(..model, anchor_pos: None)
}
} Demonstrated by Shared text editor Source ↗