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:
- Wrap the call in your own retry policy inside the handler (
Effect.retry,pRetry, etc.). - Move the work into a reaction whose
actstarts a workflow — workflows have first-class retry semantics, and the reaction'sdedupeKeymakes 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 handlerThe 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
useSubscriptionhook 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.