ElementsSignal

Overview

Data in motion — once, broadcast, and live SSE tapes under one typed declaration with HTTP-shaped delivery helpers.

Signal is how your backend moves data when the producer should not wait. An email job, a cache bust across instances, and a browser status feed share one handle shape — only the helper changes: signal.once, signal.broadcast, or signal.live.

For developers wiring async work on okengine — declare the physics, emit with fx.emit, bind workers with on(handle, flow("name", { do })).

The one rule

Declare every signal with signal.once, signal.broadcast, or signal.live as an exported const. Bind with on(handle, flow("name", { do })). Physics and the emit schema live on the Signal; the worker inherits the payload type, not flow.in.

delivery — pick the physics

required · no default
  • once

    Queue — competing consumers

    Exactly one claims · retries + DLQ

    At-least-once jobs: emails, payment sync (idempotent consumers)

  • broadcast

    Pub/sub — every subscriber

    Each subscriber id gets its own copy

    Cache invalidation, cross-service events

  • live

    Stream — retained feed

    Late bus.live() replays full history

    Status feeds, progress — server-side today

Smallest Example

One handle, three independent uses

orderPlaced is a shared const. Declare it, bind a worker, and emit from a producer — different files, different people, any order. None of these is a prerequisite step for the others.

Declare

src/signals/orders.ts
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,
});

Bind a worker

src/flows/orders/fulfill.ts
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 });
    },
  }),
);

Emit

// Inside any Flow — need not live next to the worker:
await fx.emit(orderPlaced, {
  orderId: "ord_99",
  amount: 150,
  userId: "usr_1",
});

The compiler records emits: ["orders.placed"] on the producer. The HTTP request does not wait for the worker.

Live is not a worker

signal.live is an HTTP SSE tape. Expose it with http.live — do not bind on(liveSignal, flow) as a competing consumer.

A string fx.emit("name", payload) still runs (runtime schema still applies) but does not type-check. Import the exported const.

Progressive Patterns

Same helpers + fx.emit from a queue job to a fan-out and a browser feed:

Competing workers — exactly one claims each message. Failed attempts retry, then dead-letter when deadLetter is true (default):

src/signals/email.ts
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,
});
src/flows/workers/email.ts
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 });
    },
  }),
);

Delivery Physics

HelperPatternConsumerReplayTypical use
signal.onceWork queueCompeting workers, lease lockRetries + DLQEmails, fulfillment, sync
signal.broadcastPub/subEvery active subscriberNone (miss if offline)Cache invalidation, fan-out
signal.liveSSE tapeHTTP clients via http.liveLast-Event-ID resumeStatus feeds, progress

Competing consumers (once vs broadcast)

This is the most common mix-up. signal.once is a work queue: exactly one worker claims each message. It is not fan-out.

Three differently-named Flows bound to the same once signal, then one emit:

src/flows/orders/side-effects.ts
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";

export const charge = on(
  orderPlaced,
  flow("orders.charge", {
    do: async ({ orderId }, fx) => {
      await fx.call(chargeOrder, { orderId });
    },
  }),
);
export const ship = on(
  orderPlaced,
  flow("orders.ship", {
    do: async ({ orderId }, fx) => {
      await fx.call(shipOrder, { orderId });
    },
  }),
);
export const notify = on(
  orderPlaced,
  flow("orders.notify", {
    do: async ({ orderId }, fx) => {
      await fx.call(notifyOrder, { orderId });
    },
  }),
);
await fx.emit(orderPlaced, { orderId: "ord_99", amount: 150, userId: "usr_1" });
ExpectationReal result
All three Flows runNo. Exactly one of the three runs
Always orders.charge (first on)No. The winner is whichever claim lands first — not a fixed Flow
A sticky assignment to one FlowNo. There is no owner — only an exclusive claim per message

Consequence: if every bound Flow should independently receive its own copy, that is signal.broadcast — switch the declaration. That is the correct fix for this exact mistake.

Two or more different Flow definitions on the same once signal fail OKE1071.

Cause: Once signal "{signal}" is bound to more than one Flow ({flows}).

Load-balancing competing consumers is the same Flow on many process replicas — still one on() in source.

Fix: Use signal.broadcast if each flow should independently receive this event, or bind only one flow if these should compete for the same work.

The Capabilities of Signal

Options Reference

Optional second argument to signal.once / signal.broadcast / signal.live. Delivery is the helper name — not an option.

OptionTypeDefaultMeaning
schemaStandard SchemaomittedEmit contract — enforced at fx.emit (OKE1250 on mismatch). Workers inherit the payload; do not put in on flow().
retriesnumber3Extra attempts after the first (retries + 1 total) — once path
deadLetterbooleantrueKeep exhausted once messages; false marks them delivered
optionalbooleanfalseAllow emit with zero subscribers
retention{ maxAge?, maxCount? }unboundedsignal.live only — type error on once / broadcast
descriptionstringthe nameConsole / docs blurb

Consequence: deadLetter is a boolean flag, not a queue name string.

Emit through fx

Detailed section

If you only need fx.emit(signal, payload), jump to the table. Emit commits the outbox when the call resolves. The producer run id is stamped as parentRunId for Console trace chains.

CallRecordsUse
fx.emit(signal, payload?, { key? })emitsPublish; { key } serializes once per key
fx.deadLetters(signal)reads signal:<name>Inspect exhausted once messages
fx.live(signal, { match? })reads signal:<name>Server SSE body for a live tape

Invalid schema payloads fail at emit with OKE1250 ("{resource}": {detail}) before any consumer runs. Cross-signal fx.deadLetters / fx.live without a declared read throws OKE1001.

await fx.emit(orderPlaced, { orderId: "ord_1", userId: "usr_1" }, { key: "usr_1" });

Omit key for a pure competing pool with no ordering.

Per-environment drivers

Protocol ids: memory · redis · postgres · nats. Defaults (when oke.config.ts omits drivers.signal):

EnvDefaultBoot today
devredisEmit relays to Redis; consume / live / drain use a process-local outbox
testmemoryIn-process bus
prodredisSame redis honesty as dev
oke.config.ts
export default defineConfig({
  drivers: {
    signal: { test: "memory", prod: "redis" },
  },
});

postgres and nats fail loud at boot until a native bind ships — never silently fall back to memory. Prefer memory for tests; pin redis when Compose provides Redis.

Troubleshooting

Learn more

  • Once — leases, retries, partition keys, DLQ
  • Broadcast — ephemeral fan-out
  • Live — SSE tapes and retention
  • Consumerson(signal) workers next to Clock / CDC
  • HTTP · Live Streamshttp.live exposure
  • fxfx.emit, fx.deadLetters, fx.live
  • Clientapi.live for browsers
  • Errors — OKE1070 · OKE1071 · OKE1072 · OKE1240 · OKE1250 · OKE1210

Next

On this page