diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec77d4..64a306b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## 0.6.0 + +Adds an optimistic atomic increment to the live query hook, so counters feel +instant without giving up atomic safety. + +- **New: `bool.entities..useQuery().increment(id, field, by?)`** (`by` + defaults to 1). It bumps the field on the row in the same frame as the tap, + performs the write as an atomic SQL `$inc` so two people incrementing at once + cannot lose each other's clicks, and then reads the committed row back before + retiring the overlay, so the digit never dips between the optimistic bump and + the server value. A failed write rolls the bump back and surfaces the error on + the snapshot; it never throws. Resolves to the committed row, or null on + failure. + + Rapid taps are safe against their read-backs racing: each tap is stamped, and + only the newest one's read-back may settle `server`, so a stale response + landing late cannot rewind the count. And a write that commits but whose + read-back then blips offline is not rolled back or reported as an error — the + increment is durable and the doorbell echo settles the value. + + This replaces the pattern generated counter code kept reaching for: a manual + local bump reconciled against a full `refetch()`, which flickered when the + refetch raced the change broadcast, plus a `working` guard that dropped fast + taps. `increment` needs neither. Rapid taps stack on the overlay and each + lands as its own atomic `$inc`. + + For counters, likes, votes, and stock decrements, reach for `increment` + instead of `update(id, { count: current + 1 })` (a read-modify-write that + loses concurrent changes) or the one-off `updateMany({ id }, { $inc: {...} })` + (atomic, but not optimistic and not on the hook). + ## 0.5.0 Types every error the AI plane can return, and normalizes the one field whose diff --git a/package.json b/package.json index 4862148..8ca0dd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.5.0", + "version": "0.6.0", "description": "Client SDK for apps built on Bool \u2014 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/entities.ts b/src/entities.ts index e790712..e938367 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -62,9 +62,12 @@ export type FilterQuery = { $nor?: FilterQuery[]; }; -/** MongoDB-style update operators. `$set` is applied as one atomic PATCH; the - * others (`$inc`/`$mul`/`$push`/`$pull`/`$unset`) are applied read-modify-write - * (see updateMany docs — not atomic under concurrent writers). */ +/** MongoDB-style update operators. `$set`/`$unset` apply as one atomic PATCH, + * and `$inc`/`$mul` are atomic too — done in SQL (`col = col + n`) via the + * per-schema `bool_apply_numeric` function, so concurrent writers can't lose + * each other's arithmetic (older schemas without the function fall back to + * read-modify-write). Only the array operators (`$push`/`$pull`) are + * read-modify-write and not atomic under concurrent writers. See updateMany. */ export type UpdateOps = Partial<{ $set: Record; $inc: Record; @@ -155,6 +158,12 @@ export type EntityQueryResult = { create: (fields: Partial) => Promise; /** Optimistic patch. Resolves to the committed row, or null on failure. */ update: (id: string, fields: Partial) => Promise; + /** Optimistic ATOMIC increment of one numeric field (`by` defaults to 1). + * The number moves instantly, the write adds in SQL so simultaneous clicks + * can't clobber each other, and it settles from the server. Resolves to the + * committed row, or null on failure. Use this for counters/likes/votes/stock + * instead of read-then-write or a manual bump-and-refetch. */ + increment: (id: string, field: string, by?: number) => Promise; /** Optimistic delete. Resolves false on failure (row restored). */ remove: (id: string) => Promise; /** Force a full reload (rarely needed — changes arrive on their own). */ diff --git a/src/live.test.ts b/src/live.test.ts index abf71f7..ee65956 100644 --- a/src/live.test.ts +++ b/src/live.test.ts @@ -28,14 +28,23 @@ function deferred() { 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[] } = { + const calls: { + list: number; + filter: FilterQuery[]; + creates: Partial[]; + updateMany: FilterQuery[]; + } = { list: 0, filter: [], creates: [], + updateMany: [], }; // 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; + // Same idea for updateMany, so a test can hold the atomic write in flight and + // assert the optimistic overlay is already showing. + let updateManyGate: ReturnType> | null = null; const visible = () => [...rows.values()].map((r) => ({ ...r })); const handler = { @@ -75,6 +84,25 @@ function makeHarness(initial: Row[] = []) { rows.set(id, row); return { ...row }; }, + async updateMany(q: FilterQuery, ops: Record) { + calls.updateMany.push(q); + if (updateManyGate) { + const g = updateManyGate; + updateManyGate = null; + await g.promise; + } + const ids = (q.id as string[]) ?? []; + const inc = (ops.$inc ?? {}) as Record; + for (const id of ids) { + const r = rows.get(id); + if (!r) continue; + for (const [f, d] of Object.entries(inc)) { + (r as Record)[f] = Number((r as Record)[f] ?? 0) + d; + } + rows.set(id, r); + } + return { success: true, updated: ids.length, has_more: false }; + }, async delete(id: string) { rows.delete(id); return { success: true }; @@ -96,6 +124,10 @@ function makeHarness(initial: Row[] = []) { gate = deferred(); return gate; }, + gateNextUpdateMany() { + updateManyGate = deferred(); + return updateManyGate; + }, }; } @@ -523,6 +555,162 @@ describe("LiveEntityStore: optimistic mutations", () => { stop(); }); + test("increment bumps the field instantly and settles without double-counting", async () => { + const h = makeHarness([{ id: "1", rank: 5 }]); + const store = new LiveEntityStore(h.handler); + const stop = store.start(); + await tick(); + + const gate = h.gateNextUpdateMany(); + const p = store.increment("1", "rank", 1); + // Optimistic: the number moved in the same frame, before the write settled. + expect(store.getSnapshot().data[0]!.rank).toBe(6); + + gate.resolve(); + const settled = await p; + // Settled from the read-back: the overlay retired without adding a 2nd time. + expect(settled?.rank).toBe(6); + expect(store.getSnapshot().data[0]!.rank).toBe(6); + // Went through the atomic path, scoped to this id. + expect(h.calls.updateMany.at(-1)).toEqual({ id: ["1"] }); + stop(); + }); + + test("increment stacks rapid taps optimistically and lands on the summed total", async () => { + const h = makeHarness([{ id: "1", rank: 0 }]); + const store = new LiveEntityStore(h.handler); + const stop = store.start(); + await tick(); + + // Three taps, none awaited between, so the overlay shows the running sum with + // no throttle, and each write is an atomic $inc so none clobbers another. + const taps = [store.increment("1", "rank"), store.increment("1", "rank"), store.increment("1", "rank")]; + expect(store.getSnapshot().data[0]!.rank).toBe(3); // instant, cumulative + await Promise.all(taps); + expect(store.getSnapshot().data[0]!.rank).toBe(3); // atomic writes summed + stop(); + }); + + test("increment rolls back the bump and surfaces the error when the write fails", async () => { + const h = makeHarness([{ id: "1", rank: 5 }]); + const store = new LiveEntityStore(h.handler); + const stop = store.start(); + await tick(); + + h.handler.updateMany = (async () => { + throw new Error("increment failed"); + }) as typeof h.handler.updateMany; + + const result = await store.increment("1", "rank", 1); + expect(result).toBeNull(); + expect(store.getSnapshot().data[0]!.rank).toBe(5); // rolled back to committed + expect((store.getSnapshot().error as Error).message).toBe("increment failed"); + stop(); + }); + + test("increment does not double-count when the doorbell echoes the write mid-flight", async () => { + const h = makeHarness([{ id: "1", rank: 0 }]); + const store = new LiveEntityStore(h.handler); + const stop = store.start(); + await tick(); + + const gate = h.gateNextUpdateMany(); + const p = store.increment("1", "rank", 1); + expect(store.getSnapshot().data[0]!.rank).toBe(1); // optimistic, same frame + + // The private doorbell echoes our own committed write as a row-bearing ding + // (rank already 1) while the optimistic overlay is still up. A +1 delta + // overlay would land ON TOP -> 2 (the fast-tap bounce Jack hit). The absolute + // overlay SETS the field, so it stays 1. + h.ding({ table: "t", op: "UPDATE", id: "1", row: { id: "1", rank: 1 } }); + await settle(); + expect(store.getSnapshot().data[0]!.rank).toBe(1); // NOT 2 + + gate.resolve(); + await p; + expect(store.getSnapshot().data[0]!.rank).toBe(1); + stop(); + }); + + test("a stale read-back landing after a newer tap does not rewind the count", async () => { + // Two rapid taps. The atomic writes commit in order (0 -> 1 -> 2), but the + // settle read-backs race: the NEWER tap's read-back (value 2) lands first, + // then the OLDER tap's stale read-back (value 1) lands late. Without the + // per-id seq guard the late stale response would set `server` back to 1; + // the guard drops it so the digit holds at 2. + const filters: Array>> = []; + let committed = 0; + const handler = { + async list() { + return [{ id: "1", rank: 0 }]; + }, + async filter() { + const d = deferred(); + filters.push(d); + return d.promise; + }, + async updateMany(_q: FilterQuery, ops: Record) { + committed += Number(ops.$inc?.rank ?? 0); + return { success: true, updated: 1, has_more: false }; + }, + subscribe() { + return () => {}; + }, + } as unknown as EntityHandler; + + const store = new LiveEntityStore(handler); + const stop = store.start(); + await tick(); + + const p1 = store.increment("1", "rank", 1); // seq 1, commits -> 1 + const p2 = store.increment("1", "rank", 1); // seq 2, commits -> 2 + await tick(); // both writes settled; both read-backs now parked, in order + expect(filters.length).toBe(2); + expect(committed).toBe(2); + + filters[1]!.resolve([{ id: "1", rank: 2 }]); // newer tap's read-back, fresh + filters[0]!.resolve([{ id: "1", rank: 1 }]); // older tap's read-back, STALE + await Promise.all([p1, p2]); + + expect(store.getSnapshot().data[0]!.rank).toBe(2); // not rewound to 1 + stop(); + }); + + test("a read-back that fails after the write committed does not roll back or error", async () => { + // The atomic write commits, then the settle read-back blips offline. The + // increment is durable, so we must NOT surface an error or signal failure + // as if the write were lost — the doorbell echo will deliver the true value. + const handler = { + async list() { + return [{ id: "1", rank: 5 }]; + }, + async filter() { + throw new Error("read-back offline"); + }, + async updateMany() { + return { success: true, updated: 1, has_more: false }; + }, + subscribe() { + return () => {}; + }, + } as unknown as EntityHandler; + + const store = new LiveEntityStore(handler); + const stop = store.start(); + await tick(); + + const result = await store.increment("1", "rank", 1); + // No spurious error on a write that actually succeeded (the load-bearing + // fix: the old code surfaced the read-back's error and returned null here, + // which reads to the app as "the save failed"). + expect(store.getSnapshot().error).toBeNull(); + // The overlay retired to the last committed value we know (5) rather than + // dropping below it; the echo settles it to 6. It never throws. + expect(store.getSnapshot().data[0]!.rank).toBe(5); + expect(result).toBeNull(); + 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); diff --git a/src/live.ts b/src/live.ts index b424957..5eac9c1 100644 --- a/src/live.ts +++ b/src/live.ts @@ -169,6 +169,7 @@ export function matchesFilter(row: Record, query: FilterQuery): type PendingOp = | { kind: "create"; id: string; row: Partial } | { kind: "update"; id: string; patch: Partial } + | { kind: "increment"; id: string; field: string; value: number } | { kind: "remove"; id: string }; /** Generate a client-side row id (the optimistic-UI cornerstone: ONE id shared @@ -196,6 +197,11 @@ export class LiveEntityStore { /** 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; + /** Per-id monotonic increment counter — the same anti-rewind guard, but for + * an increment's settle read-back: only the NEWEST tap's read-back may write + * `server`, so an earlier tap's read-back landing late can't rewind the count + * to a stale value. Grows one small entry per row ever incremented. */ + private incSeq = new Map(); private unsubscribe: (() => void) | null = null; constructor( @@ -313,6 +319,76 @@ export class LiveEntityStore { } } + /** Optimistic ATOMIC increment of one numeric field (`by` defaults to 1). + * + * The number moves in the same frame as the tap, and the write itself is the + * atomic `$inc` (`col = col + n` in SQL), so two people incrementing at the + * same instant can't lose each other's clicks. Once it commits we read the row + * back before retiring the overlay, so the digit never dips to a stale value + * between the tap and the server value. A failed WRITE drops the overlay + * (rolls back) and surfaces the error on the snapshot; it never throws. A write + * that commits but whose settle read-back then fails is NOT rolled back — the + * increment is durable, and the row-bearing doorbell echo settles the value. + * + * The overlay stores the ABSOLUTE optimistic value (current + by), not a `+by` + * delta, and this is load-bearing. The private doorbell echoes our own write + * back as a row-bearing ding that lands on `server` while the overlay is still + * up. A delta would then add ON TOP of a committed row that already counts our + * write (0 -> optimistic 1 -> ding sets server 1, delta still +1 -> 2 -> drop + * overlay -> 1): the digit visibly bounces, worse the faster you tap. An + * absolute overlay SETS the field, so it stays idempotent with the echo (and + * with the read-back), exactly like `update`'s overlay. `current` is read from + * the live snapshot, so rapid taps that haven't settled stack correctly. */ + async increment(id: string, field: string, by = 1): Promise { + const current = this.snapshot.data.find((r) => r.id === id) ?? this.server.get(id); + const value = + Number((current as Record | undefined)?.[field] ?? 0) + by; + const op: PendingOp = { kind: "increment", id, field, value }; + // Stamp this tap so a stale read-back can't rewind `server` (see incSeq). + const seq = (this.incSeq.get(id) ?? 0) + 1; + this.incSeq.set(id, seq); + this.pending.push(op); + this.emit(); + + try { + await this.handler.updateMany({ id: [id] } as FilterQuery, { $inc: { [field]: by } }); + } catch (error) { + // The atomic write itself failed — nothing committed. Roll the overlay + // back and surface the error, exactly like a failed create/update. + this.snapshot = { ...this.snapshot, error }; + this.pending = this.pending.filter((p) => p !== op); + this.emit(); + return null; + } + + // The write COMMITTED. From here we never roll back — the increment is + // durable even if the settle read-back fails. Read the row back so `server` + // holds the true value before the overlay retires (no dip in the gap before + // the doorbell echo), but only when we are still the newest tap for this id; + // an older read-back landing late must not overwrite a fresher one. + let settled: T | null = null; + try { + const rows = await this.handler.filter({ id: [id] } as FilterQuery, undefined, 1); + settled = rows[0] ?? null; + if (this.incSeq.get(id) === seq) { + if (rows[0]) this.server.set(id, rows[0]); + else this.server.delete(id); // removed while our increment was in flight + } + this.snapshot = { ...this.snapshot, error: null }; + } catch { + // Write committed but the settle read-back failed (offline blip). Do NOT + // roll a durable write back and do NOT surface an error. Leave `server` + // untouched: the overlay retires to whatever's committed, and the + // row-bearing doorbell echo delivers the authoritative value. (Folding our + // own delta in here would double-count if the echo already landed.) + this.snapshot = { ...this.snapshot, error: null }; + } finally { + this.pending = this.pending.filter((p) => p !== op); + this.emit(); + } + return settled; + } + // ---- loading & reconciling ------------------------------------------------ private async load(): Promise { @@ -426,6 +502,13 @@ export class LiveEntityStore { } else if (op.kind === "update") { const base = byId.get(op.id); if (base) byId.set(op.id, { ...base, ...op.patch }); + } else if (op.kind === "increment") { + // SET the field to the optimistic ABSOLUTE value (not add a delta), so + // the doorbell echo of our own write can't double-count. Only when the + // row is present; incrementing something not yet loaded is a no-op + // overlay, the same way an update to an unknown id is. + const base = byId.get(op.id); + if (base) byId.set(op.id, { ...base, [op.field]: op.value } as T); } else { byId.delete(op.id); } diff --git a/src/react.tsx b/src/react.tsx index a5852c7..9139ecd 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -366,6 +366,7 @@ function useEntityHandler( error: snap.error, create: (fields) => store.create(fields), update: (id, fields) => store.update(id, fields), + increment: (id, field, by) => store.increment(id, field, by), remove: (id) => store.remove(id), refetch: () => store.refetch(), };