diff --git a/CHANGELOG.md b/CHANGELOG.md index b7399b2..3851492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # 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 73efb91..2b591db 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.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.ts b/src/client.ts index a6e822b..c7c9419 100644 --- a/src/client.ts +++ b/src/client.ts @@ -127,7 +127,19 @@ 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 }; +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; +}; /** 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 fe37e82..95e2927 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,3 +28,11 @@ 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 new file mode 100644 index 0000000..d8ae96e --- /dev/null +++ b/src/live.test.ts @@ -0,0 +1,473 @@ +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 new file mode 100644 index 0000000..65f3b38 --- /dev/null +++ b/src/live.ts @@ -0,0 +1,411 @@ +// 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 9973bec..265b0c8 100644 --- a/src/react.test.tsx +++ b/src/react.test.tsx @@ -1,7 +1,13 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { renderToString } from "react-dom/server"; import { createBoolClient } from "./client"; -import { AuthGate, BoolAuthProvider, takeResetTokenFromSearch, useBoolAuth } from "./react"; +import { + AuthGate, + BoolAuthProvider, + takeResetTokenFromSearch, + useBoolAuth, + useEntity, +} 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 @@ -97,3 +103,32 @@ 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 6bbd2bc..3583173 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -12,11 +12,19 @@ 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 }; @@ -250,3 +258,94 @@ 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(), + }; +}