Edit upstream.
Converge downstream.

watershed is a Gleam client toolkit for Fluid Framework distributed data structures. Clients edit locally without waiting; a Fluid-compatible server sequences every op; every replica reaches the same state — on the BEAM and in the browser, from one pure core.

Photorevision, live

On a photorevised survey sheet, magenta marks updates not yet field-checked. The demos below borrow that convention: local edits appear instantly in magenta, then set in ink when a Fluid-compatible server assigns a sequence number. Both clients run the real Gleam kernels — maps, counters, PN counters, OR-maps, and claims — compiled to JavaScript and sharing one ordered stream.

Nudge the gauges, tally the sandbags, balance the earthwork, strike a stockpile, stake a claim, stretch the link latency, race a concurrent write. The replicas always converge — each structure by its own rule.

Data structure shown in both clients

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
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

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
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
Sequencer SN 0

    Converged replicas identical · nothing pending

    Five DDS field sheets, one ordered stream

    watershed does not hide every conflict behind the same merge story. Each DDS has a small, explicit rule: what can be applied optimistically, what must wait for sequencing, and what shape can be persisted in a summary.

    SharedMap

    river gauges

    map_kernel
    Merge rule
    server-sequenced last write wins per key
    Optimistic behavior
    local writes can render optimistically until the sequencer stamps them
    Summary bedrock
    JSON entries reload with the same keys the Fluid wire op names

    SharedCounter

    sandbag tally

    counter_kernel
    Merge rule
    increments commute, so concurrent adds converge on the sum
    Optimistic behavior
    local deltas can be shown beside the committed value
    Summary bedrock
    a scalar summary is enough because every op is additive

    PN Counter

    cut-and-fill balance

    pn_counter_kernel
    Merge rule
    positive and negative grow-only ledgers join as a CRDT lattice
    Optimistic behavior
    fill and cut deltas can be replayed without double-counting
    Summary bedrock
    replica-tagged tallies survive reconnect and duplicate delivery

    OR-Map

    stockpile ledger

    or_map_kernel
    Merge rule
    observed-remove with add-wins delivery under concurrent strike
    Optimistic behavior
    struck rows can stay readable until the op is ordered
    Summary bedrock
    entries retain their causal dots, not just their visible value

    Claims

    duty stations

    claims_kernel
    Merge rule
    first writer wins against the sequenced holder and reference SN
    Optimistic behavior
    reads stay non-optimistic until the filed claim resolves
    Summary bedrock
    holder values reload with their sequence numbers intact

    The same channel carries every structure

    Maps, counters, OR-maps, and claims all ride one ordered document stream. The picker in the live demo changes the visible field sheet; the kernels stay hosted together, just like DDSes sharing a Fluid container.

    1. SN 1

      Submit

      The client kernel emits an op and updates only the local pending layer.

    2. SN 2

      Sequence

      A Fluid-compatible server like levee orders the op stream and broadcasts one SN to every replica.

    3. SN 3

      Ack / apply

      The author promotes pending state; peers apply remote state through the same pure kernel.

    4. SN 4

      Summarize

      A summary reloads through the same from_summary path used after reconnect.

    One pure core, two runtimes

    The shared layer is real code, not a diagram convenience: runtime_core, channel, wire, and the kernels compile for both targets. Erlang and JavaScript keep separate facades, runtimes, and socket bindings only where the host platform forces them to differ.

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

    Reads like a map, syncs like a river

    A SharedMap is just a map — set, get, subscribe. There is no merge callback to write and no conflict handler to forget: the server sequences every op, last-writer-wins per key, and each replica applies the same ops in the same order.

    The same API runs on the Erlang target as an OTP actor and on the JavaScript target inside a Lustre single-page app — see the examples for both, verified converging against a live Fluid-compatible server like levee.

    import gleam/json
    import watershed
    import watershed/map_kernel.{ValueChanged}
    
    pub fn main() {
      // Connect, blocking until the op history has replayed locally.
      let assert Ok(doc) =
        watershed.connect(
          host: "127.0.0.1",
          port: 4000,
          tenant: "dev-tenant",
          document: "river-gauges",
          token: token,
          user_id: "gauge-1",
        )
      let gauges = watershed.root(doc)
    
      // Optimistic write: applies now, sequences on the server.
      watershed.set(gauges, "mill-race", json.int(24))
    
      // Every replica observes the same sequence of changes.
      watershed.subscribe(gauges, fn(event) {
        case event {
          ValueChanged(key:, ..) -> io.println("revised: " <> key)
          _ -> Nil
        }
      })
    }
    examples · erlang + javascript targets

    What is proven, what is next

    The current runtime is tested against an oracle, a live levee server, and adversarial operation orderings. The ledger separates implemented pieces from the DDS work still on the roadmap.

    Property-tested convergence
    qcheck drives 1,000 iterations each of convergence across authorship and submit-timing interleavings, ack transparency, and rebase equivalence.
    Byte-identical wire format
    Ops match @fluidframework/map exactly — {type, key, value} down to the byte — verified against a corpus generated from the TypeScript kernel itself.
    Reconnect safety
    Buffered out-of-order delivery, in-band op requests, and client-id remapping on reconnect. Pending edits survive the round trip.
    Roadmap ledger
    B1 SharedMap basin Map kernel, Fluid-compatible ops, summaries, nested handles, BEAM and browser facades implemented
    B2 Runtime channels Server-sequenced delivery, requestOps catch-up, reconnect, nack policy, heartbeat, resubmit implemented
    B3 Extra DDS kernels Counter, PN counter, OR-map, and first-writer claims share the live sequencer in the browser demo implemented
    R1 Register collection Consensus register with retained concurrent versions and atomic/LWW read policies planned
    R2 OR-Map runtime channel Promote the lattice-backed kernel into a full DDS handle with register mode and handle-aware summaries planned
    R3 Consensus membership PactMap and ordered collection need follow-on ops plus sequenced membership-leave handling planned
    R4 Directory + sequence bedrock SharedDirectory hierarchy first, then a persistent merge-tree redesign for string/sequence DDSes researching