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.
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.
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.
❌ 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