Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quick-bats-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@proofkit/webviewer": minor
"@proofkit/fmdapi": minor
---

Add opt-in WebViewerAdapter batching and adapter-level listAll/findAll pagination hooks.
91 changes: 91 additions & 0 deletions apps/docs/content/docs/webviewer/fmdapi.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions packages/fmdapi/src/adapters/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ export type LayoutMetadataOptions = BaseRequest;

export interface Adapter {
list: (opts: ListOptions) => Promise<GetResponse>;
listAll?: (opts: ListOptions) => Promise<GetResponse>;
get: (opts: GetOptions) => Promise<GetResponse>;
find: (opts: FindOptions) => Promise<GetResponse>;
findAll?: (opts: FindOptions) => Promise<GetResponse>;
create: (opts: CreateOptions) => Promise<CreateResponse>;
update: (opts: UpdateOptions) => Promise<UpdateResponse>;
delete: (opts: DeleteOptions) => Promise<DeleteResponse>;
Expand Down
139 changes: 105 additions & 34 deletions packages/fmdapi/src/client.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -88,8 +88,10 @@ function DataApi<
create,
delete: _adapterDelete,
find,
findAll: adapterFindAll,
get,
list,
listAll: adapterListAll,
update,
layoutMetadata,
containerUpload,
Expand Down Expand Up @@ -126,18 +128,16 @@ function DataApi<

type ExecuteScriptArgs = Omit<ExecuteScriptOptions, "layout">;

/**
* List all records from a given layout, no find criteria applied.
*/
async function _list(
args?: ListParams<InferredFieldData, InferredPortalData> & FetchOptions,
): Promise<GetResponse<InferredFieldData, InferredPortalData>>;
async function _list(
args?: ListParams<InferredFieldData, InferredPortalData> & FetchOptions,
): Promise<GetResponse<InferredFieldData, InferredPortalData>> {
type ListArgs = ListParams<InferredFieldData, InferredPortalData> & FetchOptions;
type FindMethodArgs = FindArgs<InferredFieldData, InferredPortalData> & 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;
}
Expand All @@ -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"],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
timeout,
};
}

/**
* List all records from a given layout, no find criteria applied.
*/
async function _list(
args?: ListParams<InferredFieldData, InferredPortalData> & FetchOptions,
): Promise<GetResponse<InferredFieldData, InferredPortalData>>;
async function _list(
args?: ListParams<InferredFieldData, InferredPortalData> & FetchOptions,
): Promise<GetResponse<InferredFieldData, InferredPortalData>> {
const { fetch, params, timeout } = normalizeListRequest(args);

const result = await list({
layout,
data: params,
Expand Down Expand Up @@ -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<InferredFieldData, InferredPortalData>;
return (await runSchemaValidationAndTransform(schema, result)).data;
}

while (true) {
const data = await _list({
...args,
Expand Down Expand Up @@ -274,30 +345,9 @@ function DataApi<
async function _find(
args: FindArgs<InferredFieldData, InferredPortalData> & IgnoreEmptyResult & FetchOptions,
): Promise<GetResponse<InferredFieldData, InferredPortalData>> {
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,
Expand Down Expand Up @@ -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<InferredFieldData, InferredPortalData>;
return (await runSchemaValidationAndTransform(schema, result)).data;
}

while (true) {
const data = await _find({
...args,
Expand Down
Loading
Loading