diff --git a/.changeset/quick-bats-queue.md b/.changeset/quick-bats-queue.md new file mode 100644 index 00000000..7b5f077d --- /dev/null +++ b/.changeset/quick-bats-queue.md @@ -0,0 +1,6 @@ +--- +"@proofkit/webviewer": minor +"@proofkit/fmdapi": minor +--- + +Add opt-in WebViewerAdapter batching and adapter-level listAll/findAll pagination hooks. diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index 7412c5bd..e28a6d8d 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -87,3 +87,94 @@ const users = await UsersClient.findOne({ query: { id: "===1234" } }); ``` For examples of all methods, see the [@proofkit/fmdapi](/docs/fmdapi) documentation. + +## Batched Web Viewer Requests + +`WebViewerAdapter` can coalesce Data API calls that happen within a short window into one FileMaker script call. This reduces Web Viewer bridge round trips, but FileMaker still executes the enclosed Data API operations sequentially inside your script. + +```ts title="client.ts" +import { DataApi } from "@proofkit/fmdapi"; +import { WebViewerAdapter } from "@proofkit/webviewer/adapter"; + +export const client = DataApi({ + adapter: new WebViewerAdapter({ + scriptName: "ExecuteDataApi", + batch: { + windowMs: 8, + maxSize: 20, + }, + }), + layout: "API_Customers", +}); +``` + +Batching is disabled unless `batch` is set. Use `batch: true` for defaults. + +When batching is enabled, the FileMaker script receives this envelope: + +```json +{ + "proofkitBatch": 1, + "requests": [ + { + "id": "batch-0", + "layouts": "API_Customers", + "action": "read", + "version": "vLatest", + "limit": 10 + } + ] +} +``` + +Return one FileMaker Data API response per request: + +```json +{ + "responses": [ + { + "id": "batch-0", + "messages": [{ "code": "0" }], + "response": { + "data": [], + "dataInfo": { + "foundCount": 0, + "returnedCount": 0, + "totalRecordCount": 0 + } + } + } + ] +} +``` + +Each original JavaScript promise resolves or rejects from its matching response. If the batch-level script call fails or omits `responses`, all requests in that batch reject. + +## Script-Side Pagination + +For `client.listAll()` and `client.findAll()`, `WebViewerAdapter` sends one FileMaker script call instead of paging from JavaScript. The FileMaker script should loop through Execute Data API pages, combine the records, and return one normal Data API response. + +`listAll()` sends: + +```json +{ + "layouts": "API_Customers", + "action": "readAll", + "version": "vLatest", + "limit": 100 +} +``` + +`findAll()` sends: + +```json +{ + "layouts": "API_Customers", + "action": "findAll", + "version": "vLatest", + "limit": 100, + "query": [{ "status": "Active" }] +} +``` + +This is still sequential FileMaker work. It removes repeated Web Viewer bridge round trips for dependent pagination. diff --git a/packages/fmdapi/src/adapters/core.ts b/packages/fmdapi/src/adapters/core.ts index b9acc2f9..21d4b30c 100644 --- a/packages/fmdapi/src/adapters/core.ts +++ b/packages/fmdapi/src/adapters/core.ts @@ -53,8 +53,10 @@ export type LayoutMetadataOptions = BaseRequest; export interface Adapter { list: (opts: ListOptions) => Promise; + listAll?: (opts: ListOptions) => Promise; get: (opts: GetOptions) => Promise; find: (opts: FindOptions) => Promise; + findAll?: (opts: FindOptions) => Promise; create: (opts: CreateOptions) => Promise; update: (opts: UpdateOptions) => Promise; delete: (opts: DeleteOptions) => Promise; diff --git a/packages/fmdapi/src/client.ts b/packages/fmdapi/src/client.ts index d2882bd1..42893973 100644 --- a/packages/fmdapi/src/client.ts +++ b/packages/fmdapi/src/client.ts @@ -1,5 +1,5 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; -import type { Adapter, ExecuteScriptOptions } from "./adapters/core.js"; +import type { Adapter, ExecuteScriptOptions, FindOptions, ListOptions } from "./adapters/core.js"; import type { CreateParams, CreateResponse, @@ -88,8 +88,10 @@ function DataApi< create, delete: _adapterDelete, find, + findAll: adapterFindAll, get, list, + listAll: adapterListAll, update, layoutMetadata, containerUpload, @@ -126,18 +128,16 @@ function DataApi< type ExecuteScriptArgs = Omit; - /** - * List all records from a given layout, no find criteria applied. - */ - async function _list( - args?: ListParams & FetchOptions, - ): Promise>; - async function _list( - args?: ListParams & FetchOptions, - ): Promise> { + type ListArgs = ListParams & FetchOptions; + type FindMethodArgs = FindArgs & IgnoreEmptyResult & FetchOptions; + + function normalizeListRequest(args?: ListArgs): { + fetch?: RequestInit; + params: ListOptions["data"]; + timeout?: number; + } { const { fetch, timeout, ...params } = args ?? {}; - // rename and refactor limit, offset, and sort keys for this request if ("limit" in params && params.limit !== undefined) { Object.assign(params, { _limit: params.limit }).limit = undefined; } @@ -154,6 +154,62 @@ function DataApi< }).sort = undefined; } + return { + fetch, + params: params as ListOptions["data"], + timeout, + }; + } + + function normalizeFindRequest(args: FindMethodArgs): { + fetch?: RequestInit; + ignoreEmptyResult: boolean; + params: FindOptions["data"]; + timeout?: number; + } { + const { query: queryInput, ignoreEmptyResult = false, timeout, fetch, ...params } = args; + const query = Array.isArray(queryInput) ? queryInput : [queryInput]; + + if ("offset" in params && params.offset !== undefined && params.offset <= 1) { + params.offset = undefined; + } + if ("sort" in params && params.sort !== undefined && !Array.isArray(params.sort)) { + params.sort = [params.sort]; + } + if ("dateformats" in params && params.dateformats !== undefined) { + let dateFormatValue: number; + if (params.dateformats === "US") { + dateFormatValue = 0; + } else if (params.dateformats === "file_locale") { + dateFormatValue = 1; + } else if (params.dateformats === "ISO8601") { + dateFormatValue = 2; + } else { + dateFormatValue = 0; + } + // @ts-expect-error FM wants a string, so this is fine + params.dateformats = dateFormatValue.toString(); + } + + return { + fetch, + ignoreEmptyResult, + params: { ...params, query } as FindOptions["data"], + timeout, + }; + } + + /** + * List all records from a given layout, no find criteria applied. + */ + async function _list( + args?: ListParams & FetchOptions, + ): Promise>; + async function _list( + args?: ListParams & FetchOptions, + ): Promise> { + const { fetch, params, timeout } = normalizeListRequest(args); + const result = await list({ layout, data: params, @@ -189,6 +245,21 @@ function DataApi< const limit = args?.limit ?? 100; let offset = args?.offset ?? 1; + if (adapterListAll) { + const { fetch, params, timeout } = normalizeListRequest({ + ...args, + limit, + offset, + }); + const result = (await adapterListAll({ + data: params, + fetch, + layout, + timeout, + })) as GetResponse; + return (await runSchemaValidationAndTransform(schema, result)).data; + } + while (true) { const data = await _list({ ...args, @@ -274,30 +345,9 @@ function DataApi< async function _find( args: FindArgs & IgnoreEmptyResult & FetchOptions, ): Promise> { - const { query: queryInput, ignoreEmptyResult = false, timeout, fetch, ...params } = args; - const query = Array.isArray(queryInput) ? queryInput : [queryInput]; - - // rename and refactor limit, offset, and sort keys for this request - if ("offset" in params && params.offset !== undefined && params.offset <= 1) { - params.offset = undefined; - } - if ("dateformats" in params && params.dateformats !== undefined) { - // reassign dateformats to match FileMaker's expected values - let dateFormatValue: number; - if (params.dateformats === "US") { - dateFormatValue = 0; - } else if (params.dateformats === "file_locale") { - dateFormatValue = 1; - } else if (params.dateformats === "ISO8601") { - dateFormatValue = 2; - } else { - dateFormatValue = 0; - } - // @ts-expect-error FM wants a string, so this is fine - params.dateformats = dateFormatValue.toString(); - } + const { fetch, ignoreEmptyResult, params, timeout } = normalizeFindRequest(args); const result = (await find({ - data: { ...params, query }, + data: params, layout, fetch, timeout, @@ -379,6 +429,27 @@ function DataApi< const limit = args.limit ?? 100; let offset = args.offset ?? 1; + if (adapterFindAll) { + const { fetch, params, timeout } = normalizeFindRequest({ + ...args, + ignoreEmptyResult: true, + limit, + offset, + }); + const result = (await adapterFindAll({ + data: params, + fetch, + layout, + timeout, + }).catch((e: unknown) => { + if (e instanceof FileMakerError && e.code === "401") { + return { data: [], dataInfo: { foundCount: 0, returnedCount: 0 } }; + } + throw e; + })) as GetResponse; + return (await runSchemaValidationAndTransform(schema, result)).data; + } + while (true) { const data = await _find({ ...args, diff --git a/packages/fmdapi/tests/client-methods.test.ts b/packages/fmdapi/tests/client-methods.test.ts index 21804d07..769787c8 100644 --- a/packages/fmdapi/tests/client-methods.test.ts +++ b/packages/fmdapi/tests/client-methods.test.ts @@ -4,6 +4,7 @@ */ import { afterEach, describe, expect, it, test, vi } from "vitest"; import { z } from "zod/v4"; +import type { Adapter } from "../src/adapters/core"; import type { AllLayoutsMetadataResponse, Layout, ScriptOrFolder, ScriptsMetadataResponse } from "../src/client-types"; import { DataApi, FileMakerError, OttoAdapter } from "../src/index"; import { mockResponses } from "./fixtures/responses"; @@ -21,6 +22,21 @@ function createTestClient(layout = "layout") { }); } +function createAdapter(overrides: Partial): Adapter { + return { + containerUpload: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + executeScript: vi.fn(), + find: vi.fn(), + get: vi.fn(), + layoutMetadata: vi.fn(), + list: vi.fn(), + update: vi.fn(), + ...overrides, + } as Adapter; +} + describe("sort methods", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -71,6 +87,39 @@ describe("find methods", () => { expect(resp.data.length).toBe(2); }); + test("normalizes single find sort objects to arrays", async () => { + const adapterFind = vi.fn().mockResolvedValue({ + data: [], + dataInfo: { + database: "test", + foundCount: 0, + layout: "layout", + returnedCount: 0, + table: "layout", + totalRecordCount: 0, + }, + }); + const client = DataApi({ + adapter: createAdapter({ find: adapterFind }), + layout: "layout", + }); + + await client.find({ + query: { anything: "anything" }, + sort: { fieldName: "name" }, + }); + + expect(adapterFind).toHaveBeenCalledWith({ + data: { + query: [{ anything: "anything" }], + sort: [{ fieldName: "name" }], + }, + fetch: undefined, + layout: "layout", + timeout: undefined, + }); + }); + test("successful findFirst with multiple return", async () => { vi.stubGlobal("fetch", createMockFetch(mockResponses["find-basic"])); const client = createTestClient(); @@ -218,6 +267,41 @@ describe("other methods", () => { expect(data.length).toBe(3); }); + it("should use adapter listAll capability when available", async () => { + const adapterListAll = vi.fn().mockResolvedValue({ + data: [{ fieldData: { name: "Ada" }, modId: "1", portalData: {}, recordId: "1" }], + dataInfo: { + database: "test", + foundCount: 1, + layout: "layout", + returnedCount: 1, + table: "layout", + totalRecordCount: 1, + }, + }); + const adapter = createAdapter({ listAll: adapterListAll }); + const client = DataApi({ adapter, layout: "layout" }); + + const data = await client.listAll({ + limit: 25, + offset: 2, + sort: { fieldName: "name" }, + }); + + expect(data).toHaveLength(1); + expect(adapter.list).not.toHaveBeenCalled(); + expect(adapterListAll).toHaveBeenCalledWith({ + data: { + _limit: 25, + _offset: 2, + _sort: [{ fieldName: "name" }], + }, + fetch: undefined, + layout: "layout", + timeout: undefined, + }); + }); + it("should paginate using findAll method", async () => { vi.stubGlobal("fetch", createMockFetchSequence([mockResponses["find-basic"], mockResponses["find-no-results"]])); const client = createTestClient(); @@ -230,6 +314,41 @@ describe("other methods", () => { expect(data.length).toBe(2); }); + it("should use adapter findAll capability when available", async () => { + const adapterFindAll = vi.fn().mockResolvedValue({ + data: [{ fieldData: { name: "Ada" }, modId: "1", portalData: {}, recordId: "1" }], + dataInfo: { + database: "test", + foundCount: 1, + layout: "layout", + returnedCount: 1, + table: "layout", + totalRecordCount: 1, + }, + }); + const adapter = createAdapter({ findAll: adapterFindAll }); + const client = DataApi({ adapter, layout: "layout" }); + + const data = await client.findAll({ + limit: 50, + query: { name: "==Ada" }, + sort: { fieldName: "name" }, + }); + + expect(data).toHaveLength(1); + expect(adapter.find).not.toHaveBeenCalled(); + expect(adapterFindAll).toHaveBeenCalledWith({ + data: { + limit: 50, + query: [{ name: "==Ada" }], + sort: [{ fieldName: "name" }], + }, + fetch: undefined, + layout: "layout", + timeout: undefined, + }); + }); + it("should return from execute script", async () => { vi.stubGlobal("fetch", createMockFetch(mockResponses["execute-script"])); const client = createTestClient(); diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index 7c5bf12c..081f4ccf 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -17,44 +17,118 @@ export type ExecuteScriptOptions = BaseRequest & { data: { script: string; scriptParam?: string }; }; +type DataApiAction = "read" | "readAll" | "metaData" | "create" | "update" | "delete" | "duplicate" | "findAll"; + +export interface WebViewerAdapterBatchOptions { + enabled?: boolean; + windowMs?: number; + maxSize?: number; +} + export interface WebViewerAdapterOptions { scriptName: string; + batch?: boolean | WebViewerAdapterBatchOptions; +} + +interface ResolvedBatchOptions { + windowMs: number; + maxSize: number; +} + +interface DataApiScriptRequest extends Record { + action: DataApiAction; + layouts: string; + version: "vLatest"; +} + +interface QueuedBatchRequest { + id: string; + payload: DataApiScriptRequest; + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; +} + +interface BatchScriptResponse { + responses?: Array; +} + +const DEFAULT_BATCH_WINDOW_MS = 8; +const DEFAULT_BATCH_MAX_SIZE = 20; + +function normalizeBatchMaxSize(maxSize: number | undefined): number { + if (maxSize === undefined || !Number.isFinite(maxSize)) { + return DEFAULT_BATCH_MAX_SIZE; + } + return Math.max(1, Math.trunc(maxSize)); +} + +function resolveBatchOptions(batch: WebViewerAdapterOptions["batch"]): ResolvedBatchOptions | undefined { + if (!batch) { + return; + } + if (batch === true) { + return { + maxSize: DEFAULT_BATCH_MAX_SIZE, + windowMs: DEFAULT_BATCH_WINDOW_MS, + }; + } + if (batch.enabled === false) { + return; + } + return { + maxSize: normalizeBatchMaxSize(batch.maxSize), + windowMs: Math.max(0, batch.windowMs ?? DEFAULT_BATCH_WINDOW_MS), + }; +} + +function normalizeBody(body: object): Record { + const { _limit, _offset, _sort, ...normalizedBody } = body as Record; + if (_offset !== undefined) { + normalizedBody.offset = _offset; + } + if (_limit !== undefined) { + normalizedBody.limit = _limit; + } + if (_sort !== undefined) { + normalizedBody.sort = _sort; + } + return normalizedBody; } export class WebViewerAdapter implements Adapter { protected scriptName: string; + private readonly batchOptions?: ResolvedBatchOptions; + private batchFlushInProgress = false; + private batchRequestId = 0; + private batchTimer?: ReturnType; + private readonly batchQueue: QueuedBatchRequest[] = []; constructor(options: WebViewerAdapterOptions & { refreshToken?: boolean }) { this.scriptName = options.scriptName; + this.batchOptions = resolveBatchOptions(options.batch); } - protected request = async (params: { - layout: string; - body: object; - action?: "read" | "metaData" | "create" | "update" | "delete" | "duplicate"; - }): Promise => { - const { action = "read", layout, body } = params; + protected request = (params: { layout: string; body: object; action?: DataApiAction }): Promise => { + const payload = this.createScriptRequest(params); - if ("_offset" in body) { - Object.assign(body, { offset: body._offset }); - body._offset = undefined; - } - if ("_limit" in body) { - Object.assign(body, { limit: body._limit }); - body._limit = undefined; - } - if ("_sort" in body) { - Object.assign(body, { sort: body._sort }); - body._sort = undefined; + if (this.batchOptions) { + return this.enqueueBatchRequest(payload); } - const resp = await fmFetch(this.scriptName, { - ...body, - layouts: layout, + return this.executeSingleRequest(payload); + }; + + private createScriptRequest(params: { layout: string; body: object; action?: DataApiAction }): DataApiScriptRequest { + const { action = "read", layout, body } = params; + return { + ...normalizeBody(body), action, + layouts: layout, version: "vLatest", - }); + }; + } + private handleDataApiResponse(resp: clientTypes.RawFMResponse): unknown { if (resp.messages?.[0].code !== "0") { throw new FileMakerError( resp?.messages?.[0].code ?? "500", @@ -63,7 +137,118 @@ export class WebViewerAdapter implements Adapter { } return resp.response; - }; + } + + private async executeSingleRequest(payload: DataApiScriptRequest): Promise { + const resp = await fmFetch(this.scriptName, payload); + return this.handleDataApiResponse(resp); + } + + private enqueueBatchRequest(payload: DataApiScriptRequest): Promise { + return new Promise((resolve, reject) => { + this.batchQueue.push({ + id: `batch-${this.batchRequestId}`, + payload, + resolve, + reject, + }); + this.batchRequestId++; + + if (this.batchQueue.length >= normalizeBatchMaxSize(this.batchOptions?.maxSize)) { + this.flushBatchQueue(); + return; + } + + this.scheduleBatchFlush(this.batchOptions?.windowMs ?? DEFAULT_BATCH_WINDOW_MS); + }); + } + + private scheduleBatchFlush(windowMs: number) { + if (this.batchTimer) { + return; + } + this.batchTimer = setTimeout(() => { + this.flushBatchQueue(); + }, windowMs); + } + + private flushBatchQueue() { + if (this.batchTimer) { + clearTimeout(this.batchTimer); + this.batchTimer = undefined; + } + + if (this.batchFlushInProgress) { + return; + } + + this.batchFlushInProgress = true; + this.drainBatchQueue() + .finally(() => { + this.batchFlushInProgress = false; + if (this.batchQueue.length > 0) { + this.flushBatchQueue(); + } + }) + .catch((error: unknown) => { + this.rejectQueuedBatchRequests(error); + }); + } + + private async drainBatchQueue() { + const batchOptions = this.batchOptions; + if (!batchOptions) { + return; + } + + while (this.batchQueue.length > 0) { + const requests = this.batchQueue.splice(0, normalizeBatchMaxSize(batchOptions.maxSize)); + await this.executeBatchRequests(requests); + } + } + + private async executeBatchRequests(requests: QueuedBatchRequest[]) { + try { + const resp = await fmFetch(this.scriptName, { + proofkitBatch: 1, + requests: requests.map((request) => ({ + id: request.id, + ...request.payload, + })), + }); + + const responses = resp.responses; + if (!Array.isArray(responses)) { + throw new Error("FileMaker batch response must include a responses array"); + } + + const responsesById = new Map(responses.map((response) => [response.id, response])); + + for (const request of requests) { + const response = responsesById.get(request.id); + if (!response) { + request.reject(new Error(`FileMaker batch response missing result for ${request.id}`)); + continue; + } + try { + request.resolve(this.handleDataApiResponse(response)); + } catch (error) { + request.reject(error); + } + } + } catch (error) { + for (const request of requests) { + request.reject(error); + } + } + } + + private rejectQueuedBatchRequests(error: unknown) { + const queuedRequests = this.batchQueue.splice(0); + for (const request of queuedRequests) { + request.reject(error); + } + } list = async (opts: ListOptions): Promise => { const { data, layout } = opts; @@ -74,6 +259,16 @@ export class WebViewerAdapter implements Adapter { return resp as clientTypes.GetResponse; }; + listAll = async (opts: ListOptions): Promise => { + const { data, layout } = opts; + const resp = await this.request({ + action: "readAll", + body: data, + layout, + }); + return resp as clientTypes.GetResponse; + }; + get = async (opts: GetOptions): Promise => { const { data, layout } = opts; const resp = await this.request({ @@ -92,6 +287,16 @@ export class WebViewerAdapter implements Adapter { return resp as clientTypes.GetResponse; }; + findAll = async (opts: FindOptions): Promise => { + const { data, layout } = opts; + const resp = await this.request({ + action: "findAll", + body: data, + layout, + }); + return resp as clientTypes.GetResponse; + }; + create = async (opts: CreateOptions): Promise => { const { data, layout } = opts; const resp = await this.request({ diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts new file mode 100644 index 00000000..0d5bb008 --- /dev/null +++ b/packages/webviewer/tests/adapter.test.ts @@ -0,0 +1,239 @@ +import type { FindOptions, ListOptions, UpdateOptions } from "@proofkit/fmdapi/adapters/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebViewerAdapter } from "../src/adapter.ts"; +import { fmFetch } from "../src/main.js"; + +vi.mock("../src/main.js", () => ({ + fmFetch: vi.fn(), +})); + +describe("WebViewerAdapter", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(fmFetch).mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("uses a single FileMaker script call by default", async () => { + vi.mocked(fmFetch).mockResolvedValue({ + messages: [{ code: "0" }], + response: { data: [] }, + }); + + const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" }); + await expect( + adapter.list({ + data: { _limit: 10 } as unknown as ListOptions["data"], + layout: "Customers", + }), + ).resolves.toEqual({ data: [] }); + + expect(fmFetch).toHaveBeenCalledTimes(1); + expect(fmFetch).toHaveBeenCalledWith("execute_data_api", { + action: "read", + layouts: "Customers", + limit: 10, + version: "vLatest", + }); + }); + + it("sends listAll and findAll as single script-side pagination requests", async () => { + vi.mocked(fmFetch).mockResolvedValue({ + messages: [{ code: "0" }], + response: { data: [] }, + }); + + const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" }); + await adapter.listAll({ + data: { _limit: 100 } as unknown as ListOptions["data"], + layout: "Customers", + }); + await adapter.findAll({ + data: { + limit: 100, + query: [{ status: "Active" }], + } as unknown as FindOptions["data"], + layout: "Customers", + }); + + expect(fmFetch).toHaveBeenCalledTimes(2); + expect(fmFetch).toHaveBeenNthCalledWith(1, "execute_data_api", { + action: "readAll", + layouts: "Customers", + limit: 100, + version: "vLatest", + }); + expect(fmFetch).toHaveBeenNthCalledWith(2, "execute_data_api", { + action: "findAll", + layouts: "Customers", + limit: 100, + query: [{ status: "Active" }], + version: "vLatest", + }); + }); + + it("coalesces adapter requests into one batch envelope when enabled", async () => { + vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { + const batch = data as { + requests: Array<{ action: string; id: string; layouts: string }>; + }; + return Promise.resolve({ + responses: batch.requests.map((request) => ({ + id: request.id, + messages: [{ code: "0" }], + response: { + action: request.action, + layout: request.layouts, + }, + })), + }); + }); + + const adapter = new WebViewerAdapter({ + batch: { windowMs: 8 }, + scriptName: "execute_data_api", + }); + const listResult = adapter.list({ + data: { _limit: 10 } as unknown as ListOptions["data"], + layout: "Customers", + }); + const updateResult = adapter.update({ + data: { + fieldData: { name: "Ada" }, + recordId: 12, + } as unknown as UpdateOptions["data"], + layout: "Customers", + }); + + expect(fmFetch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(8); + + await expect(Promise.all([listResult, updateResult])).resolves.toEqual([ + { action: "read", layout: "Customers" }, + { action: "update", layout: "Customers" }, + ]); + expect(fmFetch).toHaveBeenCalledTimes(1); + expect(fmFetch).toHaveBeenCalledWith("execute_data_api", { + proofkitBatch: 1, + requests: [ + { + action: "read", + id: "batch-0", + layouts: "Customers", + limit: 10, + version: "vLatest", + }, + { + action: "update", + fieldData: { name: "Ada" }, + id: "batch-1", + layouts: "Customers", + recordId: 12, + version: "vLatest", + }, + ], + }); + }); + + it("rejects only the failed item in a batch response", async () => { + vi.mocked(fmFetch).mockResolvedValue({ + responses: [ + { + id: "batch-0", + messages: [{ code: "0" }], + response: { data: [] }, + }, + { + id: "batch-1", + messages: [{ code: "401" }], + response: {}, + }, + ], + }); + + const adapter = new WebViewerAdapter({ + batch: true, + scriptName: "execute_data_api", + }); + const first = adapter.list({ data: {} as ListOptions["data"], layout: "Customers" }); + const second = adapter.list({ data: {} as ListOptions["data"], layout: "Invoices" }); + const secondExpectation = expect(second).rejects.toMatchObject({ code: "401" }); + + await vi.advanceTimersByTimeAsync(8); + + await expect(first).resolves.toEqual({ data: [] }); + await secondExpectation; + }); + + it("flushes immediately at maxSize and keeps later requests ordered", async () => { + vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { + const batch = data as { + requests: Array<{ id: string; layouts: string }>; + }; + return Promise.resolve({ + responses: batch.requests.map((request) => ({ + id: request.id, + messages: [{ code: "0" }], + response: { layout: request.layouts }, + })), + }); + }); + + const adapter = new WebViewerAdapter({ + batch: { maxSize: 2, windowMs: 100 }, + scriptName: "execute_data_api", + }); + const first = adapter.list({ data: {} as ListOptions["data"], layout: "A" }); + const second = adapter.list({ data: {} as ListOptions["data"], layout: "B" }); + const third = adapter.list({ data: {} as ListOptions["data"], layout: "C" }); + + await Promise.resolve(); + expect(fmFetch).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + + await expect(Promise.all([first, second, third])).resolves.toEqual([ + { layout: "A" }, + { layout: "B" }, + { layout: "C" }, + ]); + expect(fmFetch).toHaveBeenCalledTimes(2); + }); + + it.each([ + Number.NaN, + Number.POSITIVE_INFINITY, + ])("falls back to default maxSize for invalid input %s", async (maxSize) => { + vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { + const batch = data as { + requests: Array<{ id: string; layouts: string }>; + }; + return Promise.resolve({ + responses: batch.requests.map((request) => ({ + id: request.id, + messages: [{ code: "0" }], + response: { layout: request.layouts }, + })), + }); + }); + + const adapter = new WebViewerAdapter({ + batch: { maxSize, windowMs: 100 }, + scriptName: "execute_data_api", + }); + const requests = Array.from({ length: 20 }, (_, index) => + adapter.list({ + data: {} as ListOptions["data"], + layout: `Layout${index}`, + }), + ); + + await Promise.resolve(); + + expect(fmFetch).toHaveBeenCalledTimes(1); + await expect(Promise.all(requests)).resolves.toHaveLength(20); + }); +});