Business rules
Some things must never happen, whoever tries: booking a full class, logging a workout in the future, a price below cost. A rule is a small check that runs on the server before a row is saved. If it says no, nothing is written and your user sees your message.
When to use it
- The condition depends on the data: capacity, dates, totals, who wrote first.
- It must hold for every write, including scripts that call the API directly.
Use accessfor "who may touch which rows", a rule for "what may be in them", and a workflowfor what should happen after a write (a workflow can't stop one).
Step by step
- Describe the rule to your agent in plain words, or add it to the entity's
ruleslist. - Give it an
id, when it runs (create,updateorboth), amessage, and acheck: the body of an async function. - Return
trueto allow,falseto refuse with the message, or a string to refuse with that string. - Check and apply. A rule that doesn't compile is reported by the check and the contract isn't applied.
Code
The booking app refuses a booking for a class that is full or has started:
"rules": [{
"id": "seats-left",
"on": "create",
"message": "That class is full. Try another time.",
"check": "const r = await ctx.db.query('select capacity, booked, starts_at from session where id = $1', [row.session_id]);\nconst rows = Array.isArray(r) ? r : (r as { rows?: Array<Record<string, unknown>> }).rows ?? [];\nconst s = rows[0];\nif (!s) return 'That class is no longer on the schedule.';\nif (new Date(String(s.starts_at)).getTime() < ctx.now().getTime()) return 'That class has already started.';\nreturn Number(s.booked ?? 0) < Number(s.capacity);"
}]The same check, formatted:
const r = await ctx.db.query(
"select capacity, booked, starts_at from session where id = $1",
[row.session_id],
);
const rows = Array.isArray(r) ? r : (r as { rows?: Array<Record<string, unknown>> }).rows ?? [];
const s = rows[0];
if (!s) return "That class is no longer on the schedule.";
if (new Date(String(s.starts_at)).getTime() < ctx.now().getTime()) return "That class has already started.";
return Number(s.booked ?? 0) < Number(s.capacity); // false: "That class is full."What the check gets
| Name | Value |
|---|---|
row | The row as it would be written (on update, merged with the patch). Owner and actor_ref fields are already set |
ctx.old | The current row on update, null on create |
ctx.user | { id, role } of the writer |
await ctx.me("member") | The writer's own row id in a persona table |
await ctx.db.query(sql, params) | One read-only SELECT, run as the writer |
ctx.now() | The current time |
What the user sees
HTTP/1.1 422
{ "ok": false, "error": { "code": "RULE_REFUSED", "message": "That class is full. Try another time." } }In a component, app.createrejects with that message. Catch it and show it, as the booking app's schedule does.
Gotchas
- Rules fail closed. A rule that throws, times out (3 seconds) or returns something odd refuses the write.
- Queries run as the writer, so a rule only sees rows the writer may read. Keep what it needs readable.
- No writes and no network inside a check: one SELECT, in a sandbox.
- Up to 6 rules per entity, run in order; the first refusal wins. Checks are up to 4,000 characters.
- Workflows don't run rules. Rules guard data-API writes; platform code acting for the app (workflows, seeds) skips them.
- Replace an entity's rules with
{ "op": "set_entity", "entity": "booking", "rules": [...] };[]removes them.