When a write is refused
A write can be refused for two reasons, and a lifecycle transition for a
third, when its when: doesn't hold
(Transitions, above):
- The record isn't there. That reports the entity's not-found
failure,
TodoNotFound. It includes a reference that names a missing record: a badproject_idisProjectNotFound. - A database rule refuses it: a unique rule, or a
restrictedreference blocking a delete.
Map each rule a write can break in its conflict: block, keyed by the
rule: Project.name_per_owner (a named unique rule), User.email (a
unique field) or Todo.project_id (a reference that blocks deleting a
project). The compiler knows which rules every write can break, so a
missing mapping is a compile error, and there is no catch-all. The
database's own error is kept as the failure's internal cause
(Failures).
Here creating a project can break the unique rule on its owner and name,
and closing one is blocked while todos still point at it. owner.id comes
from a loaded record, so the owner is surely there and the create can't
fail with UserNotFound:
action start_project(owner: User, name: Project.name) fails ProjectNameTaken -> Project { return attempt Project.create { set: { owner_id: owner.id // a loaded record's id: the owner is there name: name } conflict: { Project.name_per_owner: ProjectNameTaken } }}
action close_project(project: Project) fails ProjectHasTodos -> Unit { attempt Project.delete(project) { conflict: { Todo.project_id: ProjectHasTodos } }}
What a write can break follows from what it writes:
- a create: every unique rule of its entity;
- an update: the unique rules over the fields it sets;
- a delete: each
restrictedreference to the record, or to a record its delete cascades to.
A reference's value surely names a record that is there when it is read
from a record loaded earlier in the action (owner.id, or a loaded
record's own reference to that entity), or when it is none. Any other
value may name a record that isn't there, so the write can fail with that
entity's not-found failure, which attempt passes on. A loaded record is
surely there because the transaction's snapshot holds it, and a
concurrent delete of it is a concurrent change
(All-or-nothing actions and concurrent changes).
The runtime maps the store's constraint name back to the rule, since Jadpo names every constraint itself (Data).
A failed action inside another
An action whose failure its caller handles with match runs inside a
savepoint, a point in the transaction it can be rolled back to. When it
fails, everything it wrote is undone, a refused write among them, and the
caller carries on in a transaction the failure didn't end. An action is
all-or-nothing whoever calls it.
An action that passes its failure on with attempt needs no savepoint:
the failure ends its caller too, up to a match that undoes both, or up
to the outermost action, which rolls back.
Markdown for agents: /docs/when-a-write-is-refused.md.Built from Jadpo main at a707efb.