Live
Retained live event tapes streamed to clients over HTTP SSE with Last-Event-ID resume.
signal.live keeps a retained event tape and exposes it over Server-Sent Events. Emit with
fx.emit, and mount the feed with http.live (or a gated GET).
For developers shipping status feeds and progress UIs — declare the Signal, expose SSE, subscribe from the typed client.
The one rule
signal.live is an HTTP SSE tape — not a competing worker. Bind with
http.live. Do not use on(liveSignal, flow) as a
queue consumer. Prefer { optional: true } so emit succeeds when no client is connected yet.
live — retain, then replay
bus.live("order-status", …)placedretained stream
placedfulfillingshipped
late subscriber · not connected
———
Emits land in the retained stream even with no subscriber. A late bus.live() call replays every retained payload in order — then continues with new ones. Console monitor shows the newest 50 for display only.
Smallest Example
One handle, three independent uses
orderStatus is a shared const. Declare it, expose SSE, and emit — different files, any order.
The firehose is not "step 2"; emit is not "step 3".
Declare
import { signal } from "okengine";
import { z } from "zod";
export const orderStatus = signal.live("order-status", {
optional: true,
schema: z.object({
orderId: z.string(),
status: z.enum(["placed", "fulfilling", "shipped"]),
}),
});Expose SSE
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";
export const firehose = on(http.live(orderStatus).gate(member));Emit and subscribe
// Inside any Flow:
await fx.emit(orderStatus, { orderId: "ord_1", status: "shipped" });curl -N http://localhost:6530/_oke/live/order-status \
-H "accept: text/event-stream" \
-H "authorization: Bearer …"Response Content-Type is text/event-stream. Frames are JSON data: lines
(optional id: for resume), then data: [DONE].
Pathless firehose
on(http.live(signal)) always mounts GET /_oke/live/{name} — there is no pathless file-tree
stamp for live. Custom paths use http.get(path).live(signal). See Exposure.
Progressive Patterns
From a default firehose to filtered paths, retention, and the typed client:
on(http.live(signal)) mounts GET /_oke/live/{name}. Chain .gate(...) like any GET:
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";
export const firehose = on(http.live(orderStatus).gate(member));Signal names in the path are encodeURIComponent'd (chat.message stays readable).
Options Reference
Optional second argument to signal.live(name, options?). Delivery is the helper name — not an
option.
| Option | Type | Default | Meaning |
|---|---|---|---|
schema | Standard Schema | omitted | Emit contract — validated at fx.emit (OKE1250) |
optional | boolean | false | Allow emit with zero SSE clients / subscribers |
retention | { maxAge?, maxCount? } | unbounded | Prune the tape (live-only) |
description | string | the name | Console / docs blurb |
retries | number | 3 | Declared; live uses the tape, not once DLQ |
deadLetter | boolean | true | Declared; live does not use once DLQ |
Consequence: retention on signal.once / signal.broadcast is a type error — switch to
signal.live or drop the option.
Exposure
Detailed section
If you only need the default firehose, jump to the example below. .live(…) is GET-only —
on(http.post("/x").live(signal)) throws on(http.*.live(signal)): live exposure must be GET.
http.live(signal) is one-arg on() — the engine synthesizes the stream Flow
(fx.live + effects.reads: ["signal:<name>"]). Chain .gate(...) like any GET.
import { on, http, signal } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";
export const orderStatus = signal.live("order-status", {
optional: true,
schema: z.object({
orderId: z.string(),
status: z.enum(["placed", "fulfilling", "shipped"]),
}),
});
export const firehose = on(http.live(orderStatus).gate(member));curl -N http://localhost:6530/_oke/live/order-status \
-H "accept: text/event-stream" \
-H "authorization: Bearer …"Three GET shapes expose a live SSE body. Pick the physics first, then the path.
| Declaration | Path | Physics |
|---|---|---|
on(http.live(signal)) | GET /_oke/live/{name} | Signal tape — every event |
on(http.get(path).live(signal)) | Your path | Signal tape — auto-match on :params |
on(http.get(path).live(table), flow) | Your path | Live query — classified CDC rows |
store.resource({ live: true }) + http.resource | GET <path>/live | Same live-query physics |
Signal firehoses and resource live queries are different physics — see Live Queries below.
Path params become a filter: an event is forwarded when each :param that
exists on the payload equals the request value. Params missing from the
payload are skipped (the event still flows). No params = firehose.
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";
export const events = on(http.get("/orders/:orderId/events").gate(member).live(orderStatus));GET /orders/ord_1/events receives { orderId: "ord_1", status: "shipped" }
and drops events for other orders.
Pass your own Flow as the second argument to on() when auto-match is not
enough. Return fx.live(signal, { match }) from do — do not wrap it with
fx.json.stream.
import { on, flow, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";
export const vipFeed = on(
http.get("/orders/vip/events").gate(member).live(orderStatus),
flow({
do: (_input, fx) =>
fx.live(orderStatus, {
match: (payload) => payload.status === "shipped",
}),
}),
);Consequence: a custom Flow stamps a distinct match key, so it can coexist with the auto-match route for the same signal (different path). Two synthesized firehoses that share signal and gates fail uniqueness — see Uniqueness.
Resource / table live is not a signal tape. Each subscriber gets classified
row events (RLS + list filters). Prefer http.resource + { live: true } when
you already mount the five CRUD ops.
live on the resource | Result |
|---|---|
{ live: true } | Mount GET <path>/live now |
| omitted | Mount only if oke({ store: { live: true } }) |
{ live: false } | Never mount live for this resource |
Wire events (consumed with useLiveQuery on the typed client):
kind | Meaning |
|---|---|
upsert | Row visible under stamp + query — merge by primary key |
revoked | Row left visibility (reason: "rls" or "query") — remove |
delete | Row deleted — remove |
For a handwritten list, bind the table on GET and open the window with
liveQuery — full detail under HTTP · Live Streams.
Boot keys each live HTTP route as (signal, gates, match). Match is the
sorted path-param names, or custom:<flow> when you passed a Flow, or
(firehose) when there are no params.
| Pair | Boots? |
|---|---|
Member :orderId + admin firehose | Yes — gates and match differ |
| Same params, different gates | Yes — the client disambiguates with via |
| Two member firehoses on different paths | No — OKE1050 |
| Same method + path twice | No — OKE1041 first |
OKE1050 cause: Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).
Fix: a different gate, a path-param filter, or drop the extra route.
Emit through fx
Detailed section
If you only need fx.emit(signal, payload), jump to the table. Emit appends to the retained tape
when the call resolves. The producer run id is stamped as parentRunId for Console trace chains.
| Call | Records | Use |
|---|---|---|
fx.emit(signal, payload?) | emits | Append to the live tape |
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
client receives the frame. Cross-signal fx.live without a declared read throws OKE1001.
import { on, flow, http } from "okengine";
import { z } from "zod";
import { orderStatus } from "@/signals/orders";
export const ship = on(
http.post({
in: z.object({ id: z.string() }),
}),
flow({
do: async ({ id }, fx) => {
await fx.emit(orderStatus, { orderId: id, status: "shipped" });
return { id, status: "shipped" };
},
}),
);Zero SSE clients + optional: false → OKE1240. Cause:
Flow "{flow}" emits signal "{resource}" with no subscriber.
Fix: mount http.live (or a path .live) before emitting, or set { optional: true }.
Retention
Detailed section
If you only need an unbounded tape, skip this section. retention is live-only — omit both fields
(or omit retention) for unlimited history.
| Field | Type | Meaning |
|---|---|---|
maxAge | duration string ("24h", "30s", …) | Drop events older than this |
maxCount | integer ≥ 1 | Keep only the newest N events |
import { signal } from "okengine";
import { z } from "zod";
export const chatMessage = signal.live("chat.message", {
optional: true,
retention: { maxAge: "7d", maxCount: 10_000 },
schema: z.object({
room: z.string(),
text: z.string(),
author: z.string(),
}),
});Invalid values throw at declare:
signal.live("…"): retention.maxAge must be a duration like "24h" or "30s"
signal.live("…"): retention.maxCount must be an integer ≥ 1Consequence: a pruned id: becomes a resume gap — reconnects that still send that
Last-Event-ID hit OKE1210 / 410 LiveResumeGap. Prefer autoResubscribe: true on flaky
networks, or raise maxCount / maxAge if clients need longer catch-up.
Resume and gaps
Detailed section
Resume is exclusive: replay events after Last-Event-ID, then continue live. Unknown or
pruned ids throw OKE1210 before the SSE body — HTTP maps that to 410 LiveResumeGap.
| Symptom | Meaning | Fix |
|---|---|---|
OKE1210 / 410 LiveResumeGap | Cursor gone from the tape | Drop Last-Event-ID; replay remaining |
autoResubscribe: true | Client clears gap after backoff | Prefer for flaky networks |
Custom fx.live(signal, { match }) | Server-side filter | Do not wrap with fx.json.stream |
Cursor "{afterId}" missing on "{signal}".SSE frames carry optional id: lines. Clients that reconnect send
Last-Event-ID from the last id: they actually received. A 410 means that cursor is
gone — drop it and replay the remaining tape.
Client subscription
Detailed section
signal.live is HTTP SSE. for await stays on the server; the browser uses a callback. Prefer
api.live or useLive — not raw EventSource on an invented path.
The client picks the unique exposure whose matchKey fields are a subset of the input,
preferring the largest match ({ orderId } beats firehose). A tie needs via: "unit.flow".
const stop = api.live(
orderStatus,
{ orderId: "ord_1" },
{
onEvent: (event) => {
/* { orderId, status } */
},
onError: (err) => {
/* 4xx, envelope, or drop */
},
onOpen: () => {
/* HTTP 200, including reconnects */
},
autoResubscribe: false,
},
);
stop();api.orders.events({ orderId }, { onEvent }) is the same shape on the exposing Flow.
Reconnects send Last-Event-ID from the last id: received.
| Option | Default | Meaning |
|---|---|---|
onEvent | (required) | Each JSON frame from the tape |
onError | omitted | 4xx, envelope errors, or network drop |
onOpen | omitted | After a successful SSE open (incl. reconnects) |
autoResubscribe | false | Re-open after a drop (500ms…30s backoff) |
via | omitted | Disambiguate when two exposures share a match shape |
signal | omitted | AbortSignal to cancel the subscribe |
React:
import { useLive } from "okengine/client-react";
const { events, latest, error, isConnected } = useLive(
api,
orderStatus,
{ orderId: "ord_1" },
{ autoResubscribe: true },
);Resource live queries use useLiveQuery (snapshot + classified events), not api.live.
See Client · Live.
What live is not
Detailed section
Pick physics from the guarantee you need. Live is the wrong tool when work must be claimed once, or when every in-process listener should react with no retained history.
| Need | Use instead |
|---|---|
| Exactly one worker processes the job | once |
| Every active Flow gets a copy, no tape | broadcast |
| Classified CDC rows for a list window | http.resource live / useLiveQuery |
| Durable multi-step work with journal | Durable Workflows |
Live does not use the once visibility lease or dead-letter queue. retries /
deadLetter may appear on the declare options bag, but the live path is the retained tape +
SSE resume — not competing-consumer physics.
Troubleshooting
Confirm on(http.live(signal)) (or a path .live(signal)) is adopted. Names in the default path
are URI-encoded (encodeURIComponent). A bare 404 with body Not Found means the router found
no match.
.live(signal) only attaches to http.get / http.live. Other verbs reject live synthesis:
on(http.*.live(signal)): live exposure must be GET.
Cause: Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match} ). Two firehoses (http.live or param-less .live) that share the signal and gates cannot boot.
Change the gate, add a path-param filter, or remove a route.
Cause: {method} {path} is bound twice (flow "{flow}"). Two mounts collide on the same method +
path (for example two http.live firehoses that resolve to the same URL). Drop one binding.
Cause: Cursor "{afterId}" missing on "{signal}". That Last-Event-ID was pruned or never
existed. Reconnect without it; remaining events replay. autoResubscribe: true does this after
backoff.
No subscriber / exposure counted. Set { optional: true } (usual for firehoses) or mount
http.live before emitting in tests.
Cause: "{resource}": {detail} from the Standard Schema issues. Align the payload with schema —
no client receives a frame.
Live is not competing-consumer physics. Use signal.once for workers, or expose SSE with
http.live.
That path is not the engine firehose. Use GET /_oke/live/{name}, a gated .live route, or
api.live / useLive from the typed client.
Two routes share the same match shape. Pass via: "unit.flow" or call the exposing Flow
(api.orders.events({orderId}, {onEvent})) instead of root api.live.
retention: { maxAge, maxCount } is live-only. Drop it on queue / pub-sub Signals, or switch
to signal.live.
Extract: live: true on table "…" requires a primary key column. Runtime needs an RLS-capable SQL
driver (postgres / pglite) and a gated identity. That path is resource live — not
signal.live. Attach .gate(...) and declare a PK.
Learn more
- HTTP · Live Streams — exposure, uniqueness, match
- Signal Overview — delivery matrix and drivers
- Once — when you need competing workers instead
- Broadcast — ephemeral fan-out without a tape
- Client —
api.live,useLive,useLiveQuery - fx —
fx.emit,fx.live - Gate —
.gate(...)/.public()on triggers - Errors — OKE1050 · OKE1210 · OKE1240 · OKE1250