Storing todos
Page 4 of 14 in Learn Jadpo
By the end you can keep every todo in PostgreSQL.
On this page
So far add_todo sends a todo back and forgets it. On this page each
todo is kept in PostgreSQL, and the route answers 201 Created with the
stored todo, its id and when it was made. You need PostgreSQL installed,
as page 1 said; you don't create a database.
An entity is a table with types
An entity is a kind of record the app stores. Think of it as a table, where each field is a column with a Jadpo type, so the rules you gave the type hold for what's stored too. The entity also says who may create, read, change and delete its records, and the compiler applies that to every query and write.
Entities have their own folder, entities/:
domains/:
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] }}
id: Uuidis the record's key. Every entity has one, and Jadpo makes a new one for each record, so you never set it.title,statusanddue_atuse the types from page 3.created_atis filled in with the time the record is made:generated: Generated.on_create. Code never sets it either.policy:says who may do what with a todo. For now, anyone may create one:Access.public, as on page 2. Page 12 changes this to each todo's owner.
Because the todo now carries its own rule, policy.jadpo from page 2
isn't needed any more. Delete it. An action takes its permission from
the entities it uses, which is what the page 2 error meant by "reaches".
The root policy.jadpo is for actions that use no entity, as
add_todo did on page 2.
Make the response from the entity
TodoView was a list of fields. Now that a todo is stored, the
response can name the entity's fields it sends instead:
domains/, changed. Replace the whole file.
use todos/entities { Todo }/// A todo, as the app sends it back.type TodoView = Output { pick: [Todo.id, Todo.title, Todo.status, Todo.due_at, Todo.created_at]}
pick: takes fields from the entity, with their types. The response
sends exactly these, so a field you add to Todo later stays private
until you pick it.
Write the record
Todo.create adds a record. Every entity gets it, without your writing
it:
domains/, changed. Replace the whole file: new_view is gone, and add_todo stores the todo.
use todos/entities { Todo }use todos/types { NewTodo, TodoStatus, TodoView }/// Adds an open todo.action add_todo(input: NewTodo) -> TodoView { var todo: Todo = Todo.create { set: { title: input.title status: TodoStatus.open due_at: input.due_at } } return TodoView { id: todo.id title: todo.title status: todo.status due_at: todo.due_at created_at: todo.created_at }}
set:gives every field exceptidand the generated ones. Leave one out and the compiler says which.Todo.creategives back the record as stored, with itsidandcreated_at.var todo: Todo = ...names a value, likeconstin TypeScript: it never changes once set. Every name says its type. For a name you do want to change, Jadpo hasvar mut, which this path doesn't need.
Jadpo writes the SQL. jadpo sql add_todo shows it:
write Todo.create INSERT INTO "todo" ("id", "title", "status", "due_at", "created_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id", "title", "status", "due_at", "created_at" writes: creates a Todo file: domains/todos/actions/add_todo.jadpo:6 sends: 1 statement $1: a new identity · uuid $2: input.title · text $3: TodoStatus.open · text $4: input.due_at · timestamptz $5: clock.now · timestamptz
clock.now is the current time, which page 5 covers.
Answer 201 Created
A route that makes something should say so. Add status: to the route.
Without it a route answers 200; page 7 uses no_content, for 204.
domains/, changed. The status: line is new.
use todos/types { NewTodo, TodoView }use todos/actions { add_todo }/// 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) }}
Write the migration
Check the project:
jadpo checkIt reports the API record, which jadpo api update fixes, and something
new. The "system tables" it lists are Jadpo's own, explained below:
error: No migration creates these entities' tables yet --> domains/todos/entities/todo.jadpo:4:8 |4 | entity Todo { | ^^^^ no table yet reason: `jadpo check` compares the database schema the entities ask for with the migrations in `database/migrations/`, and there are none: new entity `Todo`, new system table for circuit breakers, new system table for deliveries, new system table for handled deliveries, new system table for housekeeping, and 3 more. next: Run `jadpo migrate new initial` (agent) rule: migration.out_of_date help: https://jadpo.dev/docs/errors/migration.out_of_date
A migration is the SQL that changes the database to match your
entities, as with any ORM. You never write one: jadpo migrate new
compares the entities with the last migration and writes the next.
initial is the migration's name, saying what it is for.
jadpo migrate new initialcompared the entities with an empty database Todo + table todo new entity System tables + table jadpo_circuit_breakers system table for circuit breakers + table jadpo_deliveries system table for deliveries + table jadpo_handled_deliveries system table for handled deliveries + table jadpo_housekeeping system table for housekeeping + table jadpo_idempotency_keys system table for idempotency keys + table jadpo_operator_actions system table for operator actions + table jadpo_outbox system table for outboxWrote database/migrations/20261029T1000_initial/ snapshot.json the whole schema at this point postgres.sql 14 statements: create table, add unique rule, add reference, create indexnext: jadpo check confirms the migration matches the entities
The folder's name starts with the date and time you ran it, so yours
differs. It holds two files, both written by jadpo migrate new: you
commit them, and never edit them. postgres.sql is the SQL, and this
is the part for your todos:
CREATE TABLE "todo" ( "id" uuid NOT NULL, "title" text NOT NULL, "status" text NOT NULL, "due_at" timestamptz(3), "created_at" timestamptz(3) NOT NULL, CONSTRAINT "todo_pkey" PRIMARY KEY ("id"));
The tables starting jadpo_ are Jadpo's own. It keeps its bookkeeping
in your database, such as work waiting to be retried, so that it commits
or rolls back with your data. You don't use them directly.
snapshot.json is the whole schema as it stands after this migration;
the next jadpo migrate new compares against it.
Run jadpo api update for the record too, and jadpo check passes:
checked: 9 filesRun it
jadpo devjadpo dev, started 21:27:33listening on http://localhost:3000 routes 2 routes database disposable PostgreSQL, gone when dev stopsserving: saves reload, press ctrl-c to quit
jadpo dev started a PostgreSQL database of its own and ran the
migrations on it. It is thrown away when jadpo dev stops, so each run
starts empty. Add two todos:
curl -i -X POST http://localhost:3000/todos \ -H 'content-type: application/json' \ -d '{"title": "Buy milk"}'curl -X POST http://localhost:3000/todos \ -H 'content-type: application/json' \ -d '{"title": "Post the letter", "due_at": "2099-11-02T09:00:00Z"}'
HTTP/1.1 201 Createdcontent-type: application/jsonx-request-id: req_9d1edfda96680c0fcontent-length: 134date: Thu, 29 Oct 2026 21:54:17 GMT{"id":"01a189a8-1a08-779a-9a4b-9d7b9a671b80","title":"Buy milk","status":"open","due_at":null,"created_at":"2026-10-29T21:54:17.736Z"}{"id":"01a189a8-1a16-707d-bd55-ffa1a0560ec4","title":"Post the letter","status":"open","due_at":"2099-11-02T09:00:00Z","created_at":"2026-10-29T21:54:17.750Z"}
Your ids and times will differ. A due_at of none is sent as null.
What you have
Todos that are stored, with an id and a time each, and a migration
history you commit. You can't read them back yet: on page 6 you add
GET /. First, page 5 makes the app refuse a due date that has
already passed.
Read more in the reference: Entities and the id, Migrations and The operations every entity gets.
Markdown for agents: /learn/learn-storing.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.