Reading todos
Page 6 of 14 in Learn Jadpo
By the end you can answer GET /todos and GET /todos/{id}.
On this page
The app stores todos but can't show them. On this page you add
GET /, which lists them newest first and can show only the open
or only the done ones, and GET /, which fetches one.
A query is a named read
Jadpo has no hand-written SQL. A read is a query: a declaration that
says which entity to read, which records, in what order and how many.
The compiler turns it into SQL. Queries have their own folder,
queries/, one per file:
domains/:
use todos/entities { Todo, TodoNotFound }use todos/types { TodoView }/// One todo.query todo_by_id(todo_id: Todo.id) fails TodoNotFound -> TodoView { from: Todo where: Todo.id == todo_id missing: TodoNotFound}
todo_id: Todo.idis the query's parameter.Todo.idis a type of its own: a UUID that is a todo's id. So a query that wants aTodo.idcan't be handed some other record's id by mistake.from: Todois the entity it reads.where:picks the records, written like a condition in code:Todo.id == todo_id.- The result type says how many records come back.
TodoViewmeans exactly one, and when there's none the query fails with the failuremissing:names.TodoView?would mean one or none, and a list type means many, as below. TodoNotFoundis a failure every entity gets, without your declaring it: kindnot_found, so a404. You import it fromentities/, besideTodo.
A query only reads. It can't write, call an action, or read the clock.
A list, in order
The list takes a filter, which the caller sends in the query string:
domains/:
use todos/types { TodoStatus }/// What a caller may ask for when listing todos.type TodoFilter = Input { status: TodoStatus { optional: true }}
optional: true means the caller may leave status out. Unlike
default: none on page 3, it gives the field no value at all, which the
next query uses.
domains/:
use todos/entities { Todo }use todos/types { TodoFilter, TodoView }/// Todos, newest first, with only one status when the caller asks.query list_todos(filter: TodoFilter) -> List<TodoView> { from: Todo where_present: Todo.status == filter.status order: [Todo.created_at desc] limit: 100}
- A list result,
List<TodoView>, needsorder:andlimit:, so a list can never grow without bound by accident. For a list too long for one answer, a query returnsPage<TodoView>instead, a page at a time; the reference covers it. where_present:is a condition that applies only when the caller sent the field. With nostatus, every todo comes back.Todo.created_at descis newest first.
Routes that read
A GET route runs a query. A route that changes something, such as a
POST, runs an action:
domains/, changed. The two GET routes and their use lines are new.
use todos/entities { Todo }use todos/types { NewTodo, TodoFilter, TodoView }use todos/actions { add_todo }use todos/queries { list_todos, todo_by_id }/// The todo list.routes Todos { prefix: /todos /// Adds a todo. POST / { authentication: RouteAuthentication.none input: NewTodo output: TodoView status: SuccessStatus.created run: add_todo(input) } /// Lists todos, newest first. GET / { authentication: RouteAuthentication.none input: TodoFilter output: List<TodoView> run: list_todos(input) } /// One todo. GET /{todo_id: Todo.id} { authentication: RouteAuthentication.none output: TodoView run: todo_by_id(todo_id) }}
{todo_id: Todo.id} is a placeholder in the path, with its type. The
segment of the URL is checked as a Todo.id, a UUID, and handed to the
query by name.
Let people read todos
Check the project. Besides the record, two things need fixing. The first is permission: the entity says who may create a todo, but not who may read one, so the compiler refuses both new routes:
error: `GET /todos` reads `Todo` records, which nobody may do reason: Policy is always on: every create, read, update and delete a route reaches needs a grant, and `Todo`'s policy grants nobody `read`, so this route could never succeed. next: Add `read: [...]` to `Todo`'s `policy:`, naming who may read one (human)
(The same error follows for GET /, and each has its
code frame and help line, left out here.) Add read: to Todo's
policy:
domains/, changed. The read: line is new.
use todos/types { TodoStatus, TodoTitle }/// Something to do, kept in the database.entity Todo { id: Uuid title: TodoTitle status: TodoStatus due_at: Instant? created_at: Instant { generated: Generated.on_create } policy: { create: [Access.public] read: [Access.public] }}
The second is new:
error: The entities have changed since the latest migration --> domains/todos/entities/todo.jadpo:4:8 |4 | entity Todo { | ^^^^ changed reason: `jadpo check` compares the database schema the entities ask for with the latest migration's snapshot (`database/migrations/20261029T1000_initial/snapshot.json`): new index for query `list_todos`. next: Run `jadpo migrate new index_list_todos` (agent) rule: migration.out_of_date help: https://jadpo.dev/docs/errors/migration.out_of_date
You didn't change the entity, but the compiler saw that list_todos
sorts by created_at, and that a table of todos needs an index for
that to stay fast. So it asks for a migration that makes the index:
jadpo migrate new index_list_todoscompared the entities with 20261029T1000_initial Todo + index (created_at, id) for list_todosWrote database/migrations/20261029T1100_index_list_todos/ snapshot.json the whole schema at this point postgres.sql 1 statement: create indexnext: jadpo check confirms the migration matches the entities
The index includes id because the query's order does too: two todos
made at the same moment still come back in the same order every time.
jadpo sql list_todos shows the SQL the query sends:
query list_todos SELECT "id", "title", "status", "due_at", "created_at" FROM "todo" WHERE ($1 IS NULL OR "status" = $1) ORDER BY "created_at" DESC, "id" DESC LIMIT 100 reads: Todo, a list of TodoView file: domains/todos/queries/list_todos.jadpo:5 sends: 1 statement $1: filter.status · text
It reads only the columns TodoView sends. Run jadpo api update, and
jadpo check passes.
Try it
Run jadpo dev, add two todos as on page 4, then list them:
curl http://localhost:3000/todos[{"id":"01a18990-d0bd-7593-a195-4202a0086aed","title":"Post the letter","status":"open","due_at":null,"created_at":"2026-10-29T21:28:51.645Z"},{"id":"01a18990-d0a8-77e8-8892-8a07e884a2d6","title":"Buy milk","status":"open","due_at":null,"created_at":"2026-10-29T21:28:51.624Z"}]Only the done ones, of which there are none yet:
curl "http://localhost:3000/todos?status=done"[]One todo, by its id. Use an id from your own list:
curl http://localhost:3000/todos/01a18990-d0bd-7593-a195-4202a0086aed{"id":"01a18990-d0bd-7593-a195-4202a0086aed","title":"Post the letter","status":"open","due_at":null,"created_at":"2026-10-29T21:28:51.645Z"}An id no todo has, and one that isn't an id at all:
curl -i http://localhost:3000/todos/01a1898f-0000-7000-8000-000000000000curl http://localhost:3000/todos/42
HTTP/1.1 404 Not Foundcontent-type: application/jsonx-request-id: req_34931ef0e7674e96content-length: 94date: Thu, 29 Oct 2026 21:28:51 GMT{"error":{"code":"todo_not_found","message":"Not found.","request_id":"req_34931ef0e7674e96"}}{"error":{"code":"invalid_input","message":"Request input is invalid.","request_id":"req_c85d30a895414635","details":{"problems":[{"field":"todo_id","rule":"format"}]}}}
What you have
Four routes: add a todo, list them, filter them and fetch one. Next, on page 7, you change and delete todos.
Read more in the reference: Named queries, Filters and Order and pages.
Markdown for agents: /learn/learn-reading.md.Built from Jadpo main at a707efb. Every file on this page is compiled and tested by Jadpo's CI, on the app as the pages before it built it.