Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.<table>.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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 12 additions & 3 deletions src/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
$inc: Record<string, number>;
Expand Down Expand Up @@ -155,6 +158,12 @@ export type EntityQueryResult<T = any> = {
create: (fields: Partial<T>) => Promise<T | null>;
/** Optimistic patch. Resolves to the committed row, or null on failure. */
update: (id: string, fields: Partial<T>) => Promise<T | null>;
/** 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<T | null>;
/** Optimistic delete. Resolves false on failure (row restored). */
remove: (id: string) => Promise<boolean>;
/** Force a full reload (rarely needed — changes arrive on their own). */
Expand Down
190 changes: 189 additions & 1 deletion src/live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,23 @@ function deferred<V>() {
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<Row>[] } = {
const calls: {
list: number;
filter: FilterQuery[];
creates: Partial<Row>[];
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<typeof deferred<Row[]>> | 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<typeof deferred<void>> | null = null;

const visible = () => [...rows.values()].map((r) => ({ ...r }));
const handler = {
Expand Down Expand Up @@ -75,6 +84,25 @@ function makeHarness(initial: Row[] = []) {
rows.set(id, row);
return { ...row };
},
async updateMany(q: FilterQuery, ops: Record<string, any>) {
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<string, number>;
for (const id of ids) {
const r = rows.get(id);
if (!r) continue;
for (const [f, d] of Object.entries(inc)) {
(r as Record<string, unknown>)[f] = Number((r as Record<string, unknown>)[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 };
Expand All @@ -96,6 +124,10 @@ function makeHarness(initial: Row[] = []) {
gate = deferred<Row[]>();
return gate;
},
gateNextUpdateMany() {
updateManyGate = deferred<void>();
return updateManyGate;
},
};
}

Expand Down Expand Up @@ -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<Row>(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<Row>(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<Row>(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<Row>(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<ReturnType<typeof deferred<Row[]>>> = [];
let committed = 0;
const handler = {
async list() {
return [{ id: "1", rank: 0 }];
},
async filter() {
const d = deferred<Row[]>();
filters.push(d);
return d.promise;
},
async updateMany(_q: FilterQuery, ops: Record<string, any>) {
committed += Number(ops.$inc?.rank ?? 0);
return { success: true, updated: 1, has_more: false };
},
subscribe() {
return () => {};
},
} as unknown as EntityHandler<Row>;

const store = new LiveEntityStore<Row>(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<Row>;

const store = new LiveEntityStore<Row>(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<Row>(h.handler);
Expand Down
Loading
Loading