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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## 0.2.0-next.19

- Rows no longer pop out and back in when you add several quickly. The doorbell
says "something changed" without saying which row, so an app reloads its whole
list on every ping — and a reload issued while the app's own inserts are still
in flight comes back *without* them, replacing what's on screen with a snapshot
missing rows the user already added. Measured on a live app: a row was absent
for 200ms before reappearing. It's a lost-update race, not latency; an
instantaneous network would still return a snapshot lacking uncommitted rows.

The SDK now withholds the reload signal while this client's own writes are
landing and fires once when they drain, plus a short trailing coalesce so a
burst collapses into one reload (the trigger fires per changed *row*, so a bulk
write produces N pings for one logical refresh). A ceiling bounds the hold so
continuous local editing can't starve the app of other users' changes.

**No app changes needed** — the existing `subscribe(() => load())` pattern
simply stops flickering. Apps pick this up on their next publish.

## 0.2.0-next.18

- `bool create` now verifies the new project can be developed against *before*
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.2.0-next.18",
"version": "0.2.0-next.19",
"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",
Expand Down
85 changes: 83 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ const GATEWAY_API = "v1";
// used, and the server never returns a token), so it's never exposed there.
const EU_SESSION_KEY = "bool_eu_session_token";

/** Collapse a burst of doorbell pings into one reload. The trigger fires once per
* changed ROW, so a bulk write produces N pings for what the app should treat as
* a single refresh. */
const PING_COALESCE_MS = 50;

/** Never hold a ping longer than this, even with writes still in flight —
* otherwise someone editing continuously would stop seeing other people's
* changes for as long as they kept typing. */
const MAX_HOLD_MS = 2000;

/** True when `host` is a single-label deployment subdomain of `appHost` (e.g.
* "acme.bool.so" under "bool.so") — the exact shape the platform proxy rewrites
* to /served/<label>/… keyed on the request host. Mirrors `deploymentSlugFromHost`
Expand Down Expand Up @@ -264,6 +274,31 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {

// Route REST + Storage through the gateway; leave everything else (the
// realtime WebSocket, in particular) connecting directly to Supabase.
// ── The reload race ────────────────────────────────────────────────────────
// The doorbell says "something changed" without saying WHICH row, so an app
// reloads its whole list on every ping. If that reload is issued while the
// app's own writes are still in flight, the server answers WITHOUT them — and
// the app replaces what's on screen with a snapshot missing rows the user has
// already added. They vanish, then reappear on the next ping. Adding three
// todos quickly is enough to see it.
//
// It's a lost-update race, not latency: even an instantaneous network returns
// a snapshot that legitimately lacks uncommitted rows, so no amount of speed
// fixes it.
//
// The SDK is the one place that sees both sides — it issues every write AND
// owns the broadcast handler. So hold the reload signal while this client's
// writes are landing, then fire once when they drain. App code is untouched:
// the existing `subscribe(() => load())` pattern simply stops flickering.
let pendingWrites = 0;
const drainWaiters = new Set<() => void>();

function noteWriteSettled(): void {
pendingWrites = Math.max(0, pendingWrites - 1);
// Copy before iterating: a waiter may unsubscribe during the callback.
if (pendingWrites === 0) for (const wake of [...drainWaiters]) wake();
}

const proxyFetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const raw =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
Expand All @@ -276,11 +311,19 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {
// credentials:include so the live-gate identity cookie flows to the
// gateway (same-origin or custom-domain); the viewer token covers the
// cross-origin preview.
return fetch(`${GATEWAY}/_bool/${GATEWAY_API}/db${url.pathname}${url.search}`, {
const call = fetch(`${GATEWAY}/_bool/${GATEWAY_API}/db${url.pathname}${url.search}`, {
...init,
headers,
credentials: "include",
});
// Count writes so doorbell pings can wait for them. Counted HERE rather
// than in the entities layer because both styles we teach —
// `supabase.from(...).insert(...)` and `bool.entities.x.create(...)` —
// funnel through this fetch, and only one of them goes via entities.
const method = (init?.method ?? "GET").toUpperCase();
if (method === "GET" || method === "HEAD") return call;
pendingWrites++;
return call.finally(noteWriteSettled);
}
return fetch(input as RequestInfo, init);
};
Expand Down Expand Up @@ -598,13 +641,51 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {
const subscribeToChanges = (
listener: (payload: BoolChangePayload) => void,
): (() => void) => {
let held: BoolChangePayload | null = null;
let heldSince = 0;
let flushTimer: ReturnType<typeof setTimeout> | null = null;

function flush(): void {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
const payload = held;
held = null;
heldSince = 0;
if (payload) listener(payload);
}

function scheduleFlush(): void {
if (flushTimer) return; // a flush is already pending; it'll take the latest
flushTimer = setTimeout(flush, PING_COALESCE_MS);
}

function onPing(payload: BoolChangePayload): void {
held = payload;
if (!heldSince) heldSince = Date.now();
// Wait for this client's own writes to land — but not past the ceiling, or
// continuous local editing would starve the app of other users' changes.
if (pendingWrites > 0 && Date.now() - heldSince < MAX_HOLD_MS) return;
scheduleFlush();
}

const onDrain = (): void => {
if (held) scheduleFlush();
};
drainWaiters.add(onDrain);

const channel = db
.channel("bool:" + schema)
.on("broadcast", { event: "*" }, (msg) =>
listener((msg as { payload?: BoolChangePayload }).payload ?? {}),
onPing((msg as { payload?: BoolChangePayload }).payload ?? {}),
)
.subscribe();

return () => {
drainWaiters.delete(onDrain);
if (flushTimer) clearTimeout(flushTimer);
held = null;
void db.removeChannel(channel);
};
};
Expand Down
227 changes: 227 additions & 0 deletions src/reload-race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { createBoolClient, type BoolClientConfig, type BoolChangePayload } from "./client";

// The reload race that makes rows pop out and back in.
//
// The app reloads its whole list on every doorbell ping. A reload issued while
// the app's own writes are still in flight comes back WITHOUT them, and replaces
// what's on screen — so rows the user already added vanish until the next ping.
// The SDK holds the ping until its own writes drain, then fires once.
//
// These drive the real client with `fetch` stubbed, and control exactly when each
// write resolves, so the race is deterministic rather than timing-dependent.

const CONFIG: BoolClientConfig = {
supabaseUrl: "https://upstream.supabase.test",
supabaseAnonKey: "anon-key",
schema: "bool_abc",
appOrigin: "https://bool.test",
slug: "my-app",
};

/** Writes park until released, so "in flight" is something we decide. */
let releases: Array<() => void> = [];
let respond: (url: string, init?: RequestInit) => Response;

beforeEach(() => {
releases = [];
respond = () => new Response("[]", { headers: { "content-type": "application/json" } });
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const method = (init?.method ?? "GET").toUpperCase();
if (method !== "GET" && method !== "HEAD") {
await new Promise<void>((r) => releases.push(r));
}
return respond(url, init);
}) as unknown as typeof fetch;
(globalThis as any).sessionStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
delete (globalThis as any).location;
});

/** Swap in a fake channel so no WebSocket is opened, and capture the handler the
* client registers so tests can deliver pings by hand. */
function instrument(client: ReturnType<typeof createBoolClient>) {
const seen = { handler: null as null | ((msg: unknown) => void), removed: 0 };
(client.db as any).channel = () => {
const ch: any = {
on: (_e: string, _f: unknown, cb: (msg: unknown) => void) => {
seen.handler = cb;
return ch;
},
subscribe: () => ch,
};
return ch;
};
(client.db as any).removeChannel = async () => void seen.removed++;
return seen;
}

const tick = () => new Promise((r) => setTimeout(r, 0));
/** Longer than PING_COALESCE_MS (50ms) so a scheduled flush has run. */
const afterCoalesce = () => new Promise((r) => setTimeout(r, 90));

function ping(seen: { handler: null | ((m: unknown) => void) }, payload: BoolChangePayload = {}) {
seen.handler!({ payload });
}

describe("reload is withheld while this client's writes are in flight", () => {
test("a ping during an in-flight write does not reload until it settles", async () => {
const client = createBoolClient(CONFIG);
const seen = instrument(client);
let reloads = 0;
client.subscribeToChanges(() => reloads++);

// Start a write and leave it pending.
void client.entities.todos.create({ text: "a" });
await tick();

ping(seen); // the doorbell fires for the row that just committed
await afterCoalesce();
expect(reloads).toBe(0); // withheld — a reload here would drop optimistic rows

releases.forEach((r) => r()); // the write settles
await afterCoalesce();
expect(reloads).toBe(1); // ...and now exactly one reload
});

// The reported bug: three quick adds. Every ping lands while later writes are
// still in flight, so all of them must collapse into a single reload at the end.
test("three rapid writes collapse to ONE reload, after the last settles", async () => {
const client = createBoolClient(CONFIG);
const seen = instrument(client);
let reloads = 0;
client.subscribeToChanges(() => reloads++);

void client.entities.todos.create({ text: "a" });
void client.entities.todos.create({ text: "b" });
void client.entities.todos.create({ text: "c" });
await tick();
expect(releases).toHaveLength(3);

// Each commit pings while the others are still outstanding.
releases[0]!();
ping(seen);
await tick();
releases[1]!();
ping(seen);
await tick();
expect(reloads).toBe(0);

releases[2]!();
ping(seen);
await afterCoalesce();
expect(reloads).toBe(1);
});

test("with nothing in flight a ping reloads promptly", async () => {
const client = createBoolClient(CONFIG);
const seen = instrument(client);
let reloads = 0;
client.subscribeToChanges(() => reloads++);

ping(seen);
await afterCoalesce();
expect(reloads).toBe(1);
});

// The trigger fires per changed ROW, so a bulk write produces a burst of pings
// for what the app should treat as one refresh.
test("a burst of pings coalesces into one reload", async () => {
const client = createBoolClient(CONFIG);
const seen = instrument(client);
let reloads = 0;
client.subscribeToChanges(() => reloads++);

for (let i = 0; i < 10; i++) ping(seen);
await afterCoalesce();
expect(reloads).toBe(1);
});

test("the latest payload wins when pings coalesce", async () => {
const client = createBoolClient(CONFIG);
const seen = instrument(client);
const got: BoolChangePayload[] = [];
client.subscribeToChanges((p) => got.push(p));

ping(seen, { table: "todos", op: "INSERT" });
ping(seen, { table: "todos", op: "DELETE" });
await afterCoalesce();
expect(got).toEqual([{ table: "todos", op: "DELETE" }]);
});

test("a failed write still releases the hold", async () => {
const client = createBoolClient(CONFIG);
const seen = instrument(client);
let reloads = 0;
client.subscribeToChanges(() => reloads++);

respond = () => new Response("boom", { status: 500 });
void client.entities.todos.create({ text: "a" }).catch(() => {});
await tick();
ping(seen);
await afterCoalesce();
expect(reloads).toBe(0);

releases.forEach((r) => r());
await afterCoalesce();
expect(reloads).toBe(1); // the counter must not leak on failure
});

test("reads never hold anything back", async () => {
const client = createBoolClient(CONFIG);
const seen = instrument(client);
let reloads = 0;
client.subscribeToChanges(() => reloads++);

void client.entities.todos.list(); // GET — must not count as a write
await tick();
ping(seen);
await afterCoalesce();
expect(reloads).toBe(1);
});

test("unsubscribing cancels a pending reload", async () => {
const client = createBoolClient(CONFIG);
const seen = instrument(client);
let reloads = 0;
const stop = client.subscribeToChanges(() => reloads++);

ping(seen);
stop(); // before the coalesce window elapses
await afterCoalesce();
expect(reloads).toBe(0);
expect(seen.removed).toBe(1);
});

// Raw supabase-js is the style SYNC_GUIDANCE_V2 actually teaches most apps, so
// it has to be counted too — which is why writes are tracked in proxyFetch
// rather than in the entities layer.
test("a raw supabase.from().insert() is counted as a write", async () => {
const client = createBoolClient(CONFIG);
const seen = instrument(client);
let reloads = 0;
client.subscribeToChanges(() => reloads++);

// supabase-js builders are thenable — they don't issue the request until
// awaited, so this needs a .then() to actually fire.
void client.db
.from("todos")
.insert({ text: "a" })
.then(() => {});
await tick();
expect(releases).toHaveLength(1); // the write really is in flight

ping(seen);
await afterCoalesce();
expect(reloads).toBe(0);

releases.forEach((r) => r());
await afterCoalesce();
expect(reloads).toBe(1);
});
});
Loading