bool.room — the ephemeral lane (presence + events) - #26
Closed
HomemadeToast57 wants to merge 9 commits into
Closed
Conversation
Adds the second lane the SDK has needed since a prod build tried to ship live
cursors through the database: bool.room carries data that flies between the
people currently in the app and is never stored. The durable lane
(bool.entities) keeps everything that must survive a reload.
Surface (final, from a three-way design review recorded in the platform's
docs/2026-07-ephemeral-broadcast.md):
bool.room.self { id, color } per-tab identity
bool.room.useOthers<P>() peers only, presence: Partial<P>
bool.room.useSetMe<P>() merged, throttled, cleared on unmount
bool.room.broadcast(event, data) one-shot, same-tick local echo
bool.room.useEventListener(event, cb) lifecycle-safe listener
bool.room.useStatus() honest delivery state
Named room, not live: "live" already means the DURABLE lane across this SDK
(LiveEntityStore, LiveQueryOptions are public exports) and across the prompt
("Live data ... comes from useQuery"), and a word on both sides of the
durable/ephemeral split is how data lands in the wrong lane. A room is who's
here right now; nobody expects a room to survive a reload. Rooms are also the
industry noun (Liveblocks, InstantDB, Socket.IO, Colyseus, PartyKit), and the
name gives per-board scoping a rename-free future (bool.room("board-1")).
Mechanics encode this month's failure catalog:
- Presence rides Supabase track()/presence sync — late joiners hydrate,
disconnects auto-clear; event-built cursors leave ghosts nothing can clean.
- presence is Partial<P>: reading .cursor.x from a peer who hasn't moved is a
build error, not a white screen that only real users ever see.
- useSetMe is a hook so unmount clears exactly the keys that component wrote;
the setter is imperative so pointer positions never enter React state.
- The ~25ms trailing-edge throttle lives in the SDK, not in a rule; the
supabase-js per-connection event budget is raised to match (default 10/s
would silently freeze cursors).
- broadcast delivers to the sender synchronously before touching the socket,
and filters the wire copy by sender id — one code path, zero self-latency,
identical behavior in one tab and ten.
- useEventListener owns subscribe/cleanup with latest-callback semantics —
StrictMode cannot double it, closures cannot go stale.
- Colors derive from ids, so every viewer agrees without transmitting them.
- No index signature: hallucinated Liveblocks members fail tsc.
- The connection is ref-counted by mounted hooks, runs the doorbell's proven
gen/backoff/refresh pattern in its own small machine (a presence-only app
has zero entity subscribers, so it can't piggyback the doorbell's count),
and reports unauthorized/unavailable honestly rather than faking liveness.
Requires the platform half (topics.room from the wristband desk + the send
policy scoped to the :room topic); on older platforms the room reports
unavailable and nothing else changes. 189 tests pass; the store is exercised
over a fake multi-peer wire, including the ghost-cursor case, throttle
collapse with final-position guarantee, echo-once, and mint-refusal honesty.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…xt.1) First real-world failure of bool.room, from a preview app: the cursor page showed "ROOM IS LIVE" with zero peers, forever. Diagnosed by driving the served app directly — an external observer client confirmed the tab was absent from presence, and a 34-second WebSocket watch showed zero frames AND zero sockets. The channel was dead and nothing in the stack noticed. Four defects, each individually silent, each fixed: CLOSED was not a handled channel state. supabase-js reports a terminal status on a dropped socket and does NOT auto-rejoin (verified against a real close: CHANNEL_ERROR "socket closed: 1005", no follow-up SUBSCRIBED), so a blip, server close, token expiry, or a StrictMode teardown racing a re-subscribe killed the room permanently. Every terminal state now tears down and retries. teardown() assigned `state` directly instead of through setStatus, so useStatus()'s useSyncExternalStore subscribers kept a stale snapshot. That is precisely why the badge claimed "live" over a dead connection — the UI was rendering a value nobody had invalidated. Status now always notifies. track()/send() failures were swallowed. supabase-js RESOLVES with the string "timed out" / "error" rather than throwing, so `void ch.track(...)` discarded every failure — a catch block would not have helped. Outcomes are inspected now, and a failed presence publish reconnects instead of going quiet. mint() had no timeout, so one hung fetch could wedge the machine with no channel, no status change and no retry — indistinguishable from healthy. Also fixes the same unhandled-CLOSED bug in the doorbell, where it was latent: a dropped socket would have stopped live entity updates permanently while the last reported status stayed "live". Same class, same file family, same fix. Seven regression tests cover recovery, including a transport that resolves "timed out" and a mint that never settles. Also hardened the test harness: its join() waited a fixed number of microtasks, which silently stopped joining at all when mint gained a timeout race — it now waits for the real handshake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The most common realtime failure is also the most mundane: a backgrounded tab has its timers throttled and its idle socket dropped, so switching away and returning finds the connection dead. Both lanes handled the drop honestly — the channel reports a terminal state and retry() runs — but only on a [1s, 5s, 15s, 60s] ladder. Observed on a deployed app: a tab left in the background reported OFFLINE and took ~30s to come back. Half a minute of "offline" on a page someone is actively looking at is indistinguishable from broken, which is exactly how this was reported. Neither machine had any wake handling; the gap was identical in both. A new shared `onWake` (visibilitychange→visible, online, focus) resets the backoff ladder and reconnects immediately. A wake while already live is ignored so tab focus doesn't churn a healthy socket, and the signals coalesce because focus/visibilitychange/online fire together. Also in this release, both found while fixing the above: - The broadcast size guard measured `JSON.stringify(x).length` — UTF-16 code units, not bytes. A payload of emoji or CJK measured at a third of what it actually sends, so an over-limit message sailed past the guard for the server to drop silently. Now measures the encoded form. - The doorbell's last-unsubscribe reset `state` by bare assignment, so every onStatus subscriber kept holding "live" for a torn-down doorbell. Same stale-snapshot bug the room lane had; now goes through setStatus. 11 new tests, all injected through the existing dep seams (no real DOM). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
supabase's removeChannel fires the channel's own status callback with CLOSED — synchronously, from inside leave(). Verified against the real client: subscribe, removeChannel, and CLOSED arrives before removeChannel returns. next.1 made CLOSED a terminal state that drives recovery (it had to — supabase-js does not auto-rejoin), which armed a loop: terminal state → teardown → leave → CLOSED → teardown → … a stack overflow, reported from a deployed app as an endless stream of "Maximum call stack size exceeded". The gen counter never breaks the loop because retry() deliberately runs within one generation. The loop needs a HEALTHY channel being torn down, which is why the first drop test (dead socket, nothing left to close) didn't catch it. Two-part fix, applied to both machines since both had the identical shape: detach the channel reference BEFORE leaving, and make the status handler answer only for the channel that is still current (identity in the room, membership in the doorbell). The reproduction test stack-overflowed against the old code and now passes, alongside proof that recovery still works after a synchronous CLOSED — on the drop path and the unmount path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three people moving cursors broke the room: positions froze while the
status flapped between live and unavailable. Measured on real infra, the
cause is presence itself — it's a server-side CRDT built for
low-frequency state, and at 3 movers × 40Hz its track() calls stopped
being acknowledged within a second (5 acks per mover, then 10s timeouts;
the observer got 3 presence syncs in 15s). The identical 120 msg/s over
broadcast delivered 97–100% at ~10ms, sustained. The flapping was our own
next.1 behavior compounding it: each timed-out track was treated as a
dead channel and forced a full reconnect — a reconnect storm.
The split, with the public API unchanged:
- Presence now carries MEMBERSHIP only — one track({}) per join. Keeps
what presence is uniquely good at (auto-clearing a closed tab's cursor,
no ghost peers) and stays far under its rate ceiling. Its failure means
the join genuinely failed, so healing there is still right.
- setMe state rides broadcast as throttled FULL-state sends on the
reserved "~me" event, fire-and-forget: no per-message acks to time out,
and a dropped frame is healed by the next one. broadcast() rejects
"~"-prefixed names so app events can't squat the wire namespace.
- Peers = membership ∪ last ~me state, with old-SDK peers' presence meta
overlaid so mixed-version rooms still see each other's state.
- Late joiners hydrate via a jittered one-shot re-send from every peer
that sees a new member — nobody waits for someone's next move.
- The throttle now scales with room size (throttleForPeers): fan-out is
O(peers) per message, so a big room degrades to slower cursors instead
of a saturated channel. 10 people ≈ 11Hz each; ≤6 keep the full 40Hz.
Acceptance, same rig as the failure: 3 movers × 40Hz through the new
store on real infra — ~70 observed position updates/s sustained for 15s,
zero status flaps, adaptive throttle engaged (6 peers → ~24Hz each).
The four transport-coupled tests now assert what a PEER observes on the
wire instead of the wire's presence map; plus new coverage for late-joiner
hydration and the reserved namespace. 211 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"IT WORKS!! But it's super, super, super laggy." Measured on the real room (9 members): p50 latency was 96ms — most of it spent waiting in our own adaptive throttle, whose n(n-1) curve slowed a 10-person room to 11Hz. A rate limit that makes the feature feel broken is not a rate limit worth having. Three changes: - throttleForPeers halves the curve: full 40Hz through 8 people, ~22Hz at 10, ~10Hz at 15 — still bounded well under the measured 2500/s tenant ceiling, no longer a slideshow at demo size. - Inbound coalescing: a room of movers delivers hundreds of ~me messages a second, and next.4 re-emitted (= re-rendered every useOthers subscriber) per message. Now the first change in a quiet period emits immediately and the flood coalesces to one emit per 16ms frame. - Stable peer identities: a peer whose meta/state didn't change keeps the same object across rebuilds, so a memoized cursor for a still person doesn't re-render because someone else moved. Teardown cancels the new timers and clears the cache; "the room emptied" emits directly, never absorbed into a cancelled trailing edge. 214 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The send throttle drops to 16ms — one send per frame, the most a 60fps screen can show — and the room-size curve now budgets against the raised tenant ceiling (10k events/s on Bool's user-apps projects, PATCHed via the Management API on dev; prod needs the same at launch): 60Hz holds through 10 people, ~37Hz at 15, ~21Hz at 20. The per-connection eventsPerSecond declaration rises 50 → 500 so a 60Hz sender plus events never brushes its own declared cap. Measured while investigating "sometimes it lags for a second": delivery is 100% even at 75Hz, but the wire has intermittent 300–500ms stalls (p99≈430ms from this vantage) regardless of rate. That tail isn't ours to fix — the render-side answer is interpolation, which ships as prompt guidance in the platform (remote cursors animate toward their target, so a stall glides instead of freezing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oms)
The rewrite Jack asked for, built as a structured refactor rather than a
blank page: the public API and the behavior tests are the spec (both
survived five emergency releases unchanged), and the internals reshape
around one RealtimeSession.
Architecture:
- session.ts (new, ~300 lines): THE connection machine — mint with a
timeout race, setAuth, refresh at 75% TTL, backoff ladder, wake
reconnect, generation counter, identity-guarded teardown. Every scar
from the July 2026 fires is written exactly once, each with a comment
naming the production failure that earned it; session.test.ts pins one
test per scar. Consumers register ChannelGroups and contain zero
connection code.
- realtime.ts (280 → 118): the doorbell is now pure fan-out.
- room.ts (722 → ~530 incl. the new API): pure membership ∪ state +
throttle/coalescing/hydration/stable-identities logic.
- client.ts: ONE session shared by the doorbell and every room — one
socket, one wristband refresh, no more duplicate lifecycles.
API — the one addition, decided with Jack against the convention survey
(Liveblocks/PartyKit/Yjs): bool.room is now CALLABLE. `bool.room` stays
the app-wide default room (every existing example works verbatim);
`bool.room("game:4")` returns a scoped room with the identical surface,
memoized per id, topic bool:<schema>:room:<id>. Ids are validated loudly
([A-Za-z0-9][A-Za-z0-9:_.-]{0,63}) — silent normalization would put two
"different" ids in one room. Rejected on purpose: provider components,
join()/leave() ceremony, useUpdateMyPresence-weight names, selectors —
recorded in room.ts's header.
Behavior changes beyond structure: NONE. All 214 pre-rewrite tests pass
with only harness-internal edits, except one test that pinned the old
"any failed track() reconnects" rule — deliberately re-pinned to the
rate-shaped join-beacon contract (that rule is what amplified the
presence meltdown into a reconnect storm).
Not published yet: next.7 waits for the platform policy v4 (scoped-topic
send/recv) and a real two-browser verification, per the proof gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator
Author
Status — ON HOLD (closed temporarily, branch preserved)Pausing by Jack's call. This branch is the complete SDK side of the realtime arc, Where it ended up
Verified before closing
To resumeReopen and merge (pairs with codehs/bool#600). At launch: publish 🤖 Generated with Claude Code |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The second lane the SDK has needed since a prod build tried to ship live cursors through the database (one gateway invocation + one Postgres write per mouse move, cursors updating ~1/sec — "this was awful").
bool.roomcarries data that flies between the people currently in the app and is never stored;bool.entitiesremains the home of everything that must survive a reload.The surface
Why it's shaped this way (the review's findings, encoded)
This design went through a three-agent review (ecosystem research with sources, an AI-authoring failure-mode critique, and a naming/extensibility memo — full record lands in the platform's
docs/2026-07-ephemeral-broadcast.md). Every choice below traces to a named failure:room, notliveLiveEntityStore/LiveQueryOptionsare public exports of this SDK, and the prompt's headline durable rule is "Live data … comes fromuseQuery". One word on both sides of the durable/ephemeral split is how chat messages end up in the lane that loses them on reload.roomis also the industry noun (Liveblocks, InstantDB, Socket.IO, Colyseus, PartyKit) and gives per-board scoping a rename-free future:bool.room("board-1").track(), not eventstrack()hydrates late joiners and auto-clears on disconnect.presence: Partial<P>.cursor.xfrom a peer who joined but hasn't moved. Under the template'sstrictTS, the unguarded read fails the build — invisible-to-the-builder, certain-for-users crashes become compile errors.useSetMeis a hookbroadcastechoes locally, synchronouslyuseEventListenerowns the lifecycleon()insideuseEffectis three chances to fail (cleanup → StrictMode double-fire, deps, stale closure). Latest-ref semantics inside; nothing to hold wrong.useMyPresence,useStorage,RoomProvider) becometscerrors instead ofundefined is not a functionin front of a user.unauthorizedand retries — never fakes liveness.Testing
189 pass (16 new store tests + 4 react-layer). The store runs over a fake multi-peer wire: presence sync with self-exclusion and
presence_refstripping, the ghost-cursor empty-presence case, throttle collapse under a 60fps burst with the final-position guarantee, pre-join presence replay, per-key unmount clearing, same-tick echo, echo-once under a server that echoes anyway, cross-store event delivery with sender ids, oversized-payload throw, mint-refusal honesty, and ref-count teardown.Platform dependency
Needs the platform half (PR on
codehs/bool):topics.roomfrom the wristband desk and the send policy scoped to the:roomtopic — disjoint from the doorbell topics by design, so a client can never forge a{table, op, row}message into anyone's entity state. On older platforms the room reportsunavailable; nothing else in the SDK changes.Publishing
0.4.0-next.0to thenextdist-tag from this branch so the platform preview can test against it before either PR merges — the same canary-first flow every battery has used.