diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a231c2..038c55e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 001d023..00862b2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/realtime.test.ts b/src/realtime.test.ts index 0b9324d..c0b715c 100644 --- a/src/realtime.test.ts +++ b/src/realtime.test.ts @@ -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(() => {}); diff --git a/src/realtime.ts b/src/realtime.ts index 4a852ca..5428cea 100644 --- a/src/realtime.ts +++ b/src/realtime.ts @@ -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); }