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 a refined
type Limit = 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[Limit] = 1A store type argument names a type, so declare the refinement as its own type
(type Limit = Int where Positive) rather than writing it inline.
Beyond 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 add(e: Event) -> Effect[()] given Clock { let _ <- events.append(e) ()}
on call recent_count(since: Instant) -> Effect[Int] { let n <- events.since(since).count() n}A map query yields values by default, but map.entries exposes each entry as a
MapEntry[K, V] record — read the key
with .key and the value with .value, so a stored value need not carry a
denormalised copy of its own key (map.keys and map.values give the keys and
values alone):
store items: Map[String, Stored]
on call all() -> Effect[List[TodoItem]] { items.entries.map((e) => TodoItem { id: e.key, seq: e.value.seq, title: e.value.title, done: e.value.done }).collect()}To route an equality filter through a maintained index, annotate the map with
@indexed.
Change stored state safely
Section titled “Change stored state safely”A deployed agent’s store fields hold data your users wrote, so a build records
each agent’s stored shape in bynk.schema.lock (commit it with your source) and
refuses a change that deployed data cannot be read through.
-
Adding a field is safe. Instances stored before it existed read it as its zero or initialiser.
-
Removing, retyping, or changing the storage kind of a field is refused with
bynk.agent.non_additive_state_change. So is renaming one: the old values would be left behind under the old name while the new field reads as zero. When exactly one field disappeared and one with the same shape appeared, the diagnostic names the likely rename. -
Changing a refinement builds, with a warning (
bynk.agent.refinement_changed). Widening one is safe. Narrowing one makes any stored value that no longer fits fault on load, so migrate that data first. -
Removing or renaming an agent without saying so is refused with
bynk.agent.class_removed. Declare what happens to its data instead:agent Basket replaces Cart { … }renames the class. Its stored data moves toBasketwhen you deploy, and its schema history (and anymigratesteps) moves with it.removed agent Wishlist, at the top level of the context, deletes the class and all of its stored data when you deploy. The deploy asks you to confirm each deletion by name (bynk deploy --confirm-delete shop.Wishlist).
Keep the declaration until a deploy prints Cloudflare’s
stale_tombstonenotice for it, then delete it. To rename a class twice, deploy the first rename before writing the second.
To make such a change, declare a migration step that carries the stored data
across. Rename owner to ownerName:
type CartV1 = { owner: String }
agent Cart { key id: String store ownerName: Cell[String]
migrate from 1 (old: CartV1) { ownerName := old.owner }
on call owner() -> Effect[String] { ownerName }}olddeclares what you read.CartV1is an ordinary record type naming the fields of version 1 that the step reads. The build checks it against the versionbynk.schema.lockrecorded, so it can’t drift from what is stored.- The body assigns current fields. Each
f := egives a current field its migrated value. Each field is assigned at most once, the order doesn’t matter, and a bare field name reads the value carried over from the old record, never one assigned earlier in the step. A field you leave alone is carried over if its shape didn’t change. - Every change must be accounted for. A removed field must be read (
old.f) or dropped withdrop f. A retyped field must be assigned. ASet,LogorCachefield can’t be read or assigned in a step, so a change to one is covered bydrop f, which starts it afresh. - Steps are pure. No capabilities, no effects, and no
key. A step that throws while running, such as anIntdivision by zero, faults on load asMigrationFailed, and the stored record is left alone. The step’s output still has to satisfy your types: a refinement it breaks faults on load like any other. - One step covers older versions too. A step
from 2also handles a record from version 1 if every change from 1 to 2 was additive. A field ofoldthat such a record lacks takes the default declared on the old type (note: String = "n/a"), or its type’s zero.
A stored record is migrated when it next loads, and written back in the new shape
the next time a handler changes it. A version that no step covers and that can’t be
read forward warns with bynk.agent.version_without_path; a record at that version
faults with NoMigrationPath if one is ever loaded.
If the agent has never been deployed, there is no data to protect: you can instead
delete its [agents."<context>.<Agent>"] entry from bynk.schema.lock, and the
next build records the new shape as a fresh start.
bynkc check and bynkc test check against the lock without changing it; a
build records each new shape.
Every record an agent stores carries that version, as a __v field written with
it. Two things follow:
- Rolling back is loud. If you redeploy an older build after a newer one has
written state, an agent whose record is newer than the code faults on load
(
NewerSchema) instead of misreading it, and leaves it untouched. Redeploy forward again to recover. - Re-baselining resets the version to 1. Delete a class’s lock entry only if
it was never deployed: a deployed instance’s records would be newer than the
fresh baseline, and would fault. (Local
bynk devstate needs no care:bynk devstarts every session with empty storage.)
A field your agent no longer declares, renamed before the lock recorded it, is logged once by name when an agent loads it; its value is kept.
Retire old migration steps
Section titled “Retire old migration steps”A step can only be deleted once no stored instance can still hold the version it
reads. Records migrate lazily, so an instance nobody has loaded since an old
deploy still holds its old version. bynk migrate tells you where each class
stands, and wakes every stored instance so the old steps can go.
-
See where you stand.
bynk migrate statusis offline. For each agent class it prints the recorded version and floor (the oldest version any instance can still hold), each step and the versions it covers, any version with no path forward, and the environments the project deploys the class to.$ bynk migrate statusshop.Cart version 3, floor 1migrate from 2 covers 1..=2; deletable once the floor passes 2deployed to: default, staging (a sweep of both can raise the floor) -
Sweep every environment.
bynk migrate sweeplists each deployed Worker’s stored instances through Cloudflare’s API and wakes each one. It loads, migrates, and commits at the current version. It needs bothCLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID; the token needs “Workers Scripts: Edit”.$ bynk migrate sweepshop (default): Cart 1204 with stored data, woke 1204 (0 failed); deployed at 3, oldest held now 3shop-staging (staging): Cart 12 with stored data, woke 12 (0 failed); deployed at 3, oldest held now 3floor: shop.Cart 1 → 3 (every recorded environment swept in this run)The floor rises only when one run completed every environment
bynk.deploy.lockrecords the class’s Worker in. It never rises past the version any environment’s deployed Worker writes. A sweep narrowed with--env, or one where any instance failed to wake, migrates what it can and leaves the floor alone.The floor also waits until 30 minutes after a deploy. Cloudflare’s listing can take about 10 minutes to show a newly stored instance. An instance the previous build stored just before a deploy might be missing from a sweep run straight afterwards, and the floor would rise past it. A sweep inside that window still migrates everything it finds, and says how long until a sweep can raise the floor. A sweep’s own key changes don’t count as deploys.
-
Commit the lock and delete the steps. The raised floor is written to
bynk.schema.lock. Commit it. The next build warns about each step below the floor (bynk.agent.migrate_below_floor): delete them, and their old types.
After a floor rise, roll a Worker back only to a build at or above the floor: an
older build would store new instances at a version no step covers. Commit
bynk.deploy.lock too, because the sweep can only cover the environments it
records.
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.