diff --git a/scripts/mintlify-post-processing/types-to-expose.json b/scripts/mintlify-post-processing/types-to-expose.json index 5d7ef016..824bb16e 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", + "EntityDistinctOptions", "EntityHandler", + "EntityListOptions", + "EntityPage", "EntityRecord", "EntityTypeRegistry", + "EntityUpsertOptions", + "EntityUpsertResult", "FunctionName", "FunctionNameRegistry", "FunctionsModule", diff --git a/src/index.ts b/src/index.ts index 8842b8fe..94452593 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,12 +39,20 @@ export type { DeleteManyResult, DeleteResult, EntitiesModule, + EntityAggregateResult, + EntityAggregateSpec, + EntityDateBucketUnit, + EntityDistinctOptions, 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..e999cb77 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -3,8 +3,15 @@ import { DeleteManyResult, DeleteResult, EntitiesModule, + EntityAggregateResult, + EntityAggregateSpec, + EntityDistinctOptions, EntityFilterQuery, EntityHandler, + EntityListOptions, + EntityPage, + EntityUpsertOptions, + EntityUpsertResult, ImportResult, RealtimeCallback, RealtimeEvent, @@ -75,6 +82,14 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null } } +const DEFAULT_PAGE_LIMIT = 100; + +type PageOptions = EntityListOptions | EntityDistinctOptions; + +function isPageOptions(value: unknown): value is PageOptions { + return typeof value === "object" && value !== null; +} + /** * Creates a handler for a specific entity. * @@ -93,43 +108,52 @@ function createEntityHandler( ): EntityHandler { const baseURL = `/apps/${appId}/entities/${entityName}`; + const fieldsParam = (fields?: readonly (keyof T)[]) => + Array.isArray(fields) ? fields.join(",") : (fields as string | undefined); + + // 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 }); + }; + + // 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); + params.limit = options.limit || DEFAULT_PAGE_LIMIT; + if (options.cursor) params.cursor = options.cursor; + 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 }); + }; + return { - // List entities with optional pagination and sorting - async list( - sort?: SortField, - limit?: number, - skip?: number, - fields?: K[] - ): Promise[]> { - const params: Record = {}; - if (sort) params.sort = sort; - if (limit) params.limit = limit; - if (skip) params.skip = skip; - if (fields) - params.fields = Array.isArray(fields) ? fields.join(",") : fields; - - return axios.get(baseURL, { params }); + // 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 isPageOptions(sort) ? readPage(sort) : readArray(sort, limit, skip, fields); }, - // Filter entities based on query - async filter( - query: EntityFilterQuery, - sort?: SortField, - limit?: number, - skip?: number, - fields?: K[] - ): Promise[]> { - const params: Record = { - q: JSON.stringify(query), - }; - - if (sort) params.sort = sort; - if (limit) params.limit = limit; - if (skip) params.skip = skip; - if (fields) - params.fields = Array.isArray(fields) ? fields.join(",") : 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 isPageOptions(sort) ? readPage(sort, query) : readArray(sort, limit, skip, fields, query); }, // Get entity by ID @@ -167,6 +191,27 @@ 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; + }, + + // 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..e96265a7 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -56,6 +56,135 @@ 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'`. */ + 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. + * + * 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 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; +} + +/** + * 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, 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; + /** Whether records remain after this page. */ + has_more: boolean; +} + +/** + * Time unit for {@linkcode EntityAggregateSpec.dateBucket | dateBucket}. + */ +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. */ + query?: EntityFilterQuery; + /** Field, or up to four fields, to group by. Omit to get one total row. */ + 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`. */ + 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_`. */ + 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_`. */ + 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'`. */ + 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 +373,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`; with a `distinct` option the items are the field's values. * * @example * ```typescript @@ -277,6 +410,24 @@ 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 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); + * } + * ``` + * + * @example + * ```typescript + * // Distinct values of one field, instead of records + * const { items: categories } = await base44.entities.Product.list({ distinct: 'category' }); + * ``` */ list( sort?: SortField, @@ -284,6 +435,12 @@ export interface EntityHandler { skip?: number, fields?: K[], ): Promise[]>; + list( + options: EntityDistinctOptions, + ): Promise>; + list( + options: EntityListOptions, + ): Promise>>; /** * Filters records based on a query. @@ -291,7 +448,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 +461,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`; with a `distinct` option the items are the field's values. * * @example * ```typescript @@ -380,6 +541,30 @@ 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 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); + * } + * ``` + * + * @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( query: EntityFilterQuery, @@ -388,6 +573,14 @@ export interface EntityHandler { skip?: number, fields?: K[], ): Promise[]>; + filter( + query: EntityFilterQuery, + options: EntityDistinctOptions, + ): Promise>; + filter( + query: EntityFilterQuery, + options: EntityListOptions, + ): Promise>>; /** * Gets a single record by ID. @@ -605,6 +798,117 @@ 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; + + /** + * 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({ + * query: { sale_date: { $gte: '2026-09-01' } }, + * groupBy: '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({ + * dateBucket: { field: 'created_date', unit: 'day' } + * }); + * ``` + * + * @example + * ```typescript + * // Find duplicated external ids + * const { rows } = await base44.entities.Contact.aggregate({ + * groupBy: 'external_id', + * having: { count: { $gt: 1 } } + * }); + * ``` + * + * @example + * ```typescript + * // Unique visitors per page + * const { rows } = await base44.entities.PageView.aggregate({ + * groupBy: 'path', + * countDistinct: '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/src/utils/axios-client.ts b/src/utils/axios-client.ts index febb2be5..5ce54330 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -240,18 +240,18 @@ export function createAxiosClient({ return response.data; }, (error) => { + const data = error.response?.data; const message = - error.response?.data?.message || - error.response?.data?.detail || - error.message; + data?.error?.message || data?.message || data?.detail || error.message; const base44Error = new Base44Error( message, error.response?.status, - error.response?.data?.code ?? + data?.error?.code ?? + data?.code ?? error.response?.headers?.get?.("x-base44-connector-error") ?? error.response?.headers?.["x-base44-connector-error"], - error.response?.data, + data, error ); diff --git a/tests/types/entities-primitives.types.ts b/tests/types/entities-primitives.types.ts new file mode 100644 index 00000000..45f0500d --- /dev/null +++ b/tests/types/entities-primitives.types.ts @@ -0,0 +1,84 @@ +import type { + EntityAggregateSpec, + EntityDistinctOptions, + 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 = { + query: { sale_date: { $gte: "2026-09-01" } }, + groupBy: "agent_id", + sum: ["amount"], + avg: "amount", + sort: "-sum_amount", + limit: 50, +} satisfies EntityAggregateSpec; + +const perDay = { + dateBucket: { field: "created_date", unit: "day" }, + countDistinct: "agent_id", +} satisfies EntityAggregateSpec; + +const duplicates = { + groupBy: ["agent_id", "store"], + having: { count: { $gt: 1 } }, +} satisfies EntityAggregateSpec; + +// @ts-expect-error unknown field names are rejected +const badGroup = { groupBy: "region" } satisfies EntityAggregateSpec; + +// @ts-expect-error unknown bucket unit +const badUnit = { dateBucket: { 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 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, 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 new file mode 100644 index 00000000..9ef55108 --- /dev/null +++ b/tests/unit/entities-primitives.test.ts @@ -0,0 +1,197 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; +import type { + EntityAggregateResult, + 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 v2/list", async () => { + const reply: EntityPage> = { + items: [{ id: "1", amount: 10 }], + next_cursor: "tok-2", + has_more: true, + }; + scope + .get(`${base}/v2/list`) + .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, 100 rows by default", async () => { + scope + .get(`${base}/v2/list`) + .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 }); + expect(page).toEqual({ items: [], next_cursor: null, has_more: false }); + 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) + .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("aggregate() posts the spec as-is to /aggregate", async () => { + const spec = { + query: { status: "paid" }, + groupBy: "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); + }); + + test("a coded API error surfaces its code and message on the thrown error", async () => { + scope + .get(`${base}/v2/list`) + .query({ cursor: "stale", sort: "amount", limit: "100" }) + .reply(400, { error: { code: "invalid_cursor", message: "Cursor was issued for a different sort", details: {} } }); + + const err = await base44.entities.Order.list({ cursor: "stale", sort: "amount" }).catch((e) => e); + expect(err.status).toBe(400); + expect(err.code).toBe("invalid_cursor"); + expect(err.message).toBe("Cursor was issued for a different sort"); + expect(scope.isDone()).toBe(true); + }); +});