Add business logic with hooks
Most rules belong in the model, where the compiler can check them and the engine can turn them
into SQL. A check, a require ... when, a unique(...), and a deny when all run without a
line of code.
A hook is for what the model cannot express: derive a value from text, refuse a delete with a counted reason, or send a mail after a write.
Bind a script to a write
Section titled “Bind a script to a write”A table binds hooks by event:
table posts "Posts" { display: title
title text required "Title" body markdown required "Content" words int derived "Words"
before insert -> logic/count-words.ts before update -> logic/count-words.ts}A form can bind its own:
form of members { fields: first_name, last_name, email actions: save after save -> logic/member-changed.ts}The events are before and after, each with insert, update, or delete on a table, and
save on a form.
Write the script
Section titled “Write the script”import { defineHook } from "@matterdata/sdk";
export default defineHook(async (ctx, row) => { const text = String(row.body ?? ""); row.words = text.split(/\s+/).filter((w) => w.length > 0).length;});A before hook receives the row that the write produces. Change a property to change what
is written.
Whatever the hook changes is validated again against every rule the submission already passed. A hook is not trusted by the write it runs in. If it changes a column the engine owns, the write is refused.
This includes a reference. If the hook sets a ref column to a record that does not exist, or
to a record the person saving could not choose, the write is refused with the message a typed
choice gets: “Choose one of the available values.” The message is on the field when the form
shows it, and on the form when it does not.
Mark a column that the hook writes
Section titled “Mark a column that the hook writes”Write derived after the type of a column that a before insert or before update hook
writes. The flag goes with the other flags, before the label:
words int derived "Words"reading_minutes int "Reading time" { value: round(ratio(words, 200), scale: 0, ties: up) }A computed column such as reading_minutes can be sorted, filtered, and exported. Its value is
correct only if the hook ran on every write that changed body. The flag records that you own
that recomputation.
The compiler cannot read the script, so it checks the declarations. It refuses the app when a
computed value: reads a column that has no other writer and no derived flag. A column has
another writer when it is in a form’s fields: and not in readonly:, in a sheet’s columns:,
in a set of a form or a transition, or when it has a default:. The message shows the line
with the flag added:
schema.mtd:11:3: column "words" of table "posts" feeds the computed column "posts.reading_minutes", and nothing declared writes it — no form field, sheet column, set or default — so the table's before hook is its only writer, and a value a hook writes is right only where that hook runs (decision 0002) if the before hook on "posts" writes it, write: words int derived "Words" — you then own recomputing it on every write path; if a person enters it, put it in a form's fields:If a person enters the value, put the column in a form’s fields: instead.
The compiler also refuses derived in these cases:
- the table has no
before insertorbefore updatehook. Anafterhook, abefore deletehook, and a form’sbefore savedo not count; - the column has a
value:, or it is an aggregate, alist, or amulti; - the engine writes the column itself: a
slug, anunguessable, animg, or the lifecycle state.
A form without a fields: list does not show a derived column, because the hook would
overwrite the value that a person types. To show it, name it in fields:. Add it to readonly:
as well if people must not edit it.
The compiler cannot see a column that a form writes and the hook also changes. The engine sees
it when the hook runs. If a before hook changes a column that a computed value: reads, and
the column has no derived flag, the engine writes a warning to its log. The warning names the
table, the column, the script, and the computed columns that read it. The save still succeeds.
Add derived to the column to show that you own the recomputation.
Refuse a write
Section titled “Refuse a write”Throw to cancel a before hook. The message is shown to the person who pressed save.
import { defineHook } from "@matterdata/sdk";
export default defineHook(async (ctx, row) => { const n = await ctx.db.count("members", { fee_group_id: row.id });
if (n > 0) { throw ctx.error(`${n} members still use this fee group, so it cannot be deleted.`); }});The foreign key refuses this delete anyway. The hook turns a database error into a sentence a treasurer can act on.
What a hook can reach
Section titled “What a hook can reach”ctx.db is table-shaped and never a SQL string:
| Call | Returns |
|---|---|
ctx.db.get(table, filter) |
one row, or nothing |
ctx.db.query(table, filter, limit) |
rows |
ctx.db.count(table, filter) |
a number |
The table name is resolved against the app’s own model, so a hook cannot reach the engine’s own tables. Every filter value is bound, never pasted into text.
An after hook also gets ctx.mail, which writes to the outbox.
A write by a visitor with no login may mail only the organization’s own logins. On such a write
ctx.user is null, and ctx.mail.send accepts a recipient only when it is the address of a
login that is not disabled. Any other address is refused as a capability denial: nothing is
queued, and the refusal is audited without the address. Otherwise a public form that mails
row.guest_email would let anybody make your organization send mail to anybody. A visitor’s
own submission cannot be confirmed by mail yet (see Project status), so
return early when ctx.user is null:
export default defineHook(async (ctx, row) => { if (!ctx.user) return; // …mail the confirmation});There is no ctx.http. A hook cannot call the network.
CAUTION: A hook runs with the application’s authority and not the reader’s. ctx.db applies
no row filter, no application scope and no sensitive grant. It sees every row of the table and
every column of the row. That is deliberate here, because a treasurer who cannot see a member must
still be stopped from deleting that member’s fee group. Two consequences follow. Never use a
hook’s result to decide what a person may see. And treat anything a hook puts into a message, a
mail or an amended column as disclosed, because ctx.mail.send takes any recipient you give it
when somebody is logged in.
See Security model.
When each hook runs
Section titled “When each hook runs”A before hook runs inside the write’s transaction, after a deny when has passed. If it
throws, nothing is written.
An after hook runs after the commit, in its own transaction. It cannot roll the write
back and cannot make a successful save look failed. Use it for a notification, not for a rule.
Budgets
Section titled “Budgets”Each hook call gets its own budget: 100 ms of wall clock, then a 300 ms grace period, a memory allowance, and at most 50 database calls. A script that exceeds a budget is stopped and the write is refused.
Write a regular expression with . and the s flag rather than [\s\S]. The sandbox cannot
translate \s inside a character class for the linear-time engine, so [\s\S] reaches an
engine that no time budget can stop. The deploy check refuses it and names this repair.
Status: a .ts file with TypeScript type annotations does not run yet. Bundling is a
placeholder until the deploy command lands. Write plain JavaScript syntax in a .ts file for
now.