From 284557d2f918459ffc1e06929df253c11dfc95d7 Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Mon, 27 Jul 2026 17:07:22 -0500 Subject: [PATCH 1/3] Revert "Fix useEntity throwing "No Bool client exists yet"" This reverts commit 123d8c5c4d8ed373b4a4d3cc51125adc0b627ae3. --- CHANGELOG.md | 28 ---------------------------- package.json | 2 +- src/client.test.ts | 33 --------------------------------- src/client.ts | 36 ++++++------------------------------ src/index.ts | 1 - 5 files changed, 7 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa1ff8f..3851492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,33 +1,5 @@ # Changelog -## 0.2.0-next.22 - -- **Fixes `useEntity` throwing "No Bool client exists yet" on first render.** - Two independent causes, both closed: - - 1. **The bootstrap could go unimported.** An app's client is created in - `src/lib/supabase.ts`, and `useEntity` reads it from the default registry. - But once data access goes through the hook, nothing in app code imports - `@/lib/supabase` by name — so on an app without auth (the auth layer - side-imports it), that module never loaded and the client was never - created. Fixed platform-side: the scaffold's `src/main.tsx` now imports it - for its side effect. - 2. **The registry wasn't a singleton across module instances.** Apps import - `createBoolClient` from `"bool-sdk"` and `useEntity` from - `"bool-sdk/react"` — two entry points, which Vite's dep optimizer - pre-bundles into separate chunks. With the registry in a module-scoped - `let`, a duplicated `client.js` gave each chunk its own copy: the app - registered in one, the hook read `null` from the other, and every hook - threw even though the app was wired correctly. The registry now lives on a - `Symbol.for("bool-sdk.defaultClient")` key on `globalThis`, shared by - construction regardless of how the bundler splits the graph. - -- The unregistered-client error now names the actual fix (`import - "./lib/supabase";` in `src/main.tsx`) instead of "call createBoolClient() - first", which was useless to someone whose app already calls it in a module - nothing imports. -- New `hasDefaultBoolClient()` so callers can branch instead of catching a throw. - ## 0.2.0-next.21 - **Does what `0.2.0-next.20` concluded was the real fix: stop replacing the diff --git a/package.json b/package.json index bd05c49..2b591db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.2.0-next.22", + "version": "0.2.0-next.21", "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", diff --git a/src/client.test.ts b/src/client.test.ts index 385b759..ee5ada8 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { createBoolClient, getDefaultBoolClient, - hasDefaultBoolClient, isDeploymentSubdomain, BoolAiError, type BoolClientConfig, @@ -415,11 +414,6 @@ describe("bool.ai battery", () => { }); }); -// The registry has to be a singleton across module INSTANCES, not just within -// one. Regression coverage for a real break: a generated app imported -// `createBoolClient` from "bool-sdk" and `useEntity` from "bool-sdk/react", -// nothing imported the bootstrap module at all, and every hook threw "No Bool -// client exists yet" at first render. describe("default client registry", () => { test("the last-created client is the default (hot reload re-registers)", () => { const first = createBoolClient(CONFIG); @@ -427,33 +421,6 @@ describe("default client registry", () => { const second = createBoolClient(CONFIG); expect(getDefaultBoolClient()).toBe(second); }); - - test("lives on a globalThis symbol, so a duplicated client.js shares it", () => { - // Simulates the second bundle: another copy of this module would read the - // same well-known symbol rather than its own module-scoped variable. - const client = createBoolClient(CONFIG); - const shared = (globalThis as any)[Symbol.for("bool-sdk.defaultClient")]; - expect(shared).toBe(client); - }); - - test("hasDefaultBoolClient reports registration without throwing", () => { - createBoolClient(CONFIG); - expect(hasDefaultBoolClient()).toBe(true); - }); - - test("the unregistered error names the exact fix (the import to add)", () => { - const saved = (globalThis as any)[Symbol.for("bool-sdk.defaultClient")]; - (globalThis as any)[Symbol.for("bool-sdk.defaultClient")] = null; - try { - expect(hasDefaultBoolClient()).toBe(false); - // The old message said "call createBoolClient() first", which is useless - // to someone whose app already calls it in a module nothing imports. - expect(() => getDefaultBoolClient()).toThrow(/import ".\/lib\/supabase"/); - expect(() => getDefaultBoolClient()).toThrow(/src\/main\.tsx/); - } finally { - (globalThis as any)[Symbol.for("bool-sdk.defaultClient")] = saved; - } - }); }); describe("isDeploymentSubdomain", () => { diff --git a/src/client.ts b/src/client.ts index ac2013b..c7c9419 100644 --- a/src/client.ts +++ b/src/client.ts @@ -204,44 +204,20 @@ export type BoolClient = { // The last-created client, used by the React layer (bool-sdk/react) so app // components don't have to thread the client through props. Last-created wins // so a hot-reloaded `src/lib/supabase.ts` re-registers its fresh client. -// -// Held on `globalThis`, NOT in a module-scoped `let`, because the registry must -// be a true singleton across module *instances*. An app imports -// `createBoolClient` from "bool-sdk" and `useEntity` from "bool-sdk/react" — -// two separate entry points, which Vite's dep optimizer pre-bundles into two -// chunks (`bool-sdk.js`, `bool-sdk_react.js`). If client.js gets inlined into -// both, a module-scoped variable gives each chunk its OWN registry: the app -// registers its client in one and the hook reads `null` from the other, so -// every hook throws even though the app did everything right. A symbol on -// globalThis is shared by construction, whatever the bundler does with the -// module graph. -const REGISTRY = Symbol.for("bool-sdk.defaultClient"); -type Registry = { [REGISTRY]?: BoolClient | null }; - -function registry(): Registry { - return globalThis as unknown as Registry; -} +let defaultClient: BoolClient | null = null; export function getDefaultBoolClient(): BoolClient { - const client = registry()[REGISTRY]; - if (!client) { + if (!defaultClient) { throw new Error( - "No Bool client exists yet. Add `import \"./lib/supabase\";` to " + - "src/main.tsx — that module calls createBoolClient() and registers it, " + - "and hooks like useEntity read it from there. (A file that only imports " + - "from \"bool-sdk/react\" never loads it on its own.)", + "No Bool client exists yet — call createBoolClient() first. " + + "(Bool apps do this in src/lib/supabase.ts; import from there.)", ); } - return client; + return defaultClient; } export function setDefaultBoolClient(client: BoolClient): void { - registry()[REGISTRY] = client; -} - -/** Is a client registered? Lets callers branch instead of catching a throw. */ -export function hasDefaultBoolClient(): boolean { - return Boolean(registry()[REGISTRY]); + defaultClient = client; } export function createBoolClient(config: BoolClientConfig): BoolClient { diff --git a/src/index.ts b/src/index.ts index bc06389..95e2927 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,6 @@ export { createBoolClient, getDefaultBoolClient, setDefaultBoolClient, - hasDefaultBoolClient, type BoolClient, type BoolClientConfig, type BoolAuth, From ba982b72c7a900e0e63a6e86ac95ba9cd28579a1 Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Mon, 27 Jul 2026 17:07:22 -0500 Subject: [PATCH 2/3] Revert "Merge pull request #18 from codehs/jack/live-entities" This reverts commit a117611608236119e05b7f03dfd4025886b3cb8e, reversing changes made to 0218f9cbc9fe2edbb574b5e056317e173949b0a0. --- CHANGELOG.md | 50 ----- package.json | 2 +- src/client.ts | 14 +- src/index.ts | 8 - src/live.test.ts | 473 --------------------------------------------- src/live.ts | 411 --------------------------------------- src/react.test.tsx | 37 +--- src/react.tsx | 99 ---------- 8 files changed, 3 insertions(+), 1091 deletions(-) delete mode 100644 src/live.test.ts delete mode 100644 src/live.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3851492..b7399b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,55 +1,5 @@ # Changelog -## 0.2.0-next.21 - -- **Does what `0.2.0-next.20` concluded was the real fix: stop replacing the - list.** Adds a live data layer that merges changed rows by id instead of - reloading whole lists from a server snapshot, which is what made rows pop out - and back in. - - New `useEntity` hook (`bool-sdk/react`) — one call replaces the hand-wired - `useEffect` + `useState` + `load()` + change-subscription pattern that every - app was rebuilding, and getting subtly wrong: - - ```tsx - const todos = useEntity("todos", { sort: "-created_at" }); - todos.data; todos.loading; todos.error; - await todos.create({ title }); // optimistic, rolls back on failure - await todos.update(id, { done: true }); - await todos.remove(id); - ``` - - It owns the four things the hand-rolled version got wrong: - - - **Merges by id, never replaces.** A change applies as a delta, so a stale - snapshot can no longer overwrite rows the user already added. This is the - structural fix `next.20` identified; `next.19`'s reload-hold only shrank the - window. - - **Coalesces.** The doorbell trigger fires per row, so a 50-row bulk write - rang it 50 times and cost 50 full-list reloads. Pings inside a 50ms window - now batch into ONE keyed fetch. - - **Orders responses.** Full reloads carry a monotonic sequence; a stale - response landing after a newer one is dropped instead of rewinding the view. - - **Layers optimistic writes over committed state.** An in-flight write is an - overlay, so a concurrent reload can't wipe it, and a failure rolls back by - dropping the overlay. - - Reconciling is as cheap as the ding allows: a `DELETE` applies with no fetch at - all; other ops fetch only the changed rows by id through the gateway (so - auth and telemetry still apply); a ding carrying the full row applies with no - fetch. A ding with no id (older platform trigger) degrades to one coalesced - full reload — still better than one reload per ping. - - Filtered views stay correct on their own: a row that stops matching leaves the - view, one that starts matching arrives, evaluated client-side against the same - filter DSL `bool.entities` uses. - -- `subscribeToChanges` payloads now carry `id` (and optionally `row`), matching - the platform's id-bearing doorbell. `BoolChangePayload` gained both fields; - existing `{table, op}` consumers are unaffected. -- Exports `LiveEntityStore`, `matchesFilter`, and `compareBySort` for non-React - use. - ## 0.2.0-next.20 - **Reverts the reload-hold behavior added in `0.2.0-next.19`.** That release diff --git a/package.json b/package.json index 2b591db..73efb91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.2.0-next.21", + "version": "0.2.0-next.20", "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", diff --git a/src/client.ts b/src/client.ts index c7c9419..a6e822b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -127,19 +127,7 @@ export type BoolAuth = { /** A row-data-free change notification: some row in `table` saw `op`. Refetch * whatever you derive from that table — the ping never carries the data. */ -export type BoolChangePayload = { - table?: string; - op?: string; - /** The changed row's id (present since the gateway's id-bearing doorbell; - * older triggers ping without it). Lets subscribers refetch just the changed - * rows instead of re-running their whole query. */ - id?: string | null; - /** The full row, when the ding carries it. Today it never does — the public - * doorbell channel is deliberately row-data-free — but the private-channel - * variant (minted realtime token) will ship it, and the live layer already - * applies it directly when present. */ - row?: Record; -}; +export type BoolChangePayload = { table?: string; op?: string }; /** A JSON Schema describing the shape `bool.ai.generate` should return. Passed * straight to the gateway, which validates the model's output against it. e.g. diff --git a/src/index.ts b/src/index.ts index 95e2927..fe37e82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,11 +28,3 @@ export { type UpdateManyResult, type ImportResult, } from "./entities.js"; -export { - LiveEntityStore, - matchesFilter, - compareBySort, - type EntityRow, - type LiveQueryOptions, - type LiveSnapshot, -} from "./live.js"; diff --git a/src/live.test.ts b/src/live.test.ts deleted file mode 100644 index d8ae96e..0000000 --- a/src/live.test.ts +++ /dev/null @@ -1,473 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { BoolChangePayload } from "./client.js"; -import type { EntityHandler, FilterQuery, SortSpec } from "./entities.js"; -import { - compareBySort, - LiveEntityStore, - matchesFilter, - type EntityRow, -} from "./live.js"; - -// --------------------------------------------------------------------------- -// Harness: a fake entity handler with a controllable server-side row set and -// manual promise resolution, so response ordering (the racy part) is exact. -// --------------------------------------------------------------------------- - -type Row = EntityRow & { title?: string; done?: boolean; rank?: number | null }; - -function deferred() { - let resolve!: (v: V) => void; - let reject!: (e: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function makeHarness(initial: Row[] = []) { - const rows = new Map(initial.map((r) => [r.id, { ...r }])); - const listeners = new Set<(p: BoolChangePayload) => void>(); - const calls: { list: number; filter: FilterQuery[]; creates: Partial[] } = { - list: 0, - filter: [], - creates: [], - }; - // When set, the next list/filter call parks on this deferred instead of - // resolving immediately (then clears, so later calls auto-resolve). - let gate: ReturnType> | null = null; - - const visible = () => [...rows.values()].map((r) => ({ ...r })); - const handler = { - async list() { - calls.list++; - if (gate) { - const g = gate; - gate = null; - return g.promise; - } - return visible(); - }, - async filter(q: FilterQuery) { - calls.filter.push(q); - if (gate) { - const g = gate; - gate = null; - return g.promise; - } - // Only the by-id shape the store issues needs to work here. - const ids = q.id as string[] | undefined; - return visible().filter((r) => !ids || ids.includes(r.id)); - }, - async get(id: string) { - const r = rows.get(id); - if (!r) throw new Error("not found"); - return { ...r }; - }, - async create(v: Partial) { - calls.creates.push(v); - const row = { ...v, id: v.id as string } as Row; - rows.set(row.id, row); - return { ...row }; - }, - async update(id: string, v: Partial) { - const row = { ...rows.get(id)!, ...v, id }; - rows.set(id, row); - return { ...row }; - }, - async delete(id: string) { - rows.delete(id); - return { success: true }; - }, - subscribe(cb: (p: BoolChangePayload) => void) { - listeners.add(cb); - return () => listeners.delete(cb); - }, - } as unknown as EntityHandler; - - return { - handler, - rows, - calls, - ding(p: BoolChangePayload) { - for (const l of listeners) l(p); - }, - gateNextLoad() { - gate = deferred(); - return gate; - }, - }; -} - -const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms)); -// Past the 50ms coalesce window, plus a beat for the keyed fetch to settle. -const settle = () => tick(70); - -// --------------------------------------------------------------------------- -// matchesFilter — mirrors the server translation, incl. SQL null semantics -// --------------------------------------------------------------------------- - -describe("matchesFilter", () => { - const row = { id: "1", status: "active", count: 10, tags: ["a", "b"], archived_at: null }; - - test("scalar equality, null, and array (IN) shorthands", () => { - expect(matchesFilter(row, { status: "active" })).toBe(true); - expect(matchesFilter(row, { status: "done" })).toBe(false); - expect(matchesFilter(row, { archived_at: null })).toBe(true); - expect(matchesFilter(row, { status: null })).toBe(false); - expect(matchesFilter(row, { status: ["active", "paused"] })).toBe(true); - expect(matchesFilter(row, { status: ["done"] })).toBe(false); - }); - - test("comparison operators", () => { - expect(matchesFilter(row, { count: { $gte: 10 } })).toBe(true); - expect(matchesFilter(row, { count: { $gt: 10 } })).toBe(false); - expect(matchesFilter(row, { count: { $lt: 11, $gte: 10 } })).toBe(true); - expect(matchesFilter(row, { count: { $ne: 5 } })).toBe(true); - expect(matchesFilter(row, { count: { $in: [1, 10] } })).toBe(true); - expect(matchesFilter(row, { count: { $nin: [1, 10] } })).toBe(false); - }); - - test("SQL three-valued logic: a NULL column fails comparisons, like PostgREST", () => { - expect(matchesFilter(row, { archived_at: { $ne: "x" } })).toBe(false); - expect(matchesFilter(row, { archived_at: { $gt: "2020" } })).toBe(false); - expect(matchesFilter(row, { archived_at: { $exists: false } })).toBe(true); - expect(matchesFilter(row, { count: { $exists: true } })).toBe(true); - // absent key behaves like NULL - expect(matchesFilter(row, { missing: { $eq: null } })).toBe(true); - expect(matchesFilter(row, { missing: { $ne: 1 } })).toBe(false); - }); - - test("$regex, $all, $not", () => { - expect(matchesFilter(row, { status: { $regex: "^act" } })).toBe(true); - expect(matchesFilter(row, { status: { $regex: "^x" } })).toBe(false); - expect(matchesFilter(row, { tags: { $all: ["a", "b"] } })).toBe(true); - expect(matchesFilter(row, { tags: { $all: ["a", "z"] } })).toBe(false); - expect(matchesFilter(row, { count: { $not: { $eq: 5 } } })).toBe(true); - expect(matchesFilter(row, { count: { $not: { $gte: 5 } } })).toBe(false); - }); - - test("$and / $or / $nor", () => { - expect(matchesFilter(row, { $and: [{ status: "active" }, { count: { $gt: 5 } }] })).toBe(true); - expect(matchesFilter(row, { $or: [{ status: "done" }, { count: 10 }] })).toBe(true); - expect(matchesFilter(row, { $or: [{ status: "done" }, { count: 11 }] })).toBe(false); - expect(matchesFilter(row, { $nor: [{ status: "done" }] })).toBe(true); - expect(matchesFilter(row, { $nor: [{ status: "active" }] })).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// compareBySort — postgres default order, stable ties -// --------------------------------------------------------------------------- - -describe("compareBySort", () => { - const rows: Row[] = [ - { id: "b", rank: 2 }, - { id: "a", rank: 1 }, - { id: "c", rank: null }, - { id: "d", rank: 2 }, - ]; - - test("ascending puts nulls last; descending puts them first", () => { - expect([...rows].sort(compareBySort("rank")).map((r) => r.id)).toEqual(["a", "b", "d", "c"]); - expect([...rows].sort(compareBySort("-rank")).map((r) => r.id)).toEqual(["c", "b", "d", "a"]); - }); - - test("ties break on id for stability", () => { - const sorted = [...rows].sort(compareBySort("rank")); - expect(sorted[1]!.id).toBe("b"); - expect(sorted[2]!.id).toBe("d"); - }); -}); - -// --------------------------------------------------------------------------- -// LiveEntityStore — the state machine -// --------------------------------------------------------------------------- - -describe("LiveEntityStore: loading + dings", () => { - test("initial load populates the snapshot and clears loading", async () => { - const h = makeHarness([{ id: "1", title: "one" }]); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - expect(store.getSnapshot().loading).toBe(false); - expect(store.getSnapshot().data.map((r) => r.id)).toEqual(["1"]); - stop(); - }); - - test("a burst of dings coalesces into ONE keyed fetch, not N reloads", async () => { - const h = makeHarness([{ id: "1" }]); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - expect(h.calls.list).toBe(1); - - for (let i = 2; i <= 20; i++) h.rows.set(String(i), { id: String(i) }); - for (let i = 2; i <= 20; i++) h.ding({ table: "t", op: "INSERT", id: String(i) }); - await settle(); - - expect(h.calls.list).toBe(1); // never re-listed - expect(h.calls.filter.length).toBe(1); // ONE by-id fetch for the whole burst - expect((h.calls.filter[0]!.id as string[]).length).toBe(19); - expect(store.getSnapshot().data.length).toBe(20); - stop(); - }); - - test("DELETE dings apply locally with zero fetches", async () => { - const h = makeHarness([{ id: "1" }, { id: "2" }]); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - - h.ding({ table: "t", op: "DELETE", id: "2" }); - await settle(); - - expect(store.getSnapshot().data.map((r) => r.id)).toEqual(["1"]); - expect(h.calls.filter.length).toBe(0); - expect(h.calls.list).toBe(1); - stop(); - }); - - test("a ding with no id (old trigger) degrades to one coalesced full reload", async () => { - const h = makeHarness([{ id: "1" }]); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - - h.rows.set("2", { id: "2" }); - h.ding({ table: "t", op: "INSERT" }); - h.ding({ table: "t", op: "INSERT" }); - h.ding({ table: "t", op: "INSERT" }); - await settle(); - - expect(h.calls.list).toBe(2); // initial + ONE reload for the burst - expect(store.getSnapshot().data.length).toBe(2); - stop(); - }); - - test("a row carried ON the ding applies with zero fetches (private-channel forward-compat)", async () => { - const h = makeHarness([{ id: "1" }]); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - - h.ding({ table: "t", op: "INSERT", id: "2", row: { id: "2", title: "pushed" } }); - await settle(); - - expect(store.getSnapshot().data.find((r) => r.id === "2")?.title).toBe("pushed"); - expect(h.calls.filter.length).toBe(0); - expect(h.calls.list).toBe(1); - stop(); - }); - - test("an id fetched but not returned (deleted / RLS-hidden) leaves the view", async () => { - const h = makeHarness([{ id: "1" }, { id: "2" }]); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - - h.rows.delete("2"); // row vanishes server-side before the keyed fetch lands - h.ding({ table: "t", op: "UPDATE", id: "2" }); - await settle(); - - expect(store.getSnapshot().data.map((r) => r.id)).toEqual(["1"]); - stop(); - }); - - test("filtered view: a changed row that stops matching drops out; one that starts matching drops in", async () => { - const h = makeHarness([ - { id: "1", done: false }, - { id: "2", done: true }, - ]); - const store = new LiveEntityStore(h.handler, { filter: { done: false } }); - const stop = store.start(); - await tick(); - - h.rows.set("1", { id: "1", done: true }); // leaves the filter - h.rows.set("2", { id: "2", done: false }); // enters it - h.ding({ table: "t", op: "UPDATE", id: "1" }); - h.ding({ table: "t", op: "UPDATE", id: "2" }); - await settle(); - - expect(store.getSnapshot().data.map((r) => r.id)).toEqual(["2"]); - stop(); - }); -}); - -describe("LiveEntityStore: response ordering (the anti-rewind guard)", () => { - test("a stale full-load response landing after a newer one is DROPPED", async () => { - const h = makeHarness([{ id: "1", title: "fresh" }]); - const store = new LiveEntityStore(h.handler); - - const stale = h.gateNextLoad(); // first load parks - const stop = store.start(); - await tick(); - await store.refetch(); // second load resolves immediately with "fresh" - expect(store.getSnapshot().data[0]!.title).toBe("fresh"); - - stale.resolve([{ id: "1", title: "stale" }]); // now the FIRST response lands - await tick(); - - expect(store.getSnapshot().data[0]!.title).toBe("fresh"); // not rewound - stop(); - }); -}); - -describe("LiveEntityStore: optimistic mutations", () => { - test("create renders immediately, settles to the committed row", async () => { - const h = makeHarness(); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - - const p = store.create({ title: "new" }); - expect(store.getSnapshot().data.length).toBe(1); // before the await - const created = await p; - expect(created?.title).toBe("new"); - expect(store.getSnapshot().data.length).toBe(1); // still one row (same id) - expect(store.getSnapshot().error).toBeNull(); - stop(); - }); - - test("an optimistic row SURVIVES a full refetch that doesn't include it yet", async () => { - const h = makeHarness([{ id: "1" }]); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - - // Park the create so its overlay stays in-flight... - let releaseCreate!: () => void; - const origCreate = h.handler.create.bind(h.handler); - h.handler.create = (async (v: Partial) => { - await new Promise((r) => (releaseCreate = r)); - return origCreate(v); - }) as typeof h.handler.create; - - const p = store.create({ id: "opt-1", title: "mine" }); - await tick(); - // ...and refetch while it's pending: the server list lacks opt-1. - await store.refetch(); - const ids = store.getSnapshot().data.map((r) => r.id); - expect(ids).toContain("opt-1"); // did NOT flicker away - - releaseCreate(); - await p; - expect(store.getSnapshot().data.map((r) => r.id)).toContain("opt-1"); - stop(); - }); - - test("create failure rolls back and surfaces the error without throwing", async () => { - const h = makeHarness(); - h.handler.create = (async () => { - throw new Error("insert failed"); - }) as typeof h.handler.create; - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - - const created = await store.create({ title: "doomed" }); - expect(created).toBeNull(); - expect(store.getSnapshot().data.length).toBe(0); // rolled back - expect((store.getSnapshot().error as Error).message).toBe("insert failed"); - stop(); - }); - - test("update overlays instantly and rolls back on failure", async () => { - const h = makeHarness([{ id: "1", title: "old" }]); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - - // success path - await store.update("1", { title: "new" }); - expect(store.getSnapshot().data[0]!.title).toBe("new"); - - // failure path - h.handler.update = (async () => { - throw new Error("update failed"); - }) as typeof h.handler.update; - const updated = await store.update("1", { title: "doomed" }); - expect(updated).toBeNull(); - expect(store.getSnapshot().data[0]!.title).toBe("new"); // restored - stop(); - }); - - test("remove hides instantly and restores on failure", async () => { - const h = makeHarness([{ id: "1" }]); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - - h.handler.delete = (async () => { - throw new Error("delete failed"); - }) as typeof h.handler.delete; - const p = store.remove("1"); - expect(store.getSnapshot().data.length).toBe(0); // hidden immediately - expect(await p).toBe(false); - expect(store.getSnapshot().data.length).toBe(1); // restored - stop(); - }); - - test("the doorbell echo of your own write reconciles to a no-op (no duplicates)", async () => { - const h = makeHarness(); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - - const created = await store.create({ title: "mine" }); - h.ding({ table: "t", op: "INSERT", id: created!.id }); // the echo - await settle(); - - expect(store.getSnapshot().data.length).toBe(1); - stop(); - }); -}); - -describe("LiveEntityStore: view shaping", () => { - test("sort order is maintained across delta applies", async () => { - const h = makeHarness([ - { id: "1", rank: 1 }, - { id: "3", rank: 3 }, - ]); - const store = new LiveEntityStore(h.handler, { sort: "rank" as SortSpec }); - const stop = store.start(); - await tick(); - - h.rows.set("2", { id: "2", rank: 2 }); - h.ding({ table: "t", op: "INSERT", id: "2" }); - await settle(); - - expect(store.getSnapshot().data.map((r) => r.id)).toEqual(["1", "2", "3"]); - stop(); - }); - - test("an explicit limit trims the view after sorting", async () => { - const h = makeHarness([ - { id: "1", rank: 1 }, - { id: "2", rank: 2 }, - ]); - const store = new LiveEntityStore(h.handler, { sort: "rank", limit: 2 }); - const stop = store.start(); - await tick(); - - h.rows.set("0", { id: "0", rank: 0 }); - h.ding({ table: "t", op: "INSERT", id: "0" }); - await settle(); - - expect(store.getSnapshot().data.map((r) => r.id)).toEqual(["0", "1"]); - stop(); - }); - - test("stop() detaches: dings after teardown do nothing", async () => { - const h = makeHarness([{ id: "1" }]); - const store = new LiveEntityStore(h.handler); - const stop = store.start(); - await tick(); - stop(); - - h.ding({ table: "t", op: "DELETE", id: "1" }); - await settle(); - expect(store.getSnapshot().data.length).toBe(1); // untouched - expect(h.calls.filter.length).toBe(0); - }); -}); diff --git a/src/live.ts b/src/live.ts deleted file mode 100644 index 65f3b38..0000000 --- a/src/live.ts +++ /dev/null @@ -1,411 +0,0 @@ -// The Bool live layer: a framework-agnostic sync engine over an entity table. -// -// This is the machinery `useEntity` (bool-sdk/react) sits on, and it exists -// because the hand-rolled version of this state machine — wire load(), refetch -// on every doorbell ping, layer optimistic writes — is exactly what generated -// app code kept getting subtly wrong: unguarded refetches raced each other -// (stale response lands last and rewinds the screen), a burst of pings caused -// a burst of full-list refetches, and a full-list replacement wiped optimistic -// rows whose writes were still in flight (the "items pop in and out" bug). -// Same reasoning as the auth React layer: own the state machine in one tested -// place; app code just renders. -// -// What it does, per change ding from the doorbell (see BoolChangePayload): -// - COALESCE: pings arriving within a short window batch into ONE reconcile -// pass, so a 50-row bulk write costs one round trip, not fifty. -// - DELTA-APPLY: `{op: DELETE, id}` removes locally with no fetch at all; -// other ops fetch just the changed rows by id through the gateway (full -// auth + telemetry) and upsert them. A ding that carries the full `row` -// (the future private-channel doorbell) is applied directly, zero fetches. -// - ORDER: full reloads carry a monotonic sequence number; a stale response -// that lands after a newer one is dropped, never applied. -// - OPTIMISTIC LAYER: create/update/remove render immediately as an overlay -// ON TOP of committed server state — a concurrent refetch can't wipe an -// in-flight write, and a failed write rolls back by simply dropping its -// overlay. Committed rows and the overlay share the same client-generated -// id, so the doorbell echo of your own write reconciles to a no-op. -// -// Fallbacks are graceful: a ding with no id (an old-trigger fleet, or a table -// without an `id` column) degrades to one coalesced full reload — still -// strictly better than today's ping-per-refetch. -import type { BoolChangePayload } from "./client.js"; -import type { EntityHandler, FilterQuery, SortSpec } from "./entities.js"; - -/** Rows the live layer manages. Entity tables always have a string `id`. */ -export type EntityRow = { id: string } & Record; - -export type LiveQueryOptions = { - /** Same filter DSL as `bool.entities..filter(...)`. */ - filter?: FilterQuery; - /** `-col` descending, `col` ascending. Defaults to `-created_at`. */ - sort?: SortSpec; - /** Max rows to keep in view. When set, the view is trimmed after sorting. */ - limit?: number; -}; - -export type LiveSnapshot = { - data: T[]; - /** True until the first load settles (success or error). */ - loading: boolean; - /** The most recent load/mutation error, cleared by the next success. */ - error: unknown; -}; - -// How long to gather dings before reconciling. Long enough to swallow a bulk -// write's per-row pings, short enough to be imperceptible next to the network -// hop the reconcile itself costs. -const COALESCE_MS = 50; - -// A reconcile pass that would need to fetch more than this many distinct rows -// is cheaper as one full reload. -const MAX_KEYED_FETCH = 100; - -/** Postgres-flavored comparator for a SortSpec: ascending puts NULLs last, - * descending puts them first (matching the server's default order, so a - * delta-applied row lands where the next full load would put it). Ties break - * on id so the view is stable across reconciles. */ -export function compareBySort(sort: SortSpec = "-created_at") { - const desc = sort.startsWith("-"); - const col = sort.replace(/^[-+]/, ""); - return (a: T, b: T): number => { - const av = a[col] as string | number | null | undefined; - const bv = b[col] as string | number | null | undefined; - if (av != null || bv != null) { - if (av == null) return desc ? -1 : 1; - if (bv == null) return desc ? 1 : -1; - if (av < bv) return desc ? 1 : -1; - if (av > bv) return desc ? -1 : 1; - } - return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; - }; -} - -/** Does one Mongo-style operator hold for a row value? Mirrors the SQL the - * server-side translation produces — including three-valued logic: a NULL - * column fails every comparison except an explicit null/exists check, exactly - * as PostgREST excludes those rows. */ -function operatorMatches(v: unknown, op: string, operand: unknown): boolean { - switch (op) { - case "$eq": - return operand === null ? v == null : v === operand; - case "$exists": - return operand ? v != null : v == null; - default: - break; - } - // SQL: NULL anything → NULL → row excluded. - if (v == null) return false; - switch (op) { - case "$ne": - return operand === null ? true : v !== operand; - case "$gt": - return (v as never) > (operand as never); - case "$gte": - return (v as never) >= (operand as never); - case "$lt": - return (v as never) < (operand as never); - case "$lte": - return (v as never) <= (operand as never); - case "$in": - return Array.isArray(operand) && operand.includes(v); - case "$nin": - return Array.isArray(operand) && !operand.includes(v); - case "$regex": - // POSIX ~ vs JS RegExp — identical for the common patterns app code - // writes; exotic POSIX classes may diverge until the next full load. - try { - return new RegExp(String(operand)).test(String(v)); - } catch { - return false; - } - case "$all": - return ( - Array.isArray(v) && Array.isArray(operand) && operand.every((x) => (v as unknown[]).includes(x)) - ); - case "$not": { - const [innerOp, innerVal] = Object.entries((operand ?? {}) as Record)[0] ?? []; - if (!innerOp) return true; - return !operatorMatches(v, innerOp, innerVal); - } - default: - return true; // unsupported operator — server ignores it too - } -} - -/** Client-side evaluation of the entities filter DSL, used to decide whether a - * changed row still belongs in a filtered live view without re-running the - * whole query. Mirrors the server translation in entities.ts; on any - * divergence the next full load is the source of truth. */ -export function matchesFilter(row: Record, query: FilterQuery): boolean { - for (const [column, value] of Object.entries(query)) { - if (value === undefined) continue; - if (column === "$and") { - if (!(value as FilterQuery[]).every((sub) => matchesFilter(row, sub))) return false; - } else if (column === "$or") { - if (!(value as FilterQuery[]).some((sub) => matchesFilter(row, sub))) return false; - } else if (column === "$nor") { - if ((value as FilterQuery[]).some((sub) => matchesFilter(row, sub))) return false; - } else { - const v = row[column]; - if (value === null) { - if (v != null) return false; - } else if (Array.isArray(value)) { - if (v == null || !(value as unknown[]).includes(v)) return false; - } else if (typeof value === "object") { - for (const [op, operand] of Object.entries(value as Record)) { - if (operand === undefined) continue; - if (!operatorMatches(v, op, operand)) return false; - } - } else if (v !== value) { - return false; - } - } - } - return true; -} - -type PendingOp = - | { kind: "create"; id: string; row: Partial } - | { kind: "update"; id: string; patch: Partial } - | { kind: "remove"; id: string }; - -/** Generate a client-side row id (the optimistic-UI cornerstone: ONE id shared - * by the optimistic row, the insert, and the doorbell echo). */ -function newRowId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - // Non-crypto fallback for exotic runtimes; collision odds are irrelevant at - // per-user-session row counts. - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; -} - -export class LiveEntityStore { - /** Committed (server-confirmed) rows by id. */ - private server = new Map(); - /** Optimistic overlay, in creation order, applied over `server`. */ - private pending: PendingOp[] = []; - private listeners = new Set<() => void>(); - private snapshot: LiveSnapshot = { data: [], loading: true, error: null }; - private queue: BoolChangePayload[] = []; - private flushTimer: ReturnType | null = null; - /** Monotonic full-load counter — the anti-rewind guard. Only the response to - * the NEWEST load may apply; anything else is a stale answer arriving late. */ - private loadSeq = 0; - private unsubscribe: (() => void) | null = null; - - constructor( - private handler: EntityHandler, - private opts: LiveQueryOptions = {}, - ) {} - - /** Subscribe to the doorbell and run the initial load. Returns a stop - * function; the store is restartable (React StrictMode mounts twice). */ - start(): () => void { - this.unsubscribe?.(); - this.unsubscribe = this.handler.subscribe((p) => this.onDing(p)); - void this.load(); - return () => { - this.unsubscribe?.(); - this.unsubscribe = null; - if (this.flushTimer !== null) { - clearTimeout(this.flushTimer); - this.flushTimer = null; - } - this.queue = []; - }; - } - - /** Register a snapshot listener (useSyncExternalStore-shaped). */ - onSnapshot(listener: () => void): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - /** The current immutable snapshot. Reference-stable between changes. */ - getSnapshot(): LiveSnapshot { - return this.snapshot; - } - - /** Force a full reload (sequenced like any other). */ - async refetch(): Promise { - await this.load(); - } - - // ---- optimistic mutations ------------------------------------------------- - // Each renders instantly via the overlay, settles into `server` on success, - // and rolls back by dropping its overlay on failure (error surfaced on the - // snapshot, never thrown — the view must keep rendering). - - async create(fields: Partial): Promise { - const id = typeof fields.id === "string" ? fields.id : newRowId(); - const op: PendingOp = { kind: "create", id, row: { ...fields, id } }; - this.pending.push(op); - this.emit(); - try { - const created = await this.handler.create({ ...fields, id } as Partial); - this.server.set(id, created); - this.snapshot = { ...this.snapshot, error: null }; - return created; - } catch (error) { - this.snapshot = { ...this.snapshot, error }; - return null; - } finally { - this.pending = this.pending.filter((p) => p !== op); - this.emit(); - } - } - - async update(id: string, fields: Partial): Promise { - const op: PendingOp = { kind: "update", id, patch: fields }; - this.pending.push(op); - this.emit(); - try { - const updated = await this.handler.update(id, fields); - this.server.set(id, updated); - this.snapshot = { ...this.snapshot, error: null }; - return updated; - } catch (error) { - this.snapshot = { ...this.snapshot, error }; - return null; - } finally { - this.pending = this.pending.filter((p) => p !== op); - this.emit(); - } - } - - async remove(id: string): Promise { - const op: PendingOp = { kind: "remove", id }; - this.pending.push(op); - this.emit(); - try { - await this.handler.delete(id); - this.server.delete(id); - this.snapshot = { ...this.snapshot, error: null }; - return true; - } catch (error) { - this.snapshot = { ...this.snapshot, error }; - return false; - } finally { - this.pending = this.pending.filter((p) => p !== op); - this.emit(); - } - } - - // ---- loading & reconciling ------------------------------------------------ - - private async load(): Promise { - const seq = ++this.loadSeq; - try { - const rows = this.opts.filter - ? await this.handler.filter(this.opts.filter, this.opts.sort, this.opts.limit) - : await this.handler.list(this.opts.sort, this.opts.limit); - if (seq !== this.loadSeq) return; // a newer load superseded this one - this.server = new Map(rows.map((r) => [r.id, r])); - this.snapshot = { ...this.snapshot, loading: false, error: null }; - this.emit(); - } catch (error) { - if (seq !== this.loadSeq) return; - this.snapshot = { ...this.snapshot, loading: false, error }; - this.emit(); - } - } - - private onDing(p: BoolChangePayload): void { - this.queue.push(p); - if (this.flushTimer === null) { - this.flushTimer = setTimeout(() => { - this.flushTimer = null; - void this.flush(); - }, COALESCE_MS); - } - } - - private async flush(): Promise { - const batch = this.queue.splice(0); - if (batch.length === 0) return; - - // Collapse the batch to one final op per id (a row inserted then deleted - // within the window nets out to its LAST op). Any ding without an id means - // we can't reconcile precisely — degrade the whole pass to one full load. - const finalOps = new Map(); - for (const p of batch) { - if (!p.id) return void this.load(); - finalOps.set(p.id, p); - } - - let changed = false; - const toFetch: string[] = []; - for (const [id, p] of finalOps) { - if (p.op === "DELETE") { - if (this.server.delete(id)) changed = true; - } else if (p.row) { - // Row rode the ding (private-channel doorbell) — apply directly. - changed = this.applyRow(id, p.row as T) || changed; - } else { - toFetch.push(id); - } - } - if (changed) this.emit(); - - if (toFetch.length === 0) return; - if (toFetch.length > MAX_KEYED_FETCH) return void this.load(); - - try { - const rows = await this.handler.filter( - { id: toFetch } as FilterQuery, - this.opts.sort, - toFetch.length, - ); - const returned = new Set(rows.map((r) => r.id)); - let applied = false; - for (const row of rows) applied = this.applyRow(row.id, row) || applied; - // Requested but absent: deleted meanwhile, or not visible to this viewer - // (RLS) — either way it doesn't belong in the view. - for (const id of toFetch) { - if (!returned.has(id) && this.server.delete(id)) applied = true; - } - if (applied) this.emit(); - } catch { - // Keyed reconcile failed (offline blip, gateway hiccup) — fall back to a - // sequenced full load rather than leaving the view stale. - void this.load(); - } - } - - /** Upsert one committed row, honoring the view filter: a row that no longer - * matches drops out, one that now matches drops in. Returns whether the - * committed state changed. */ - private applyRow(id: string, row: T): boolean { - const belongs = !this.opts.filter || matchesFilter(row, this.opts.filter); - if (!belongs) return this.server.delete(id); - this.server.set(id, row); - return true; - } - - // ---- view computation ----------------------------------------------------- - - private emit(): void { - const byId = new Map(this.server); - // Overlay optimistic ops in creation order. Creates and updates stay - // visible even if they wouldn't match the view filter — hiding the row the - // user JUST touched reads as data loss; the next committed reconcile - // settles membership. - for (const op of this.pending) { - if (op.kind === "create") { - byId.set(op.id, { ...(byId.get(op.id) ?? {}), ...op.row } as T); - } else if (op.kind === "update") { - const base = byId.get(op.id); - if (base) byId.set(op.id, { ...base, ...op.patch }); - } else { - byId.delete(op.id); - } - } - let data = [...byId.values()].sort(compareBySort(this.opts.sort)); - if (this.opts.limit !== undefined && data.length > this.opts.limit) { - data = data.slice(0, this.opts.limit); - } - this.snapshot = { ...this.snapshot, data }; - for (const l of this.listeners) l(); - } -} diff --git a/src/react.test.tsx b/src/react.test.tsx index 265b0c8..9973bec 100644 --- a/src/react.test.tsx +++ b/src/react.test.tsx @@ -1,13 +1,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { renderToString } from "react-dom/server"; import { createBoolClient } from "./client"; -import { - AuthGate, - BoolAuthProvider, - takeResetTokenFromSearch, - useBoolAuth, - useEntity, -} from "./react"; +import { AuthGate, BoolAuthProvider, takeResetTokenFromSearch, useBoolAuth } from "./react"; // SSR smoke tests: effects don't run in renderToString, so the provider is in // its initial loading state — enough to pin the gate/hook contract without a @@ -103,32 +97,3 @@ describe("takeResetTokenFromSearch", () => { expect(takeResetTokenFromSearch("")).toEqual({ token: null, rest: "" }); }); }); - -// SSR smoke for useEntity: effects don't run in renderToString, so this pins -// the initial contract (loading, empty data, callable shape) without a -// browser. The live state machine itself is covered headlessly in live.test.ts. -describe("useEntity", () => { - test("renders the initial loading snapshot on the server", () => { - createBoolClient(CONFIG); - function List() { - const todos = useEntity("todos"); - return ( -
- {todos.loading ? "loading" : "ready"}:{todos.data.length} -
- ); - } - const html = renderToString(); - expect(html).toContain("loading"); - expect(html).toContain("0"); - }); - - test("accepts an explicit client and returns mutation handles", () => { - const client = createBoolClient(CONFIG); - function Probe() { - const t = useEntity("todos", { client, sort: "-created_at", limit: 5 }); - return {typeof t.create === "function" ? "ok" : "bad"}; - } - expect(renderToString()).toContain("ok"); - }); -}); diff --git a/src/react.tsx b/src/react.tsx index 3583173..6bbd2bc 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -12,19 +12,11 @@ import { createContext, useContext, useEffect, - useRef, useState, - useSyncExternalStore, type FormEvent, type ReactNode, } from "react"; import { getDefaultBoolClient, type BoolClient, type BoolUser } from "./client.js"; -import { - LiveEntityStore, - type EntityRow, - type LiveQueryOptions, -} from "./live.js"; -import type { EntityHandler } from "./entities.js"; type AuthActionResult = { error: unknown }; @@ -258,94 +250,3 @@ export function useSignInForm() { signInWithGoogle: startGoogleSignIn, }; } - -// --------------------------------------------------------------------------- -// useEntity — a live view of one entity table. -// -// The data-sync state machine (initial load, doorbell subscription, coalesced -// delta reconciling, stale-response ordering, optimistic create/update/remove -// with rollback) is exactly what generated app code kept getting wrong when it -// hand-wired load() + subscribe(() => load()) — races made items pop in and -// out. Same philosophy as the auth layer above: the SDK owns the machine -// (LiveEntityStore in live.ts, where it's unit-tested headlessly); app code -// just renders. -// -// const todos = useEntity("todos", { sort: "-created_at" }); -// todos.data // live rows — updates on every change -// todos.create({ title: "hi" }) // appears instantly, rolls back on error -// todos.update(id, { done: true }) -// todos.remove(id) -// --------------------------------------------------------------------------- - -export type UseEntityResult = { - /** Live rows: committed server state with in-flight optimistic writes - * layered on top, filtered/sorted/limited per the hook options. */ - data: T[]; - /** True until the first load settles. */ - loading: boolean; - /** Latest load/mutation error (cleared by the next success). Mutations never - * throw — check this (or a mutation's return value) to surface failures. */ - error: unknown; - /** Optimistic insert. Resolves to the committed row, or null on failure - * (already rolled back). Pass an `id` to control it; one is generated - * otherwise. */ - create: (fields: Partial) => Promise; - /** Optimistic patch. Resolves to the committed row, or null on failure. */ - update: (id: string, fields: Partial) => Promise; - /** Optimistic delete. Resolves false on failure (row restored). */ - remove: (id: string) => Promise; - /** Force a full reload (rarely needed — changes arrive on their own). */ - refetch: () => Promise; -}; - -export function useEntity( - table: string, - opts?: LiveQueryOptions & { - /** Defaults to the client created by createBoolClient() — in a Bool app - * you never pass this. */ - client?: BoolClient; - }, -): UseEntityResult { - const bool = opts?.client ?? getDefaultBoolClient(); - // A new table/filter/sort/limit is a different query → fresh store. JSON - // keying means an inline `{ filter: {...} }` object literal is fine (no - // useMemo required of app code). - const key = - table + - " " + - bool.schema + - " " + - JSON.stringify([opts?.filter ?? null, opts?.sort ?? null, opts?.limit ?? null]); - const ref = useRef<{ key: string; store: LiveEntityStore } | null>(null); - if (ref.current === null || ref.current.key !== key) { - ref.current = { - key, - store: new LiveEntityStore(bool.entities[table] as unknown as EntityHandler, { - filter: opts?.filter, - sort: opts?.sort, - limit: opts?.limit, - }), - }; - } - const store = ref.current.store; - - // Subscribe + initial load, torn down on unmount or query change. The store - // is restartable, so StrictMode's mount-unmount-mount is safe. - useEffect(() => store.start(), [store]); - - const snap = useSyncExternalStore( - (cb) => store.onSnapshot(cb), - () => store.getSnapshot(), - () => store.getSnapshot(), - ); - - return { - data: snap.data, - loading: snap.loading, - error: snap.error, - create: (fields) => store.create(fields), - update: (id, fields) => store.update(id, fields), - remove: (id) => store.remove(id), - refetch: () => store.refetch(), - }; -} From c7b55499b56495349d919e9e8af388ba1fdded2f Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Mon, 27 Jul 2026 17:08:02 -0500 Subject: [PATCH 3/3] Release 0.2.0-next.23: revert the live data layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts next.21 (useEntity/LiveEntityStore) and next.22 (globalThis registry) — the id-ping + keyed-refetch architecture they optimize is being abandoned for private Broadcast channels with the row on the socket, which deletes the refetch hop instead of polishing it. Work preserved on preserve/live-entities; the reconciler half re-lands with the private-channel client. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- src/cli.test.ts | 11 +++- src/cli.ts | 130 ++++++++++++++++++++++++++++++++++-------------- 3 files changed, 105 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index 73efb91..0f3e510 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.2.0-next.20", + "version": "0.2.0-next.23", "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", diff --git a/src/cli.test.ts b/src/cli.test.ts index 9289caf..b3e7d0e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -453,7 +453,16 @@ describe("help / unknown", () => { test("no command prints usage and exits 1", async () => { const { deps, logs } = makeDeps(cwd, {}); expect(await runCli([], deps)).toBe(1); - expect(logs.join("\n")).toContain("Usage:"); + const output = logs.join("\n"); + expect(output).toContain("/ /_ ____ ____ / /"); + expect(output).toContain("Build locally. Ship to Bool."); + expect(output).toContain("Usage:"); + expect(output).not.toContain("\u001b["); + }); + test("styles help when color is enabled", async () => { + const { deps, logs } = makeDeps(cwd, {}); + expect(await runCli(["help"], { ...deps, color: true })).toBe(0); + expect(logs.join("\n")).toContain("\u001b[36m"); }); test("unknown command errors", async () => { const { deps, errors } = makeDeps(cwd, {}); diff --git a/src/cli.ts b/src/cli.ts index 4b34389..5170228 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -66,9 +66,12 @@ export type CliDeps = { log: (msg: string) => void; error: (msg: string) => void; sleep: (ms: number) => Promise; + isTTY: boolean; + color: boolean; }; function defaults(): CliDeps { + const isTTY = process.stdout.isTTY === true; return { fetch: (...args) => fetch(...args), cwd: process.cwd(), @@ -76,9 +79,43 @@ function defaults(): CliDeps { log: (m) => console.log(m), error: (m) => console.error(m), sleep: (ms) => new Promise((r) => setTimeout(r, ms)), + isTTY, + color: + process.env.NO_COLOR === undefined && + (isTTY || (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== "0")), }; } +const ESC = "\u001b["; + +function paint(deps: CliDeps, code: number, text: string): string { + return deps.color ? `${ESC}${code}m${text}${ESC}0m` : text; +} + +const bold = (deps: CliDeps, text: string) => paint(deps, 1, text); +const dim = (deps: CliDeps, text: string) => paint(deps, 2, text); +const red = (deps: CliDeps, text: string) => paint(deps, 31, text); +const green = (deps: CliDeps, text: string) => paint(deps, 32, text); +const yellow = (deps: CliDeps, text: string) => paint(deps, 33, text); +const cyan = (deps: CliDeps, text: string) => paint(deps, 36, text); + +function success(deps: CliDeps, message: string): void { + deps.log(`${green(deps, "✓")} ${message}`); +} + +function progress(deps: CliDeps, message: string): void { + deps.log(`${cyan(deps, "◆")} ${message}`); +} + +function warning(deps: CliDeps, message: string): void { + deps.log(`${yellow(deps, "⚠")} ${message}`); +} + +function commandHeader(deps: CliDeps, command: string): void { + if (!deps.isTTY) return; + deps.log(`${bold(deps, cyan(deps, "bool"))} ${dim(deps, "/")} ${bold(deps, command)}\n`); +} + /** Tiny arg parser: `--key value` / `--key=value` / bare `--flag`, plus bare * positionals (subcommands like `entities push`). */ export function parseArgs(argv: string[]): { @@ -281,9 +318,11 @@ async function writeConfigAndKey( if (keyRes.ok) { const { apiKey } = (await keyRes.json()) as { apiKey: string }; writeEnvKey(deps.cwd, "BOOL_API_KEY", apiKey); - deps.log(`Wrote the project's admin data key to ${ENV_FILE} (gitignored — keep it secret).`); + success(deps, `Saved the admin data key to ${bold(deps, ENV_FILE)}`); + deps.log(` ${dim(deps, "Gitignored automatically — keep this file secret.")}`); } else { - deps.log( + warning( + deps, `Skipped the admin data key (${keyRes.status === 404 ? "owner-only" : `HTTP ${keyRes.status}`}) — set BOOL_API_KEY yourself to read/write data locally.`, ); } @@ -302,7 +341,8 @@ async function cmdLink( const typesPath = str(flags.types) ?? DEFAULT_TYPES_PATH; const { config, name } = await writeConfigAndKey(projectId, apiUrl, tok, typesPath, deps); - deps.log(`Linked to "${name}" (${config.projectId}) — wrote ${CONFIG_FILE}.`); + success(deps, `Linked to ${bold(deps, `"${name}"`)} ${dim(deps, `(${config.projectId})`)}`); + deps.log(` Wrote ${cyan(deps, CONFIG_FILE)}`); await pullTypes(config, tok, deps); @@ -369,7 +409,7 @@ async function cmdCreate( "/api/projects", { name, template: "vite-react" }, ); - deps.log(`Created project "${project.name ?? name}" (${project.id}).`); + success(deps, `Created project ${bold(deps, `"${project.name ?? name}"`)} ${dim(deps, `(${project.id})`)}`); // 2. Confirm the project can actually be developed against BEFORE writing any // files. Local dev requires the gateway runtime; a non-gateway (v1) @@ -400,7 +440,8 @@ async function cmdCreate( mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, content); } - deps.log( + success( + deps, `Scaffolded a todo app in ${relative(deps.cwd, dir) || "."}/ (${Object.keys(files).length} files).`, ); @@ -417,7 +458,7 @@ async function cmdCreate( sub, conn, ); - deps.log(`Linked ${CONFIG_FILE} to project ${project.id}.`); + success(deps, `Linked ${cyan(deps, CONFIG_FILE)} to project ${project.id}`); // 5. Declare the todos entity so the table exists, then refresh types. // If this fails the app has no data — don't ship a broken deploy; tell the @@ -457,7 +498,7 @@ async function pullTypes(config: BoolConfig, tok: string, deps: CliDeps): Promis const out = resolve(deps.cwd, config.typesPath || DEFAULT_TYPES_PATH); mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, body); - deps.log(`Wrote entity types to ${relative(deps.cwd, out)}.`); + success(deps, `Generated entity types at ${cyan(deps, relative(deps.cwd, out))}`); } async function cmdTypes( @@ -551,8 +592,8 @@ async function cmdEntitiesPush( failed++; continue; } - deps.log(`✓ ${parsed.name}: ${body.changed ? "migrated" : "already up to date"}`); - for (const w of body.warnings ?? []) deps.log(` ⚠ ${w}`); + success(deps, `${parsed.name}: ${body.changed ? "migrated" : "already up to date"}`); + for (const w of body.warnings ?? []) warning(deps, w); } await pullTypes(config, tok, deps); @@ -591,7 +632,7 @@ async function cmdEntitiesPull( } mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, s.content); - deps.log(`✓ ${s.path}`); + success(deps, s.path); } await pullTypes(config, tok, deps); return 0; @@ -614,9 +655,11 @@ async function cmdEntities( return 0; } for (const e of entities) { - deps.log(`${e.name} (${e.access})`); - for (const f of e.fields) { - deps.log(` ${f.name}: ${f.type}${f.required ? " (required)" : ""}`); + deps.log(`${bold(deps, e.name)} ${dim(deps, `(${e.access})`)}`); + for (let i = 0; i < e.fields.length; i++) { + const f = e.fields[i]!; + const branch = i === e.fields.length - 1 ? "└─" : "├─"; + deps.log(` ${dim(deps, branch)} ${f.name}: ${cyan(deps, f.type)}${f.required ? ` ${dim(deps, "(required)")}` : ""}`); } } return 0; @@ -683,7 +726,7 @@ async function cmdDeploy( ); } const archive = createZip(entries); - deps.log(`Packed ${entries.length} files (${(archive.length / 1024).toFixed(1)} KB). Creating drop…`); + progress(deps, `Packing ${entries.length} files ${dim(deps, `(${(archive.length / 1024).toFixed(1)} KB)`)}`); const createRes = await deps.fetch(`${config.apiUrl.replace(/\/$/, "")}/api/drops`, { method: "POST", @@ -710,7 +753,7 @@ async function cmdDeploy( body: archive.buffer as ArrayBuffer, }); if (!putRes.ok) throw new CliError(`Uploading the archive failed: HTTP ${putRes.status}`); - deps.log("Uploaded. Bool is building in the cloud…"); + progress(deps, "Uploaded — building in the cloud…"); const deadline = Date.now() + DEPLOY_TIMEOUT_MS; while (Date.now() < deadline) { @@ -718,7 +761,7 @@ async function cmdDeploy( const status = (await statusRes.json().catch(() => null)) as DropStatus | null; if (!statusRes.ok || !status) throw new CliError(`Polling drop status failed: HTTP ${statusRes.status}`); if (status.status === "ready") { - deps.log(`Live at ${status.url ?? config.appUrl}`); + success(deps, `Live at ${bold(deps, status.url ?? config.appUrl)}`); return 0; } if (status.status === "failed") { @@ -731,26 +774,41 @@ async function cmdDeploy( throw new CliError("Timed out waiting for the deploy — check the project on Bool."); } -const USAGE = `bool — develop locally against a Bool project, deploy to Bool - -Usage: - bool create [name] [--path ] [--deploy] [--token ] - scaffold a new Bool todo app + project - (name is optional — one is generated) - bool link --project [--api-url ] [--token ] [--types ] - bool types [--out ] [--token ] - bool entities [--token ] list the project's data models - bool entities pull [--token ] write schemas to ${ENTITIES_DIR}/ - bool entities push [--dir ] declare local schemas on the project - bool deploy [--dir ] [--token ] - -Auth: pass --token or set BOOL_TOKEN (Bool → Settings → Access tokens). -Data key: link writes BOOL_API_KEY to ${ENV_FILE} (owner only) — pass it to -createBoolClient as \`apiKey\`.`; +const LOGO = ` __ __ + / /_ ____ ____ / / + / __ \u005c/ __ \u005c/ __ \u005c/ / + / /_/ / /_/ / /_/ / / +/_.___/\u005c____/\u005c____/_/`; + +function usage(deps: CliDeps): string { + const command = (value: string) => cyan(deps, value.padEnd(34)); + return `${cyan(deps, LOGO)} + +${bold(deps, "Build locally. Ship to Bool.")} + +${bold(deps, "Usage:")} + ${command("bool create [name]")} Scaffold a new app and project + ${command("bool link --project ")} Link this directory to a project + ${command("bool types [--out ]")} Refresh generated entity types + ${command("bool entities")} List the project's data models + ${command("bool entities pull")} Write schemas to ${ENTITIES_DIR}/ + ${command("bool entities push")} Apply local schemas to the project + ${command("bool deploy [--dir ]")} Build and publish the app + +${bold(deps, "Options:")} + ${command("--token ")} Personal access token ${dim(deps, "(or BOOL_TOKEN)")} + ${command("--api-url ")} Override the Bool API URL + ${command("--help")} Show this help + +${dim(deps, `Link writes the owner data key to ${ENV_FILE}; the file is gitignored automatically.`)}`; +} export async function runCli(argv: string[], overrides?: Partial): Promise { const deps: CliDeps = { ...defaults(), ...overrides }; const { command, positionals, flags } = parseArgs(argv); + if (command && !["help", "--help"].includes(command)) { + commandHeader(deps, command === "entities" && positionals[0] ? `entities ${positionals[0]}` : command); + } try { switch (command) { case "create": @@ -768,7 +826,7 @@ export async function runCli(argv: string[], overrides?: Partial): Prom case "pull": return await cmdEntitiesPull(flags, deps); default: - deps.error(`Unknown entities subcommand "${positionals[0]}".\n\n${USAGE}`); + deps.error(`${red(deps, "✗")} Unknown entities subcommand "${positionals[0]}".\n\n${usage(deps)}`); return 1; } case "deploy": @@ -776,15 +834,15 @@ export async function runCli(argv: string[], overrides?: Partial): Prom case undefined: case "help": case "--help": - deps.log(USAGE); + deps.log(usage(deps)); return command ? 0 : 1; default: - deps.error(`Unknown command "${command}".\n\n${USAGE}`); + deps.error(`${red(deps, "✗")} Unknown command "${command}".\n\n${usage(deps)}`); return 1; } } catch (err) { if (err instanceof CliError) { - deps.error(err.message); + deps.error(`${red(deps, "✗")} ${err.message}`); return 1; } throw err;