Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Learn Jadpo: all 14 pages

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/todos/entities/todo.jadpo:

jadpo
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: Uuid is the record's key. Every entity has one, and Jadpo makes a new one for each record, so you never set it.
  • title, status and due_at use the types from page 3.
  • created_at is 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/todos/types/todo_view.jadpo, changed. Replace the whole file.

jadpo
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/todos/actions/add_todo.jadpo, changed. Replace the whole file: new_view is gone, and add_todo stores the todo.

jadpo
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 except id and the generated ones. Leave one out and the compiler says which.
  • Todo.create gives back the record as stored, with its id and created_at.
  • var todo: Todo = ... names a value, like const in TypeScript: it never changes once set. Every name says its type. For a name you do want to change, Jadpo has var mut, which this path doesn't need.

Jadpo writes the SQL. jadpo sql add_todo shows it:

text
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/todos/routes/todos.jadpo, changed. The status: line is new.

jadpo
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:

shell
jadpo check

It reports the API record, which jadpo api update fixes, and something new. The "system tables" it lists are Jadpo's own, explained below:

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

shell
jadpo migrate new initial
text
  compared 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:

text
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:

text
checked: 9 files

Run it

shell
jadpo dev
text
jadpo 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:

shell
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"}'
text
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 /todos. 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.