Skip to content

Commit af0255a

Browse files
authored
Generic entities module (#108)
1 parent 84cc48b commit af0255a

File tree

4 files changed

+325
-221
lines changed

4 files changed

+325
-221
lines changed

src/modules/entities.ts

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { AxiosInstance } from "axios";
22
import {
3+
DeleteManyResult,
4+
DeleteResult,
35
EntitiesModule,
46
EntityHandler,
7+
ImportResult,
58
RealtimeCallback,
69
RealtimeEvent,
710
RealtimeEventType,
11+
SortField,
812
} from "./entities.types";
913
import { RoomsSocket } from "../utils/socket-utils.js";
1014

@@ -54,12 +58,12 @@ export function createEntitiesModule(
5458
* Parses the realtime message data and extracts event information.
5559
* @internal
5660
*/
57-
function parseRealtimeMessage(dataStr: string): RealtimeEvent | null {
61+
function parseRealtimeMessage<T = any>(dataStr: string): RealtimeEvent<T> | null {
5862
try {
5963
const parsed = JSON.parse(dataStr);
6064
return {
6165
type: parsed.type as RealtimeEventType,
62-
data: parsed.data,
66+
data: parsed.data as T,
6367
id: parsed.id || parsed.data?.id,
6468
timestamp: parsed.timestamp || new Date().toISOString(),
6569
};
@@ -79,17 +83,22 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null {
7983
* @returns Entity handler with CRUD methods
8084
* @internal
8185
*/
82-
function createEntityHandler(
86+
function createEntityHandler<T = any>(
8387
axios: AxiosInstance,
8488
appId: string,
8589
entityName: string,
8690
getSocket: () => ReturnType<typeof RoomsSocket>
87-
): EntityHandler {
91+
): EntityHandler<T> {
8892
const baseURL = `/apps/${appId}/entities/${entityName}`;
8993

9094
return {
9195
// List entities with optional pagination and sorting
92-
async list(sort: string, limit: number, skip: number, fields: string[]) {
96+
async list<K extends keyof T = keyof T>(
97+
sort?: SortField<T>,
98+
limit?: number,
99+
skip?: number,
100+
fields?: K[]
101+
): Promise<Pick<T, K>[]> {
93102
const params: Record<string, string | number> = {};
94103
if (sort) params.sort = sort;
95104
if (limit) params.limit = limit;
@@ -101,13 +110,13 @@ function createEntityHandler(
101110
},
102111

103112
// Filter entities based on query
104-
async filter(
105-
query: Record<string, any>,
106-
sort: string,
107-
limit: number,
108-
skip: number,
109-
fields: string[]
110-
) {
113+
async filter<K extends keyof T = keyof T>(
114+
query: Partial<T>,
115+
sort?: SortField<T>,
116+
limit?: number,
117+
skip?: number,
118+
fields?: K[]
119+
): Promise<Pick<T, K>[]> {
111120
const params: Record<string, string | number> = {
112121
q: JSON.stringify(query),
113122
};
@@ -122,37 +131,37 @@ function createEntityHandler(
122131
},
123132

124133
// Get entity by ID
125-
async get(id: string) {
134+
async get(id: string): Promise<T> {
126135
return axios.get(`${baseURL}/${id}`);
127136
},
128137

129138
// Create new entity
130-
async create(data: Record<string, any>) {
139+
async create(data: Partial<T>): Promise<T> {
131140
return axios.post(baseURL, data);
132141
},
133142

134143
// Update entity by ID
135-
async update(id: string, data: Record<string, any>) {
144+
async update(id: string, data: Partial<T>): Promise<T> {
136145
return axios.put(`${baseURL}/${id}`, data);
137146
},
138147

139148
// Delete entity by ID
140-
async delete(id: string) {
149+
async delete(id: string): Promise<DeleteResult> {
141150
return axios.delete(`${baseURL}/${id}`);
142151
},
143152

144153
// Delete multiple entities based on query
145-
async deleteMany(query: Record<string, any>) {
154+
async deleteMany(query: Partial<T>): Promise<DeleteManyResult> {
146155
return axios.delete(baseURL, { data: query });
147156
},
148157

149158
// Create multiple entities in a single request
150-
async bulkCreate(data: Record<string, any>[]) {
159+
async bulkCreate(data: Partial<T>[]): Promise<T[]> {
151160
return axios.post(`${baseURL}/bulk`, data);
152161
},
153162

154163
// Import entities from a file
155-
async importEntities(file: File) {
164+
async importEntities(file: File): Promise<ImportResult<T>> {
156165
const formData = new FormData();
157166
formData.append("file", file, file.name);
158167

@@ -164,14 +173,14 @@ function createEntityHandler(
164173
},
165174

166175
// Subscribe to realtime updates
167-
subscribe(callback: RealtimeCallback): () => void {
176+
subscribe(callback: RealtimeCallback<T>): () => void {
168177
const room = `entities:${appId}:${entityName}`;
169178

170179
// Get the socket and subscribe to the room
171180
const socket = getSocket();
172181
const unsubscribe = socket.subscribeToRoom(room, {
173182
update_model: (msg) => {
174-
const event = parseRealtimeMessage(msg.data);
183+
const event = parseRealtimeMessage<T>(msg.data);
175184
if (!event) {
176185
return;
177186
}

src/modules/entities.types.ts

Lines changed: 91 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ export type RealtimeEventType = "create" | "update" | "delete";
55

66
/**
77
* Payload received when a realtime event occurs.
8+
*
9+
* @typeParam T - The entity type for the data field. Defaults to `any`.
810
*/
9-
export interface RealtimeEvent {
11+
export interface RealtimeEvent<T = any> {
1012
/** The type of change that occurred */
1113
type: RealtimeEventType;
1214
/** The entity data */
13-
data: any;
15+
data: T;
1416
/** The unique identifier of the affected entity */
1517
id: string;
1618
/** ISO 8601 timestamp of when the event occurred */
@@ -19,15 +21,73 @@ export interface RealtimeEvent {
1921

2022
/**
2123
* Callback function invoked when a realtime event occurs.
24+
*
25+
* @typeParam T - The entity type for the event data. Defaults to `any`.
26+
*/
27+
export type RealtimeCallback<T = any> = (event: RealtimeEvent<T>) => void;
28+
29+
/**
30+
* Result returned when deleting a single entity.
31+
*/
32+
export interface DeleteResult {
33+
/** Whether the deletion was successful */
34+
success: boolean;
35+
}
36+
37+
/**
38+
* Result returned when deleting multiple entities.
2239
*/
23-
export type RealtimeCallback = (event: RealtimeEvent) => void;
40+
export interface DeleteManyResult {
41+
/** Whether the deletion was successful */
42+
success: boolean;
43+
/** Number of entities that were deleted */
44+
deleted: number;
45+
}
46+
47+
/**
48+
* Result returned when importing entities from a file.
49+
*
50+
* @typeParam T - The entity type for imported records. Defaults to `any`.
51+
*/
52+
export interface ImportResult<T = any> {
53+
/** Status of the import operation */
54+
status: "success" | "error";
55+
/** Details message, e.g., "Successfully imported 3 entities with RLS enforcement" */
56+
details: string | null;
57+
/** Array of created entity objects when successful, or null on error */
58+
output: T[] | null;
59+
}
60+
61+
/**
62+
* Sort field type for entity queries.
63+
*
64+
* Supports ascending (no prefix or `'+'`) and descending (`'-'`) sorting.
65+
*
66+
* @typeParam T - The entity type to derive sortable fields from.
67+
*
68+
* @example
69+
* ```typescript
70+
* // Ascending sort (default)
71+
* 'created_date'
72+
* '+created_date'
73+
*
74+
* // Descending sort
75+
* '-created_date'
76+
* ```
77+
*/
78+
export type SortField<T> =
79+
| (keyof T & string)
80+
| `+${keyof T & string}`
81+
| `-${keyof T & string}`;
2482

2583
/**
2684
* Entity handler providing CRUD operations for a specific entity type.
2785
*
2886
* Each entity in the app gets a handler with these methods for managing data.
87+
*
88+
* @typeParam T - The entity type. Defaults to `any` for backward compatibility.
2989
*/
30-
export interface EntityHandler {
90+
export interface EntityHandler<T = any> {
3191
/**
3292
* Lists records with optional pagination and sorting.
3393
*
@@ -36,11 +96,12 @@ export interface EntityHandler {
3696
*
3797
* **Note:** The maximum limit is 5,000 items per request.
3898
*
99+
* @typeParam K - The fields to include in the response. Defaults to all fields.
39100
* @param sort - Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`.
40101
* @param limit - Maximum number of results to return. Defaults to `50`.
41102
* @param skip - Number of results to skip for pagination. Defaults to `0`.
42103
* @param fields - Array of field names to include in the response. Defaults to all fields.
43-
* @returns Promise resolving to an array of records.
104+
* @returns Promise resolving to an array of records with selected fields.
44105
*
45106
* @example
46107
* ```typescript
@@ -67,12 +128,12 @@ export interface EntityHandler {
67128
* const fields = await base44.entities.MyEntity.list('-created_date', 10, 0, ['name', 'status']);
68129
* ```
69130
*/
70-
list(
71-
sort?: string,
131+
list<K extends keyof T = keyof T>(
132+
sort?: SortField<T>,
72133
limit?: number,
73134
skip?: number,
74-
fields?: string[]
75-
): Promise<any>;
135+
fields?: K[]
136+
): Promise<Pick<T, K>[]>;
76137

77138
/**
78139
* Filters records based on a query.
@@ -82,14 +143,15 @@ export interface EntityHandler {
82143
*
83144
* **Note:** The maximum limit is 5,000 items per request.
84145
*
146+
* @typeParam K - The fields to include in the response. Defaults to all fields.
85147
* @param query - Query object with field-value pairs. Each key should be a field name
86148
* from your entity schema, and each value is the criteria to match. Records matching all
87149
* specified criteria are returned. Field names are case-sensitive.
88150
* @param sort - Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`.
89151
* @param limit - Maximum number of results to return. Defaults to `50`.
90152
* @param skip - Number of results to skip for pagination. Defaults to `0`.
91153
* @param fields - Array of field names to include in the response. Defaults to all fields.
92-
* @returns Promise resolving to an array of filtered records.
154+
* @returns Promise resolving to an array of filtered records with selected fields.
93155
*
94156
* @example
95157
* ```typescript
@@ -131,13 +193,13 @@ export interface EntityHandler {
131193
* );
132194
* ```
133195
*/
134-
filter(
135-
query: Record<string, any>,
136-
sort?: string,
196+
filter<K extends keyof T = keyof T>(
197+
query: Partial<T>,
198+
sort?: SortField<T>,
137199
limit?: number,
138200
skip?: number,
139-
fields?: string[]
140-
): Promise<any>;
201+
fields?: K[]
202+
): Promise<Pick<T, K>[]>;
141203

142204
/**
143205
* Gets a single record by ID.
@@ -154,7 +216,7 @@ export interface EntityHandler {
154216
* console.log(record.name);
155217
* ```
156218
*/
157-
get(id: string): Promise<any>;
219+
get(id: string): Promise<T>;
158220

159221
/**
160222
* Creates a new record.
@@ -175,7 +237,7 @@ export interface EntityHandler {
175237
* console.log('Created record with ID:', newRecord.id);
176238
* ```
177239
*/
178-
create(data: Record<string, any>): Promise<any>;
240+
create(data: Partial<T>): Promise<T>;
179241

180242
/**
181243
* Updates an existing record.
@@ -205,7 +267,7 @@ export interface EntityHandler {
205267
* });
206268
* ```
207269
*/
208-
update(id: string, data: Record<string, any>): Promise<any>;
270+
update(id: string, data: Partial<T>): Promise<T>;
209271

210272
/**
211273
* Deletes a single record by ID.
@@ -219,10 +281,10 @@ export interface EntityHandler {
219281
* ```typescript
220282
* // Delete a record
221283
* const result = await base44.entities.MyEntity.delete('entity-123');
222-
* console.log('Deleted:', result);
284+
* console.log('Deleted:', result.success);
223285
* ```
224286
*/
225-
delete(id: string): Promise<any>;
287+
delete(id: string): Promise<DeleteResult>;
226288

227289
/**
228290
* Deletes multiple records matching a query.
@@ -244,7 +306,7 @@ export interface EntityHandler {
244306
* console.log('Deleted:', result);
245307
* ```
246308
*/
247-
deleteMany(query: Record<string, any>): Promise<any>;
309+
deleteMany(query: Partial<T>): Promise<DeleteManyResult>;
248310

249311
/**
250312
* Creates multiple records in a single request.
@@ -265,7 +327,7 @@ export interface EntityHandler {
265327
* ]);
266328
* ```
267329
*/
268-
bulkCreate(data: Record<string, any>[]): Promise<any>;
330+
bulkCreate(data: Partial<T>[]): Promise<T[]>;
269331

270332
/**
271333
* Imports records from a file.
@@ -274,7 +336,7 @@ export interface EntityHandler {
274336
* The file format should match your entity structure. Requires a browser environment and can't be used in the backend.
275337
*
276338
* @param file - File object to import.
277-
* @returns Promise resolving to the import result.
339+
* @returns Promise resolving to the import result containing status, details, and created records.
278340
*
279341
* @example
280342
* ```typescript
@@ -283,12 +345,14 @@ export interface EntityHandler {
283345
* const file = event.target.files?.[0];
284346
* if (file) {
285347
* const result = await base44.entities.MyEntity.importEntities(file);
286-
* console.log(`Imported ${result.count} records`);
348+
* if (result.status === 'success' && result.output) {
349+
* console.log(`Imported ${result.output.length} records`);
350+
* }
287351
* }
288352
* };
289353
* ```
290354
*/
291-
importEntities(file: File): Promise<any>;
355+
importEntities(file: File): Promise<ImportResult<T>>;
292356

293357
/**
294358
* Subscribes to realtime updates for all records of this entity type.
@@ -315,7 +379,7 @@ export interface EntityHandler {
315379
* unsubscribe();
316380
* ```
317381
*/
318-
subscribe(callback: RealtimeCallback): () => void;
382+
subscribe(callback: RealtimeCallback<T>): () => void;
319383
}
320384

321385
/**
@@ -364,5 +428,5 @@ export interface EntitiesModule {
364428
* base44.entities.AnotherEntity
365429
* ```
366430
*/
367-
[entityName: string]: EntityHandler;
431+
[entityName: string]: EntityHandler<any>;
368432
}

0 commit comments

Comments
 (0)