From cd90b22885eb67013a91da045e6e477ca4de2ed1 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Tue, 27 Jan 2026 13:39:54 +0200 Subject: [PATCH 1/3] Refactor entities module to support generic entity types and improve type safety --- src/modules/entities.ts | 47 ++++---- src/modules/entities.types.ts | 56 +++++++--- tests/unit/entities.test.js | 173 ----------------------------- tests/unit/entities.test.ts | 197 ++++++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+), 209 deletions(-) delete mode 100644 tests/unit/entities.test.js create mode 100644 tests/unit/entities.test.ts diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 9cfc6bd..3e703e0 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -1,5 +1,7 @@ import { AxiosInstance } from "axios"; import { + DeleteManyResult, + DeleteResult, EntitiesModule, EntityHandler, RealtimeCallback, @@ -54,12 +56,12 @@ export function createEntitiesModule( * Parses the realtime message data and extracts event information. * @internal */ -function parseRealtimeMessage(dataStr: string): RealtimeEvent | null { +function parseRealtimeMessage(dataStr: string): RealtimeEvent | null { try { const parsed = JSON.parse(dataStr); return { type: parsed.type as RealtimeEventType, - data: parsed.data, + data: parsed.data as T, id: parsed.id || parsed.data?.id, timestamp: parsed.timestamp || new Date().toISOString(), }; @@ -79,17 +81,22 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null { * @returns Entity handler with CRUD methods * @internal */ -function createEntityHandler( +function createEntityHandler( axios: AxiosInstance, appId: string, entityName: string, getSocket: () => ReturnType -): EntityHandler { +): EntityHandler { const baseURL = `/apps/${appId}/entities/${entityName}`; return { // List entities with optional pagination and sorting - async list(sort: string, limit: number, skip: number, fields: string[]) { + async list( + sort?: string, + limit?: number, + skip?: number, + fields?: string[] + ): Promise { const params: Record = {}; if (sort) params.sort = sort; if (limit) params.limit = limit; @@ -102,12 +109,12 @@ function createEntityHandler( // Filter entities based on query async filter( - query: Record, - sort: string, - limit: number, - skip: number, - fields: string[] - ) { + query: Partial, + sort?: string, + limit?: number, + skip?: number, + fields?: string[] + ): Promise { const params: Record = { q: JSON.stringify(query), }; @@ -122,37 +129,37 @@ function createEntityHandler( }, // Get entity by ID - async get(id: string) { + async get(id: string): Promise { return axios.get(`${baseURL}/${id}`); }, // Create new entity - async create(data: Record) { + async create(data: Partial): Promise { return axios.post(baseURL, data); }, // Update entity by ID - async update(id: string, data: Record) { + async update(id: string, data: Partial): Promise { return axios.put(`${baseURL}/${id}`, data); }, // Delete entity by ID - async delete(id: string) { + async delete(id: string): Promise { return axios.delete(`${baseURL}/${id}`); }, // Delete multiple entities based on query - async deleteMany(query: Record) { + async deleteMany(query: Partial): Promise { return axios.delete(baseURL, { data: query }); }, // Create multiple entities in a single request - async bulkCreate(data: Record[]) { + async bulkCreate(data: Partial[]): Promise { return axios.post(`${baseURL}/bulk`, data); }, // Import entities from a file - async importEntities(file: File) { + async importEntities(file: File): Promise { const formData = new FormData(); formData.append("file", file, file.name); @@ -164,14 +171,14 @@ function createEntityHandler( }, // Subscribe to realtime updates - subscribe(callback: RealtimeCallback): () => void { + subscribe(callback: RealtimeCallback): () => void { 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); + const event = parseRealtimeMessage(msg.data); if (!event) { return; } diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 8666db0..be3812a 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -5,12 +5,14 @@ export type RealtimeEventType = "create" | "update" | "delete"; /** * Payload received when a realtime event occurs. + * + * @typeParam T - The entity type for the data field. Defaults to `any`. */ -export interface RealtimeEvent { +export interface RealtimeEvent { /** The type of change that occurred */ type: RealtimeEventType; /** The entity data */ - data: any; + data: T; /** The unique identifier of the affected entity */ id: string; /** ISO 8601 timestamp of when the event occurred */ @@ -19,15 +21,37 @@ export interface RealtimeEvent { /** * Callback function invoked when a realtime event occurs. + * + * @typeParam T - The entity type for the event data. Defaults to `any`. + */ +export type RealtimeCallback = (event: RealtimeEvent) => void; + +/** + * Result returned when deleting a single entity. + */ +export interface DeleteResult { + /** Whether the deletion was successful */ + success: boolean; +} + +/** + * Result returned when deleting multiple entities. */ -export type RealtimeCallback = (event: RealtimeEvent) => void; +export interface DeleteManyResult { + /** Whether the deletion was successful */ + success: boolean; + /** Number of entities that were deleted */ + deleted: number; +} /** * Entity handler providing CRUD operations for a specific entity type. * * Each entity in the app gets a handler with these methods for managing data. + * + * @typeParam T - The entity type. Defaults to `any` for backward compatibility. */ -export interface EntityHandler { +export interface EntityHandler { /** * Lists records with optional pagination and sorting. * @@ -72,7 +96,7 @@ export interface EntityHandler { limit?: number, skip?: number, fields?: string[] - ): Promise; + ): Promise; /** * Filters records based on a query. @@ -132,12 +156,12 @@ export interface EntityHandler { * ``` */ filter( - query: Record, + query: Partial, sort?: string, limit?: number, skip?: number, fields?: string[] - ): Promise; + ): Promise; /** * Gets a single record by ID. @@ -154,7 +178,7 @@ export interface EntityHandler { * console.log(record.name); * ``` */ - get(id: string): Promise; + get(id: string): Promise; /** * Creates a new record. @@ -175,7 +199,7 @@ export interface EntityHandler { * console.log('Created record with ID:', newRecord.id); * ``` */ - create(data: Record): Promise; + create(data: Partial): Promise; /** * Updates an existing record. @@ -205,7 +229,7 @@ export interface EntityHandler { * }); * ``` */ - update(id: string, data: Record): Promise; + update(id: string, data: Partial): Promise; /** * Deletes a single record by ID. @@ -219,10 +243,10 @@ export interface EntityHandler { * ```typescript * // Delete a record * const result = await base44.entities.MyEntity.delete('entity-123'); - * console.log('Deleted:', result); + * console.log('Deleted:', result.success); * ``` */ - delete(id: string): Promise; + delete(id: string): Promise; /** * Deletes multiple records matching a query. @@ -244,7 +268,7 @@ export interface EntityHandler { * console.log('Deleted:', result); * ``` */ - deleteMany(query: Record): Promise; + deleteMany(query: Partial): Promise; /** * Creates multiple records in a single request. @@ -265,7 +289,7 @@ export interface EntityHandler { * ]); * ``` */ - bulkCreate(data: Record[]): Promise; + bulkCreate(data: Partial[]): Promise; /** * Imports records from a file. @@ -315,7 +339,7 @@ export interface EntityHandler { * unsubscribe(); * ``` */ - subscribe(callback: RealtimeCallback): () => void; + subscribe(callback: RealtimeCallback): () => void; } /** @@ -364,5 +388,5 @@ export interface EntitiesModule { * base44.entities.AnotherEntity * ``` */ - [entityName: string]: EntityHandler; + [entityName: string]: EntityHandler; } diff --git a/tests/unit/entities.test.js b/tests/unit/entities.test.js deleted file mode 100644 index d75b374..0000000 --- a/tests/unit/entities.test.js +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; -import { createClient } from '../../src/index.ts'; - -describe('Entities Module', () => { - let base44; - let scope; - const appId = 'test-app-id'; - const serverUrl = 'https://api.base44.com'; - - beforeEach(() => { - // Create a new client for each test - base44 = createClient({ - serverUrl, - appId, - }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); - - // Enable request debugging for Nock - nock.disableNetConnect(); - nock.emitter.on('no match', (req) => { - console.log(`Nock: No match for ${req.method} ${req.path}`); - console.log('Headers:', req.getHeaders()); - }); - }); - - afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); - nock.emitter.removeAllListeners('no match'); - nock.enableNetConnect(); - }); - - test('list() should fetch entities with correct parameters', async () => { - // Mock the API response - scope.get(`/api/apps/${appId}/entities/Todo`) - .query(true) // Accept any query parameters - .reply(200, { - items: [ - { id: '1', title: 'Task 1', completed: false }, - { id: '2', title: 'Task 2', completed: true } - ], - total: 2 - }); - - // Call the API - const result = await base44.entities.Todo.list('title', 10, 0, ['id', 'title']); - - // Verify the response - expect(result.items).toHaveLength(2); - expect(result.items[0].title).toBe('Task 1'); - expect(result.total).toBe(2); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('filter() should send correct query parameters', async () => { - const filterQuery = { completed: true }; - - // Mock the API response - scope.get(`/api/apps/${appId}/entities/Todo`) - .query(query => { - // Verify the query contains our filter - const parsedQ = JSON.parse(query.q); - return parsedQ.completed === true; - }) - .reply(200, { - items: [ - { id: '2', title: 'Task 2', completed: true } - ], - total: 1 - }); - - // Call the API - const result = await base44.entities.Todo.filter(filterQuery); - - // Verify the response - expect(result.items).toHaveLength(1); - expect(result.items[0].completed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('get() should fetch a single entity', async () => { - const todoId = '123'; - - // Mock the API response - scope.get(`/api/apps/${appId}/entities/Todo/${todoId}`) - .reply(200, { - id: todoId, - title: 'Get milk', - completed: false - }); - - // Call the API - const todo = await base44.entities.Todo.get(todoId); - - // Verify the response - expect(todo.id).toBe(todoId); - expect(todo.title).toBe('Get milk'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('create() should send correct data', async () => { - const newTodo = { - title: 'New task', - completed: false - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/entities/Todo`, newTodo) - .reply(201, { - id: '123', - ...newTodo - }); - - // Call the API - const todo = await base44.entities.Todo.create(newTodo); - - // Verify the response - expect(todo.id).toBe('123'); - expect(todo.title).toBe('New task'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('update() should send correct data', async () => { - const todoId = '123'; - const updates = { - title: 'Updated task', - completed: true - }; - - // Mock the API response - scope.put(`/api/apps/${appId}/entities/Todo/${todoId}`, updates) - .reply(200, { - id: todoId, - ...updates - }); - - // Call the API - const todo = await base44.entities.Todo.update(todoId, updates); - - // Verify the response - expect(todo.id).toBe(todoId); - expect(todo.title).toBe('Updated task'); - expect(todo.completed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('delete() should call correct endpoint', async () => { - const todoId = '123'; - - // Mock the API response - scope.delete(`/api/apps/${appId}/entities/Todo/${todoId}`) - .reply(204); - - // Call the API - await base44.entities.Todo.delete(todoId); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); -}); \ No newline at end of file diff --git a/tests/unit/entities.test.ts b/tests/unit/entities.test.ts new file mode 100644 index 0000000..fec0f31 --- /dev/null +++ b/tests/unit/entities.test.ts @@ -0,0 +1,197 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; +import type { DeleteResult } from "../../src/modules/entities.types.ts"; + +/** + * Todo entity type for testing. + */ +interface Todo { + id: string; + title: string; + completed: boolean; +} + +describe("Entities Module", () => { + let base44: ReturnType; + let scope: nock.Scope; + const appId = "test-app-id"; + const serverUrl = "https://api.base44.com"; + + beforeEach(() => { + // Create a new client for each test + base44 = createClient({ + serverUrl, + appId, + }); + + // Create a nock scope for mocking API calls + scope = nock(serverUrl); + + // Enable request debugging for Nock + nock.disableNetConnect(); + nock.emitter.on("no match", (req) => { + console.log(`Nock: No match for ${req.method} ${req.path}`); + console.log("Headers:", req.getHeaders()); + }); + }); + + afterEach(() => { + // Clean up any pending mocks + nock.cleanAll(); + nock.emitter.removeAllListeners("no match"); + nock.enableNetConnect(); + }); + + test("list() should fetch entities with correct parameters", async () => { + const mockTodos: Todo[] = [ + { id: "1", title: "Task 1", completed: false }, + { id: "2", title: "Task 2", completed: true }, + ]; + + // Mock the API response + scope + .get(`/api/apps/${appId}/entities/Todo`) + .query(true) // Accept any query parameters + .reply(200, mockTodos); + + // Call the API + const result = await base44.entities.Todo.list("title", 10, 0, [ + "id", + "title", + ]); + + // Verify the response + expect(result).toHaveLength(2); + expect(result[0].title).toBe("Task 1"); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("filter() should send correct query parameters", async () => { + const filterQuery: Partial = { completed: true }; + const mockTodos: Todo[] = [{ id: "2", title: "Task 2", completed: true }]; + + // Mock the API response + scope + .get(`/api/apps/${appId}/entities/Todo`) + .query((query) => { + // Verify the query contains our filter + const parsedQ = JSON.parse(query.q as string); + return parsedQ.completed === true; + }) + .reply(200, mockTodos); + + // Call the API + const result = await base44.entities.Todo.filter(filterQuery); + + // Verify the response + expect(result).toHaveLength(1); + expect(result[0].completed).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("get() should fetch a single entity", async () => { + const todoId = "123"; + const mockTodo: Todo = { + id: todoId, + title: "Get milk", + completed: false, + }; + + // Mock the API response + scope.get(`/api/apps/${appId}/entities/Todo/${todoId}`).reply(200, mockTodo); + + // Call the API + const todo = await base44.entities.Todo.get(todoId); + + // Verify the response + expect(todo.id).toBe(todoId); + expect(todo.title).toBe("Get milk"); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("create() should send correct data", async () => { + const newTodo: Partial = { + title: "New task", + completed: false, + }; + const createdTodo: Todo = { + id: "123", + title: "New task", + completed: false, + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/entities/Todo`, newTodo as nock.RequestBodyMatcher) + .reply(201, createdTodo); + + // Call the API + const todo = await base44.entities.Todo.create(newTodo); + + // Verify the response + expect(todo.id).toBe("123"); + expect(todo.title).toBe("New task"); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("update() should send correct data", async () => { + const todoId = "123"; + const updates: Partial = { + title: "Updated task", + completed: true, + }; + const updatedTodo: Todo = { + id: todoId, + title: "Updated task", + completed: true, + }; + + // Mock the API response + scope + .put( + `/api/apps/${appId}/entities/Todo/${todoId}`, + updates as nock.RequestBodyMatcher + ) + .reply(200, updatedTodo); + + // Call the API + const todo = await base44.entities.Todo.update(todoId, updates); + + // Verify the response + expect(todo.id).toBe(todoId); + expect(todo.title).toBe("Updated task"); + expect(todo.completed).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("delete() should call correct endpoint and return DeleteResult", async () => { + const todoId = "123"; + const deleteResult: DeleteResult = { success: true }; + + // Mock the API response + scope + .delete(`/api/apps/${appId}/entities/Todo/${todoId}`) + .reply(200, deleteResult); + + // Call the API + const result = await base44.entities.Todo.delete(todoId); + + // Verify the response matches DeleteResult type + expect(result.success).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + +}); From 4b25c61471cd9cfeb3df16c82156d0090b78bd4b Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Tue, 27 Jan 2026 13:42:39 +0200 Subject: [PATCH 2/3] Extend EntitiesModule with typed Todo handler for improved type safety in entities tests --- tests/unit/entities.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/entities.test.ts b/tests/unit/entities.test.ts index fec0f31..904587f 100644 --- a/tests/unit/entities.test.ts +++ b/tests/unit/entities.test.ts @@ -12,6 +12,13 @@ interface Todo { completed: boolean; } +// Declaration merging: extend EntitiesModule with typed Todo handler +declare module "../../src/modules/entities.types.ts" { + interface EntitiesModule { + Todo: EntityHandler; + } +} + describe("Entities Module", () => { let base44: ReturnType; let scope: nock.Scope; From 07267141b477c94a2e83099fa4a3feafc3a5fa0a Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Wed, 28 Jan 2026 13:25:25 +0200 Subject: [PATCH 3/3] Enhance entity handling with typed sorting and import results - Introduced `SortField` type for improved sorting parameter handling in list and filter methods. - Updated `importEntities` method to return a structured `ImportResult` type, providing detailed import status and output. - Modified list and filter methods to support generic field selection, enhancing type safety and flexibility. --- src/modules/entities.ts | 20 ++++++----- src/modules/entities.types.ts | 66 ++++++++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 3e703e0..1edeeb4 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -4,9 +4,11 @@ import { DeleteResult, EntitiesModule, EntityHandler, + ImportResult, RealtimeCallback, RealtimeEvent, RealtimeEventType, + SortField, } from "./entities.types"; import { RoomsSocket } from "../utils/socket-utils.js"; @@ -91,12 +93,12 @@ function createEntityHandler( return { // List entities with optional pagination and sorting - async list( - sort?: string, + async list( + sort?: SortField, limit?: number, skip?: number, - fields?: string[] - ): Promise { + fields?: K[] + ): Promise[]> { const params: Record = {}; if (sort) params.sort = sort; if (limit) params.limit = limit; @@ -108,13 +110,13 @@ function createEntityHandler( }, // Filter entities based on query - async filter( + async filter( query: Partial, - sort?: string, + sort?: SortField, limit?: number, skip?: number, - fields?: string[] - ): Promise { + fields?: K[] + ): Promise[]> { const params: Record = { q: JSON.stringify(query), }; @@ -159,7 +161,7 @@ function createEntityHandler( }, // Import entities from a file - async importEntities(file: File): Promise { + async importEntities(file: File): Promise> { const formData = new FormData(); formData.append("file", file, file.name); diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index be3812a..7460bd0 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -44,6 +44,42 @@ export interface DeleteManyResult { deleted: number; } +/** + * Result returned when importing entities from a file. + * + * @typeParam T - The entity type for imported records. Defaults to `any`. + */ +export interface ImportResult { + /** Status of the import operation */ + status: "success" | "error"; + /** Details message, e.g., "Successfully imported 3 entities with RLS enforcement" */ + details: string | null; + /** Array of created entity objects when successful, or null on error */ + output: T[] | null; +} + +/** + * Sort field type for entity queries. + * + * Supports ascending (no prefix or `'+'`) and descending (`'-'`) sorting. + * + * @typeParam T - The entity type to derive sortable fields from. + * + * @example + * ```typescript + * // Ascending sort (default) + * 'created_date' + * '+created_date' + * + * // Descending sort + * '-created_date' + * ``` + */ +export type SortField = + | (keyof T & string) + | `+${keyof T & string}` + | `-${keyof T & string}`; + /** * Entity handler providing CRUD operations for a specific entity type. * @@ -60,11 +96,12 @@ export interface EntityHandler { * * **Note:** The maximum limit is 5,000 items per request. * + * @typeParam K - The fields to include in the response. Defaults to all fields. * @param sort - Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`. * @param limit - Maximum number of results to return. Defaults to `50`. * @param skip - Number of results to skip for pagination. Defaults to `0`. * @param fields - Array of field names to include in the response. Defaults to all fields. - * @returns Promise resolving to an array of records. + * @returns Promise resolving to an array of records with selected fields. * * @example * ```typescript @@ -91,12 +128,12 @@ export interface EntityHandler { * const fields = await base44.entities.MyEntity.list('-created_date', 10, 0, ['name', 'status']); * ``` */ - list( - sort?: string, + list( + sort?: SortField, limit?: number, skip?: number, - fields?: string[] - ): Promise; + fields?: K[] + ): Promise[]>; /** * Filters records based on a query. @@ -106,6 +143,7 @@ export interface EntityHandler { * * **Note:** The maximum limit is 5,000 items per request. * + * @typeParam K - The fields to include in the response. Defaults to all fields. * @param query - Query object with field-value pairs. Each key should be a field name * from your entity schema, and each value is the criteria to match. Records matching all * specified criteria are returned. Field names are case-sensitive. @@ -113,7 +151,7 @@ export interface EntityHandler { * @param limit - Maximum number of results to return. Defaults to `50`. * @param skip - Number of results to skip for pagination. Defaults to `0`. * @param fields - Array of field names to include in the response. Defaults to all fields. - * @returns Promise resolving to an array of filtered records. + * @returns Promise resolving to an array of filtered records with selected fields. * * @example * ```typescript @@ -155,13 +193,13 @@ export interface EntityHandler { * ); * ``` */ - filter( + filter( query: Partial, - sort?: string, + sort?: SortField, limit?: number, skip?: number, - fields?: string[] - ): Promise; + fields?: K[] + ): Promise[]>; /** * Gets a single record by ID. @@ -298,7 +336,7 @@ export interface EntityHandler { * The file format should match your entity structure. Requires a browser environment and can't be used in the backend. * * @param file - File object to import. - * @returns Promise resolving to the import result. + * @returns Promise resolving to the import result containing status, details, and created records. * * @example * ```typescript @@ -307,12 +345,14 @@ export interface EntityHandler { * const file = event.target.files?.[0]; * if (file) { * const result = await base44.entities.MyEntity.importEntities(file); - * console.log(`Imported ${result.count} records`); + * if (result.status === 'success' && result.output) { + * console.log(`Imported ${result.output.length} records`); + * } * } * }; * ``` */ - importEntities(file: File): Promise; + importEntities(file: File): Promise>; /** * Subscribes to realtime updates for all records of this entity type.