TallyMode: a counter per key
You already created the votes channel back in step 01, when ensure_or_map set it up with TallyMode. That
mode turns every key in the map into what’s sometimes called a PN
counter, short for “positive-negative counter”: a value that only
accepts changes — plus one, minus one — and combines them by adding them
up, never by picking one write as the winner.
The vote API is three functions, one line of work each. An upvote sends
+1; a downvote sends -1.
pub fn upvote(votes: OrMap, id: String) -> Nil {
change_votes(votes, id, 1)
}
pub fn downvote(votes: OrMap, id: String) -> Nil {
change_votes(votes, id, -1)
}
fn change_votes(votes: OrMap, id: String, amount: Int) -> Nil {
watershed.or_map_increment(votes, id, amount)
} UpvoteClicked(id) ->
case model.shared {
Some(shared) -> {
board.upvote(shared.votes, id)
#(snapshot(model), effect.none())
}
None -> #(model, effect.none())
}
DownvoteClicked(id) ->
case model.shared {
Some(shared) -> {
board.downvote(shared.votes, id)
#(snapshot(model), effect.none())
}
None -> #(model, effect.none())
} Both branches re-read the board immediately after writing. The change applies to your own copy the moment you click, so the number moves right away under your finger — and then stays put once the server confirms it, because adding the same changes in a different order produces the same total.
Why not read, add one, write back?
A simpler-looking approach stores the total as a plain number and updates it like this:
// The version that loses a vote: a plain number in a SharedMap.
// Two clients read 4, both write 5, and the second write is not wrong —
// it is just the last one.
let current = read_total(board, id)
watershed.set(board, id, json.int(current + 1))
It reads, adds one, writes the result back. Read is the
problem: by the time your write reaches the server, the number it was
based on is already out of date, because someone else’s write got there
first. The newer write silently overwrites the older one’s work. Two
votes go in, one total comes out, and no error appears anywhere — the
lost vote just vanishes without a trace. The +1 tally above
avoids this because it sends the change, not the number you think the
total should become — however the changes arrive, they add up to the
same total.
The counter bug page runs the losing version and a commuting fix against the same two clicks, then shows the same idea with signed deltas.
Two channels, one board
Notes and votes are separate channels, keyed the same way, by note id.
Reading the votes map mirrors reading the notes map: every value comes
back as a Tally, and anything else would mean the channel
was set up in the wrong mode.
fn vote_entries(votes: OrMap) -> Result(List(#(String, Int)), String) {
watershed.or_map_entries(votes)
|> list.try_map(fn(entry) {
case entry.1 {
or_map_kernel.Tally(count) -> Ok(#(entry.0, count))
or_map_kernel.Register(_) ->
Error("votes channel has wrong mode; expected TallyMode")
}
})
}
The board joins them by note id when it builds each card. A note with no
tally shows 0 rather than nothing, so a fresh note and a note
voted back down to zero look the same — which is what a reader expects.
fn card(id: String, note: Note, votes_by_id: Dict(String, Int)) -> NoteCard {
NoteCard(
id: id,
note: note,
votes: dict.get(votes_by_id, id) |> result.unwrap(0),
)
} Two channels, one order. Both maps belong to the same document, so every tab applies their operations in the order the sequencer picked. A vote cannot overtake the note it belongs to. The read still cannot assume that every tally has a note: a note that never arrived, or one removed later, leaves a tally with nothing to attach to. The read is note-driven, so each card looks up its tally and a tally without a note is never visited.
pub fn tallies_attach_to_matching_notes_and_orphans_are_ignored_test() -> Nil {
let snapshot =
board.snapshot(
"Sprint retro",
[#("note-1", note_in("action_items", 1, "ship docs"))],
[#("gone", 7), #("note-1", 2)],
)
snapshot.action_items
|> should.equal([
NoteCard(
id: "note-1",
note: note_in("action_items", 1, "ship docs"),
votes: 2,
),
])
total_occurrences(snapshot, "gone") |> should.equal(0)
} Deepening: what a tally deliberately can’t do
A tally settles on the same total everywhere, no matter what order the
votes arrive in — and it manages that by refusing to answer questions
about who. It has no per-voter record, so it can’t enforce one
vote per person, can’t give you a vote budget, and can’t tell you whether
a total of 2 means two people voted or one person clicked
twice.
Those are real features, and they need a different structure: an OR-set of voter ids per note, or the coordination family when the question is “who holds this” rather than “how many”. Add them only when the question actually demands it — a tally that fits is worth more than a fancier structure that doesn’t.
Field note Show writes that have not settled ↓
Open two tabs, hammer +1 and -1 on the same note
from both, and get an honest total every time — every click counted,
whatever order the sequencer assigns.
How the examples do it
Each note connects an implementation practice to a checked-in example. Open it for the code and reasoning.
Propose on release, render the pending signoff
Send one consensus proposal per gesture, then show whose approval is still missing.
A PactMap stores the tempo and accepts a change after every connected client approves it. The slider sends its proposal on release. Sending one on every pointer move would overwhelm a protocol that allows only one pending proposal.
While the group decides, the UI disables the slider and names the clients that have not approved. A short poll catches changes that the kernel does not report as events.
src/drum_machine_lustre.gleam// Propose on release, never per pointer move. A `pact_map_set` per frame
// would flood the protocol with proposals that invalidate each other —
// `apply_set` rejects a proposal made while one is pending — so a dragged
// slider would land on whichever frame happened to arrive between pacts.
BpmCommitted ->
case model.shared, tempo_locked(model), model.bpm_draft == model.bpm {
Some(shared), False, False -> {
watershed.pact_map_set(
shared.settings,
bpm_key,
json.int(model.bpm_draft),
)
// Poll rather than wait for an event, because a proposal the kernel
// *rejects* — one made while a peer's is already pending — emits
// nothing at all. Without this tick the control would stay disabled
// forever on a rejection.
#(
Model(..model, proposing: True),
watershed_lustre.after(signoff_poll_milliseconds, PollSignoffs),
)
}
_, _, _ -> #(model, effect.none())
} Demonstrated by Drum machine Source ↗
Show writes that have not settled
When writes are not optimistic, show them as pending until the confirming event arrives.
A RegisterCollection stores each match result under the Atomic policy, which chooses one compare-and-swap winner. Local writes stay hidden until the server orders them, so the bracket shows a submitted result as awaiting confirmation.
VersionChanged reports every ordered submission, including the ones that lose. AtomicChanged reports only the winner and updates the official bracket. The log keeps the competing reports visible.
src/tournament_bracket_lustre.gleamReportClicked(match_key, winner) ->
case model.matches {
None -> #(model, effect.none())
Some(matches) -> {
let score =
dict.get(model.score_drafts, match_key)
|> option.from_result
|> option.unwrap("")
let value = bracket.to_json(MatchResult(winner:, score:))
watershed.register_write(matches, match_key, value)
#(
Model(..model, pending: set.insert(model.pending, match_key)),
effect.none(),
)
}
}
// ... and in the register event handler:
AtomicChanged(key, value, _local) -> {
let result = bracket.from_json(value)
Model(
..model,
results: dict.insert(model.results, key, result),
pending: set.delete(model.pending, key),
)
|> log_line(key <> " official result: " <> result.winner)
} Demonstrated by Tournament bracket Source ↗