Skip to content
Talk to our solutions team

Change streams

A change stream is a long-lived HTTP connection that receives one server-sent event per committed mutation on an entity. The engine ships the whole path — an event hook, an in-process subscription manager, and a server-sent-events transport — but only the engine library’s own REST router mounts the route.

Read this table before anything else on the page. It decides whether you can use change streams at all.

ComponentWhere it livesState
Event hook (publishes on create/update/delete)Engine hook chainRegistered in data.svc, with no bus — it returns immediately and emits nothing
Subscription manager (topics, filters, fan-out)Engine libraryImplemented; constructed only by an embedding host
Server-sent-events transportThe library’s own REST routerImplemented; mounted only when that router is used
GET {prefix}/subscribe/{entity}The library’s own REST routerNot served by data.svc
subscriptions: YAML blockSchema loaderEntity-level: parsed and stored, never read. Schema-level: parsed, then dropped at conversion

One route is registered per entity in the schema, at router construction:

GET {prefix}/subscribe/{entity}

{prefix} is the library router’s URL prefix, /api unless the host overrides it. The entity name is captured at registration, so a request to an entity that is not in the schema is a plain 404 from the mux.

StatusCause
200Stream opened; frames follow until either side disconnects
404No route for that entity name
500The response writer does not support flushing (streaming not supported by the server)
503No identity function is wired on the router (subscriptions require server identity wiring)

The 503 is the cross-tenant guard. Identity supplies the tenant that scopes the subscriber’s filter; without it every subscriber would be unscoped, so the handler refuses to open the stream rather than serving a firehose.

ParameterValuesDefaultBehaviour
opscomma-separated create, read, update, deleteall operationsBitmask filter on the event’s operation
heartbeatGo duration string (45s, 1m) or bare integer seconds30sKeep-alive comment interval

An unparseable heartbeat value falls back to 30s without an error. The default sits under the common reverse-proxy idle timeouts (60s on nginx, 120s on an ALB), so the connection stays open through an idle period.

HeaderValue
Content-Typetext/event-stream
Cache-Controlno-cache
Connectionkeep-alive
X-Accel-Bufferingno

X-Accel-Buffering: no is set because nginx and CloudFront buffer text/event-stream by default, which would hold frames until a buffer fills.

The handler writes a comment frame on connect, then one frame per delivered event, plus a heartbeat comment on each tick.

: connected sub=17
id: evt-1749728461000000000
event: entity.create
data: {"id":"evt-1749728461000000000","type":"entity.create","source":"orders-api/orders","subject":"01J8...","time":"2026-07-26T10:41:01Z","entity":"orders","operation":"create","tenant":"acme","user_id":"u-42","data":{"id":"01J8...","status":"draft","total":1200}}
: ping
: server shutting down

sub=<n> is the monotonic subscriber id, useful for correlating a connection with its counters in logs. id: and event: are emitted so a browser EventSource can dispatch by type and track the last event id. Payloads are JSON-encoded on one line — the event-stream format splits on newlines, so a pretty-printed body would emit continuation lines.

The stream ends on any of: client disconnect, a failed write to the client, or manager shutdown (preceded by the : server shutting down comment). Every exit path unsubscribes and releases the buffer.

FieldTypeContent
idstringEvent id. Defaults to evt-<unix nanoseconds> unless the host supplies a generator
typestringentity.create, entity.update or entity.delete
sourcestring<source>/<entity>, where the source prefix is set by the host
subjectstringPrimary-key value, when the payload carries one; omitted otherwise
timeRFC 3339Publication time, UTC
entitystringEntity name
operationstringcreate, update or delete
tenantstringTenant of the mutation
user_idstringUser who performed the mutation
dataobjectWrite payload after the entity’s include: / exclude: filtering

The type values are the operation tokens, not past-participles. The AsyncAPI document produced by schema export describes channels named entity.<name>.created / .updated / .deleted; those channel names do not correspond to any topic or event: value this transport uses.

Events are published to a topic string. The default topic for an entity is entity.<name>, and the subscribe handler subscribes to exactly entity.<entity-from-the-path>.

TopicWho receives it
entity.<name>Subscribers on that topic, plus wildcard subscribers
*Every publication, on any topic — Go subscribers only, no route serves it

A subscriber carries a filter applied at fan-out, after topic matching. Structural dimensions are checked first; the script predicate runs only if they pass.

DimensionSemanticsHow the stream sets it
EntityCase-insensitive entity-name match; empty means anyFrom the URL path
OperationsOperation bitmask (create 1, read 2, update 4, delete 8); 0 means no constraintFrom ?ops=
TenantIDExact match; empty means every tenantFrom the router’s identity function
UserIDExact match on the mutating user; empty means every userAlways empty — the transport does not expose it
ScriptScript predicate; truthy delivers, falsy or an error dropsNot exposed by the transport

The script predicate receives event, entity, operation, data, tenant and user_id, and is evaluated only when the host installed a script-evaluation callback on the manager. Without one, script filters are treated as always-allow — they fail open, not closed.

Each connection gets a channel of 64 events. Publication is non-blocking: the publisher walks the matching subscribers and enqueues without waiting for any of them to drain.

ConditionResult
Buffer has roomEvent enqueued; the subscriber’s delivered counter increments
Buffer fullEvent dropped for that subscriber only; its dropped counter increments; other subscribers are unaffected
Subscriber already unsubscribedSkipped before the send
Filter rejects the eventSkipped; counted as neither delivered nor dropped

A slow consumer therefore loses events; it never slows the mutation that produced them, and it never blocks another subscriber. A non-zero drop counter is the signal that a consumer cannot keep up.

Two further paths produce no events at all:

  • Bulk create. The bulk insert path runs only the two validation phases per record, never the post-execution phase where publication happens. Rows inserted in bulk are silent.
  • Entities with no events: block. The publishing hook returns immediately when the entity declares no event configs. Declaring the entity is not enough; declare the event.
ScopeCounters
Per subscriberdelivered, dropped
Per manageractive subscribers, active topics, published, delivered, dropped (cumulative)

Both are read through the Go API. No HTTP endpoint publishes them, so a host that wants them in telemetry has to export them itself.

An entity publishes only what it declares. The event hook runs after the mutation statement returns, so a statement that errored publishes nothing.

entities:
- name: orders
events:
- name: order_changed
operations: [create, update]
exclude: [internal_notes, card_last4]
condition:
language: expr
expression: payload.status != "draft"
KeyTypeDefaultMeaning
namestringLabel; also used in the publish-error metadata key
operationslistall write operationscreate, update, delete, matched case-insensitively
conditionscript refGuard; a falsy result skips this event. An evaluation error aborts the mutation
topicstringentity.<name>Publish topic
formatstringcloudevents, json or avro. Stored; the transport always writes JSON
includelistAllow-list of payload fields kept in data
excludelistDeny-list of payload fields removed from data. Ignored when include is set

A publish failure is recorded in the request metadata and does not fail the mutation. A guard evaluation failure does — the condition script is on the request path.

The loader accepts a subscriptions: list in two places, and treats them differently. An entity-level list is converted and stored on the entity. A schema-level list is parsed and merged by name across the files of a multi-file schema, then discarded — the typed schema has no schema-level subscription field, so nothing survives conversion. The two levels are never merged with each other.

Neither one is read at runtime: no transport is selected from a declaration, no filter is built from it at boot, no scope is enforced from it.

KeyValues
namestring
sourcetopic name
eventtypescreate, update, delete
scopeglobal, tenant, user
rlsbool
fieldgroupfield-group name
transportssse, websocket, graphql, grpc
filterscript ref

It is documented here so you recognise it in an inherited schema. Do not model behaviour on it. The filter that actually applies is the one the transport builds per connection, described above.

An embedding host constructs the manager, gives it to the event hook as its bus, and passes it to the router alongside an identity function. Both are required: the manager without the router publishes to nobody, and the router without an identity function answers 503.

mgr := subscription.NewManager(0)
mgr.SetScriptEval(evalScript) // optional; without it script filters always pass
// Register on the engine's hook chain. Bus is what makes the hook do anything.
evt := &hook.EventHook{
Bus: mgr,
Source: "orders-api", // becomes "orders-api/<entity>" in event.source
EvalBool: evalBool, // evaluates events[].condition
}
router := rest.NewRouter(engine, schema,
rest.WithPrefix("/api"),
rest.WithIdentity(identityFromToken), // returns tenant, user, roles
rest.WithSubscriptions(mgr),
)

Set Source explicitly. Unset, it falls back to an internal placeholder that will appear in every consumer’s source field.

Call the manager’s Close at shutdown: it terminates every subscriber, which makes each open stream write its shutdown comment and exit rather than hanging until the client times out.

Consume the subscriber handle by selecting on both its event channel and its done channel. The event channel is never closed — ranging over it alone hangs forever after unsubscribe.

ForSee
The events:, triggers: and pointcuts: keys in contextEntities
Who may read the entity at allAccess rules
The AsyncAPI contract documentSchema export
Every served route, parameter and status codeData API reference