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 && MaxLength(200)
---
The stored shape of a todo. The id is *not* here: it lives in the map key, so
there is no denormalised copy to keep in sync. `TodoItem` is the boundary shape
the 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 read
handlers 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'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`/`pending` walk `items.entries` — the
key-exposing query — so each entry sees both its id (the key) and its stored
fields, then `view` rejoins them; `pendingCount` is a storage aggregate over the
values 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
}
}
}
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 && 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.