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(),
};