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
Reference: Reads and writes

Reads and writes

Counters and other one-step updates

When a new value depends only on the stored one, the database works it out in one step and nothing needs checking. A view counter is the usual case:

example
action view_video(video_id: Video.id) fails VideoNotFound -> Video {    return attempt Video.update(video_id) {        set: {            view_count: ViewCount(Video.view_count + 1)            last_viewed_at: clock.now        }    }}

On the right of set:, Video.view_count means the value stored at the moment of the write, while video.view_count would be the copy loaded earlier. Such an update takes the record's id instead of a loaded record. Its values may use only parameters, constants (Constants), clock.now and the record's stored fields, so no increment is ever lost.

The statement is UPDATE "video" SET "view_count" = ("view_count" + $1), ..., and no row is VideoNotFound.

The database works a stored field out only with + and -, which give the same result in SQL as in Jadpo (+ joining text is written ||). They must sit inside a construction the declared ranges prove: ViewCount(...) here, since a count of at least 0 plus 1 is at least 0. Anything else needs the record loaded first.

A decimal added to a stored field may not have more decimal places than the field's scale, since PostgreSQL would round the sum its own way (half away from zero) rather than Jadpo's. A literal counts by its own places and any other value by its type's scale. A value whose type sets no scale is refused (data.stored_decimal_scale).

Which isolation level an action runs at

Which isolation level an action runs at follows from what it does. You write nothing for it: the compiler decides it from the graph.

An action that loads no record, neither itself nor through any action it calls, runs at PostgreSQL's read committed level. Of two requests counting the same row at once, the second waits for the first, adds to its count and commits, and neither reruns.

An action that loads a record runs at repeatable read (Isolation levels). So a counter racing another request on a row it loaded reruns the action, as any concurrent change does.

An action loads a record when it:

  • reads one: get, or a query, with what it includes;
  • is handed one: a parameter whose type holds a record, such as video: Video, or a list or object holding one;
  • writes one back: an update, delete or transition of a record, rather than of its id;
  • runs a transition whose when: can refuse a visible record, since telling that refusal from a missing record takes a second statement, which must see the first one's snapshot.

A create, and an update, delete or other transition by id, counters among them, loads nothing: the store checks a reference a create sets in the same statement. Calling a function never loads a record.

jadpo check decides each action and fixture from these and the actions and fixtures it calls. It writes the level into the manifest as isolation (read_committed or repeatable_read), and the runtime begins the transaction at it (BEGIN ISOLATION LEVEL READ COMMITTED or REPEATABLE READ). An action another action calls joins that action's transaction at its level, which already counts what the callee does. Each action a test calls begins its own.

So view_video above runs at read committed. Two requests counting one video at once both commit at their first attempt, the second waiting for the first's lock and adding to its count. A counter in an action that read the video first still reruns the second. Both are checked live against PostgreSQL in crates/jadpo-cli/tests/writes.rs.

What runs outside an action, such as a read a test makes itself, migrations and purge, runs at repeatable read.

Markdown for agents: /docs/counters-and-other-one-step-updates.md.Built from Jadpo main at a707efb.