From c8f9b30b502400e2234ec308d8f52eaabc4cfba8 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Wed, 16 Sep 2026 12:08:40 +0300 Subject: [PATCH 01/11] =?UTF-8?q?feat(entities):=20new=20entity=20APIs=20?= =?UTF-8?q?=E2=80=94=20cursor=20pages,=20count,=20distinct,=20aggregate,?= =?UTF-8?q?=20upsert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../types-to-expose.json | 8 + src/index.ts | 8 + src/modules/entities.ts | 79 ++++- src/modules/entities.types.ts | 318 +++++++++++++++++- tests/types/entities-primitives.types.ts | 58 ++++ tests/unit/entities-primitives.test.ts | 176 ++++++++++ 6 files changed, 628 insertions(+), 19 deletions(-) create mode 100644 tests/types/entities-primitives.types.ts create mode 100644 tests/unit/entities-primitives.test.ts diff --git a/scripts/mintlify-post-processing/types-to-expose.json b/scripts/mintlify-post-processing/types-to-expose.json index 5d7ef016..03da1fe9 100644 --- a/scripts/mintlify-post-processing/types-to-expose.json +++ b/scripts/mintlify-post-processing/types-to-expose.json @@ -19,9 +19,17 @@ "DeleteManyResult", "DeleteResult", "EntitiesModule", + "EntityAggregateResult", + "EntityAggregateSpec", + "EntityDateBucketUnit", + "EntityDistinctResult", "EntityHandler", + "EntityListOptions", + "EntityPage", "EntityRecord", "EntityTypeRegistry", + "EntityUpsertOptions", + "EntityUpsertResult", "FunctionName", "FunctionNameRegistry", "FunctionsModule", diff --git a/src/index.ts b/src/index.ts index 8842b8fe..51b01713 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,12 +39,20 @@ export type { DeleteManyResult, DeleteResult, EntitiesModule, + EntityAggregateResult, + EntityAggregateSpec, + EntityDateBucketUnit, + EntityDistinctResult, EntityFilterOperators, EntityFilterQuery, EntityFilterValue, EntityHandler, + EntityListOptions, + EntityPage, EntityRecord, EntityTypeRegistry, + EntityUpsertOptions, + EntityUpsertResult, ImportResult, RealtimeEventType, RealtimeEvent, diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 1eaaf287..5ebd6e68 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -3,8 +3,15 @@ import { DeleteManyResult, DeleteResult, EntitiesModule, + EntityAggregateResult, + EntityAggregateSpec, + EntityDistinctResult, EntityFilterQuery, EntityHandler, + EntityListOptions, + EntityPage, + EntityUpsertOptions, + EntityUpsertResult, ImportResult, RealtimeCallback, RealtimeEvent, @@ -75,6 +82,20 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null } } +function isListOptions(value: unknown): value is EntityListOptions { + return typeof value === "object" && value !== null; +} + +function pageParams(options: EntityListOptions): Record { + const params: Record = {}; + if (options.sort) params.sort = options.sort; + if (options.limit) params.limit = options.limit; + if (options.cursor) params.cursor = options.cursor; + if (options.fields) + params.fields = Array.isArray(options.fields) ? options.fields.join(",") : options.fields; + return params; +} + /** * Creates a handler for a specific entity. * @@ -94,15 +115,18 @@ function createEntityHandler( const baseURL = `/apps/${appId}/entities/${entityName}`; return { - // List entities with optional pagination and sorting + // List entities. Positional args read one array; an options object reads one cursor page. async list( - sort?: SortField, + sortOrOptions?: SortField | EntityListOptions, limit?: number, skip?: number, fields?: K[] - ): Promise[]> { + ): Promise { + if (isListOptions(sortOrOptions)) { + return axios.get(`${baseURL}/page`, { params: pageParams(sortOrOptions) }); + } const params: Record = {}; - if (sort) params.sort = sort; + if (sortOrOptions) params.sort = sortOrOptions; if (limit) params.limit = limit; if (skip) params.skip = skip; if (fields) @@ -111,19 +135,21 @@ function createEntityHandler( return axios.get(baseURL, { params }); }, - // Filter entities based on query + // Filter entities. Positional args read one array; an options object reads one cursor page. async filter( query: EntityFilterQuery, - sort?: SortField, + sortOrOptions?: SortField | EntityListOptions, limit?: number, skip?: number, fields?: K[] - ): Promise[]> { - const params: Record = { - q: JSON.stringify(query), - }; + ): Promise { + const q = JSON.stringify(query); + if (isListOptions(sortOrOptions)) { + return axios.get(`${baseURL}/page`, { params: { q, ...pageParams(sortOrOptions) } }); + } + const params: Record = { q }; - if (sort) params.sort = sort; + if (sortOrOptions) params.sort = sortOrOptions; if (limit) params.limit = limit; if (skip) params.skip = skip; if (fields) @@ -167,6 +193,37 @@ function createEntityHandler( return axios.patch(`${baseURL}/update-many`, { query, data }); }, + // Count entities matching a query + async count(query?: EntityFilterQuery): Promise { + const params: Record = {}; + if (query) params.q = JSON.stringify(query); + const result: { count: number } = await axios.get(`${baseURL}/count`, { params }); + return result.count; + }, + + // Distinct values of one field + async distinct( + field: K, + query?: EntityFilterQuery + ): Promise> { + const params: Record = { field }; + if (query) params.q = JSON.stringify(query); + return axios.get(`${baseURL}/distinct`, { params }); + }, + + // Server-side group-by aggregation + async aggregate(spec: EntityAggregateSpec): Promise { + return axios.post(`${baseURL}/aggregate`, spec); + }, + + // Create or update by a natural key + async upsert( + records: Partial[], + options: EntityUpsertOptions + ): Promise> { + return axios.post(`${baseURL}/upsert`, { records, key: options.key }); + }, + // Update multiple entities by ID, each with its own update data async bulkUpdate(data: (Partial & { id: string })[]): Promise { return axios.put(`${baseURL}/bulk`, data); diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index f552af63..9caf8fe5 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -56,6 +56,125 @@ export interface UpdateManyResult { has_more: boolean; } +/** + * Options object accepted by {@linkcode EntityHandler.list | list()} and + * {@linkcode EntityHandler.filter | filter()} to read one cursor page. + * + * @typeParam T - Entity record type. + * @typeParam K - The fields to include in each record. + */ +export interface EntityListOptions { + /** Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`. Every page of one walk must use the same sort. */ + sort?: SortField; + /** Maximum number of records per page, up to 5,000. Defaults to 5,000. */ + limit?: number; + /** `next_cursor` from the previous page. Omit or pass `null` for the first page. */ + cursor?: string | null; + /** Array of field names to include in each record. Defaults to all fields. */ + fields?: K[]; +} + +/** + * One page of records, returned by {@linkcode EntityHandler.list | list()} and + * {@linkcode EntityHandler.filter | filter()} when called with an options object. + * + * @typeParam T - Record type of the items. + */ +export interface EntityPage { + /** The page's records, in the requested sort order. */ + items: T[]; + /** Pass as `cursor` to get the next page. `null` on the last page. */ + next_cursor: string | null; + /** Whether records remain after this page. */ + has_more: boolean; +} + +/** + * Result returned by {@linkcode EntityHandler.distinct | distinct()}. + * + * @typeParam V - Type of the field's values. + */ +export interface EntityDistinctResult { + /** The distinct values, unordered. */ + values: V[]; + /** `true` when the field has more than 5,000 distinct values and the list was cut. */ + truncated: boolean; +} + +/** + * Time unit for {@linkcode EntityAggregateSpec.date_bucket | date_bucket}. + */ +export type EntityDateBucketUnit = "day" | "week" | "month" | "year"; + +/** + * Describes what {@linkcode EntityHandler.aggregate | aggregate()} computes. + * + * Name the fields to group by and the measures to compute; the server does the work and + * returns one row per group. Field names are the entity's own field names. + * + * @typeParam T - Entity record type. + */ +export interface EntityAggregateSpec { + /** Filter applied before grouping, in the same form {@linkcode EntityHandler.filter | filter()} accepts. Defaults to all records. */ + match?: EntityFilterQuery; + /** Field, or up to four fields, to group by. Omit to get one total row. */ + group_by?: (keyof T & string) | (keyof T & string)[]; + /** Group by a time bucket of a date field. `created_date` and `updated_date` support every unit; date fields of your schema support `day`, `month` and `year`. */ + date_bucket?: { field: keyof T & string; unit: EntityDateBucketUnit }; + /** Whether to include the number of records per group as `count`. Defaults to `true`. */ + count?: boolean; + /** Field, or fields, to sum. Each appears in the rows as `sum_`. */ + sum?: (keyof T & string) | (keyof T & string)[]; + /** Field, or fields, to average. Each appears in the rows as `avg_`. */ + avg?: (keyof T & string) | (keyof T & string)[]; + /** Field, or fields, to take the minimum of. Each appears in the rows as `min_`. */ + min?: (keyof T & string) | (keyof T & string)[]; + /** Field, or fields, to take the maximum of. Each appears in the rows as `max_`. */ + max?: (keyof T & string) | (keyof T & string)[]; + /** Field whose distinct values to count per group, returned as `count_distinct_`. Can't be combined with `count`, `sum`, `avg`, `min` or `max`. */ + count_distinct?: keyof T & string; + /** Filter on the computed fields, applied after grouping. For example `{ count: { $gt: 1 } }` keeps only duplicated groups. */ + having?: Record; + /** Computed or group field to sort the rows by, with a `-` prefix for descending. For example `'-count'`. */ + sort?: string; + /** Maximum number of rows, up to 1,000. Defaults to 1,000. */ + limit?: number; +} + +/** + * Rows returned by {@linkcode EntityHandler.aggregate | aggregate()}. + */ +export interface EntityAggregateResult { + /** One row per group: the group fields by name, then `count`, `sum_`, `avg_`, `min_`, `max_` or `count_distinct_`. */ + rows: Record[]; + /** `true` when more groups exist than `limit` allowed. */ + truncated: boolean; +} + +/** + * Options for {@linkcode EntityHandler.upsert | upsert()}. + * + * @typeParam T - Entity record type. + */ +export interface EntityUpsertOptions { + /** Field, or fields, that identify a record. A record whose key values match an existing record updates it; any other record is created. */ + key: (keyof T & string) | (keyof T & string)[]; +} + +/** + * Result returned by {@linkcode EntityHandler.upsert | upsert()}. + * + * @typeParam T - Entity record type. + */ +export interface EntityUpsertResult { + /** Number of records that were created. */ + created: number; + /** Number of existing records that were updated. */ + updated: number; + /** The written records, created and updated, as they now exist. */ + records: T[]; +} + /** * Result returned when importing entities from a file. * @@ -244,14 +363,18 @@ export interface EntityHandler { * Retrieves all records of this type with support for sorting, * pagination, and field selection. * - * **Note:** The maximum limit is 5,000 items per request. + * **Note:** The maximum limit is 5,000 items per request. To read more than + * one page, pass an {@linkcode EntityListOptions | options object} with a + * `cursor` instead of `skip`: every page costs the same however deep you are, + * and records deleted between pages never shift the boundary. `skip` is kept + * for existing code and is deprecated for loops. * * @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 limit - Maximum number of results to return. Defaults to `5000`. + * @param skip - Number of results to skip for pagination. Defaults to `0`. Deprecated for loops; use a cursor. * @param fields - Array of field names to include in the response. Defaults to all fields. - * @returns Promise resolving to an array of records with selected fields. + * @returns Promise resolving to an array of records with selected fields. When called with an options object, resolves instead to an {@linkcode EntityPage | EntityPage} with `items`, `next_cursor` and `has_more`. * * @example * ```typescript @@ -277,6 +400,18 @@ export interface EntityHandler { * // Get only specific fields * const fields = await base44.entities.MyEntity.list('-created_date', 10, 0, ['name', 'status']); * ``` + * + * @example + * ```typescript + * // Walk every record with a cursor. Pass an options object instead of + * // positional arguments to get a page with `next_cursor` and `has_more`. + * let cursor: string | null = null; + * do { + * const page = await base44.entities.MyEntity.list({ sort: '-created_date', limit: 1000, cursor }); + * await exportRows(page.items); + * cursor = page.next_cursor; + * } while (cursor); + * ``` */ list( sort?: SortField, @@ -284,6 +419,9 @@ export interface EntityHandler { skip?: number, fields?: K[], ): Promise[]>; + list( + options: EntityListOptions, + ): Promise>>; /** * Filters records based on a query. @@ -291,7 +429,11 @@ export interface EntityHandler { * Retrieves records that match specific criteria with support for * sorting, pagination, and field selection. * - * **Note:** The maximum limit is 5,000 items per request. + * **Note:** The maximum limit is 5,000 items per request. To read more than + * one page, pass an {@linkcode EntityListOptions | options object} with a + * `cursor` instead of `skip`: every page costs the same however deep you are, + * and records deleted between pages never shift the boundary. `skip` is kept + * for existing code and is deprecated for loops. * * @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 @@ -300,10 +442,10 @@ export interface EntityHandler { * for exact matches, `null` for null values, arrays as shorthand for matching any of the * provided values, or documented MongoDB query operators for advanced filtering. * @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 limit - Maximum number of results to return. Defaults to `5000`. + * @param skip - Number of results to skip for pagination. Defaults to `0`. Deprecated for loops; use a cursor. * @param fields - Array of field names to include in the response. Defaults to all fields. - * @returns Promise resolving to an array of filtered records with selected fields. + * @returns Promise resolving to an array of filtered records with selected fields. When called with an options object, resolves instead to an {@linkcode EntityPage | EntityPage} with `items`, `next_cursor` and `has_more`. * * @example * ```typescript @@ -380,6 +522,21 @@ export interface EntityHandler { * ['name', 'priority'] * ); * ``` + * + * @example + * ```typescript + * // Walk all matching records with a cursor. Pass an options object as the + * // second argument to get a page with `next_cursor` and `has_more`. + * let cursor: string | null = null; + * do { + * const page = await base44.entities.Order.filter( + * { status: 'open' }, + * { sort: '-created_date', limit: 1000, cursor } + * ); + * await exportRows(page.items); + * cursor = page.next_cursor; + * } while (cursor); + * ``` */ filter( query: EntityFilterQuery, @@ -388,6 +545,10 @@ export interface EntityHandler { skip?: number, fields?: K[], ): Promise[]>; + filter( + query: EntityFilterQuery, + options: EntityListOptions, + ): Promise>>; /** * Gets a single record by ID. @@ -605,6 +766,147 @@ export interface EntityHandler { data: Record>, ): Promise; + /** + * Counts the records that match a query. + * + * Returns the number of records the current user can read, without fetching them. + * Use it for totals, badges and "page N of M" instead of listing records and + * measuring the array. + * + * @param query - Filter query, in the same form {@linkcode filter | filter()} accepts. Defaults to all records. + * @returns Promise resolving to the number of matching records. + * + * @example + * ```typescript + * // How many tasks are still open? + * const open = await base44.entities.Task.count({ status: 'open' }); + * ``` + * + * @example + * ```typescript + * // Total records in the entity + * const total = await base44.entities.Task.count(); + * ``` + */ + count(query?: EntityFilterQuery): Promise; + + /** + * Returns the distinct values of one field. + * + * Use it to fill dropdowns and autocomplete lists instead of loading every record + * and deduplicating in the browser. At most 5,000 values are returned; `truncated` + * tells you when the field has more. Not available on entities with field-level + * read rules. + * + * @typeParam K - The field to read. + * @param field - Name of the field. + * @param query - Filter query, in the same form {@linkcode filter | filter()} accepts. Defaults to all records. + * @returns Promise resolving to the values and a `truncated` flag. + * + * @example + * ```typescript + * // Brands available in the catalog + * const { values: brands } = await base44.entities.Product.distinct('brand'); + * ``` + * + * @example + * ```typescript + * // Cities of the active customers + * const { values } = await base44.entities.Customer.distinct('city', { status: 'active' }); + * ``` + */ + distinct( + field: K, + query?: EntityFilterQuery, + ): Promise>; + + /** + * Computes counts, sums, averages, minimums, maximums or distinct counts, grouped by fields. + * + * Use it for dashboards, leaderboards and reports instead of loading every record + * and adding up in the browser. The server groups the records you can read and + * returns one row per group, up to 1,000 rows. + * + * @param spec - What to group by and what to compute. See {@linkcode EntityAggregateSpec | EntityAggregateSpec}. + * @returns Promise resolving to the rows and a `truncated` flag. + * + * @example + * ```typescript + * // Sales per agent this month, biggest first + * const { rows } = await base44.entities.Sale.aggregate({ + * match: { sale_date: { $gte: '2026-09-01' } }, + * group_by: 'agent_id', + * sum: 'amount', + * sort: '-sum_amount' + * }); + * // rows: [{ agent_id: 'a1', count: 42, sum_amount: 18250 }, ...] + * ``` + * + * @example + * ```typescript + * // Records created per day + * const { rows } = await base44.entities.Visit.aggregate({ + * date_bucket: { field: 'created_date', unit: 'day' } + * }); + * ``` + * + * @example + * ```typescript + * // Find duplicated external ids + * const { rows } = await base44.entities.Contact.aggregate({ + * group_by: 'external_id', + * having: { count: { $gt: 1 } } + * }); + * ``` + * + * @example + * ```typescript + * // Unique visitors per page + * const { rows } = await base44.entities.PageView.aggregate({ + * group_by: 'path', + * count_distinct: 'session_id' + * }); + * ``` + */ + aggregate(spec: EntityAggregateSpec): Promise; + + /** + * Creates or updates records by a key of your own. + * + * Use this when you sync data from another system: name the field, or fields, that + * identify a record, and the server updates the records whose key already exists and + * creates the rest, in one call. You no longer need to list existing records to check + * for duplicates before writing. + * + * You can upsert up to 500 records per request. When two records in one call share a + * key, the last one wins. Updates merge the given fields into the existing record, like + * {@linkcode update | update()}. + * + * @param records - Array of record data objects. Each must carry a value for every key field. + * @param options - The key field or fields. See {@linkcode EntityUpsertOptions | EntityUpsertOptions}. + * @returns Promise resolving to the counts and the written records. + * + * @example + * ```typescript + * // Sync contacts from a CRM by their CRM id + * const result = await base44.entities.Contact.upsert( + * crmContacts.map(c => ({ crm_id: c.id, name: c.name, email: c.email })), + * { key: 'crm_id' } + * ); + * console.log(`${result.created} new, ${result.updated} updated`); + * ``` + * + * @example + * ```typescript + * // Compound key + * await base44.entities.Inventory.upsert(rows, { key: ['sku', 'warehouse'] }); + * ``` + */ + upsert( + records: Partial[], + options: EntityUpsertOptions, + ): Promise>; + /** * Updates the specified records in a single request, each with its own data. * diff --git a/tests/types/entities-primitives.types.ts b/tests/types/entities-primitives.types.ts new file mode 100644 index 00000000..7d6c6080 --- /dev/null +++ b/tests/types/entities-primitives.types.ts @@ -0,0 +1,58 @@ +import type { + EntityAggregateSpec, + EntityListOptions, + EntityUpsertOptions, +} from "../../src/index.js"; + +interface Sale { + id: string; + agent_id: string; + store: string; + amount: number; + sale_date: string; + created_date: string; +} + +const perAgent = { + match: { sale_date: { $gte: "2026-09-01" } }, + group_by: "agent_id", + sum: ["amount"], + avg: "amount", + sort: "-sum_amount", + limit: 50, +} satisfies EntityAggregateSpec; + +const perDay = { + date_bucket: { field: "created_date", unit: "day" }, + count_distinct: "agent_id", +} satisfies EntityAggregateSpec; + +const duplicates = { + group_by: ["agent_id", "store"], + having: { count: { $gt: 1 } }, +} satisfies EntityAggregateSpec; + +// @ts-expect-error unknown field names are rejected +const badGroup = { group_by: "region" } satisfies EntityAggregateSpec; + +// @ts-expect-error unknown bucket unit +const badUnit = { date_bucket: { field: "created_date", unit: "hour" } } satisfies EntityAggregateSpec; + +const firstPage = { + sort: "-created_date", + limit: 1000, + fields: ["id", "amount"], +} satisfies EntityListOptions; + +const nextPage = { cursor: "opaque", sort: "-created_date" } satisfies EntityListOptions; + +// @ts-expect-error sort must name a field of the entity +const badSort = { sort: "-total" } satisfies EntityListOptions; + +const singleKey = { key: "id" } satisfies EntityUpsertOptions; +const compoundKey = { key: ["agent_id", "store"] } satisfies EntityUpsertOptions; + +// @ts-expect-error key must name fields of the entity +const badKey = { key: "sku" } satisfies EntityUpsertOptions; + +export { perAgent, perDay, duplicates, badGroup, badUnit, firstPage, nextPage, badSort, singleKey, compoundKey, badKey }; diff --git a/tests/unit/entities-primitives.test.ts b/tests/unit/entities-primitives.test.ts new file mode 100644 index 00000000..0797bf0e --- /dev/null +++ b/tests/unit/entities-primitives.test.ts @@ -0,0 +1,176 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; +import type { + EntityAggregateResult, + EntityDistinctResult, + EntityPage, + EntityUpsertResult, +} from "../../src/modules/entities.types.ts"; + +interface Order { + id: string; + status: string; + agent_id: string; + amount: number; + external_id: string; + created_date: string; +} + +declare module "../../src/modules/entities.types.ts" { + interface EntityTypeRegistry { + Order: Order; + } +} + +describe("Entities scan-free primitives", () => { + let base44: ReturnType; + let scope: nock.Scope; + const appId = "test-app-id"; + const serverUrl = "https://api.base44.com"; + const base = `/api/apps/${appId}/entities/Order`; + + beforeEach(() => { + base44 = createClient({ serverUrl, appId }); + scope = nock(serverUrl); + nock.disableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + test("filter() with an options object reads a cursor page from /page", async () => { + const reply: EntityPage> = { + items: [{ id: "1", amount: 10 }], + next_cursor: "tok-2", + has_more: true, + }; + scope + .get(`${base}/page`) + .query((q) => { + return ( + JSON.parse(q.q as string).status === "open" && + q.sort === "-created_date" && + q.limit === "1000" && + q.cursor === "tok-1" && + q.fields === "id,amount" + ); + }) + .reply(200, reply); + + const page = await base44.entities.Order.filter( + { status: "open" }, + { sort: "-created_date", limit: 1000, cursor: "tok-1", fields: ["id", "amount"] } + ); + + expect(page.items[0].amount).toBe(10); + expect(page.next_cursor).toBe("tok-2"); + expect(page.has_more).toBe(true); + expect(scope.isDone()).toBe(true); + }); + + test("list() with an options object reads the first page when cursor is null", async () => { + scope + .get(`${base}/page`) + .query((q) => q.sort === "amount" && q.cursor === undefined && q.q === undefined) + .reply(200, { items: [], next_cursor: null, has_more: false }); + + const page = await base44.entities.Order.list({ sort: "amount", cursor: null }); + expect(page).toEqual({ items: [], next_cursor: null, has_more: false }); + expect(scope.isDone()).toBe(true); + }); + + test("list() and filter() with positional arguments still return arrays from the list route", async () => { + scope + .get(base) + .query((q) => q.sort === "-created_date" && q.limit === "10" && q.skip === "20") + .reply(200, [{ id: "1" }]); + scope + .get(base) + .query((q) => JSON.parse(q.q as string).status === "open" && q.limit === "5") + .reply(200, [{ id: "2" }]); + + expect(await base44.entities.Order.list("-created_date", 10, 20)).toEqual([{ id: "1" }]); + expect(await base44.entities.Order.filter({ status: "open" }, "-created_date", 5)).toEqual([{ id: "2" }]); + expect(scope.isDone()).toBe(true); + }); + + test("count() returns the number and passes the filter as q", async () => { + scope + .get(`${base}/count`) + .query((q) => JSON.parse(q.q as string).status === "open") + .reply(200, { count: 42 }); + + expect(await base44.entities.Order.count({ status: "open" })).toBe(42); + expect(scope.isDone()).toBe(true); + }); + + test("count() without a query sends no q", async () => { + scope + .get(`${base}/count`) + .query((q) => q.q === undefined) + .reply(200, { count: 7 }); + + expect(await base44.entities.Order.count()).toBe(7); + expect(scope.isDone()).toBe(true); + }); + + test("distinct() sends the field and optional filter", async () => { + const reply: EntityDistinctResult = { values: ["a1", "a2"], truncated: false }; + scope + .get(`${base}/distinct`) + .query((q) => q.field === "agent_id" && JSON.parse(q.q as string).status === "open") + .reply(200, reply); + + const result = await base44.entities.Order.distinct("agent_id", { status: "open" }); + expect(result.values).toEqual(["a1", "a2"]); + expect(result.truncated).toBe(false); + expect(scope.isDone()).toBe(true); + }); + + test("aggregate() posts the spec as-is to /aggregate", async () => { + const spec = { + match: { status: "paid" }, + group_by: "agent_id", + sum: "amount", + having: { count: { $gt: 1 } }, + sort: "-sum_amount", + limit: 10, + } as const; + const reply: EntityAggregateResult = { + rows: [{ agent_id: "a1", count: 3, sum_amount: 300 }], + truncated: false, + }; + scope.post(`${base}/aggregate`, spec as nock.RequestBodyMatcher).reply(200, reply); + + const result = await base44.entities.Order.aggregate(spec); + expect(result.rows[0].sum_amount).toBe(300); + expect(scope.isDone()).toBe(true); + }); + + test("upsert() posts records and the key", async () => { + const records = [ + { external_id: "x1", amount: 5 }, + { external_id: "x2", amount: 6 }, + ]; + const reply: EntityUpsertResult = { + created: 1, + updated: 1, + records: [ + { id: "1", status: "open", agent_id: "a1", amount: 5, external_id: "x1", created_date: "2026-01-01" }, + { id: "2", status: "open", agent_id: "a1", amount: 6, external_id: "x2", created_date: "2026-01-02" }, + ], + }; + scope + .post(`${base}/upsert`, { records, key: ["external_id", "agent_id"] } as nock.RequestBodyMatcher) + .reply(200, reply); + + const result = await base44.entities.Order.upsert(records, { key: ["external_id", "agent_id"] }); + expect(result.created).toBe(1); + expect(result.updated).toBe(1); + expect(result.records).toHaveLength(2); + expect(scope.isDone()).toBe(true); + }); +}); From 035b9b7ca2b94ab1ce688034bb9b7bed6c48fa5d Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Wed, 16 Sep 2026 12:58:25 +0300 Subject: [PATCH 02/11] entities: cursor list route is v2/list, not page Co-Authored-By: Claude Fable 5.1 --- src/modules/entities.ts | 4 ++-- tests/unit/entities-primitives.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 5ebd6e68..ebc5dbf6 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -123,7 +123,7 @@ function createEntityHandler( fields?: K[] ): Promise { if (isListOptions(sortOrOptions)) { - return axios.get(`${baseURL}/page`, { params: pageParams(sortOrOptions) }); + return axios.get(`${baseURL}/v2/list`, { params: pageParams(sortOrOptions) }); } const params: Record = {}; if (sortOrOptions) params.sort = sortOrOptions; @@ -145,7 +145,7 @@ function createEntityHandler( ): Promise { const q = JSON.stringify(query); if (isListOptions(sortOrOptions)) { - return axios.get(`${baseURL}/page`, { params: { q, ...pageParams(sortOrOptions) } }); + return axios.get(`${baseURL}/v2/list`, { params: { q, ...pageParams(sortOrOptions) } }); } const params: Record = { q }; diff --git a/tests/unit/entities-primitives.test.ts b/tests/unit/entities-primitives.test.ts index 0797bf0e..8fd5c800 100644 --- a/tests/unit/entities-primitives.test.ts +++ b/tests/unit/entities-primitives.test.ts @@ -41,14 +41,14 @@ describe("Entities scan-free primitives", () => { nock.enableNetConnect(); }); - test("filter() with an options object reads a cursor page from /page", async () => { + test("filter() with an options object reads a cursor page from v2/list", async () => { const reply: EntityPage> = { items: [{ id: "1", amount: 10 }], next_cursor: "tok-2", has_more: true, }; scope - .get(`${base}/page`) + .get(`${base}/v2/list`) .query((q) => { return ( JSON.parse(q.q as string).status === "open" && @@ -73,7 +73,7 @@ describe("Entities scan-free primitives", () => { test("list() with an options object reads the first page when cursor is null", async () => { scope - .get(`${base}/page`) + .get(`${base}/v2/list`) .query((q) => q.sort === "amount" && q.cursor === undefined && q.q === undefined) .reply(200, { items: [], next_cursor: null, has_more: false }); From 074d37345e3da2808aff791777b056171d5011dc Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Wed, 16 Sep 2026 13:01:28 +0300 Subject: [PATCH 03/11] entities: name the array and page readers instead of a union-typed parameter Co-Authored-By: Claude Fable 5.1 --- src/modules/entities.ts | 86 ++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 48 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index ebc5dbf6..210b02af 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -86,16 +86,6 @@ function isListOptions(value: unknown): value is EntityListOptions { return typeof value === "object" && value !== null; } -function pageParams(options: EntityListOptions): Record { - const params: Record = {}; - if (options.sort) params.sort = options.sort; - if (options.limit) params.limit = options.limit; - if (options.cursor) params.cursor = options.cursor; - if (options.fields) - params.fields = Array.isArray(options.fields) ? options.fields.join(",") : options.fields; - return params; -} - /** * Creates a handler for a specific entity. * @@ -114,48 +104,48 @@ function createEntityHandler( ): EntityHandler { const baseURL = `/apps/${appId}/entities/${entityName}`; - return { - // List entities. Positional args read one array; an options object reads one cursor page. - async list( - sortOrOptions?: SortField | EntityListOptions, - limit?: number, - skip?: number, - fields?: K[] - ): Promise { - if (isListOptions(sortOrOptions)) { - return axios.get(`${baseURL}/v2/list`, { params: pageParams(sortOrOptions) }); - } - const params: Record = {}; - if (sortOrOptions) params.sort = sortOrOptions; - if (limit) params.limit = limit; - if (skip) params.skip = skip; - if (fields) - params.fields = Array.isArray(fields) ? fields.join(",") : fields; + const fieldsParam = (fields?: readonly (keyof T)[]) => + Array.isArray(fields) ? fields.join(",") : (fields as string | undefined); - return axios.get(baseURL, { params }); - }, + // GET /{entity}: the array form shared by list() and filter() + const readArray = ( + sort?: SortField, + limit?: number, + skip?: number, + fields?: (keyof T)[], + query?: EntityFilterQuery + ) => { + const params: Record = {}; + if (query) params.q = JSON.stringify(query); + if (sort) params.sort = sort; + if (limit) params.limit = limit; + if (skip) params.skip = skip; + if (fields) params.fields = fieldsParam(fields)!; + return axios.get(baseURL, { params }); + }; - // Filter entities. Positional args read one array; an options object reads one cursor page. - async filter( - query: EntityFilterQuery, - sortOrOptions?: SortField | EntityListOptions, - limit?: number, - skip?: number, - fields?: K[] - ): Promise { - const q = JSON.stringify(query); - if (isListOptions(sortOrOptions)) { - return axios.get(`${baseURL}/v2/list`, { params: { q, ...pageParams(sortOrOptions) } }); - } - const params: Record = { q }; + // GET /{entity}/v2/list: one cursor page, shared by list(options) and filter(query, options) + const readPage = (options: EntityListOptions, query?: EntityFilterQuery) => { + const params: Record = {}; + if (query) params.q = JSON.stringify(query); + if (options.sort) params.sort = options.sort; + if (options.limit) params.limit = options.limit; + if (options.cursor) params.cursor = options.cursor; + if (options.fields) params.fields = fieldsParam(options.fields)!; + return axios.get(`${baseURL}/v2/list`, { params }); + }; - if (sortOrOptions) params.sort = sortOrOptions; - if (limit) params.limit = limit; - if (skip) params.skip = skip; - if (fields) - params.fields = Array.isArray(fields) ? fields.join(",") : fields; + return { + // list(sort, limit, skip, fields) returns an array; list(options) returns one cursor page. + async list(...args: any[]): Promise { + const [sort, limit, skip, fields] = args; + return isListOptions(sort) ? readPage(sort) : readArray(sort, limit, skip, fields); + }, - return axios.get(baseURL, { params }); + // filter(query, sort, limit, skip, fields) returns an array; filter(query, options) returns one cursor page. + async filter(query: EntityFilterQuery, ...args: any[]): Promise { + const [sort, limit, skip, fields] = args; + return isListOptions(sort) ? readPage(sort, query) : readArray(sort, limit, skip, fields, query); }, // Get entity by ID From 77cef170438e94179f3c8d2d0db1926c701aff3e Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Wed, 16 Sep 2026 13:04:36 +0300 Subject: [PATCH 04/11] entities: drop distinct(); aggregate({ group_by }) covers it Co-Authored-By: Claude Fable 5.1 --- .../types-to-expose.json | 1 - src/index.ts | 1 - src/modules/entities.ts | 11 ----- src/modules/entities.types.ts | 42 ------------------- tests/unit/entities-primitives.test.ts | 14 ------- 5 files changed, 69 deletions(-) diff --git a/scripts/mintlify-post-processing/types-to-expose.json b/scripts/mintlify-post-processing/types-to-expose.json index 03da1fe9..1d80ef47 100644 --- a/scripts/mintlify-post-processing/types-to-expose.json +++ b/scripts/mintlify-post-processing/types-to-expose.json @@ -22,7 +22,6 @@ "EntityAggregateResult", "EntityAggregateSpec", "EntityDateBucketUnit", - "EntityDistinctResult", "EntityHandler", "EntityListOptions", "EntityPage", diff --git a/src/index.ts b/src/index.ts index 51b01713..24c69e0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,6 @@ export type { EntityAggregateResult, EntityAggregateSpec, EntityDateBucketUnit, - EntityDistinctResult, EntityFilterOperators, EntityFilterQuery, EntityFilterValue, diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 210b02af..5c25cb95 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -5,7 +5,6 @@ import { EntitiesModule, EntityAggregateResult, EntityAggregateSpec, - EntityDistinctResult, EntityFilterQuery, EntityHandler, EntityListOptions, @@ -191,16 +190,6 @@ function createEntityHandler( return result.count; }, - // Distinct values of one field - async distinct( - field: K, - query?: EntityFilterQuery - ): Promise> { - const params: Record = { field }; - if (query) params.q = JSON.stringify(query); - return axios.get(`${baseURL}/distinct`, { params }); - }, - // Server-side group-by aggregation async aggregate(spec: EntityAggregateSpec): Promise { return axios.post(`${baseURL}/aggregate`, spec); diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 9caf8fe5..18faac4a 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -89,18 +89,6 @@ export interface EntityPage { has_more: boolean; } -/** - * Result returned by {@linkcode EntityHandler.distinct | distinct()}. - * - * @typeParam V - Type of the field's values. - */ -export interface EntityDistinctResult { - /** The distinct values, unordered. */ - values: V[]; - /** `true` when the field has more than 5,000 distinct values and the list was cut. */ - truncated: boolean; -} - /** * Time unit for {@linkcode EntityAggregateSpec.date_bucket | date_bucket}. */ @@ -790,36 +778,6 @@ export interface EntityHandler { */ count(query?: EntityFilterQuery): Promise; - /** - * Returns the distinct values of one field. - * - * Use it to fill dropdowns and autocomplete lists instead of loading every record - * and deduplicating in the browser. At most 5,000 values are returned; `truncated` - * tells you when the field has more. Not available on entities with field-level - * read rules. - * - * @typeParam K - The field to read. - * @param field - Name of the field. - * @param query - Filter query, in the same form {@linkcode filter | filter()} accepts. Defaults to all records. - * @returns Promise resolving to the values and a `truncated` flag. - * - * @example - * ```typescript - * // Brands available in the catalog - * const { values: brands } = await base44.entities.Product.distinct('brand'); - * ``` - * - * @example - * ```typescript - * // Cities of the active customers - * const { values } = await base44.entities.Customer.distinct('city', { status: 'active' }); - * ``` - */ - distinct( - field: K, - query?: EntityFilterQuery, - ): Promise>; - /** * Computes counts, sums, averages, minimums, maximums or distinct counts, grouped by fields. * diff --git a/tests/unit/entities-primitives.test.ts b/tests/unit/entities-primitives.test.ts index 8fd5c800..0eb8bb98 100644 --- a/tests/unit/entities-primitives.test.ts +++ b/tests/unit/entities-primitives.test.ts @@ -3,7 +3,6 @@ import nock from "nock"; import { createClient } from "../../src/index.ts"; import type { EntityAggregateResult, - EntityDistinctResult, EntityPage, EntityUpsertResult, } from "../../src/modules/entities.types.ts"; @@ -117,19 +116,6 @@ describe("Entities scan-free primitives", () => { expect(scope.isDone()).toBe(true); }); - test("distinct() sends the field and optional filter", async () => { - const reply: EntityDistinctResult = { values: ["a1", "a2"], truncated: false }; - scope - .get(`${base}/distinct`) - .query((q) => q.field === "agent_id" && JSON.parse(q.q as string).status === "open") - .reply(200, reply); - - const result = await base44.entities.Order.distinct("agent_id", { status: "open" }); - expect(result.values).toEqual(["a1", "a2"]); - expect(result.truncated).toBe(false); - expect(scope.isDone()).toBe(true); - }); - test("aggregate() posts the spec as-is to /aggregate", async () => { const spec = { match: { status: "paid" }, From 14f9ccd64ff44f92016e8191d669a0a49933fdec Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Wed, 16 Sep 2026 13:07:27 +0300 Subject: [PATCH 05/11] entities: align the API with common practice - aggregate spec: `query` (not `match`), camelCase keys (`groupBy`, `dateBucket`, `countDistinct`), and no rule against combining countDistinct with other measures - cursor pages default to 100 rows; the maximum stays 5,000 Co-Authored-By: Claude Fable 5.1 --- src/modules/entities.ts | 4 +++- src/modules/entities.types.ts | 26 ++++++++++++------------ tests/types/entities-primitives.types.ts | 14 ++++++------- tests/unit/entities-primitives.test.ts | 8 ++++---- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 5c25cb95..9fcfd692 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -81,6 +81,8 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null } } +const DEFAULT_PAGE_LIMIT = 100; + function isListOptions(value: unknown): value is EntityListOptions { return typeof value === "object" && value !== null; } @@ -128,7 +130,7 @@ function createEntityHandler( const params: Record = {}; if (query) params.q = JSON.stringify(query); if (options.sort) params.sort = options.sort; - if (options.limit) params.limit = options.limit; + params.limit = options.limit || DEFAULT_PAGE_LIMIT; if (options.cursor) params.cursor = options.cursor; if (options.fields) params.fields = fieldsParam(options.fields)!; return axios.get(`${baseURL}/v2/list`, { params }); diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 18faac4a..74fa4e12 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -66,7 +66,7 @@ export interface UpdateManyResult { export interface EntityListOptions { /** Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`. Every page of one walk must use the same sort. */ sort?: SortField; - /** Maximum number of records per page, up to 5,000. Defaults to 5,000. */ + /** Maximum number of records per page, up to 5,000. Defaults to 100. */ limit?: number; /** `next_cursor` from the previous page. Omit or pass `null` for the first page. */ cursor?: string | null; @@ -90,7 +90,7 @@ export interface EntityPage { } /** - * Time unit for {@linkcode EntityAggregateSpec.date_bucket | date_bucket}. + * Time unit for {@linkcode EntityAggregateSpec.dateBucket | dateBucket}. */ export type EntityDateBucketUnit = "day" | "week" | "month" | "year"; @@ -104,11 +104,11 @@ export type EntityDateBucketUnit = "day" | "week" | "month" | "year"; */ export interface EntityAggregateSpec { /** Filter applied before grouping, in the same form {@linkcode EntityHandler.filter | filter()} accepts. Defaults to all records. */ - match?: EntityFilterQuery; + query?: EntityFilterQuery; /** Field, or up to four fields, to group by. Omit to get one total row. */ - group_by?: (keyof T & string) | (keyof T & string)[]; + groupBy?: (keyof T & string) | (keyof T & string)[]; /** Group by a time bucket of a date field. `created_date` and `updated_date` support every unit; date fields of your schema support `day`, `month` and `year`. */ - date_bucket?: { field: keyof T & string; unit: EntityDateBucketUnit }; + dateBucket?: { field: keyof T & string; unit: EntityDateBucketUnit }; /** Whether to include the number of records per group as `count`. Defaults to `true`. */ count?: boolean; /** Field, or fields, to sum. Each appears in the rows as `sum_`. */ @@ -119,8 +119,8 @@ export interface EntityAggregateSpec { min?: (keyof T & string) | (keyof T & string)[]; /** Field, or fields, to take the maximum of. Each appears in the rows as `max_`. */ max?: (keyof T & string) | (keyof T & string)[]; - /** Field whose distinct values to count per group, returned as `count_distinct_`. Can't be combined with `count`, `sum`, `avg`, `min` or `max`. */ - count_distinct?: keyof T & string; + /** Field whose distinct values to count per group, returned as `count_distinct_`. */ + countDistinct?: keyof T & string; /** Filter on the computed fields, applied after grouping. For example `{ count: { $gt: 1 } }` keeps only duplicated groups. */ having?: Record; /** Computed or group field to sort the rows by, with a `-` prefix for descending. For example `'-count'`. */ @@ -792,8 +792,8 @@ export interface EntityHandler { * ```typescript * // Sales per agent this month, biggest first * const { rows } = await base44.entities.Sale.aggregate({ - * match: { sale_date: { $gte: '2026-09-01' } }, - * group_by: 'agent_id', + * query: { sale_date: { $gte: '2026-09-01' } }, + * groupBy: 'agent_id', * sum: 'amount', * sort: '-sum_amount' * }); @@ -804,7 +804,7 @@ export interface EntityHandler { * ```typescript * // Records created per day * const { rows } = await base44.entities.Visit.aggregate({ - * date_bucket: { field: 'created_date', unit: 'day' } + * dateBucket: { field: 'created_date', unit: 'day' } * }); * ``` * @@ -812,7 +812,7 @@ export interface EntityHandler { * ```typescript * // Find duplicated external ids * const { rows } = await base44.entities.Contact.aggregate({ - * group_by: 'external_id', + * groupBy: 'external_id', * having: { count: { $gt: 1 } } * }); * ``` @@ -821,8 +821,8 @@ export interface EntityHandler { * ```typescript * // Unique visitors per page * const { rows } = await base44.entities.PageView.aggregate({ - * group_by: 'path', - * count_distinct: 'session_id' + * groupBy: 'path', + * countDistinct: 'session_id' * }); * ``` */ diff --git a/tests/types/entities-primitives.types.ts b/tests/types/entities-primitives.types.ts index 7d6c6080..fe40c3b5 100644 --- a/tests/types/entities-primitives.types.ts +++ b/tests/types/entities-primitives.types.ts @@ -14,8 +14,8 @@ interface Sale { } const perAgent = { - match: { sale_date: { $gte: "2026-09-01" } }, - group_by: "agent_id", + query: { sale_date: { $gte: "2026-09-01" } }, + groupBy: "agent_id", sum: ["amount"], avg: "amount", sort: "-sum_amount", @@ -23,20 +23,20 @@ const perAgent = { } satisfies EntityAggregateSpec; const perDay = { - date_bucket: { field: "created_date", unit: "day" }, - count_distinct: "agent_id", + dateBucket: { field: "created_date", unit: "day" }, + countDistinct: "agent_id", } satisfies EntityAggregateSpec; const duplicates = { - group_by: ["agent_id", "store"], + groupBy: ["agent_id", "store"], having: { count: { $gt: 1 } }, } satisfies EntityAggregateSpec; // @ts-expect-error unknown field names are rejected -const badGroup = { group_by: "region" } satisfies EntityAggregateSpec; +const badGroup = { groupBy: "region" } satisfies EntityAggregateSpec; // @ts-expect-error unknown bucket unit -const badUnit = { date_bucket: { field: "created_date", unit: "hour" } } satisfies EntityAggregateSpec; +const badUnit = { dateBucket: { field: "created_date", unit: "hour" } } satisfies EntityAggregateSpec; const firstPage = { sort: "-created_date", diff --git a/tests/unit/entities-primitives.test.ts b/tests/unit/entities-primitives.test.ts index 0eb8bb98..50f1ae41 100644 --- a/tests/unit/entities-primitives.test.ts +++ b/tests/unit/entities-primitives.test.ts @@ -70,10 +70,10 @@ describe("Entities scan-free primitives", () => { expect(scope.isDone()).toBe(true); }); - test("list() with an options object reads the first page when cursor is null", async () => { + test("list() with an options object reads the first page when cursor is null, 100 rows by default", async () => { scope .get(`${base}/v2/list`) - .query((q) => q.sort === "amount" && q.cursor === undefined && q.q === undefined) + .query((q) => q.sort === "amount" && q.limit === "100" && q.cursor === undefined && q.q === undefined) .reply(200, { items: [], next_cursor: null, has_more: false }); const page = await base44.entities.Order.list({ sort: "amount", cursor: null }); @@ -118,8 +118,8 @@ describe("Entities scan-free primitives", () => { test("aggregate() posts the spec as-is to /aggregate", async () => { const spec = { - match: { status: "paid" }, - group_by: "agent_id", + query: { status: "paid" }, + groupBy: "agent_id", sum: "amount", having: { count: { $gt: 1 } }, sort: "-sum_amount", From d64879f4a280c9cfd8cb5b991dc56a79cb7010b2 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Wed, 16 Sep 2026 14:48:29 +0300 Subject: [PATCH 06/11] entities: distinct option on list()/filter(); cursor carries the query A cursor token now carries the query, sort and fields of the walk, so a later page needs only cursor and limit (same model as Wix Data cursorPaging). distinct moves out of aggregate-only usage into an option on list()/filter() that returns a page of values, matching how query APIs usually expose it. Co-Authored-By: Claude Fable 5.1 --- .../types-to-expose.json | 1 + src/index.ts | 1 + src/modules/entities.ts | 21 +++-- src/modules/entities.types.ts | 80 ++++++++++++++----- tests/types/entities-primitives.types.ts | 28 ++++++- tests/unit/entities-primitives.test.ts | 22 +++++ 6 files changed, 127 insertions(+), 26 deletions(-) diff --git a/scripts/mintlify-post-processing/types-to-expose.json b/scripts/mintlify-post-processing/types-to-expose.json index 1d80ef47..824bb16e 100644 --- a/scripts/mintlify-post-processing/types-to-expose.json +++ b/scripts/mintlify-post-processing/types-to-expose.json @@ -22,6 +22,7 @@ "EntityAggregateResult", "EntityAggregateSpec", "EntityDateBucketUnit", + "EntityDistinctOptions", "EntityHandler", "EntityListOptions", "EntityPage", diff --git a/src/index.ts b/src/index.ts index 24c69e0d..94452593 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ export type { EntityAggregateResult, EntityAggregateSpec, EntityDateBucketUnit, + EntityDistinctOptions, EntityFilterOperators, EntityFilterQuery, EntityFilterValue, diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 9fcfd692..e999cb77 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -5,6 +5,7 @@ import { EntitiesModule, EntityAggregateResult, EntityAggregateSpec, + EntityDistinctOptions, EntityFilterQuery, EntityHandler, EntityListOptions, @@ -83,7 +84,9 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null const DEFAULT_PAGE_LIMIT = 100; -function isListOptions(value: unknown): value is EntityListOptions { +type PageOptions = EntityListOptions | EntityDistinctOptions; + +function isPageOptions(value: unknown): value is PageOptions { return typeof value === "object" && value !== null; } @@ -125,14 +128,18 @@ function createEntityHandler( return axios.get(baseURL, { params }); }; - // GET /{entity}/v2/list: one cursor page, shared by list(options) and filter(query, options) - const readPage = (options: EntityListOptions, query?: EntityFilterQuery) => { + // GET /{entity}/v2/list: one cursor page of records or distinct values, shared by list(options) and filter(query, options) + const readPage = (options: PageOptions, query?: EntityFilterQuery) => { const params: Record = {}; if (query) params.q = JSON.stringify(query); - if (options.sort) params.sort = options.sort; params.limit = options.limit || DEFAULT_PAGE_LIMIT; if (options.cursor) params.cursor = options.cursor; - if (options.fields) params.fields = fieldsParam(options.fields)!; + if ("distinct" in options) { + params.distinct = options.distinct; + } else { + if (options.sort) params.sort = options.sort; + if (options.fields) params.fields = fieldsParam(options.fields)!; + } return axios.get(`${baseURL}/v2/list`, { params }); }; @@ -140,13 +147,13 @@ function createEntityHandler( // list(sort, limit, skip, fields) returns an array; list(options) returns one cursor page. async list(...args: any[]): Promise { const [sort, limit, skip, fields] = args; - return isListOptions(sort) ? readPage(sort) : readArray(sort, limit, skip, fields); + return isPageOptions(sort) ? readPage(sort) : readArray(sort, limit, skip, fields); }, // filter(query, sort, limit, skip, fields) returns an array; filter(query, options) returns one cursor page. async filter(query: EntityFilterQuery, ...args: any[]): Promise { const [sort, limit, skip, fields] = args; - return isListOptions(sort) ? readPage(sort, query) : readArray(sort, limit, skip, fields, query); + return isPageOptions(sort) ? readPage(sort, query) : readArray(sort, limit, skip, fields, query); }, // Get entity by ID diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 74fa4e12..96ac06c3 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -64,16 +64,38 @@ export interface UpdateManyResult { * @typeParam K - The fields to include in each record. */ export interface EntityListOptions { - /** Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`. Every page of one walk must use the same sort. */ + /** Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`. */ sort?: SortField; /** Maximum number of records per page, up to 5,000. Defaults to 100. */ limit?: number; - /** `next_cursor` from the previous page. Omit or pass `null` for the first page. */ + /** + * `next_cursor` from the previous page. Omit or pass `null` for the first page. + * + * The token carries the query, sort and fields of the walk, so a later page needs only + * `cursor` and `limit`. Passing a different query, sort or fields with a cursor is an error. + */ cursor?: string | null; /** Array of field names to include in each record. Defaults to all fields. */ fields?: K[]; } +/** + * Options object accepted by {@linkcode EntityHandler.list | list()} and + * {@linkcode EntityHandler.filter | filter()} to read the distinct values of one field + * instead of records. + * + * @typeParam T - Entity record type. + * @typeParam K - The field whose distinct values to read. + */ +export interface EntityDistinctOptions { + /** Field whose distinct values to return, in ascending order. Array fields contribute each element. */ + distinct: K; + /** Maximum number of values per page, up to 5,000. Defaults to 100. */ + limit?: number; + /** `next_cursor` from the previous page. Omit or pass `null` for the first page. The token carries the query and field. */ + cursor?: string | null; +} + /** * One page of records, returned by {@linkcode EntityHandler.list | list()} and * {@linkcode EntityHandler.filter | filter()} when called with an options object. @@ -81,7 +103,7 @@ export interface EntityListOptions { * @typeParam T - Record type of the items. */ export interface EntityPage { - /** The page's records, in the requested sort order. */ + /** The page's records in the requested sort order, or the distinct values in ascending order. */ items: T[]; /** Pass as `cursor` to get the next page. `null` on the last page. */ next_cursor: string | null; @@ -362,7 +384,7 @@ export interface EntityHandler { * @param limit - Maximum number of results to return. Defaults to `5000`. * @param skip - Number of results to skip for pagination. Defaults to `0`. Deprecated for loops; use a cursor. * @param fields - Array of field names to include in the response. Defaults to all fields. - * @returns Promise resolving to an array of records with selected fields. When called with an options object, resolves instead to an {@linkcode EntityPage | EntityPage} with `items`, `next_cursor` and `has_more`. + * @returns Promise resolving to an array of records with selected fields. When called with an options object, resolves instead to an {@linkcode EntityPage | EntityPage} with `items`, `next_cursor` and `has_more`; with a `distinct` option the items are the field's values. * * @example * ```typescript @@ -393,12 +415,18 @@ export interface EntityHandler { * ```typescript * // Walk every record with a cursor. Pass an options object instead of * // positional arguments to get a page with `next_cursor` and `has_more`. - * let cursor: string | null = null; - * do { - * const page = await base44.entities.MyEntity.list({ sort: '-created_date', limit: 1000, cursor }); + * let page = await base44.entities.MyEntity.list({ sort: '-created_date', limit: 1000 }); + * await exportRows(page.items); + * while (page.has_more) { + * page = await base44.entities.MyEntity.list({ cursor: page.next_cursor, limit: 1000 }); * await exportRows(page.items); - * cursor = page.next_cursor; - * } while (cursor); + * } + * ``` + * + * @example + * ```typescript + * // Distinct values of one field, instead of records + * const { items: categories } = await base44.entities.Product.list({ distinct: 'category' }); * ``` */ list( @@ -407,6 +435,9 @@ export interface EntityHandler { skip?: number, fields?: K[], ): Promise[]>; + list( + options: EntityDistinctOptions, + ): Promise>; list( options: EntityListOptions, ): Promise>>; @@ -433,7 +464,7 @@ export interface EntityHandler { * @param limit - Maximum number of results to return. Defaults to `5000`. * @param skip - Number of results to skip for pagination. Defaults to `0`. Deprecated for loops; use a cursor. * @param fields - Array of field names to include in the response. Defaults to all fields. - * @returns Promise resolving to an array of filtered records with selected fields. When called with an options object, resolves instead to an {@linkcode EntityPage | EntityPage} with `items`, `next_cursor` and `has_more`. + * @returns Promise resolving to an array of filtered records with selected fields. When called with an options object, resolves instead to an {@linkcode EntityPage | EntityPage} with `items`, `next_cursor` and `has_more`; with a `distinct` option the items are the field's values. * * @example * ```typescript @@ -515,15 +546,24 @@ export interface EntityHandler { * ```typescript * // Walk all matching records with a cursor. Pass an options object as the * // second argument to get a page with `next_cursor` and `has_more`. - * let cursor: string | null = null; - * do { - * const page = await base44.entities.Order.filter( - * { status: 'open' }, - * { sort: '-created_date', limit: 1000, cursor } - * ); + * let page = await base44.entities.Order.filter( + * { status: 'open' }, + * { sort: '-created_date', limit: 1000 } + * ); + * await exportRows(page.items); + * while (page.has_more) { + * page = await base44.entities.Order.filter({ status: 'open' }, { cursor: page.next_cursor, limit: 1000 }); * await exportRows(page.items); - * cursor = page.next_cursor; - * } while (cursor); + * } + * ``` + * + * @example + * ```typescript + * // Distinct values of one field among the matching records + * const { items: agents } = await base44.entities.Order.filter( + * { status: 'open' }, + * { distinct: 'agent_id' } + * ); * ``` */ filter( @@ -533,6 +573,10 @@ export interface EntityHandler { skip?: number, fields?: K[], ): Promise[]>; + filter( + query: EntityFilterQuery, + options: EntityDistinctOptions, + ): Promise>; filter( query: EntityFilterQuery, options: EntityListOptions, diff --git a/tests/types/entities-primitives.types.ts b/tests/types/entities-primitives.types.ts index fe40c3b5..45f0500d 100644 --- a/tests/types/entities-primitives.types.ts +++ b/tests/types/entities-primitives.types.ts @@ -1,5 +1,6 @@ import type { EntityAggregateSpec, + EntityDistinctOptions, EntityListOptions, EntityUpsertOptions, } from "../../src/index.js"; @@ -49,10 +50,35 @@ const nextPage = { cursor: "opaque", sort: "-created_date" } satisfies EntityLis // @ts-expect-error sort must name a field of the entity const badSort = { sort: "-total" } satisfies EntityListOptions; +const distinctStores = { distinct: "store", limit: 500 } satisfies EntityDistinctOptions; + +// @ts-expect-error distinct must name a field of the entity +const badDistinct = { distinct: "region" } satisfies EntityDistinctOptions; + const singleKey = { key: "id" } satisfies EntityUpsertOptions; const compoundKey = { key: ["agent_id", "store"] } satisfies EntityUpsertOptions; // @ts-expect-error key must name fields of the entity const badKey = { key: "sku" } satisfies EntityUpsertOptions; -export { perAgent, perDay, duplicates, badGroup, badUnit, firstPage, nextPage, badSort, singleKey, compoundKey, badKey }; +export { perAgent, perDay, duplicates, badGroup, badUnit, firstPage, nextPage, badSort, distinctStores, badDistinct, singleKey, compoundKey, badKey }; + +import { createClient } from "../../src/index.js"; +declare module "../../src/modules/entities.types.js" { + interface EntityTypeRegistry { + Sale: Sale; + } +} +async function inferred() { + const client = createClient({ appId: "x" }); + const stores = await client.entities.Sale.filter({ store: "s1" }, { distinct: "store" }); + const s: string = stores.items[0]; + const amounts = await client.entities.Sale.list({ distinct: "amount" }); + const n: number = amounts.items[0]; + const page = await client.entities.Sale.list({ cursor: "tok", fields: ["id"] }); + const id: string = page.items[0].id; + // @ts-expect-error amount was not selected + page.items[0].amount; + return [s, n, id]; +} +export { inferred }; diff --git a/tests/unit/entities-primitives.test.ts b/tests/unit/entities-primitives.test.ts index 50f1ae41..c507d7d0 100644 --- a/tests/unit/entities-primitives.test.ts +++ b/tests/unit/entities-primitives.test.ts @@ -81,6 +81,28 @@ describe("Entities scan-free primitives", () => { expect(scope.isDone()).toBe(true); }); + test("a later page needs only the cursor: no q, sort or fields are sent", async () => { + scope + .get(`${base}/v2/list`) + .query((q) => q.cursor === "tok-1" && q.limit === "100" && q.q === undefined && q.sort === undefined && q.fields === undefined) + .reply(200, { items: [], next_cursor: null, has_more: false }); + + const page = await base44.entities.Order.list({ cursor: "tok-1" }); + expect(page.has_more).toBe(false); + expect(scope.isDone()).toBe(true); + }); + + test("filter() with a distinct option reads a page of values from v2/list", async () => { + scope + .get(`${base}/v2/list`) + .query((q) => JSON.parse(q.q as string).status === "open" && q.distinct === "agent_id" && q.limit === "100" && q.sort === undefined) + .reply(200, { items: ["a1", "a2"], next_cursor: null, has_more: false }); + + const page = await base44.entities.Order.filter({ status: "open" }, { distinct: "agent_id" }); + expect(page.items).toEqual(["a1", "a2"]); + expect(scope.isDone()).toBe(true); + }); + test("list() and filter() with positional arguments still return arrays from the list route", async () => { scope .get(base) From d8b9484e5d9a184edc40f9e7d3b9f637d5e89fb9 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Wed, 16 Sep 2026 14:59:00 +0300 Subject: [PATCH 07/11] entities: distinct pages are capped at 1,000 values Co-Authored-By: Claude Fable 5.1 --- src/modules/entities.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 96ac06c3..e96265a7 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -90,7 +90,7 @@ export interface EntityListOptions { export interface EntityDistinctOptions { /** Field whose distinct values to return, in ascending order. Array fields contribute each element. */ distinct: K; - /** Maximum number of values per page, up to 5,000. Defaults to 100. */ + /** Maximum number of values per page, up to 1,000. Defaults to 100. */ limit?: number; /** `next_cursor` from the previous page. Omit or pass `null` for the first page. The token carries the query and field. */ cursor?: string | null; From cd90cc12c59d62d4b61d4decffea1b29562f0f66 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Fri, 18 Sep 2026 09:03:52 +0300 Subject: [PATCH 08/11] entities: aggregate() accepts a raw MongoDB pipeline aggregate(stages[]) posts { pipeline } to the same route; field names are the entity's own and the server translates them. The spec form stays the primary, typed API; the pipeline is the escape hatch for what the spec cannot express. Co-Authored-By: Claude Fable 5.1 --- .../types-to-expose.json | 1 + src/index.ts | 1 + src/modules/entities.ts | 6 ++-- src/modules/entities.types.ts | 31 ++++++++++++++++++- tests/types/entities-primitives.types.ts | 11 ++++++- tests/unit/entities-primitives.test.ts | 12 +++++++ 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/scripts/mintlify-post-processing/types-to-expose.json b/scripts/mintlify-post-processing/types-to-expose.json index 824bb16e..e169c0d7 100644 --- a/scripts/mintlify-post-processing/types-to-expose.json +++ b/scripts/mintlify-post-processing/types-to-expose.json @@ -26,6 +26,7 @@ "EntityHandler", "EntityListOptions", "EntityPage", + "EntityPipelineStage", "EntityRecord", "EntityTypeRegistry", "EntityUpsertOptions", diff --git a/src/index.ts b/src/index.ts index 94452593..755623e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ export type { EntityHandler, EntityListOptions, EntityPage, + EntityPipelineStage, EntityRecord, EntityTypeRegistry, EntityUpsertOptions, diff --git a/src/modules/entities.ts b/src/modules/entities.ts index e999cb77..883fd7e2 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -10,6 +10,7 @@ import { EntityHandler, EntityListOptions, EntityPage, + EntityPipelineStage, EntityUpsertOptions, EntityUpsertResult, ImportResult, @@ -200,8 +201,9 @@ function createEntityHandler( }, // Server-side group-by aggregation - async aggregate(spec: EntityAggregateSpec): Promise { - return axios.post(`${baseURL}/aggregate`, spec); + // aggregate(spec) posts the spec; aggregate(stages) posts { pipeline: stages }. + async aggregate(spec: EntityAggregateSpec | EntityPipelineStage[]): Promise { + return axios.post(`${baseURL}/aggregate`, Array.isArray(spec) ? { pipeline: spec } : spec); }, // Create or update by a natural key diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index e96265a7..0540c267 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -151,6 +151,12 @@ export interface EntityAggregateSpec { limit?: number; } +/** + * One stage of a MongoDB aggregation pipeline, such as `{ $match: {...} }` or `{ $group: {...} }`, + * accepted by {@linkcode EntityHandler.aggregate | aggregate()}. Field names are the entity's own. + */ +export type EntityPipelineStage = Record<`$${string}`, unknown>; + /** * Rows returned by {@linkcode EntityHandler.aggregate | aggregate()}. */ @@ -829,7 +835,17 @@ export interface EntityHandler { * and adding up in the browser. The server groups the records you can read and * returns one row per group, up to 1,000 rows. * - * @param spec - What to group by and what to compute. See {@linkcode EntityAggregateSpec | EntityAggregateSpec}. + * For anything the spec cannot say, pass a MongoDB aggregation pipeline instead: an array + * of stages written in the entity's own field names (`amount`, `address.city`, `id`, + * `created_date`). Allowed stages are `$match`, `$group`, `$sort`, `$limit`, `$skip`, + * `$project`, `$count`, `$unwind`, `$addFields`, `$set`, `$unset`, `$sortByCount`, + * `$bucket`, `$bucketAuto`, `$replaceRoot` and `$replaceWith`, up to 20 of them; joins, + * writes, `$facet`, `$$ROOT` and server-side JavaScript are rejected. The pipeline runs on + * the records you can read and is not available on the `User` entity or on entities with + * field-level read rules. Rows that are still records come back in the same shape as + * `list()`; grouped rows are your own output. + * + * @param spec - What to group by and what to compute. See {@linkcode EntityAggregateSpec | EntityAggregateSpec}. Or an array of {@linkcode EntityPipelineStage | pipeline stages}. * @returns Promise resolving to the rows and a `truncated` flag. * * @example @@ -869,8 +885,21 @@ export interface EntityHandler { * countDistinct: 'session_id' * }); * ``` + * + * @example + * ```typescript + * // A raw pipeline: revenue per agent from paid deals, biggest first + * const { rows } = await base44.entities.Deal.aggregate([ + * { $match: { status: 'paid' } }, + * { $group: { _id: '$agent_id', total: { $sum: '$income' }, deals: { $sum: 1 } } }, + * { $sort: { total: -1 } }, + * { $limit: 10 } + * ]); + * // rows: [{ _id: 'a1', total: 310050, deals: 12 }, ...] + * ``` */ aggregate(spec: EntityAggregateSpec): Promise; + aggregate(pipeline: EntityPipelineStage[]): Promise; /** * Creates or updates records by a key of your own. diff --git a/tests/types/entities-primitives.types.ts b/tests/types/entities-primitives.types.ts index 45f0500d..c79c3d91 100644 --- a/tests/types/entities-primitives.types.ts +++ b/tests/types/entities-primitives.types.ts @@ -1,5 +1,6 @@ import type { EntityAggregateSpec, + EntityPipelineStage, EntityDistinctOptions, EntityListOptions, EntityUpsertOptions, @@ -81,4 +82,12 @@ async function inferred() { page.items[0].amount; return [s, n, id]; } -export { inferred }; +const stages = [ + { $match: { store: "s1" } }, + { $group: { _id: "$agent_id", total: { $sum: "$amount" } } }, +] satisfies EntityPipelineStage[]; + +// @ts-expect-error a stage key must be a $operator +const badStage = [{ match: { store: "s1" } }] satisfies EntityPipelineStage[]; + +export { inferred, stages, badStage }; diff --git a/tests/unit/entities-primitives.test.ts b/tests/unit/entities-primitives.test.ts index c507d7d0..c5f031f3 100644 --- a/tests/unit/entities-primitives.test.ts +++ b/tests/unit/entities-primitives.test.ts @@ -158,6 +158,18 @@ describe("Entities scan-free primitives", () => { expect(scope.isDone()).toBe(true); }); + test("aggregate() with an array posts it as a pipeline", async () => { + const pipeline = [ + { $match: { status: "paid" } }, + { $group: { _id: "$agent_id", total: { $sum: "$amount" } } }, + ]; + scope.post(`${base}/aggregate`, { pipeline } as nock.RequestBodyMatcher).reply(200, { rows: [{ _id: "a1", total: 5 }], truncated: false }); + + const result = await base44.entities.Order.aggregate(pipeline); + expect(result.rows[0]._id).toBe("a1"); + expect(scope.isDone()).toBe(true); + }); + test("upsert() posts records and the key", async () => { const records = [ { external_id: "x1", amount: 5 }, From 81282705986bbd47cf46909f250a4aad71e6ad22 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Fri, 18 Sep 2026 09:23:06 +0300 Subject: [PATCH 09/11] entities: pipeline docs reflect field-level rules support and index use Co-Authored-By: Claude Fable 5.1 --- src/modules/entities.types.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 0540c267..55551814 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -840,10 +840,11 @@ export interface EntityHandler { * `created_date`). Allowed stages are `$match`, `$group`, `$sort`, `$limit`, `$skip`, * `$project`, `$count`, `$unwind`, `$addFields`, `$set`, `$unset`, `$sortByCount`, * `$bucket`, `$bucketAuto`, `$replaceRoot` and `$replaceWith`, up to 20 of them; joins, - * writes, `$facet`, `$$ROOT` and server-side JavaScript are rejected. The pipeline runs on - * the records you can read and is not available on the `User` entity or on entities with - * field-level read rules. Rows that are still records come back in the same shape as - * `list()`; grouped rows are your own output. + * writes, `$facet`, `$$ROOT`, `$getField` and server-side JavaScript are rejected. The + * pipeline runs on the records you can read, fields you may not read are removed before + * your first stage, and the `User` entity is not supported. A leading `$match` plus + * `$sort` is served by the entity's indexes like a `list()`. Rows that are still records + * come back in the same shape as `list()`; grouped rows are your own output. * * @param spec - What to group by and what to compute. See {@linkcode EntityAggregateSpec | EntityAggregateSpec}. Or an array of {@linkcode EntityPipelineStage | pipeline stages}. * @returns Promise resolving to the rows and a `truncated` flag. From 5f663f28a19cf6fd64cac7e2f79c2e1966f88092 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Fri, 18 Sep 2026 11:22:58 +0300 Subject: [PATCH 10/11] Revert "entities: pipeline docs reflect field-level rules support and index use" This reverts commit 81282705986bbd47cf46909f250a4aad71e6ad22. --- src/modules/entities.types.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 55551814..0540c267 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -840,11 +840,10 @@ export interface EntityHandler { * `created_date`). Allowed stages are `$match`, `$group`, `$sort`, `$limit`, `$skip`, * `$project`, `$count`, `$unwind`, `$addFields`, `$set`, `$unset`, `$sortByCount`, * `$bucket`, `$bucketAuto`, `$replaceRoot` and `$replaceWith`, up to 20 of them; joins, - * writes, `$facet`, `$$ROOT`, `$getField` and server-side JavaScript are rejected. The - * pipeline runs on the records you can read, fields you may not read are removed before - * your first stage, and the `User` entity is not supported. A leading `$match` plus - * `$sort` is served by the entity's indexes like a `list()`. Rows that are still records - * come back in the same shape as `list()`; grouped rows are your own output. + * writes, `$facet`, `$$ROOT` and server-side JavaScript are rejected. The pipeline runs on + * the records you can read and is not available on the `User` entity or on entities with + * field-level read rules. Rows that are still records come back in the same shape as + * `list()`; grouped rows are your own output. * * @param spec - What to group by and what to compute. See {@linkcode EntityAggregateSpec | EntityAggregateSpec}. Or an array of {@linkcode EntityPipelineStage | pipeline stages}. * @returns Promise resolving to the rows and a `truncated` flag. From 7839698e796864a1757c5c08620ad6654f320998 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Fri, 18 Sep 2026 11:22:58 +0300 Subject: [PATCH 11/11] Revert "entities: aggregate() accepts a raw MongoDB pipeline" This reverts commit cd90cc12c59d62d4b61d4decffea1b29562f0f66. --- .../types-to-expose.json | 1 - src/index.ts | 1 - src/modules/entities.ts | 6 ++-- src/modules/entities.types.ts | 31 +------------------ tests/types/entities-primitives.types.ts | 11 +------ tests/unit/entities-primitives.test.ts | 12 ------- 6 files changed, 4 insertions(+), 58 deletions(-) diff --git a/scripts/mintlify-post-processing/types-to-expose.json b/scripts/mintlify-post-processing/types-to-expose.json index e169c0d7..824bb16e 100644 --- a/scripts/mintlify-post-processing/types-to-expose.json +++ b/scripts/mintlify-post-processing/types-to-expose.json @@ -26,7 +26,6 @@ "EntityHandler", "EntityListOptions", "EntityPage", - "EntityPipelineStage", "EntityRecord", "EntityTypeRegistry", "EntityUpsertOptions", diff --git a/src/index.ts b/src/index.ts index 755623e8..94452593 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,7 +49,6 @@ export type { EntityHandler, EntityListOptions, EntityPage, - EntityPipelineStage, EntityRecord, EntityTypeRegistry, EntityUpsertOptions, diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 883fd7e2..e999cb77 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -10,7 +10,6 @@ import { EntityHandler, EntityListOptions, EntityPage, - EntityPipelineStage, EntityUpsertOptions, EntityUpsertResult, ImportResult, @@ -201,9 +200,8 @@ function createEntityHandler( }, // Server-side group-by aggregation - // aggregate(spec) posts the spec; aggregate(stages) posts { pipeline: stages }. - async aggregate(spec: EntityAggregateSpec | EntityPipelineStage[]): Promise { - return axios.post(`${baseURL}/aggregate`, Array.isArray(spec) ? { pipeline: spec } : spec); + async aggregate(spec: EntityAggregateSpec): Promise { + return axios.post(`${baseURL}/aggregate`, spec); }, // Create or update by a natural key diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 0540c267..e96265a7 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -151,12 +151,6 @@ export interface EntityAggregateSpec { limit?: number; } -/** - * One stage of a MongoDB aggregation pipeline, such as `{ $match: {...} }` or `{ $group: {...} }`, - * accepted by {@linkcode EntityHandler.aggregate | aggregate()}. Field names are the entity's own. - */ -export type EntityPipelineStage = Record<`$${string}`, unknown>; - /** * Rows returned by {@linkcode EntityHandler.aggregate | aggregate()}. */ @@ -835,17 +829,7 @@ export interface EntityHandler { * and adding up in the browser. The server groups the records you can read and * returns one row per group, up to 1,000 rows. * - * For anything the spec cannot say, pass a MongoDB aggregation pipeline instead: an array - * of stages written in the entity's own field names (`amount`, `address.city`, `id`, - * `created_date`). Allowed stages are `$match`, `$group`, `$sort`, `$limit`, `$skip`, - * `$project`, `$count`, `$unwind`, `$addFields`, `$set`, `$unset`, `$sortByCount`, - * `$bucket`, `$bucketAuto`, `$replaceRoot` and `$replaceWith`, up to 20 of them; joins, - * writes, `$facet`, `$$ROOT` and server-side JavaScript are rejected. The pipeline runs on - * the records you can read and is not available on the `User` entity or on entities with - * field-level read rules. Rows that are still records come back in the same shape as - * `list()`; grouped rows are your own output. - * - * @param spec - What to group by and what to compute. See {@linkcode EntityAggregateSpec | EntityAggregateSpec}. Or an array of {@linkcode EntityPipelineStage | pipeline stages}. + * @param spec - What to group by and what to compute. See {@linkcode EntityAggregateSpec | EntityAggregateSpec}. * @returns Promise resolving to the rows and a `truncated` flag. * * @example @@ -885,21 +869,8 @@ export interface EntityHandler { * countDistinct: 'session_id' * }); * ``` - * - * @example - * ```typescript - * // A raw pipeline: revenue per agent from paid deals, biggest first - * const { rows } = await base44.entities.Deal.aggregate([ - * { $match: { status: 'paid' } }, - * { $group: { _id: '$agent_id', total: { $sum: '$income' }, deals: { $sum: 1 } } }, - * { $sort: { total: -1 } }, - * { $limit: 10 } - * ]); - * // rows: [{ _id: 'a1', total: 310050, deals: 12 }, ...] - * ``` */ aggregate(spec: EntityAggregateSpec): Promise; - aggregate(pipeline: EntityPipelineStage[]): Promise; /** * Creates or updates records by a key of your own. diff --git a/tests/types/entities-primitives.types.ts b/tests/types/entities-primitives.types.ts index c79c3d91..45f0500d 100644 --- a/tests/types/entities-primitives.types.ts +++ b/tests/types/entities-primitives.types.ts @@ -1,6 +1,5 @@ import type { EntityAggregateSpec, - EntityPipelineStage, EntityDistinctOptions, EntityListOptions, EntityUpsertOptions, @@ -82,12 +81,4 @@ async function inferred() { page.items[0].amount; return [s, n, id]; } -const stages = [ - { $match: { store: "s1" } }, - { $group: { _id: "$agent_id", total: { $sum: "$amount" } } }, -] satisfies EntityPipelineStage[]; - -// @ts-expect-error a stage key must be a $operator -const badStage = [{ match: { store: "s1" } }] satisfies EntityPipelineStage[]; - -export { inferred, stages, badStage }; +export { inferred }; diff --git a/tests/unit/entities-primitives.test.ts b/tests/unit/entities-primitives.test.ts index c5f031f3..c507d7d0 100644 --- a/tests/unit/entities-primitives.test.ts +++ b/tests/unit/entities-primitives.test.ts @@ -158,18 +158,6 @@ describe("Entities scan-free primitives", () => { expect(scope.isDone()).toBe(true); }); - test("aggregate() with an array posts it as a pipeline", async () => { - const pipeline = [ - { $match: { status: "paid" } }, - { $group: { _id: "$agent_id", total: { $sum: "$amount" } } }, - ]; - scope.post(`${base}/aggregate`, { pipeline } as nock.RequestBodyMatcher).reply(200, { rows: [{ _id: "a1", total: 5 }], truncated: false }); - - const result = await base44.entities.Order.aggregate(pipeline); - expect(result.rows[0]._id).toBe("a1"); - expect(scope.isDone()).toBe(true); - }); - test("upsert() posts records and the key", async () => { const records = [ { external_id: "x1", amount: 5 },