helipod
Components

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.

wakes readLog(afterTs: cursorTs) success throws A mutation writes to the watched table Trigger driver loop Batch of changes Your handler runs Cursor advances Retry with backoff

Enabling and configuring a trigger

helipod.config.ts
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:

  1. Unknown path. The configured handler doesn't resolve to any registered function at all.
  2. Non-internal path. The path resolves, but isn't _-prefixed. Trigger handlers must not be directly client-callable.
  3. Wrong kind. The path resolves and is internal, but is a query, not a mutation or action.
@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):

helipod/audit.ts
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.

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 own cursors table entirely: an external triggers:resume call 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's computeBackoff).

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.

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.

helipod/loops.ts
// 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 });
  },
});
helipod.config.ts
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 with JSON.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.

helipod.config.ts
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" } }),
  ],
});
helipod/orders.ts
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 changeId in 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.ts and 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.runAfter for 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.

  • Scheduling: the recurring-driver seam (onCommit plus 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.

On this page