UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

496 lines (392 loc) 15.5 kB
--- name: fmodata-client description: > fmodata OData FMServerConnection fmTableOccurrence field builders textField numberField dateField timestampField containerField calcField listField query builder execute() filter operators eq ne gt gte lt lte contains startsWith endsWith matchesPattern inArray notInArray isNull isNotNull and or not tolower toupper trim CRUD insert update delete byId where navigate expand relationships batch Result error handling Effect.ts pattern FMODataError HTTPError ODataError ValidationError BatchTruncatedError entity IDs FMTID FMFID defaultSelect readValidator writeValidator orderBy asc desc top skip single maybeSingle count getSingleField FileMaker OData API schema management webhooks getTableColumns select("all") metadata: type: core library: proofkit library_version: "0.1.2" requires: - typegen-fmodata sources: - "proofsh/proofkit:packages/fmodata/src/**/*.ts" - "proofsh/proofkit:apps/docs/content/docs/fmodata/*.mdx" --- ## Setup > **Start with typegen.** Before writing any fmodata code, use `npx @proofkit/typegen@beta` to generate table schemas with correct entity IDs from FileMaker metadata. See the **typegen-fmodata** skill. Schema files (fields, entity IDs) MUST come from typegendo NOT manually add fields or change entity IDs (`FMFID`/`FMTID`), as guessing them causes silent failures. You MAY add/edit `readValidator`, `writeValidator`, `defaultSelect`, `navigationPaths`, and other options on existing fields. ### 1. Create a server connection ```ts import { FMServerConnection } from "@proofkit/fmodata"; const connection = new FMServerConnection({ serverUrl: "https://your-server.com", auth: { username: "admin", password: "secret" }, // OR with OttoFMS API key: // auth: { apiKey: "your-otto-api-key" }, // OR with Claris ID for FileMaker Cloud: // auth: { clarisId: { username: "you@claris-id.com", password: "secret" } }, fetchClientOptions: { retries: 2, timeout: 30000, }, }); ``` For FileMaker Cloud, use a Claris ID account, not an external IdP account. MFA-backed Claris ID accounts are not supported. ### 2. Create a database reference ```ts const db = connection.database("MyDatabase.fmp12", { useEntityIds: true, // use FMTID/FMFID instead of names includeSpecialColumns: false, // include ROWID/ROWMODID }); ``` ### 3. Define table schemas with fmTableOccurrence > **IMPORTANT:** Schema files — including field definitions and entity IDs — are generated by `@proofkit/typegen`. The entity IDs shown below are illustrative only. Do NOT invent `FMFID`/`FMTID` values; they must come from FileMaker metadata via typegen. You may customize generated schemas by adding `readValidator`, `writeValidator`, `defaultSelect`, `navigationPaths`, and other options. ```ts import { fmTableOccurrence, textField, numberField, timestampField, containerField, calcField, listField, } from "@proofkit/fmodata"; import { z } from "zod/v4"; // Generated by @proofkit/typegen — do not manually add fields or change entity IDs const contacts = fmTableOccurrence( "contacts", { id: textField().primaryKey().entityId("FMFID:1"), name: textField().notNull().entityId("FMFID:2"), email: textField().notNull().entityId("FMFID:3"), phone: textField().entityId("FMFID:4"), age: numberField().entityId("FMFID:5"), // readValidator/writeValidator are safe to add or edit manually active: numberField() .readValidator(z.coerce.boolean()) .writeValidator(z.boolean().transform((v) => (v ? 1 : 0))) .entityId("FMFID:6"), tags: listField({ itemValidator: z.string() }), photo: containerField(), fullName: calcField(), createdAt: timestampField().readOnly().entityId("FMFID:10"), }, { entityId: "FMTID:100", // defaultSelect and navigationPaths are safe to add or edit manually defaultSelect: "schema", navigationPaths: ["invoices", "notes"], }, ); ``` Field builders: `textField()`, `numberField()`, `dateField()`, `timeField()`, `timestampField()`, `containerField()`, `calcField()`, `listField()`. Chainable methods: `.primaryKey()`, `.notNull()`, `.readOnly()`, `.entityId("FMFID:...")`, `.readValidator(schema)`, `.writeValidator(schema)`, `.comment("...")`. `defaultSelect` options: - `"schema"` (default) -- always sends `$select` with only schema-defined fields - `"all"` -- no `$select`, returns all non-container fields from FM - `(cols) => ({ ... })` -- custom subset of columns ## Core Patterns ### Querying (list / get / select / where / execute) ```ts import { eq, gt, and, contains, asc, desc, getTableColumns } from "@proofkit/fmodata"; // List with filter, sort, pagination const result = await db .from(contacts) .list() .select({ name: contacts.name, email: contacts.email }) .where(and(eq(contacts.active, true), gt(contacts.age, 18))) .orderBy(asc(contacts.name), desc(contacts.age)) .top(50) .skip(0) .execute(); if (result.data) { for (const row of result.data) { console.log(row.name, row.email); } } // Get single record by ID const one = await db.from(contacts).get("abc-123").execute(); // Get single record by FileMaker ROWID const byRowId = await db.from(contacts).get({ ROWID: 2 }).execute(); // single() -- error if != 1 result; maybeSingle() -- null if 0, error if > 1 const exact = await db .from(contacts) .list() .where(eq(contacts.email, "a@b.com")) .single() .execute(); // Count const count = await db.from(contacts).list().count().execute(); // Override defaultSelect for one query const all = await db.from(contacts).list().select("all").execute(); // Get single field (required for container fields) const photo = await db .from(contacts) .get("abc-123") .getSingleField(contacts.photo) .execute(); // Select all columns except some const { photo: _, ...cols } = getTableColumns(contacts); const withoutPhoto = await db.from(contacts).list().select(cols).execute(); ``` ### CRUD (insert / update / delete) ```ts // Insert -- notNull fields are required, readOnly/primaryKey excluded const inserted = await db .from(contacts) .insert({ name: "Alice", email: "alice@co.com", active: true }) .execute(); // Update by ID (default: returns { updatedCount }) const updated = await db .from(contacts) .update({ phone: "+1-555-0100" }) .byId("abc-123") .execute(); // Update by ROWID const updatedByRowId = await db .from(contacts) .update({ phone: "+1-555-0100" }) .byRowId(2) .execute(); // Update by filter const bulk = await db .from(contacts) .update({ active: false }) .where((q) => q.where(eq(contacts.active, true))) .execute(); // Delete by ID const deleted = await db.from(contacts).delete().byId("abc-123").execute(); // Delete by ROWID const deletedByRowId = await db.from(contacts).delete().byRowId(2).execute(); // Delete by filter const bulkDel = await db .from(contacts) .delete() .where((q) => q.where(eq(contacts.active, false))) .execute(); ``` ### Error handling (Result pattern) Every `.execute()` returns `Result<T>`: ```ts type Result<T> = { data: T; error: undefined } | { data: undefined; error: FMODataErrorType }; ``` Always check `result.error` before accessing `result.data`. Use type guards or `instanceof`: ```ts import { isHTTPError, isODataError, isValidationError, isBatchTruncatedError } from "@proofkit/fmodata"; const result = await db.from(contacts).list().execute(); if (result.error) { if (isHTTPError(result.error)) { console.log(result.error.status, result.error.statusText); } else if (isODataError(result.error)) { console.log(result.error.code, result.error.details); } else if (isValidationError(result.error)) { console.log(result.error.field, result.error.issues); } return; } // result.data is guaranteed non-undefined here console.log(result.data); ``` ### Relationships (navigate / expand) Define `navigationPaths` on table occurrences, then use `navigate()` or `expand()`. ```ts // navigate -- changes query context to related table const orders = await db .from(contacts) .get("abc-123") .navigate(invoices) .execute(); // Navigate starting from a ROWID-located record const ordersByRowId = await db .from(contacts) .get({ ROWID: 2 }) .navigate(invoices) .execute(); // expand -- includes related records inline const withInvoices = await db .from(contacts) .list() .expand(invoices, (b) => b .select({ total: invoices.total, date: invoices.date }) .where(gt(invoices.total, 100)) .top(5), ) .execute(); // Nested expand const nested = await db .from(contacts) .list() .expand(invoices, (ib) => ib.expand(lineItems, (lb) => lb.select({ desc: lineItems.description })), ) .execute(); ``` ### Batch operations ```ts const result = await db .batch([ db.from(contacts).list().top(5), db.from(contacts).insert({ name: "New", email: "new@co.com" }), db.from(contacts).update({ active: false }).byId("old-id"), ]) .execute(); const [r1, r2, r3] = result.results; console.log(result.successCount, result.errorCount, result.truncated); ``` ## Common Mistakes ### [CRITICAL] Forgetting .execute() on query builders Wrong: ```ts const data = await db.from(contacts).list().where(eq(contacts.active, true)); ``` Correct: ```ts const result = await db.from(contacts).list().where(eq(contacts.active, true)).execute(); ``` Query builders are lazy; they return a builder object, not data. `.execute()` triggers the HTTP request and returns `Result<T>`. Source: `packages/fmodata/src/client/query/query-builder.ts` ### [CRITICAL] Ignoring Result error -- data is null when error present Wrong: ```ts const result = await db.from(contacts).list().execute(); console.log(result.data.length); ``` Correct: ```ts const result = await db.from(contacts).list().execute(); if (result.error) { console.error(result.error.message); return; } console.log(result.data.length); ``` `Result` is a discriminated union. When `error` is defined, `data` is `undefined`. Accessing `.data` without checking `error` causes runtime TypeError. Source: `packages/fmodata/src/types.ts` ### [CRITICAL] Using Drizzle ORM patterns instead of fmodata patterns Wrong: ```ts import { eq } from "drizzle-orm"; const rows = await db.select().from(contacts).where(eq(contacts.name, "Alice")); ``` Correct: ```ts import { eq } from "@proofkit/fmodata"; const result = await db.from(contacts).list().where(eq(contacts.name, "Alice")).select({ name: contacts.name }).execute(); ``` fmodata has a different chain order: `db.from(table).list().where().select().execute()`. Operators must be imported from `@proofkit/fmodata`, not `drizzle-orm`. fmodata uses `list()` not `select()` to start a query, and always ends with `.execute()`. Source: `packages/fmodata/src/client/entity-set.ts` ### [CRITICAL] Using raw JS operators instead of OData filter functions Wrong: ```ts .where(contacts.name === "Alice") .where(contacts.age > 18) ``` Correct: ```ts import { eq, gt } from "@proofkit/fmodata"; .where(eq(contacts.name, "Alice")) .where(gt(contacts.age, 18)) ``` JavaScript comparison operators return booleans at build time, not filter expressions. Use the imported operator functions which produce OData `$filter` query strings. Source: `packages/fmodata/src/orm/operators.ts` ### [HIGH] Container fields in select() calls Wrong: ```ts const result = await db .from(contacts) .list() .select({ photo: contacts.photo, name: contacts.name }) .execute(); ``` Correct: ```ts // Get container field separately const photo = await db .from(contacts) .get("abc-123") .getSingleField(contacts.photo) .execute(); ``` Container fields (Edm.Stream) cannot be included in `$select`. The FileMaker OData API requires fetching them individually via `.getSingleField()`. TypeScript will show a compile error if you try to select a container field. Source: `packages/fmodata/src/orm/table.ts` (ValidateNoContainerFields type) ### [HIGH] Expecting batch to continue after first error Wrong: ```ts const result = await db.batch([op1, op2, op3]).execute(); // Assuming all three ran regardless of errors const allData = result.results.map((r) => r.data); ``` Correct: ```ts const result = await db.batch([op1, op2, op3]).execute(); if (result.truncated) { console.warn(`Stopped at index ${result.firstErrorIndex}`); } for (const r of result.results) { if (isBatchTruncatedError(r.error)) { console.log(`Op ${r.error.operationIndex} never ran`); } } ``` FileMaker stops batch processing on first error. Subsequent operations get `BatchTruncatedError` with `status: 0`. Check `result.truncated` and handle each result individually. Source: `packages/fmodata/src/errors.ts` (BatchTruncatedError) ### [MEDIUM] Script names with special characters via OData Wrong: ```ts await db.runScript("My Script (v2)"); ``` Correct: ```ts await db.runScript("MyScript_v2"); ``` OData script endpoint uses `Script.{name}` URL pattern. Spaces, parentheses, and special characters can cause URL encoding issues. Prefer alphanumeric + underscore names for scripts called via OData. Source: `packages/fmodata/src/client/database.ts` (runScript) ### [MEDIUM] Using defaultSelect "all" without understanding performance Wrong: ```ts const contacts = fmTableOccurrence("contacts", { /* 50 fields */ }, { defaultSelect: "all", }); // Every query fetches all 50+ fields from FM ``` Correct: ```ts const contacts = fmTableOccurrence("contacts", { /* 50 fields */ }, { defaultSelect: "schema", // default -- only fetches defined fields }); // Or override per-query: const result = await db.from(contacts).list().select("all").execute(); ``` `defaultSelect: "all"` removes `$select` from every query, causing FileMaker to return all non-container fields. This is slower for tables with many fields. Use `"schema"` (default) and override with `.select("all")` per-query when needed. Source: `packages/fmodata/src/client/entity-set.ts` ### [CRITICAL] Manually adding fields or entity IDs to schema files Wrong: ```ts // Agent adds a field it thinks exists in FileMaker const contacts = fmTableOccurrence("contacts", { ...existingFields, newField: textField().entityId("FMFID:99"), // guessed ID — will silently fail }); ``` Correct: ```bash # Re-run typegen to pick up new fields from FileMaker npx @proofkit/typegen ``` Field definitions and entity IDs (`FMFID`/`FMTID`) must come from FileMaker metadata via `@proofkit/typegen`. Guessed entity IDs cause silent query failures (wrong data or empty results). You may safely add `readValidator`, `writeValidator`, `defaultSelect`, `navigationPaths`, and other options to existing fields. Source: `packages/typegen/src/fmodata/typegen.ts` ### [CRITICAL] Manually redefining types instead of using generated/inferred types fmodata infers all types from `fmTableOccurrence` definitions. Use `InferTableSchema<typeof table>` if you need an explicit type alias. See `typegen-fmodata` skill for details. ### [HIGH] Mixing Zod v3 and v4 in the same project Use one Zod version consistently (v4 recommended). See `typegen-fmodata` skill for details. ## References - [Filter Operators Reference](references/filter-operators.md) - [Error Types Reference](references/error-types.md) - Cross-ref: `typegen-fmodata` -- generate schemas from FileMaker layouts. This is the recommended entry point for new projects.