watershed Collaborative data structures for Gleam

Edit upstream.
Converge downstream.

watershed lets people edit shared data without losing concurrent changes. Clients apply edits at once; the server sequences them; each data structure resolves conflicts under its stated policy. The same Gleam core runs on the BEAM and in the browser, on a distributed-data-structure (DDS) runtime and sequencer drawn from Fluid Framework's design.

Skip past the interactive demo

Watch three clients converge

On a photorevised survey sheet, magenta marks updates not yet field-checked. This demo borrows that color code: edits that aren't confirmed yet are drawn in magenta, confirmed state in ink. All three clients run watershed's compiled map_kernel Gleam code and share one ordered list of changes. That is a shared map, the structure most collaborative apps start with.

Nudge a gauge, have two clients write at once, stretch the network delay. The copies agree once queued writes arrive; the most recent write to a key wins.

Try cutting Client B's link mid-edit: its writes park locally, the others keep converging, and restoring the link catches B up and resubmits what it wrote offline.

  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: last write wins. Race two writes to the same key and the op the sequencer stamps later overwrites the earlier one, identically on every replica.

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.

    Every other structure runs the same way on its own page. Start with the field atlas, the full catalog →

    One ordered stream, one clear rule per structure

    watershed doesn't paper over conflicts with one catch-all merge. Each structure states a small, clear rule: what can show up instantly, what has to wait for the server to confirm it, and what gets saved so a client can reload later.

    Only a few of watershed's structures are shown here. Find the rest, grouped by family, in the field atlas. The full catalog includes a live demo for each structure.

    SharedMap

    map_kernel
    Merge rule
    for each key, the most recent write wins, decided by server order
    Before it's confirmed
    your writes show instantly, then lock in once the server confirms them
    What gets saved
    confirmed entries reload with their keys and insertion order intact
    Read in depth →

    SharedCounter

    counter_kernel
    Merge rule
    everyone sends +/− changes instead of overwriting, so simultaneous edits just add up
    Before it's confirmed
    your change shows next to the confirmed total right away
    What gets saved
    only one number needs saving, since every change is an add
    Read in depth →

    OrSet

    or_set_kernel
    Merge rule
    add, remove, and add again all work; if an add and a remove race, the add wins
    Before it's confirmed
    your change overlays the list in magenta until it's confirmed
    What gets saved
    current members and their removal history reload intact
    Read in depth →

    Claims

    claims_kernel
    Merge rule
    the first client to claim a slot owns it; every later claim is refused
    Before it's confirmed
    a claim only shows as yours once it has actually won, never before
    What gets saved
    who owns what reloads intact
    Read in depth →

    Every structure uses the same channel

    Every structure above rides the same ordered stream of changes, the very one the live SharedMap demo runs on. Open any family to switch which structure is on screen; they all stay hosted together as channels sharing one sequenced document.

    1. 01

      Submit

      A client makes a change and updates just its own local copy for now.

    2. 02

      Sequence

      A server like floodgate puts everyone's changes in one order and sends that order to every client.

    3. 03

      Confirm / apply

      The client that made the change locks it in; everyone else applies it through the exact same code.

    4. 04

      Summarize

      A saved snapshot lets any client reload the current state, the same path used after a reconnect.

    One pure core, two runtimes

    watershed is written once and runs in two places. runtime_core, channel, wire, and the kernels compile for both targets. Erlang and JavaScript keep separate facades, runtimes, and socket bindings only where the platform forces them to differ.

    The boundary is enforced by target gates: watershed_beam and runtime_beam are @target(erlang); watershed, runtime, and transport_js are @target(javascript). The ungated core underneath is the compiled code running in the browser demo.

    A map over one sequenced stream

    connect hands back a document, root hands back its SharedMap, and subscribe hands back a stream of changes to select on. Below is that whole opening move from the dice CLI — connect, take the root, subscribe, print the entries already there, then loop. No merge callback appears anywhere in it, because there is none to install: the server sequences every write, and the highest-sequenced write wins per key.

    The same core runs on the Erlang target as an OTP actor and on the JavaScript target inside a Lustre single-page app. The facades differ at the edge: watershed_beam blocks and returns a Subject, as it does here, while watershed uses callbacks. See the examples for both, verified converging against a live floodgate sequencer.

    Build an app, step by step: the field guide →

    examples/dice_cli/src/dice_cli.gleam
    pub fn main() -> Nil {
      let token =
        watershed_beam.dev_token(
          secret: tenant_secret,
          tenant: tenant,
          document: document_id,
          user_id: user_id,
        )
    
      io.println("Connecting to " <> host <> ":" <> int.to_string(port) <> "…")
    
      case
        watershed_beam.connect(
          host: host,
          port: port,
          tenant: tenant,
          document: document_id,
          token: token,
          user_id: user_id,
        )
      {
        Error(reason) -> {
          io.println("Connection failed: " <> reason)
        }
        Ok(document) -> run(document)
      }
    }
    
    fn run(document: watershed_beam.Document(DiceDocument)) -> Nil {
      io.println("Connected. Subscribing to events…")
      let map = watershed_beam.root(document)
      let events = watershed_beam.subscribe(map)
    
      // Print current state
      let current = watershed_beam.entries(map)
      case current {
        [] -> io.println("Document is empty.")
        entries -> {
          io.println("Current entries:")
          list.each(entries, fn(entry) {
            let #(key, value) = entry
            io.println("  " <> key <> " = " <> json.to_string(value))
          })
        }
      }
    
      // Roll a die
      let roll_due = process.new_subject()
      let selector =
        process.new_selector()
        |> process.select_map(events, MapChanged)
        |> process.select_map(roll_due, fn(_) { RollDue })
    
      io.println(
        "Rolling every "
        <> int.to_string(roll_interval_milliseconds / 1000)
        <> "s; press Ctrl+C to stop.",
      )
      schedule_roll(roll_due, first_roll_delay_milliseconds)
      event_loop(map, selector, roll_due)
    }
    erlang target

    What is implemented and tested

    The runtime is tested three ways: against reference models, against a live floodgate server, and against deliberately nasty orderings of operations — over a thousand test functions across more than a hundred test files. The ledger is what those checks cover: everything watershed ships.

    Property-tested convergence
    Eleven reference models — map, directory, counters, claims, registers, ordered collection, pact, sequence, text — each replay randomized multi-client command traces, 200 per property by default (FUZZ_ITERATIONS raises it), including partial sequencing and delivery, reconnect, rollback, stashed ops, and summary-loaded joins. Invariants are checked after each command and convergence after a final sync; qcheck shrinks failures and the harness dumps a replayable JSON trace. Read the harness →
    Reference-checked map ops
    SharedMap's inner set, delete, and clear payloads are checked against 26 oracle scenarios generated from @fluidframework/map itself, including state, events, and convergence. Container, attach, and summary formats remain watershed's own. Read the corpus →
    Reconnect safety
    Buffered out-of-order delivery, in-band op requests, and client-id remapping on reconnect. Pending edits survive the round trip — the fuzz harness disconnects clients mid-trace to prove it. Read the disconnect suite →
    What ships today
    01 Maps SharedMap, OR-map, and SharedDirectory: Fluid-derived op semantics, summaries, nested handles, and folder hierarchies
    02 Counters and sets SharedCounter, G-counter, PN counter, G-set, 2P-set, and OR-set: delta merge, duplicate-safe delivery, add-wins and permanent removal
    03 Sequences and text SharedSequence and SharedText: grapheme-indexed edits, convergent move, and anchors that survive concurrent edits
    04 Transforms json_ot and SharedRichText: one client-transform protocol over the ottypes json0 and quill-delta algebras
    05 Coordination Claims, TaskManager, ordered collection, register collection, and pact map: ownership, work queues with failover, and quorum agreement
    06 Runtime Server-sequenced delivery, requestOps catch-up, reconnect with client-id remap, nack policy, heartbeat, resubmit, and automatic summaries
    07 App layer Typed schema decoding, ensure_* bootstrap, presence ripples, Lustre effect bindings, and the in-memory sluice for tests

    Not yet a Hex release — installs are git-pinned — and container, attach, and summary formats are watershed's own rather than Fluid's. Wire compatibility is at the operation layer.