watershed Collaborative data structures for Gleam

Registers / Revision slate

Converged.
Still two answers.

A last-write-wins cell would discard one revision. This one keeps the disagreement. Race "raise crest" against "arm pump": all three clients reach the same two alternatives. Nothing is stuck in transit.

Once you've read both, resolve with "raise crest + arm pump". That's an ordinary write, not a special conflict API. An unseen third revision would survive it.

Skip past the interactive demo

One slate, three field crews

Confirmed alternatives stay in ink; your pending revision appears in magenta. Cut Client B's link, write on both sides, then restore it. Agreement means every crew sees the same alternatives, not necessarily a single answer.

Re-deliver keeps an earlier delta even after resolution. The old revision stays retired. Reset tears off a fresh slate and discards delayed work from the previous one.

  1. 1 · local edit prints in magenta
  2. 2 · the sequencer stamps it with an SN
  3. 3 · every client lands the same ink state

Merge rule: keep concurrent alternatives. Two revisions can converge without agreeing on one answer. After both arrive, write a combined revision to replace the alternatives you've seen.

Client A

0 pending
Shared map replica on Client A
mill-race 24
kettle-run 61
low-ford 42
sandbags-placed 120
earthwork-balance · yd³ +44
fill Σ
74
cut Σ
30
inspection-count 18
A Σ
0
B Σ
0
C Σ
0

Gate settings

Confirmed entries, sorted by key

[["gate-mode","surveyed"]]

Local view

[["gate-mode","surveyed"]]

Confirmed timestamps and tombstones (null)

[{"key":"gate-mode","value":"surveyed","timestamp":100}]

An empty string is still a value. Removal retains a timestamped tombstone; a newer ordinary edit can restore the key.

Field note

Confirmed value

Survey datum

Local view

Survey datum timestamp 100 · survey-lww

Race uses one timestamp for both writers. The author ID—not arrival order—breaks the tie.

Revision slate

Confirmed alternatives

["Survey datum"]

Local view

["Survey datum"]

Resolve writes "raise crest + arm pump". Any new revision replaces only the history this client has seen.

Gate revisions

Confirmed entries, sorted by key and value

[["gate-mode",["surveyed"]]]

Local view

[["gate-mode",["surveyed"]]]

Sorted doesn't mean preferred. Equal text from two concurrent writers still counts as two alternatives; an empty string is a revision, not a removal.

Claims replica on Client A
north-levee —
spillway-gate —
pump-house Survey
OR-map stockpile ledger replica on Client A
spoil-north 18
borrow-pit-7 -6
wash-fill 12
OR-set field marker roster replica on Client A
north-stake marked
sluice-tag marked
borrow-flag clear
G-set permanent benchmark registry replica on Client A
BM-17 recorded
BM-22 unrecorded
BM-31 unrecorded
2P-set retired marker ledger replica on Client A
stake-3 active
gate-pin unplaced
silt-flag retired
RegisterCollection replica on Client A
register atomic LWW revise
north-bench Survey Survey
gate-setpoint — —
pump-mode — —
OrderedCollection replica on Client A
sheet state op
queue grade-stakes, pump-check
held jobs none
PactMap replica on Client A
pact accepted pending op
datum-grid Survey datum —
gate-policy — —
inspection-window — —
TaskManager replica on Client A
task assigned waiters op
sluice-inspection — empty
pump-watch — empty
crest-walk — empty

Client B

0 pending
Shared map replica on Client B
mill-race 24
kettle-run 61
low-ford 42
sandbags-placed 120
earthwork-balance · yd³ +44
fill Σ
74
cut Σ
30
inspection-count 18
A Σ
0
B Σ
0
C Σ
0

Gate settings

Confirmed entries, sorted by key

[["gate-mode","surveyed"]]

Local view

[["gate-mode","surveyed"]]

Confirmed timestamps and tombstones (null)

[{"key":"gate-mode","value":"surveyed","timestamp":100}]

An empty string is still a value. Removal retains a timestamped tombstone; a newer ordinary edit can restore the key.

Field note

Confirmed value

Survey datum

Local view

Survey datum timestamp 100 · survey-lww

Race uses one timestamp for both writers. The author ID—not arrival order—breaks the tie.

Revision slate

Confirmed alternatives

["Survey datum"]

Local view

["Survey datum"]

Resolve writes "raise crest + arm pump". Any new revision replaces only the history this client has seen.

Gate revisions

Confirmed entries, sorted by key and value

[["gate-mode",["surveyed"]]]

Local view

[["gate-mode",["surveyed"]]]

Sorted doesn't mean preferred. Equal text from two concurrent writers still counts as two alternatives; an empty string is a revision, not a removal.

Claims replica on Client B
north-levee —
spillway-gate —
pump-house Survey
OR-map stockpile ledger replica on Client B
spoil-north 18
borrow-pit-7 -6
wash-fill 12
OR-set field marker roster replica on Client B
north-stake marked
sluice-tag marked
borrow-flag clear
G-set permanent benchmark registry replica on Client B
BM-17 recorded
BM-22 unrecorded
BM-31 unrecorded
2P-set retired marker ledger replica on Client B
stake-3 active
gate-pin unplaced
silt-flag retired
RegisterCollection replica on Client B
register atomic LWW revise
north-bench Survey Survey
gate-setpoint — —
pump-mode — —
OrderedCollection replica on Client B
sheet state op
queue grade-stakes, pump-check
held jobs none
PactMap replica on Client B
pact accepted pending op
datum-grid Survey datum —
gate-policy — —
inspection-window — —
TaskManager replica on Client B
task assigned waiters op
sluice-inspection — empty
pump-watch — empty
crest-walk — empty

Client C

0 pending
Shared map replica on Client C
mill-race 24
kettle-run 61
low-ford 42
sandbags-placed 120
earthwork-balance · yd³ +44
fill Σ
74
cut Σ
30
inspection-count 18
A Σ
0
B Σ
0
C Σ
0

Gate settings

Confirmed entries, sorted by key

[["gate-mode","surveyed"]]

Local view

[["gate-mode","surveyed"]]

Confirmed timestamps and tombstones (null)

[{"key":"gate-mode","value":"surveyed","timestamp":100}]

An empty string is still a value. Removal retains a timestamped tombstone; a newer ordinary edit can restore the key.

Field note

Confirmed value

Survey datum

Local view

Survey datum timestamp 100 · survey-lww

Race uses one timestamp for both writers. The author ID—not arrival order—breaks the tie.

Revision slate

Confirmed alternatives

["Survey datum"]

Local view

["Survey datum"]

Resolve writes "raise crest + arm pump". Any new revision replaces only the history this client has seen.

Gate revisions

Confirmed entries, sorted by key and value

[["gate-mode",["surveyed"]]]

Local view

[["gate-mode",["surveyed"]]]

Sorted doesn't mean preferred. Equal text from two concurrent writers still counts as two alternatives; an empty string is a revision, not a removal.

Claims replica on Client C
north-levee —
spillway-gate —
pump-house Survey
OR-map stockpile ledger replica on Client C
spoil-north 18
borrow-pit-7 -6
wash-fill 12
OR-set field marker roster replica on Client C
north-stake marked
sluice-tag marked
borrow-flag clear
G-set permanent benchmark registry replica on Client C
BM-17 recorded
BM-22 unrecorded
BM-31 unrecorded
2P-set retired marker ledger replica on Client C
stake-3 active
gate-pin unplaced
silt-flag retired
RegisterCollection replica on Client C
register atomic LWW revise
north-bench Survey Survey
gate-setpoint — —
pump-mode — —
OrderedCollection replica on Client C
sheet state op
queue grade-stakes, pump-check
held jobs none
PactMap replica on Client C
pact accepted pending op
datum-grid Survey datum —
gate-policy — —
inspection-window — —
TaskManager replica on Client C
task assigned waiters op
sluice-inspection — empty
pump-watch — empty
crest-walk — empty
Sequencer SN 0

    Loading booting watershed kernels…

    Jitter can change simulated arrival order; animation speed changes playback only. Each moving request shows its sampled hop latency. “Ops in flight” counts every hop still travelling: one client → sequencer leg, then one sequencer → replica leg per client.

    A list isn't a queue

    mv_register_values returns a result containing a sorted List(String). The order is for display, not priority. A fresh register reads []; writing an empty string reads [""]. There's no delete hidden in that spelling.

    Two concurrent writes of the same text still produce two entries. Don't deduplicate them and mistake that for causal agreement. A fresh write after observing both replaces both, even if its text is unchanged. The clock advances, though no visible-value event need fire.

    One typed field on the sequenced stream

    The JavaScript facade's ensure callback returns an MvRegister handle. Ensure waits for synchronization before looking up or creating the field; it doesn't make the next user write wait for acknowledgement. The read below includes that optimistic write. On the BEAM, watershed_beam has the corresponding typed API with blocking ensures and subject subscriptions.

    tools/website-samples/src/website_samples/mv_register_sample.gleam
    pub type RevisionSheet
    
    pub fn revision() -> schema.ChannelField(
      RevisionSheet,
      schema.MvRegisterChannel,
    ) {
      schema.channel_field("revision")
    }
    
    pub fn ensure_and_revise(
      document: watershed.Document(RevisionSheet),
      text: String,
      done: fn(Result(List(String), String)) -> Nil,
    ) -> Nil {
      // Ensure waits for synchronization before it reads or creates the field.
      use ensured <- watershed.ensure_mv_register(
        document,
        watershed.root_typed(document),
        revision(),
      )
      let values = {
        use register <- result.try(ensured)
        watershed.mv_register_set(register, text)
        watershed.mv_register_values(register)
        |> result.map_error(fn(_) { "Could not read revision alternatives" })
      }
      done(values)
    }

    Or let the peers exchange revisions

    A CRDT document can use this register as its root. Connect with this configuration, then use the document delivered by a successful readiness callback. If you're filling a missing default rather than handling a user edit, wait for readiness and inspect the synchronized value first: an early seed can become a concurrent alternative.

    tools/website-samples/src/website_samples/mv_register_sample.gleam
    pub fn configure(
      signaling: Signaling,
    ) -> crdt_js.Config(schema.MvRegisterChannel) {
      crdt_js.config(
        room_id: "revision-slate",
        replica_label: "field-crew",
        compatibility_tag: "revision-slate/v1",
        root: p2p.mv_register_root(),
        signaling: signaling,
      )
      |> crdt_js.with_transport_policy(crdt_js.P2pOnly)
    }
    
    // Call after readiness succeeds. Use this for an intentional user edit.
    pub fn revise(
      document: crdt_js.CrdtDocument(schema.MvRegisterChannel),
      text: String,
    ) -> Result(List(String), p2p.P2pError) {
      let register = crdt_js.root(document)
      use _ <- result.try(crdt_js.mv_register_set(register, text))
      crdt_js.mv_register_values(register)
    }

    The merge example is a regression, too

    This is the kernel test the suite runs, not a second merge implementation written for the page. A and B author independently, observe each other's alternatives, then A writes a combined revision. An unseen C would not be superseded: resolution covers only the history the writer observed.

    test/watershed/mv_register_kernel_test.gleam
    pub fn concurrent_writes_survive_until_observed_resolution_test() -> Nil {
      let #(a, _, write_a, id_a) =
        mv.set(mv.new(replica_id.new("a")), "raise crest")
      let #(b, _, write_b, id_b) = mv.set(mv.new(replica_id.new("b")), "arm pump")
      let assert Ok(a) = mv.ack_local_with_message_id(a, write_a, id_a)
      let assert Ok(b) = mv.ack_local_with_message_id(b, write_b, id_b)
      let #(a, _) = mv.apply_remote(a, write_b)
      let #(b, _) = mv.apply_remote(b, write_a)
      mv.values(a) |> expect.to_equal(["arm pump", "raise crest"])
      mv.values(b) |> expect.to_equal(["arm pump", "raise crest"])
      let #(a, _, resolved, resolved_id) = mv.set(a, "raise crest + arm pump")
      let assert Ok(a) = mv.ack_local_with_message_id(a, resolved, resolved_id)
      let #(b, _) = mv.apply_remote(b, resolved)
      mv.values(a) |> expect.to_equal(["raise crest + arm pump"])
      mv.values(b) |> expect.to_equal(["raise crest + arm pump"])
    }

    Return to the MvRegister field sheet, or compare RegisterCollection, which maintains atomic and last-write-wins views using server sequence numbers, and PactMap, which waits for a frozen quorum's protocol acknowledgements. None of those acknowledgements means a person approved a revision. Here, the application decides when and how to offer a resolving write.