From 09e962e8b20f472cb8dc3c64cdfb14efdc5ea633 Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Tue, 28 Jul 2026 09:43:37 -0500 Subject: [PATCH] Document the live data layer and the realtime contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README still described the previous design — a row-data-free ping on a public channel with "refetch on each ping" — which is both gone and, as app guidance, the pattern that made rows pop in and out. Replaces it with what callers actually use (useEntity), what happens underneath (row-bearing private channels, gateway-minted tokens), and the notes that prevent the mistakes real apps hit: keep the bootstrap import, render the hook's data directly, don't layer your own optimistic state, and batch high-frequency input instead of writing per input event. Clarifies the term that misleads everyone: "private" means token-gated, not login-gated. A public app with no accounts gets a token for every visitor. AGENTS.md gains a realtime section pointing at the platform repo's architecture doc, plus the four traps that are easy to walk back into. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 21 ++++++++++++++++ README.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d718ef0..661c450 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,3 +22,24 @@ understand ours). sync with the gateway routes in the Bool platform repo (`lib/gateway/`). - Semver discipline is load-bearing: generated apps install from a caret range on every sandbox boot, so a breaking change requires a major bump. + +## Realtime (`src/realtime.ts`, `src/live.ts`) + +Read the platform repo's `docs/2026-07-realtime.md` before changing either. +It records three generations of this design, why the first two were abandoned, +and the traps that are easy to walk back into. The short version: + +- Change payloads arrive on **private** channels joined with a short-TTL token + the gateway mints; there is **no public channel** to fall back to. Don't add + one "just in case" — the previous one cost double writes forever and leaked + row ids to anyone holding the (public) anon key. +- A payload's `id` is only trustworthy on channels whose payload shape we + control: the transport injects its own message uuid when a payload lacks one, + and it looks exactly like a row id. +- `LiveEntityStore` merges changes by id and never replaces the list from a + server snapshot. Replacing is what made rows pop in and out; two earlier + releases tried to shrink that race window before we removed it instead. +- On failure, retry and report (`connecting | live | unauthorized | unavailable`) + rather than degrading quietly. Distinguish "refused" from "unreachable": a + public app admits every visitor, so a dropped request must never surface as + *unauthorized*. diff --git a/README.md b/README.md index f2152e0..4dda4e8 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,33 @@ tested, and upgradable independently of any one app. gateway (`/_bool/v1/db`). The gateway injects the real credential server-side and pins the app's private Postgres schema — the anon key in the bundle has no data grants and can't read anything directly. -- **Realtime "doorbell".** Postgres changes broadcast a row-data-free - `{table, op}` ping on the app's public channel; `subscribeToChanges` wraps - the subscription. Refetch on each ping — the ping never carries row data. +- **Live data.** `useEntity` (from `bool-sdk/react`) gives a screen rows that + stay current, with optimistic writes: + + ```tsx + import { useEntity } from "bool-sdk/react"; + + const todos = useEntity("todos", { sort: "-created_at" }); + todos.data; // live rows + await todos.create({ title }); // appears instantly, rolls back on failure + await todos.update(id, { done: true }); + await todos.remove(id); + ``` + + Under it: a Postgres trigger broadcasts each change — **with the row** — on a + **private** channel that only a short-TTL token minted by the Bool gateway can + join, so an update reaches other viewers in one socket hop with no refetch. + Writes and first loads still go through the gateway (that's where + authorization and metering live). `subscribeToChanges` is the low-level + primitive underneath; prefer `useEntity`. + + "Private" here means **token-gated, not login-gated** — a public app with no + accounts gets a token for every visitor. When an app *does* have end users, + per-user rows additionally ride that user's own private channel. + + If a token can't be obtained (not authorized for this app, or the gateway is + unreachable) the doorbell retries with backoff and reports state rather than + degrading silently — data still loads over HTTP, it just isn't live. - **End-user auth.** `client.auth` mirrors the `supabase.auth` surface (`signUp`, `signInWithPassword`, `signInWithOAuth`, `signOut`, `getUser`, `onAuthStateChange`, password reset) but talks to the Bool gateway's users @@ -170,15 +194,46 @@ import { BoolAuthProvider, AuthGate, useBoolAuth, useSignInForm } from "bool-sdk ``` `createBoolClient` registers the client it returns as the default, which the -React layer picks up — pass `client={...}` to `` only if you -create more than one. +React layer and `useEntity` pick up — pass `client={...}` only if you create +more than one. + +> **Keep `import "./lib/supabase"` in `src/main.tsx`.** That module calls +> `createBoolClient`, and hooks find the client from that registration. Under +> `useEntity` no file mentions `@/lib/supabase` by name, so the import looks +> unused — deleting it makes every screen throw *"No Bool client exists yet"* on +> first render. + +### Live data notes + +- **Render `todos.data` directly.** Don't copy it into another `useState` and + sync them; the duplicate is what goes stale. +- **Don't add your own optimistic state.** `create`/`update`/`remove` already + apply immediately and roll back on failure. They don't throw — they resolve to + the new row (or `true`), or `null`/`false` with the reason on `error`. +- **High-frequency input must be batched.** Drawing strokes, drag positions, + sliders: buffer locally and persist **once per gesture** (pointer-up, blur, + debounce), e.g. one row holding a points array. A write per input event is + ~60 requests/second. +- **Filters and sorts** use the same DSL as `entities`: + `useEntity("todos", { filter: { done: false }, sort: "title", limit: 100 })`. + A filtered view self-maintains — rows that stop matching leave, rows that start + matching arrive. +- **`postgres_changes` does not work** on gateway-era apps (the app's schema has + no direct grants). `useEntity` is the way to react to changes. ## Compatibility -The gateway wire paths (`/_bool/v1/db`, `/_bool/v1/users`, `/_bool/v1/ai`) are -append-only: new server behavior ships under a new path/version segment, never -by mutating what existing SDK versions call. Keep this SDK in sync with the -gateway routes in the Bool platform repo (`lib/gateway/`). +The gateway wire paths (`/_bool/v1/db`, `/_bool/v1/users`, `/_bool/v1/ai`, +`/_bool/v1/realtime`) are append-only: new server behavior ships under a new +path/version segment, never by mutating what existing SDK versions call. Keep +this SDK in sync with the gateway routes in the Bool platform repo +(`lib/gateway/`). + +Realtime specifically pairs with the platform's doorbell trigger and its +`realtime.messages` policy. The platform-side architecture, the history behind +it, and the failure modes to avoid are documented in the platform repo at +`docs/2026-07-realtime.md` — read that before changing anything in +`src/realtime.ts` or `src/live.ts`. ## Development