Skip to content

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.

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.

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.

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 insert or before update hook. An after hook, a before delete hook, and a form’s before save do not count;
  • the column has a value:, or it is an aggregate, a list, or a multi;
  • the engine writes the column itself: a slug, an unguessable, an img, 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.

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.

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.

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.

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.