Keep a per-user todo list
Give every authenticated user their own todo list, addressed by their sealed identity rather than a forgeable parameter, and support adding, listing, filtering, and completing items.
context todos
---A user proves who they are with a JWT. The verified `UserId` is *sealed* — mintedat the boundary, never forged downstream — and it becomes the agent key, so eachuser transparently gets their own private list.---type UserId = String where NonEmpty
actor User { auth = Bearer(secret = "AUTH_JWT_SECRET"), identity = UserId }
---A todo's title — non-empty and bounded, so an empty or oversized title isrejected at the boundary before any handler runs.---type Title = String where NonEmpty && MaxLength(200)
---The stored shape of a todo. The id is *not* here: it lives in the map key, sothere is no denormalised copy to keep in sync. `TodoItem` is the boundary shapethe API returns — the id rejoined with the stored fields.---type Stored = { seq: Int, title: Title, done: Bool,}
type TodoItem = { id: String, seq: Int, title: Title, done: Bool,}
type AddRequest = { title: Title,}
type TodoError = enum { NotFound }
---Rejoin a stored todo with its map key into the boundary `TodoItem`. The readhandlers project every entry through this, so the id is sourced from the key —never stored twice.---fn view(id: String, s: Stored) -> TodoItem { TodoItem { id: id, seq: s.seq, title: s.title, done: s.done }}
---One user's todo list. Keyed by `UserId`, so a call always addresses the caller'sown list (a Cloudflare Durable Object per user). State is a storage `Map` of itemsplus a monotonic sequence counter. A `Map` has no intrinsic order, so each itemcarries a `seq` and the read handlers `sortBy` it to recover insertion order; thecounter zeroes to `0`, and a `Map` field needs no initialiser.
Reads are `Query[T]` over the map. `all`/`pending` walk `items.entries` — thekey-exposing query — so each entry sees both its id (the key) and its storedfields, then `view` rejoins them; `pendingCount` is a storage aggregate over thevalues that never materialises the list.---agent Todos { key owner: UserId
store items: Map[String, Stored] store lastSeq: Cell[Int]
on call add(title: Title) -> Effect[TodoItem] { let next = lastSeq + 1 let id = "\(next)" let item = Stored { seq: next, title: title, done: false } let _ <- items.put(id, item) lastSeq := next view(id, item) }
on call all() -> Effect[List[TodoItem]] { items.entries.sortBy((e) => e.value.seq).map((e) => view(e.key, e.value)).collect() }
on call pending() -> Effect[List[TodoItem]] { items.entries .filter((e) => e.value.done == false) .sortBy((e) => e.value.seq) .map((e) => view(e.key, e.value)) .collect() }
on call pendingCount() -> Effect[Int] { items.values.filter((it) => it.done == false).count() }
on call complete(id: String) -> Effect[Result[(), TodoError]] { let found <- items.get(id) match found { Some(it) => { let _ <- items.put(id, Stored { ...it, done: true }) Ok(()) } None => Err(NotFound) } }}
service api from http { on POST("/todos") (body: AddRequest) -> Effect[HttpResult[TodoItem]] by u: User { let item <- Todos(u.identity).add(body.title) Created(item) }
on GET("/todos") () -> Effect[HttpResult[List[TodoItem]]] by u: User { let items <- Todos(u.identity).all() Ok(items) }
on GET("/todos/pending") () -> Effect[HttpResult[List[TodoItem]]] by u: User { let items <- Todos(u.identity).pending() Ok(items) }
on POST("/todos/:id/complete") (id: String) -> Effect[HttpResult[String]] by u: User { let outcome <- Todos(u.identity).complete(id) match outcome { Ok(_) => NoContent Err(_) => NotFound } }}This example reaches Workers-only shapes (storage bindings, agents, or cron), so it runs with bynk dev rather than in the browser playground. See Install to get started.
How it works
Section titled “How it works”User is a Bearer actor whose UserId (a non-empty String) is minted at the
boundary and never forged downstream. That identity becomes the agent key:
Todos(u.identity) always addresses the caller’s own list — a Cloudflare Durable
Object per user. A todo’s Title is refined NonEmpty && MaxLength(200), so an
empty or oversized title is rejected before any handler runs.
State on agent Todos is a storage Map[String, TodoItem] plus a Cell[Int]
counter, lastSeq. A Map has no intrinsic order, so each TodoItem carries a
seq; the counter zeroes to 0, and a Map field needs no initialiser. add
increments lastSeq, builds the item, writes it with items.put, and assigns the
new sequence with :=. complete reads with items.get, and on a hit writes back
a copy with done: true using record-spread ({ ...it, done: true }), returning
Err(NotFound) otherwise — the error is a one-variant TodoError enum.
The reads are Query[T] over the map. all is sortBy(seq).collect(), pending
filters not-done items then sorts, and pendingCount is a storage aggregate —
filter(…).count() — that never materialises the list. The HTTP service maps each
handler onto a route: POST /todos, GET /todos, GET /todos/pending, and
POST /todos/:id/complete, the last translating Ok/Err into
NoContent/NotFound.