Skip to content

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.

src/todos.bynk
context todos
---
A user proves who they are with a JWT. The verified `UserId` is *sealed* — minted
at the boundary, never forged downstream — and it becomes the agent key, so each
user 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 is
rejected at the boundary before any handler runs.
---
type Title = String where NonEmpty and MaxLength(200)
type TodoItem = {
id: String,
seq: Int,
title: Title,
done: Bool,
}
type AddRequest = {
title: Title,
}
type TodoError = enum { NotFound }
---
One user's todo list. Keyed by `UserId`, so a call always addresses the caller's
own list (a Cloudflare Durable Object per user). State is a storage `Map` of items
plus a monotonic sequence counter. A `Map` has no intrinsic order, so each item
carries a `seq` and the read handlers `sortBy` it to recover insertion order; the
counter zeroes to `0`, and a `Map` field needs no initialiser.
Reads are `Query[T]` over the map: `all` sorts, `pending` filters then sorts, and
`pendingCount` is a storage aggregate that never materialises the list.
---
agent Todos {
key owner: UserId
store items: Map[String, TodoItem]
store lastSeq: Cell[Int]
on call add(title: Title) -> Effect[TodoItem] {
let next = lastSeq + 1
let id = "\(next)"
let item = TodoItem { id: id, seq: next, title: title, done: false }
let _ <- items.put(id, item)
lastSeq := next
item
}
on call all() -> Effect[List[TodoItem]] {
items.sortBy((it) => it.seq).collect()
}
on call pending() -> Effect[List[TodoItem]] {
items.filter((it) => it.done == false).sortBy((it) => it.seq).collect()
}
on call pendingCount() -> Effect[Int] {
items.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, TodoItem { ...it, done: true })
Ok(())
}
None => Err(NotFound)
}
}
}
service api from http {
on POST("/todos") by u: User (body: AddRequest) -> Effect[HttpResult[TodoItem]] {
let item <- Todos(u.identity).add(body.title)
Created(item)
}
on GET("/todos") by u: User () -> Effect[HttpResult[List[TodoItem]]] {
let items <- Todos(u.identity).all()
Ok(items)
}
on GET("/todos/pending") by u: User () -> Effect[HttpResult[List[TodoItem]]] {
let items <- Todos(u.identity).pending()
Ok(items)
}
on POST("/todos/:id/complete") by u: User (id: String) -> Effect[HttpResult[String]] {
let outcome <- Todos(u.identity).complete(id)
match outcome {
Ok(_) => NoContent
Err(_) => NotFound
}
}
}
Open the full project ↗

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.

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 and 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.