From 36b0d05a0ed0ee675cf69f1d74759ff0bdeb77b9 Mon Sep 17 00:00:00 2001 From: Eliran Elnasi Date: Mon, 27 Apr 2026 18:56:18 +0300 Subject: [PATCH 1/2] feat(entities): auto-refetch when realtime broadcast is truncated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side BASE-40236 slims oversize entity broadcasts to fit under the Redis pubsub cap, signaling the slim with `_truncated: true` on the event data. Customer apps that render the truncated fields would otherwise display placeholder text until refresh. The SDK now detects `_truncated` in `entities.X.subscribe` and transparently refetches the full record over HTTP before invoking the user callback, so deployed customer code keeps working without changes. Concurrent subscribers in the same browser fan out to a single HTTP call via an in-flight debounce keyed by `${entityName}:${id}:${timestamp}`. On refetch failure the SDK falls through with the partial payload and logs a warning, so the failure mode is no worse than today's drop-and-stale. Delete events skip refetch — the record no longer exists. Co-Authored-By: Claude Opus 4.7 --- src/modules/entities.ts | 61 +++++++- tests/unit/entities-subscribe.test.ts | 209 ++++++++++++++++++++++++++ 2 files changed, 269 insertions(+), 1 deletion(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index c03be02e..3af6a12f 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -74,6 +74,42 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null } } +// In-flight HTTP refetches for truncated realtime events. Lets multiple +// subscribers in the same browser (e.g. several React components subscribed +// to the same entity) share one HTTP call when they all receive the same +// truncated event. Keyed by `${entityName}:${id}:${timestamp}` so distinct +// updates are not collapsed. +const inflightRefetches = new Map>(); + +/** + * Refetches a record over HTTP after the server signaled it had to slim the + * realtime broadcast (`_truncated: true`). Reuses an in-flight promise if + * one exists for the same (entityName, id, timestamp) so concurrent + * subscribers in the same browser fan out to a single HTTP call. + * @internal + */ +function refetchTruncated( + axios: AxiosInstance, + baseURL: string, + entityName: string, + id: string, + timestamp: string +): Promise { + const key = `${entityName}:${id}:${timestamp}`; + let promise = inflightRefetches.get(key) as Promise | undefined; + if (!promise) { + promise = axios.get(`${baseURL}/${id}`) as Promise; + inflightRefetches.set(key, promise); + // Clear the cache entry after the promise settles plus a short grace + // window so late subscribers can still piggy-back on the result. Use + // .then(success, failure) instead of .finally to avoid creating an + // unhandled rejection tail when the underlying axios call rejects. + const cleanup = () => setTimeout(() => inflightRefetches.delete(key), 5_000); + promise.then(cleanup, cleanup); + } + return promise; +} + /** * Creates a handler for a specific entity. * @@ -190,12 +226,35 @@ function createEntityHandler( // Get the socket and subscribe to the room const socket = getSocket(); const unsubscribe = socket.subscribeToRoom(room, { - update_model: (msg) => { + update_model: async (msg) => { const event = parseRealtimeMessage(msg.data); if (!event) { return; } + // Server signals oversize broadcasts with `_truncated: true` on + // `data`. The wire payload is bounded for transport; we transparently + // refetch the full record over HTTP so callers always see complete + // data. Skip on delete events — the record no longer exists. + if (event.type !== "delete" && (event.data as any)?._truncated) { + try { + event.data = await refetchTruncated( + axios, + baseURL, + entityName, + event.id, + event.timestamp + ); + } catch (error) { + console.warn( + "[Base44 SDK] Failed to refetch truncated entity, falling through with partial data:", + error + ); + // event.data stays as the truncated payload; user code receives + // partial data — same UX as today's drop-and-stale. + } + } + try { callback(event); } catch (error) { diff --git a/tests/unit/entities-subscribe.test.ts b/tests/unit/entities-subscribe.test.ts index dc9ffbff..c1669190 100644 --- a/tests/unit/entities-subscribe.test.ts +++ b/tests/unit/entities-subscribe.test.ts @@ -207,6 +207,215 @@ describe("Entities Module - subscribe()", () => { warnSpy.mockRestore(); }); + describe("auto-refetch on _truncated events", () => { + test("refetches full record over HTTP when data._truncated is true", async () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + mockAxios.get.mockResolvedValueOnce({ + id: "123", + title: "Full Title", + body: "Full long body content", + }); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + entities.Todo.subscribe(callback); + + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "update", + data: { id: "123", _truncated: true }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }); + + // Wait for the async refetch to settle + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + + expect(mockAxios.get).toHaveBeenCalledWith(`/apps/${appId}/entities/Todo/123`); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + type: "update", + id: "123", + data: { id: "123", title: "Full Title", body: "Full long body content" }, + }) + ); + }); + + test("does NOT refetch on delete events even if _truncated is set", async () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + entities.Todo.subscribe(callback); + + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "delete", + data: { id: "123", _truncated: true }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }); + + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + + // Delete events should not trigger a refetch — the record is gone + expect(mockAxios.get).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ type: "delete", id: "123" }) + ); + }); + + test("does NOT refetch when data has no _truncated flag", async () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + entities.Todo.subscribe(callback); + + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "update", + data: { id: "123", title: "Normal Todo" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }); + + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + + // Untruncated payload — no refetch + expect(mockAxios.get).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + data: { id: "123", title: "Normal Todo" }, + }) + ); + }); + + test("falls through with partial data when HTTP refetch fails", async () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + mockAxios.get.mockRejectedValueOnce(new Error("Network down")); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + entities.Todo.subscribe(callback); + + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "update", + data: { id: "456", _truncated: true }, + id: "456", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }); + + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + + // Callback fires with the partial data (not crashed) + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + type: "update", + data: { id: "456", _truncated: true }, + }) + ); + expect(warnSpy).toHaveBeenCalledWith( + "[Base44 SDK] Failed to refetch truncated entity, falling through with partial data:", + expect.any(Error) + ); + + warnSpy.mockRestore(); + }); + + test("debounces concurrent refetches for the same (entity, id, timestamp)", async () => { + // The debounce map is keyed by `${entityName}:${id}:${timestamp}`, so two + // events arriving back-to-back with the same key should fan out to a + // single HTTP refetch. We simulate that by sending the same truncated + // message twice in quick succession (before the first refetch resolves) + // and asserting only one HTTP call fires. + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + let resolveRecord: (v: any) => void = () => {}; + mockAxios.get.mockReturnValueOnce( + new Promise((resolve) => { + resolveRecord = resolve; + }) + ); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + entities.Todo.subscribe(callback); + + const truncatedMsg = { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "update", + data: { id: "789", _truncated: true }, + id: "789", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }; + + // Same key arrives twice while the first refetch is still in-flight. + mockSocket._simulateMessage(`entities:${appId}:Todo`, truncatedMsg); + mockSocket._simulateMessage(`entities:${appId}:Todo`, truncatedMsg); + + // Both handlers piggy-back on a single HTTP call. + await Promise.resolve(); + expect(mockAxios.get).toHaveBeenCalledTimes(1); + + // Resolve the shared HTTP promise — both queued handlers fire the callback. + resolveRecord({ id: "789", title: "Full" }); + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(2)); + expect(mockAxios.get).toHaveBeenCalledTimes(1); + // Both invocations carry the freshly fetched record, not the truncated stub. + expect(callback).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ data: { id: "789", title: "Full" } }) + ); + expect(callback).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ data: { id: "789", title: "Full" } }) + ); + }); + }); + test("subscribe() should catch and log errors thrown by callback", () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); From 84a40f736e5bea18d63c92927d8c7a22c02bbcaa Mon Sep 17 00:00:00 2001 From: Eliran Elnasi Date: Tue, 28 Apr 2026 11:51:15 +0300 Subject: [PATCH 2/2] =?UTF-8?q?refactor(entities):=20rename=20=5Ftruncated?= =?UTF-8?q?=20=E2=86=92=20=5Foversize=20for=20stub=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire flag is set only when the server falls back to a stub payload because the original record was too big for realtime transport. Calling it "truncated" was misleading — we don't truncate fields in that path, we replace the whole payload with `{id, _oversize: true}`. `_oversize` names the actual cause and tells the SDK why a refetch is needed. Coordinated with the matching backend rename. Co-Authored-By: Claude Opus 4.7 --- src/modules/entities.ts | 16 +++++++------- tests/unit/entities-subscribe.test.ts | 32 +++++++++++++-------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 3af6a12f..dc51d688 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -74,16 +74,16 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null } } -// In-flight HTTP refetches for truncated realtime events. Lets multiple +// In-flight HTTP refetches for oversize realtime events. Lets multiple // subscribers in the same browser (e.g. several React components subscribed // to the same entity) share one HTTP call when they all receive the same -// truncated event. Keyed by `${entityName}:${id}:${timestamp}` so distinct +// oversize event. Keyed by `${entityName}:${id}:${timestamp}` so distinct // updates are not collapsed. const inflightRefetches = new Map>(); /** * Refetches a record over HTTP after the server signaled it had to slim the - * realtime broadcast (`_truncated: true`). Reuses an in-flight promise if + * realtime broadcast (`_oversize: true`). Reuses an in-flight promise if * one exists for the same (entityName, id, timestamp) so concurrent * subscribers in the same browser fan out to a single HTTP call. * @internal @@ -232,11 +232,11 @@ function createEntityHandler( return; } - // Server signals oversize broadcasts with `_truncated: true` on + // Server signals oversize broadcasts with `_oversize: true` on // `data`. The wire payload is bounded for transport; we transparently // refetch the full record over HTTP so callers always see complete // data. Skip on delete events — the record no longer exists. - if (event.type !== "delete" && (event.data as any)?._truncated) { + if (event.type !== "delete" && (event.data as any)?._oversize) { try { event.data = await refetchTruncated( axios, @@ -247,11 +247,11 @@ function createEntityHandler( ); } catch (error) { console.warn( - "[Base44 SDK] Failed to refetch truncated entity, falling through with partial data:", + "[Base44 SDK] Failed to refetch oversize entity, falling through with stub payload:", error ); - // event.data stays as the truncated payload; user code receives - // partial data — same UX as today's drop-and-stale. + // event.data stays as the `{id, _oversize: true}` stub; user + // code receives partial data — same UX as today's drop-and-stale. } } diff --git a/tests/unit/entities-subscribe.test.ts b/tests/unit/entities-subscribe.test.ts index c1669190..8e1af6b1 100644 --- a/tests/unit/entities-subscribe.test.ts +++ b/tests/unit/entities-subscribe.test.ts @@ -207,8 +207,8 @@ describe("Entities Module - subscribe()", () => { warnSpy.mockRestore(); }); - describe("auto-refetch on _truncated events", () => { - test("refetches full record over HTTP when data._truncated is true", async () => { + describe("auto-refetch on _oversize events", () => { + test("refetches full record over HTTP when data._oversize is true", async () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); mockAxios.get.mockResolvedValueOnce({ @@ -230,7 +230,7 @@ describe("Entities Module - subscribe()", () => { room: `entities:${appId}:Todo`, data: JSON.stringify({ type: "update", - data: { id: "123", _truncated: true }, + data: { id: "123", _oversize: true }, id: "123", timestamp: "2024-01-01T00:00:00.000Z", }), @@ -249,7 +249,7 @@ describe("Entities Module - subscribe()", () => { ); }); - test("does NOT refetch on delete events even if _truncated is set", async () => { + test("does NOT refetch on delete events even if _oversize is set", async () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); @@ -266,7 +266,7 @@ describe("Entities Module - subscribe()", () => { room: `entities:${appId}:Todo`, data: JSON.stringify({ type: "delete", - data: { id: "123", _truncated: true }, + data: { id: "123", _oversize: true }, id: "123", timestamp: "2024-01-01T00:00:00.000Z", }), @@ -281,7 +281,7 @@ describe("Entities Module - subscribe()", () => { ); }); - test("does NOT refetch when data has no _truncated flag", async () => { + test("does NOT refetch when data has no _oversize flag", async () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); @@ -306,7 +306,7 @@ describe("Entities Module - subscribe()", () => { await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); - // Untruncated payload — no refetch + // No oversize flag — no refetch expect(mockAxios.get).not.toHaveBeenCalled(); expect(callback).toHaveBeenCalledWith( expect.objectContaining({ @@ -335,7 +335,7 @@ describe("Entities Module - subscribe()", () => { room: `entities:${appId}:Todo`, data: JSON.stringify({ type: "update", - data: { id: "456", _truncated: true }, + data: { id: "456", _oversize: true }, id: "456", timestamp: "2024-01-01T00:00:00.000Z", }), @@ -347,11 +347,11 @@ describe("Entities Module - subscribe()", () => { expect(callback).toHaveBeenCalledWith( expect.objectContaining({ type: "update", - data: { id: "456", _truncated: true }, + data: { id: "456", _oversize: true }, }) ); expect(warnSpy).toHaveBeenCalledWith( - "[Base44 SDK] Failed to refetch truncated entity, falling through with partial data:", + "[Base44 SDK] Failed to refetch oversize entity, falling through with stub payload:", expect.any(Error) ); @@ -361,7 +361,7 @@ describe("Entities Module - subscribe()", () => { test("debounces concurrent refetches for the same (entity, id, timestamp)", async () => { // The debounce map is keyed by `${entityName}:${id}:${timestamp}`, so two // events arriving back-to-back with the same key should fan out to a - // single HTTP refetch. We simulate that by sending the same truncated + // single HTTP refetch. We simulate that by sending the same oversize // message twice in quick succession (before the first refetch resolves) // and asserting only one HTTP call fires. const mockSocket = createMockSocket(); @@ -382,19 +382,19 @@ describe("Entities Module - subscribe()", () => { const callback = vi.fn(); entities.Todo.subscribe(callback); - const truncatedMsg = { + const oversizeMsg = { room: `entities:${appId}:Todo`, data: JSON.stringify({ type: "update", - data: { id: "789", _truncated: true }, + data: { id: "789", _oversize: true }, id: "789", timestamp: "2024-01-01T00:00:00.000Z", }), }; // Same key arrives twice while the first refetch is still in-flight. - mockSocket._simulateMessage(`entities:${appId}:Todo`, truncatedMsg); - mockSocket._simulateMessage(`entities:${appId}:Todo`, truncatedMsg); + mockSocket._simulateMessage(`entities:${appId}:Todo`, oversizeMsg); + mockSocket._simulateMessage(`entities:${appId}:Todo`, oversizeMsg); // Both handlers piggy-back on a single HTTP call. await Promise.resolve(); @@ -404,7 +404,7 @@ describe("Entities Module - subscribe()", () => { resolveRecord({ id: "789", title: "Full" }); await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(2)); expect(mockAxios.get).toHaveBeenCalledTimes(1); - // Both invocations carry the freshly fetched record, not the truncated stub. + // Both invocations carry the freshly fetched record, not the oversize stub. expect(callback).toHaveBeenNthCalledWith( 1, expect.objectContaining({ data: { id: "789", title: "Full" } })