Three kinds of key, one tag
Everything a watershed map can hold falls into three shapes. A Field(tag, value) is a plain JSON value: a string, a number,
a bool, whatever you tell it to decode as. A ChildField(tag, child) is a handle to another typed map
nested underneath. A ChannelField(tag, kind) is a handle to
a channel of some other kind entirely — a counter, an OR-set, a claims
channel. One schema module declares all three for a document, and tag is the thread that ties every field in it to the one
map it belongs to.
A field is a value you can read straight back
Field is a key plus a JSON encoder and a decoder, nothing
more. Call schema.field once for each key you want, and
wrap that call in a zero-argument function — Gleam constants can't hold
a function call, so a field's name has to be a function, not a
constant.
/// A typed key: its name, the encoder from its value to `Json`, and the decoder
/// back. `schema` is a phantom tag that limits the field to one map shape. `a`
/// is the value type. The type is opaque, so nothing can change the codec.
pub opaque type Field(schema, a) {
Field(key: String, encode: fn(a) -> Json, decode: Decoder(a))
} /// The board title shown in the header.
pub fn title() -> Field(BoardDocument, String) {
schema.field("title", json.string, decode.string)
} BoardDocument is the tag. Nothing ever constructs a BoardDocument value — it exists so title() can only
be set or read against a map that's been marked as a TypedMap(BoardDocument). Try it against a map of some other
schema and the build refuses to compile, before your code ever reaches a
server.
The tag is a label the compiler checks and the runtime never sees
This is what people mean by a phantom type: BoardDocument
shows up in a type signature and nowhere else. It isn't stored, isn't
sent over the wire, and costs nothing at runtime. It's there purely so
the type checker can catch the one bug that matters — reading a field
against the wrong document — before you ship it. Two documents can even
declare a field with the same name and completely different types; the
tag keeps them from ever getting confused for each other.
A child field is a handle to another typed map
Nest a whole document inside another one by declaring a ChildField instead of a Field. The stored
value isn't the child map's contents — it's a handle, the same handle
you'd get back from handle_of — but the type on the field
still tells you which schema the child map uses, so reading through it
is just as typed as reading a plain field.
/// A typed key whose stored value is a handle to a nested map of the shape
/// `child`. It carries the key only. The backend encodes and resolves the
/// handle, with the `handle_of` and `resolve` functions.
pub opaque type ChildField(schema, child) {
ChildField(key: String)
} /// The plain-text editor's sub-document.
pub fn text() -> ChildField(Showcase, text_schema.TextDocument) {
schema.child_field("text")
} The next sheet covers what "handle" really means here — the child map is a separate channel, not a branch of one big tree.
A channel field is a handle to something that isn't a map at all
A document is rarely all maps. ChannelField(tag, kind) is a
handle to any other collaborative structure — a counter that moves by
signed amounts, up or down, an OR-set where a concurrent add beats a
remove, a claims channel where the first writer keeps the slot. The kind
tag is what makes this safe: it routes each field to the one set of
functions that understands that kind, so calling a counter's resolver on
an OR-set field is a compile error, not a runtime surprise.
/// A typed key whose stored value is a handle to a channel of `kind`.
pub opaque type ChannelField(schema, kind) {
ChannelField(key: String)
} /// The player-entered digits, keyed `r{row}c{column}` → digit.
pub fn cells() -> ChannelField(SudokuDocument, MapChannel) {
schema.channel_field("cells")
}
/// The pencil-mark notes, as `r{row}c{column}={digit}` set elements.
pub fn notes() -> ChannelField(SudokuDocument, OrSetChannel) {
schema.channel_field("notes")
}
/// The puzzle's immutable givens, first-writer-wins claims per cell.
pub fn givens() -> ChannelField(SudokuDocument, ClaimsChannel) {
schema.channel_field("givens")
}
/// The shared mistake tally.
pub fn mistakes() -> ChannelField(SudokuDocument, CounterChannel) {
schema.channel_field("mistakes")
} Each of those four fields answers "what happens when two players touch this at once" on its own terms. The field atlas covers that answer for every structure watershed ships.
A read is a decode boundary, not a promise from the server
None of this is enforced anywhere but your own build. A peer running an
older version of your app, a stale summary, or a bug can write any JSON
to any key, and the server won't stop it — floodgate, the relay
watershed talks to, never looks inside the values it relays. So a typed
read always has to consider that the value on the wire might not match
what you expect, and it hands you a Result instead of the
bare value.
/// The reason that a typed read failed.
///
/// - `Missing`: a required single field was absent. See `get_required`.
/// - `Invalid`: a value was present, and it did not decode to the expected
/// type.
/// - `UnknownKeys`: a `sealed` schema found keys that it does not declare.
/// - `SchemaMismatch`: a `versioned` schema found a different stored
/// version.
pub type FieldError {
Missing(key: String)
Invalid(reason: json.DecodeError)
UnknownKeys(keys: List(String))
SchemaMismatch(expected: Int, found: Int)
} /// Read a typed field. The result is `Ok(None)` when the key is absent, and
/// `Error(Invalid)` when the stored value does not decode to the type `a`.
pub fn get_field(
typed_map: TypedMap(s),
field: Field(s, a),
) -> Result(Option(a), FieldError) {
case get(typed_map.map, schema.field_key(field)) {
Error(Nil) -> Ok(None)
Ok(stored) -> schema.decode_value(field, stored) |> result.map(Some)
}
} The error is the interesting case, not the exception. Ok(None) means the key just isn't set yet — completely
normal for a field nobody's written to. Error(Invalid)
means somebody wrote a value that doesn't decode as your type. Handle
both, because either one shows up in an ordinary multiplayer session.
Advanced: describing a whole record at once
Everything above is a single key at a time. A separate, optional layer —
Schema, not Field — covers the case where
several keys together form one record you want to read and write as a
unit. Build one with record1 through record9:
each prop declares a field once, and both the decoder and
the per-key encoder come from that one declaration, so they can't drift
out of sync with each other.
"As a unit" describes the Gleam-side ergonomics, not a transaction on
the wire: writing a record still turns into one write op per key, the
same as if you'd called set_field on each one separately.
Two clients touching different fields of the same record still merge
key by key, exactly like two plain fields would — nothing about Schema makes the record's keys succeed or fail together.
/// The player-map schema. `record4` derives the decoder *and* the per-key
/// encoder from a single prop list, so they can never drift; `sealed_known`
/// seals it to exactly those declared keys (no hand-repeated list); `versioned`
/// marks a version so a read can reject a mismatched stored version. One
/// declaration replaces the old decoder / encoder / seal-list trio.
fn player_schema() -> Result(schema.Schema(Player, PlayerState), Nil) {
schema.record4(
PlayerState,
schema.prop(player_name(), fn(p: PlayerState) { p.name }),
schema.optional_prop(player_last_roll(), fn(p: PlayerState) { p.last_roll }),
schema.prop(player_total(), fn(p: PlayerState) { p.total }),
schema.prop(player_rolls(), fn(p: PlayerState) { p.rolls }),
)
|> schema.versioned(1)
|> schema.sealed_known
} sealed_known closes the record to exactly the keys its
props declared, with no key list to repeat by hand and let go stale. versioned marks the record with an integer version: a read
whose stored version doesn't match the expected one rejects,
but a map with no version marker at all — one nobody's stamped yet —
reads fine regardless. Nothing writes that marker for you automatically;
call stamp yourself, by convention, once right after you
create the map, or every reader treats it as unversioned.
/// Write the version marker of a schema that has a version, one time. The usual
/// position for this call is immediately after you create the map. The function
/// does nothing for a schema with no version.
pub fn stamp(
typed_map: TypedMap(s),
map_schema: schema.Schema(s, record),
) -> Nil {
case schema.stamp_entry(map_schema) {
Some(entry) -> set(typed_map.map, entry.0, entry.1)
None -> Nil
}
}
Build a whole-record Schema when a group of keys only makes
sense together, like a player's name and score. Keep using plain Field declarations for everything else — most documents
need far more of those than they need a sealed record.
Field is a value, ChildField is a handle to a
nested typed map, and ChannelField is a handle to any other
channel kind. The phantom tag is checked at compile time and gone by
runtime, and every read still comes back as a Result
because a peer really can write anything. Next: how those handles actually lay out a document.