Skip to content

Commit c8f9b30

Browse files
arosenanclaude
andcommitted
feat(entities): new entity APIs — cursor pages, count, distinct, aggregate, upsert
Wraps the scan-free entity routes added in base44-dev/apper#24939. Fifty apps with the deepest `skip` reads were reviewed; none paged because a user asked for page N. Every loop existed to get a number, to check whether a key already exists, to walk a table with a resume point that is not an offset, or to list a field's distinct values. - list(options) / filter(query, options): pass an options object {sort, limit, cursor, fields} instead of positional args to read one cursor page; returns {items, next_cursor, has_more}. Positional calls are unchanged. skip is documented as deprecated for loops. - count(query?): number of readable records matching a filter. - distinct(field, query?): {values, truncated}, capped at 5000 values. - aggregate(spec): group_by / date_bucket / count / sum / avg / min / max / count_distinct / having / sort / limit; returns {rows, truncated}. - upsert(records, {key}): create or update by a natural key, up to 500 records. New public types are exported and listed in types-to-expose.json. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 99bab39 commit c8f9b30

6 files changed

Lines changed: 628 additions & 19 deletions

File tree

‎scripts/mintlify-post-processing/types-to-expose.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,17 @@
1919
"DeleteManyResult",
2020
"DeleteResult",
2121
"EntitiesModule",
22+
"EntityAggregateResult",
23+
"EntityAggregateSpec",
24+
"EntityDateBucketUnit",
25+
"EntityDistinctResult",
2226
"EntityHandler",
27+
"EntityListOptions",
28+
"EntityPage",
2329
"EntityRecord",
2430
"EntityTypeRegistry",
31+
"EntityUpsertOptions",
32+
"EntityUpsertResult",
2533
"FunctionName",
2634
"FunctionNameRegistry",
2735
"FunctionsModule",

‎src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,20 @@ export type {
3939
DeleteManyResult,
4040
DeleteResult,
4141
EntitiesModule,
42+
EntityAggregateResult,
43+
EntityAggregateSpec,
44+
EntityDateBucketUnit,
45+
EntityDistinctResult,
4246
EntityFilterOperators,
4347
EntityFilterQuery,
4448
EntityFilterValue,
4549
EntityHandler,
50+
EntityListOptions,
51+
EntityPage,
4652
EntityRecord,
4753
EntityTypeRegistry,
54+
EntityUpsertOptions,
55+
EntityUpsertResult,
4856
ImportResult,
4957
RealtimeEventType,
5058
RealtimeEvent,

‎src/modules/entities.ts‎

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@ import {
33
DeleteManyResult,
44
DeleteResult,
55
EntitiesModule,
6+
EntityAggregateResult,
7+
EntityAggregateSpec,
8+
EntityDistinctResult,
69
EntityFilterQuery,
710
EntityHandler,
11+
EntityListOptions,
12+
EntityPage,
13+
EntityUpsertOptions,
14+
EntityUpsertResult,
815
ImportResult,
916
RealtimeCallback,
1017
RealtimeEvent,
@@ -75,6 +82,20 @@ function parseRealtimeMessage<T = any>(dataStr: string): RealtimeEvent<T> | null
7582
}
7683
}
7784

85+
function isListOptions(value: unknown): value is EntityListOptions<any, any> {
86+
return typeof value === "object" && value !== null;
87+
}
88+
89+
function pageParams(options: EntityListOptions<any, any>): Record<string, string | number> {
90+
const params: Record<string, string | number> = {};
91+
if (options.sort) params.sort = options.sort;
92+
if (options.limit) params.limit = options.limit;
93+
if (options.cursor) params.cursor = options.cursor;
94+
if (options.fields)
95+
params.fields = Array.isArray(options.fields) ? options.fields.join(",") : options.fields;
96+
return params;
97+
}
98+
7899
/**
79100
* Creates a handler for a specific entity.
80101
*
@@ -94,15 +115,18 @@ function createEntityHandler<T = any>(
94115
const baseURL = `/apps/${appId}/entities/${entityName}`;
95116

96117
return {
97-
// List entities with optional pagination and sorting
118+
// List entities. Positional args read one array; an options object reads one cursor page.
98119
async list<K extends keyof T = keyof T>(
99-
sort?: SortField<T>,
120+
sortOrOptions?: SortField<T> | EntityListOptions<T, K>,
100121
limit?: number,
101122
skip?: number,
102123
fields?: K[]
103-
): Promise<Pick<T, K>[]> {
124+
): Promise<any> {
125+
if (isListOptions(sortOrOptions)) {
126+
return axios.get(`${baseURL}/page`, { params: pageParams(sortOrOptions) });
127+
}
104128
const params: Record<string, string | number> = {};
105-
if (sort) params.sort = sort;
129+
if (sortOrOptions) params.sort = sortOrOptions;
106130
if (limit) params.limit = limit;
107131
if (skip) params.skip = skip;
108132
if (fields)
@@ -111,19 +135,21 @@ function createEntityHandler<T = any>(
111135
return axios.get(baseURL, { params });
112136
},
113137

114-
// Filter entities based on query
138+
// Filter entities. Positional args read one array; an options object reads one cursor page.
115139
async filter<K extends keyof T = keyof T>(
116140
query: EntityFilterQuery<T>,
117-
sort?: SortField<T>,
141+
sortOrOptions?: SortField<T> | EntityListOptions<T, K>,
118142
limit?: number,
119143
skip?: number,
120144
fields?: K[]
121-
): Promise<Pick<T, K>[]> {
122-
const params: Record<string, string | number> = {
123-
q: JSON.stringify(query),
124-
};
145+
): Promise<any> {
146+
const q = JSON.stringify(query);
147+
if (isListOptions(sortOrOptions)) {
148+
return axios.get(`${baseURL}/page`, { params: { q, ...pageParams(sortOrOptions) } });
149+
}
150+
const params: Record<string, string | number> = { q };
125151

126-
if (sort) params.sort = sort;
152+
if (sortOrOptions) params.sort = sortOrOptions;
127153
if (limit) params.limit = limit;
128154
if (skip) params.skip = skip;
129155
if (fields)
@@ -167,6 +193,37 @@ function createEntityHandler<T = any>(
167193
return axios.patch(`${baseURL}/update-many`, { query, data });
168194
},
169195

196+
// Count entities matching a query
197+
async count(query?: EntityFilterQuery<T>): Promise<number> {
198+
const params: Record<string, string> = {};
199+
if (query) params.q = JSON.stringify(query);
200+
const result: { count: number } = await axios.get(`${baseURL}/count`, { params });
201+
return result.count;
202+
},
203+
204+
// Distinct values of one field
205+
async distinct<K extends keyof T & string>(
206+
field: K,
207+
query?: EntityFilterQuery<T>
208+
): Promise<EntityDistinctResult<T[K]>> {
209+
const params: Record<string, string> = { field };
210+
if (query) params.q = JSON.stringify(query);
211+
return axios.get(`${baseURL}/distinct`, { params });
212+
},
213+
214+
// Server-side group-by aggregation
215+
async aggregate(spec: EntityAggregateSpec<T>): Promise<EntityAggregateResult> {
216+
return axios.post(`${baseURL}/aggregate`, spec);
217+
},
218+
219+
// Create or update by a natural key
220+
async upsert(
221+
records: Partial<T>[],
222+
options: EntityUpsertOptions<T>
223+
): Promise<EntityUpsertResult<T>> {
224+
return axios.post(`${baseURL}/upsert`, { records, key: options.key });
225+
},
226+
170227
// Update multiple entities by ID, each with its own update data
171228
async bulkUpdate(data: (Partial<T> & { id: string })[]): Promise<T[]> {
172229
return axios.put(`${baseURL}/bulk`, data);

0 commit comments

Comments
 (0)