packages/fmodata/skills/odata-query-optimization/SKILL.md
OData performance patterns for @proofkit/fmodata. Covers defaultSelect schema vs all, select() for minimal field fetching, select("all") override, pagination with top/skip, default 1000 record limit, batch operations for reducing round trips, entity IDs FMFID FMTID for rename resilience, null field query performance, getQueryString() debugging, relationship query performance testing, FileMaker OData optimization, avoiding OData service overload during testing.
npx skillsauth add proofgeist/proofkit odata-query-optimizationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill assumes a working fmodata client (see fmodata-client). Optimization starts at the schema level with defaultSelect and continues per-query with select().
import { fmTableOccurrence, textField, numberField } from "@proofkit/fmodata";
// "schema" (default) — only fetches fields defined in the schema via $select
const users = fmTableOccurrence("users", {
id: textField().primaryKey().entityId("FMFID:12039485"),
name: textField().notNull().entityId("FMFID:34323433"),
email: textField().entityId("FMFID:12232424"),
age: numberField().entityId("FMFID:43234355"),
}, {
defaultSelect: "schema", // default — adds $select with all schema fields
entityId: "FMTID:12432533",
});
// "all" — no $select applied, FileMaker returns every field on the layout
const usersAll = fmTableOccurrence("users", {
id: textField().primaryKey(),
name: textField().notNull(),
}, {
defaultSelect: "all", // slower: returns all layout fields, not just schema fields
});
// Override defaultSelect for a single query — fetch only what you need
const result = await db.from(users).list()
.select({ name: users.name, email: users.email })
.execute();
// Override defaultSelect:"schema" to fetch all fields for this query
const result = await db.from(users).list()
.select("all")
.execute();
Fewer fields = smaller response = faster queries. Always prefer explicit select() when you need a subset of fields.
// Fetch only 2 fields instead of all schema fields
const result = await db.from(users).list()
.select({ name: users.name, email: users.email })
.execute();
// Rename fields in the result object
const result = await db.from(users).list()
.select({ userName: users.name, userEmail: users.email })
.execute();
// result.data[0].userName, result.data[0].userEmail
// Exclude specific fields using destructuring + getTableColumns
import { getTableColumns } from "@proofkit/fmodata";
const { age, ...cols } = getTableColumns(users);
const result = await db.from(users).list().select(cols).execute();
list() applies .top(1000) by default. Override for smaller pages or to fetch more.
// Page 1: first 50 records
const page1 = await db.from(users).list().top(50).skip(0).execute();
// Page 2: next 50
const page2 = await db.from(users).list().top(50).skip(50).execute();
// Get total count (separate request, returns number)
const countResult = await db.from(users).list().count().execute();
// countResult.data === 1523
// Fetch more than 1000 (override default limit)
const allRecords = await db.from(users).list().top(5000).execute();
Combine multiple queries into a single HTTP request. Write operations are transactional.
const contactsQuery = db.from(contacts).list().top(5);
const usersQuery = db.from(users).list().top(5);
// Single HTTP request for both queries
const result = await db.batch([contactsQuery, usersQuery]).execute();
const [contactsResult, usersResult] = result.results;
if (contactsResult.data) { /* ... */ }
if (usersResult.data) { /* ... */ }
// Mix reads and writes — writes are atomic
const result = await db.batch([
db.from(contacts).list().top(10),
db.from(contacts).insert({ name: "Alice", email: "[email protected]" }),
db.from(users).update({ active: true }).byId("user-123"),
]).execute();
Entity IDs (FMTID/FMFID) prevent breakage when FileMaker fields or table occurrences are renamed.
IMPORTANT: Entity IDs must come from FileMaker metadata via
@proofkit/typegen. Do NOT invent FMFID/FMTID values — the IDs shown below are illustrative only. Guessed IDs cause silent query failures.
// Entity IDs are generated by @proofkit/typegen — do not manually add or change
const users = fmTableOccurrence("users", {
id: textField().primaryKey().entityId("FMFID:12039485"),
name: textField().notNull().entityId("FMFID:34323433"),
}, {
entityId: "FMTID:12432533",
});
// Enable at database level
const db = connection.database("MyDatabase", { useEntityIds: true });
// Or per-request
const result = await db.from(users).list().execute({ useEntityIds: true });
Inspect the generated OData URL without executing the request.
import { eq, asc } from "@proofkit/fmodata";
const queryString = db.from(users).list()
.select({ name: users.name, email: users.email })
.where(eq(users.active, true))
.orderBy(asc(users.name))
.top(10)
.getQueryString();
console.log(queryString);
// "/users?$select=name,email&$filter=active eq true&$orderby=name&$top=10"
// View with entity IDs resolved to see what's actually sent
const entityIdQuery = db.from(users).list()
.getQueryString({ useEntityIds: false });
Wrong:
// Expects all 5000 records but only gets 1000
const result = await db.from(users).list().execute();
Correct:
// Explicitly set top() to fetch more than 1000
const result = await db.from(users).list().top(5000).execute();
// Or paginate through results
let allRecords = [];
let skip = 0;
const pageSize = 1000;
while (true) {
const page = await db.from(users).list().top(pageSize).skip(skip).execute();
if (!page.data || page.data.length === 0) break;
allRecords.push(...page.data);
if (page.data.length < pageSize) break;
skip += pageSize;
}
list() internally calls .top(1000) as a safety limit. If you need more records, override with an explicit .top() or paginate.
Source: packages/fmodata/src/client/entity-set.ts (line 185), packages/fmodata/src/client/query/query-builder.ts (line 50)
Wrong:
const db = connection.database("MyDatabase", { includeSpecialColumns: true });
// Expects ROWID and ROWMODID but they are silently excluded
const result = await db.from(users).list()
.select({ name: users.name })
.execute();
Correct:
// Pass system columns as second argument to select()
const result = await db.from(users).list()
.select(
{ name: users.name },
{ ROWID: true, ROWMODID: true }
)
.execute();
// Or don't use select() — special columns appear when no $select is applied
const result = await db.from(users).list().execute();
Per OData spec, special columns are only included when no $select query parameter is applied. Using .select() generates a $select, so special columns must be explicitly requested via the second argument.
Source: apps/docs/content/docs/fmodata/extra-properties.mdx
Wrong:
// Table has no entityId or fmfIds configured
const users = fmTableOccurrence("users", {
id: textField().primaryKey(),
name: textField(),
});
// Throws immediately — no FMTID/FMFID available
const result = await db.from(users).list().execute({ useEntityIds: true });
Correct:
// Configure entity IDs on the schema
const users = fmTableOccurrence("users", {
id: textField().primaryKey().entityId("FMFID:12039485"),
name: textField().entityId("FMFID:34323433"),
}, {
entityId: "FMTID:12432533",
});
// Now useEntityIds works
const result = await db.from(users).list().execute({ useEntityIds: true });
Entity IDs require both a table-level entityId (FMTID) and per-field .entityId() (FMFID) to be set. These must be generated by @proofkit/typegen from FileMaker metadata — do not manually add or invent entity IDs.
Source: apps/docs/content/docs/fmodata/entity-ids.mdx, packages/fmodata/src/orm/table.ts
Wrong:
import { isNull } from "@proofkit/fmodata";
// Extremely slow on large datasets — FileMaker OData handles null checks poorly
const result = await db.from(users).list()
.where(isNull(users.deletedAt))
.execute();
Correct:
import { eq } from "@proofkit/fmodata";
// Use a calc field in FileMaker that returns 1 when deletedAt is empty
// e.g., IsEmpty(deletedAt) calc field → isActive
const result = await db.from(users).list()
.where(eq(users.isActive, 1))
.execute();
FileMaker's OData implementation has severe performance issues when filtering on null/empty fields. Create a calculation field in FileMaker that evaluates the null condition and filter on that instead.
Source: FileMaker OData performance testing (known limitation)
Wrong:
// Rapid-fire queries in a loop without any throttling
for (const id of userIds) {
const result = await db.from(users).get(id).execute();
// ... process
}
Correct:
// Use batch operations to reduce round trips
const queries = userIds.map(id => db.from(users).get(id));
const result = await db.batch(queries).execute();
// Or paginate with reasonable page sizes and delays between pages
for (let skip = 0; skip < total; skip += 100) {
const result = await db.from(users).list().top(100).skip(skip).execute();
// ... process page
await new Promise(resolve => setTimeout(resolve, 100)); // throttle
}
Rapid sequential queries can degrade the FileMaker OData service. Use batch operations to combine multiple requests, and add throttling when iterating through large datasets during testing or migration.
Source: FileMaker OData service behavior (known limitation)
Wrong:
// Assumes expand performance is predictable
const result = await db.from(invoices).list()
.expand(lineItems)
.top(500)
.execute();
Correct:
// Test with small datasets first and measure performance
const test = await db.from(invoices).list()
.expand(lineItems)
.top(10)
.execute();
// Measure timing, then scale up cautiously
// Debug the generated query
const queryString = db.from(invoices).list()
.expand(lineItems)
.top(10)
.getQueryString();
console.log(queryString);
// Consider fetching related records separately if expand is too slow
const invoiceResult = await db.from(invoices).list().top(100).execute();
const lineItemResult = await db.from(lineItems).list()
.where(inArray(lineItems.invoiceId, invoiceIds))
.execute();
Relationship query performance via expand() is unpredictable in FileMaker's OData implementation. Always test with production-sized data. If performance is poor, fetch related records in separate queries or use batch operations.
Source: FileMaker OData relationship performance (must be tested case-by-case)
development
FileMaker WebDirect ProofKit Web Viewer runtime behavior refresh resilience session state localStorage browser resize reload same deployment embedded bundle avoid separate deployment avoid separate web server @proofkit/webviewer fmFetch callFMScript WebViewerAdapter WebDirect page refresh
development
webviewer fmFetch callFMScript WebViewerAdapter globalSettings setWebViewerName SendCallback window.FileMaker browser-only FileMaker Web Viewer script execution fire-and-forget FMScriptOption PerformScript callback fetchId handleFmWVFetchCallback
development
ENTRY POINT for @proofkit/fmodata projects. Generate TypeScript table schemas with entity IDs from FileMaker OData metadata using @proofkit/typegen. Covers proofkit-typegen-config.jsonc for OData mode, npx @proofkit/typegen setup, fmTableOccurrence generation, entity IDs (FMFID/FMTID), generated output structure, field exclusion, type overrides, InferTableSchema, env var configuration, OData prerequisites, fmodata privilege, and why typegen is required for entity ID correctness.
tools
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")