Subscribers

Per-table post-commit reactivity via file convention. Default-exported defineSubscriber({ table, on, handler }) — fires AFTER commit, best-effort, fire-and-forget for async handlers.

Use a *.subscribe.ts file when you want code to run after every commit to a specific table — refresh a search index, emit an external notification, invalidate a cache, push to a worker queue. The file convention is parallel to *.startup.ts / *.cron.tsx / *.webhook.tsx: drop a file matching the suffix anywhere under apps/<api>/, default-export a defineSubscriber({...}), the framework discovers + binds it at boot.

Subscribers are deliberately best-effort + non-durable. For crash-safe async work — "a row changed, now run a workflow" — reach for a reaction instead.

File shape

// apps/api/subscribers/auditUserChanges.subscribe.ts
import { defineSubscriber } from '@voltro/runtime'

export default defineSubscriber({
  table: 'users',
  on:    'any',          // 'insert' | 'update' | 'delete' | 'any' | ['insert','delete']
  handler: async (event, ctx) => {
    ctx.log.info('user changed', {
      op: event.op,
      id: event.new?.id ?? event.old?.id,
    })
    // event.new — present on insert + update; null on delete
    // event.old — present on update + delete; null on insert
  },
})

The default export must be a defineSubscriber({...}) result. The file is identified by suffix (*.subscribe.ts / *.subscribe.tsx).

ctx.store — reading and writing back

ctx carries log, id, and store — the same store surface a handler uses, so a subscriber can act on the change it just saw:

export default defineSubscriber({
  table: 'orders',
  on:    'insert',
  handler: async (event, ctx) => {
    await ctx.store.insert('order_audit', {
      orderId:  String(event.new?.['id']),
      tenantId: String(event.new?.['tenantId']),   // explicit — see below
    })
  },
})

ctx.store is NOT tenant-scoped. A subscriber fires from the change stream, not from a request, so there is no subject to scope to and no tenant to infer. Reads see every tenant's rows; a write to a tenant() table without an explicit tenantId fails with TenantScopeViolation rather than landing in an arbitrary tenant. When your reaction is per-tenant, take the tenant from the row that changed, as above. This is the same posture a schedule's ctx.app.store has — both are post-request system work.

Writes are mixin-stamped exactly as a request-path write is: id scheme, timestamps, audit columns. Only the subject differs.

What fires when

The framework binds to the store's onChange channel. Subscribers fire after the transaction commits — the row IS persisted when your handler runs. This means:

  • You can read the just-committed row via event.new (it's the committed value).
  • You CAN'T reject the change — by the time the handler runs, the transaction has landed. For pre-write rejection, use table().validate(Schema).
  • No transaction boundary. Side effects you do in the handler are not rolled back if some later operation fails.

The on filter narrows by operation:

  • 'any' (default) — every insert/update/delete fires the handler
  • 'insert' / 'update' / 'delete' — single op
  • ['insert', 'delete'] — array of ops

Other-table events get filtered out before your handler sees them. The matcher does this at the dispatcher level so subscribers add zero hot-path overhead to writes that don't match their table.

Semantics — best-effort, fire-and-forget

Subscribers are non-durable by design:

  • A throw doesn't fail the request. The original mutation has already committed. The framework logs the failure (scoped to subscribe:<filename>) and the next event still arrives.
  • Async handlers are NOT awaited by the dispatcher. Fire-and-forget — a slow handler can't back-pressure the change stream. Errors propagate to the structured log via .catch(), but the change-emission path returns immediately.
  • No retry, no resume. If the process crashes mid-handler, the work is gone. Same if the network call inside the handler fails — there's no built-in retry policy.

If you need any of those properties (transactional, durable, retried), don't hand-roll the kickoff in the handler — a subscriber's ctx carries no workflow handle, deliberately. "A row changed → start a workflow" has its own primitive: a reaction. A *.reaction.tsx watches the same post-commit change stream and its act starts the workflow for you, behind mandatory guards:

// reactions/fulfillOrder.reaction.tsx
import { defineReaction } from '@voltro/runtime'

export default defineReaction({
  name:  'fulfillOrder',
  watch: { table: 'orders', on: 'insert' },
  act:   { kind: 'workflow', workflow: 'orders.fulfill' },   // the changed row is the payload
  guards: {
    // REQUIRED — a stable idempotency key, so the same change acts exactly once.
    dedupeKey: (event) => String((event.new as { id?: string })?.id ?? ''),
  },
})

The workflow body owns durability; the reaction owns the trigger and the guards. dedupeKey is not optional — an ungated reaction whose act writes back into the watched table self-triggers forever, so defineReaction throws at boot when it's missing. See Reactions for the when predicate, rateLimit, costBudgetUsd, and the act: { kind: 'agent' } target.

Why this and not a regular subscription?

A useSubscription('app', 'todos.list') is the right tool when the client wants live data — the framework pushes a delta over WebSocket and React re-renders. Subscribers are the server-side equivalent: when something on the server wants to react to a write — emit a notification, refresh an index, push to a queue — without round-tripping through a connected client.

What's reacting Use
The browser, to show fresh data useSubscription in a hook
A server-side action, every commit *.subscribe.ts (this)
A server-side action, eventually-consistent + durable *.reaction.tsx with act: { kind: 'workflow' }
A server-side action, in the same transaction Do the work inside the mutation handler

Decision tree — picking the right seam

What you want Use
In-transaction work tied to a write mutation handler
Reject the insert table().validate(Schema)
Derive a value at write time column.computed(row => ...) / column.default(() => ...)
Best-effort post-commit reactivity per row *.subscribe.ts (this)
Crash-safe post-commit reactivity *.reaction.tsx acting on a workflow
Periodically-refreshed pre-computed query *.aggregate.ts
Event ingestion + analytical aggregates Analytics sink

Common patterns

Refresh a search index

import { meiliClient } from '../lib/meili'

export default defineSubscriber({
  table: 'posts',
  on:    ['insert', 'update'],
  handler: async (event) => {
    if (!event.new) return
    await meiliClient.index('posts').updateDocuments([event.new])
  },
})

Mirror to an external system

export default defineSubscriber({
  table: 'organizations',
  on:    'insert',
  handler: async (event, ctx) => {
    if (!event.new) return
    const slug = event.new.slug as string
    // Best-effort sync; failure logs + continues
    await fetch('https://my-crm.example/sync', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ slug, name: event.new.name }),
    })
  },
})

Soft-delete cleanup workflow

Starting a workflow off a state transition is a reaction, not a subscriber — when expresses the transition, dedupeKey keeps it from acting twice:

// reactions/cleanupDeletedDocuments.reaction.tsx
import { defineReaction } from '@voltro/runtime'

export default defineReaction({
  name:  'cleanupDeletedDocuments',
  watch: { table: 'documents', on: 'update' },
  // Fire ONLY when deletedAt was just set — not on every other update.
  when:  (event) => event.old?.['deletedAt'] === null && event.new?.['deletedAt'] != null,
  act:   { kind: 'workflow', workflow: 'documents.cleanupDeleted' },
  guards: { dedupeKey: (event) => String((event.new as { id?: string })?.id ?? '') },
})

The workflow does the heavy lift (scrub child rows, notify owners, archive to cold storage); the reaction's job is just to detect the state transition and hand the row over.

Failure semantics in detail

The framework logs subscriber failures via the structured logger — scope subscribe:<filename> — so you can grep + filter with voltro logs --scope 'subscribe:*'. Operations the handler reaches that themselves emit logs (HTTP client, store calls) keep their own scope.

There's no retry. If a transient failure should be retried, do one of:

  1. Wrap the call in your own retry policy inside the handler (Effect.retry, pRetry, etc.).
  2. Move the work into a reaction whose act starts a workflow — workflows have first-class retry semantics, and the reaction's dedupeKey makes the trigger idempotent.

By design

The subscriber is a deliberately thin post-commit hook — the sharp edges below are choices, not gaps. When you outgrow them, the escape hatch is a reaction acting on a workflow (durable, retried) or handler-side logic.

  • One subscriber per file. Multiple defaults exported don't compose; pick the most natural file boundary (one cohesive concern per file).
  • Filter on on + table; per-row predicates live in the handler. A predicate like "fire only when the user's plan changed" is a one-line guard at the top of the handler — kept there rather than in a framework pre-filter so the matching rule sits next to the code that reacts to it.
  • No batching. Each commit fires its subscribers individually. If a hot write path needs batched network calls, batch inside the handler (rolling window, debounce) — the framework doesn't impose a batching window you'd have to fight.

Configuration

*.subscribe.ts files are discovered + bound automatically by voltro dev / voltro start. There's no app.config.ts flag — the file existing IS the registration.

Subscribers fire AFTER the mutation handler commits, which happens AFTER any plugin interceptMutation chain returns. They're at the very tail of the write path:

client → AuthMiddleware → plugin interceptors → mutation handler → commit → subscriber handler

The subscriber sees the post-commit row, which has been auto-stamped by audit/tenant/softDelete mixins, validated against table().validate(...) if set, and persisted to the database.

See also

  • Subscriptions — the CLIENT-side useSubscription hook for live query data (different concept, similar name).
  • Streams — transient element feeds; for crash-safe post-commit work, use a reaction that acts on a workflow.