From 36b0d05a0ed0ee675cf69f1d74759ff0bdeb77b9 Mon Sep 17 00:00:00 2001 From: Eliran Elnasi Date: Mon, 27 Apr 2026 18:56:18 +0300 Subject: [PATCH 1/3] 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/3] =?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" } }) From 35967a38d39aa207dad82e2dd9589131587f727c Mon Sep 17 00:00:00 2001 From: Eliran Elnasi Date: Wed, 29 Apr 2026 19:26:39 +0300 Subject: [PATCH 3/3] refactor(entities): replace auto-refetch with console.error on _oversize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PM pushback, drop the HTTP auto-refetch path on oversize broadcasts — even with debouncing, an entity that tens of subscribers all refetch on every broadcast (e.g. a popular Truck record updated every 30 s in a fleet app) shifts realtime load to HTTP at potentially uncapped fan-out. New behavior: when `_oversize: true` is seen, log a single console.error naming the entity, id, and the recommended `entities.X.get(id)` call so the developer is notified at debug time. The user callback still fires with the slimmed payload (big string fields are empty, rest of the record may be the stub) — caller decides what to do. Removed the inflightRefetches debounce map and refetchTruncated helper since they're no longer needed. Tests collapse from 5 refetch scenarios to 3 logging scenarios: - logs and passes the stub through when _oversize is true - does NOT log on delete events even if _oversize is set - does NOT log when _oversize is absent 156/156 pass; lint + build clean. --- src/modules/entities.ts | 69 +++--------- tests/unit/entities-subscribe.test.ts | 149 +++++--------------------- 2 files changed, 38 insertions(+), 180 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index dc51d688..b0c7fa53 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -74,42 +74,6 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null } } -// 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 -// 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 (`_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 - */ -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. * @@ -226,33 +190,26 @@ function createEntityHandler( // Get the socket and subscribe to the room const socket = getSocket(); const unsubscribe = socket.subscribeToRoom(room, { - update_model: async (msg) => { + update_model: (msg) => { const event = parseRealtimeMessage(msg.data); if (!event) { return; } // 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. + // `data`. The wire payload was slimmed to fit under the realtime + // transport cap, so big string fields arrive as empty strings (or + // the whole record collapses to a stub). Surface this to the + // developer console so they know to fetch the full record on + // demand (e.g. a follow-up entities.X.get(id) call) instead of + // rendering the slimmed payload directly. Skip on delete events + // — the record no longer exists. if (event.type !== "delete" && (event.data as any)?._oversize) { - try { - event.data = await refetchTruncated( - axios, - baseURL, - entityName, - event.id, - event.timestamp - ); - } catch (error) { - console.warn( - "[Base44 SDK] Failed to refetch oversize entity, falling through with stub payload:", - error - ); - // event.data stays as the `{id, _oversize: true}` stub; user - // code receives partial data — same UX as today's drop-and-stale. - } + console.error( + `[Base44 SDK] Realtime broadcast for ${entityName}#${event.id} was oversize and got slimmed for transport. ` + + `Fields >10 KB are empty and the rest of the record may be a stub. ` + + `Call \`entities.${entityName}.get("${event.id}")\` to fetch the full record.` + ); } try { diff --git a/tests/unit/entities-subscribe.test.ts b/tests/unit/entities-subscribe.test.ts index 8e1af6b1..7ac3d905 100644 --- a/tests/unit/entities-subscribe.test.ts +++ b/tests/unit/entities-subscribe.test.ts @@ -207,16 +207,10 @@ describe("Entities Module - subscribe()", () => { warnSpy.mockRestore(); }); - describe("auto-refetch on _oversize events", () => { - test("refetches full record over HTTP when data._oversize is true", async () => { + describe("oversize broadcast handling", () => { + test("logs a console.error and passes the stub through when data._oversize is true", () => { 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, @@ -224,6 +218,8 @@ describe("Entities Module - subscribe()", () => { }); const callback = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + entities.Todo.subscribe(callback); mockSocket._simulateMessage(`entities:${appId}:Todo`, { @@ -236,23 +232,27 @@ describe("Entities Module - subscribe()", () => { }), }); - // Wait for the async refetch to settle - await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); - - expect(mockAxios.get).toHaveBeenCalledWith(`/apps/${appId}/entities/Todo/123`); + // No HTTP call — the SDK never auto-refetches. + expect(mockAxios.get).not.toHaveBeenCalled(); + // Developer is notified via console.error. + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("[Base44 SDK] Realtime broadcast for Todo#123 was oversize") + ); + // Callback still fires with the slimmed payload — caller decides what to do. expect(callback).toHaveBeenCalledWith( expect.objectContaining({ type: "update", id: "123", - data: { id: "123", title: "Full Title", body: "Full long body content" }, + data: { id: "123", _oversize: true }, }) ); + + errorSpy.mockRestore(); }); - test("does NOT refetch on delete events even if _oversize is set", async () => { + test("does NOT log on delete events even if _oversize is set", () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); - const entities = createEntitiesModule({ axios: mockAxios as any, appId, @@ -260,6 +260,8 @@ describe("Entities Module - subscribe()", () => { }); const callback = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + entities.Todo.subscribe(callback); mockSocket._simulateMessage(`entities:${appId}:Todo`, { @@ -272,19 +274,17 @@ describe("Entities Module - subscribe()", () => { }), }); - await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); - - // Delete events should not trigger a refetch — the record is gone - expect(mockAxios.get).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); expect(callback).toHaveBeenCalledWith( expect.objectContaining({ type: "delete", id: "123" }) ); + + errorSpy.mockRestore(); }); - test("does NOT refetch when data has no _oversize flag", async () => { + test("does NOT log when data has no _oversize flag", () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); - const entities = createEntitiesModule({ axios: mockAxios as any, appId, @@ -292,6 +292,8 @@ describe("Entities Module - subscribe()", () => { }); const callback = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + entities.Todo.subscribe(callback); mockSocket._simulateMessage(`entities:${appId}:Todo`, { @@ -304,115 +306,14 @@ describe("Entities Module - subscribe()", () => { }), }); - await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); - - // No oversize flag — no refetch - expect(mockAxios.get).not.toHaveBeenCalled(); + expect(errorSpy).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", _oversize: 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", _oversize: true }, - }) - ); - expect(warnSpy).toHaveBeenCalledWith( - "[Base44 SDK] Failed to refetch oversize entity, falling through with stub payload:", - 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 oversize - // 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 oversizeMsg = { - room: `entities:${appId}:Todo`, - data: JSON.stringify({ - type: "update", - 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`, oversizeMsg); - mockSocket._simulateMessage(`entities:${appId}:Todo`, oversizeMsg); - - // 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 oversize stub. - expect(callback).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ data: { id: "789", title: "Full" } }) - ); - expect(callback).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ data: { id: "789", title: "Full" } }) - ); + errorSpy.mockRestore(); }); });