diff --git a/src/modules/entities.ts b/src/modules/entities.ts index c03be02e..dc51d688 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 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. * @@ -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 `_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)?._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. + } + } + try { callback(event); } catch (error) { diff --git a/tests/unit/entities-subscribe.test.ts b/tests/unit/entities-subscribe.test.ts index dc9ffbff..8e1af6b1 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 _oversize events", () => { + test("refetches full record over HTTP when data._oversize 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", _oversize: 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 _oversize 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", _oversize: 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 _oversize 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)); + + // No oversize flag — 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", _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" } }) + ); + }); + }); + test("subscribe() should catch and log errors thrown by callback", () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios();