Skip to content

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.

InterfaceMethodReceives
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.

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;
}
}

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.

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
}
}

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;
}
}

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.

Triggers live under automations/ in your configuration directory:

automations/
Issue/
ApplyDefaults.ts
GuardClosed.ts
Terminal window
polymesa deploy ./config --dry-run
polymesa deploy ./config