Skip to content

bool.room — the ephemeral lane (presence + events) - #26

Closed
HomemadeToast57 wants to merge 9 commits into
mainfrom
jack/room-battery
Closed

bool.room — the ephemeral lane (presence + events)#26
HomemadeToast57 wants to merge 9 commits into
mainfrom
jack/room-battery

Conversation

@HomemadeToast57

Copy link
Copy Markdown
Collaborator

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.room carries data that flies between the people currently in the app and is never stored; bool.entities remains the home of everything that must survive a reload.

The surface

type Presence = { cursor: { x: number; y: number } };

const others = bool.room.useOthers<Presence>();   // peers only — never you
const setMe  = bool.room.useSetMe<Presence>();    // merged, throttled, cleared on unmount

<div onPointerMove={(e) => setMe({ cursor: { x: e.clientX, y: e.clientY } })}>
  {others.map((o) => o.presence.cursor && <Cursor key={o.id} color={o.color} {...o.presence.cursor} />)}

bool.room.broadcast("reaction", { emoji: "🎉" });                        // echoes to you same-tick
bool.room.useEventListener("reaction", ({ from, data }) => burst(data)); // lifecycle handled inside
bool.room.useStatus();  // "connecting" | "live" | "unauthorized" | "unavailable"
bool.room.self;         // { id, color }

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:

Choice The failure it kills
room, not live "live" already means the durable lane — LiveEntityStore/LiveQueryOptions are public exports of this SDK, and the prompt's headline durable rule is "Live data … comes from useQuery". One word on both sides of the durable/ephemeral split is how chat messages end up in the lane that loses them on reload. room is also the industry noun (Liveblocks, InstantDB, Socket.IO, Colyseus, PartyKit) and gives per-board scoping a rename-free future: bool.room("board-1").
Presence rides track(), not events Cursors built on fire-and-forget events leave ghosts nothing can clean up — no disconnect signal exists for app code. track() hydrates late joiners and auto-clears on disconnect.
presence: Partial<P> The ghost-cursor crash: reading .cursor.x from a peer who joined but hasn't moved. Under the template's strict TS, the unguarded read fails the build — invisible-to-the-builder, certain-for-users crashes become compile errors.
useSetMe is a hook A module-level setter can't know when its component unmounts, so a cursor freezes on everyone's screen after an in-app route change. The hook clears exactly the keys its component wrote.
Setter is imperative, from the raw handler Any hook-argument/declarative shape forces pointer positions into React state → a re-render per mousemove across the board subtree.
Throttle inside the SDK (~25ms trailing) A prompt rule asking the model to throttle is forgettable; hand-rolled debounces make peers see you only after you stop moving. Trailing edge guarantees the final position lands. The supabase-js event budget is raised to match — its default 10/s limiter would silently freeze cursors.
broadcast echoes locally, synchronously Echo-off forces "burst locally AND send" (two paths, one forgettable); echo-via-server makes your own reaction wait a round trip. Local-first echo: one path, zero self-latency, one tab behaves like ten. Wire copies are filtered by sender id.
useEventListener owns the lifecycle on() inside useEffect is three chances to fail (cleanup → StrictMode double-fire, deps, stale closure). Latest-ref semantics inside; nothing to hold wrong.
Colors derive from ids Random-color-at-render strobes; transmitting colors is wasted bytes. Same hash on every client → everyone agrees, nothing sent.
No index signature Hallucinated Liveblocks members (useMyPresence, useStorage, RoomProvider) become tsc errors instead of undefined is not a function in front of a user.
Own lifecycle machine (doorbell's gen/backoff/refresh pattern) A presence-only app has zero entity subscribers, so the room can't piggyback the doorbell's ref-count. Same mint desk, same honesty rules: a refused wristband reports unauthorized and 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_ref stripping, 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.room from the wristband desk and the send policy scoped to the :room topic — 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 reports unavailable; nothing else in the SDK changes.

Publishing 0.4.0-next.0 to the next dist-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.

HomemadeToast57 and others added 9 commits July 28, 2026 19:14
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>
@HomemadeToast57

Copy link
Copy Markdown
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, 0.4.0-next.0 through next.7, all published to the next dist-tag (latest remains 0.3.3 — production apps untouched).

Where it ended up

  • RealtimeSession (session.ts): the one connection machine — mint/refresh/backoff/wake/identity-guarded teardown, every scar from the 2026-07-29 fires written once, one regression test per scar. Doorbell (280→118 lines) and room (~530) are pure consumers with zero connection code.
  • bool.room callable: default room unchanged; bool.room("game:4") scopes with the identical surface. Rejected on the record (room.ts header): providers, join/leave ceremony, selectors, heavy hook names.
  • Transport: membership on presence, state on broadcast (~me, full-state, fire-and-forget), 60Hz with room-size degradation, frame-coalesced emits, stable peer identities, UTF-8 byte guard, reserved ~ namespace.
  • 216 tests, tsc clean at close.

Verified before closing

  • Real-infra probes (scoped-room isolation, drop recovery, sustained 3×40Hz)
  • Two real browsers: Xo Duel gameplay end-to-end on next.7
  • Backward compat: 0.3.3 (this repo's main) verified doing full entity realtime against the new platform + policy v4

To resume

Reopen and merge (pairs with codehs/bool#600). At launch: publish 0.4.0 stable (same bits as next.7) so the platform can flip its seed range to ^0.4.0. Queued: $eq accepting arrays as jsonb equality (needs real-PostgREST verification first).

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant