diff --git a/src/client.ts b/src/client.ts index 6b9448f..8e31ae1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -139,7 +139,11 @@ export function createClient(config: CreateClientConfig): Base44Client { ); const userModules = { - entities: createEntitiesModule(axiosClient, appId), + entities: createEntitiesModule({ + axios: axiosClient, + appId, + getSocket, + }), integrations: createIntegrationsModule(axiosClient, appId), auth: userAuthModule, functions: createFunctionsModule(functionsAxiosClient, appId), @@ -167,7 +171,11 @@ export function createClient(config: CreateClientConfig): Base44Client { }; const serviceRoleModules = { - entities: createEntitiesModule(serviceRoleAxiosClient, appId), + entities: createEntitiesModule({ + axios: serviceRoleAxiosClient, + appId, + getSocket, + }), integrations: createIntegrationsModule(serviceRoleAxiosClient, appId), sso: createSsoModule(serviceRoleAxiosClient, appId, token), connectors: createConnectorsModule(serviceRoleAxiosClient, appId), diff --git a/src/index.ts b/src/index.ts index 0ae6849..752f560 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,10 @@ export * from "./types.js"; export type { EntitiesModule, EntityHandler, + RealtimeEventType, + RealtimeEvent, + RealtimeCallback, + Subscription, } from "./modules/entities.types.js"; export type { diff --git a/src/modules/entities.ts b/src/modules/entities.ts index b59a29c..6ffb179 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -1,18 +1,35 @@ import { AxiosInstance } from "axios"; -import { EntitiesModule, EntityHandler } from "./entities.types"; +import { + EntitiesModule, + EntityHandler, + RealtimeCallback, + RealtimeEvent, + RealtimeEventType, + Subscription, +} from "./entities.types"; +import { RoomsSocket } from "../utils/socket-utils.js"; + +/** + * Configuration for the entities module. + * @internal + */ +export interface EntitiesModuleConfig { + axios: AxiosInstance; + appId: string; + getSocket: () => ReturnType; +} /** * Creates the entities module for the Base44 SDK. * - * @param axios - Axios instance - * @param appId - Application ID + * @param config - Configuration object containing axios, appId, and getSocket * @returns Entities module with dynamic entity access * @internal */ export function createEntitiesModule( - axios: AxiosInstance, - appId: string + config: EntitiesModuleConfig ): EntitiesModule { + const { axios, appId, getSocket } = config; // Using Proxy to dynamically handle entity names return new Proxy( {}, @@ -28,25 +45,46 @@ export function createEntitiesModule( } // Create entity handler - return createEntityHandler(axios, appId, entityName); + return createEntityHandler(axios, appId, entityName, getSocket); }, } ) as EntitiesModule; } +/** + * Parses the realtime message data and extracts event information. + * @internal + */ +function parseRealtimeMessage(dataStr: string): RealtimeEvent | null { + try { + const parsed = JSON.parse(dataStr); + return { + type: parsed.type as RealtimeEventType, + data: parsed.data, + id: parsed.id || parsed.data?.id, + timestamp: parsed.timestamp || new Date().toISOString(), + }; + } catch (error) { + console.warn("[Base44 SDK] Failed to parse realtime message:", error); + return null; + } +} + /** * Creates a handler for a specific entity. * * @param axios - Axios instance * @param appId - Application ID * @param entityName - Entity name + * @param getSocket - Function to get the socket instance * @returns Entity handler with CRUD methods * @internal */ function createEntityHandler( axios: AxiosInstance, appId: string, - entityName: string + entityName: string, + getSocket: () => ReturnType ): EntityHandler { const baseURL = `/apps/${appId}/entities/${entityName}`; @@ -125,5 +163,29 @@ function createEntityHandler( }, }); }, + + // Subscribe to realtime updates + subscribe(callback: RealtimeCallback): Subscription { + const room = `entities:${appId}:${entityName}`; + + // Get the socket and subscribe to the room + const socket = getSocket(); + const unsubscribe = socket.subscribeToRoom(room, { + update_model: (msg) => { + const event = parseRealtimeMessage(msg.data); + if (!event) { + return; + } + + try { + callback(event); + } catch (error) { + console.error("[Base44 SDK] Subscription callback error:", error); + } + }, + }); + + return unsubscribe; + }, }; } diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 712cf0f..2251244 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -1,3 +1,32 @@ +/** + * Event types for realtime entity updates. + */ +export type RealtimeEventType = "create" | "update" | "delete"; + +/** + * Payload received when a realtime event occurs. + */ +export interface RealtimeEvent { + /** The type of change that occurred */ + type: RealtimeEventType; + /** The entity data */ + data: any; + /** The unique identifier of the affected entity */ + id: string; + /** ISO 8601 timestamp of when the event occurred */ + timestamp: string; +} + +/** + * Callback function invoked when a realtime event occurs. + */ +export type RealtimeCallback = (event: RealtimeEvent) => void; + +/** + * Function returned from subscribe, call it to unsubscribe. + */ +export type Subscription = () => void; + /** * Entity handler providing CRUD operations for a specific entity type. * @@ -261,6 +290,27 @@ export interface EntityHandler { * ``` */ importEntities(file: File): Promise; + + /** + * Subscribes to realtime updates for all records of this entity type. + * + * Receives notifications whenever any record is created, updated, or deleted. + * + * @param callback - Function called when an entity changes. + * @returns Unsubscribe function to stop listening. + * + * @example + * ```typescript + * // Subscribe to all Task changes + * const unsubscribe = base44.entities.Task.subscribe((event) => { + * console.log(`Task ${event.id} was ${event.type}d:`, event.data); + * }); + * + * // Later, unsubscribe + * unsubscribe(); + * ``` + */ + subscribe(callback: RealtimeCallback): Subscription; } /** diff --git a/tests/unit/entities-subscribe.test.ts b/tests/unit/entities-subscribe.test.ts new file mode 100644 index 0000000..dc9ffbf --- /dev/null +++ b/tests/unit/entities-subscribe.test.ts @@ -0,0 +1,253 @@ +import { describe, test, expect, vi } from "vitest"; +import { createEntitiesModule } from "../../src/modules/entities.ts"; + +describe("Entities Module - subscribe()", () => { + const appId = "test-app-id"; + + // Helper to create a mock socket + function createMockSocket() { + const listeners: Record = {}; + return { + subscribeToRoom: vi.fn((room: string, handlers: any) => { + listeners[room] = handlers; + // Return unsubscribe function + return () => { + delete listeners[room]; + }; + }), + // Helper to simulate incoming messages + _simulateMessage: (room: string, msg: any) => { + listeners[room]?.update_model?.(msg); + }, + _getListeners: () => listeners, + }; + } + + // Helper to create a mock axios instance + function createMockAxios() { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }; + } + + test("subscribe() should return an unsubscribe function", () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + const unsubscribe = entities.Todo.subscribe(callback); + + expect(typeof unsubscribe).toBe("function"); + expect(mockSocket.subscribeToRoom).toHaveBeenCalledWith( + `entities:${appId}:Todo`, + expect.any(Object) + ); + }); + + test("subscribe() should call callback when update_model event is received", () => { + 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); + + // Simulate an incoming message + const messageData = JSON.stringify({ + type: "create", + data: { id: "123", title: "New Todo" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }); + + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: messageData, + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + type: "create", + data: { id: "123", title: "New Todo" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }); + }); + + test("subscribe() should handle update and delete events", () => { + 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); + + // Test update event + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "update", + data: { id: "123", title: "Updated Todo" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }); + + expect(callback).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "update" }) + ); + + // Test delete event + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "delete", + data: { id: "123" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }); + + expect(callback).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "delete" }) + ); + expect(callback).toHaveBeenCalledTimes(2); + }); + + test("subscribe() unsubscribe function should stop receiving events", () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + const unsubscribe = entities.Todo.subscribe(callback); + + // Simulate a message before unsubscribing + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "create", + data: {}, + id: "1", + timestamp: "", + }), + }); + + expect(callback).toHaveBeenCalledTimes(1); + + // Unsubscribe + unsubscribe(); + + // Simulate another message after unsubscribing + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "create", + data: {}, + id: "2", + timestamp: "", + }), + }); + + // Callback should not have been called again + expect(callback).toHaveBeenCalledTimes(1); + }); + + test("subscribe() should not call callback for invalid JSON messages", () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + 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); + + // Simulate an invalid JSON message + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: "invalid json {{{", + }); + + expect(callback).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "[Base44 SDK] Failed to parse realtime message:", + expect.any(Error) + ); + + warnSpy.mockRestore(); + }); + + test("subscribe() should catch and log errors thrown by callback", () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + // Callback that throws an error + const throwingCallback = vi.fn(() => { + throw new Error("Callback error!"); + }); + + entities.Todo.subscribe(throwingCallback); + + // Simulate a message - this should NOT throw, but log the error + expect(() => { + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "create", + data: { id: "123" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }); + }).not.toThrow(); + + // The callback should have been called + expect(throwingCallback).toHaveBeenCalledTimes(1); + + // The error should have been logged + expect(errorSpy).toHaveBeenCalledWith( + "[Base44 SDK] Subscription callback error:", + expect.any(Error) + ); + + errorSpy.mockRestore(); + }); +});