diff --git a/.changeset/quick-bats-queue.md b/.changeset/quick-bats-queue.md
new file mode 100644
index 00000000..dd5d0f0b
--- /dev/null
+++ b/.changeset/quick-bats-queue.md
@@ -0,0 +1,9 @@
+---
+"@proofkit/webviewer": minor
+"@proofkit/fmdapi": minor
+---
+
+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
new file mode 100644
index 00000000..66fe30a6
--- /dev/null
+++ b/apps/docs/content/docs/webviewer/batching.mdx
@@ -0,0 +1,90 @@
+---
+title: Batching Data API Requests
+description: Coalesce FileMaker Data API requests from a Web Viewer in fewer script calls
+---
+
+import { Callout } from "fumadocs-ui/components/callout";
+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"
+import { DataApi } from "@proofkit/fmdapi";
+import { WebViewerAdapter } from "@proofkit/webviewer/adapter";
+
+export const client = DataApi({
+ adapter: new WebViewerAdapter({
+ scriptName: "ExecuteDataApi",
+ batch: {
+ windowMs: 16,
+ maxSize: 10,
+ },
+ }),
+ layout: "API_Customers",
+});
+```
+
+## Per-request control
+
+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 coalesce that call even when adapter-level coalescing was disabled:
+
+```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 configured with `batch: false`, `batch: true` uses the default 8 ms window and maximum batch size of 20.
+
+To disable coalescing for every request unless it opts in, set `batch: false` on the adapter:
+
+```ts
+new WebViewerAdapter({
+ scriptName: "ExecuteDataApi",
+ batch: false,
+});
+```
+
+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 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 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 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 7412c5bd..aa3b1d14 100644
--- a/apps/docs/content/docs/webviewer/fmdapi.mdx
+++ b/apps/docs/content/docs/webviewer/fmdapi.mdx
@@ -87,3 +87,37 @@ 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` 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";
+import { WebViewerAdapter } from "@proofkit/webviewer/adapter";
+
+export const client = DataApi({
+ adapter: new WebViewerAdapter({
+ scriptName: "ExecuteDataApi",
+ batch: {
+ windowMs: 16,
+ maxSize: 10,
+ },
+ }),
+ layout: "API_Customers",
+});
+```
+
+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
+
+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 share FileMaker script calls. Each request is still a bounded `read` operation:
+
+
+ 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.
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/fmdapi/src/adapters/core.ts b/packages/fmdapi/src/adapters/core.ts
index b9acc2f9..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;
@@ -53,8 +54,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..529fda2a 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,
@@ -41,6 +41,7 @@ export interface ClientObjectProps {
}
interface FetchOptions {
+ batch?: boolean;
fetch?: RequestInit;
}
@@ -88,8 +89,10 @@ function DataApi<
create,
delete: _adapterDelete,
find,
+ findAll: adapterFindAll,
get,
list,
+ listAll: adapterListAll,
update,
layoutMetadata,
containerUpload,
@@ -126,18 +129,17 @@ 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> {
- const { fetch, timeout, ...params } = args ?? {};
+ type ListArgs = ListParams & FetchOptions;
+ type FindMethodArgs = FindArgs & IgnoreEmptyResult & FetchOptions;
+
+ function normalizeListRequest(args?: ListArgs): {
+ batch?: boolean;
+ fetch?: RequestInit;
+ params: ListOptions["data"];
+ timeout?: number;
+ } {
+ const { batch, 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,7 +156,67 @@ function DataApi<
}).sort = undefined;
}
+ return {
+ batch,
+ fetch,
+ params: params as ListOptions["data"],
+ timeout,
+ };
+ }
+
+ function normalizeFindRequest(args: FindMethodArgs): {
+ batch?: boolean;
+ fetch?: RequestInit;
+ ignoreEmptyResult: boolean;
+ params: FindOptions["data"];
+ timeout?: number;
+ } {
+ 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) {
+ 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 {
+ batch,
+ 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 { batch, fetch, params, timeout } = normalizeListRequest(args);
+
const result = await list({
+ batch,
layout,
data: params,
fetch,
@@ -189,6 +251,22 @@ function DataApi<
const limit = args?.limit ?? 100;
let offset = args?.offset ?? 1;
+ if (adapterListAll) {
+ const { batch, fetch, params, timeout } = normalizeListRequest({
+ ...args,
+ limit,
+ offset,
+ });
+ const result = (await adapterListAll({
+ batch,
+ data: params,
+ fetch,
+ layout,
+ timeout,
+ })) as GetResponse;
+ return (await runSchemaValidationAndTransform(schema, result)).data;
+ }
+
while (true) {
const data = await _list({
...args,
@@ -210,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,
@@ -226,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,
@@ -244,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,
@@ -258,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,
@@ -274,30 +356,10 @@ 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 { batch, fetch, ignoreEmptyResult, params, timeout } = normalizeFindRequest(args);
const result = (await find({
- data: { ...params, query },
+ batch,
+ data: params,
layout,
fetch,
timeout,
@@ -379,6 +441,28 @@ function DataApi<
const limit = args.limit ?? 100;
let offset = args.offset ?? 1;
+ if (adapterFindAll) {
+ const { batch, fetch, params, timeout } = normalizeFindRequest({
+ ...args,
+ ignoreEmptyResult: true,
+ limit,
+ offset,
+ });
+ const result = (await adapterFindAll({
+ batch,
+ 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,
@@ -400,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
@@ -407,7 +492,7 @@ function DataApi<
}
async function _containerUpload(args: ContainerUploadArgs & FetchOptions) {
- const { ...params } = args;
+ const { fetch, timeout, ...params } = args;
return await containerUpload({
layout,
data: {
@@ -415,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 21804d07..313f75ed 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();
@@ -149,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();
@@ -218,6 +298,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 +345,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..e1251caa 100644
--- a/packages/webviewer/src/adapter.ts
+++ b/packages/webviewer/src/adapter.ts
@@ -17,44 +17,200 @@ export type ExecuteScriptOptions = BaseRequest & {
data: { script: string; scriptParam?: string };
};
+type DataApiAction = "read" | "metaData" | "create" | "update" | "delete" | "duplicate";
+
+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 {
+ batchOptions: ResolvedBatchOptions;
+ id: string;
+ payload: DataApiScriptRequest;
+ resolve: (value: unknown) => void;
+ reject: (reason?: unknown) => void;
+}
+
+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)) {
+ return DEFAULT_BATCH_MAX_SIZE;
+ }
+ return Math.max(1, Math.trunc(maxSize));
+}
+
+function resolveBatchOptions(batch: WebViewerAdapterOptions["batch"]): ResolvedBatchOptions | undefined {
+ if (batch === false) {
+ return;
+ }
+ if (batch === undefined || 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 defaultBatchOptions(): ResolvedBatchOptions {
+ return {
+ maxSize: DEFAULT_BATCH_MAX_SIZE,
+ windowMs: DEFAULT_BATCH_WINDOW_MS,
+ };
+}
+
+function singleRequestBatchOptions(): ResolvedBatchOptions {
+ return {
+ maxSize: 1,
+ windowMs: 0,
+ };
+}
+
+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;
+}
+
+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) => {
+ 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;
+ private readonly batchQueue: QueuedBatchRequest[] = [];
constructor(options: WebViewerAdapterOptions & { refreshToken?: boolean }) {
this.scriptName = options.scriptName;
+ this.batchOptions = resolveBatchOptions(options.batch);
}
- protected request = async (params: {
- layout: string;
+ protected request = (params: {
+ batch?: boolean;
body: object;
- action?: "read" | "metaData" | "create" | "update" | "delete" | "duplicate";
+ action?: DataApiAction;
+ layout: string;
}): Promise => {
- const { action = "read", layout, body } = params;
+ const payload = this.createScriptRequest(params);
+ const batchOptions = this.getBatchOptionsForRequest(params.batch);
+
+ if (batchOptions) {
+ if (normalizeBatchMaxSize(batchOptions.maxSize) === 1) {
+ return this.executeSingleBatchRequest(payload);
+ }
+ return this.enqueueBatchRequest(payload, batchOptions);
+ }
+
+ return this.executeSingleRequest(payload);
+ };
- if ("_offset" in body) {
- Object.assign(body, { offset: body._offset });
- body._offset = undefined;
+ private getBatchOptionsForRequest(batch: boolean | undefined): ResolvedBatchOptions | undefined {
+ if (this.batchDisabledByLegacyScript) {
+ return;
}
- if ("_limit" in body) {
- Object.assign(body, { limit: body._limit });
- body._limit = undefined;
+ if (batch === false) {
+ return singleRequestBatchOptions();
}
- if ("_sort" in body) {
- Object.assign(body, { sort: body._sort });
- body._sort = undefined;
+ if (batch === true) {
+ return this.batchOptions ?? defaultBatchOptions();
}
+ return this.batchOptions ?? singleRequestBatchOptions();
+ }
- const resp = await fmFetch(this.scriptName, {
- ...body,
- layouts: layout,
+ 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,20 +219,240 @@ 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 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({
+ batchOptions,
+ id: `batch-${this.batchRequestId}`,
+ payload,
+ resolve,
+ reject,
+ });
+ this.batchRequestId++;
+
+ if (this.batchQueue.length >= normalizeBatchMaxSize(batchOptions.maxSize)) {
+ this.flushBatchQueue();
+ return;
+ }
+
+ this.scheduleBatchFlush(batchOptions.windowMs);
+ });
+ }
+
+ 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() {
+ 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);
+ continue;
+ }
+ await this.executeBatchRequests(requests);
+ }
+ }
+
+ private async executeBatchRequests(requests: QueuedBatchRequest[]) {
+ try {
+ const resp = await fmFetch(this.scriptName, {
+ 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");
+ }
+
+ 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 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) {
+ request.reject(error);
+ }
+ }
+
+ 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.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));
+ 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 { data, layout } = opts;
+ const { batch, data, layout } = opts;
const resp = await this.request({
+ batch,
body: data,
layout,
});
return resp as clientTypes.GetResponse;
};
+ listAll = (opts: ListOptions): Promise => {
+ return this.paginateAll(opts);
+ };
+
get = async (opts: GetOptions): Promise => {
- const { data, layout } = opts;
+ const { batch, data, layout } = opts;
const resp = await this.request({
+ batch,
body: data,
layout,
});
@@ -84,18 +460,24 @@ 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,
});
return resp as clientTypes.GetResponse;
};
+ findAll = (opts: FindOptions): Promise => {
+ return this.paginateAll(opts);
+ };
+
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,
});
@@ -103,9 +485,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,
});
@@ -113,9 +496,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,
});
@@ -125,6 +509,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
new file mode 100644
index 00000000..f17953fe
--- /dev/null
+++ b/packages/webviewer/tests/adapter.test.ts
@@ -0,0 +1,676 @@
+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("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" });
+ 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", {
+ batch: true,
+ requests: [
+ {
+ action: "read",
+ id: "batch-0",
+ layouts: "Customers",
+ limit: 10,
+ version: "vLatest",
+ },
+ ],
+ });
+ });
+
+ 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({
+ batch: { maxSize: 5, windowMs: 0 },
+ scriptName: "execute_data_api",
+ });
+ const result = await adapter.listAll({
+ data: { _limit: 100 } as unknown as ListOptions["data"],
+ layout: "Customers",
+ });
+
+ expect(result.data).toHaveLength(1000);
+ expect(result.dataInfo.returnedCount).toBe(1000);
+ expect(fmFetch).toHaveBeenCalledTimes(3);
+ expect(fmFetch).toHaveBeenNthCalledWith(1, "execute_data_api", {
+ batch: true,
+ requests: [
+ {
+ action: "read",
+ id: "batch-0",
+ 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 {
+ 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",
+ foundCount: totalRecordCount,
+ layout: "Customers",
+ returnedCount: pageData.length,
+ 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),
+ });
+ });
+
+ const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" });
+ const result = await adapter.findAll({
+ batch: false,
+ data: {
+ limit: 100,
+ query: [{ status: "Active" }],
+ } as unknown as FindOptions["data"],
+ layout: "Customers",
+ });
+
+ expect(result.data).toHaveLength(250);
+ expect(fmFetch).toHaveBeenCalledTimes(3);
+ expect(fmFetch).toHaveBeenNthCalledWith(1, "execute_data_api", {
+ 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({
+ batch: true,
+ requests: [expect.objectContaining({ action: "read", limit: 100, offset: 101 })],
+ }),
+ );
+ expect(fmFetch).toHaveBeenNthCalledWith(
+ 3,
+ "execute_data_api",
+ expect.objectContaining({
+ batch: true,
+ requests: [expect.objectContaining({ action: "read", limit: 100, offset: 201 })],
+ }),
+ );
+ 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 () => {
+ 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", {
+ batch: true,
+ 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("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) {
+ 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({
+ batch: true,
+ requests: [expect.objectContaining({ layouts: "Customers" })],
+ }),
+ );
+ expect(fmFetch).toHaveBeenNthCalledWith(
+ 2,
+ "execute_data_api",
+ expect.objectContaining({
+ batch: true,
+ }),
+ );
+ });
+
+ 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 {
+ 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",
+ });
+ 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) => {
+ 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 {
+ 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 expect(Promise.all(requests)).resolves.toHaveLength(20);
+ expect(fmFetch).toHaveBeenCalledTimes(1);
+ for (const call of vi.mocked(fmFetch).mock.calls) {
+ const payload = call[1] as { requests: unknown[] };
+ expect(payload.requests).toHaveLength(20);
+ }
+ });
+});