Client A
0 pending mill-race | 24 | |
|---|---|---|
kettle-run | 61 | |
low-ford | 42 |
sandbags-placed earthwork-balance · yd³ - fill Σ
- 74
- cut Σ
- 30
north-levee | ||
|---|---|---|
spillway-gate | ||
pump-house |
spoil-north | ||
|---|---|---|
borrow-pit-7 | ||
wash-fill |
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.
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.
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.
Merge rule — increments commute. Race two increments and both apply. The counter converges on the sum; nothing is overwritten.
Merge rule — deltas join a lattice. Every update travels as a CRDT delta; merging is commutative and idempotent. Race it, resend it, deliver it twice — it counts exactly once, and the balance converges on fill − cut.
Merge rule — first writer wins. A claim commits only if the slot is still unclaimed when the sequencer stamps it (or its reference SN matches the holder’s exactly). Nothing prints optimistically: a filed claim is invisible — even to you — until it round-trips as won or lost.
Merge rule — add-wins, observed-remove. A strike only removes stockpile entries the striker had seen. Race a strike against a concurrent delivery and the row survives — with every logged yard. Re-opening a struck pile brings its ledger back.
mill-race | 24 | |
|---|---|---|
kettle-run | 61 | |
low-ford | 42 |
sandbags-placed earthwork-balance · yd³ north-levee | ||
|---|---|---|
spillway-gate | ||
pump-house |
spoil-north | ||
|---|---|---|
borrow-pit-7 | ||
wash-fill |
mill-race | 24 | |
|---|---|---|
kettle-run | 61 | |
low-ford | 42 |
sandbags-placed earthwork-balance · yd³ north-levee | ||
|---|---|---|
spillway-gate | ||
pump-house |
spoil-north | ||
|---|---|---|
borrow-pit-7 | ||
wash-fill |
Converged replicas identical · nothing pending
The live demo couldn’t start — it runs watershed’s actual kernels as compiled JavaScript modules, and this browser didn’t load them. The rest of the page works fine without it.
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.
river gauges
map_kernel sandbag tally
counter_kernel cut-and-fill balance
pn_counter_kernel stockpile ledger
or_map_kernel duty stations
claims_kernel 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.
The client kernel emits an op and updates only the local pending layer.
A Fluid-compatible server like levee orders the op stream and broadcasts one SN to every replica.
The author promotes pending state; peers apply remote state through the same pure kernel.
A summary reloads through the same from_summary path used after reconnect.
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.
watershed
Erlang-only API: blocking connect, OTP subject calls,
map/counter handles, summaries, and subscriptions.
watershed_js
JavaScript-only API: immediate document handle, on_ready
callback, promises, and the same DDS verbs.
runtimeOTP actor, receiver process, process calls, heartbeat timer, and reconnect orchestration.
runtime_jsCallback-driven shell over a mutable cell; same bootstrap, ordering, catch-up, and resubmit discipline.
runtime_core owns CSN/RSN, ack matching, gap detection,
detached attach, and resubmit. channel dispatches the
kernels hosted by the document runtime. wire and
wire/* encode the same Fluid-compatible payloads on both
targets.
map_kernel · counter_kernel ·
pn_counter_kernel · or_map_kernel ·
claims_kernel
aquamarine
Phoenix channel client over Gun, with Roost decoding; gated to
Erlang with @target(erlang).
transport_jsPhoenix.js FFI, socket callbacks, mutable cell helpers, and browser Web Crypto dev-token signing.
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.
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
}
})
} 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.
@fluidframework/map exactly —
{type, key, value} down to the byte — verified
against a corpus generated from the TypeScript kernel itself.
| 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 |