Live queries
Subscribe to the same query you already run — and be told when its answer changes.
Today every MorphDB read is a snapshot that starts rotting the moment it returns; two tabs never see each other. This spec adds one endpoint — GET /stream/{type}, Server-Sent Events, taking exactly the list endpoint's query grammar — that pushes either fresh whole results (snapshot mode) or per-object deltas (delta mode) whenever a write changes what the query would return. It closes gap #5 and lights up watch() without changing a line of app code.
The gap today
MorphDB's read plane is request/response only. Gap #5 rates it a blocker and calls it "the keystone gap": two tabs or two users never see each other's edits without a reload, the Figma clone's header says "synced" while it isn't, and every app that wants freshness hand-rolls a polling loop. The morphdb.js spec shipped the honest 80/20 — watch() re-runs a query on an interval and diffs — and logged the ceiling out loud: freshness is bounded by the interval, N watchers cost N list queries per tick whether or not anything changed, and its own decision D6 promises a transport swap "once a server events endpoint exists."
This spec is that endpoint. The design question is not "can we push bytes" — it's what is the unit of subscription? Firebase-class systems answer: the query itself. You don't subscribe to a table or a topic; you subscribe to "the answer to this question," and the server keeps the answer true. That is the contract watch() already exposes to app code, so it is the contract the wire should speak.
Two properties make MorphDB unusually well positioned to build this correctly and cheaply:
- Every write already flows through one choke point. All mutations enter via
objects.create_object / upsert_object / delete_object(plus schema and app ops), inside one process, serialized by the storage lock. There is no out-of-band writer to capture — no WAL decoding, no oplog tailing, no CDC infrastructure. A change feed is a function call after commit. - The query semantics already exist in memory. The DynamoDB backend evaluates the full filter grammar (
eq/ne/gt/gte/lt/lte/contains/in/exists, relation filters, defaults-aware) in pure Python —objects._matches_value/_matches_relation/_filter_specs. The delta engine reuses that battle-tested matcher instead of inventing a second predicate language.
Goals & non-goals
2.1Goals
- G1 — The query is the subscription.
GET /stream/{type}?done=false&sort=priorityaccepts the list endpoint's grammar verbatim — filters, relation filters, sort, and (in snapshot mode)limit/offset/include. Nothing new to learn; the endpoint docs are the stream docs. - G2 — Whole-result or delta, caller's choice.
mode=snapshot(default) pushes the full, fresh result after relevant writes.mode=deltapushesenter/update/leaveper object. Snapshot is general by construction; delta is the economy option. - G3 — Correct by construction where possible, by test where not. Snapshot mode literally re-runs
list_objects— it can never disagree with what a fresh GET would return (on DynamoDB the stream reads consistent, so it can only be fresher than a racing eventually-consistent GET, never staler). Delta mode reuses the existing in-memory matcher: on DynamoDB that's the serving read path itself; on the SQL engines, parity with the SQL read path is a maintained invariant enforced by cross-engine tests (§5.2, §9). - G4 — Zero new dependencies, zero new infra. SSE is plain HTTP; the stdlib
ThreadingHTTPServeralready holds one thread per connection. No websocket library, no broker, no sidecar. - G5 — Bounded cost, stated bounds. Per-write work is gated to zero when nobody listens; refreshes are coalesced per query and debounced with backend-aware clamps; per-connection queues are capped with a self-healing overflow path. §7 does the arithmetic, including the hostile cases.
- G6 —
watch()upgrades invisibly. The SDK feature-detects the endpoint and swaps transport; app code, SKILL.md examples, and the callback shape don't change a character (the migration the SDK spec's decision D6 pre-reserved).
2.2Non-goals
- N1 — No WebSocket. This is server→client data flow; the client already has a write path (plain HTTP). SSE gives auto-reconnect and CORS for free, and the Python stdlib has an HTTP server but no websocket server — a hand-rolled frame/handshake implementation would burn the zero-dependency budget on bidirectionality nobody asked for.
- N2 — No persisted change log, no replay. The bus is in-memory and ephemeral. Reconnect gets a fresh
initsnapshot, which is strictly simpler and always correct. (A durable op log is what webhooks will want — §11.) - N3 — No presence, cursors, or broadcast messaging. Those are gap-list section B; different primitive (ephemeral pub/sub, not query subscription).
- N4 — No incremental view maintenance. We do not differentiate queries through a dataflow engine (Materialize/Zero territory). Two modes — re-run, or set-membership deltas — cover MorphDB-scale apps at a fraction of the machinery.
- N5 — No cross-instance fan-out in v1. Streams see writes that enter this process. Single instance — the default deployment — is fully correct; multi-instance is a documented ceiling with named upgrade paths (§6.6).
- N6 — No auth beyond the app key. Per-user rules are MorphRules; when it lands, its check slots into stream attach exactly where it slots into list.
How everyone else streams — the deep dive
Every system that lets a client "listen to a query" picks from the same four architectures. The differences are where the work happens (per write vs. per interval), what travels (deltas vs. whole results), and how ruthlessly the query grammar is restricted to keep the first two affordable.
3.1The four families
| Family | Mechanism | Who | Cost shape | Failure mode |
|---|---|---|---|---|
| A · Poll & diff | Re-run the query on an interval; diff; notify on change. | Meteor's poll-and-diff engine · Hasura live queries (server-side, multiplexed) · morphdb.js watch() today |
rate × query cost — independent of write rate; pays even when idle | Staleness = interval; idle polls burn money at scale |
| B · Change-matching push | Capture each write; test it against every subscription's predicate; push matching deltas. | Supabase Realtime postgres_changes (WAL → Elixir fan-out) · RethinkDB changefeeds · Meteor oplog tailing · Firestore listeners |
writes × subscriptions predicate tests; payload ∝ change size | The W×S quadratic wall; restricted predicates; ordered/limited windows need server state |
| C · Invalidate & re-run | Track what each query read; when a write overlaps, re-run it and push the whole fresh result. | Convex (read-set tracking, deterministic re-run) · Hasura's diffing layer on top of A | invalidations × query cost; payload ∝ result size | Refresh storms on hot data — must coalesce/debounce; result-sized payloads |
| D · Incremental view maintenance | Compile the query into a dataflow that updates its result incrementally per write. | Materialize/Feldera · Rocicorp Zero (ZQL) · ElectricSQL shapes (restricted form) | Near-optimal per write | An order of magnitude more machinery; joins/aggregates are research-grade |
3.2What each system teaches
Supabase Realtime decodes Postgres WAL (logical replication + wal2json) in an Elixir cluster and fans row changes out over websocket channels. The instructive parts are the restrictions: a postgres_changes filter is one column compared against one value (eq/neq, ranges, in capped at 100 values, like/regex), AND-only — no OR, no joins, no computed predicates, and DELETE events can't be filtered at all — because every committed change must be tested against every subscription's filter. WAL consumption is deliberately single-threaded to preserve ordering, so a bigger database instance buys no more changes throughput; and with row-level security on, authorization runs per subscriber per event — their own benchmarks show throughput collapsing from 30–50k messages/s without RLS (tier-dependent) to 3–4k with it. The docs guarantee no replay of messages missed while disconnected (guidance: refetch on reconnect), and officially tell anyone expecting more than ~3,000 concurrent subscribers on the same changes to stop using the general primitive and re-stream through plain Broadcast instead. Lesson: a delta feed over a rich query grammar is a trap; restrict the grammar or restrict the promise.
Firestore is the contract gold standard: onSnapshot fires once with the initial result, then again with the full current result plus docChanges() — typed added/modified/removed entries, where a document that stops matching the query is reported as removed from that query's perspective. That per-query membership view (not raw row events) is what makes client code trivial, and it's exactly the shape watch() already exposes. Under the hood, writes flow through a sharded changelog matched against registered listener queries by a purpose-built reverse query matcher — the write→listener fan-out layer is first-class architecture, not a bolt-on (the after-the-fact fan-out tier is exactly what RethinkDB's proxy nodes and Meteor's redis-oplog had to become, below). Firestore also prices the model honestly: every listener's delivered document counts as a billed read — including a document removed because it changed — and — with offline persistence enabled — a reconnect gap over 30 minutes re-bills the entire result set (without it, every reconnect does). Fresh-start-on-reconnect is priced into the product.
RethinkDB changefeeds went furthest on generality-with-deltas: .changes() attached to filtered queries and even to orderBy.limit(n) windows, the server maintaining each window's membership (a doc pushed out of the window got a distinct uninitial event, plus includeOffsets for positional bookkeeping); includeInitial prepended the starting result — rebuilt in v2.2 as "atomic changefeeds" specifically to close the register-vs-initial-read race; squash coalesced bursts server-side; each feed buffered up to 100k undelivered changes and on overflow dropped the oldest with an error marker. The price: a write to a watched table was shipped to every proxy node holding any changefeed on it and predicate-tested there — irrelevant writes were never pruned upstream. CoCalc's production account: 10k changefeeds needed 20 dedicated proxy pods beside a 6-node cluster, all running hot. And feeds had no resume whatsoever — a dropped socket meant re-query and accept the gap. Lesson: window maintenance is the expensive 5% — we refuse limited windows in delta mode and route them to snapshot mode instead — and we adopt their squash insight as our snapshot debounce, their atomic-attach as our §5.1, and their overflow-drop as our cleaner collapse-to-init.
Meteor is the cautionary tale that validates having two engines. Its livequery watched MongoDB's replication oplog and evaluated every observed query's predicate against every op — every server scanning every write cluster-wide, even for collections it had no observers on — with fallback to poll-and-diff (re-run every ~10s or on own-writes) for queries the in-memory matcher couldn't model. Meteor's own tuning guide calls the aggregate cost O(N²) in concurrent users; the community's eventual remedy, redis-oplog, routes each write only to servers that declared interest — which is precisely the interested() gate this design starts with instead of retrofits. Lesson: keep both engines, choose per query, dedupe identical subscriptions, and never make disinterested parties read the firehose.
Hasura live queries proved family C scales farther than intuition suggests: subscriptions are parameterized GraphQL queries; identical shapes (same role, same selection set, different argument values) are multiplexed — up to 100 per batch — into one SQL query polled at a fixed interval (default 1,000 ms), results are diffed server-side, and only subscribers whose data actually changed get pushed. They chose polling over CDC explicitly: mapping change events to arbitrary filtered queries is "intractable for anything complicated." Their published benchmark held one million concurrent live queries against a single Postgres at ~28% CPU and ~850 connections. Lesson: re-running is fine if you coalesce aggressively — the unit of server work must be the distinct query, never the connection. (Their newer streaming subscriptions — cursor-forward row streams — are a different, append-log-shaped primitive.)
Convex makes family C airtight: queries are deterministic functions whose read sets (documents and index ranges) are recorded; an overlapping write invalidates and fully re-runs them, pushing the complete new result at a consistent timestamp. Two details matter. First, invalidation is aggregated: the sync worker walks each commit once against the union of all subscriptions' read sets, so matching cost scales with rows touched, not with subscriber count. Second, there are no deltas on the wire at all — incremental result maintenance sits on their public roadmap, explicitly unshipped; re-run-and-replace is what the correctness-first team ships first. Lesson: whole-result push is a feature, not a fallback — clients that replace state can't drift. Our snapshot mode is Convex's bet with a coarser invalidation index (type-level instead of read-range-level).
ElectricSQL and Zero mark the far shore: Electric syncs "shapes" (table + where-clause subsets) via an offset-addressed, append-only shape log over HTTP long-polling — restricted enough that a CDN collapses identical (shape, offset) requests into one origin hit, which is their entire scaling story (their benchmarks: optimized field = constant shape filters cost a flat ~0.2 ms per change regardless of shape count; non-optimized clauses degrade linearly). Zero incrementally maintains ZQL queries server-side (family D) — with a built-in circuit breaker that abandons incremental maintenance and rehydrates from scratch whenever the delta would cost more than the re-run, a telling admission inside the most sophisticated architecture surveyed. Both are sync engines with client stores, solving a bigger problem than MorphDB has. Noted and left — except for the law that falls out of the whole survey: the systems with unrestricted query expressiveness (Convex, Hasura) all pay with recompute-based invalidation; the systems with flat per-write cost (Electric, Zero) all bought it by restricting the query grammar. Expressiveness and cheap incrementality are in direct tension, and §5.4's mode split is that tension made explicit rather than discovered in production. (Electric has since distilled its transport into "Durable Streams" — offset-addressed, CDN-cacheable, resumable append logs, deliberately not a query engine — the cleanest current statement of the opposite end of the trade.)
3.3What MorphDB takes
- From Firestore: the event contract — init result, then per-query membership changes (
enter/update/leave), never raw row noise. - From Convex: snapshot mode as the default and the correctness anchor; invalidate-and-rerun over cleverness.
- From Hasura: coalesce by distinct query and debounce refreshes — with one deliberate simplification: Hasura diffs server-side to suppress unchanged pushes, we push the whole result and let
watch()diff client-side, keeping the server stateless per snapshot-subscriber (a no-op refresh costs one redundant frame, not a stored copy of every subscriber's last result). - From RethinkDB:
squash-style burst coalescing; the warning about ordered/limited windows (delta mode refuses them). - From Supabase: restrict the delta grammar honestly and say so in a 400, rather than shipping a general promise that collapses at load.
- From Meteor: two engines behind one interface, and observer dedup as a first-class cost control.
MorphDB ships families C + B behind one endpoint — snapshot mode (C) as the general default, delta mode (B) as the restricted economy tier — with family A remaining as the SDK's fallback transport. Family D is explicitly rejected (N4). This is the same landing zone the industry converged on from three different directions.
The interface
4.1The endpoint
one new routeGET /stream/{type}?«any list-endpoint query»&mode=snapshot|delta&refresh=«ms»&app_key=«key»
Accept: text/event-stream
Everything before mode is the existing list grammar, unchanged and verbatim: field filters (done=false, priority__gte=3, title__contains=buy, status__in=open,blocked), relation filters (assignee=<guid>, assignee__exists=true), sort, order, and — in snapshot mode — limit, offset, include. A stream is defined as: the answer to this GET, kept true over time.
| Param | Values | Notes |
|---|---|---|
mode | snapshot (default) · delta | What travels after init: whole results, or per-object membership events. |
refresh | ms, snapshot mode only | Debounce window for re-runs. Default 200 ms (SQLite/Postgres), 1000 ms (DynamoDB); clamped to [50, 60000] ([500, 60000] on DynamoDB). Supplying it in delta mode is a didactic 400 (D8's rule: teach, don't surprise). One knob, backend-aware defaults — §7.3. |
app_key | the app's key | Alternative to the X-App-Key header, because browser EventSource cannot set headers. App keys are namespaces, not secrets (README), so a query param leaks nothing a URL doesn't already. |
One deliberate asterisk on "verbatim": mode, refresh, and app_key add three reserved words to the five the list endpoint already reserves the same way (limit/offset/sort/order/include are popped before filter classification everywhere — the stream inherits that, it doesn't invent it). The collision rule, exactly: a bare reserved name is always the control param — deterministic, never guessed from intent — and a field or relation with such a name is filtered via the explicit-operator spelling (mode__eq=…), which is unambiguous and always works. The didactic 400 fires on the case that's actually a mistake: a bare reserved name whose value is not a legal control value (mode=urgent on a type declaring mode → "mode is reserved on this route; filter the field as mode__eq=urgent"). Malformed control values 400 the same way regardless (refresh=abc); out-of-range refresh clamps. The SDK never trips any of this: it emits explicit-operator spellings for every stream filter (a stated one-route amendment to the SDK spec's D5 "keys verbatim" rule) and only legal control values.
4.2The event grammar
what actually comes down the wire (delta mode)retry: 3000
event: init
id: 1
data: {"mode":"delta","objects":[{"_guid":"task_a1","title":"ship","done":false,…}],"total":1}
: hb ← comment heartbeat, every 20 s
event: enter ← a write made task_b2 match the query
id: 2
data: {"object":{"_guid":"task_b2","title":"buy milk","done":false,…}}
event: update ← a member changed but still matches
id: 3
data: {"object":{"_guid":"task_a1","title":"ship it","done":false,…}}
event: leave ← done=true now, or deleted — same thing to this query
id: 4
data: {"guid":"task_a1"}
| Event | Payload | Sent when |
|---|---|---|
init | {mode, objects, total, limit?, offset?} | Once on attach — the full current result of the query (snapshot mode: exactly what the equivalent list GET returns; delta mode: the unpaginated set, §5.4). Also re-sent by the server after any internal recovery — connection-queue overflow (§6.4), bus overflow (§6.2), schema-morph collapse (§5.6); clients simply replace state. |
snapshot | {objects, total, limit?, offset?} | Snapshot mode only: the debounced re-run after relevant writes. Same shape as init. |
enter | {object} | Delta mode: an object joined the result set — created matching, updated into matching, or linked/unlinked into matching via a relation. |
update | {object} | Delta mode: a member changed and still matches (including sort-field changes — client re-sorts). |
leave | {guid} | Delta mode: a member stopped matching, or was deleted. One event for both — to this query they are the same fact. |
end | {error:{code, message}}, then the server closes | The stream can't continue truthfully: app or type deleted, a schema morph made the query illegal (§5.6), a resource ceiling crossed mid-stream (§7.3), or an unrecoverable worker error after its bounded retry (§6.4). Named end, not error, because EventSource already fires a built-in error event for transport failures — the two must stay distinguishable (D14). |
: hb | comment line | Every 20 s of silence — keeps intermediaries (ALB idle 60 s, nginx 60 s) from reaping the connection. |
id: carries a per-connection seq — strictly monotonic for the connection's lifetime, present on every frame including end. It rides only on the id: line, never inside the JSON: payloads stay byte-identical across a query group's subscribers, so a coalesced result is serialized once and framed per connection (the win §6.3 depends on). A server-sent init resets the client's state, never the numbering, so a seq gap always means a bug, not a protocol feature. Last-Event-ID on reconnect is deliberately ignored: with no replay log (N2), a fresh init is both simpler and strictly more correct than any resume protocol. While the server runs, the stream never ends server-side except via end; on process shutdown connections simply close, and the browser's reconnect succeeds or fails against whatever comes back up. The retry: 3000 sent once at attach is advisory — the SDK paces its own reconnects (§8); raw EventSource users get the browser's reconnect with a 3 s hint. The response has no Content-Length and is terminated by connection close.
4.3Three clients, same stream
curl — debuggingcurl -N "http://127.0.0.1:8787/stream/task?done=false&sort=priority&order=desc&mode=delta&app_key=my-site"
raw EventSource — no SDKconst es = new EventSource("http://127.0.0.1:8787/stream/task?done=false&mode=snapshot&app_key=my-site");
es.addEventListener("init", e => render(JSON.parse(e.data).objects));
es.addEventListener("snapshot", e => render(JSON.parse(e.data).objects));
// EventSource auto-reconnects; every (re)attach starts with a fresh init.
morphdb.js — the code that does not changeconst stop = tasks.watch(
{ where: { done: false }, sort: "priority", order: "desc" },
({ objects, added, updated, removed, initial }) => render(objects)
);
// identical call, identical callback — the SDK just stopped polling (§8)
4.4Errors & feature detection
- Attach-time validation is the list endpoint's, verbatim: unknown app 404, unknown type 404, un-indexed filter field 400 with the same didactic message. A bad stream request fails before any event flows, as a normal JSON error response — which
EventSourcesurfaces as a terminal error instead of retrying forever. - Delta-ineligible queries (§5.4) get a 400 that names the fix: "
includeis not supported in delta mode — use mode=snapshot, or drop include." GET /gains"streaming": true|false— the transport capability flag. Mechanically: astreamsmodule global, default false, thatserve()flips at startup — so the transport-neutraldispatch()(and therefore Lambda, and anyone embedding it) reports false unless a streaming-capable transport opted in. Lambda servesGET /stream/{type}as501 not_implementedwith a teaching message ("this deployment is request/response — watch() falls back to polling") — readable by curl and fetch users; rawEventSourcecan't expose response bodies, which is exactly why the SDK consults the flag instead of probing the route.GET /helpdocuments the route either way.
SSE over WebSocket. The data flow is one-directional (writes already have a perfectly good path), EventSource ships reconnection and CORS handling in the platform, SSE traverses plain HTTP infrastructure, and — decisively for a zero-dependency project — the Python stdlib can serve SSE with wfile.write() but has no websocket server at all. A hand-rolled RFC 6455 implementation would be ~200 lines of frame/handshake code purchasing nothing this design needs.
Over HTTP/1.1 browsers allow ~6 connections per origin, and each stream is one connection. A page holding many simultaneous watches on a plain-HTTP localhost backend can exhaust that (the 7th stream queues). At MorphDB scale (a page watches 1–3 queries) this is headroom, not a wall; HTTP/2 termination (any reverse proxy) lifts it entirely, and the browser-standard workaround (share one stream across tabs via SharedWorker/BroadcastChannel) remains available to apps. If it ever pinches, the server-side answer is multiplexing several queries onto one stream — parked in §11, not built speculatively.
Semantics, pinned down
5.1Attach is linearized with writes
On attach the server, under the same storage lock that serializes every write: (1) validates the query, (2) registers the subscription and records the current change seq — every committed change is stamped from a global counter incremented under that lock — and (3) runs the initial query. For delta attaches, step 3 holds the lock on every engine (with ConsistentRead on DynamoDB): membership is edge-triggered — a transition missed at the seed is missed forever — so no write may interleave, period. For snapshot attaches on DynamoDB only, the read runs outside the lock behind the captured fence: snapshot is level-triggered, so a write landing mid-read is stamped past the fence, re-dirties the group, and the next refresh heals it (§7.2). Either way the dispatcher discards any already-queued record stamped at or before the subscription's attach point, so nothing published before the attach can replay into it. The init snapshot plus the subsequent stream is gapless and duplicate-free: no MVCC, no resume tokens — the lock MorphDB already holds for every mutation, plus one integer fence. Clients should still treat enter as an idempotent upsert; it costs nothing and absorbs any future loosening. (The attach race is real enough that RethinkDB had to rebuild includeInitial as "atomic changefeeds" in v2.2; the single-process lock gets us the atomic version for free.)
5.2Delta events are per-query membership, decided by a membership set
Each distinct delta query keeps the set of guids currently in its result — seeded at group creation, shared by all subscribers of that query hash (§6.3). The set carries a group fence (the §5.1 fence, group-wide): records stamped at or before it neither mutate the set nor deliver. A later subscriber attaching to an existing group never touches the set — its init read is per-connection, and its own fence hides the queued past — so a queued leave can't be swallowed by a re-seed that already reflects it. When a committed write produces object O of a subscribed type, the server evaluates the query's predicates against O's new body (the same projected body the write handler already computed to return to the writer — no extra read) and consults the set:
| Predicate on new body | guid ∈ set? | Event | Set op |
|---|---|---|---|
| matches | no | enter | add |
| matches | yes | update | — |
| doesn't match | yes | leave | remove |
| doesn't match | no | silence | — |
| object deleted & guid ∈ set | leave | remove | |
Routing is equally pinned: a record's body is evaluated directly against a query's specs only when its type is the streamed type; other trigger types reach the stream solely through §5.3 neighbor re-reads (which produce streamed-type bodies) and §5.6 resets — a user body is never tested against a task query, however its fields might vacuously match. This is Firestore's added/modified/removed and RethinkDB's changefeed semantics, implemented with one set and one predicate call — no old-body capture, no write-path reads. The predicate evaluator is the existing _matches_value/_matches_relation pair — the code that already serves DynamoDB list reads, so on that engine delta semantics match reads by construction. On SQLite/Postgres the serving read path is SQL, so parity there is a maintained invariant, not a structural one: the suite runs the same fixtures through both evaluators and fails on divergence (§9) — for every operator except one that cannot converge: contains is ASCII-case-insensitive under SQLite's LIKE but Unicode-case-insensitive in Python and Postgres's ILIKE, a permanent three-way fact of the engines. The invariant is therefore scoped honestly: full parity everywhere except non-ASCII-case contains on SQLite, where delta follows the matcher (i.e. the Postgres/DynamoDB behavior) and the fixture pins the divergence per engine instead of pretending it away. Everywhere in scope, a mismatch would surface as a spurious enter/leave on a no-op write — which is what the parity tests assert never happens.
5.3Relation writes touch both ends
Writing task.assignee changes more bodies than the task's. The user whose tasks list gained the guid changed; so did the one who lost it; and because single-valued slots are last-write-wins, so can a third object that was never written — moving a task into project P evicts it from project Q's slot, and Q's projected body changes silently. The change record therefore carries the written object plus exactly the guids whose edges changed: (old ∖ new) ∪ (new ∖ old) ∪ evicted slot-holders — a neighbor merely re-listed by a set-as-field write has unchanged edges and gets neither a re-read nor an event. The relation writer computes two of the three sets today (old and new, internally) — P1 surfaces those and adds a pre-delete edge enumeration on object delete; the evicted-slot removals are blind DELETEs on the SQL store, so that third set arrives with P2's enumerate-then-delete (the stated P1 generality corner, §9). All of it is enumeration inside the same transaction, not body capture — D4 stands; the enumerations are new, cheap, in-lock reads, the one qualifier on §7.1's "no new reads". For each touched neighbor with subscriptions on its type, the dispatcher re-reads that neighbor once (shared across its subscriptions, ConsistentRead on DynamoDB) and runs the same §5.2 evaluation; a re-read that finds the neighbor already deleted stays silent — the delete's own record follows in bus order and carries the leave. This is what makes ?assignee__exists=true and ?assignee=<guid> streams correct — the classic hole in naive per-table CDC, and in naive per-object change records too.
5.4Mode eligibility
| Query feature | snapshot | delta | Why delta refuses |
|---|---|---|---|
| field filters (all ops) | ✓ | ✓ | — |
relation filters (eq/ne/in/exists) | ✓ | ✓ | — |
sort / order | ✓ (server-ordered) | ✓ (client re-sorts) | — |
limit / offset | ✓ | ✗ 400 | A bounded window needs server-side order maintenance: one leave inside the window means knowing which object outside it slides in — RethinkDB paid real complexity for this; we route windows to snapshot mode instead. |
include | ✓ | ✗ 400 | A hydrated neighbor's change would demand deep-graph invalidation per event; snapshot mode re-hydrates wholesale and stays correct. |
Because delta mode forbids windows, a delta client's local set is the entire result: total is just objects.length, maintained for free. Seeding that set is the one place delta steps off the public list path: list_objects silently pages (default limit 100, hard cap 1,000), which would seed a fraction of the true result. So the seed is the delta evaluator itself: an unpaginated storage-level read of the type (db.store().list_objects(app, type) — both stores already expose it), projected and filtered through the compiled specs — the existing _list_objects_storage pipeline minus sort, pagination, and re-validation, not new code — one path on every engine, making seed semantics equal stream semantics by construction (§5.2's parity scope applies to both identically). The seed is O(type size) — the shape DynamoDB already pays for every filtered list — priced in §7.1's attach row; the member ceiling (§7.3) is enforced during it, and the init frame and membership set are result-sized by definition. Snapshot mode's trigger is deliberately coarse, and two-part: any committed change on a trigger type — the streamed type, plus every type reachable through the query's relation filters and include paths (computed at attach, recomputed on morphs — §5.6) — marks the query dirty; and any record whose touched list names a trigger type dirties it too, even when the record's own type is in no trigger set — because every projected body carries its relation values, a task write that re-points task.project changes two project bodies without writing either, and both a filter-less project stream (trigger: its own type) and a post?include=comments stream whose hydrated comment was edge-touched by some third type must see it. Coarse means occasional no-op refreshes; it also means never missing an edge-of-graph update. Delta subscriptions carry a trigger set of the same shape — the streamed type plus relation-filter endpoint types — used to route §5.3 neighbor work and to scope §5.6 resets. Correctness first; §11 sketches the predicate-gated skip.
5.5Ordering & delivery
- Writes are serialized by the storage lock; records are published from a post-commit hook while that lock is still held (§6.2), so publication order is commit order; each connection's queue preserves it.
seqis per-connection, strictly monotonic, and never resets (§4.2). - Within a connection that keeps up: one event per committed change for own-type records (delta). Neighbor-path events are weaker and say so: the re-read is as-of-evaluation, so a racing burst of edge writes can merge events, flip their type, or cancel to silence — what is guaranteed is at-least-final-state per burst. Overflow collapses any backlog into a single fresh
init(§6.4). Snapshot is at-least-fresh: any burst yields at least one refresh reflecting its final state; intermediates may never be seen. - Across reconnects: at-most-once with a fresh
initre-anchor. No replay (N2). Clients that need every intermediate state are building an audit log, not a UI — different tool.
5.6Schema morphs under a live stream
MorphDB's whole premise is that the schema changes constantly, so streams must survive it — and a schema morph is the one write-free event that can flip every object's effective values at once: a default added or changed, a retype that makes old values read as unset, an index toggle, a relation re-pointed. Rebuilding filter specs is not enough; a delta stream's membership can be wrong wholesale with no object write ever arriving to correct it. So on any schema write that actually changed something — upsert_type already holds the existing doc, and an idempotent re-PUT (the morphdb init boot pattern, the SDK's self-provisioning pages, both of which re-PUT every type routinely) stages no record and resets nothing; affected_types names actually-changed relations' endpoints, not relations merely restated — every subscription whose trigger set intersects the affected types — counting both endpoint types of any touched relation, since declaring one side creates the inverse on the other — is reset. The both-endpoints rule is what catches cascades: deleting a type or pruning a relation rewires every neighbor's projected body with zero per-object writes, so the neighbors' subscriptions reset too, not just the deleted type's own. Reset means: snapshot subscriptions recompute their trigger sets and are marked dirty (a re-pointed relation must change what dirties them, not just what the next refresh returns); delta subscriptions collapse to a fresh init (D10's one recovery path again). The work splits by thread on purpose: the dispatcher, on reaching the schema-op record in bus order, freezes each affected delta group (no further evaluation, no frames — post-morph records cannot be judged by pre-morph specs because the freeze precedes them in bus order, and dropping the frozen window is safe because the re-seed re-anchors past it; a second morph landing while frozen just re-queues the re-seed, last swap wins), re-keys groups, and recomputes-and-dirties snapshot trigger sets. The refresh executor then performs the re-seed as an internal re-attach under the storage lock: it recompiles the specs and trigger sets against the new schema, captures the fence at the seed read, builds a fresh {specs, triggers, fence, set} reference and swaps it in — the swap is the unfreeze — then init to every member. An attach landing on a frozen group is deferred to the swap and served against fresh state. Records behind the re-seed fence — bodies projected under the old schema — can neither corrupt the new membership nor be delivered after it re-anchors; frames already sitting in connection queues from before the freeze may still flush first, harmlessly, because the coming init supersedes them. If recompilation or any re-query finds the query no longer legal — filter field dropped or un-indexed, relation deleted, including a snapshot refresh that trips the same 400 on the executor — the stream ends with the didactic message as an end frame. Type deleted → end on its streams; app deleted → end on all of the app's streams.
Architecture
6.1One new module, three small hooks
the whole machinewrite handler (objects.py / schema.py / apps.py)
│ inside the write transaction: assemble record ← gated: k dict probes when nobody listens
│ post-commit hook, storage lock still held:
│ stamp record with the global change seq → append to the bounded bus
▼
ChangeBus (morphdb/streams.py)
record = {seq, app, type, guid, verb, new_body|None, touched:[(type,guid),…]}
| {seq, app, schema_op: {op, affected_types:[…]}} ← captured in-transaction,
one record per schema/app op — cascades never flood the bus per-object
│
├─ dispatcher thread (one, daemon): drains the bus in seq order
│ skips records ≤ the group's / subscriber's fence seq (§5.1/§5.2)
│ delta: own-type record → evaluate new_body (§5.2);
│ touched neighbor → §5.3 re-read; once per distinct query → fan out
│ snapshot: hand the dirty mark to the executor's queue (§6.5)
│
└─ refresh executor (one, daemon): earliest-deadline-first loop over dirty hashes
a hash quiet for ≥ refresh runs immediately (leading edge);
a busy one runs at its window deadline — and if re-dirtied
during the run it re-arms, so the final state always ships
→ one `snapshot` result, one frame per subscriber of that hash
(recovery inits and morph re-seeds run here too — §6.4/§5.6)
▼
per-connection SSE writer (the request's own thread)
queue.get(timeout=20) → write frame + flush | timeout → ": hb" | send-timeout/EPIPE → unregister
morphdb/streams.py— the registry ({(app, type): [subscription]}), the bounded bus, the two worker threads, the deadline-loop debounce (a single loop over next-deadlines — no per-query timer threads), SSE frame encoding. Everything stream-shaped lives here.- Write-path hooks — the record is assembled inside the transaction in
create_object/upsert_object/delete_object, and published by a post-commit hook thatdb.store_transactionruns while the storage lock is still held (§6.2). One stated restructuring makes that true: today the handlers project the returned body after the transaction block — P1 relocates that projection (and its relation reads) inside it, which the reentrant lock permits. No new reads; the existing ones move into the lock-held window (priced in §7.1). Schema upsert/delete and app delete hook the same way. Guarded bystreams.interested(app, …)probing the written type and its schema-declared relation-neighbor types — a write tousermust still publish when onlytaskstreams exist, because its edges touch tasks (§5.3), and the same probe set gates whether edge diffs get computed at all. A handful of dict lookups; still effectively free when streaming is idle. - Transport —
server.pyrecognizesGET /stream/…before generic dispatch and hands the socket to the stream writer: status 200,Content-Type: text/event-stream,Cache-Control: no-cache, no-transform(theno-transformstops intermediaries re-framing the stream),X-Accel-Buffering: no, CORS headers, noContent-Length,Connection: closewith the handler marked non-reusable (the stream is close-delimited; on the stdlib's keep-alive HTTP/1.1 this is mandatory or the connection desyncs — and an h2-terminating proxy strips hop-by-hop headers before any client can object), flush per frame,disable_nagle_algorithmon (a server-wide handler flag — harmless for the JSON routes, and an SSE frame is exactly the small-write pattern Nagle+delayed-ACK penalizes).routes.pykeeps a transport-neutral fallback handler for the same path that returns the 501 teaching error — which is exactly what the Lambda adapter serves.
6.2Publish inside the lock, evaluate off the write path
"At the end of the handler" would be a bug: the transaction helper releases the storage lock when its block exits, so two writers could publish out of commit order — and a delta engine that saw v2 then v1 would leave clients on stale state forever, with no correcting event. So publication is a post-commit hook run by db.store_transaction itself, while the storage lock is still held: the record was assembled during the transaction, the commit has succeeded, and the change seq is incremented under the same lock — records enter the bus in commit order by construction, and a rolled-back write can never publish. The writing thread does exactly one append; predicate evaluation, neighbor re-reads, and re-runs all happen on the worker threads. A write's latency gains ~microseconds even under heavy streaming. Mechanically, the seam is two functions: handlers call db.stage_change(record), which appends to a db-module staging list; streams installs its consumer once at startup via db.set_publish_hook(fn), so db never imports streams. store_transaction's success path calls fn(staged) after commit, inside the lock, at the outermost exit only (a depth counter — insurance against a nesting pattern that today's engines don't even support); its failure path clears the list and — if anything had been staged — replaces it with the single synthetic dirty-only record described below, so a rollback never publishes object records. Cascade records: schema and app ops stage exactly one schema_op record each, with affected_types enumerated while the rows still exist. The bus itself is bounded (§7.3): if a write burst outruns the dispatcher, the oldest records are dropped — the bus keeps each dropped record's (app, own type ∪ touched types) addresses, because a neighbor-touch record reaches groups its own type never names — and every delta group streaming any of those types collapses via a group-wide re-seed with a re-stamped fence (§5.6's mechanism): a dropped record's membership transition is lost, not just its frame. Snapshot groups on those addresses are simply marked dirty. schema_op records are never evicted from the bus — they are rare and tiny, and dropping one would leave snapshot trigger sets permanently stale in a way no dirty mark heals. One DynamoDB-only corner motivates that synthetic record: a rolled-back transaction must not publish its object records, yet a lock-free snapshot refresh (§7.2) may have read its transient item states — the dirty-only record makes the next refresh heal whatever the rollback left visible. Writers never block on streaming, and correctness self-heals through the same one recovery path as everything else (D10).
6.3Coalescing is the load-bearing wall
Snapshot subscriptions are grouped by query hash — the canonicalized (app, type, filters, sort, order, limit, offset, include) tuple. The app is part of the identity: two tenants' identical-looking queries are different queries and never share a group or a result. A group's effective refresh is the minimum its subscribers asked for. "Canonicalized" means the compiled spec, not the raw string: coerced values, default-op spellings collapsed (done=false ≡ done__eq=false), params and in-lists sorted, the include tree normalized — equivalent spellings must collide or the coalescing win quietly evaporates; refresh and the key param stay out of the hash. Delta groups additionally canonicalize sort/order away — ordering is a client-side concern in delta (§8), and keeping it in the hash would split identical membership sets into parallel groups doing identical work. After a schema morph recompiles a group, it is re-keyed under the new schema, so post-morph attaches still coalesce with it; should two delta groups re-key to the same hash, the registry entries unify but the membership sets never merge — all members collapse to fresh inits under the surviving key (merging sets with different fences is a trap not worth outsmarting; colliding snapshot groups merge trivially, min refresh wins). A hundred tabs watching the same todo list are one dirty flag, one re-run per debounce window, one result serialized once and fanned out a hundred times. Delta subscriptions dedupe by the same hash: identical queries share one compiled spec, one membership set, and one predicate evaluation per change, with the resulting event fanned to the group. This is Hasura's multiplexing insight (and, at type granularity, Convex's aggregated-matching insight) applied at MorphDB scale — it converts the worst realistic load pattern, many viewers of the same thing, into the cheapest one.
6.4Backpressure: the slow-client rule
Each connection has a bounded queue — 64 events or 1 MB, whichever trips first (a queue of snapshot frames must be bytes-bounded, not just count-bounded). A client that can't drain it gets the queue cleared and replaced with a single fresh init (the current full result): the stream self-heals to truth instead of buffering unboundedly or silently dropping deltas mid-sequence. (RethinkDB faced the same choice and buffered 100k changes per feed, dropping the oldest with an error marker on overflow — same problem, noisier recovery.) Two failure shapes, named honestly: a slow peer trips the queue bound; a black-holed peer — mobile tab in a tunnel, lid closed mid-frame — errors on nothing, its frames absorbing silently into the kernel send buffer for minutes of TCP retry. Heartbeats only surface clean closes, so the writer also carries a per-socket send timeout (20 s), after which the connection is declared dead and reaped. Memory per connection is therefore capped at the queue ceiling plus one pending result. The collapse itself is an internal re-attach, run on the refresh executor. "Pause" is dispatcher-side, not writer-side: the overflow marks the connection collapsing, the dispatcher stops enqueueing to it (heartbeats keep flowing — a 60 s ALB idle reap mid-recovery would be self-inflicted), and the executor computes a fresh init from storage — never derived from the group's shared set, which may lag the fence. For a delta connection this runs under the storage lock (§7.2), which is what makes the swap airtight: while the lock is held no new record can commit, so clearing the queue, enqueueing the init, and advancing the connection's fence happen before any post-fence frame can exist; queued older records die on the fence. For a snapshot connection the read may be lock-free on DynamoDB — level-triggered mode heals any mid-read commit at the next refresh. Recovery recomputes on every collapse, self-bounded at roughly one re-run per queue-capacity of events per connection — a wedged reader costs a trickle of executor work, not a loop. Bookkeeping edges, pinned: seq is stamped by the connection's writer as each frame flushes, so cleared frames never consumed numbers and the no-gap invariant survives collapses; an end frame bypasses the queue bound (one tiny frame — the stream closes behind it); the send-timeout reap frees the connection's cap slot, so a ghost holds capacity for roughly a heartbeat plus the timeout, not for TCP's minutes. Worker-side storage errors (a DynamoDB throttle mid-refresh, a re-seed failing mid-stream) get one bounded retry, then the didactic end. The attach pipeline order is fixed the other way: validate → cap-check-and-register (one atomic step under the registry lock — the 429 fires here, before any headers, and two racing attaches can't both squeeze under a cap) → seed → then the 200 and init — so attach-time failures, including the member-ceiling 400, are always ordinary JSON errors. Internal re-attaches (§5.6 re-seeds, collapse recoveries) bypass the cap check: the connection already owns its slot, and a recovery that could 429 would be a self-inflicted outage. On shutdown, serve() stops the workers and drops a sentinel into every connection queue; writers exit, sockets close, no final frame is promised (§4.2). Overload on the work side degrades the same way: snapshot dirty-flags coalesce by nature, and the delta bus is bounded with collapse-on-overflow (§6.2) — no queue in the system grows with write volume.
6.5Lock discipline — two locks, one legal order
There are exactly two locks: the storage lock (the existing RLock serializing all engine access) and a new registry lock (guarding the subscription tables). One order is legal: storage → registry — attach holds storage while registering (§5.1), and the post-commit publish holds storage while appending to the bus. The reverse never happens: the worker threads copy the subscriber lists they need under the registry lock, release it, and only then touch storage (initial queries, neighbor re-reads, refreshes) — so the AB-BA inversion is structurally impossible rather than carefully avoided. Membership state lives in one atomically-swapped reference per group — {compiled specs, trigger set, fence, set}. The dispatcher is its only mutator (set transitions per record); re-seeds never mutate: the executor builds a fresh reference under the storage lock and swaps it in, and a record mid-evaluation against the old reference mutates only the doomed set — harmless by construction. The one seeding carve-out: a new group's reference is built on the attach thread, also under the storage lock (delta seeds hold it on every engine — §5.1/§7.2), and installed atomically with registration; attaches to an existing group never touch its reference (§5.2). Per-group debounce state is owned solely by the refresh executor; the dispatcher hands dirty marks over a queue instead of mutating deadlines itself. The per-connection queues are the one thread-safe handoff between workers and SSE writers. Recovery inits order by fence and by the lock, not by scheduling luck (§6.4).
6.6Deployment reality
| Deployment | Streams? | Why |
|---|---|---|
morphdb start (stdlib server, SQLite) — the default | Fully correct | Single process sees every write. The primary target, and it gets the complete feature. |
| One container / VM against Postgres or DynamoDB | Fully correct | Still one process; the bus still sees every write. |
| Several stateless instances behind a balancer | Per-instance only | A stream attached to instance A misses writes that land on instance B. Honest ceiling — see below. |
| Lambda Function URL (the hosted-demo deploy) | No — explicit 501 | Lambda response streaming is native to Node.js runtimes only; Python would need the Lambda Web Adapter sidecar plus RESPONSE_STREAM invoke mode — real machinery to stream from a per-request billing model that's wrong for held-open connections anyway. watch() polling covers it, automatically, via the capability flag. |
v1's bus is in-process (N5). Running N instances gives each stream a 1/N view of writes. The upgrade paths are known and additive, in cost order: sticky-route by app key at the balancer (zero code — an app's writers and watchers land on one instance); Postgres LISTEN/NOTIFY as a cross-instance bus (~40 lines, same record JSON); DynamoDB Streams as the bus for the Dynamo backend — which would also capture out-of-band table writers, the one writer class the in-process bus can never see. None of these change the client contract, which is the point of speccing the interface now.
Performance — where a generic interface gets expensive
A subscription interface this general has two structural costs that no implementation cleverness removes — they can only be bounded and priced. This section does the arithmetic, worst cases included, because "generalizable" is exactly where streaming systems go quadratic and die (§3's scar tissue: Meteor's observer melt, Supabase's changes×subscriptions warning, RethinkDB's per-shard predicate tax).
7.1The cost anatomy of one write
| Step | Cost | When |
|---|---|---|
| interested-gate | k dict probes (type + its relation-neighbor types), short-circuiting on the app — ~0.1 µs each | every write — the total cost when nothing is streaming |
| publish | 1 queue append, ~1 µs | subscribers exist for the app+type |
| delta evaluation | ~1–5 µs per distinct delta query (identical queries evaluate once — §6.3) | per delta query on trigger types |
attach (init) | snapshot: the query verbatim (lock-free on DynamoDB); delta: a storage-list of the type filtered through the compiled specs — O(type size), under the storage lock on every engine, ConsistentRead on DynamoDB (§5.1/§7.2) | every (re)connect — delta herds serialize here (§7.2, wall 3) |
| record assembly | the write's own projection, relocated inside the lock-held window (§6.1) — no new reads; on DynamoDB the relation-projection edge reads are the heavy part | every write on an interested type |
| neighbor re-read | 1 object get plus its relation projection per touched neighbor, shared across subs — on DynamoDB the edge reads dominate (one edge query per relation on the type) | relation writes only |
| delta fan-out | ~0.3–1 KB per matching sub (one object + SSE framing) | per matching sub |
| snapshot refresh | 1 full list query per distinct dirty query-hash per debounce window + result-sized frame per subscriber | snapshot subs on trigger types |
Delta mode's marginal cost per write is microseconds of CPU and a kilobyte of egress per matching watcher — it stays flat as data grows. Snapshot mode's marginal cost is a query plus a result-sized payload, which inherits the backend's economics. That asymmetry drives every default below.
7.2The two walls
Wall 1 — W×S predicate work (delta). Every write tests every delta subscription on its trigger types. 100 writes/s against 500 delta subs on one hot type = 50k evaluations/s ≈ 50–250 ms of CPU per second — noticeable, survivable. 1000 writes/s × 5000 subs = 5M evals/s — not survivable in Python. The bounds: the product is per-type; identical queries evaluate once and fan out (§6.3), so S counts distinct delta queries, not connections; subscriptions are capped (§7.3); and MorphDB's stated scale (small hosted apps) sits 2–3 orders of magnitude below the cliff. Stated, not hidden.
Wall 2 — refresh × result-size (snapshot). The hostile case, worked honestly. A hot type holds 5,000 objects (~1 KB each, ~5 MB per full read); 20 writes/s arrive steadily; 200 snapshot streams watch it with 200 distinct query hashes (worst case: zero coalescing win):
- SQLite/Postgres, refresh=200 ms: every window, all 200 hashes are dirty → a theoretical 1,000 re-runs/s. The sequential refresh executor is the structural governor: at 2–10 ms per indexed query it sustains ~100–500 re-runs/s, and dirty flags coalesce while it's busy — so the system degrades to staleness (refreshes arrive every 0.4–2 s instead of 0.2 s), never to collapse. Egress is the real bill: if results average 50 objects (~50 KB), 200 pushes/s ≈ 10 MB/s. That saturates links before CPU. Mitigation ladder: delta mode (the ~2.5×–500× arithmetic at the end of the next bullet), then smaller
limit, longerrefresh. - DynamoDB, same scenario, naïve settings: the list path answers a filtered query by reading the whole type — a ref-partition Query plus per-item BatchGets, and BatchGet rounds each item up to 4 KB, so 5,000 × 1 KB objects ≈ 5,000 consistent read units per refresh (refreshes read consistent so a final state can't be missed — §7.5). A theoretical 1,000 re-runs/s would be ~5M RRU/s ≈ $54,000/day at on-demand rates (~$0.125/M reads since AWS's Nov-2024 price cut). That is the bankruptcy bug this section exists to prevent. What actually bounds it: 5,000 items means ~50 sequential BatchGet pages ≈ 0.5–2 s per refresh, so the sequential executor self-throttles to ~0.5–2 refreshes/s ≈ 2.5–10k RRU/s ≈ $27–108/day worst case — still alarm-worthy, hence the 1 s DynamoDB default and 500 ms floor on
refresh, the per-app caps, and the SDK preferring delta. Two more truths at this scale, stated rather than discovered: the executor is sequential, so 200 distinct dirty hashes sweep in 100–400 s — per-hash staleness is minutes here, not the uncontended ~1.5–3 s; and to keep that sweep from starving writers, DynamoDB snapshot reads — init and refresh — run outside the storage lock: fence captured under a brief acquisition, read lock-free, and anything committing mid-read is stamped past the fence, re-dirties the group, and heals at the next refresh. That argument is level-triggered and does not transfer to delta: a membership transition missed mid-sweep has no next refresh to heal it, so delta seeds, re-seeds, and recoveries hold the storage lock on every engine (with ConsistentRead on DynamoDB) — the member ceiling and the caps bound how long. SQLite/Postgres reads stay under the lock at ms-scale, where it costs nothing. Delta's advantage is structural, not a constant: its cost scales with matched changes (microseconds + ~1 KB per matching watcher) while snapshot scales with result size × refresh rate — anywhere from ~2.5× to ~500× cheaper in this scenario depending on how selectively writes match watchers.
Wall 3 — the attach herd. A deploy restart that drops 300 streams brings 300 reconnects back near-simultaneously. Snapshot attaches are cheap: ms-scale under the lock on SQL engines, lock-free on DynamoDB (§7.2). Delta attaches are the herd's real bill — O(type size) seeds that hold the storage lock on every engine (§5.1), so 300 of them on a large DynamoDB type serialize into seconds-to-minutes of degraded writes. Three things blunt it: the SDK jitters its reconnects (§8) instead of trusting EventSource's fixed retry; the per-app and per-process caps bound the herd's size; and identical snapshot queries attaching within one refresh window can be served the group's just-computed result instead of re-running (v1.1 candidate, §11). The honest v1 statement: a restart trades a few seconds of elevated attach latency for correctness — budgeted, not accidental.
MorphDB's DynamoDB list path reads the whole type to answer a filtered query (documented API-parity trade-off), so a snapshot refresh costs O(type size) RCUs no matter how narrow the filter. Streams don't create this cost — they multiply it by refresh rate. The defaults clamp the multiplier; they cannot fix the base. Hot-type + DynamoDB + snapshot streams is the one configuration this spec actively steers away from (the SDK prefers delta whenever the query is eligible — §8).
7.3The knobs, and why each exists
| Knob | Default | Bound | What it prevents |
|---|---|---|---|
| query-hash coalescing | always on | — | N identical watchers costing N re-runs (Hasura's lesson; the common case becomes the cheap case) |
refresh debounce | 200 ms · 1000 ms (DynamoDB) | clamp [50, 60000] · [500, 60000] (DynamoDB) | write bursts translating 1:1 into re-runs (RethinkDB's squash, applied server-side) |
| sequential refresh executor | 1 thread | structural | refresh storms competing with writes for the storage lock; overload degrades to staleness, not failure |
| per-connection queue | 64 events / 1 MB (env-tunable) | overflow → collapse to fresh init | slow clients holding server memory hostage; self-heals to truth |
| change-bus records | 10,000 (env-tunable) | overflow → collapse addressed delta groups (own ∪ touched types) to fresh init | write bursts outrunning the dispatcher becoming unbounded memory; writers never block |
| delta members per query group | 10,000 (env-tunable) | attach → 400; a re-seed or organic growth crossing it → end frame, same steering message | result-sized init frames and membership sets growing without bound (§5.4) |
| socket send timeout | 20 s (env-tunable) | reap frees thread, memory, and the cap slot | black-holed peers pinning a thread and kernel buffers for TCP-retry minutes (§6.4) |
| streams per app / per process | 100 / 500 (env-tunable) | attach → 429, new error code too_many_streams (one-line errors.py addition); internal re-attaches exempt (§6.4) | the W×S wall, the thread ceiling, and the attach-herd size, reached by accident. A resource bound, not protection — app keys are public namespaces, and the SDK re-probes after a 429 (§8) |
| heartbeat | 20 s | env-tunable (tests) | idle reaps by ALB/nginx (60 s defaults); surfaces clean closes (black holes are the send timeout's job). WHATWG suggests ~15 s against legacy proxies; 20 s clears every timeout in our chain with margin |
interested() gate | always on | — | any write-path cost for the 99% of MorphDB processes with zero streams open |
7.4Worked scenarios
| Scenario | Load | Verdict |
|---|---|---|
| A — localhost dev (the default user) | 3 tabs × 2 watches, ~1 write/s, SQLite | Server side: ~6 threads, <1 ms CPU/s, kilobytes/s — cheaper than today's polling (3 queries/s at all times). The binding constraint is the client: six streams is the entire HTTP/1.1 per-origin pool, shared across tabs, so the next fetch — including writes — queues behind them (§4.4). SDK guidance: 1–2 watches per page on plain-HTTP localhost, or share a stream via SharedWorker; any h2 proxy dissolves the wall. |
| B — small hosted app (the design target) | 50 users × 3 delta watches = 150 streams, 10 writes/s hot type, Postgres container | ≤1.5k predicate evals/s (~5 ms CPU/s; fewer with query dedup); worst-case all-match fan-out 10 × 150 × ~1 KB ≈ 1.5 MB/s ≈ 12 Mbit/s egress — fine for a container NIC, and realistic selectivity sits far below all-match. 150 threads ≈ tens of MB RSS. Comfortable; the balance favors delta by design. |
| C — hostile (the case Felix asked about) | 200 distinct snapshot queries on a 5k-object hot type, 20 writes/s | Degrades to bounded staleness on SQL backends (worked math §7.2); on DynamoDB the sequential sweep stretches per-hash staleness to minutes while writes keep flowing (lock-free reads, §7.2) — survivable only because of clamps + steering to delta. This scenario is the reason mode choice, refresh clamps, and caps are in the interface rather than in a tuning guide. |
| D — restart herd | 300 streams reconnect within seconds of a deploy | 300 initial reads (wall 3, §7.2): snapshot ones are cheap everywhere; delta seeds serialize under the lock — the member ceiling bounds each, jitter spreads them, caps bound the count. Every attach re-inits, so nothing is lost — seconds of elevated latency, zero missed state. |
7.5Latency, threads, memory
- Push latency: delta — commit → evaluate → queue → flush, single-digit ms on localhost (no debounce on deltas), and the refresh executor's long re-runs can't head-of-line block it (separate worker — §6.1). Snapshot — an isolated write refreshes after roughly the query time (leading edge, §6.1); under sustained writes worst-case freshness ≈
refresh+ query time. On SQL backends that beats the 2,000 ms polling default by 10–100×; on a DynamoDB hot type it's ~1.5–3 s (§7.2) — rough parity with polling on freshness, still cheaper on no-change traffic, and exactly why the SDK prefers delta there. - Threads: one per open stream (the stdlib server's model, unchanged) + two workers (dispatcher, refresh executor). Debounce is a deadline loop, not per-query timer threads. CPython threads cost ~50–100 KB touched stack; 500 streams ≈ 25–50 MB and negligible scheduler load at these event rates. The 500-stream process cap keeps us an order of magnitude inside that envelope. Beyond it lies an asyncio transport — a rewrite this spec deliberately does not buy (scope: small-scale developer tool).
- Memory per stream: bounded queue (≤64 events / 1 MB) + membership set (~60–100 B per member guid, CPython set-entry reality, capped by the member ceiling) + compiled specs. Tens of KB typical; a delta stream's floor is its result size at attach; worst case capped by the overflow collapse.
- Storage-lock coupling: on SQLite/Postgres, refreshes and attach seeds take the same global lock as writes — ms-scale reads, at most one competing with writers at a time (sequential executor). On DynamoDB, snapshot reads run outside the lock behind a fence capture, so a 2 s BatchGet sweep never stalls a writer; delta seeds hold the lock there too (§7.2) — the one deliberately expensive lock-held operation, bounded by the member ceiling. Attach herds are wall 3 either way.
- DynamoDB read consistency: attach seeds, neighbor re-reads, and refreshes use
ConsistentRead— an eventually-consistent read racing its triggering write could miss state permanently: the missed write's record predates the attach fence, and a missed final refresh has no successor to converge on. Consistent reads bill 2× the read units; §7.2's numbers already price that in. Plumbing note: single-object gets are alreadyConsistentReadtoday (which is why §5.3 neighbor re-reads need nothing) — the internalconsistent=flag is needed only on the list/seed path's Query + BatchGet (objects.pyandstorage.py, on P1's file list); the public list GET stays eventually consistent and its cost unchanged. Delta evaluation needs no read at all — it pushes the body the write itself produced.
Defaults do the steering. The SDK picks delta automatically whenever the query is eligible and falls back to snapshot only for windows/includes; raw-HTTP users get snapshot's correctness by default and read one table (§5.4) to opt into economy. Nobody should need this section to use the feature safely — it exists so the bounds are chosen, not discovered.
morphdb.js — the transport swap watch() reserved
The SDK spec's D6 froze the watch() contract precisely so this moment costs app code nothing. The upgrade, in full:
- Detect once: first
watch()fetchesGET /and reads"streaming", cached per client object. A failed detection fetch is not cached — the watcher polls and re-probes on its next attach, so a briefly-down backend can't lock the client into polling.false→ today's polling loop, unchanged (Lambda deploys land here automatically). - Choose mode — and pin what "no limit" means: a watch that passes
limitis a windowed watch — snapshot stream (or polling) on every transport, identical results everywhere. A watch with nolimitmeans the whole result on every transport: delta serves it natively; where delta is unavailable, the SDK paginates the list GET tototal(bounded by the member ceiling) instead of silently showing the server's default first 100 — one stated semantic tightening of the SDK spec's pollingwatch(), made so G6's "app code cannot tell the transport changed" is actually true for result size, not just freshness. Stream filters are emitted in explicit-operator spelling (§4.1). Delta-eligible per §5.4 →mode=delta; the SDK maintains the result set fromenter/update/leave, re-sorting locally with the server's exact comparator — pinned here because it hides three traps: (1) the sort key is(value-is-present, value)and the whole comparison reverses fordesc, so nulls sort first ascending but last descending; (2) ties break by_guidascending in both directions — the server pre-sorts by guid and relies on sort stability, so a naive comparator that reverses the tiebreak too diverges; (3) datetimes compare as the server's normalized fixed-width ISO strings, lexically — neverDate-parsed. One shared ~15-line function once those three decisions are written down, which they now are. (One engine caveat, closed in P2 rather than scoped around: Postgres's defaultORDER BYplaces NULLs opposite this comparator — its list path already diverges from SQLite/DynamoDB today. P2 adds explicitNULLS FIRST/LASTto the SQL sort — one line; SQLite ≥3.30 and Postgres both support it — plus sort-parity fixtures, so "exact" holds on every engine.) Windows orinclude→mode=snapshot; each frame replaces the set wholesale. - Same callback: the SDK still delivers
{objects, added, updated, removed, initial}— in delta mode the conveniences are literal event translations; in snapshot mode they come from the existing diff-by-_guid+_updated_at. App code cannot tell the transport changed except by its freshness. - Auth & lifecycle:
EventSourcewithapp_keyas a query param; every attach re-inits, so reconnection is semantically free. The SDK paces reconnects itself — close and reopen with full-jitter exponential backoff,random(0, min(30 s, 1 s·2ⁿ)), the same 30 s cap its polling backoff already uses — rather than trustingEventSource's fixedretry, so a restart can't bring the whole fleet back in lockstep (wall 3, §7.2).document.hiddentears the stream down and re-attaches on return, same as polling's pause. - Failure is interim, not a demotion — and it classifies by shape. Transport-shaped failures (a buffering middlebox, a network flap, a 429/5xx on attach):
EventSourcetreats any non-200 as terminal, so a naive fallback would convert every restart into permanent polling — instead the watcher polls in the interim and re-probes with backoff (and on the nextvisibilitychange). Query-shaped failures (anendframe, or a 4xx on attach): the equivalent polling query would fail identically, so falling back would just spam a failing request — the SDK surfaces the didactic message viaonErrorand stops that watcher. One class between those two: eligibility-shaped 400s fail only on the stream, so the SDK degrades rather than kills — delta-ineligibility retries once asmode=snapshot; a reserved-name collision can't occur from the SDK at all (it emits explicit-operator spellings, §4.1), and if seen anyway parks that watcher on polling, whose endpoint has no reserved names. Mechanism, sinceEventSourceexposes neither status nor body on a failed attach: on a terminal error the SDK replays the attach once as a plain fetch and classifies from the JSON — and if that replay comes back200 text/event-stream(the failure was a healed transient), it aborts the fetch immediately, classifies transport-shaped, and resumes streaming rather than holding a cap slot on a zombie read. Onlystreaming:false— the server stating this transport doesn't exist here — parks a watcher on polling for the session.
Estimated cost: ~60 lines in sdk.js, inside the seam P3 already reserved.
Implementation plan
| Phase | Ships | Size / notes |
|---|---|---|
| P1 — snapshot mode, end to end | morphdb/streams.py (registry + registry lock, bounded bus, dispatcher + refresh executor, deadline-loop debounce, SSE framing) · post-commit publish hook in db.store_transaction (outermost-commit depth counter) + record assembly moved inside the write handlers' transactions · internal consistent= read variants threaded through objects.py/storage.py · server.py SSE transport, nagle-off, socket send timeout · routes.py 501 fallback + streaming capability flag + /help entry · caps & heartbeats |
~260 LOC new module, ~40 LOC changed elsewhere. P1 populates touched from the relation writer's old/new sets (computed today, surfaced by P1) plus a pre-delete edge enumeration on object delete. P1 closes gap #5 for every legal list query, with one stated corner deferred: last-write-wins slot-steal evictions don't produce touched entries until P2's enumerate-then-delete — a filter-less snapshot stream on the evicted side can lag until any other write lands. Delta is an optimization, not a prerequisite. |
| P2 — delta mode | Eligibility 400s + member ceiling · matcher-seeded attach (§5.4) · compiled specs via _filter_specs · group-fenced membership sets · associations.py returns edge diffs (old/new/evicted) — both stores' evicted-slot and object-delete edge removals become enumerate-then-delete, and the prune/delete-type paths return touched endpoint types · schema-morph collapse/close · SQL ORDER BY gains explicit NULLS FIRST/LAST + sort-parity fixtures |
~180 LOC in streams.py plus deliberate associations.py/storage.py surgery; the matcher itself is reused, not written. |
| P3 — SDK transport | watch() feature-detect + EventSource + local merge/sort + polling fallback · SKILL.md note · migrate one example clone as the showcase ("two tabs, live") |
~60 LOC of JS in the reserved seam. |
| P4 — deferred, interface-stable | Cross-instance bus (LISTEN/NOTIFY · DynamoDB Streams) · multi-query multiplexing · webhooks off the same bus | All additive behind the same client contract; built when a deployment actually needs them. |
Testing shape. The stdlib test harness gains a ~30-line SSE reader (raw socket, parses frames with a deadline). Then, against a live threaded server: attach → init matches the equivalent GET; write from a second client → snapshot/enter/update/leave arrive with correct payloads and seq; relation write → both-ends events (the §5.3 case, including __exists flips and a slot-steal eviction emitting on all three objects' streams); enter-vs-update-vs-leave truth table (§5.2) driven through defaults and retypes; SQL-vs-matcher parity fixtures (same data through both evaluators, contains case edges pinned); attach fence (a record queued before attach never replays into the new stream); ineligible-delta 400s + member ceiling; queue overflow → single fresh init; debounce coalescing (3 rapid writes → 1 snapshot) and trailing edge (a write landing mid-refresh still produces a final refresh); schema morph → delta collapse-to-init with re-seeded membership, or didactic close when the query went illegal; app/type delete → end; caps → 429; heartbeat cadence; black-holed socket reaped by the send timeout; Lambda dispatch path → 501 + streaming:false. Harness additions this needs: a streams.reset() hook clearing registry and workers between tests (the module state outlives the harness's db.init_db re-runs); the harness flips the capability flag itself (it instantiates MorphServer directly, bypassing serve()), and reset() restores it; env knobs MORPHDB_STREAM_HEARTBEAT / _QUEUE_EVENTS / _QUEUE_BYTES / _BUS_SIZE / _SEND_TIMEOUT / _MEMBER_CAP / _APP_CAP / _PROC_CAP, read at streams init so reset() re-reads them per test — the heartbeat, overflow, reap, ceiling, and cap tests then run in milliseconds with tiny fixtures instead of minutes with thousands of objects. One more fixture: a neighbor re-read racing a delete stays silent and the delete's own record carries the leave (§5.3). The matcher needs no new semantic tests — it is the already-tested DynamoDB list matcher.
Decision log
| # | Chose | Over | Because |
|---|---|---|---|
| D1 | SSE | WebSocket · long-polling | Unidirectional data, platform-native reconnect + CORS, plain HTTP infra, and the stdlib can serve it with zero dependencies (it has no websocket server at all). |
| D2 | The list query grammar, verbatim, as the subscription language | A new subscription DSL · channels/topics | G1. The docs, the mental model, and the validation code already exist; "the query is the subscription" is the entire feature. |
| D3 | Two modes behind one endpoint; snapshot default | Delta-only (Supabase-shaped) · snapshot-only (Convex-shaped) | Snapshot buys correctness-by-construction for every query; delta buys economy for the common flat query. Each covers the other's weakness; the SDK chooses so users don't have to. |
| D4 | Membership-set delta semantics (evaluate new body against a per-query guid set, shared by the hash's subscribers and group-fenced) | Old/new body capture and predicate pairing | Same events with no old-body plumbing through the write handlers; the set also yields free total. Edge diffs (old/new/evicted neighbors) still come from the relation writer — enumeration inside the transaction, not body capture. (§5.2–5.3) |
| D5 | Ephemeral in-process bus; reconnect = fresh init | Persisted change log with replay offsets | No compaction problem, no unbounded storage, no resume-token protocol; a UI wants current truth, and init is current truth. Webhooks can motivate a durable log later (§11). |
| D6 | Publish from a post-commit hook inside the storage-lock scope, evaluate on worker threads, behind an interested() gate | Publish calls at handler ends · inline evaluation in write handlers | Handler-end publish runs after the lock releases — two writers could publish out of commit order and strand delta clients on stale state. Inside the lock, bus order = commit order by construction; a rollback can never leak an event; writes pay k dict probes when idle. (§6.2) |
| D7 | Coarse type-level triggers + query-hash coalescing + debounce | Convex-style read-set tracking | Type-level is trivially correct with edge-awareness (§5.3) and cheap to maintain; the false-refresh cost it admits is exactly what coalescing + debounce absorb. Read sets are D-family sophistication MorphDB-scale apps don't need. |
| D8 | Didactic 400 for delta-ineligible queries | Silent server-side fallback to snapshot | House rule: teach, don't surprise. A caller who asked for deltas and silently got 50 KB frames would discover it in a bandwidth bill. The SDK does the graceful choosing instead, above the wire. |
| D9 | app_key query param on this endpoint | Header-only auth · cookies | EventSource cannot set headers; app keys are namespaces, not secrets (README); the SDK's fetch-based paths keep using the header everywhere else. |
| D10 | Overflow collapses to a server-sent fresh init | Client-visible resync-and-reconnect event | One recovery path (replace state on init) instead of two; the client never orchestrates its own repair. |
| D11 | Backend-aware refresh clamps; caps that 429 | One-size defaults · unbounded attach | §7.2's DynamoDB arithmetic: the same knob value that's generous on SQLite is a four-figure daily bill on Dynamo. Limits that explain themselves beat outages that don't. |
| D12 | Lambda serves an explicit 501 + streaming:false | Pretending (buffered "SSE") · silence | A buffered event stream is a lie that times out; the capability flag routes the SDK to polling, which genuinely works there. |
| D13 | Two workers (dispatcher + refresh executor) over a bounded bus | One thread for everything · an unbounded bus | Result-sized re-runs must not head-of-line block microsecond delta pushes, and the one queue that scales with write rate needs a bound — overflow collapses to init, writers never block. (§6.1–6.2) |
| D14 | end as the terminal event name | error | EventSource fires a built-in error for transport failures; reusing the name makes server-fault and network-fault indistinguishable in the one place they must not be. |
Open questions
- Predicate-gated snapshot refresh: for filter-only snapshot queries, run the delta matcher as a pre-filter to skip refreshes no write could have affected. Free correctness (it's the same matcher) — worth it when a hot type carries many cold queries. v1.1 candidate.
- Attach reuse: a snapshot attach whose query-hash group refreshed within the current window could serve the group's cached result instead of re-querying under the lock — directly blunts the restart herd (wall 3, §7.2). v1.1 candidate.
- Delta burst squash: a
squash=msknob coalescing rapid updates to the same guid (RethinkDB precedent). YAGNI until a real chat-shaped app hits it. - Multi-query multiplexing: one stream carrying several subscriptions (solves the HTTP/1.1 6-connection budget). Interface sketch:
POST /streamwith a query list → one SSE with per-query event prefixes. Build when a page legitimately needs >5 watches. - Webhooks: the bus already carries exactly what a webhook dispatcher needs; a durable log + at-least-once delivery is the missing half (revisits D5). Gap #5 predicted this pairing.
- Parallel refresh on managed backends: the sequential refresh executor is right for SQLite's lock; Postgres/DynamoDB could run 2–4 refresh workers safely. Measure before adding threads.
- MorphRules interaction: when per-user auth lands, a stream must re-validate on rule change (close-and-reattach is probably enough). Reserve the hook in attach.
References
- The Gaps — #5 No realtime / subscriptions (the blocker this closes) · section B — realtime & collab (explicitly out of scope here)
- morphdb.js §07 — watch() and decision D6 (the transport-blind contract this spec fulfills)
- MorphRules (the auth seam reserved at attach)
- Supabase Realtime — postgres_changes (filter grammar, single-threaded WAL, the ~3,000-subscriber Broadcast cutoff) · benchmarks (RLS 50k→3–4k msgs/s collapse) · broadcast-from-database
- Firestore — onSnapshot / docChanges() · listener billing (removed-by-change bills; >30 min reconnect re-bills) · real-time queries at scale (changelog + reverse query matcher)
- RethinkDB — changes() API (
includeInitial/squash/includeOffsets, 100k feed buffer) · changefeeds guide · CoCalc's 10k-changefeed production account - Hasura — 1M active subscriptions benchmark · live-queries architecture (multiplex ≤100/batch, 1s poll, diff-before-push)
- Convex — How Convex Works (read sets, aggregated single-pass matching) · sync-engine roadmap (deltas explicitly unshipped)
- Meteor — oplog observe driver (poll-and-diff fallback conditions) · livedata tuning (O(N²) cost model) ·
cultofcoders:redis-oplog(interest-routed writes) - ElectricSQL — shapes · benchmarks (flat 0.2 ms/change on optimized filters) · Durable Streams; Rocicorp Zero — sync/IVM docs (incl. the rehydrate circuit-breaker)
- SSE — WHATWG spec §9.2 (framing,
Last-Event-ID, 15 s comment guidance) · MDN EventSource (no custom headers) · the HTTP/1.1 6-connection pitfall · ALB idle timeout (60 s default) · Lambda response streaming (Node-only native)