UNPKG

@proofkit/fmdapi

Version:

FileMaker Data API client

488 lines (386 loc) • 14.1 kB
--- name: fmdapi-client description: > DataApi factory, OttoAdapter (dk_ API key), FetchAdapter (username/password with token stores), CRUD methods: list, listAll, find, findOne, findFirst, maybeFindFirst, findAll, create, update, delete, get, executeScript, containerUpload, Standard Schema validation, portal data access, FileMaker Data API, layout-bound clients, schema inference metadata: type: core library: proofkit library_version: "5.2.1" requires: - typegen-fmdapi sources: - "proofsh/proofkit:packages/fmdapi/src/client.ts" - "proofsh/proofkit:packages/fmdapi/src/adapters/otto.ts" - "proofsh/proofkit:packages/fmdapi/src/adapters/fetch.ts" - "proofsh/proofkit:apps/docs/content/docs/fmdapi/*.mdx" --- ## Setup > **Start with typegen.** Before manually configuring a DataApi client, use `npx @proofkit/typegen@beta` to generate type-safe clients from your FileMaker layouts. See the **typegen-fmdapi** skill. Manual setup below is only needed for advanced or non-typegen workflows. Install the package: ```bash pnpm add @proofkit/fmdapi ``` ### OttoAdapter (recommended) Requires [OttoFMS](https://ottofms.com/) installed on the FileMaker Server. No token management needed — the proxy handles sessions. ```ts import { DataApi, OttoAdapter } from "@proofkit/fmdapi"; const client = DataApi({ adapter: new OttoAdapter({ auth: { apiKey: process.env.OTTO_API_KEY as `dk_${string}` }, db: process.env.FM_DATABASE, server: process.env.FM_SERVER, // must start with https:// }), layout: "API_Contacts", }); ``` API keys must start with `dk_` (OttoFMS) or `KEY_` (Otto v3). OttoFMS keys use the default HTTPS port with an `/otto` path prefix. Otto v3 keys use port 3030 by default (configurable via `auth.ottoPort`). ### FetchAdapter (direct Data API) Authenticates with username/password. Manages Data API session tokens automatically. ```ts import { DataApi, FetchAdapter } from "@proofkit/fmdapi"; const client = DataApi({ adapter: new FetchAdapter({ auth: { username: process.env.FM_USERNAME, password: process.env.FM_PASSWORD, }, db: process.env.FM_DATABASE, server: process.env.FM_SERVER, tokenStore: fileTokenStore(), // IMPORTANT for production — see Common Mistakes }), layout: "API_Contacts", }); ``` ### With typegen-generated clients The recommended path is to use `@proofkit/typegen` to generate layout-specific clients with full type safety and schema validation. The generated client file exports a pre-configured `DataApi` instance per layout: ```ts import { CustomersLayout } from "./schema/client"; const { data } = await CustomersLayout.findOne({ query: { id: "==abc123" }, }); // data.fieldData is fully typed with your FM field names ``` ## Core Patterns ### CRUD Operations Every `DataApi` client is bound to a single layout. All methods operate on that layout. **Find records:** ```ts // Standard find — returns { data: FMRecord[], dataInfo } const response = await client.find({ query: { city: "Portland" }, }); // OR finds — pass an array of query objects const response = await client.find({ query: [{ city: "Portland" }, { city: "Seattle" }], }); // findOne — throws unless exactly 1 record found const { data } = await client.findOne({ query: { email: "==user@example.com" }, }); // findFirst — returns first record, throws if none found const { data } = await client.findFirst({ query: { status: "Active" }, }); // maybeFindFirst — returns first record or null const result = await client.maybeFindFirst({ query: { email: "==user@example.com" }, }); // findAll — auto-paginates through all results (caution with large datasets) const allRecords = await client.findAll({ query: { status: "==Active" }, }); // Suppress error on empty result set (FM error 401) const response = await client.find({ query: { email: "==nonexistent@example.com" }, ignoreEmptyResult: true, // returns empty array instead of throwing }); ``` **List records (no find criteria):** ```ts // list — returns up to 100 records by default const response = await client.list({ sort: [{ fieldName: "lastName", sortOrder: "ascend" }], limit: 50, offset: 1, }); // listAll — auto-paginates (caution with large datasets) const allRecords = await client.listAll(); ``` **Create:** ```ts const { recordId, modId } = await client.create({ fieldData: { firstName: "Jane", lastName: "Doe", email: "jane@example.com", }, }); ``` **Update:** ```ts // recordId is FileMaker's internal record ID (from find/list/create responses) await client.update({ recordId: 42, fieldData: { email: "new@example.com" }, modId: 5, // optional optimistic locking }); ``` **Delete:** ```ts await client.delete({ recordId: 42 }); ``` **Get by record ID:** ```ts const response = await client.get({ recordId: 42 }); ``` ### Script Execution ```ts // Direct execution const result = await client.executeScript({ script: "Process Order", scriptParam: JSON.stringify({ orderId: "12345" }), }); console.log(result.scriptResult); // string returned by Exit Script // Scripts attached to CRUD operations const { recordId, scriptResult } = await client.create({ fieldData: { name: "New Record" }, script: "After Create Hook", "script.param": JSON.stringify({ notify: true }), // Also available: script.prerequest, script.presort (and their .param variants) }); ``` ### Portal Data Portal data is returned on every record in the `portalData` property. Each portal row includes its own `recordId` and `modId`. ```ts // Type-safe portal access with manual types type TOrderRow = { "Orders::orderId": string; "Orders::orderDate": string; "Orders::total": number; }; type TPortals = { portal_orders: TOrderRow; // key = portal object name on layout }; const client = DataApi<TContact, TPortals>({ adapter: new OttoAdapter({ /* ... */ }), layout: "API_Contacts", }); const { data } = await client.find({ query: { id: "==123" } }); for (const row of data) { for (const order of row.portalData.portal_orders) { console.log(order["Orders::orderId"], order.recordId); } } // Control portal pagination const response = await client.list({ portalRanges: { portal_orders: { offset: 1, limit: 10 }, }, }); ``` ### Container Upload ```ts const file = new Blob(["file contents"], { type: "text/plain" }); await client.containerUpload({ recordId: 42, containerFieldName: "photo", // typed to field names if using schema file, containerFieldRepetition: 1, // optional, defaults to first repetition }); ``` ### Schema Validation > **Note:** Schema validators are typically generated by `@proofkit/typegen`. Manual schemas are only needed for non-typegen setups. If using typegen, customize via override files (see `typegen-setup` skill). The `schema` option accepts any [Standard Schema](https://standardschema.dev/) compliant validator (Zod, Valibot, ArkType, etc.). When set, every read method validates and transforms each record's `fieldData` (and optionally `portalData`). ```ts import { z } from "zod/v4"; import { DataApi, OttoAdapter } from "@proofkit/fmdapi"; const ZContact = z.object({ firstName: z.string(), lastName: z.string(), active: z.coerce.boolean(), // transform FM number to boolean }); const client = DataApi({ adapter: new OttoAdapter({ /* ... */ }), layout: "API_Contacts", schema: { fieldData: ZContact, // portalData: { portal_orders: ZOrderRow }, // optional }, }); // data.fieldData.active is now boolean, not number const { data } = await client.findFirst({ query: { id: "==123" } }); ``` If validation fails, the client throws with details about which fields mismatched. This catches FileMaker field renames at runtime before they corrupt downstream logic. ## Common Mistakes ### CRITICAL: Creating DataApi without an adapter Wrong: ```ts import { DataApi } from "@proofkit/fmdapi"; const client = DataApi({ layout: "Contacts", server: "https://fm.example.com", db: "MyDB.fmp12", auth: { apiKey: "dk_abc123" }, }); ``` Correct: ```ts import { DataApi, OttoAdapter } from "@proofkit/fmdapi"; const client = DataApi({ adapter: new OttoAdapter({ server: "https://fm.example.com", db: "MyDB.fmp12", auth: { apiKey: "dk_abc123" as `dk_${string}` }, }), layout: "Contacts", }); ``` v5 requires an explicit adapter instance. Connection config (`server`, `db`, `auth`) goes on the adapter, not `DataApi`. `layout` goes on `DataApi`. ### CRITICAL: Omitting token store in production (FetchAdapter) Wrong: ```ts const client = DataApi({ adapter: new FetchAdapter({ auth: { username: "admin", password: "pass" }, db: "MyDB.fmp12", server: "https://fm.example.com", // no tokenStore — defaults to in-memory }), layout: "Contacts", }); ``` Correct: ```ts import { fileTokenStore } from "@proofkit/fmdapi/tokenStore/file"; // or for serverless: // import { upstashTokenStore } from "@proofkit/fmdapi/tokenStore/upstash"; const client = DataApi({ adapter: new FetchAdapter({ auth: { username: "admin", password: "pass" }, db: "MyDB.fmp12", server: "https://fm.example.com", tokenStore: fileTokenStore(), }), layout: "Contacts", }); ``` Default `memoryStore` loses tokens on process restart, creating a new session each time. FileMaker allows max 500 concurrent sessions — serverless/edge deployments exhaust this quickly. Use `fileTokenStore()` for persistent servers or `upstashTokenStore()` for edge/serverless. OttoAdapter avoids this entirely. ### HIGH: Storing FM recordId as a persistent identifier Wrong: ```ts // Saving recordId to your own database as a foreign key const { recordId } = await client.create({ fieldData: { name: "Acme" } }); await myDb.insert({ fmRecordId: recordId }); // fragile! ``` Correct: ```ts // Use a stable primary key field (e.g., UUID) from FileMaker const { data } = await client.findOne({ query: { name: "Acme" } }); const stableId = data.fieldData.primaryKey; // UUID set by auto-enter ``` FileMaker's internal `recordId` can change during imports, migrations, or file recovery. Always use a dedicated primary key field (UUID or serial) for cross-system references. Only use `recordId` for immediate operations (update/delete) within the same request flow. ### HIGH: Assuming dynamic layout switching on a single client Wrong: ```ts const client = DataApi({ adapter: new OttoAdapter({ /* ... */ }), layout: "Contacts", }); // Trying to query a different layout await client.find({ layout: "Invoices", query: { status: "Open" } }); ``` Correct: ```ts const contactsClient = DataApi({ adapter: new OttoAdapter({ /* ... */ }), layout: "Contacts", }); const invoicesClient = DataApi({ adapter: new OttoAdapter({ /* ... */ }), layout: "Invoices", }); ``` Each `DataApi` client is bound to one layout at creation. v5 removed per-method layout override. Create a separate client per layout. The adapter instance can be shared. ### MEDIUM: Using wrong Otto API key format Wrong: ```ts new OttoAdapter({ auth: { apiKey: "abc123-def456" }, // no prefix db: "MyDB.fmp12", server: "https://fm.example.com", }); ``` Correct: ```ts new OttoAdapter({ auth: { apiKey: "dk_abc123def456" as `dk_${string}` }, db: "MyDB.fmp12", server: "https://fm.example.com", }); ``` OttoFMS keys start with `dk_`, Otto v3 keys start with `KEY_`. The adapter uses this prefix to determine the connection method (port 3030 for `KEY_`, `/otto` path prefix for `dk_`). An unrecognized prefix throws at construction time. ### HIGH: Using deprecated zodValidators option instead of schema Wrong: ```ts import { z } from "zod"; const client = DataApi({ adapter: new OttoAdapter({ /* ... */ }), layout: "Contacts", zodValidators: { fieldData: z.object({ name: z.string() }), }, }); ``` Correct: ```ts import { z } from "zod/v4"; const client = DataApi({ adapter: new OttoAdapter({ /* ... */ }), layout: "Contacts", schema: { fieldData: z.object({ name: z.string() }), }, }); ``` `zodValidators` was removed in v5. Use `schema` instead, which accepts any Standard Schema compliant validator. If upgrading from v4, re-run `npx @proofkit/typegen` to regenerate clients with the new option. The client throws at runtime if `zodValidators` is passed. ### CRITICAL: Manually redefining TypeScript types instead of generated types Wrong: ```ts // Hand-writing types that duplicate your FM layout type Contact = { firstName: string; lastName: string; email: string; }; const client = DataApi<Contact>({ adapter: new OttoAdapter({ /* ... */ }), layout: "API_Contacts", }); ``` Correct: ```ts // Use typegen-generated client which includes schema + types import { ContactsLayout } from "./schema/client"; const { data } = await ContactsLayout.find({ query: { email: "==test@example.com" } }); ``` Manual types drift when FileMaker fields change, with no runtime protection. The typegen-generated client bundles a Standard Schema validator that catches field renames at runtime. Run `npx @proofkit/typegen` after any layout change. See `typegen-setup` skill for more details. ### HIGH: Mixing Zod v3 and v4 in the same project Wrong: ```ts import { z } from "zod"; // v3 import { z as z4 } from "zod/v4"; // v4 in another file // Both installed, schemas from different versions passed to DataApi ``` Correct: ```ts // Use one version consistently. v5 typegen generates zod/v4 imports. import { z } from "zod/v4"; ``` Zod v3 and v4 have different Standard Schema implementations. Mixing them causes subtle type mismatches and potential runtime validation failures. The typegen tool generates `zod/v4` imports by default. See `typegen-setup` skill for more details. ## References - **typegen-fmdapi** -- type generation and client scaffolding that produces the layout-specific clients referenced above. This is the recommended entry point for new projects. - **fmodata-client** -- alternative ORM-style client using the OData API (Drizzle-like query builder, different from the REST-based Data API covered here)