Commit consistency: Changelog entries and subscription events are emitted only after the database transaction commits. A mutation whose transaction rolls back (e.g. a throwing onAfterUpdate/onAfterDelete hook, or a failed commit) never produces a changelog entry or subscription event — there are no phantom events for uncommitted state. (On engines without interactive transactions — Cloudflare D1 — single-statement writes auto-commit before after-hooks run, so a throwing after-hook cannot roll the write back; see the mutation-tracking contract.)
Actor attribution: Changelog entries carry the authenticated user's ID (userId) when the mutation came through a resource route or mutation pipeline with a user in context; anonymous/raw-SQL/external mutations leave it unset.
At-least-once delivery: Every mutation that matches a subscription's filter will generate at least one event
Event exclusivity: A single mutation generates exactly one of: added, changed, removed, or invalidate per subscription (never multiple conflicting events)
Row-data scope changes (immediate): When a mutation moves a row out of a subscriber's scope — including scopes expressed against the row's own columns, e.g. ownerOnly() / userId==<me> — they receive removed immediately as part of normal mutation processing. A row entering scope yields added.
Out-of-band scope changes (periodic): Permission changes that are not driven by a row mutation (e.g. losing org membership, a revoked role) are caught by a periodic re-resolution of each subscription's scope. Every sse.scopeRecheckMs (default 30000ms, per-resource, 0 disables) the subscription re-resolves its scope; if the resolved scope changed, the current matching set is recomputed and diffed against what the subscriber holds — rows that left scope emit removed, rows that entered emit added. The DB scan runs only when the resolved scope string actually changes; the new scope is also persisted so subsequent live events honor it (a revoked subscriber stops receiving live updates for rows it can no longer see).
Detection limit: the re-check reflects changes that the scope resolver itself recomputes (e.g. resolvers that query current membership/roles on each call). A resolver that only reads static fields off the user object captured at connect cannot observe an out-of-band change and still requires a reconnect.
Session expiry is handled separately: a subscription whose authExpiresAt has passed is torn down with an invalidate.
A stream is only touched by the request that opened it: On runtimes with per-request I/O (Cloudflare Workers), an SSE stream and the fan-out socket it relies on belong to the request that opened them. When the KV provides subscribeScoped, each SSE handler's socket is opened from that request, and mutation push paths never write a handler's stream directly — they publish, and the handler's own socket delivers the event inside its request. Without subscribeScoped (Node), events are written locally first, as before.
No lost events while the socket connects: A subscription waits for its handler's socket to be subscribed before it takes its initial snapshot, so a change made between the snapshot and the socket going live is still delivered.
Per-resource isolation: Subscriptions are stored sharded by resource (covara:subs:byres:<resource>, with a covara:subs:resources index for enumeration). A mutation loads and evaluates only the mutated resource's subscriptions — its cost scales with that resource's subscriber count, never the total subscription count across resources.
O(own subscriptions) disconnect: SSE handlers are process-local, so each process tracks handler → subscription IDs in memory; a client disconnect removes exactly its own subscriptions without scanning the registry. (Cleanup of subscriptions left by a dead process falls back to a shard scan.)
Self-addressing IDs: Subscription IDs embed their resource (<uuid>:<resource>), so ID-only operations (get/remove/seq-update) address the right shard with no secondary lookup. IDs remain opaque to clients.
❌ Not promised: Per-subscription filter evaluation within a resource is still O(subscribers-on-that-resource) per mutation — each subscription's filter/scope must be checked to decide delivery. This is in-memory predicate evaluation, not I/O.
Recompute-on-change: Unlike row subscriptions, aggregate subscriptions do not track individual rows. The server recomputes the full aggregate (honoring groupBy/sum/avg/min/max/count/having and the read scope + filter) whenever the resource is mutated, and emits an aggregate event with the new result.
Exactness: Because the result is recomputed from the database, it is always exact for any grouping/having combination — no incremental-aggregation drift.
Initial snapshot: On connect the server emits connected then one aggregate event with the current result (even when the resource is empty).
Scope-aware skip: A subscription only recomputes when a mutated row could actually be in its scope. Each watcher carries the subscription's compiled read scope + filter and is handed the changed rows; if none match, the recompute is skipped entirely. This keeps a per-user aggregate (e.g. userId==<me>) from recomputing on every other user's insert, update, or delete. The changed rows passed are: the new row for inserts, new and previous state for updates (so scope entry/exit is caught), and the deleted rows' prior content for deletes. Unscoped/global aggregates (matcher *) always recompute.
Request-owned recompute: A watcher whose handler has its own scoped fan-out socket (Workers) is never invoked from the mutating request; it is notified only by the covara:aggregate message on its own socket, so its debounce timer, query and writes run in the stream's request. The publishing process keeps the changed rows for 30s under a notice id carried in that message, so same-process watchers still get the scope-aware skip.
Conservative fallback: When the changed rows aren't available — raw-SQL/external invalidations (the framework doesn't know which rows changed) and cross-process notifications (row data is intentionally not shipped over pub/sub) — the watcher recomputes unconditionally. Skipping is only ever applied when it is provably safe; the result-level dedup below is the correctness backstop, so over-recomputing is always safe.
Debounced + deduplicated: Bursts of mutations coalesce into a single recompute (sse.aggregateDebounceMs, default 150ms), and an aggregate event is suppressed when the recomputed result matches the last one sent under an order-independent comparison (group order is normalized, since GROUP BY has no stable ORDER BY).
Mutation coverage: Inserts, updates, deletes, and raw-SQL/external invalidations all (potentially) trigger recompute. Cross-process mutations reach watchers via the covara:aggregate KV channel; double-delivery to the originating process is harmless (it collapses in the debounce).
Scope: The read scope and filter are resolved once at connect and reused for every recompute for the life of the connection.
Invisible + default-on: All live subscriptions on a client share one SSE stream; the client createEventSource returns an EventSource-shaped channel of that stream and the subscription managers are unchanged. Disable server-side with createCovara({ multiplex: false }) or client-side with multiplex: false.
Per-channel semantics preserved: Each channel is a full subscription — its auth scope, filter, include, resumeFrom/catchup, scope-recheck, and per-user/IP limits are resolved and enforced exactly as for a standalone GET /subscribe. The shared stream changes only the transport, never what a subscriber may see.
Framing + demux: Server events are framed event: mux / data: { c: <channelId>, n: <connected|message|aggregate|error>, d: <payload> } and demultiplexed on the client back into the per-subscription events. A frame is only ever delivered to the channel whose id it carries.
Same-principal control: The stream is bound to the authenticated user at open; a control subscribe/unsubscribe from a different user is rejected (403). The connection id is an unguessable server-minted uuid delivered only on the owning stream.
Reuses the fan-out engine: Each channel registers its own handler id against the shared writer, so mutation push, cross-instance KV pub/sub, filter/scope matching, and backpressure are the existing machinery — no separate delivery path.
Channels run in the stream's request: The control POST only validates (scope, filter, limits — errors are its HTTP status) and claims the channel id. Starting the channel (handler registration, its fan-out socket, the initial connected/existing frames, scope-recheck and aggregate timers) and stopping it on unsubscribe always run inside the request that opened GET /__covara/stream. With a scoped-socket KV (Cloudflare Workers / Durable Object, and the memory KV) the work is queued and the stream's request is woken by a doorbell on its own socket (covara:mux:<cid>), with a 1s timer in that request as a fallback; there the POST returns 200 once queued, and a start failure arrives as the channel's error frame. Without one (Node: no KV, Redis) it runs inline and the POST answers after the channel has started.
Graceful fallback: When the stream can't be used — endpoint absent (404), no fetch/EventSource, or a control POST reaching a process without the stream (409 stream_not_found, e.g. multi-isolate) — the affected subscription transparently opens its own GET /subscribe connection. Correctness is identical; only connection sharing is lost.
❌ Not promised: Single-connection multiplexing across processes. The control POST must reach the process holding the stream; it does on Node/startServer (single process) but not necessarily on multi-isolate deployments, which fall back per the above.
❌ Per-mutation events: An aggregate event is not emitted per row change — only the recomputed result, after debounce, and only when it differs from the last sent payload.
❌ Resume/catchup: Aggregate subscriptions carry a seq for reference but do not support changelog catchup/resumeFrom; a reconnect simply re-emits the current snapshot.
With a distributed KV store initialized via initializeKV, mutations on one instance are fanned out to subscribers on other instances (at-least-once)
With the in-memory KV (per-process), cross-instance delivery is NOT provided
External Writers (mutations outside the tracked db)
Mutations made outside useResource/the tracked db (cron jobs, other services, manual edits) are NOT observed automatically
Writers MUST call recordExternalMutation(resource, type, { objectId? }) to notify subscribers; this emits an invalidate event (never added/changed/removed) so clients refetch