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 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'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` 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 } }}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 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.