Triggers
React to table changes.
A trigger is a small piece of code that runs itself whenever a table changes. Think of it as a database trigger, except it's an ordinary Helipod function, and Helipod runs it durably with no dropped events.
@helipod/triggers runs a handler you choose whenever documents in a table you're watching are
inserted, updated, or deleted. It fills the gap between mutations, which are synchronous and run
inside the writer's own transaction with no external effects, and
workflows, which are explicit and caller-initiated. A trigger reacts on
its own to something that already happened. Use it for denormalized counters, audit logs,
notification fan-out, kicking off a durable workflow, or syncing to an external system.
Opt-in component
Like @helipod/scheduler and
@helipod/workflow, triggers are an opt-in component. There's no CLI
init command that installs it for you. You compose it in helipod.config.ts;
examples/chat/helipod.config.ts is the reference pattern real projects copy from.
The core idea: a durable cursor over the log, not a queue
Every write in Helipod lands in an append-only MVCC log. That log is the change feed itself, so there's no separate queue for anything to fall out of.
A trigger owns one durable cursor row, cursorTs, recording how far it has read. A background driver
loop wakes on every commit, reads the committed revisions after the cursor for the trigger's watched
table (through a new engine seam, DriverContext.readLog), runs your handler with the batch, and only
then advances the cursor.
Because there's no separate queue table to lose anything from, a missed change is impossible by construction. The only failure mode a crash can produce is redelivering a batch that already ran but whose cursor advance never landed.
This is a meaningfully different guarantee from a typical "fire a webhook on write" feature. Nothing is ever silently lost, because there's nothing separate from the log to lose it from.
Enabling and configuring a trigger
import { defineConfig } from "@helipod/component";
import { defineTriggers } from "@helipod/triggers";
export default defineConfig({
components: [
defineTriggers({
messages: { handler: "notifications:_onMessage" }, // an internal mutation or action
users: { handler: "audit:_onUserChange", fromStart: true },
}),
],
});defineTriggers(opts) takes a plain object. Each key is a watched table's app-visible name (the name
as it appears in schema.ts), and its value configures that one trigger.
Prop
Type
A brand-new trigger, with no fromStart, starts at the log's current tip. It only sees changes
committed after it was configured, not the table's pre-existing rows. This is deliberate: silently
replaying an entire table through a newly added handler the first time it boots would be a
surprising, unbounded-cost default.
There's no ctx.triggers.* facade to call from a mutation. Unlike the scheduler, nothing in your own
function code ever invokes a trigger directly. The whole surface is this declarative config, plus the
triggers:resume mutation for un-pausing one (see Pausing and resuming
below).
The delivery contract
Read this before writing a handler.
Delivery is bounded at-least-once, in-order per document
Never coalesced. A crash between a handler succeeding and its cursor advance landing redelivers the
last batch's changes, possibly inside a larger batch if new commits landed in the meantime (the batch
boundary is not stable across redelivery), but every change's changeId is stable. A document written
twice before the cursor reaches it produces both revisions in the log, not one coalesced update. There
is no ordering guarantee across different triggers, and one delivery is in flight per trigger at a
time: a slow handler backs up only its own trigger's backlog, never another trigger's. Handlers must
be idempotent, or dedup on changeId.
In practice, your handler will occasionally see a change it already processed, after a crash, or (in a
multi-node deployment) a default-shard failover. Treat changeId as that change's permanent, stable
identity. Either make writing it twice harmless (an upsert keyed off something derived from the
document, so it sets a counter to a value rather than incrementing it), or explicitly check-and-skip a
seen changeId, as the audit-log handler below does.
changeId: the stable, redelivery-proof identity
changeId is "<table>:<id>:<ts>", the change's exact log coordinate. It never changes, regardless of
which batch a redelivery happens to place it in, which is exactly what makes dedup possible. Your
handler can't rely on stable batch boundaries, but it can always rely on this one field.
Edge case: delete then re-insert
Re-inserting a document id that was previously deleted is not classified as a fresh "insert". Its log
entry's prev_ts still points at the tombstone the delete left behind, so op reads "update", but
oldDoc reads back null (the tombstone), not the value the document held before the delete. If your
handler diffs oldDoc/newDoc, treat a null oldDoc on an "update" the same as a fresh document,
not as "nothing changed."
Handlers and the change batch
handler is an ordinary registered mutation or action, the same mutation/action builder from
./_generated/server you'd use for anything else, whose path is internal. Helipod's convention for
"internal" is a _-prefixed function or module-segment name somewhere in the path (for example
notifications:_onMessage, or a whole module named _internal), the same convention
@helipod/scheduler job targets use. There's no separate internalMutation/internalAction
factory. The _ prefix alone is what makes a function internal, meaning not directly client-callable.
This is enforced at boot, before the driver ever tries to dispatch to it. Three checks run, and the first violation found throws immediately with an instructive message:
- Unknown path. The configured
handlerdoesn't resolve to any registered function at all. - Non-internal path. The path resolves, but isn't
_-prefixed. Trigger handlers must not be directly client-callable. - Wrong kind. The path resolves and is internal, but is a
query, not amutationoraction.
@helipod/triggers: trigger "messages" references handler "notifications:_onMessage", which is
not a registered function. Check the path matches an exported mutation or action
(e.g. "notifications:_onMessage").The handler receives exactly one argument:
{ changes: LogChange[] }interface LogChange {
table: string; // the watched table's app-visible name
id: string; // the document's id, as a string
op: "insert" | "update" | "delete";
newDoc: unknown | null; // this revision's value (null for a delete)
oldDoc: unknown | null; // the prior revision (null for an insert, see the tombstone edge case below)
ts: number; // this revision's commit timestamp
changeId: string; // "<table>:<id>:<ts>", this change's stable, immutable identity
}Writing a handler: mutation or action
A handler is either kind. Pick based on whether the reaction is itself a write, or an effect outside the transaction.
A mutation handler runs inside its own transaction, exactly like any other mutation. It has ctx.db,
it's OCC-replayed on conflict, and any writes it makes are immediately visible and fan out reactively
to any live subscription that overlaps them (the chat example's counter and audit-log pattern):
import { mutation } from "./_generated/server";
import type { LogChange } from "@helipod/component";
export const _onChange = mutation<{ changes: LogChange[] }, null>({
handler: async (ctx, { changes }) => {
for (const change of changes) {
// Idempotency: dedup on changeId (see "The delivery contract" above). A redelivered
// change is a no-op, not a duplicate audit row.
const dup = await ctx.db.query("auditLog", "by_changeId").eq("changeId", change.changeId).take(1).collect();
if (dup.length > 0) continue;
await ctx.db.insert("auditLog", {
changeId: change.changeId,
table: change.table,
docId: change.id,
op: change.op,
});
}
return null;
},
});This is exactly examples/chat's _onChange handler, a durable audit log on the messages table,
driven entirely by defineTriggers({ messages: { handler: "audit:_onChange" } }) in that example's
helipod.config.ts. Nothing in messages.ts's own mutations is aware the handler exists.
Reach for a mutation handler when the reaction is itself a write your app should see reactively:
denormalized counters, an audit table, chaining into ctx.workflow.start.
An action handler works the same way, but runs outside the transaction: no ctx.db, native
fetch/Date/clock, exactly like any other action. This is the shape
for notification fan-out or syncing to an external system:
import { action } from "./_generated/server";
import type { LogChange } from "@helipod/component";
export const _onMessage = action({
handler: async (_ctx, { changes }: { changes: LogChange[] }) => {
await fetch("https://hooks.example.com/new-message", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ changes }),
});
},
});Reach for an action handler when the reaction is an external effect, like a webhook, an email, or a third-party API call, that has no place inside a transaction.
How the driver decides what to deliver: the durable cursor loop
Under the hood, @helipod/triggers runs one independent event loop per configured trigger. Each
trigger name gets its own coalescing state, so a slow handler on one trigger can never
head-of-line-block another trigger's backlog. They're separate promise chains, woken by the same
commit fan-out.
Each trigger has two wake sources, with no fixed-interval polling driving normal delivery:
- Reactive. The engine's commit fan-out (
DriverContext.onCommit) wakes a trigger whenever a commit's touched tables include its own watched table, and only then. The subscription deliberately ignores the triggers component's owncursorstable entirely: an externaltriggers:resumecall is noticed by the periodic backstop beat instead, within about 30 seconds (see Failure handling below). - Timer. A failed delivery arms a single retry timer for that one trigger, at an exponential
backoff delay (
@helipod/scheduler'scomputeBackoff).
A trigger's own routine cursor-bookkeeping writes (_advanceCursor/_recordFailure/_pause) do not
themselves re-wake the loop. If they did, a trigger would perpetually rediscover its own housekeeping
as new work and spin forever. Only a real write to the watched table wakes it reactively.
On every wake, the driver drains as much as it can. It peeks the log's current stable bound once at the
start of the attempt, then loops readLog({ afterTs: cursorTs, tables: [name], limit: batchSize }),
advancing the cursor after each successful delivery (or immediately, at no cost, across a stretch of
the log with no matching changes at all), until it reaches that bound, a delivery fails, or the breaker
or max-failures trips. A wake that lands mid-drain doesn't get silently swallowed: the loop notices and
takes one more full pass with a freshly re-peeked bound before it actually goes idle.
A brand-new trigger's very first pass needs to know where "now" is before it can seed its cursor at the
tip. It peeks the log's bound before creating its own cursor row, not after, because _initCursor is
itself a write. A fresh peek taken after that write landed would see the trigger's own housekeeping
insert and mistake it for one more change to catch up to: a self-chasing loop that never settles.
Capturing the bound once, up front, and reusing that exact value for both the cursor seed and the
pass's drain target sidesteps this entirely.
readLog's "how far can I safely read" bound is not simply the highest committed timestamp in a
multi-node (fleet) deployment. Per-shard commits can land at the driver out of order, so a naive
max-ts bound could let a cursor advance past a commit that's still in flight on another shard,
permanently skipping it once the cursor moves beyond it. Instead the bound is the stable log prefix:
in a fleet, the minimum frontier timestamp across shard leases; on a single node, simply the highest
committed timestamp. So a change above an in-flight gap elsewhere in the fleet is never surfaced
early, and never silently skipped later. This is what makes triggers safe to run unmodified under
sharding: correctness doesn't depend on which topology the driver happens to be running in.
Failure handling, pausing, and resuming
If a handler throws, the identical batch, the same changeIds, possibly widened by new commits that
landed in the meantime, is retried with exponential backoff. The cursor never advances past an
undelivered batch, which is exactly what makes redelivery, not loss, the failure mode. failureCount
persists on the trigger's cursor row (restart-safe); the in-memory backoff delay itself does not, so a
process restart mid-backoff retries immediately rather than waiting out the remaining delay, an
accepted, documented gap.
After 8 consecutive failures (MAX_CONSECUTIVE_FAILURES), the trigger pauses itself (state: "paused", pausedReason: "max-failures", with an operator-visible error log) rather than retrying
forever. A paused trigger stops consuming its backlog entirely until you call the triggers:resume
mutation, directly, or from the dashboard's function runner, which clears the failure count and flips
it back to "running".
Resuming isn't itself a write to the watched table, so the driver doesn't learn about it via the
reactive commit fan-out. Instead, a periodic backstop timer (BEAT_MS, 30 seconds by default) wakes
every configured trigger regardless, which is what notices an external triggers:resume call. A
resumed trigger is picked back up eventually, within about 30 seconds, not the instant
triggers:resume returns. (A host that stretches this backstop cadence, see DriverContext.backstopMs,
trades a slower resume pickup for fewer wake-ups. The documented cost is exactly that stretched
window, nothing else.)
triggers:cursors: inspecting trigger state
Every configured trigger's cursor is a row you can browse (in the dashboard's data browser, or via
triggers:_status, which returns every cursor row for operator introspection):
interface CursorRow {
_id: string;
name: string; // the watched table name
cursorTs: number; // how far this trigger has read
state: "running" | "paused";
failureCount: number;
lastError?: string;
pausedReason?: string; // "max-failures" | "circuit-breaker"
}Recursion and the circuit breaker
A trigger's own handler writing back to its own watched table is delivered like any other write. This is intentional, DB-trigger semantics: legitimate patterns need it, like recomputing a field, normalizing a value on write, or chaining a state machine forward. It also means a handler that unconditionally writes to its watched table on every delivery will recurse: each of its own writes becomes a new change it delivers to itself.
As a safety net, not a substitute for writing a well-behaved handler, each trigger counts its own
deliveries in a fixed tumbling window (the count resets when a window's 10 seconds are up, rather
than sliding continuously): default 1000 deliveries per 10-second window
(DEFAULT_MAX_DELIVERIES_PER_WINDOW / BREAKER_WINDOW_MS), configurable per-trigger via
maxDeliveriesPerWindow. Tripping it pauses the trigger with pausedReason: "circuit-breaker"
instead of spinning the node, recognizably distinct from a "max-failures" pause, because the handler
may have been succeeding on every single call. It was just running far too often. Fix the recursive
write pattern, then triggers:resume.
// The recursion footgun, deliberately: this handler writes its OWN watched table on every
// delivery, one row per call. With a low maxDeliveriesPerWindow the breaker trips well before
// this melts the node.
export const _spin = mutation({
handler: async (ctx, { changes }: { changes: LogChange[] }) => {
const last = changes[changes.length - 1]!.newDoc as { n: number };
await ctx.db.insert("loops", { n: last.n + 1 });
},
});defineTriggers({ loops: { handler: "app:_spin", maxDeliveriesPerWindow: 8 } });Once the breaker trips, the rest of the server keeps running normally. An unrelated mutation still commits fine; only the runaway trigger itself is paused.
Batching: batchSize and the byte budget
Each handler invocation receives at most batchSize changes (default 64, DEFAULT_BATCH_SIZE).
Three more rules shape a batch:
- A byte budget cuts a batch early. Once a batch's serialized size crosses roughly 1MB
(
BYTE_BUDGET), it's cut mid-batch, so one giant commit touching many documents at once can't blow up a single delivery into an unbounded payload. The cut is measured withJSON.stringify(...).length(UTF-16 code units, a ballpark, not an exact byte count). - A batch never splits a commit. A commit that touches multiple documents in the same watched table at once is either delivered whole or not yet, never half-delivered with the cursor advanced past the excluded half (which would silently and permanently skip them).
- A too-big commit still delivers. If the very first commit-ts group alone already exceeds the budget (one huge document, or many documents committed together), it's delivered whole and unbounded rather than stalling the trigger forever waiting for something smaller.
The cost of fromStart
Setting fromStart: true replays a table's entire existing history: every revision ever committed, in
commit order, through your handler. This is honestly documented as potentially expensive: on a large
table this can be minutes of catch-up, not seconds, throttled by the same batchSize/byte budget as
live delivery so it never becomes one unbounded delivery. Reach for it deliberately, for a new audit
table that needs to backfill from day one, for example, not by default. The default (no fromStart)
starts a brand-new trigger at the log's current tip precisely to avoid this cost by default.
Composition pattern: a trigger that starts a workflow
Triggers pair naturally with durable workflows: react to a data change by kicking off a durable, multi-step, resumable workflow, instead of doing the multi-step work inline in the handler itself.
import { defineConfig } from "@helipod/component";
import { defineScheduler } from "@helipod/scheduler";
import { defineWorkflow, workflow } from "@helipod/workflow";
import { defineTriggers } from "@helipod/triggers";
const fulfillOrder = workflow.define({
handler: async (step, { orderId }: { orderId: string }) => {
await step.runMutation("orders:_reserveStock", { orderId });
await step.runAction("payments:_charge", { orderId });
await step.runMutation("orders:_markFulfilled", { orderId });
},
});
export default defineConfig({
components: [
defineScheduler(),
defineWorkflow({ workflows: { "workflows:fulfillOrder": fulfillOrder } }),
defineTriggers({ orders: { handler: "orders:_onOrderChange" } }),
],
});import { mutation } from "./_generated/server";
import type { LogChange } from "@helipod/component";
export const _onOrderChange = mutation({
handler: async (ctx, { changes }: { changes: LogChange[] }) => {
for (const change of changes) {
if (change.op !== "insert") continue;
// ctx.workflow.start is idempotent-by-changeId here only if you make it so (e.g. checking
// a "workflowStarted" flag on the order): the same at-least-once guidance applies. This
// handler can, per the delivery contract above, see the same insert twice.
await ctx.workflow.start("workflows:fulfillOrder", { orderId: change.id });
}
},
});Every new order insert reactively kicks off a durable fulfillOrder run. The trigger is the reactive
edge (data change to workflow start), and the workflow is the durable multi-step body: retries,
resumability, and saga-style rollback if a step declares compensate, all coming from
@helipod/workflow itself, not from anything triggers-specific.
What triggers are not
- Not effectively-once. Delivery is bounded at-least-once. Co-committing a mutation handler's
cursor advance atomically with its own effects in one transaction would need a same-transaction
sub-call seam that doesn't exist yet. Dedup on
changeIdin the meantime. - Not a field-level or predicate filter. A trigger watches an entire table. If you only care about some changes (a specific field, a specific value transition), check inside the handler.
- Not ordered across tables. Only within one trigger's own watched table, and only within that one trigger: there's no cross-trigger ordering guarantee even on the same table.
- Not dynamically registerable at runtime. Like crons, a trigger is declared in
helipod.config.tsand fixed for the deployment's life. Adding or removing one needs a restart. - Not a substitute for the scheduler or workflows. A trigger reacts to data that already changed.
It doesn't schedule future work on its own (pair it with
ctx.workflow.start/ctx.scheduler.runAfterfor that), and it doesn't orchestrate multi-step retryable logic itself. That's what workflows are for.
Renamed or dropped watched tables
A cursor row is keyed by the table's name. Helipod deploys are additive-only (no table renames or
drops via helipod deploy), so this mostly can't happen in the ordinary flow. But if a table a
trigger watches does disappear from the schema (a local schema edit plus restart, say), there is no
name-resolution error: the trigger simply stops matching any changes for that name. It goes quiet
rather than pausing. Its cursor keeps advancing along with the log (a watched-but-nonexistent table
just never contributes a change to scan), so nothing is flagged as broken. If a trigger seems to have
stopped delivering, check that its watched table name still exists in the current schema.
Related
- Scheduling: the recurring-driver seam (
onCommitplus a wall-clock timer, coalescing, backoff) that triggers' own event loop is built on the same shape as. - Workflows: the durable multi-step body a trigger's handler commonly kicks off.
- Queries and Mutations: the reactivity model a mutation handler's own writes participate in exactly like any other write.