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

## 0.2.0-next.25

- **Fixes live updates being silently swallowed on the public fallback channel.**
Supabase Realtime injects its own message-uuid `id` into any broadcast payload
that lacks one. The public compat channel is row-data-free by contract, so its
payloads have no row id — meaning subscribers received an `id` that looked
exactly like a row id but wasn't. The live store would keyed-fetch a
nonexistent row, apply nothing, and drop the change on the floor.

The doorbell now strips legacy-channel payloads to `{table, op}` rather than
trusting the transport's shape. Absent `id` is the signal that triggers a
coalesced full reload, which is the correct behavior on a channel that carries
no row data. Private-channel payloads are untouched — they keep `id` and `row`.

Only reachable on the fallback paths (no mint desk, revoked access, refused
join), which is also where it was hardest to notice.

## 0.2.0-next.24

- **Private realtime: live updates now arrive with the ROW on a private
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.24",
"version": "0.2.0-next.25",
"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
31 changes: 31 additions & 0 deletions src/realtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,37 @@ describe("createDoorbell: fallback & teardown", () => {
expect(got).toHaveLength(1);
});

// Regression: Supabase Realtime injects its own message-uuid `id` into any
// broadcast payload that lacks one. On the row-data-free public channel that
// uuid looks exactly like a row id to the live store, which would keyed-fetch
// a nonexistent row and silently swallow the change. Missing `id` is the
// signal that triggers a full reload — so the legacy path must discard it.
test("legacy payloads are stripped to {table, op} — no injected id survives", async () => {
const { h, doorbell } = makeHarness({ mints: [null] });
const got: BoolChangePayload[] = [];
doorbell.subscribe((p) => got.push(p));
await tick();
h.ding("bool:app_x", {
table: "todos",
op: "INSERT",
id: "a-realtime-message-uuid",
row: { id: "should-not-appear" },
} as BoolChangePayload);
expect(got).toEqual([{ table: "todos", op: "INSERT" }]);
expect(got[0]!.id).toBeUndefined();
expect(got[0]!.row).toBeUndefined();
});

test("private payloads keep id and row (only the legacy channel is stripped)", async () => {
const { h, doorbell } = makeHarness({ mints: [MINT] });
const got: BoolChangePayload[] = [];
doorbell.subscribe((p) => got.push(p));
await tick();
h.ding("bool:app_x:app", { table: "todos", op: "INSERT", id: "row-1", row: { id: "row-1" } });
expect(got[0]!.id).toBe("row-1");
expect(got[0]!.row).toEqual({ id: "row-1" });
});

test("a refused private join degrades to the public ping (never a dead app)", async () => {
const { h, doorbell } = makeHarness({ mints: [MINT] });
doorbell.subscribe(() => {});
Expand Down
17 changes: 16 additions & 1 deletion src/realtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,25 @@ export function createDoorbell(deps: DoorbellDeps): Doorbell {
}
}

/** Strip everything except {table, op} from a legacy-channel payload.
*
* The public compat channel is row-data-free by contract — the server sends
* only table and op, because anyone with the (public) anon key and the schema
* name can join it. But Supabase Realtime INJECTS its own `id` (a message
* uuid) into any broadcast payload that lacks one, and that value looks
* exactly like a row id to the live store: it would keyed-fetch a row that
* doesn't exist, apply nothing, and silently swallow the change. So the
* doorbell discards it here rather than trusting the transport's shape —
* missing `id` is precisely the signal that makes the store fall back to a
* coalesced full reload, which is the correct behavior on this channel. */
function legacyPayload(p: BoolChangePayload): BoolChangePayload {
return { table: p.table, op: p.op };
}

function joinLegacy(myGen: number): void {
if (myGen !== gen) return;
const ch = deps.channel(deps.legacyTopic, { private: false });
ch.onBroadcast(fanout);
ch.onBroadcast((p) => fanout(legacyPayload(p)));
ch.join(() => {});
channels.push(ch);
}
Expand Down
Loading