Build a stateful agent and keep its state zeroable
Goal: declare an agent that owns state, reads it, and updates it — with state that initialises cleanly for a never-seen key.
Agents live inside a context.
Declare the agent
Section titled “Declare the agent”Give it a key (its identity), one or more store fields, and handlers:
context counters
type CounterId = opaque String
agent Counter { key id: CounterId
store count: Cell[Int]
on call current() -> Effect[Int] { count }
on call increment() -> Effect[Int] { let _ <- count.update((c) => c + 1) count }}- Read a
storefield by its bare name (count). - Write it unconditionally with
:=(the new value does not depend on the old one). - When the new value does depend on the old one, use
count.update(fn)— a read-modify-write — rather than:=. A:=whose right-hand side names its own field is rejected (see below). - Every
storewrite is committed atomically when the handler returns; there is nocommitstep, and a faulting handler persists nothing. - Handlers return
Effect[T]; returning a plain value in tail position is lifted automatically.
Modify a cell in place
Section titled “Modify a cell in place”A := write replaces a cell’s value with an expression that stands on its own:
count := 0 -- resetlimit := limit -- rejected: the right-hand side reads the cell being writtenWhen the new value is computed from the old one, reach for update(fn) instead.
It takes a pure combiner (T) -> T and applies it to the current value:
let _ <- count.update((c) => c + 1) -- incrementlet _ <- count.update((c) => c * 2) -- doubleWhy a separate operation rather than count := count + 1? Because the latter
hides a read of the prior value inside what looks like a plain write. Making it
update keeps that prior-value dependency visible (and the combiner retry-safe).
A self-referencing := is therefore rejected with
bynk.cell.self_reference, steering you to
update.
update mutates the cell; it does not return the new value. To read-modify-write
and return — as increment above does — await the update, then read the
bare name back (the read sees the staged write):
let _ <- count.update((c) => c + 1)count -- the committed new valueKeep state zeroable
Section titled “Keep state zeroable”Every store field needs a starting value for the never-seen key that Bynk
initialises automatically. Either the type has a zero (Int→0, Bool→false,
String→"", Option[T]→None), or you supply an explicit initialiser with
=. A field whose type excludes its zero (for example Int where Positive, which
excludes 0) and which has no initialiser is rejected with
bynk.agents.non_zeroable_state_field.
When you need “not set yet”, use Option:
store reading: Cell[Option[Int]] -- starts as None — "never set"When the type has no zero but you have a sensible default, give an initialiser:
store limit: Cell[Int where Positive] = 1Beyond Cell: maps, sets, caches, and logs
Section titled “Beyond Cell: maps, sets, caches, and logs”Cell holds a single value; the other four storage kinds hold collections and
expose effectful methods (awaited with <-) instead of :=.
A Map keys values; a Set holds membership:
store members: Set[UserId]store profiles: Map[UserId, Profile]
on call join(u: UserId, p: Profile) -> Effect[()] { let _ <- members.add(u) -- idempotent let _ <- profiles.put(u, p) ()}
on call lookup(u: UserId) -> Effect[Option[Profile]] { let found <- profiles.get(u) found}A Cache is a TTL-bounded map: @ttl is required, and any time-consulting op
needs given Clock (which makes expiry testable with a mocked clock):
store sessions: Cache[SessionId, Session] @ttl(30.minutes)
on call touch(id: SessionId, s: Session) -> Effect[()] given Clock { let _ <- sessions.put(id, s) -- expires 30 minutes after it is written ()}A Log is an append-only, time-indexed sequence. append stamps the current
time (so it needs given Clock), but the window reads take explicit Instants and
so need no clock — they return a lazy Query:
store events: Log[Event] @retain(7.days)
on call record(e: Event) -> Effect[()] given Clock { let _ <- events.append(e) ()}
on call recent_count(since: Instant) -> Effect[Int] { let n <- events.since(since).count() n}To route an equality filter through a maintained index, annotate the map with
@indexed.
Address an agent
Section titled “Address an agent”Construct an agent with its key, then call a handler (binding the effectful
result with <-):
let c = Counter(CounterId.unsafe("a"))let n <- c.increment()Related
Section titled “Related”- Tutorial: Add a stateful agent.
- Reference: agents.
- Troubleshooting:
bynk.agents.non_zeroable_state_field.