The operations every entity gets
Every entity gets these operations, called through the entity's name, as
Todo.get(todo_id). Only actions in the entity's own domain may call the
ones that write. Other domains call that domain's public actions instead.
| Operation | Does |
|---|---|
Todo.get(todo_id) |
reads one visible record, or fails TodoNotFound |
Todo.create { set: { ... } } |
adds a record |
Todo.update(todo) { set: { ... } } |
changes fields of a record loaded earlier in the action |
Todo.update(todo) { patch: input } |
changes only the fields a request supplied |
Todo.update(todo_id) { set: { ... } } |
changes the record with that id in one step (counters, below) |
Todo.delete(todo) |
removes the record, or runs the lifecycle's delete transition |
User.disable(user) |
runs a lifecycle transition, named after it |
An update, delete or transition takes either the record the action
loaded earlier or its id. By id, it fails with the entity's not-found
failure when no record has that id.
An update gives its fields in set:, or the request's changes with
patch:, never both. when_supplied: goes with patch: (both are below).
A create and an update give back the record as stored; a delete gives
nothing. Each fills the fields the store owns: a new version 7 UUID as the
id on create (Ctx::new_id, from the invocation's seed), and the
operation's time in each generated field, on_create once and on_change
on every update.
Every write joins the action's transaction. In the compiler's graph of the
program, a writes edge runs from the action or function to the entity.
In this action, attempt User.get(owner_id) gives the user, or passes on
UserNotFound. Project.create sets every field but id and the
generated ones:
// domains/planning/actions/start_project.jadpoaction start_project(owner_id: User.id, name: Project.name) fails UserNotFound -> Project { var owner: User = attempt User.get(owner_id) // the user, or UserNotFound return Project.create { set: { owner_id: owner.id // every field but `id` and the generated ones name: name } }}
Transitions
A transition (Lifecycle) is an update of the
fields its set: gives. Like an update, it runs on the record loaded
earlier or by id, and gives back the record as stored; a delete
transition gives nothing. Its block takes only conflict:, since what it
changes is fixed in the lifecycle.
Like every update and delete, a transition reaches only a record that
visible: lets through, so a hidden record is the entity's not-found
failure. A when: that asks more than visible: can refuse a record that
is there. Map that refusal in conflict:, as you would a rule, keyed by
the transition. Here UserDisabled is what the action fails with when
disable's when: doesn't hold:
// domains/planning/actions/disable_user.jadpoaction disable_user(user_id: User.id) fails UserNotFound, UserDisabled -> User { return attempt User.disable(user_id) { conflict: { User.disable: UserDisabled // its `when:` doesn't hold } }}
The statement is UPDATE "user" SET "stage" = $1, "disabled_at" = $2 WHERE "id" = $3 AND "stage" = $4, with the stages bound as their column
holds them, 'disabled' and 'active'
(Every boundary through the contract).
When it changes no row, a second statement asks whether the user is there
and visible, which tells UserDisabled from UserNotFound.
jadpo sql disable_user shows both.
The not-found failure
Nothing else is an entity operation: Todo.fetch(todo_id) is
semantic.unknown_callee. Todo.title(value) still builds a value of the
field's type (Creating values).
TodoNotFound is the domain's failure of that name in failures/. When
the domain declares none, the compiler provides one: kind not_found,
public, with no fields, placed at the entity in the graph. When a declared
one has a field typed <Entity>.id, a read or write that fails fills it
with the id it looked for. A write that can't fail needs no attempt
(Attempt).
This domain declares no TodoNotFound, so the compiler provides it:
// domains/planning/actions/reschedule_todo.jadpoaction reschedule_todo(todo_id: Todo.id, due_at: DueAt) fails TodoNotFound, DueDateInPast -> Todo { var todo: Todo = attempt Todo.get(todo_id) // TodoNotFound: provided if (due_at <= clock.now) { reject DueDateInPast } return Todo.update(todo) { set: { due_at: due_at reminder_sent_at: none } }}
Patches from a request
A patch takes an Input whose fields are optional and match the
entity's. A field the request left out keeps its value, and one sent as
none is cleared (Values that may be missing).
when_supplied: sets further fields when a given field was supplied: here
a new due_at also clears reminder_sent_at. A patch with no fields
changes nothing and succeeds, as a JSON Merge Patch does, so there is no
empty: failure.
type TodoChanges = Input { pick: [Todo.title, Todo.due_at] optional: all}action edit_todo(todo: Todo, amendment: TodoChanges) -> Todo { return Todo.update(todo) { patch: amendment when_supplied: { due_at: { reminder_sent_at: none } } }}
The statement's text is fixed: each field of the input is written
CASE WHEN <supplied> THEN <value> ELSE <column> END, and an on_change
field changes only when a field was supplied.
A value built without one of its optional Input fields remembers that
the field was left out. Only a patch reads that, and the value still
equals one given none.
A field whose type can't be none (title above) can't be cleared, so
none given to it is the same as leaving it out, and the patch keeps the
stored value. That isn't an error, since passing an optional value on
into an optional field, such as a page's next cursor, is ordinary code.
Markdown for agents: /docs/the-operations-every-entity-gets.md.Built from Jadpo main at a707efb.