From 8283691ec75efdfe5ffaa08d3d36f8fab6e0b169 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:16:08 -0500 Subject: [PATCH 01/11] Add webviewer batching --- .changeset/quick-bats-queue.md | 6 + apps/docs/content/docs/webviewer/fmdapi.mdx | 91 +++++++ packages/fmdapi/src/adapters/core.ts | 2 + packages/fmdapi/src/client.ts | 139 ++++++++--- packages/fmdapi/tests/client-methods.test.ts | 119 +++++++++ packages/webviewer/src/adapter.ts | 249 +++++++++++++++++-- packages/webviewer/tests/adapter.test.ts | 239 ++++++++++++++++++ 7 files changed, 789 insertions(+), 56 deletions(-) create mode 100644 .changeset/quick-bats-queue.md create mode 100644 packages/webviewer/tests/adapter.test.ts 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); + }); +}); From e0b287c9af07a827f68420df050a09b5ff416f5a Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:39:15 -0500 Subject: [PATCH 02/11] Handle legacy batching scripts --- .changeset/tiny-lamps-tap.md | 5 ++ apps/docs/content/docs/webviewer/batching.mdx | 85 +++++++++++++++++++ apps/docs/content/docs/webviewer/fmdapi.mdx | 42 +-------- apps/docs/content/docs/webviewer/meta.json | 1 + packages/webviewer/src/adapter.ts | 43 +++++++++- packages/webviewer/tests/adapter.test.ts | 72 +++++++++++++++- 6 files changed, 203 insertions(+), 45 deletions(-) create mode 100644 .changeset/tiny-lamps-tap.md create mode 100644 apps/docs/content/docs/webviewer/batching.mdx diff --git a/.changeset/tiny-lamps-tap.md b/.changeset/tiny-lamps-tap.md new file mode 100644 index 00000000..231f9da7 --- /dev/null +++ b/.changeset/tiny-lamps-tap.md @@ -0,0 +1,5 @@ +--- +"@proofkit/webviewer": patch +--- + +Use `batch: true` in WebViewerAdapter batch script payloads, link fallback warnings to batching docs, and fall back when older FileMaker scripts reject them. diff --git a/apps/docs/content/docs/webviewer/batching.mdx b/apps/docs/content/docs/webviewer/batching.mdx new file mode 100644 index 00000000..e0a2a7cb --- /dev/null +++ b/apps/docs/content/docs/webviewer/batching.mdx @@ -0,0 +1,85 @@ +--- +title: Batching Data API Requests +description: Coalesce FileMaker Data API requests from a Web Viewer and update older ProofKit add-on scripts. +--- + +`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. + +Batching is disabled unless `batch` is set. Use `batch: true` for defaults. + +```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", +}); +``` + +## FileMaker script contract + +When batching is enabled, the FileMaker script receives this envelope: + +```json +{ + "batch": true, + "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. + +## Older add-on scripts + +Older ProofKit add-on scripts do not understand the `batch` key. They can return: + +```json +{ + "response": {}, + "messages": [{ "code": "1708", "message": "Unknown key (batch)" }] +} +``` + +When `WebViewerAdapter` sees error `1708` with `Unknown key (batch)`, it logs a warning, replays the queued requests individually, and disables batching for that adapter instance. + +To enable batching, install the latest version of the ProofKit add-on in your FileMaker file. See [Updating ProofKit](/docs/ai/updating-proofkit) for the add-on update flow. + +## When batching retries + +The fallback is kept in memory on the `WebViewerAdapter` instance. ProofKit tries batching again when a new adapter instance is created, usually after reloading the Web Viewer app or recreating the client. diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index e28a6d8d..db2bcc54 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -90,7 +90,7 @@ For examples of all methods, see the [@proofkit/fmdapi](/docs/fmdapi) documentat ## 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. +`WebViewerAdapter` can coalesce Data API calls that happen within a short window into one FileMaker script call. See [Batching Data API Requests](/docs/webviewer/batching) for the full script contract and older add-on fallback behavior. ```ts title="client.ts" import { DataApi } from "@proofkit/fmdapi"; @@ -110,46 +110,6 @@ export const client = DataApi({ 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. diff --git a/apps/docs/content/docs/webviewer/meta.json b/apps/docs/content/docs/webviewer/meta.json index d20300e5..2e4a2bf2 100644 --- a/apps/docs/content/docs/webviewer/meta.json +++ b/apps/docs/content/docs/webviewer/meta.json @@ -14,6 +14,7 @@ "filemaker-scripts-as-backend", "commands", "initial-props", + "batching", "platform-notes", "deployment-methods", "---Reference---", diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index 081f4ccf..94f789fe 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -48,12 +48,14 @@ interface QueuedBatchRequest { reject: (reason?: unknown) => void; } -interface BatchScriptResponse { +interface BatchScriptResponse extends Partial { responses?: Array; } const DEFAULT_BATCH_WINDOW_MS = 8; const DEFAULT_BATCH_MAX_SIZE = 20; +const BATCHING_DOCS_URL = "https://proofkit.dev/docs/webviewer/batching"; +const LEGACY_BATCH_WARNING = `[ProofKit] ProofKit called the FileMaker script to execute Data API, but it did not support batching. Install the latest ProofKit add-on in your FileMaker file to get the updated script. Falling back to unbatched requests for this adapter. See ${BATCHING_DOCS_URL}`; function normalizeBatchMaxSize(maxSize: number | undefined): number { if (maxSize === undefined || !Number.isFinite(maxSize)) { @@ -95,9 +97,21 @@ function normalizeBody(body: object): Record { return normalizedBody; } +function isLegacyBatchUnsupportedResponse(resp: BatchScriptResponse): boolean { + return ( + resp.messages?.some((message) => { + const normalizedMessage = String((message as { message?: unknown }).message ?? "").toLowerCase(); + return ( + message.code === "1708" && normalizedMessage.includes("unknown key") && normalizedMessage.includes("batch") + ); + }) ?? false + ); +} + export class WebViewerAdapter implements Adapter { protected scriptName: string; private readonly batchOptions?: ResolvedBatchOptions; + private batchDisabledByLegacyScript = false; private batchFlushInProgress = false; private batchRequestId = 0; private batchTimer?: ReturnType; @@ -111,7 +125,7 @@ export class WebViewerAdapter implements Adapter { protected request = (params: { layout: string; body: object; action?: DataApiAction }): Promise => { const payload = this.createScriptRequest(params); - if (this.batchOptions) { + if (this.batchOptions && !this.batchDisabledByLegacyScript) { return this.enqueueBatchRequest(payload); } @@ -203,6 +217,10 @@ export class WebViewerAdapter implements Adapter { while (this.batchQueue.length > 0) { const requests = this.batchQueue.splice(0, normalizeBatchMaxSize(batchOptions.maxSize)); + if (this.batchDisabledByLegacyScript) { + await this.executeUnbatchedRequests(requests); + continue; + } await this.executeBatchRequests(requests); } } @@ -210,13 +228,20 @@ export class WebViewerAdapter implements Adapter { private async executeBatchRequests(requests: QueuedBatchRequest[]) { try { const resp = await fmFetch(this.scriptName, { - proofkitBatch: 1, + batch: true, requests: requests.map((request) => ({ id: request.id, ...request.payload, })), }); + if (isLegacyBatchUnsupportedResponse(resp)) { + console.warn(LEGACY_BATCH_WARNING); + this.batchDisabledByLegacyScript = true; + await this.executeUnbatchedRequests(requests); + return; + } + const responses = resp.responses; if (!Array.isArray(responses)) { throw new Error("FileMaker batch response must include a responses array"); @@ -243,6 +268,18 @@ export class WebViewerAdapter implements Adapter { } } + private async executeUnbatchedRequests(requests: QueuedBatchRequest[]) { + await Promise.all( + requests.map(async (request) => { + try { + request.resolve(await this.executeSingleRequest(request.payload)); + } catch (error) { + request.reject(error); + } + }), + ); + } + private rejectQueuedBatchRequests(error: unknown) { const queuedRequests = this.batchQueue.splice(0); for (const request of queuedRequests) { diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts index 0d5bb008..682b96cb 100644 --- a/packages/webviewer/tests/adapter.test.ts +++ b/packages/webviewer/tests/adapter.test.ts @@ -117,7 +117,7 @@ describe("WebViewerAdapter", () => { ]); expect(fmFetch).toHaveBeenCalledTimes(1); expect(fmFetch).toHaveBeenCalledWith("execute_data_api", { - proofkitBatch: 1, + batch: true, requests: [ { action: "read", @@ -168,6 +168,76 @@ describe("WebViewerAdapter", () => { await secondExpectation; }); + it("falls back to unbatched requests when the FileMaker script rejects batch payloads", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { + const payload = data as { batch?: boolean; layouts?: string }; + if (payload.batch) { + return Promise.resolve({ + messages: [{ code: "1708", message: "Unknown key (batch)" }], + response: {}, + }); + } + return Promise.resolve({ + messages: [{ code: "0" }], + response: { layout: payload.layouts }, + }); + }); + + 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" }); + + await vi.advanceTimersByTimeAsync(8); + + await expect(Promise.all([first, second])).resolves.toEqual([{ layout: "Customers" }, { layout: "Invoices" }]); + expect(warnSpy).toHaveBeenCalledWith( + "[ProofKit] ProofKit called the FileMaker script to execute Data API, but it did not support batching. Install the latest ProofKit add-on in your FileMaker file to get the updated script. Falling back to unbatched requests for this adapter. See https://proofkit.dev/docs/webviewer/batching", + ); + expect(fmFetch).toHaveBeenCalledTimes(3); + expect(fmFetch).toHaveBeenNthCalledWith( + 1, + "execute_data_api", + expect.objectContaining({ + batch: true, + }), + ); + expect(fmFetch).toHaveBeenNthCalledWith( + 2, + "execute_data_api", + expect.objectContaining({ + layouts: "Customers", + }), + ); + expect(fmFetch).toHaveBeenNthCalledWith( + 3, + "execute_data_api", + expect.objectContaining({ + layouts: "Invoices", + }), + ); + + await expect(adapter.list({ data: {} as ListOptions["data"], layout: "Orders" })).resolves.toEqual({ + layout: "Orders", + }); + expect(fmFetch).toHaveBeenLastCalledWith( + "execute_data_api", + expect.objectContaining({ + layouts: "Orders", + }), + ); + expect(fmFetch).not.toHaveBeenLastCalledWith( + "execute_data_api", + expect.objectContaining({ + batch: true, + }), + ); + warnSpy.mockRestore(); + }); + it("flushes immediately at maxSize and keeps later requests ordered", async () => { vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { const batch = data as { From b8c40900a00f2f82ef2c9bf5a9983962327f84c4 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:54:16 -0500 Subject: [PATCH 03/11] Add per-request batching --- .changeset/quick-bats-queue.md | 1 + .changeset/tiny-lamps-tap.md | 5 - apps/docs/content/docs/webviewer/batching.mdx | 19 ++++ apps/docs/content/docs/webviewer/fmdapi.mdx | 2 +- packages/fmdapi/src/adapters/core.ts | 1 + packages/fmdapi/src/client.ts | 40 +++++--- packages/fmdapi/tests/client-methods.test.ts | 31 ++++++ packages/webviewer/src/adapter.ts | 68 +++++++++---- packages/webviewer/tests/adapter.test.ts | 99 +++++++++++++++++++ 9 files changed, 228 insertions(+), 38 deletions(-) delete mode 100644 .changeset/tiny-lamps-tap.md diff --git a/.changeset/quick-bats-queue.md b/.changeset/quick-bats-queue.md index 7b5f077d..39576235 100644 --- a/.changeset/quick-bats-queue.md +++ b/.changeset/quick-bats-queue.md @@ -4,3 +4,4 @@ --- Add opt-in WebViewerAdapter batching and adapter-level listAll/findAll pagination hooks. +Batching can be controlled per request with `batch: true` or `batch: false`, and older FileMaker add-on scripts fall back to unbatched requests with a warning that links to batching docs. diff --git a/.changeset/tiny-lamps-tap.md b/.changeset/tiny-lamps-tap.md deleted file mode 100644 index 231f9da7..00000000 --- a/.changeset/tiny-lamps-tap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@proofkit/webviewer": patch ---- - -Use `batch: true` in WebViewerAdapter batch script payloads, link fallback warnings to batching docs, and fall back when older FileMaker scripts reject them. diff --git a/apps/docs/content/docs/webviewer/batching.mdx b/apps/docs/content/docs/webviewer/batching.mdx index e0a2a7cb..07cca980 100644 --- a/apps/docs/content/docs/webviewer/batching.mdx +++ b/apps/docs/content/docs/webviewer/batching.mdx @@ -23,6 +23,25 @@ export const client = DataApi({ }); ``` +## Per-request control + +Use `batch: false` on a request to bypass adapter-level batching for that call: + +```ts +await client.list({ batch: false, limit: 10 }); +``` + +Use `batch: true` on a request to batch that call even when the adapter was not configured with batching: + +```ts +await client.find({ + batch: true, + query: { status: "Active" }, +}); +``` + +Per-request batching uses the adapter's configured batch settings when present. If the adapter was not configured with `batch`, `batch: true` uses the default batch window and size. + ## FileMaker script contract When batching is enabled, the FileMaker script receives this envelope: diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index db2bcc54..40b8f94a 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -90,7 +90,7 @@ For examples of all methods, see the [@proofkit/fmdapi](/docs/fmdapi) documentat ## Batched Web Viewer Requests -`WebViewerAdapter` can coalesce Data API calls that happen within a short window into one FileMaker script call. See [Batching Data API Requests](/docs/webviewer/batching) for the full script contract and older add-on fallback behavior. +`WebViewerAdapter` can coalesce Data API calls that happen within a short window into one FileMaker script call. See [Batching Data API Requests](/docs/webviewer/batching) for per-request controls, the full script contract, and older add-on fallback behavior. ```ts title="client.ts" import { DataApi } from "@proofkit/fmdapi"; diff --git a/packages/fmdapi/src/adapters/core.ts b/packages/fmdapi/src/adapters/core.ts index 21d4b30c..047de32a 100644 --- a/packages/fmdapi/src/adapters/core.ts +++ b/packages/fmdapi/src/adapters/core.ts @@ -15,6 +15,7 @@ import type { } from "../client-types.js"; export interface BaseRequest { + batch?: boolean; layout: string; fetch?: RequestInit; timeout?: number; diff --git a/packages/fmdapi/src/client.ts b/packages/fmdapi/src/client.ts index 42893973..529fda2a 100644 --- a/packages/fmdapi/src/client.ts +++ b/packages/fmdapi/src/client.ts @@ -41,6 +41,7 @@ export interface ClientObjectProps { } interface FetchOptions { + batch?: boolean; fetch?: RequestInit; } @@ -132,11 +133,12 @@ function DataApi< type FindMethodArgs = FindArgs & IgnoreEmptyResult & FetchOptions; function normalizeListRequest(args?: ListArgs): { + batch?: boolean; fetch?: RequestInit; params: ListOptions["data"]; timeout?: number; } { - const { fetch, timeout, ...params } = args ?? {}; + const { batch, fetch, timeout, ...params } = args ?? {}; if ("limit" in params && params.limit !== undefined) { Object.assign(params, { _limit: params.limit }).limit = undefined; @@ -155,6 +157,7 @@ function DataApi< } return { + batch, fetch, params: params as ListOptions["data"], timeout, @@ -162,12 +165,13 @@ function DataApi< } function normalizeFindRequest(args: FindMethodArgs): { + batch?: boolean; fetch?: RequestInit; ignoreEmptyResult: boolean; params: FindOptions["data"]; timeout?: number; } { - const { query: queryInput, ignoreEmptyResult = false, timeout, fetch, ...params } = args; + const { batch, 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) { @@ -192,6 +196,7 @@ function DataApi< } return { + batch, fetch, ignoreEmptyResult, params: { ...params, query } as FindOptions["data"], @@ -208,9 +213,10 @@ function DataApi< async function _list( args?: ListParams & FetchOptions, ): Promise> { - const { fetch, params, timeout } = normalizeListRequest(args); + const { batch, fetch, params, timeout } = normalizeListRequest(args); const result = await list({ + batch, layout, data: params, fetch, @@ -246,12 +252,13 @@ function DataApi< let offset = args?.offset ?? 1; if (adapterListAll) { - const { fetch, params, timeout } = normalizeListRequest({ + const { batch, fetch, params, timeout } = normalizeListRequest({ ...args, limit, offset, }); const result = (await adapterListAll({ + batch, data: params, fetch, layout, @@ -281,8 +288,9 @@ function DataApi< T extends InferredFieldData = InferredFieldData, U extends InferredPortalData = InferredPortalData, >(args: CreateArgs & FetchOptions): Promise { - const { fetch, timeout, ...params } = args ?? {}; + const { batch, fetch, timeout, ...params } = args ?? {}; return await create({ + batch, layout, data: params, fetch, @@ -297,9 +305,10 @@ function DataApi< args: GetArgs & FetchOptions, ): Promise> { args.recordId = asNumber(args.recordId); - const { recordId, fetch, timeout, ...params } = args; + const { batch, recordId, fetch, timeout, ...params } = args; const result = await get({ + batch, layout, data: { ...params, recordId }, fetch, @@ -315,8 +324,9 @@ function DataApi< args: UpdateArgs & FetchOptions, ): Promise { args.recordId = asNumber(args.recordId); - const { recordId, fetch, timeout, ...params } = args; + const { batch, recordId, fetch, timeout, ...params } = args; return await update({ + batch, layout, data: { ...params, recordId }, fetch, @@ -329,9 +339,10 @@ function DataApi< */ function deleteRecord(args: DeleteArgs & FetchOptions): Promise { args.recordId = asNumber(args.recordId); - const { recordId, fetch, timeout, ...params } = args; + const { batch, recordId, fetch, timeout, ...params } = args; return _adapterDelete({ + batch, layout, data: { ...params, recordId }, fetch, @@ -345,8 +356,9 @@ function DataApi< async function _find( args: FindArgs & IgnoreEmptyResult & FetchOptions, ): Promise> { - const { fetch, ignoreEmptyResult, params, timeout } = normalizeFindRequest(args); + const { batch, fetch, ignoreEmptyResult, params, timeout } = normalizeFindRequest(args); const result = (await find({ + batch, data: params, layout, fetch, @@ -430,13 +442,14 @@ function DataApi< let offset = args.offset ?? 1; if (adapterFindAll) { - const { fetch, params, timeout } = normalizeFindRequest({ + const { batch, fetch, params, timeout } = normalizeFindRequest({ ...args, ignoreEmptyResult: true, limit, offset, }); const result = (await adapterFindAll({ + batch, data: params, fetch, layout, @@ -471,6 +484,7 @@ function DataApi< const params: FetchOptions & { timeout?: number } = restArgs; return await layoutMetadata({ + batch: params.batch, layout, fetch: params.fetch, // Now should correctly resolve to undefined if not present timeout: params.timeout, // Now should correctly resolve to undefined if not present @@ -478,7 +492,7 @@ function DataApi< } async function _containerUpload(args: ContainerUploadArgs & FetchOptions) { - const { ...params } = args; + const { fetch, timeout, ...params } = args; return await containerUpload({ layout, data: { @@ -486,8 +500,8 @@ function DataApi< containerFieldName: params.containerFieldName as string, repetition: params.containerFieldRepetition, }, - fetch: params.fetch, - timeout: params.timeout, + fetch, + timeout, }); } diff --git a/packages/fmdapi/tests/client-methods.test.ts b/packages/fmdapi/tests/client-methods.test.ts index 769787c8..313f75ed 100644 --- a/packages/fmdapi/tests/client-methods.test.ts +++ b/packages/fmdapi/tests/client-methods.test.ts @@ -198,6 +198,37 @@ describe("other methods", () => { expect(result.data).toBeDefined(); }); + it("passes batch request option to adapters without adding it to Data API params", async () => { + const adapterList = vi.fn().mockResolvedValue({ + data: [], + dataInfo: { + database: "test", + foundCount: 0, + layout: "layout", + returnedCount: 0, + table: "layout", + totalRecordCount: 0, + }, + }); + const client = DataApi({ + adapter: createAdapter({ list: adapterList }), + layout: "layout", + }); + + await client.list({ batch: false, limit: 10 }); + + expect(adapterList).toHaveBeenCalledWith({ + batch: false, + data: { + _limit: 10, + limit: undefined, + }, + fetch: undefined, + layout: "layout", + timeout: undefined, + }); + }); + it("should rename offset param", async () => { vi.stubGlobal("fetch", createMockFetch(mockResponses["list-basic"])); const client = createTestClient(); diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index 94f789fe..272f8970 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -42,6 +42,7 @@ interface DataApiScriptRequest extends Record { } interface QueuedBatchRequest { + batchOptions: ResolvedBatchOptions; id: string; payload: DataApiScriptRequest; resolve: (value: unknown) => void; @@ -83,6 +84,13 @@ function resolveBatchOptions(batch: WebViewerAdapterOptions["batch"]): ResolvedB }; } +function defaultBatchOptions(): ResolvedBatchOptions { + return { + maxSize: DEFAULT_BATCH_MAX_SIZE, + windowMs: DEFAULT_BATCH_WINDOW_MS, + }; +} + function normalizeBody(body: object): Record { const { _limit, _offset, _sort, ...normalizedBody } = body as Record; if (_offset !== undefined) { @@ -122,16 +130,32 @@ export class WebViewerAdapter implements Adapter { this.batchOptions = resolveBatchOptions(options.batch); } - protected request = (params: { layout: string; body: object; action?: DataApiAction }): Promise => { + protected request = (params: { + batch?: boolean; + body: object; + action?: DataApiAction; + layout: string; + }): Promise => { const payload = this.createScriptRequest(params); + const batchOptions = this.getBatchOptionsForRequest(params.batch); - if (this.batchOptions && !this.batchDisabledByLegacyScript) { - return this.enqueueBatchRequest(payload); + if (batchOptions) { + return this.enqueueBatchRequest(payload, batchOptions); } return this.executeSingleRequest(payload); }; + private getBatchOptionsForRequest(batch: boolean | undefined): ResolvedBatchOptions | undefined { + if (this.batchDisabledByLegacyScript || batch === false) { + return; + } + if (batch === true) { + return this.batchOptions ?? defaultBatchOptions(); + } + return this.batchOptions; + } + private createScriptRequest(params: { layout: string; body: object; action?: DataApiAction }): DataApiScriptRequest { const { action = "read", layout, body } = params; return { @@ -158,9 +182,10 @@ export class WebViewerAdapter implements Adapter { return this.handleDataApiResponse(resp); } - private enqueueBatchRequest(payload: DataApiScriptRequest): Promise { + private enqueueBatchRequest(payload: DataApiScriptRequest, batchOptions: ResolvedBatchOptions): Promise { return new Promise((resolve, reject) => { this.batchQueue.push({ + batchOptions, id: `batch-${this.batchRequestId}`, payload, resolve, @@ -168,12 +193,12 @@ export class WebViewerAdapter implements Adapter { }); this.batchRequestId++; - if (this.batchQueue.length >= normalizeBatchMaxSize(this.batchOptions?.maxSize)) { + if (this.batchQueue.length >= normalizeBatchMaxSize(batchOptions.maxSize)) { this.flushBatchQueue(); return; } - this.scheduleBatchFlush(this.batchOptions?.windowMs ?? DEFAULT_BATCH_WINDOW_MS); + this.scheduleBatchFlush(batchOptions.windowMs); }); } @@ -210,12 +235,8 @@ export class WebViewerAdapter implements Adapter { } private async drainBatchQueue() { - const batchOptions = this.batchOptions; - if (!batchOptions) { - return; - } - while (this.batchQueue.length > 0) { + const batchOptions = this.batchQueue[0]?.batchOptions ?? defaultBatchOptions(); const requests = this.batchQueue.splice(0, normalizeBatchMaxSize(batchOptions.maxSize)); if (this.batchDisabledByLegacyScript) { await this.executeUnbatchedRequests(requests); @@ -288,8 +309,9 @@ export class WebViewerAdapter implements Adapter { } list = async (opts: ListOptions): Promise => { - const { data, layout } = opts; + const { batch, data, layout } = opts; const resp = await this.request({ + batch, body: data, layout, }); @@ -297,9 +319,10 @@ export class WebViewerAdapter implements Adapter { }; listAll = async (opts: ListOptions): Promise => { - const { data, layout } = opts; + const { batch, data, layout } = opts; const resp = await this.request({ action: "readAll", + batch, body: data, layout, }); @@ -307,8 +330,9 @@ export class WebViewerAdapter implements Adapter { }; get = async (opts: GetOptions): Promise => { - const { data, layout } = opts; + const { batch, data, layout } = opts; const resp = await this.request({ + batch, body: data, layout, }); @@ -316,8 +340,9 @@ export class WebViewerAdapter implements Adapter { }; find = async (opts: FindOptions): Promise => { - const { data, layout } = opts; + const { batch, data, layout } = opts; const resp = await this.request({ + batch, body: data, layout, }); @@ -325,9 +350,10 @@ export class WebViewerAdapter implements Adapter { }; findAll = async (opts: FindOptions): Promise => { - const { data, layout } = opts; + const { batch, data, layout } = opts; const resp = await this.request({ action: "findAll", + batch, body: data, layout, }); @@ -335,9 +361,10 @@ export class WebViewerAdapter implements Adapter { }; create = async (opts: CreateOptions): Promise => { - const { data, layout } = opts; + const { batch, data, layout } = opts; const resp = await this.request({ action: "create", + batch, body: data, layout, }); @@ -345,9 +372,10 @@ export class WebViewerAdapter implements Adapter { }; update = async (opts: UpdateOptions): Promise => { - const { data, layout } = opts; + const { batch, data, layout } = opts; const resp = await this.request({ action: "update", + batch, layout, body: data, }); @@ -355,9 +383,10 @@ export class WebViewerAdapter implements Adapter { }; delete = async (opts: DeleteOptions): Promise => { - const { data, layout } = opts; + const { batch, data, layout } = opts; const resp = await this.request({ action: "delete", + batch, body: data, layout, }); @@ -367,6 +396,7 @@ export class WebViewerAdapter implements Adapter { layoutMetadata = async (opts: LayoutMetadataOptions): Promise => { return (await this.request({ action: "metaData", + batch: opts.batch, layout: opts.layout, body: {}, })) as clientTypes.LayoutMetadataResponse; diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts index 682b96cb..ae9cf7cd 100644 --- a/packages/webviewer/tests/adapter.test.ts +++ b/packages/webviewer/tests/adapter.test.ts @@ -168,6 +168,105 @@ describe("WebViewerAdapter", () => { await secondExpectation; }); + it("allows a request to opt out of adapter-level batching", async () => { + vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { + const payload = data as { batch?: boolean; requests?: Array<{ id: string; layouts: string }>; layouts?: string }; + if (payload.batch) { + return Promise.resolve({ + responses: payload.requests?.map((request) => ({ + id: request.id, + messages: [{ code: "0" }], + response: { layout: request.layouts }, + })), + }); + } + return Promise.resolve({ + messages: [{ code: "0" }], + response: { layout: payload.layouts }, + }); + }); + + const adapter = new WebViewerAdapter({ + batch: { windowMs: 8 }, + scriptName: "execute_data_api", + }); + const unbatched = adapter.list({ + batch: false, + data: {} as ListOptions["data"], + layout: "Customers", + }); + const batched = adapter.list({ + data: {} as ListOptions["data"], + layout: "Invoices", + }); + + await expect(unbatched).resolves.toEqual({ layout: "Customers" }); + expect(fmFetch).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(8); + + await expect(batched).resolves.toEqual({ layout: "Invoices" }); + expect(fmFetch).toHaveBeenCalledTimes(2); + expect(fmFetch).toHaveBeenNthCalledWith( + 1, + "execute_data_api", + expect.objectContaining({ + layouts: "Customers", + }), + ); + expect(fmFetch).toHaveBeenNthCalledWith( + 2, + "execute_data_api", + expect.objectContaining({ + batch: true, + }), + ); + }); + + it("allows requests to opt in to batching with default batch settings", 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({ + scriptName: "execute_data_api", + }); + const first = adapter.list({ batch: true, data: {} as ListOptions["data"], layout: "A" }); + const second = adapter.list({ batch: true, data: {} as ListOptions["data"], layout: "B" }); + + expect(fmFetch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(8); + + await expect(Promise.all([first, second])).resolves.toEqual([{ layout: "A" }, { layout: "B" }]); + expect(fmFetch).toHaveBeenCalledTimes(1); + expect(fmFetch).toHaveBeenCalledWith("execute_data_api", { + batch: true, + requests: [ + { + action: "read", + id: "batch-0", + layouts: "A", + version: "vLatest", + }, + { + action: "read", + id: "batch-1", + layouts: "B", + version: "vLatest", + }, + ], + }); + }); + it("falls back to unbatched requests when the FileMaker script rejects batch payloads", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { From 77c57dea05bf149193d14b985f32feca04d76f10 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:05:46 -0500 Subject: [PATCH 04/11] Bound all-record webviewer pagination --- .changeset/quick-bats-queue.md | 4 +- apps/docs/content/docs/webviewer/batching.mdx | 6 + apps/docs/content/docs/webviewer/fmdapi.mdx | 36 ++-- packages/webviewer/src/adapter.ts | 122 +++++++++++-- packages/webviewer/tests/adapter.test.ts | 165 ++++++++++++++++-- 5 files changed, 277 insertions(+), 56 deletions(-) diff --git a/.changeset/quick-bats-queue.md b/.changeset/quick-bats-queue.md index 39576235..e61edda1 100644 --- a/.changeset/quick-bats-queue.md +++ b/.changeset/quick-bats-queue.md @@ -3,5 +3,5 @@ "@proofkit/fmdapi": minor --- -Add opt-in WebViewerAdapter batching and adapter-level listAll/findAll pagination hooks. -Batching can be controlled per request with `batch: true` or `batch: false`, and older FileMaker add-on scripts fall back to unbatched requests with a warning that links to batching docs. +Add opt-in WebViewerAdapter batching with per-request `batch: true`/`batch: false` controls. +`listAll` and `findAll` now page through bounded `read` requests in the adapter, batching follow-up pages when enabled, and older FileMaker add-on scripts fall back to unbatched requests with a warning that links to batching docs. diff --git a/apps/docs/content/docs/webviewer/batching.mdx b/apps/docs/content/docs/webviewer/batching.mdx index 07cca980..e03b9733 100644 --- a/apps/docs/content/docs/webviewer/batching.mdx +++ b/apps/docs/content/docs/webviewer/batching.mdx @@ -42,6 +42,12 @@ await client.find({ Per-request batching uses the adapter's configured batch settings when present. If the adapter was not configured with `batch`, `batch: true` uses the default batch window and size. +## All-record helpers + +`listAll()` and `findAll()` never send `readAll` or `findAll` actions to FileMaker. The adapter sends one bounded `read` request first, uses `dataInfo.foundCount` to calculate the remaining pages, and then fetches those pages with bounded `read` requests. + +When batching is enabled, the follow-up page requests can be batched in chunks up to `maxSize`. This reduces Web Viewer bridge round trips without asking the FileMaker script to load an unknown number of records into script variables. + ## FileMaker script contract When batching is enabled, the FileMaker script receives this envelope: diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index 40b8f94a..7374414a 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -110,31 +110,27 @@ export const client = DataApi({ Batching is disabled unless `batch` is set. Use `batch: true` for defaults. -## Script-Side Pagination +## Adapter 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. +For `client.listAll()` and `client.findAll()`, `WebViewerAdapter` does not send `readAll` or `findAll` actions to FileMaker. It sends a bounded first `read` request to get `dataInfo.foundCount`, then requests the remaining pages with the same limit. -`listAll()` sends: +If batching is enabled, those follow-up page requests can be coalesced into batch envelopes. Each request inside the batch is still a bounded `read` operation: ```json { - "layouts": "API_Customers", - "action": "readAll", - "version": "vLatest", - "limit": 100 + "batch": true, + "requests": [ + { + "id": "batch-0", + "layouts": "API_Customers", + "action": "read", + "version": "vLatest", + "limit": 100, + "offset": 101, + "query": [{ "status": "Active" }] + } + ] } ``` -`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. +This keeps all-record helpers from loading an unbounded result set into FileMaker variables before anything is returned to the Web Viewer. diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index 272f8970..6686a241 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -17,7 +17,7 @@ export type ExecuteScriptOptions = BaseRequest & { data: { script: string; scriptParam?: string }; }; -type DataApiAction = "read" | "readAll" | "metaData" | "create" | "update" | "delete" | "duplicate" | "findAll"; +type DataApiAction = "read" | "metaData" | "create" | "update" | "delete" | "duplicate"; export interface WebViewerAdapterBatchOptions { enabled?: boolean; @@ -105,6 +105,37 @@ function normalizeBody(body: object): Record { return normalizedBody; } +function getPositiveInteger(value: unknown, fallback: number): number { + const numericValue = typeof value === "string" ? Number(value) : value; + if (typeof numericValue !== "number" || !Number.isFinite(numericValue) || numericValue < 1) { + return fallback; + } + return Math.trunc(numericValue); +} + +function getRangeValue(body: object, keys: string[], fallback: number): number { + const bodyRecord = body as Record; + for (const key of keys) { + if (bodyRecord[key] !== undefined) { + return getPositiveInteger(bodyRecord[key], fallback); + } + } + return fallback; +} + +function createPagedBody(body: object, limit: number, offset: number): Record { + const pagedBody = Object.fromEntries( + Object.entries(body as Record).filter(([key]) => key !== "limit" && key !== "offset"), + ); + pagedBody._limit = limit; + if (offset > 1) { + pagedBody._offset = offset; + } else { + pagedBody._offset = undefined; + } + return pagedBody; +} + function isLegacyBatchUnsupportedResponse(resp: BatchScriptResponse): boolean { return ( resp.messages?.some((message) => { @@ -308,6 +339,73 @@ export class WebViewerAdapter implements Adapter { } } + private async paginateAll(opts: ListOptions | FindOptions): Promise { + const { batch, data, layout } = opts; + const limit = getRangeValue(data, ["_limit", "limit"], 100); + const initialOffset = getRangeValue(data, ["_offset", "offset"], 1); + const first = (await this.executeSingleRequest( + this.createScriptRequest({ + body: createPagedBody(data, limit, initialOffset), + layout, + }), + )) as clientTypes.GetResponse; + const records = [...(first.data ?? [])]; + const foundCount = getPositiveInteger(first.dataInfo?.foundCount, records.length); + const targetCount = Math.max(0, foundCount - (initialOffset - 1)); + let nextOffset = initialOffset + limit; + + if (records.length >= targetCount) { + return { + ...first, + data: records, + dataInfo: { + ...first.dataInfo, + returnedCount: records.length, + }, + }; + } + + const pageBatchSize = this.getBatchOptionsForRequest(batch)?.maxSize ?? 1; + + while (records.length < targetCount) { + const offsets: number[] = []; + while (offsets.length < pageBatchSize && records.length + offsets.length * limit < targetCount) { + offsets.push(nextOffset); + nextOffset += limit; + } + + if (offsets.length === 0) { + break; + } + + const pages = (await Promise.all( + offsets.map((offset) => + this.request({ + batch, + body: createPagedBody(data, limit, offset), + layout, + }), + ), + )) as clientTypes.GetResponse[]; + const receivedCount = pages.reduce((sum, page) => sum + (page.data?.length ?? 0), 0); + for (const page of pages) { + records.push(...(page.data ?? [])); + } + if (receivedCount === 0) { + break; + } + } + + return { + ...first, + data: records, + dataInfo: { + ...first.dataInfo, + returnedCount: records.length, + }, + }; + } + list = async (opts: ListOptions): Promise => { const { batch, data, layout } = opts; const resp = await this.request({ @@ -318,15 +416,8 @@ export class WebViewerAdapter implements Adapter { return resp as clientTypes.GetResponse; }; - listAll = async (opts: ListOptions): Promise => { - const { batch, data, layout } = opts; - const resp = await this.request({ - action: "readAll", - batch, - body: data, - layout, - }); - return resp as clientTypes.GetResponse; + listAll = (opts: ListOptions): Promise => { + return this.paginateAll(opts); }; get = async (opts: GetOptions): Promise => { @@ -349,15 +440,8 @@ export class WebViewerAdapter implements Adapter { return resp as clientTypes.GetResponse; }; - findAll = async (opts: FindOptions): Promise => { - const { batch, data, layout } = opts; - const resp = await this.request({ - action: "findAll", - batch, - body: data, - layout, - }); - return resp as clientTypes.GetResponse; + findAll = (opts: FindOptions): Promise => { + return this.paginateAll(opts); }; create = async (opts: CreateOptions): Promise => { diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts index ae9cf7cd..ad974bb9 100644 --- a/packages/webviewer/tests/adapter.test.ts +++ b/packages/webviewer/tests/adapter.test.ts @@ -40,18 +40,135 @@ describe("WebViewerAdapter", () => { }); }); - it("sends listAll and findAll as single script-side pagination requests", async () => { - vi.mocked(fmFetch).mockResolvedValue({ - messages: [{ code: "0" }], - response: { data: [] }, + it("paginates listAll with bounded reads and batches remaining pages", async () => { + vi.useRealTimers(); + const totalRecordCount = 1000; + const getPageResponse = (payload: { limit?: number; offset?: number }) => { + const limit = payload.limit ?? 100; + const offset = payload.offset ?? 1; + const pageEnd = Math.min(offset + limit - 1, totalRecordCount); + const data = + offset > totalRecordCount + ? [] + : Array.from({ length: pageEnd - offset + 1 }, (_, index) => ({ + fieldData: { id: offset + index }, + modId: "0", + portalData: {}, + recordId: String(offset + index), + })); + return { + data, + dataInfo: { + database: "Test", + foundCount: totalRecordCount, + layout: "Customers", + returnedCount: data.length, + table: "Customers", + totalRecordCount, + }, + }; + }; + + vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { + const payload = data as { + batch?: boolean; + limit?: number; + offset?: number; + requests?: Array<{ id: string; limit?: number; offset?: number }>; + }; + if (payload.batch) { + return Promise.resolve({ + responses: payload.requests?.map((request) => ({ + id: request.id, + messages: [{ code: "0" }], + response: getPageResponse(request), + })), + }); + } + return Promise.resolve({ + messages: [{ code: "0" }], + response: getPageResponse(payload), + }); }); - const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" }); - await adapter.listAll({ + const adapter = new WebViewerAdapter({ + batch: { maxSize: 5, windowMs: 0 }, + scriptName: "execute_data_api", + }); + const result = await adapter.listAll({ data: { _limit: 100 } as unknown as ListOptions["data"], layout: "Customers", }); - await adapter.findAll({ + + expect(result.data).toHaveLength(1000); + expect(result.dataInfo.returnedCount).toBe(1000); + expect(fmFetch).toHaveBeenCalledTimes(3); + expect(fmFetch).toHaveBeenNthCalledWith(1, "execute_data_api", { + action: "read", + layouts: "Customers", + limit: 100, + version: "vLatest", + }); + expect(fmFetch).toHaveBeenNthCalledWith(2, "execute_data_api", { + batch: true, + requests: [ + expect.objectContaining({ action: "read", offset: 101 }), + expect.objectContaining({ action: "read", offset: 201 }), + expect.objectContaining({ action: "read", offset: 301 }), + expect.objectContaining({ action: "read", offset: 401 }), + expect.objectContaining({ action: "read", offset: 501 }), + ], + }); + expect(fmFetch).toHaveBeenNthCalledWith(3, "execute_data_api", { + batch: true, + requests: [ + expect.objectContaining({ action: "read", offset: 601 }), + expect.objectContaining({ action: "read", offset: 701 }), + expect.objectContaining({ action: "read", offset: 801 }), + expect.objectContaining({ action: "read", offset: 901 }), + ], + }); + for (const call of vi.mocked(fmFetch).mock.calls) { + expect(JSON.stringify(call[1])).not.toContain("readAll"); + expect(JSON.stringify(call[1])).not.toContain("findAll"); + } + }); + + it("paginates findAll with bounded find requests", async () => { + const totalRecordCount = 250; + vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { + const payload = data as { limit?: number; offset?: number }; + const limit = payload.limit ?? 100; + const offset = payload.offset ?? 1; + const pageEnd = Math.min(offset + limit - 1, totalRecordCount); + const pageData = + offset > totalRecordCount + ? [] + : Array.from({ length: pageEnd - offset + 1 }, (_, index) => ({ + fieldData: { id: offset + index }, + modId: "0", + portalData: {}, + recordId: String(offset + index), + })); + return Promise.resolve({ + messages: [{ code: "0" }], + response: { + data: pageData, + dataInfo: { + database: "Test", + foundCount: totalRecordCount, + layout: "Customers", + returnedCount: pageData.length, + table: "Customers", + totalRecordCount, + }, + }, + }); + }); + + const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" }); + const result = await adapter.findAll({ + batch: false, data: { limit: 100, query: [{ status: "Active" }], @@ -59,20 +176,38 @@ describe("WebViewerAdapter", () => { layout: "Customers", }); - expect(fmFetch).toHaveBeenCalledTimes(2); + expect(result.data).toHaveLength(250); + expect(fmFetch).toHaveBeenCalledTimes(3); expect(fmFetch).toHaveBeenNthCalledWith(1, "execute_data_api", { - action: "readAll", - layouts: "Customers", - limit: 100, - version: "vLatest", - }); - expect(fmFetch).toHaveBeenNthCalledWith(2, "execute_data_api", { - action: "findAll", + action: "read", layouts: "Customers", limit: 100, query: [{ status: "Active" }], version: "vLatest", }); + expect(fmFetch).toHaveBeenNthCalledWith( + 2, + "execute_data_api", + expect.objectContaining({ + action: "read", + limit: 100, + offset: 101, + query: [{ status: "Active" }], + }), + ); + expect(fmFetch).toHaveBeenNthCalledWith( + 3, + "execute_data_api", + expect.objectContaining({ + action: "read", + limit: 100, + offset: 201, + query: [{ status: "Active" }], + }), + ); + for (const call of vi.mocked(fmFetch).mock.calls) { + expect(JSON.stringify(call[1])).not.toContain("findAll"); + } }); it("coalesces adapter requests into one batch envelope when enabled", async () => { From 0385cb13b832956bbfc18425937c76e521d99678 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:08:55 -0500 Subject: [PATCH 05/11] Cap default webviewer batches --- .changeset/quick-bats-queue.md | 2 +- apps/docs/content/docs/webviewer/batching.mdx | 6 +++--- apps/docs/content/docs/webviewer/fmdapi.mdx | 4 ++-- packages/webviewer/src/adapter.ts | 2 +- packages/webviewer/tests/adapter.test.ts | 8 +++++--- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.changeset/quick-bats-queue.md b/.changeset/quick-bats-queue.md index e61edda1..e073edf5 100644 --- a/.changeset/quick-bats-queue.md +++ b/.changeset/quick-bats-queue.md @@ -3,5 +3,5 @@ "@proofkit/fmdapi": minor --- -Add opt-in WebViewerAdapter batching with per-request `batch: true`/`batch: false` controls. +Add opt-in WebViewerAdapter batching with per-request `batch: true`/`batch: false` controls and a default maximum batch size of 5. `listAll` and `findAll` now page through bounded `read` requests in the adapter, batching follow-up pages when enabled, and older FileMaker add-on scripts fall back to unbatched requests with a warning that links to batching docs. diff --git a/apps/docs/content/docs/webviewer/batching.mdx b/apps/docs/content/docs/webviewer/batching.mdx index e03b9733..6f6a557c 100644 --- a/apps/docs/content/docs/webviewer/batching.mdx +++ b/apps/docs/content/docs/webviewer/batching.mdx @@ -5,7 +5,7 @@ description: Coalesce FileMaker Data API requests from a Web Viewer and update o `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. -Batching is disabled unless `batch` is set. Use `batch: true` for defaults. +Batching is disabled unless `batch` is set. Use `batch: true` for defaults: an 8 ms window and maximum batch size of 5. ```ts title="client.ts" import { DataApi } from "@proofkit/fmdapi"; @@ -16,7 +16,7 @@ export const client = DataApi({ scriptName: "ExecuteDataApi", batch: { windowMs: 8, - maxSize: 20, + maxSize: 5, }, }), layout: "API_Customers", @@ -40,7 +40,7 @@ await client.find({ }); ``` -Per-request batching uses the adapter's configured batch settings when present. If the adapter was not configured with `batch`, `batch: true` uses the default batch window and size. +Per-request batching uses the adapter's configured batch settings when present. If the adapter was not configured with `batch`, `batch: true` uses the default 8 ms window and maximum batch size of 5. ## All-record helpers diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index 7374414a..8ad73b8a 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -101,14 +101,14 @@ export const client = DataApi({ scriptName: "ExecuteDataApi", batch: { windowMs: 8, - maxSize: 20, + maxSize: 5, }, }), layout: "API_Customers", }); ``` -Batching is disabled unless `batch` is set. Use `batch: true` for defaults. +Batching is disabled unless `batch` is set. Use `batch: true` for defaults: an 8 ms window and maximum batch size of 5. ## Adapter Pagination diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index 6686a241..3c2d4366 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -54,7 +54,7 @@ interface BatchScriptResponse extends Partial { } const DEFAULT_BATCH_WINDOW_MS = 8; -const DEFAULT_BATCH_MAX_SIZE = 20; +const DEFAULT_BATCH_MAX_SIZE = 5; const BATCHING_DOCS_URL = "https://proofkit.dev/docs/webviewer/batching"; const LEGACY_BATCH_WARNING = `[ProofKit] ProofKit called the FileMaker script to execute Data API, but it did not support batching. Install the latest ProofKit add-on in your FileMaker file to get the updated script. Falling back to unbatched requests for this adapter. See ${BATCHING_DOCS_URL}`; diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts index ad974bb9..9ca2d36e 100644 --- a/packages/webviewer/tests/adapter.test.ts +++ b/packages/webviewer/tests/adapter.test.ts @@ -535,9 +535,11 @@ describe("WebViewerAdapter", () => { }), ); - await Promise.resolve(); - - expect(fmFetch).toHaveBeenCalledTimes(1); await expect(Promise.all(requests)).resolves.toHaveLength(20); + expect(fmFetch).toHaveBeenCalledTimes(4); + for (const call of vi.mocked(fmFetch).mock.calls) { + const payload = call[1] as { requests: unknown[] }; + expect(payload.requests).toHaveLength(5); + } }); }); From 09f0cc821b8b9dbd009f1fa0fc80b71ac5abae78 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:23:54 -0500 Subject: [PATCH 06/11] Enable webviewer batching by default --- .changeset/quick-bats-queue.md | 2 +- apps/docs/content/docs/webviewer/batching.mdx | 23 ++++++--- apps/docs/content/docs/webviewer/fmdapi.mdx | 8 ++-- packages/webviewer/src/adapter.ts | 4 +- packages/webviewer/tests/adapter.test.ts | 47 +++++++++++++------ 5 files changed, 55 insertions(+), 29 deletions(-) diff --git a/.changeset/quick-bats-queue.md b/.changeset/quick-bats-queue.md index e073edf5..796e7d0e 100644 --- a/.changeset/quick-bats-queue.md +++ b/.changeset/quick-bats-queue.md @@ -3,5 +3,5 @@ "@proofkit/fmdapi": minor --- -Add opt-in WebViewerAdapter batching with per-request `batch: true`/`batch: false` controls and a default maximum batch size of 5. +Add default WebViewerAdapter batching with per-request `batch: true`/`batch: false` controls and a maximum batch size of 5. `listAll` and `findAll` now page through bounded `read` requests in the adapter, batching follow-up pages when enabled, and older FileMaker add-on scripts fall back to unbatched requests with a warning that links to batching docs. diff --git a/apps/docs/content/docs/webviewer/batching.mdx b/apps/docs/content/docs/webviewer/batching.mdx index 6f6a557c..5af148af 100644 --- a/apps/docs/content/docs/webviewer/batching.mdx +++ b/apps/docs/content/docs/webviewer/batching.mdx @@ -3,9 +3,9 @@ title: Batching Data API Requests description: Coalesce FileMaker Data API requests from a Web Viewer and update older ProofKit add-on scripts. --- -`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. +`WebViewerAdapter` coalesces 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. -Batching is disabled unless `batch` is set. Use `batch: true` for defaults: an 8 ms window and maximum batch size of 5. +Batching is enabled by default with an 8 ms window and maximum batch size of 5. ```ts title="client.ts" import { DataApi } from "@proofkit/fmdapi"; @@ -15,8 +15,8 @@ export const client = DataApi({ adapter: new WebViewerAdapter({ scriptName: "ExecuteDataApi", batch: { - windowMs: 8, - maxSize: 5, + windowMs: 16, + maxSize: 10, }, }), layout: "API_Customers", @@ -25,13 +25,13 @@ export const client = DataApi({ ## Per-request control -Use `batch: false` on a request to bypass adapter-level batching for that call: +Use `batch: false` on a request to bypass batching for that call: ```ts await client.list({ batch: false, limit: 10 }); ``` -Use `batch: true` on a request to batch that call even when the adapter was not configured with batching: +Use `batch: true` on a request to batch that call even when adapter-level batching was disabled: ```ts await client.find({ @@ -40,7 +40,16 @@ await client.find({ }); ``` -Per-request batching uses the adapter's configured batch settings when present. If the adapter was not configured with `batch`, `batch: true` uses the default 8 ms window and maximum batch size of 5. +Per-request batching uses the adapter's configured batch settings when present. If the adapter was configured with `batch: false`, `batch: true` uses the default 8 ms window and maximum batch size of 5. + +To disable batching for every request unless it opts in, set `batch: false` on the adapter: + +```ts +new WebViewerAdapter({ + scriptName: "ExecuteDataApi", + batch: false, +}); +``` ## All-record helpers diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index 8ad73b8a..9cf8b8d7 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -90,7 +90,7 @@ For examples of all methods, see the [@proofkit/fmdapi](/docs/fmdapi) documentat ## Batched Web Viewer Requests -`WebViewerAdapter` can coalesce Data API calls that happen within a short window into one FileMaker script call. See [Batching Data API Requests](/docs/webviewer/batching) for per-request controls, the full script contract, and older add-on fallback behavior. +`WebViewerAdapter` coalesces Data API calls that happen within a short window into one FileMaker script call by default. See [Batching Data API Requests](/docs/webviewer/batching) for per-request controls, the full script contract, and older add-on fallback behavior. ```ts title="client.ts" import { DataApi } from "@proofkit/fmdapi"; @@ -100,15 +100,15 @@ export const client = DataApi({ adapter: new WebViewerAdapter({ scriptName: "ExecuteDataApi", batch: { - windowMs: 8, - maxSize: 5, + windowMs: 16, + maxSize: 10, }, }), layout: "API_Customers", }); ``` -Batching is disabled unless `batch` is set. Use `batch: true` for defaults: an 8 ms window and maximum batch size of 5. +Default batching uses an 8 ms window and maximum batch size of 5. Use `batch: false` on the adapter or an individual request to opt out. ## Adapter Pagination diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index 3c2d4366..c037edb4 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -66,10 +66,10 @@ function normalizeBatchMaxSize(maxSize: number | undefined): number { } function resolveBatchOptions(batch: WebViewerAdapterOptions["batch"]): ResolvedBatchOptions | undefined { - if (!batch) { + if (batch === false) { return; } - if (batch === true) { + if (batch === undefined || batch === true) { return { maxSize: DEFAULT_BATCH_MAX_SIZE, windowMs: DEFAULT_BATCH_WINDOW_MS, diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts index 9ca2d36e..03d361ca 100644 --- a/packages/webviewer/tests/adapter.test.ts +++ b/packages/webviewer/tests/adapter.test.ts @@ -17,26 +17,42 @@ describe("WebViewerAdapter", () => { vi.useRealTimers(); }); - it("uses a single FileMaker script call by default", async () => { - vi.mocked(fmFetch).mockResolvedValue({ - messages: [{ code: "0" }], - response: { data: [] }, + it("batches FileMaker script calls by default", async () => { + vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { + const batch = data as { + requests: Array<{ id: string }>; + }; + return Promise.resolve({ + responses: batch.requests.map((request) => ({ + id: request.id, + 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: [] }); + const result = adapter.list({ + data: { _limit: 10 } as unknown as ListOptions["data"], + layout: "Customers", + }); + expect(fmFetch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(8); + + await expect(result).resolves.toEqual({ data: [] }); expect(fmFetch).toHaveBeenCalledTimes(1); expect(fmFetch).toHaveBeenCalledWith("execute_data_api", { - action: "read", - layouts: "Customers", - limit: 10, - version: "vLatest", + batch: true, + requests: [ + { + action: "read", + id: "batch-0", + layouts: "Customers", + limit: 10, + version: "vLatest", + }, + ], }); }); @@ -358,7 +374,7 @@ describe("WebViewerAdapter", () => { ); }); - it("allows requests to opt in to batching with default batch settings", async () => { + it("allows requests to opt in when adapter-level batching is disabled", async () => { vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { const batch = data as { requests: Array<{ id: string; layouts: string }>; @@ -373,6 +389,7 @@ describe("WebViewerAdapter", () => { }); const adapter = new WebViewerAdapter({ + batch: false, scriptName: "execute_data_api", }); const first = adapter.list({ batch: true, data: {} as ListOptions["data"], layout: "A" }); From 8ab51c319f6bdf2e7b1878b91ee1c88e470ba9fe Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:46:12 -0500 Subject: [PATCH 07/11] Set default webviewer batch size to 20 --- .changeset/quick-bats-queue.md | 2 +- apps/docs/content/docs/webviewer/batching.mdx | 4 ++-- apps/docs/content/docs/webviewer/fmdapi.mdx | 2 +- packages/webviewer/src/adapter.ts | 2 +- packages/webviewer/tests/adapter.test.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/quick-bats-queue.md b/.changeset/quick-bats-queue.md index 796e7d0e..ced23616 100644 --- a/.changeset/quick-bats-queue.md +++ b/.changeset/quick-bats-queue.md @@ -3,5 +3,5 @@ "@proofkit/fmdapi": minor --- -Add default WebViewerAdapter batching with per-request `batch: true`/`batch: false` controls and a maximum batch size of 5. +Add default WebViewerAdapter batching with per-request `batch: true`/`batch: false` controls and a maximum batch size of 20. `listAll` and `findAll` now page through bounded `read` requests in the adapter, batching follow-up pages when enabled, and older FileMaker add-on scripts fall back to unbatched requests with a warning that links to batching docs. diff --git a/apps/docs/content/docs/webviewer/batching.mdx b/apps/docs/content/docs/webviewer/batching.mdx index 5af148af..9c1c318e 100644 --- a/apps/docs/content/docs/webviewer/batching.mdx +++ b/apps/docs/content/docs/webviewer/batching.mdx @@ -5,7 +5,7 @@ description: Coalesce FileMaker Data API requests from a Web Viewer and update o `WebViewerAdapter` coalesces 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. -Batching is enabled by default with an 8 ms window and maximum batch size of 5. +Batching is enabled by default with an 8 ms window and maximum batch size of 20. ```ts title="client.ts" import { DataApi } from "@proofkit/fmdapi"; @@ -40,7 +40,7 @@ await client.find({ }); ``` -Per-request batching uses the adapter's configured batch settings when present. If the adapter was configured with `batch: false`, `batch: true` uses the default 8 ms window and maximum batch size of 5. +Per-request batching uses the adapter's configured batch settings when present. If the adapter was configured with `batch: false`, `batch: true` uses the default 8 ms window and maximum batch size of 20. To disable batching for every request unless it opts in, set `batch: false` on the adapter: diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index 9cf8b8d7..007d953b 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -108,7 +108,7 @@ export const client = DataApi({ }); ``` -Default batching uses an 8 ms window and maximum batch size of 5. Use `batch: false` on the adapter or an individual request to opt out. +Default batching uses an 8 ms window and maximum batch size of 20. Use `batch: false` on the adapter or an individual request to opt out. ## Adapter Pagination diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index c037edb4..94640b9e 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -54,7 +54,7 @@ interface BatchScriptResponse extends Partial { } const DEFAULT_BATCH_WINDOW_MS = 8; -const DEFAULT_BATCH_MAX_SIZE = 5; +const DEFAULT_BATCH_MAX_SIZE = 20; const BATCHING_DOCS_URL = "https://proofkit.dev/docs/webviewer/batching"; const LEGACY_BATCH_WARNING = `[ProofKit] ProofKit called the FileMaker script to execute Data API, but it did not support batching. Install the latest ProofKit add-on in your FileMaker file to get the updated script. Falling back to unbatched requests for this adapter. See ${BATCHING_DOCS_URL}`; diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts index 03d361ca..434078c7 100644 --- a/packages/webviewer/tests/adapter.test.ts +++ b/packages/webviewer/tests/adapter.test.ts @@ -553,10 +553,10 @@ describe("WebViewerAdapter", () => { ); await expect(Promise.all(requests)).resolves.toHaveLength(20); - expect(fmFetch).toHaveBeenCalledTimes(4); + expect(fmFetch).toHaveBeenCalledTimes(1); for (const call of vi.mocked(fmFetch).mock.calls) { const payload = call[1] as { requests: unknown[] }; - expect(payload.requests).toHaveLength(5); + expect(payload.requests).toHaveLength(20); } }); }); From 6e3b0c0ce4296c4f77c9d97b85c2e09a4b69ec23 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:53:11 -0500 Subject: [PATCH 08/11] Document batching tuning guidance --- .changeset/quick-bats-queue.md | 1 + apps/docs/content/docs/webviewer/batching.mdx | 14 ++++++++++++++ apps/docs/content/docs/webviewer/fmdapi.mdx | 4 ++++ 3 files changed, 19 insertions(+) diff --git a/.changeset/quick-bats-queue.md b/.changeset/quick-bats-queue.md index ced23616..4a8ed90c 100644 --- a/.changeset/quick-bats-queue.md +++ b/.changeset/quick-bats-queue.md @@ -5,3 +5,4 @@ Add default WebViewerAdapter batching with per-request `batch: true`/`batch: false` controls and a maximum batch size of 20. `listAll` and `findAll` now page through bounded `read` requests in the adapter, batching follow-up pages when enabled, and older FileMaker add-on scripts fall back to unbatched requests with a warning that links to batching docs. +Docs include tuning guidance for benchmarking batch size and page size against real FileMaker layouts and found sets. diff --git a/apps/docs/content/docs/webviewer/batching.mdx b/apps/docs/content/docs/webviewer/batching.mdx index 9c1c318e..9e74d78f 100644 --- a/apps/docs/content/docs/webviewer/batching.mdx +++ b/apps/docs/content/docs/webviewer/batching.mdx @@ -3,10 +3,17 @@ title: Batching Data API Requests description: Coalesce FileMaker Data API requests from a Web Viewer and update older ProofKit add-on scripts. --- +import { Callout } from "fumadocs-ui/components/callout"; + `WebViewerAdapter` coalesces 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. Batching is enabled by default with an 8 ms window and maximum batch size of 20. + + FileMaker performance depends heavily on the layout, field widths, portal payloads, script path, and found-set size. + Benchmark the default `maxSize: 20` against your own file before assuming it is the fastest setting for every workflow. + + ```ts title="client.ts" import { DataApi } from "@proofkit/fmdapi"; import { WebViewerAdapter } from "@proofkit/webviewer/adapter"; @@ -57,6 +64,13 @@ new WebViewerAdapter({ When batching is enabled, the follow-up page requests can be batched in chunks up to `maxSize`. This reduces Web Viewer bridge round trips without asking the FileMaker script to load an unknown number of records into script variables. + + `maxSize` controls how many page requests can share one script call. The request `limit` controls how many records each page returns. + For `listAll()`, `findAll()`, or your own manual paging, test page sizes such as `100`, `250`, and `500` with realistic records before choosing a default. + + +In the Foxtail benchmark, increasing page size from `100` to `250` had a larger effect than batching for some local `PK_execute_data_api` cases. Server-side script execution benefited more from batching, with `maxSize: 20` performing better than smaller batches in the tested found sets. Treat those numbers as a starting point, not a universal rule. + ## FileMaker script contract When batching is enabled, the FileMaker script receives this envelope: diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index 007d953b..00a93a63 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -116,6 +116,10 @@ For `client.listAll()` and `client.findAll()`, `WebViewerAdapter` does not send If batching is enabled, those follow-up page requests can be coalesced into batch envelopes. Each request inside the batch is still a bounded `read` operation: + + For all-record helpers and manual paging, tune `limit` with your real layouts and found sets. Larger pages can reduce bridge round trips, but wide records, portals, or container-heavy layouts may need smaller pages. + + ```json { "batch": true, From 55edab1b222e4cde7831d2714c67d7fb2da5011d Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:03:05 -0500 Subject: [PATCH 09/11] Clarify batching docs --- .changeset/quick-bats-queue.md | 2 +- apps/docs/content/docs/webviewer/batching.mdx | 76 +++---------------- apps/docs/content/docs/webviewer/fmdapi.mdx | 25 +----- 3 files changed, 17 insertions(+), 86 deletions(-) diff --git a/.changeset/quick-bats-queue.md b/.changeset/quick-bats-queue.md index 4a8ed90c..be1b4af1 100644 --- a/.changeset/quick-bats-queue.md +++ b/.changeset/quick-bats-queue.md @@ -5,4 +5,4 @@ Add default WebViewerAdapter batching with per-request `batch: true`/`batch: false` controls and a maximum batch size of 20. `listAll` and `findAll` now page through bounded `read` requests in the adapter, batching follow-up pages when enabled, and older FileMaker add-on scripts fall back to unbatched requests with a warning that links to batching docs. -Docs include tuning guidance for benchmarking batch size and page size against real FileMaker layouts and found sets. +Docs include tuning guidance for benchmarking batch size, page size, payload size, script execution path, and ProofKit add-on version requirements against real FileMaker layouts and found sets. diff --git a/apps/docs/content/docs/webviewer/batching.mdx b/apps/docs/content/docs/webviewer/batching.mdx index 9e74d78f..0806e352 100644 --- a/apps/docs/content/docs/webviewer/batching.mdx +++ b/apps/docs/content/docs/webviewer/batching.mdx @@ -5,13 +5,16 @@ description: Coalesce FileMaker Data API requests from a Web Viewer and update o import { Callout } from "fumadocs-ui/components/callout"; -`WebViewerAdapter` coalesces 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. +`WebViewerAdapter` batches Data API calls by default, coalescing requests that happen within a short window into one FileMaker script call. This can reduce Web Viewer bridge round trips, but FileMaker still executes the enclosed Data API operations sequentially inside your script. Batching is enabled by default with an 8 ms window and maximum batch size of 20. - - FileMaker performance depends heavily on the layout, field widths, portal payloads, script path, and found-set size. - Benchmark the default `maxSize: 20` against your own file before assuming it is the fastest setting for every workflow. + + FileMaker performance depends heavily on the script path, found-set size, and response payload size. Benchmark both `maxSize` and page `limit` with your own layouts, including field widths, portal rows, containers, and serialized response size, before assuming the defaults are fastest for every workflow. + + + + Batching mostly helps when bridge round trips dominate the request. You may not see a speed improvement with the local `Execute FileMaker Data API` script path; benchmarks showed clearer gains when requests ran through the server-side Data API script path. ```ts title="client.ts" @@ -66,68 +69,13 @@ When batching is enabled, the follow-up page requests can be batched in chunks u `maxSize` controls how many page requests can share one script call. The request `limit` controls how many records each page returns. - For `listAll()`, `findAll()`, or your own manual paging, test page sizes such as `100`, `250`, and `500` with realistic records before choosing a default. + For `listAll()`, `findAll()`, or your own manual paging, test page sizes such as `25`, `50`, `100`, `250`, and `500` with realistic records before choosing a default. Larger or payload-heavy tables may perform better with smaller pages. -In the Foxtail benchmark, increasing page size from `100` to `250` had a larger effect than batching for some local `PK_execute_data_api` cases. Server-side script execution benefited more from batching, with `maxSize: 20` performing better than smaller batches in the tested found sets. Treat those numbers as a starting point, not a universal rule. - -## FileMaker script contract - -When batching is enabled, the FileMaker script receives this envelope: - -```json -{ - "batch": true, - "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. - -## Older add-on scripts - -Older ProofKit add-on scripts do not understand the `batch` key. They can return: - -```json -{ - "response": {}, - "messages": [{ "code": "1708", "message": "Unknown key (batch)" }] -} -``` - -When `WebViewerAdapter` sees error `1708` with `Unknown key (batch)`, it logs a warning, replays the queued requests individually, and disables batching for that adapter instance. +Benchmarks showed page size can matter more than batch size for local `PK_execute_data_api` calls, while server-side script execution may benefit more from batching. Treat the defaults as a starting point, not a universal rule. -To enable batching, install the latest version of the ProofKit add-on in your FileMaker file. See [Updating ProofKit](/docs/ai/updating-proofkit) for the add-on update flow. +## ProofKit add-on version -## When batching retries +Batching requires the FileMaker scripts installed by the ProofKit add-on to understand batched Data API requests. If your FileMaker file has an older ProofKit add-on, ProofKit may run requests without batching until the add-on scripts are updated. -The fallback is kept in memory on the `WebViewerAdapter` instance. ProofKit tries batching again when a new adapter instance is created, usually after reloading the Web Viewer app or recreating the client. +Install the latest ProofKit add-on in each FileMaker file where you want to take advantage of batched Data API requests. See [Updating ProofKit](/docs/ai/updating-proofkit) for the add-on update flow. diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index 00a93a63..2ed195d9 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -90,7 +90,7 @@ For examples of all methods, see the [@proofkit/fmdapi](/docs/fmdapi) documentat ## Batched Web Viewer Requests -`WebViewerAdapter` coalesces Data API calls that happen within a short window into one FileMaker script call by default. See [Batching Data API Requests](/docs/webviewer/batching) for per-request controls, the full script contract, and older add-on fallback behavior. +`WebViewerAdapter` coalesces Data API calls that happen within a short window into one FileMaker script call by default. See [Batching Data API Requests](/docs/webviewer/batching) for per-request controls, tuning guidance, and add-on update requirements. ```ts title="client.ts" import { DataApi } from "@proofkit/fmdapi"; @@ -108,33 +108,16 @@ export const client = DataApi({ }); ``` -Default batching uses an 8 ms window and maximum batch size of 20. Use `batch: false` on the adapter or an individual request to opt out. +Default batching uses an 8 ms window and maximum batch size of 20. Use `batch: false` on the adapter or an individual request to opt out. Benchmark your own script path before assuming batching improves speed; local `Execute FileMaker Data API` calls may see little benefit compared with server-side script execution. ## Adapter Pagination For `client.listAll()` and `client.findAll()`, `WebViewerAdapter` does not send `readAll` or `findAll` actions to FileMaker. It sends a bounded first `read` request to get `dataInfo.foundCount`, then requests the remaining pages with the same limit. -If batching is enabled, those follow-up page requests can be coalesced into batch envelopes. Each request inside the batch is still a bounded `read` operation: +If batching is enabled, those follow-up page requests can share FileMaker script calls. Each request is still a bounded `read` operation: - For all-record helpers and manual paging, tune `limit` with your real layouts and found sets. Larger pages can reduce bridge round trips, but wide records, portals, or container-heavy layouts may need smaller pages. + For all-record helpers and manual paging, tune `limit` with your real layouts and found sets. Try lower values such as `25` or `50` for large or payload-heavy tables, then compare against larger pages such as `100`, `250`, and `500`. -```json -{ - "batch": true, - "requests": [ - { - "id": "batch-0", - "layouts": "API_Customers", - "action": "read", - "version": "vLatest", - "limit": 100, - "offset": 101, - "query": [{ "status": "Active" }] - } - ] -} -``` - This keeps all-record helpers from loading an unbounded result set into FileMaker variables before anything is returned to the Web Viewer. From e9a3c1bc3b805205a0b4dfcbb1308b82e6b51a89 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:32:47 -0500 Subject: [PATCH 10/11] Send singleton batches when disabled --- .changeset/quick-bats-queue.md | 1 + apps/docs/content/docs/webviewer/batching.mdx | 16 +- apps/docs/content/docs/webviewer/fmdapi.mdx | 2 +- packages/webviewer/src/adapter.ts | 45 ++++- packages/webviewer/tests/adapter.test.ts | 186 ++++++++++++++---- 5 files changed, 198 insertions(+), 52 deletions(-) diff --git a/.changeset/quick-bats-queue.md b/.changeset/quick-bats-queue.md index be1b4af1..dd5d0f0b 100644 --- a/.changeset/quick-bats-queue.md +++ b/.changeset/quick-bats-queue.md @@ -4,5 +4,6 @@ --- Add default WebViewerAdapter batching with per-request `batch: true`/`batch: false` controls and a maximum batch size of 20. +`batch: false` now disables request coalescing while still sending single-request batch payloads, falling back to the older direct request path only after an installed FileMaker add-on script reports that batching is unsupported. `listAll` and `findAll` now page through bounded `read` requests in the adapter, batching follow-up pages when enabled, and older FileMaker add-on scripts fall back to unbatched requests with a warning that links to batching docs. Docs include tuning guidance for benchmarking batch size, page size, payload size, script execution path, and ProofKit add-on version requirements against real FileMaker layouts and found sets. diff --git a/apps/docs/content/docs/webviewer/batching.mdx b/apps/docs/content/docs/webviewer/batching.mdx index 0806e352..76706b61 100644 --- a/apps/docs/content/docs/webviewer/batching.mdx +++ b/apps/docs/content/docs/webviewer/batching.mdx @@ -1,13 +1,13 @@ --- title: Batching Data API Requests -description: Coalesce FileMaker Data API requests from a Web Viewer and update older ProofKit add-on scripts. +description: Coalesce FileMaker Data API requests from a Web Viewer in fewer script calls --- import { Callout } from "fumadocs-ui/components/callout"; `WebViewerAdapter` batches Data API calls by default, coalescing requests that happen within a short window into one FileMaker script call. This can reduce Web Viewer bridge round trips, but FileMaker still executes the enclosed Data API operations sequentially inside your script. -Batching is enabled by default with an 8 ms window and maximum batch size of 20. +Request coalescing is enabled by default with an 8 ms window and maximum batch size of 20. FileMaker performance depends heavily on the script path, found-set size, and response payload size. Benchmark both `maxSize` and page `limit` with your own layouts, including field widths, portal rows, containers, and serialized response size, before assuming the defaults are fastest for every workflow. @@ -35,13 +35,13 @@ export const client = DataApi({ ## Per-request control -Use `batch: false` on a request to bypass batching for that call: +Use `batch: false` on a request to keep that call out of coalesced batches: ```ts await client.list({ batch: false, limit: 10 }); ``` -Use `batch: true` on a request to batch that call even when adapter-level batching was disabled: +Use `batch: true` on a request to coalesce that call even when adapter-level coalescing was disabled: ```ts await client.find({ @@ -52,7 +52,7 @@ await client.find({ Per-request batching uses the adapter's configured batch settings when present. If the adapter was configured with `batch: false`, `batch: true` uses the default 8 ms window and maximum batch size of 20. -To disable batching for every request unless it opts in, set `batch: false` on the adapter: +To disable coalescing for every request unless it opts in, set `batch: false` on the adapter: ```ts new WebViewerAdapter({ @@ -61,11 +61,13 @@ new WebViewerAdapter({ }); ``` +When coalescing is disabled, ProofKit still uses the batch-compatible FileMaker script path one request at a time. It only uses the older direct request path after a FileMaker file reports that its installed ProofKit add-on script does not support batched Data API requests. + ## All-record helpers `listAll()` and `findAll()` never send `readAll` or `findAll` actions to FileMaker. The adapter sends one bounded `read` request first, uses `dataInfo.foundCount` to calculate the remaining pages, and then fetches those pages with bounded `read` requests. -When batching is enabled, the follow-up page requests can be batched in chunks up to `maxSize`. This reduces Web Viewer bridge round trips without asking the FileMaker script to load an unknown number of records into script variables. +When coalescing is enabled, the follow-up page requests can be batched in chunks up to `maxSize`. This reduces Web Viewer bridge round trips without asking the FileMaker script to load an unknown number of records into script variables. `maxSize` controls how many page requests can share one script call. The request `limit` controls how many records each page returns. @@ -76,6 +78,6 @@ Benchmarks showed page size can matter more than batch size for local `PK_execut ## ProofKit add-on version -Batching requires the FileMaker scripts installed by the ProofKit add-on to understand batched Data API requests. If your FileMaker file has an older ProofKit add-on, ProofKit may run requests without batching until the add-on scripts are updated. +Batching requires the FileMaker scripts installed by the ProofKit add-on to understand batched Data API requests. If your FileMaker file has an older ProofKit add-on, ProofKit may run requests through the older direct request path until the add-on scripts are updated. Install the latest ProofKit add-on in each FileMaker file where you want to take advantage of batched Data API requests. See [Updating ProofKit](/docs/ai/updating-proofkit) for the add-on update flow. diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index 2ed195d9..c5a341b1 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -108,7 +108,7 @@ export const client = DataApi({ }); ``` -Default batching uses an 8 ms window and maximum batch size of 20. Use `batch: false` on the adapter or an individual request to opt out. Benchmark your own script path before assuming batching improves speed; local `Execute FileMaker Data API` calls may see little benefit compared with server-side script execution. +Default coalescing uses an 8 ms window and maximum batch size of 20. Use `batch: false` on the adapter or an individual request to send requests one at a time through the batch-compatible script path. Benchmark your own script path before assuming batching improves speed; local `Execute FileMaker Data API` calls may see little benefit compared with server-side script execution. ## Adapter Pagination diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index 94640b9e..e1251caa 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -91,6 +91,13 @@ function defaultBatchOptions(): ResolvedBatchOptions { }; } +function singleRequestBatchOptions(): ResolvedBatchOptions { + return { + maxSize: 1, + windowMs: 0, + }; +} + function normalizeBody(body: object): Record { const { _limit, _offset, _sort, ...normalizedBody } = body as Record; if (_offset !== undefined) { @@ -171,6 +178,9 @@ export class WebViewerAdapter implements Adapter { const batchOptions = this.getBatchOptionsForRequest(params.batch); if (batchOptions) { + if (normalizeBatchMaxSize(batchOptions.maxSize) === 1) { + return this.executeSingleBatchRequest(payload); + } return this.enqueueBatchRequest(payload, batchOptions); } @@ -178,13 +188,16 @@ export class WebViewerAdapter implements Adapter { }; private getBatchOptionsForRequest(batch: boolean | undefined): ResolvedBatchOptions | undefined { - if (this.batchDisabledByLegacyScript || batch === false) { + if (this.batchDisabledByLegacyScript) { return; } + if (batch === false) { + return singleRequestBatchOptions(); + } if (batch === true) { return this.batchOptions ?? defaultBatchOptions(); } - return this.batchOptions; + return this.batchOptions ?? singleRequestBatchOptions(); } private createScriptRequest(params: { layout: string; body: object; action?: DataApiAction }): DataApiScriptRequest { @@ -213,6 +226,23 @@ export class WebViewerAdapter implements Adapter { return this.handleDataApiResponse(resp); } + private executeSingleBatchRequest(payload: DataApiScriptRequest): Promise { + return new Promise((resolve, reject) => { + const request: QueuedBatchRequest = { + batchOptions: singleRequestBatchOptions(), + id: `batch-${this.batchRequestId}`, + payload, + resolve, + reject, + }; + this.batchRequestId++; + + this.executeBatchRequests([request]).catch((error: unknown) => { + request.reject(error); + }); + }); + } + private enqueueBatchRequest(payload: DataApiScriptRequest, batchOptions: ResolvedBatchOptions): Promise { return new Promise((resolve, reject) => { this.batchQueue.push({ @@ -343,12 +373,11 @@ export class WebViewerAdapter implements Adapter { const { batch, data, layout } = opts; const limit = getRangeValue(data, ["_limit", "limit"], 100); const initialOffset = getRangeValue(data, ["_offset", "offset"], 1); - const first = (await this.executeSingleRequest( - this.createScriptRequest({ - body: createPagedBody(data, limit, initialOffset), - layout, - }), - )) as clientTypes.GetResponse; + const first = (await this.request({ + batch, + body: createPagedBody(data, limit, initialOffset), + layout, + })) as clientTypes.GetResponse; const records = [...(first.data ?? [])]; const foundCount = getPositiveInteger(first.dataInfo?.foundCount, records.length); const targetCount = Math.max(0, foundCount - (initialOffset - 1)); diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts index 434078c7..f17953fe 100644 --- a/packages/webviewer/tests/adapter.test.ts +++ b/packages/webviewer/tests/adapter.test.ts @@ -120,10 +120,16 @@ describe("WebViewerAdapter", () => { expect(result.dataInfo.returnedCount).toBe(1000); expect(fmFetch).toHaveBeenCalledTimes(3); expect(fmFetch).toHaveBeenNthCalledWith(1, "execute_data_api", { - action: "read", - layouts: "Customers", - limit: 100, - version: "vLatest", + batch: true, + requests: [ + { + action: "read", + id: "batch-0", + layouts: "Customers", + limit: 100, + version: "vLatest", + }, + ], }); expect(fmFetch).toHaveBeenNthCalledWith(2, "execute_data_api", { batch: true, @@ -153,22 +159,26 @@ describe("WebViewerAdapter", () => { it("paginates findAll with bounded find requests", async () => { const totalRecordCount = 250; vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { - const payload = data as { limit?: number; offset?: number }; - const limit = payload.limit ?? 100; - const offset = payload.offset ?? 1; - const pageEnd = Math.min(offset + limit - 1, totalRecordCount); - const pageData = - offset > totalRecordCount - ? [] - : Array.from({ length: pageEnd - offset + 1 }, (_, index) => ({ - fieldData: { id: offset + index }, - modId: "0", - portalData: {}, - recordId: String(offset + index), - })); - return Promise.resolve({ - messages: [{ code: "0" }], - response: { + const payload = data as { + batch?: boolean; + limit?: number; + offset?: number; + requests?: Array<{ id: string; limit?: number; offset?: number }>; + }; + const getPageResponse = (request: { limit?: number; offset?: number }) => { + const limit = request.limit ?? 100; + const offset = request.offset ?? 1; + const pageEnd = Math.min(offset + limit - 1, totalRecordCount); + const pageData = + offset > totalRecordCount + ? [] + : Array.from({ length: pageEnd - offset + 1 }, (_, index) => ({ + fieldData: { id: offset + index }, + modId: "0", + portalData: {}, + recordId: String(offset + index), + })); + return { data: pageData, dataInfo: { database: "Test", @@ -178,7 +188,20 @@ describe("WebViewerAdapter", () => { table: "Customers", totalRecordCount, }, - }, + }; + }; + if (payload.batch) { + return Promise.resolve({ + responses: payload.requests?.map((request) => ({ + id: request.id, + messages: [{ code: "0" }], + response: getPageResponse(request), + })), + }); + } + return Promise.resolve({ + messages: [{ code: "0" }], + response: getPageResponse(payload), }); }); @@ -195,30 +218,32 @@ describe("WebViewerAdapter", () => { expect(result.data).toHaveLength(250); expect(fmFetch).toHaveBeenCalledTimes(3); expect(fmFetch).toHaveBeenNthCalledWith(1, "execute_data_api", { - action: "read", - layouts: "Customers", - limit: 100, - query: [{ status: "Active" }], - version: "vLatest", + batch: true, + requests: [ + { + action: "read", + id: "batch-0", + layouts: "Customers", + limit: 100, + query: [{ status: "Active" }], + version: "vLatest", + }, + ], }); expect(fmFetch).toHaveBeenNthCalledWith( 2, "execute_data_api", expect.objectContaining({ - action: "read", - limit: 100, - offset: 101, - query: [{ status: "Active" }], + batch: true, + requests: [expect.objectContaining({ action: "read", limit: 100, offset: 101 })], }), ); expect(fmFetch).toHaveBeenNthCalledWith( 3, "execute_data_api", expect.objectContaining({ - action: "read", - limit: 100, - offset: 201, - query: [{ status: "Active" }], + batch: true, + requests: [expect.objectContaining({ action: "read", limit: 100, offset: 201 })], }), ); for (const call of vi.mocked(fmFetch).mock.calls) { @@ -319,7 +344,7 @@ describe("WebViewerAdapter", () => { await secondExpectation; }); - it("allows a request to opt out of adapter-level batching", async () => { + it("allows a request to opt out of adapter-level coalescing", async () => { vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { const payload = data as { batch?: boolean; requests?: Array<{ id: string; layouts: string }>; layouts?: string }; if (payload.batch) { @@ -362,7 +387,8 @@ describe("WebViewerAdapter", () => { 1, "execute_data_api", expect.objectContaining({ - layouts: "Customers", + batch: true, + requests: [expect.objectContaining({ layouts: "Customers" })], }), ); expect(fmFetch).toHaveBeenNthCalledWith( @@ -374,6 +400,94 @@ describe("WebViewerAdapter", () => { ); }); + it("keeps opt-out requests separate from queued coalesced requests", 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: { windowMs: 8 }, + scriptName: "execute_data_api", + }); + const coalesced = adapter.list({ + data: {} as ListOptions["data"], + layout: "Queued", + }); + const optOut = adapter.list({ + batch: false, + data: {} as ListOptions["data"], + layout: "Solo", + }); + + await expect(optOut).resolves.toEqual({ layout: "Solo" }); + expect(fmFetch).toHaveBeenCalledTimes(1); + expect(fmFetch).toHaveBeenNthCalledWith( + 1, + "execute_data_api", + expect.objectContaining({ + requests: [expect.objectContaining({ layouts: "Solo" })], + }), + ); + + await vi.advanceTimersByTimeAsync(8); + + await expect(coalesced).resolves.toEqual({ layout: "Queued" }); + expect(fmFetch).toHaveBeenCalledTimes(2); + expect(fmFetch).toHaveBeenNthCalledWith( + 2, + "execute_data_api", + expect.objectContaining({ + requests: [expect.objectContaining({ layouts: "Queued" })], + }), + ); + }); + + it("sends single-request batches when adapter-level batching is disabled", 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: false, + scriptName: "execute_data_api", + }); + + await expect(adapter.list({ data: {} as ListOptions["data"], layout: "Customers" })).resolves.toEqual({ + layout: "Customers", + }); + + expect(fmFetch).toHaveBeenCalledTimes(1); + expect(fmFetch).toHaveBeenCalledWith("execute_data_api", { + batch: true, + requests: [ + { + action: "read", + id: "batch-0", + layouts: "Customers", + version: "vLatest", + }, + ], + }); + }); + it("allows requests to opt in when adapter-level batching is disabled", async () => { vi.mocked(fmFetch).mockImplementation((_scriptName, data) => { const batch = data as { From 2b628e3d8783d3ba4b14b699faa3a6781839a8fb Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:44:26 -0500 Subject: [PATCH 11/11] Refine webviewer batching docs --- apps/docs/content/docs/webviewer/batching.mdx | 37 +++++++++++-------- apps/docs/content/docs/webviewer/fmdapi.mdx | 6 +-- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/apps/docs/content/docs/webviewer/batching.mdx b/apps/docs/content/docs/webviewer/batching.mdx index 76706b61..66fe30a6 100644 --- a/apps/docs/content/docs/webviewer/batching.mdx +++ b/apps/docs/content/docs/webviewer/batching.mdx @@ -4,17 +4,24 @@ description: Coalesce FileMaker Data API requests from a Web Viewer in fewer scr --- import { Callout } from "fumadocs-ui/components/callout"; - -`WebViewerAdapter` batches Data API calls by default, coalescing requests that happen within a short window into one FileMaker script call. This can reduce Web Viewer bridge round trips, but FileMaker still executes the enclosed Data API operations sequentially inside your script. - -Request coalescing is enabled by default with an 8 ms window and maximum batch size of 20. - - - FileMaker performance depends heavily on the script path, found-set size, and response payload size. Benchmark both `maxSize` and page `limit` with your own layouts, including field widths, portal rows, containers, and serialized response size, before assuming the defaults are fastest for every workflow. - - - - Batching mostly helps when bridge round trips dominate the request. You may not see a speed improvement with the local `Execute FileMaker Data API` script path; benchmarks showed clearer gains when requests ran through the server-side Data API script path. +import { Badge } from "@/components/ui/badge"; + +
+ + @proofkit/webviewer 3.2.0+ + + + @proofkit/fmdapi 5.2.0+ + + + ProofKit add-on 3.0+ + +
+ +`WebViewerAdapter` batches Data API calls by default, coalescing requests that happen within a short window into one FileMaker script call. This can reduce Web Viewer bridge round trips, but FileMaker still executes the enclosed Data API operations sequentially inside your script. This is mostly done for better developer experience to avoid so many scripts in your call stack, but can sometimes also improve data loading speed (especially when calling `Execute Data API` in a server-side script). + + + FileMaker performance depends heavily on whether the script runs locally or on FileMaker Server, plus the layout, table, found set, and amount of data returned. Benchmark both `maxSize` and page `limit` with your own layouts, including field count, portal rows, and container fields, before assuming the defaults are fastest for every file. ```ts title="client.ts" @@ -61,23 +68,23 @@ new WebViewerAdapter({ }); ``` -When coalescing is disabled, ProofKit still uses the batch-compatible FileMaker script path one request at a time. It only uses the older direct request path after a FileMaker file reports that its installed ProofKit add-on script does not support batched Data API requests. +When coalescing is disabled, ProofKit still uses the batch-compatible FileMaker script format one request at a time. It only uses the older direct request format after a FileMaker file reports that its installed ProofKit add-on script does not support batched Data API requests. ## All-record helpers `listAll()` and `findAll()` never send `readAll` or `findAll` actions to FileMaker. The adapter sends one bounded `read` request first, uses `dataInfo.foundCount` to calculate the remaining pages, and then fetches those pages with bounded `read` requests. -When coalescing is enabled, the follow-up page requests can be batched in chunks up to `maxSize`. This reduces Web Viewer bridge round trips without asking the FileMaker script to load an unknown number of records into script variables. +When batching is enabled, the follow-up page requests can be batched in chunks up to `maxSize`. This reduces Web Viewer bridge round trips without asking the FileMaker script to load an unknown number of records into script variables. `maxSize` controls how many page requests can share one script call. The request `limit` controls how many records each page returns. - For `listAll()`, `findAll()`, or your own manual paging, test page sizes such as `25`, `50`, `100`, `250`, and `500` with realistic records before choosing a default. Larger or payload-heavy tables may perform better with smaller pages. + For `listAll()`, `findAll()`, or your own manual paging, test page sizes such as `25`, `50`, `100`, `250`, and `500` with records from your own tables. Larger tables, wide layouts, portal-heavy layouts, or layouts with container fields may perform better with smaller pages. Benchmarks showed page size can matter more than batch size for local `PK_execute_data_api` calls, while server-side script execution may benefit more from batching. Treat the defaults as a starting point, not a universal rule. ## ProofKit add-on version -Batching requires the FileMaker scripts installed by the ProofKit add-on to understand batched Data API requests. If your FileMaker file has an older ProofKit add-on, ProofKit may run requests through the older direct request path until the add-on scripts are updated. +Batching requires ProofKit add-on version 3.0 or later so the FileMaker scripts installed by the add-on understand batched Data API requests. If your FileMaker file has an older ProofKit add-on, ProofKit may run requests through the older direct request path until the add-on scripts are updated. Install the latest ProofKit add-on in each FileMaker file where you want to take advantage of batched Data API requests. See [Updating ProofKit](/docs/ai/updating-proofkit) for the add-on update flow. diff --git a/apps/docs/content/docs/webviewer/fmdapi.mdx b/apps/docs/content/docs/webviewer/fmdapi.mdx index c5a341b1..aa3b1d14 100644 --- a/apps/docs/content/docs/webviewer/fmdapi.mdx +++ b/apps/docs/content/docs/webviewer/fmdapi.mdx @@ -108,7 +108,7 @@ export const client = DataApi({ }); ``` -Default coalescing uses an 8 ms window and maximum batch size of 20. Use `batch: false` on the adapter or an individual request to send requests one at a time through the batch-compatible script path. Benchmark your own script path before assuming batching improves speed; local `Execute FileMaker Data API` calls may see little benefit compared with server-side script execution. +Default coalescing uses an 8 ms window and maximum batch size of 20. Use `batch: false` on the adapter or an individual request to send requests one at a time through the batch-compatible FileMaker script format. Benchmark with your own file before assuming batching improves speed; a local `Execute FileMaker Data API` script may see little benefit compared with a script that runs on FileMaker Server. ## Adapter Pagination @@ -116,8 +116,8 @@ For `client.listAll()` and `client.findAll()`, `WebViewerAdapter` does not send If batching is enabled, those follow-up page requests can share FileMaker script calls. Each request is still a bounded `read` operation: - - For all-record helpers and manual paging, tune `limit` with your real layouts and found sets. Try lower values such as `25` or `50` for large or payload-heavy tables, then compare against larger pages such as `100`, `250`, and `500`. + + For all-record helpers and manual paging, tune `limit` with your real FileMaker layouts, tables, and found sets. Try lower values such as `25` or `50` for layouts with many fields, portal rows, or container fields, then compare against larger pages such as `100`, `250`, and `500`. This keeps all-record helpers from loading an unbounded result set into FileMaker variables before anything is returned to the Web Viewer.