Triggers
A trigger is a class that implements one or more interfaces from
platform:triggers. Put it in automations/<Entity>/ and it runs whenever
records of that entity change — from the UI, the API, the CLI, or another
automation.
Triggers are bulk-first. Every method receives an array of records, never a single one. Write the loop yourself; do not assume a length of one.
The interfaces
Section titled “The interfaces”| Interface | Method | Receives |
|---|---|---|
BeforeInsert<T> | beforeInsert(newRecords) | rows about to be inserted |
AfterInsert<T> | afterInsert(newRecords) | rows just inserted |
BeforeUpdate<T> | beforeUpdate(newRecords, oldRecords) | incoming and current rows |
AfterUpdate<T> | afterUpdate(newRecords, oldRecords) | updated and previous rows |
BeforeDelete<T> | beforeDelete(oldRecords) | rows about to be deleted |
AfterDelete<T> | afterDelete(oldRecords) | rows just deleted |
before* hooks can modify or reject. after* hooks cannot — they return void.
Modifying records
Section titled “Modifying records”Return the array from a before hook and your changes are persisted. This is how
you apply defaults or derived fields.
import { BeforeInsert, BeforeResult } from "platform:triggers";import { Issue } from "platform:entities";
export default class ApplyDefaults implements BeforeInsert<Issue> { beforeInsert(newRecords: Issue[]): BeforeResult<Issue> { for (const issue of newRecords) { issue.priority ??= "P3"; issue.status ??= issue.assignee_id ? "assigned" : "new"; } return newRecords; }}Rejecting a write
Section titled “Rejecting a write”Return a ValidationResult instead of the array and the whole operation is
rejected. BeforeResult<T> is exactly that union: T[] | ValidationResult.
import { BeforeUpdate, BeforeResult } from "platform:triggers";import { Issue } from "platform:entities";
export default class GuardClosed implements BeforeUpdate<Issue> { beforeUpdate(newRecords: Issue[], oldRecords: Issue[]): BeforeResult<Issue> { const previous = new Map(oldRecords.map((r) => [r.record_id, r]));
for (const issue of newRecords) { if (previous.get(issue.record_id)?.status === "closed") { return { success: false, errors: [{ field: "status", message: "Closed issues cannot be edited." }], }; } } return newRecords; }}field is optional. Omit it for errors that are not about one field; the message
surfaces against the record as a whole.
beforeDelete is the exception: it returns void | ValidationResult, since
there is nothing to modify. Return nothing to allow, a ValidationResult to block.
Pairing old and new
Section titled “Pairing old and new”beforeUpdate and afterUpdate receive two arrays. They correspond by
record_id, not by index — build a map rather than trusting position.
const previous = new Map(oldRecords.map((r) => [r.record_id, r]));
for (const issue of newRecords) { const before = previous.get(issue.record_id); if (before && before.assignee_id !== issue.assignee_id) { // reassigned }}Combining interfaces
Section titled “Combining interfaces”One class can implement several. Useful when the same rule applies on create and edit.
import { BeforeInsert, BeforeUpdate, BeforeResult } from "platform:triggers";import { Issue } from "platform:entities";
export default class Normalize implements BeforeInsert<Issue>, BeforeUpdate<Issue> { beforeInsert(newRecords: Issue[]): BeforeResult<Issue> { return this.normalize(newRecords); }
beforeUpdate(newRecords: Issue[]): BeforeResult<Issue> { return this.normalize(newRecords); }
private normalize(records: Issue[]): Issue[] { for (const issue of records) issue.title = issue.title.trim(); return records; }}Querying from a trigger
Section titled “Querying from a trigger”Triggers can read and write via sql. Query once for the batch rather than
once per record — a query inside the loop turns one save into N round trips.
import { AfterInsert } from "platform:triggers";import { sql, tableRef } from "platform:stdlib";import { Component, Issue } from "platform:entities";
const co = tableRef(Component);
export default class NotifyOwners implements AfterInsert<Issue> { async afterInsert(newRecords: Issue[]): Promise<void> { const ids = newRecords.map((i) => i.component_id);
const { rows } = await sql.query<Component>(` SELECT ${co.component_id}, ${co.owner_id} FROM ${co} WHERE ${co.component_id} IN (${sql.delimit(ids)}) `);
const ownerByComponent = new Map(rows.map((c) => [c.component_id, c.owner_id])); // ... }}See Querying with SQL for tableRef and sql.delimit.
Deploying
Section titled “Deploying”Triggers live under automations/ in your configuration directory:
automations/ Issue/ ApplyDefaults.ts GuardClosed.tspolymesa deploy ./config --dry-runpolymesa deploy ./config