Consumers
Asynchronous Flows for Signal emissions, named Clock ticks, and SQL row changes.
Consumers are Flows that run when something else happens — a Signal emit, a named Clock tick, or a SQL row change — instead of waiting for an HTTP request.
For developers wiring background work on okengine — bind the trigger, keep do on fx.
The one rule
Bind with on(signal), on(clockDecl), or on(db.table(…).changed()). Delivery physics live on
the Signal; cron / every live on the Clock; CDC input is { before, after } plus table /
action / id. World access still goes through fx.
Smallest Example
Bind and emit are independent
The consumer and the producer share one Signal handle. They can live in different files, written in any order — emit is not "step 2" after bind.
Bind a Signal consumer
import { on, flow } from "okengine";
import { userSignedUp } from "@/signals";
import { welcomeEmail } from "@/channels/welcome";
export const sendWelcome = on(
userSignedUp,
flow("notifications.welcome", {
do: async ({ userId, email }, fx) => {
await fx.send(welcomeEmail, {
to: email,
data: { userId },
});
},
}),
);Emit from any Flow
await fx.emit(userSignedUp, { userId: "usr_123", email: "alice@example.com" });The compiler records emits: ["users.signed-up"] on the producer. The consumer runs after the
emit commits — the HTTP request does not wait for the welcome mail.
Jobs are consumers
A named Clock bound with on(clockDecl, flow) is the same species — an asynchronous Flow. There
is no separate job runner. See Clock jobs.
Progressive Patterns
Explore consumers from a typed queue worker to a cron job and a table-change handler:
Declare delivery physics on the Signal, then bind the worker with on(handle, flow):
import { signal } from "okengine";
import { z } from "zod";
export const emailTask = signal.once("tasks.email", {
schema: z.object({ to: z.string().email(), body: z.string() }),
retries: 3,
deadLetter: true,
});import { on, flow } from "okengine";
import { emailTask } from "@/signals/email";
import { rawEmail } from "@/channels/email";
export const processEmail = on(
emailTask,
flow("workers.email", {
do: async ({ to, body }, fx) => {
await fx.send(rawEmail, { to, body });
},
}),
);Trigger Reference
| Trigger | Signature | Purpose | do input |
|---|---|---|---|
| Signal | on(handle, flow("name", { do })) | Queue (once) or fan-out (broadcast) | Payload (schema) |
| Clock | on(clockDecl, flow("name", { do })) or inline clock.every(…) | Interval or cron tick | none (_) |
| CDC any | on(db.table(t).changed(), flow) | Every insert / update / delete | { before, after, table, action, id } |
| CDC column | on(db.table(t).changed("col"), flow) | Same writes; column stamped on the Manifest | { before, after, table, action, id } |
signal.live is an HTTP SSE tape — bind it with http.live,
not as a worker. A Flow with no trigger is call-only.
Signal is always an exported const (fx.emit needs the handle). Clock may write
clock.every(…) inside on() — Clock · Inline or named
export. OKE1072 if nameless.
Signal Consumers
Detailed section
If you only need a worker, jump to Once below. Physics live on signal.once / broadcast /
live — same idea as http.get / http.post. Defaults: retries: 3, deadLetter: true,
optional: false.
Each emit is handled according to the Signal helper you declared. The Flow is the subscriber.
Competing workers — exactly one consumer claims each message. Failed attempts retry, then
dead-letter when deadLetter is true (default).
Two different Flows on one once signal fail OKE1071 — see
Once · Competing consumers.
For every bound Flow to run, use signal.broadcast.
import { signal } from "okengine";
import { z } from "zod";
export const orderPlaced = signal.once("orders.placed", {
schema: z.object({
orderId: z.string(),
amount: z.number(),
userId: z.string(),
}),
retries: 5,
deadLetter: true,
});import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";
export const fulfill = on(
orderPlaced,
flow("orders.fulfill", {
do: async ({ orderId }, fx) => {
await fx.call(chargeAndShip, { orderId });
},
}),
);Visibility lease defaults to 30s. An inflight worker that dies is reclaimed on the next claim.
Optional second argument to signal.once / broadcast / live. Delivery is the helper name.
| Option | Type | Default | Meaning |
|---|---|---|---|
schema | Standard Schema | omitted | Enforced at fx.emit (OKE1250 on mismatch) |
retries | number | 3 | Extra attempts after the first (retries + 1 total) |
deadLetter | boolean | true | Keep exhausted once messages; false marks them delivered |
optional | boolean | false | Allow emit with zero subscribers |
retention | { maxAge?, maxCount? } | unbounded | signal.live only — type error on once / broadcast |
description | string | the name | Console / docs blurb |
Pass { key } on emit. No two once messages sharing (signal, key) are claimed at once.
await fx.emit(emailTask, payload, { key: user.id });Same key → FIFO. Different keys run in parallel. Omit key for a pure competing pool.
once retries then DLQ. After retries + 1 handler invocations the message is dead when
deadLetter: true. Inspect with fx.deadLetters(signal).
deadLetter: false marks the message delivered after the last attempt — nothing lands in the DLQ.
Broadcast does not use the once lease / DLQ path. Live uses the retained tape, not this worker.
Invalid payloads fail at fx.emit with OKE1250 ("{resource}": {detail}) before any consumer
runs. This is an emit contract — workers inherit the payload from the Signal; they do not
declare in on flow().
Clock Jobs
Detailed section
Prefer named helpers (clock.every / daily / cron). Bind with
on(clockDecl, flow("name", { do })), or write clock.every inside on() —
Clock · Inline or named export.
Named clocks reconcile into the Store at boot. The scheduler leader-elects so N instances do not
double-fire. do receives no payload — read time through fx.clock.
Human durations: "200ms" · "30s" · "5m" · "1h" · "7d" (integer + unit, no weeks):
import { clock } from "okengine";
export const pingClock = clock.every("health.pingExternal", "30s");import { on, flow } from "okengine";
import { pingClock } from "@/clocks/health";
export const pingExternal = on(
pingClock,
flow("health.pingExternal", {
plane: "operator",
do: async (_, fx) => {
await fx.call(pingUpstream);
},
}),
);Second argument to clock(name, options) / clock.perTenant(name, options).
| Option | Type | Default | Meaning |
|---|---|---|---|
cron | string | — | Five-field cron (m h dom mon dow) |
every | string | — | Interval ("10s", "1h", "7d", …) |
timezone | IANA string | "UTC" | Zone for cron (intervals are duration-based) |
overridable | boolean | false | Console may override the schedule in the Store |
perTenant | boolean | false | Expand {name}#{tenantId} rows |
description | string | the name | Console / docs blurb |
At least one of cron or every is required.
Dev/prod clock driver is postgres (test is frozen). A short lease (default 30s) means only one instance runs each tick.
Consequence: three pods calling runNow still execute the Flow once. After the lease
expires, another instance may take the next tick.
file (.oke/crons.json) elects across processes on one machine. memory is single-process.
Catch-up is "one": after 5 hours down on an hourly clock, the next tick fires once.
Missed slots are visible as missedRuns — they are not replayed as a burst.
Consequence: a digest that missed the night still runs once at boot, not 24 times.
Cron + a DST zone that lands in a spring-forward gap or fall-back overlap attaches a
warning on the Store row (gap / overlap). UTC never warns. The scheduler still ticks.
overridable: true lets Console edit the effective cron/every. Without it, a Console edit
fails with ScheduleNotOverridableError. Removed declarations become orphaned rows and
do not fire.
CDC
Detailed section
If you only need any-write, jump to Bare or enriched below. The handle is
db.table(table).changed(column?) — db is a store.sql declaration, table is a schema
handle. changed("insert") is not an op filter; it stamps a column named insert.
SQL writes through fx.store notify CDC after commit. The Flow input is always
{ before, after, table, action, id } (CdcPayload).
Bare or enriched
| Style | When |
|---|---|
({ before, after }) | Bound to one table — images are enough (search reindex, listing cache) |
({ table, action, id }) | Log, route, or branch — kind of change and which record (audit log) |
Both styles receive the same object. There is no second dispatch path, no performance difference, and no correctness difference — the choice is which fields this handler destructures.
{ table, action, id } are always populated; omitting them from do does not
drop them from the payload.
Bound to notes — the table is already in the trigger. Images decide upsert vs
drop; the row's id is on the surviving image:
import { on, flow } from "okengine";
import { db } from "@/core";
import { notes } from "@/schema";
export const reindexNotes = on(
db.table(notes).changed(),
flow("search.reindexNotes", {
plane: "operator",
do: async ({ before, after }, fx) => {
if (!after) {
await fx.call(dropNoteIndex, { id: String(before?.id ?? "") });
return;
}
await fx.call(upsertNoteIndex, { id: String(after.id) });
},
}),
);changed("status") stamps trigger.cdc.column on the Manifest. Still the same
payload — filter in do when you only care about that field:
import { on, flow } from "okengine";
import { db } from "@/core";
import { tasks } from "@/schema";
export const onStatus = on(
db.table(tasks).changed("status"),
flow("tasks.onStatus", {
plane: "operator",
do: async ({ before, after, id }, fx) => {
if (before?.status === after?.status) return;
await fx.emit(taskStatusChanged, {
id,
from: before?.status ?? null,
to: after?.status ?? null,
});
},
}),
);{ before, after } are always present. { table, action, id } are always populated — id is
the table's declared primary-key value, not a column assumed to be named "id". There is no
record field and no { op } (that stays on the live-query / outbox path).
Writes must go through fx.store. A raw SQL client bypasses the sink, so no consumer runs.
On RLS-capable SQL (postgres / pglite) the same write is appended to oke_cdc_outbox
for multi-host delivery. Pending backlog is a doctor finding (cdc_outbox_backlog).
Live queries (store.resource({ live: true }) / http.get(path).live(table)) share this
CDC path but classify per subscriber — see HTTP · Live Streams.
Execution
Consumers share the Flow species with HTTP. The differences are the trigger and how failure is retried.
| Kind | Start | Failure | Time |
|---|---|---|---|
Signal once | fx.emit | Signal retries then DLQ | fx.clock.now() |
Signal broadcast | fx.emit | Per-subscriber; no once DLQ | fx.clock.now() |
| Clock | scheduler tick | Flow retry if set; no catch-up burst | fx.clock.* |
| CDC | committed SQL write | Flow retry if set | fx.clock.now() |
Mark long work durable: true and wrap side effects in fx.step — see
Workflows.
Clock drivers: postgres in dev/prod, frozen in test. Signal drivers: redis in
dev/prod, memory in test.
Troubleshooting
clock(name) needs {cron} and/or {every}. Empty options throw at declaration, before on().
The first argument must be a Signal handle, a Clock handle, db.table(…).changed(), an HTTP
trigger, internal, or mcp.tool(…). A bare interval string is not a trigger — wrap it in
clock.every("name", "1h").
Cause: Flow "{flow}" is defined twice. Two flow("…") strings share a name. Give at least one a
distinct name.
Cause: A {kind} flow on "{trigger}" has no name.
Fix: pass an explicit name — on(handle, flow("unit.export", { do })).
Cause: Once signal "{signal}" is bound to more than one Flow ({flows}). Use signal.broadcast
if each Flow should independently receive this event, or bind only one Flow. See Once · Competing
consumers.
Cause: Flow "{flow}" emits signal "{resource}" with no subscriber. Add on(signal, flow) or
set { optional: true } on the Signal (live firehoses, unused hooks).
Cause: "{resource}": {detail}. The payload failed the Signal's Standard Schema at emit. Fix the
payload; the consumer never ran.
changed() takes an optional column name, not an op. changed("insert") waits for a column
named insert. Use changed() and branch on before / after being null.
Input is { before, after, table, action, id }. There is no record field. action is
"created" / "updated" / "deleted" from which image is null. { op } is live-query /
outbox only.
It should not. Catch-up is "one" — one fire per overdue clock, then nextRunAt advances. If you
see a burst, you likely bound several clocks (or clock.perTenant expanded many tenants), not a
replay of missed hourly slots.
Clock leader election needs a shared Store (drivers.clock postgres, or file on one host).
memory does not coordinate across processes. Check that both instances share DATABASE_URL.
The clock was declared without overridable: true. Add it and redeploy, or edit the declaration
in source instead of Console.
The field is timezone (IANA), default "UTC". { tz: "Asia/Riyadh" } is ignored.
Learn more
- Signal —
once/broadcast/livephysics - Signal · Once — leases, retries, partition keys
- Clock — schedules,
fx.clock.sleep - Clock · Inline or named export — one Flow vs shared schedule
- Store · SQL — tables CDC watches
- HTTP · Live Streams —
signal.liveSSE - fx —
fx.emit,fx.deadLetters,fx.clock - Errors — OKE1070 · OKE1071 · OKE1072 · OKE1240 · OKE1250
- Workflows —
durable: true+fx.stepon a consumer