Skip to content
Closed
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
61 changes: 60 additions & 1 deletion src/modules/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,42 @@ function parseRealtimeMessage<T = any>(dataStr: string): RealtimeEvent<T> | 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<string, Promise<any>>();

/**
* 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<T>(
axios: AxiosInstance,
baseURL: string,
entityName: string,
id: string,
timestamp: string
): Promise<T> {
const key = `${entityName}:${id}:${timestamp}`;
let promise = inflightRefetches.get(key) as Promise<T> | undefined;
if (!promise) {
promise = axios.get(`${baseURL}/${id}`) as Promise<T>;
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.
*
Expand Down Expand Up @@ -190,12 +226,35 @@ function createEntityHandler<T = any>(
// 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<T>(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<T>(
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) {
Expand Down
209 changes: 209 additions & 0 deletions tests/unit/entities-subscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading