@proofkit/fmdapi 5.0.3-beta.0 → 5.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/bin/intent.js +20 -0
  2. package/dist/esm/adapters/core.d.ts +4 -4
  3. package/dist/esm/adapters/fetch-base-types.d.ts +4 -4
  4. package/dist/esm/adapters/fetch-base.d.ts +2 -2
  5. package/dist/esm/adapters/fetch-base.js +36 -49
  6. package/dist/esm/adapters/fetch-base.js.map +1 -1
  7. package/dist/esm/adapters/fetch.d.ts +5 -5
  8. package/dist/esm/adapters/fetch.js +11 -10
  9. package/dist/esm/adapters/fetch.js.map +1 -1
  10. package/dist/esm/adapters/fm-http.d.ts +32 -0
  11. package/dist/esm/adapters/fm-http.js +170 -0
  12. package/dist/esm/adapters/fm-http.js.map +1 -0
  13. package/dist/esm/adapters/otto.d.ts +2 -2
  14. package/dist/esm/adapters/otto.js +3 -5
  15. package/dist/esm/adapters/otto.js.map +1 -1
  16. package/dist/esm/client-types.d.ts +41 -41
  17. package/dist/esm/client-types.js +1 -6
  18. package/dist/esm/client-types.js.map +1 -1
  19. package/dist/esm/client.d.ts +28 -44
  20. package/dist/esm/client.js +75 -80
  21. package/dist/esm/client.js.map +1 -1
  22. package/dist/esm/index.d.ts +5 -6
  23. package/dist/esm/index.js +7 -5
  24. package/dist/esm/index.js.map +1 -1
  25. package/dist/esm/tokenStore/index.d.ts +1 -1
  26. package/dist/esm/tokenStore/memory.js.map +1 -1
  27. package/dist/esm/tokenStore/types.d.ts +2 -2
  28. package/dist/esm/tokenStore/upstash.d.ts +1 -1
  29. package/dist/esm/utils.d.ts +7 -7
  30. package/dist/esm/utils.js +6 -4
  31. package/dist/esm/utils.js.map +1 -1
  32. package/package.json +37 -26
  33. package/skills/fmdapi-client/SKILL.md +490 -0
  34. package/src/adapters/core.ts +6 -9
  35. package/src/adapters/fetch-base-types.ts +5 -3
  36. package/src/adapters/fetch-base.ts +53 -78
  37. package/src/adapters/fetch.ts +19 -24
  38. package/src/adapters/fm-http.ts +224 -0
  39. package/src/adapters/otto.ts +8 -8
  40. package/src/client-types.ts +59 -83
  41. package/src/client.ts +131 -167
  42. package/src/index.ts +5 -9
  43. package/src/tokenStore/file.ts +2 -4
  44. package/src/tokenStore/index.ts +1 -1
  45. package/src/tokenStore/types.ts +2 -2
  46. package/src/tokenStore/upstash.ts +2 -5
  47. package/src/utils.ts +16 -23
@@ -0,0 +1,490 @@
1
+ ---
2
+ name: fmdapi-client
3
+ description: >
4
+ DataApi factory, OttoAdapter (dk_ API key), FetchAdapter (username/password
5
+ with token stores), CRUD methods: list, listAll, find, findOne, findFirst,
6
+ maybeFindFirst, findAll, create, update, delete, get, executeScript,
7
+ containerUpload, Standard Schema validation, portal data access, FileMaker
8
+ Data API, layout-bound clients, schema inference
9
+ type: core
10
+ library: proofkit
11
+ library_version: "5.0.3-beta.1"
12
+ requires:
13
+ - typegen-setup
14
+ sources:
15
+ - "proofgeist/proofkit:packages/fmdapi/src/client.ts"
16
+ - "proofgeist/proofkit:packages/fmdapi/src/adapters/otto.ts"
17
+ - "proofgeist/proofkit:packages/fmdapi/src/adapters/fetch.ts"
18
+ - "proofgeist/proofkit:apps/docs/content/docs/fmdapi/*.mdx"
19
+ ---
20
+
21
+ ## Setup
22
+
23
+ Install the package:
24
+
25
+ ```bash
26
+ pnpm add @proofkit/fmdapi
27
+ ```
28
+
29
+ If you want the companion `typegen-setup` skill available locally in this project, also install:
30
+
31
+ ```bash
32
+ pnpm add -D @proofkit/typegen@*
33
+ ```
34
+
35
+ ### OttoAdapter (recommended)
36
+
37
+ Requires [OttoFMS](https://ottofms.com/) installed on the FileMaker Server. No token management needed — the proxy handles sessions.
38
+
39
+ ```ts
40
+ import { DataApi, OttoAdapter } from "@proofkit/fmdapi";
41
+
42
+ const client = DataApi({
43
+ adapter: new OttoAdapter({
44
+ auth: { apiKey: process.env.OTTO_API_KEY as `dk_${string}` },
45
+ db: process.env.FM_DATABASE,
46
+ server: process.env.FM_SERVER, // must start with https://
47
+ }),
48
+ layout: "API_Contacts",
49
+ });
50
+ ```
51
+
52
+ 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`).
53
+
54
+ ### FetchAdapter (direct Data API)
55
+
56
+ Authenticates with username/password. Manages Data API session tokens automatically.
57
+
58
+ ```ts
59
+ import { DataApi, FetchAdapter } from "@proofkit/fmdapi";
60
+
61
+ const client = DataApi({
62
+ adapter: new FetchAdapter({
63
+ auth: {
64
+ username: process.env.FM_USERNAME,
65
+ password: process.env.FM_PASSWORD,
66
+ },
67
+ db: process.env.FM_DATABASE,
68
+ server: process.env.FM_SERVER,
69
+ tokenStore: fileTokenStore(), // IMPORTANT for production — see Common Mistakes
70
+ }),
71
+ layout: "API_Contacts",
72
+ });
73
+ ```
74
+
75
+ ### With typegen-generated clients
76
+
77
+ 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:
78
+
79
+ ```ts
80
+ import { CustomersLayout } from "./schema/client";
81
+
82
+ const { data } = await CustomersLayout.findOne({
83
+ query: { id: "==abc123" },
84
+ });
85
+ // data.fieldData is fully typed with your FM field names
86
+ ```
87
+
88
+ ## Core Patterns
89
+
90
+ ### CRUD Operations
91
+
92
+ Every `DataApi` client is bound to a single layout. All methods operate on that layout.
93
+
94
+ **Find records:**
95
+
96
+ ```ts
97
+ // Standard find — returns { data: FMRecord[], dataInfo }
98
+ const response = await client.find({
99
+ query: { city: "Portland" },
100
+ });
101
+
102
+ // OR finds — pass an array of query objects
103
+ const response = await client.find({
104
+ query: [{ city: "Portland" }, { city: "Seattle" }],
105
+ });
106
+
107
+ // findOne — throws unless exactly 1 record found
108
+ const { data } = await client.findOne({
109
+ query: { email: "==user@example.com" },
110
+ });
111
+
112
+ // findFirst — returns first record, throws if none found
113
+ const { data } = await client.findFirst({
114
+ query: { status: "Active" },
115
+ });
116
+
117
+ // maybeFindFirst — returns first record or null
118
+ const result = await client.maybeFindFirst({
119
+ query: { email: "==user@example.com" },
120
+ });
121
+
122
+ // findAll — auto-paginates through all results (caution with large datasets)
123
+ const allRecords = await client.findAll({
124
+ query: { status: "==Active" },
125
+ });
126
+
127
+ // Suppress error on empty result set (FM error 401)
128
+ const response = await client.find({
129
+ query: { email: "==nonexistent@example.com" },
130
+ ignoreEmptyResult: true, // returns empty array instead of throwing
131
+ });
132
+ ```
133
+
134
+ **List records (no find criteria):**
135
+
136
+ ```ts
137
+ // list — returns up to 100 records by default
138
+ const response = await client.list({
139
+ sort: [{ fieldName: "lastName", sortOrder: "ascend" }],
140
+ limit: 50,
141
+ offset: 1,
142
+ });
143
+
144
+ // listAll — auto-paginates (caution with large datasets)
145
+ const allRecords = await client.listAll();
146
+ ```
147
+
148
+ **Create:**
149
+
150
+ ```ts
151
+ const { recordId, modId } = await client.create({
152
+ fieldData: {
153
+ firstName: "Jane",
154
+ lastName: "Doe",
155
+ email: "jane@example.com",
156
+ },
157
+ });
158
+ ```
159
+
160
+ **Update:**
161
+
162
+ ```ts
163
+ // recordId is FileMaker's internal record ID (from find/list/create responses)
164
+ await client.update({
165
+ recordId: 42,
166
+ fieldData: { email: "new@example.com" },
167
+ modId: 5, // optional optimistic locking
168
+ });
169
+ ```
170
+
171
+ **Delete:**
172
+
173
+ ```ts
174
+ await client.delete({ recordId: 42 });
175
+ ```
176
+
177
+ **Get by record ID:**
178
+
179
+ ```ts
180
+ const response = await client.get({ recordId: 42 });
181
+ ```
182
+
183
+ ### Script Execution
184
+
185
+ ```ts
186
+ // Direct execution
187
+ const result = await client.executeScript({
188
+ script: "Process Order",
189
+ scriptParam: JSON.stringify({ orderId: "12345" }),
190
+ });
191
+ console.log(result.scriptResult); // string returned by Exit Script
192
+
193
+ // Scripts attached to CRUD operations
194
+ const { recordId, scriptResult } = await client.create({
195
+ fieldData: { name: "New Record" },
196
+ script: "After Create Hook",
197
+ "script.param": JSON.stringify({ notify: true }),
198
+ // Also available: script.prerequest, script.presort (and their .param variants)
199
+ });
200
+ ```
201
+
202
+ ### Portal Data
203
+
204
+ Portal data is returned on every record in the `portalData` property. Each portal row includes its own `recordId` and `modId`.
205
+
206
+ ```ts
207
+ // Type-safe portal access with manual types
208
+ type TOrderRow = {
209
+ "Orders::orderId": string;
210
+ "Orders::orderDate": string;
211
+ "Orders::total": number;
212
+ };
213
+ type TPortals = {
214
+ portal_orders: TOrderRow; // key = portal object name on layout
215
+ };
216
+
217
+ const client = DataApi<TContact, TPortals>({
218
+ adapter: new OttoAdapter({ /* ... */ }),
219
+ layout: "API_Contacts",
220
+ });
221
+
222
+ const { data } = await client.find({ query: { id: "==123" } });
223
+ for (const row of data) {
224
+ for (const order of row.portalData.portal_orders) {
225
+ console.log(order["Orders::orderId"], order.recordId);
226
+ }
227
+ }
228
+
229
+ // Control portal pagination
230
+ const response = await client.list({
231
+ portalRanges: {
232
+ portal_orders: { offset: 1, limit: 10 },
233
+ },
234
+ });
235
+ ```
236
+
237
+ ### Container Upload
238
+
239
+ ```ts
240
+ const file = new Blob(["file contents"], { type: "text/plain" });
241
+
242
+ await client.containerUpload({
243
+ recordId: 42,
244
+ containerFieldName: "photo", // typed to field names if using schema
245
+ file,
246
+ containerFieldRepetition: 1, // optional, defaults to first repetition
247
+ });
248
+ ```
249
+
250
+ ### Schema Validation
251
+
252
+ > **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).
253
+
254
+ 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`).
255
+
256
+ ```ts
257
+ import { z } from "zod/v4";
258
+ import { DataApi, OttoAdapter } from "@proofkit/fmdapi";
259
+
260
+ const ZContact = z.object({
261
+ firstName: z.string(),
262
+ lastName: z.string(),
263
+ active: z.coerce.boolean(), // transform FM number to boolean
264
+ });
265
+
266
+ const client = DataApi({
267
+ adapter: new OttoAdapter({ /* ... */ }),
268
+ layout: "API_Contacts",
269
+ schema: {
270
+ fieldData: ZContact,
271
+ // portalData: { portal_orders: ZOrderRow }, // optional
272
+ },
273
+ });
274
+
275
+ // data.fieldData.active is now boolean, not number
276
+ const { data } = await client.findFirst({ query: { id: "==123" } });
277
+ ```
278
+
279
+ If validation fails, the client throws with details about which fields mismatched. This catches FileMaker field renames at runtime before they corrupt downstream logic.
280
+
281
+ ## Common Mistakes
282
+
283
+ ### CRITICAL: Creating DataApi without an adapter
284
+
285
+ Wrong:
286
+ ```ts
287
+ import { DataApi } from "@proofkit/fmdapi";
288
+
289
+ const client = DataApi({
290
+ layout: "Contacts",
291
+ server: "https://fm.example.com",
292
+ db: "MyDB.fmp12",
293
+ auth: { apiKey: "dk_abc123" },
294
+ });
295
+ ```
296
+
297
+ Correct:
298
+ ```ts
299
+ import { DataApi, OttoAdapter } from "@proofkit/fmdapi";
300
+
301
+ const client = DataApi({
302
+ adapter: new OttoAdapter({
303
+ server: "https://fm.example.com",
304
+ db: "MyDB.fmp12",
305
+ auth: { apiKey: "dk_abc123" as `dk_${string}` },
306
+ }),
307
+ layout: "Contacts",
308
+ });
309
+ ```
310
+
311
+ v5 requires an explicit adapter instance. Connection config (`server`, `db`, `auth`) goes on the adapter, not `DataApi`. `layout` goes on `DataApi`.
312
+
313
+ ### CRITICAL: Omitting token store in production (FetchAdapter)
314
+
315
+ Wrong:
316
+ ```ts
317
+ const client = DataApi({
318
+ adapter: new FetchAdapter({
319
+ auth: { username: "admin", password: "pass" },
320
+ db: "MyDB.fmp12",
321
+ server: "https://fm.example.com",
322
+ // no tokenStore — defaults to in-memory
323
+ }),
324
+ layout: "Contacts",
325
+ });
326
+ ```
327
+
328
+ Correct:
329
+ ```ts
330
+ import { fileTokenStore } from "@proofkit/fmdapi/tokenStore/file";
331
+ // or for serverless:
332
+ // import { upstashTokenStore } from "@proofkit/fmdapi/tokenStore/upstash";
333
+
334
+ const client = DataApi({
335
+ adapter: new FetchAdapter({
336
+ auth: { username: "admin", password: "pass" },
337
+ db: "MyDB.fmp12",
338
+ server: "https://fm.example.com",
339
+ tokenStore: fileTokenStore(),
340
+ }),
341
+ layout: "Contacts",
342
+ });
343
+ ```
344
+
345
+ 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.
346
+
347
+ ### HIGH: Storing FM recordId as a persistent identifier
348
+
349
+ Wrong:
350
+ ```ts
351
+ // Saving recordId to your own database as a foreign key
352
+ const { recordId } = await client.create({ fieldData: { name: "Acme" } });
353
+ await myDb.insert({ fmRecordId: recordId }); // fragile!
354
+ ```
355
+
356
+ Correct:
357
+ ```ts
358
+ // Use a stable primary key field (e.g., UUID) from FileMaker
359
+ const { data } = await client.findOne({ query: { name: "Acme" } });
360
+ const stableId = data.fieldData.primaryKey; // UUID set by auto-enter
361
+ ```
362
+
363
+ 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.
364
+
365
+ ### HIGH: Assuming dynamic layout switching on a single client
366
+
367
+ Wrong:
368
+ ```ts
369
+ const client = DataApi({
370
+ adapter: new OttoAdapter({ /* ... */ }),
371
+ layout: "Contacts",
372
+ });
373
+ // Trying to query a different layout
374
+ await client.find({ layout: "Invoices", query: { status: "Open" } });
375
+ ```
376
+
377
+ Correct:
378
+ ```ts
379
+ const contactsClient = DataApi({
380
+ adapter: new OttoAdapter({ /* ... */ }),
381
+ layout: "Contacts",
382
+ });
383
+ const invoicesClient = DataApi({
384
+ adapter: new OttoAdapter({ /* ... */ }),
385
+ layout: "Invoices",
386
+ });
387
+ ```
388
+
389
+ 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.
390
+
391
+ ### MEDIUM: Using wrong Otto API key format
392
+
393
+ Wrong:
394
+ ```ts
395
+ new OttoAdapter({
396
+ auth: { apiKey: "abc123-def456" }, // no prefix
397
+ db: "MyDB.fmp12",
398
+ server: "https://fm.example.com",
399
+ });
400
+ ```
401
+
402
+ Correct:
403
+ ```ts
404
+ new OttoAdapter({
405
+ auth: { apiKey: "dk_abc123def456" as `dk_${string}` },
406
+ db: "MyDB.fmp12",
407
+ server: "https://fm.example.com",
408
+ });
409
+ ```
410
+
411
+ 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.
412
+
413
+ ### HIGH: Using deprecated zodValidators option instead of schema
414
+
415
+ Wrong:
416
+ ```ts
417
+ import { z } from "zod";
418
+
419
+ const client = DataApi({
420
+ adapter: new OttoAdapter({ /* ... */ }),
421
+ layout: "Contacts",
422
+ zodValidators: {
423
+ fieldData: z.object({ name: z.string() }),
424
+ },
425
+ });
426
+ ```
427
+
428
+ Correct:
429
+ ```ts
430
+ import { z } from "zod/v4";
431
+
432
+ const client = DataApi({
433
+ adapter: new OttoAdapter({ /* ... */ }),
434
+ layout: "Contacts",
435
+ schema: {
436
+ fieldData: z.object({ name: z.string() }),
437
+ },
438
+ });
439
+ ```
440
+
441
+ `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.
442
+
443
+ ### CRITICAL: Manually redefining TypeScript types instead of generated types
444
+
445
+ Wrong:
446
+ ```ts
447
+ // Hand-writing types that duplicate your FM layout
448
+ type Contact = {
449
+ firstName: string;
450
+ lastName: string;
451
+ email: string;
452
+ };
453
+ const client = DataApi<Contact>({
454
+ adapter: new OttoAdapter({ /* ... */ }),
455
+ layout: "API_Contacts",
456
+ });
457
+ ```
458
+
459
+ Correct:
460
+ ```ts
461
+ // Use typegen-generated client which includes schema + types
462
+ import { ContactsLayout } from "./schema/client";
463
+
464
+ const { data } = await ContactsLayout.find({ query: { email: "==test@example.com" } });
465
+ ```
466
+
467
+ 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.
468
+
469
+ ### HIGH: Mixing Zod v3 and v4 in the same project
470
+
471
+ Wrong:
472
+ ```ts
473
+ import { z } from "zod"; // v3
474
+ import { z as z4 } from "zod/v4"; // v4 in another file
475
+
476
+ // Both installed, schemas from different versions passed to DataApi
477
+ ```
478
+
479
+ Correct:
480
+ ```ts
481
+ // Use one version consistently. v5 typegen generates zod/v4 imports.
482
+ import { z } from "zod/v4";
483
+ ```
484
+
485
+ 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.
486
+
487
+ ## References
488
+
489
+ - **typegen-setup** -- type generation and client scaffolding that produces the layout-specific clients referenced above
490
+ - **fmodata-client** -- alternative ORM-style client using the OData API (Drizzle-like query builder, different from the REST-based Data API covered here)
@@ -6,27 +6,26 @@ import type {
6
6
  FieldData,
7
7
  GetParams,
8
8
  GetResponse,
9
- ListParamsRaw,
10
9
  LayoutMetadataResponse,
10
+ ListParamsRaw,
11
11
  Query,
12
+ ScriptResponse,
12
13
  UpdateParams,
13
14
  UpdateResponse,
14
- ScriptParams,
15
- ScriptResponse,
16
15
  } from "../client-types.js";
17
16
 
18
- export type BaseRequest = {
17
+ export interface BaseRequest {
19
18
  layout: string;
20
19
  fetch?: RequestInit;
21
20
  timeout?: number;
22
- };
21
+ }
23
22
 
24
23
  export type ListOptions = BaseRequest & { data: ListParamsRaw };
25
24
  export type GetOptions = BaseRequest & {
26
25
  data: GetParams & { recordId: number };
27
26
  };
28
27
  export type FindOptions = BaseRequest & {
29
- data: ListParamsRaw & { query: Array<Query> };
28
+ data: ListParamsRaw & { query: Query[] };
30
29
  };
31
30
  export type CreateOptions = BaseRequest & {
32
31
  data: CreateParams & { fieldData: Partial<FieldData> };
@@ -61,9 +60,7 @@ export interface Adapter {
61
60
  delete: (opts: DeleteOptions) => Promise<DeleteResponse>;
62
61
  containerUpload: (opts: ContainerUploadOptions) => Promise<void>;
63
62
 
64
- layoutMetadata: (
65
- opts: LayoutMetadataOptions,
66
- ) => Promise<LayoutMetadataResponse>;
63
+ layoutMetadata: (opts: LayoutMetadataOptions) => Promise<LayoutMetadataResponse>;
67
64
 
68
65
  executeScript: (opts: ExecuteScriptOptions) => Promise<ScriptResponse>;
69
66
  }
@@ -1,5 +1,7 @@
1
- export type BaseFetchAdapterOptions = {
1
+ export interface BaseFetchAdapterOptions {
2
2
  server: string;
3
3
  db: string;
4
- };
5
- export type GetTokenArguments = { refresh?: boolean };
4
+ }
5
+ export interface GetTokenArguments {
6
+ refresh?: boolean;
7
+ }