@proofkit/fmodata 0.1.0-alpha.0 → 0.1.0-alpha.10

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 (75) hide show
  1. package/README.md +1624 -18
  2. package/dist/esm/client/base-table.d.ts +117 -5
  3. package/dist/esm/client/base-table.js +43 -5
  4. package/dist/esm/client/base-table.js.map +1 -1
  5. package/dist/esm/client/batch-builder.d.ts +54 -0
  6. package/dist/esm/client/batch-builder.js +179 -0
  7. package/dist/esm/client/batch-builder.js.map +1 -0
  8. package/dist/esm/client/batch-request.d.ts +61 -0
  9. package/dist/esm/client/batch-request.js +252 -0
  10. package/dist/esm/client/batch-request.js.map +1 -0
  11. package/dist/esm/client/database.d.ts +55 -6
  12. package/dist/esm/client/database.js +118 -15
  13. package/dist/esm/client/database.js.map +1 -1
  14. package/dist/esm/client/delete-builder.d.ts +21 -2
  15. package/dist/esm/client/delete-builder.js +96 -32
  16. package/dist/esm/client/delete-builder.js.map +1 -1
  17. package/dist/esm/client/entity-set.d.ts +25 -11
  18. package/dist/esm/client/entity-set.js +31 -11
  19. package/dist/esm/client/entity-set.js.map +1 -1
  20. package/dist/esm/client/filemaker-odata.d.ts +23 -4
  21. package/dist/esm/client/filemaker-odata.js +124 -29
  22. package/dist/esm/client/filemaker-odata.js.map +1 -1
  23. package/dist/esm/client/insert-builder.d.ts +38 -3
  24. package/dist/esm/client/insert-builder.js +231 -34
  25. package/dist/esm/client/insert-builder.js.map +1 -1
  26. package/dist/esm/client/query-builder.d.ts +27 -6
  27. package/dist/esm/client/query-builder.js +457 -210
  28. package/dist/esm/client/query-builder.js.map +1 -1
  29. package/dist/esm/client/record-builder.d.ts +96 -9
  30. package/dist/esm/client/record-builder.js +378 -39
  31. package/dist/esm/client/record-builder.js.map +1 -1
  32. package/dist/esm/client/response-processor.d.ts +38 -0
  33. package/dist/esm/client/schema-manager.d.ts +57 -0
  34. package/dist/esm/client/schema-manager.js +132 -0
  35. package/dist/esm/client/schema-manager.js.map +1 -0
  36. package/dist/esm/client/table-occurrence.d.ts +48 -1
  37. package/dist/esm/client/table-occurrence.js +29 -2
  38. package/dist/esm/client/table-occurrence.js.map +1 -1
  39. package/dist/esm/client/update-builder.d.ts +34 -11
  40. package/dist/esm/client/update-builder.js +135 -31
  41. package/dist/esm/client/update-builder.js.map +1 -1
  42. package/dist/esm/errors.d.ts +73 -0
  43. package/dist/esm/errors.js +148 -0
  44. package/dist/esm/errors.js.map +1 -0
  45. package/dist/esm/index.d.ts +10 -3
  46. package/dist/esm/index.js +28 -5
  47. package/dist/esm/index.js.map +1 -1
  48. package/dist/esm/transform.d.ts +65 -0
  49. package/dist/esm/transform.js +114 -0
  50. package/dist/esm/transform.js.map +1 -0
  51. package/dist/esm/types.d.ts +89 -5
  52. package/dist/esm/validation.d.ts +6 -3
  53. package/dist/esm/validation.js +104 -33
  54. package/dist/esm/validation.js.map +1 -1
  55. package/package.json +10 -1
  56. package/src/client/base-table.ts +158 -8
  57. package/src/client/batch-builder.ts +265 -0
  58. package/src/client/batch-request.ts +485 -0
  59. package/src/client/database.ts +175 -18
  60. package/src/client/delete-builder.ts +149 -48
  61. package/src/client/entity-set.ts +114 -23
  62. package/src/client/filemaker-odata.ts +179 -35
  63. package/src/client/insert-builder.ts +350 -40
  64. package/src/client/query-builder.ts +616 -237
  65. package/src/client/query-builder.ts.bak +1457 -0
  66. package/src/client/record-builder.ts +692 -65
  67. package/src/client/response-processor.ts +103 -0
  68. package/src/client/schema-manager.ts +246 -0
  69. package/src/client/table-occurrence.ts +78 -3
  70. package/src/client/update-builder.ts +235 -49
  71. package/src/errors.ts +217 -0
  72. package/src/index.ts +59 -2
  73. package/src/transform.ts +249 -0
  74. package/src/types.ts +201 -35
  75. package/src/validation.ts +120 -36
package/README.md CHANGED
@@ -1,37 +1,1643 @@
1
- # @proofkit/fmodata
1
+ # @proofkit/fmodata Documentation
2
2
 
3
- FileMaker OData API client
3
+ A strongly-typed FileMaker OData API client.
4
+
5
+ ⚠️ WARNING: This library is in "alpha" status. It's still in active development and the API is subject to change. Feedback is welcome on the [community forum](https://community.ottomatic.cloud/c/proofkit/13) or on [GitHub](https://github.com/proofgeist/proofkit/issues).
6
+
7
+ Roadmap:
8
+
9
+ - [ ] Crossjoin support
10
+ - [x] Batch operations
11
+ - [ ] Automatically chunk requests into smaller batches (e.g. max 512 inserts per batch)
12
+ - [x] Schema updates (add/update tables and fields)
13
+ - [ ] Proper docs at proofkit.dev
14
+ - [ ] @proofkit/typegen integration
4
15
 
5
16
  ## Installation
6
17
 
7
18
  ```bash
8
- pnpm add @proofkit/fmodata
19
+ pnpm add @proofkit/fmodata@alpha
9
20
  ```
10
21
 
11
- ## Usage
22
+ ## Quick Start
23
+
24
+ Here's a minimal example to get you started:
12
25
 
13
26
  ```typescript
14
- import { createODataClient } from "@proofkit/fmodata";
27
+ import {
28
+ FMServerConnection,
29
+ defineBaseTable,
30
+ defineTableOccurrence,
31
+ } from "@proofkit/fmodata";
32
+ import { z } from "zod/v4";
33
+
34
+ // 1. Create a connection to the server
35
+ const connection = new FMServerConnection({
36
+ serverUrl: "https://your-server.com",
37
+ auth: {
38
+ // OttoFMS API key
39
+ apiKey: "your-api-key",
40
+
41
+ // or username and password
42
+ // username: "admin",
43
+ // password: "password",
44
+ },
45
+ });
46
+
47
+ // 2. Define your table schema
48
+ const usersBase = defineBaseTable({
49
+ schema: {
50
+ id: z.string(),
51
+ username: z.string(),
52
+ email: z.string(),
53
+ active: z.boolean(),
54
+ },
55
+ idField: "id",
56
+ });
57
+
58
+ // 3. Create a table occurrence
59
+ const usersTO = defineTableOccurrence({
60
+ name: "users",
61
+ baseTable: usersBase,
62
+ });
15
63
 
16
- // Usage example coming soon
64
+ // 4. Create a database instance
65
+ const db = connection.database("MyDatabase.fmp12", {
66
+ occurrences: [usersTO],
67
+ });
68
+
69
+ // 5. Query your data
70
+ const { data, error } = await db.from("users").list().execute();
71
+
72
+ if (error) {
73
+ console.error(error);
74
+ return;
75
+ }
76
+
77
+ if (data) {
78
+ console.log(data); // Array of users, properly typed
79
+ }
17
80
  ```
18
81
 
19
- ## Development
82
+ ## Core Concepts
20
83
 
21
- ```bash
22
- # Install dependencies
23
- pnpm install
84
+ This library relies heavily on the builder pattern for defining your queries and operations. Most operations require a final call to `execute()` to send the request to the server. The builder pattern allows you to build complex queries and also supports batch operations, allowing you to execute multiple operations in a single request as supported by the FileMaker OData API. It's also helpful for testing the library, as you can call `getQueryString()` to get the OData query string without executing the request.
85
+
86
+ As such, there are layers to the library to help you build your queries and operations.
87
+
88
+ - `FMServerConnection` - hold server connection details and authentication
89
+ - `BaseTable` - defines the fields and validators for a base table
90
+ - `TableOccurrence` - references a base table, and other table occurrences for navigation
91
+ - `Database` - connects the table occurrences to the server connection
92
+
93
+ ### FileMaker Server prerequisites
94
+
95
+ To use this library you need:
96
+
97
+ - OData service enabled on your FileMaker server
98
+ - A FileMaker account with `fmodata` privilege enabled
99
+ - (if using OttoFMS) a Data API key setup for your FileMaker account with OData enabled
100
+
101
+ A note on best practices:
102
+
103
+ OData relies entirely on the table occurances in the relationship graph for data access. Relationships between table occurrences are also used, but maybe not as you expect (in short, only the simplest relationships are supported). Given these constraints, it may be best for you to have a seperate FileMaker file for your OData connection, using external data sources to link to your actual data. We've found this especially helpful for larger projects that have very large graphs with lots of duplicated table occurances compared to actual base tables.
104
+
105
+ ### Server Connection
106
+
107
+ The client can authenticate using username/password or API key:
108
+
109
+ ```typescript
110
+ // Username and password authentication
111
+ const connection = new FMServerConnection({
112
+ serverUrl: "https://api.example.com",
113
+ auth: {
114
+ username: "test",
115
+ password: "test",
116
+ },
117
+ });
118
+
119
+ // API key authentication
120
+ const connection = new FMServerConnection({
121
+ serverUrl: "https://api.example.com",
122
+ auth: {
123
+ apiKey: "your-api-key",
124
+ },
125
+ });
126
+ ```
127
+
128
+ ### Schema Definitions
129
+
130
+ This library relies on a schema-first approach for good type-safety and optional runtime validation. These are abstracted into BaseTable and TableOccurrence classes to match FileMaker concepts.
131
+
132
+ **Helper Functions vs Constructors:**
133
+
134
+ - **`defineBaseTable()`** and **`defineTableOccurrence()`** - Recommended for better type inference, especially when using entity IDs (FMFID/FMTID). These functions provide improved TypeScript type inference for field names in queries.
135
+
136
+ - **`new BaseTable()`** and **`new TableOccurrence()`** - Still supported for backward compatibility, but may have slightly less precise type inference in some cases.
137
+
138
+ A `BaseTable` defines the schema for your FileMaker table using Standard Schema. These examples show zod, but you can use any other validation library that supports Standard Schema.
139
+
140
+ ```typescript
141
+ import { z } from "zod/v4";
142
+ import { defineBaseTable } from "@proofkit/fmodata";
143
+
144
+ const contactsBase = defineBaseTable({
145
+ schema: {
146
+ id: z.string(),
147
+ name: z.string(),
148
+ email: z.string(),
149
+ phone: z.string().optional(),
150
+ createdAt: z.string(),
151
+ },
152
+ idField: "id", // The primary key field (automatically read-only)
153
+ required: ["phone"], // optional: additional required fields for insert (beyond auto-inferred)
154
+ readOnly: ["createdAt"], // optional: fields excluded from insert/update
155
+ });
156
+ ```
157
+
158
+ A `TableOccurrence` is the actual entry point for the OData service on the FileMaker server. It's where you can define the relations between tables and also allows you to reference the same base table multiple times with different names.
159
+
160
+ **Recommended:** Use `defineTableOccurrence()` for better type inference. You can also use `new TableOccurrence()` directly.
161
+
162
+ ```typescript
163
+ import { defineTableOccurrence } from "@proofkit/fmodata";
164
+
165
+ const contactsTO = defineTableOccurrence({
166
+ name: "contacts", // The table occurrence name in FileMaker
167
+ baseTable: contactsBase,
168
+ });
169
+ ```
170
+
171
+ #### Default Field Selection
172
+
173
+ FileMaker will automatically return all non-container fields from a schema if you don't specify a $select parameter in your query. This library forces you to be a bit more explicit about what fields you want to return so that the types will more accurately reflect the full data you will get back. To modify this behavior, change the `defaultSelect` option when creating the `TableOccurrence`.
174
+
175
+ ```typescript
176
+ // Option 1 (default): "schema" - Select all fields from the schema (same as "all" but more explicit)
177
+ const usersTO = defineTableOccurrence({
178
+ name: "users",
179
+ baseTable: usersBase,
180
+ defaultSelect: "schema", // a $select parameter will be always be added to the query for only the fields you've defined in the BaseTable schema
181
+ });
182
+
183
+ // Option 2: "all" - Select all fields (default behavior)
184
+ const usersTO = defineTableOccurrence({
185
+ name: "users",
186
+ baseTable: usersBase,
187
+ defaultSelect: "all", // Don't always a $select parameter to the query; FileMaker will return all non-container fields from the table
188
+ });
189
+
190
+ // Option 3: Array of field names - Select only specific fields by default
191
+ const usersTO = defineTableOccurrence({
192
+ name: "users",
193
+ baseTable: usersBase,
194
+ defaultSelect: ["username", "email"], // Only select these fields by default
195
+ });
196
+
197
+ // When you call list(), the defaultSelect is applied automatically
198
+ const result = await db.from("users").list().execute();
199
+ // If defaultSelect is ["username", "email"], result.data will only contain those fields
200
+
201
+ // You can still override with explicit select()
202
+ const result = await db
203
+ .from("users")
204
+ .list()
205
+ .select("username", "email", "age") // Always overrides at the per-request level
206
+ .execute();
207
+ ```
208
+
209
+ Lastly, you can combine all table occurrences into a database instance for the full type-safe experience. This is a method on the main `FMServerConnection` client class.
210
+
211
+ ```typescript
212
+ const db = connection.database("MyDatabase.fmp12", {
213
+ occurrences: [contactsTO, usersTO], // Register your table occurrences
214
+ });
215
+ ```
216
+
217
+ ## Querying Data
218
+
219
+ ### Basic Queries
220
+
221
+ Use `list()` to retrieve multiple records:
222
+
223
+ ```typescript
224
+ // Get all users
225
+ const result = await db.from("users").list().execute();
226
+
227
+ if (result.data) {
228
+ result.data.forEach((user) => {
229
+ console.log(user.username);
230
+ });
231
+ }
232
+ ```
233
+
234
+ Get a specific record by ID:
235
+
236
+ ```typescript
237
+ const result = await db.from("users").get("user-123").execute();
238
+
239
+ if (result.data) {
240
+ console.log(result.data.username);
241
+ }
242
+ ```
243
+
244
+ Get a single field value:
245
+
246
+ ```typescript
247
+ const result = await db
248
+ .from("users")
249
+ .get("user-123")
250
+ .getSingleField("email")
251
+ .execute();
252
+
253
+ if (result.data) {
254
+ console.log(result.data); // "user@example.com"
255
+ }
256
+ ```
257
+
258
+ ### Filtering
259
+
260
+ fmodata provides type-safe filter operations that prevent common errors at compile time. The filter system supports three syntaxes: shorthand, single operator objects, and arrays for multiple operators.
261
+
262
+ #### Operator Syntax
263
+
264
+ You can use filters in three ways:
265
+
266
+ **1. Shorthand (direct value):**
267
+
268
+ ```typescript
269
+ .filter({ name: "John" })
270
+ // Equivalent to: { name: [{ eq: "John" }] }
271
+ ```
272
+
273
+ **2. Single operator object:**
274
+
275
+ ```typescript
276
+ .filter({ age: { gt: 18 } })
277
+ ```
278
+
279
+ **3. Array of operators (for multiple operators on same field):**
280
+
281
+ ```typescript
282
+ .filter({ age: [{ gt: 18 }, { lt: 65 }] })
283
+ // Result: age gt 18 and age lt 65
284
+ ```
285
+
286
+ The array pattern prevents duplicate operators on the same field and allows multiple conditions with implicit AND.
287
+
288
+ #### Available Operators
289
+
290
+ **String fields:**
291
+
292
+ - `eq`, `ne` - equality/inequality
293
+ - `contains`, `startswith`, `endswith` - string functions
294
+ - `gt`, `ge`, `lt`, `le` - comparison
295
+ - `in` - match any value in array
296
+
297
+ **Number fields:**
298
+
299
+ - `eq`, `ne`, `gt`, `ge`, `lt`, `le` - comparisons
300
+ - `in` - match any value in array
301
+
302
+ **Boolean fields:**
303
+
304
+ - `eq`, `ne` - equality only
305
+
306
+ **Date fields:**
307
+
308
+ - `eq`, `ne`, `gt`, `ge`, `lt`, `le` - date comparisons
309
+ - `in` - match any date in array
310
+
311
+ #### Shorthand Syntax
312
+
313
+ For simple equality checks, use the shorthand:
314
+
315
+ ```typescript
316
+ const result = await db.from("users").list().filter({ name: "John" }).execute();
317
+ // Equivalent to: { name: [{ eq: "John" }] }
318
+ ```
319
+
320
+ #### Examples
321
+
322
+ ```typescript
323
+ // Equality filter (single operator)
324
+ const activeUsers = await db
325
+ .from("users")
326
+ .list()
327
+ .filter({ active: { eq: true } })
328
+ .execute();
329
+
330
+ // Comparison operators (single operator)
331
+ const adultUsers = await db
332
+ .from("users")
333
+ .list()
334
+ .filter({ age: { gt: 18 } })
335
+ .execute();
336
+
337
+ // String operators (single operator)
338
+ const johns = await db
339
+ .from("users")
340
+ .list()
341
+ .filter({ name: { contains: "John" } })
342
+ .execute();
343
+
344
+ // Multiple operators on same field (array syntax, implicit AND)
345
+ const rangeQuery = await db
346
+ .from("users")
347
+ .list()
348
+ .filter({ age: [{ gt: 18 }, { lt: 65 }] })
349
+ .execute();
350
+
351
+ // Combine filters with AND
352
+ const result = await db
353
+ .from("users")
354
+ .list()
355
+ .filter({
356
+ and: [{ active: [{ eq: true }] }, { age: [{ gt: 18 }] }],
357
+ })
358
+ .execute();
359
+
360
+ // Combine filters with OR
361
+ const result = await db
362
+ .from("users")
363
+ .list()
364
+ .filter({
365
+ or: [{ name: [{ eq: "John" }] }, { name: [{ eq: "Jane" }] }],
366
+ })
367
+ .execute();
368
+
369
+ // IN operator
370
+ const result = await db
371
+ .from("users")
372
+ .list()
373
+ .filter({ age: [{ in: [18, 21, 25] }] })
374
+ .execute();
375
+
376
+ // Null checks
377
+ const result = await db
378
+ .from("users")
379
+ .list()
380
+ .filter({ deletedAt: [{ eq: null }] })
381
+ .execute();
382
+ ```
383
+
384
+ #### Logical Operators
385
+
386
+ Combine multiple conditions with `and`, `or`, `not`:
387
+
388
+ ```typescript
389
+ const result = await db
390
+ .from("users")
391
+ .list()
392
+ .filter({
393
+ and: [{ name: [{ contains: "John" }] }, { age: [{ gt: 18 }] }],
394
+ })
395
+ .execute();
396
+ ```
397
+
398
+ #### Escape Hatch
399
+
400
+ For unsupported edge cases, pass a raw OData filter string:
401
+
402
+ ```typescript
403
+ const result = await db
404
+ .from("users")
405
+ .list()
406
+ .filter("substringof('John', name)")
407
+ .execute();
408
+ ```
409
+
410
+ ### Sorting
411
+
412
+ Sort results using `orderBy()`:
413
+
414
+ ```typescript
415
+ // Sort ascending
416
+ const result = await db.from("users").list().orderBy("name").execute();
417
+
418
+ // Sort descending
419
+ const result = await db.from("users").list().orderBy("name desc").execute();
420
+
421
+ // Multiple sort fields
422
+ const result = await db
423
+ .from("users")
424
+ .list()
425
+ .orderBy("lastName, firstName desc")
426
+ .execute();
427
+ ```
428
+
429
+ ### Pagination
430
+
431
+ Control the number of records returned and pagination:
432
+
433
+ ```typescript
434
+ // Limit results
435
+ const result = await db.from("users").list().top(10).execute();
436
+
437
+ // Skip records (pagination)
438
+ const result = await db.from("users").list().top(10).skip(20).execute();
439
+
440
+ // Count total records
441
+ const result = await db.from("users").list().count().execute();
442
+ ```
443
+
444
+ ### Selecting Fields
445
+
446
+ Select specific fields to return:
447
+
448
+ ```typescript
449
+ const result = await db
450
+ .from("users")
451
+ .list()
452
+ .select("username", "email")
453
+ .execute();
454
+
455
+ // result.data[0] will only have username and email fields
456
+ ```
457
+
458
+ ### Single Records
459
+
460
+ Use `single()` to ensure exactly one record is returned (returns an error if zero or multiple records are found):
461
+
462
+ ```typescript
463
+ const result = await db
464
+ .from("users")
465
+ .list()
466
+ .filter({ email: { eq: "user@example.com" } })
467
+ .single()
468
+ .execute();
469
+
470
+ if (result.data) {
471
+ // result.data is a single record, not an array
472
+ console.log(result.data.username);
473
+ }
474
+ ```
475
+
476
+ Use `maybeSingle()` when you want at most one record (returns `null` if no record is found, returns an error if multiple records are found):
477
+
478
+ ```typescript
479
+ const result = await db
480
+ .from("users")
481
+ .list()
482
+ .filter({ email: { eq: "user@example.com" } })
483
+ .maybeSingle()
484
+ .execute();
485
+
486
+ if (result.data) {
487
+ // result.data is a single record or null
488
+ console.log(result.data?.username);
489
+ } else {
490
+ // No record found - result.data would be null
491
+ console.log("User not found");
492
+ }
493
+ ```
494
+
495
+ **Difference between `single()` and `maybeSingle()`:**
496
+
497
+ - `single()` - Requires exactly one record. Returns an error if zero or multiple records are found.
498
+ - `maybeSingle()` - Allows zero or one record. Returns `null` if no record is found, returns an error only if multiple records are found.
499
+
500
+ ### Chaining Methods
501
+
502
+ All query methods can be chained together:
503
+
504
+ ```typescript
505
+ const result = await db
506
+ .from("users")
507
+ .list()
508
+ .select("username", "email", "age")
509
+ .filter({ age: { gt: 18 } })
510
+ .orderBy("username")
511
+ .top(10)
512
+ .skip(0)
513
+ .execute();
514
+ ```
515
+
516
+ ## CRUD Operations
517
+
518
+ ### Insert
519
+
520
+ Insert new records with type-safe data:
521
+
522
+ ```typescript
523
+ // Insert a new user
524
+ const result = await db
525
+ .from("users")
526
+ .insert({
527
+ username: "johndoe",
528
+ email: "john@example.com",
529
+ active: true,
530
+ })
531
+ .execute();
532
+
533
+ if (result.data) {
534
+ console.log("Created user:", result.data);
535
+ }
536
+ ```
537
+
538
+ Fields are automatically required for insert if their validator doesn't allow `null` or `undefined`. You can specify additional required fields:
539
+
540
+ ```typescript
541
+ const usersBase = defineBaseTable({
542
+ schema: {
543
+ id: z.string(), // Auto-required (not nullable), but excluded from insert (idField)
544
+ username: z.string(), // Auto-required (not nullable)
545
+ email: z.string(), // Auto-required (not nullable)
546
+ phone: z.string().nullable(), // Optional by default
547
+ createdAt: z.string(), // Auto-required, but excluded (readOnly)
548
+ },
549
+ idField: "id", // Automatically excluded from insert/update
550
+ required: ["phone"], // Make phone required for inserts despite being nullable
551
+ readOnly: ["createdAt"], // Exclude from insert/update operations
552
+ });
553
+
554
+ // TypeScript enforces: username, email, and phone are required
555
+ // TypeScript excludes: id and createdAt cannot be provided
556
+ const result = await db
557
+ .from("users")
558
+ .insert({
559
+ username: "johndoe",
560
+ email: "john@example.com",
561
+ phone: "+1234567890", // Required because specified in 'required' array
562
+ })
563
+ .execute();
564
+ ```
565
+
566
+ ### Update
567
+
568
+ Update records by ID or filter:
569
+
570
+ ```typescript
571
+ // Update by ID
572
+ const result = await db
573
+ .from("users")
574
+ .update({ username: "newname" })
575
+ .byId("user-123")
576
+ .execute();
577
+
578
+ if (result.data) {
579
+ console.log(`Updated ${result.data.updatedCount} record(s)`);
580
+ }
581
+
582
+ // Update by filter
583
+ const result = await db
584
+ .from("users")
585
+ .update({ active: false })
586
+ .where((q) => q.filter({ lastLogin: { lt: "2023-01-01" } }))
587
+ .execute();
588
+
589
+ // Complex filter example
590
+ const result = await db
591
+ .from("users")
592
+ .update({ active: false })
593
+ .where((q) =>
594
+ q.filter({
595
+ and: [{ active: true }, { count: { lt: 5 } }],
596
+ }),
597
+ )
598
+ .execute();
599
+
600
+ // Update with additional query options
601
+ const result = await db
602
+ .from("users")
603
+ .update({ active: false })
604
+ .where((q) => q.filter({ active: true }).top(10))
605
+ .execute();
606
+ ```
607
+
608
+ ### Delete
609
+
610
+ Delete records by ID or filter:
611
+
612
+ ```typescript
613
+ // Delete by ID
614
+ const result = await db.from("users").delete().byId("user-123").execute();
615
+
616
+ if (result.data) {
617
+ console.log(`Deleted ${result.data.deletedCount} record(s)`);
618
+ }
619
+
620
+ // Delete by filter
621
+ const result = await db
622
+ .from("users")
623
+ .delete()
624
+ .where((q) => q.filter({ active: false }))
625
+ .execute();
626
+
627
+ // Delete with complex filters
628
+ const result = await db
629
+ .from("users")
630
+ .delete()
631
+ .where((q) =>
632
+ q.filter({
633
+ and: [{ active: false }, { lastLogin: { lt: "2023-01-01" } }],
634
+ }),
635
+ )
636
+ .execute();
637
+ ```
638
+
639
+ ## Navigation & Relationships
640
+
641
+ ### Defining Navigation
642
+
643
+ Define relationships between tables using the `navigation` option:
644
+
645
+ ```typescript
646
+ const contactsBase = defineBaseTable({
647
+ schema: {
648
+ id: z.string(),
649
+ name: z.string(),
650
+ userId: z.string(),
651
+ },
652
+ idField: "id",
653
+ });
654
+
655
+ const usersBase = defineBaseTable({
656
+ schema: {
657
+ id: z.string(),
658
+ username: z.string(),
659
+ email: z.string(),
660
+ },
661
+ idField: "id",
662
+ });
663
+
664
+ // Define navigation using functions to handle circular dependencies
665
+ // Create base occurrences first, then add navigation
666
+ const _contactsTO = defineTableOccurrence({
667
+ name: "contacts",
668
+ baseTable: contactsBase,
669
+ });
670
+
671
+ const _usersTO = defineTableOccurrence({
672
+ name: "users",
673
+ baseTable: usersBase,
674
+ });
675
+
676
+ // Then add navigation
677
+ const contactsTO = _contactsTO.addNavigation({
678
+ users: () => _usersTO,
679
+ });
680
+
681
+ const usersTO = _usersTO.addNavigation({
682
+ contacts: () => _contactsTO,
683
+ });
684
+
685
+ // You can also add navigation after creation
686
+ const updatedUsersTO = usersTO.addNavigation({
687
+ profile: () => profileTO,
688
+ });
689
+ ```
690
+
691
+ ### Navigating Between Tables
692
+
693
+ Navigate to related records:
694
+
695
+ ```typescript
696
+ // Navigate from a specific record
697
+ const result = await db
698
+ .from("contacts")
699
+ .get("contact-123")
700
+ .navigate("users")
701
+ .select("username", "email")
702
+ .execute();
703
+
704
+ // Navigate without specifying a record first
705
+ const result = await db.from("contacts").navigate("users").list().execute();
706
+
707
+ // You can navigate to arbitrary tables not in your schema
708
+ const result = await db
709
+ .from("contacts")
710
+ .navigate("some_other_table")
711
+ .list()
712
+ .execute();
713
+ ```
714
+
715
+ ### Expanding Related Records
716
+
717
+ Use `expand()` to include related records in your query results:
718
+
719
+ ```typescript
720
+ // Simple expand
721
+ const result = await db.from("contacts").list().expand("users").execute();
722
+
723
+ // Expand with field selection
724
+ const result = await db
725
+ .from("contacts")
726
+ .list()
727
+ .expand("users", (b) => b.select("username", "email"))
728
+ .execute();
729
+
730
+ // Expand with filtering
731
+ const result = await db
732
+ .from("contacts")
733
+ .list()
734
+ .expand("users", (b) => b.filter({ active: true }))
735
+ .execute();
736
+
737
+ // Multiple expands
738
+ const result = await db
739
+ .from("contacts")
740
+ .list()
741
+ .expand("users", (b) => b.select("username"))
742
+ .expand("orders", (b) => b.select("total").top(5))
743
+ .execute();
744
+
745
+ // Nested expands
746
+ const result = await db
747
+ .from("contacts")
748
+ .list()
749
+ .expand("users", (usersBuilder) =>
750
+ usersBuilder
751
+ .select("username", "email")
752
+ .expand("customer", (customerBuilder) =>
753
+ customerBuilder.select("name", "tier"),
754
+ ),
755
+ )
756
+ .execute();
757
+
758
+ // Complex expand with multiple options
759
+ const result = await db
760
+ .from("contacts")
761
+ .list()
762
+ .expand("users", (b) =>
763
+ b
764
+ .select("username", "email")
765
+ .filter({ active: true })
766
+ .orderBy("username")
767
+ .top(10)
768
+ .expand("customer", (nested) => nested.select("name")),
769
+ )
770
+ .execute();
771
+ ```
772
+
773
+ ## Running Scripts
774
+
775
+ Execute FileMaker scripts via OData:
776
+
777
+ ```typescript
778
+ // Simple script execution
779
+ const result = await db.runScript("MyScriptName");
780
+
781
+ console.log(result.resultCode); // Script result code
782
+ console.log(result.result); // Optional script result string
783
+
784
+ // Pass parameters to script
785
+ const result = await db.runScript("MyScriptName", {
786
+ scriptParam: "some value",
787
+ });
788
+
789
+ // Script parameters can be strings, numbers, or objects
790
+ const result = await db.runScript("ProcessOrder", {
791
+ scriptParam: {
792
+ orderId: "12345",
793
+ action: "approve",
794
+ }, // Will be JSON stringified
795
+ });
796
+
797
+ // Validate script result with Zod schema
798
+ // NOTE: Your validator must be able to parse a string.
799
+ // See Zod codecs for how to build a jsonCodec function that does this
800
+ // https://zod.dev/codecs?id=jsonschema
801
+
802
+ const schema = jsonCodec(
803
+ z.object({
804
+ success: z.boolean(),
805
+ message: z.string(),
806
+ recordId: z.string(),
807
+ }),
808
+ );
809
+
810
+ const result = await db.runScript("CreateRecord", {
811
+ resultSchema: schema,
812
+ });
813
+
814
+ // result.result is now typed based on your schema
815
+ console.log(result.result.recordId);
816
+ ```
817
+
818
+ **Note:** OData doesn't support script names with special characters (e.g., `@`, `&`, `/`) or script names beginning with a number. TypeScript will catch these at compile time.
819
+
820
+ ## Batch Operations
821
+
822
+ Batch operations allow you to execute multiple queries and operations together in a single request. All operations in a batch are executed atomically - they all succeed or all fail together. This is both more efficient (fewer network round-trips) and ensures data consistency across related operations.
24
823
 
25
- # Build the package
26
- pnpm build
824
+ ### Basic Batch with Multiple Queries
27
825
 
28
- # Run tests
29
- pnpm test
826
+ Execute multiple read operations in a single batch:
30
827
 
31
- # Watch mode for development
32
- pnpm dev
828
+ ```typescript
829
+ // Create query builders
830
+ const contactsQuery = db.from("contacts").list().top(5);
831
+ const usersQuery = db.from("users").list().top(5);
832
+
833
+ // Execute both queries in a single batch
834
+ const result = await db.batch([contactsQuery, usersQuery]).execute();
835
+
836
+ if (result.data) {
837
+ // Result is a tuple matching the input builders
838
+ const [contacts, users] = result.data;
839
+
840
+ console.log("Contacts:", contacts);
841
+ console.log("Users:", users);
842
+ }
843
+ ```
844
+
845
+ ### Mixed Operations (Reads and Writes)
846
+
847
+ Combine queries, inserts, updates, and deletes in a single batch:
848
+
849
+ ```typescript
850
+ // Mix different operation types
851
+ const listQuery = db.from("contacts").list().top(10);
852
+ const insertOp = db.from("contacts").insert({
853
+ name: "John Doe",
854
+ email: "john@example.com",
855
+ });
856
+ const updateOp = db.from("users").update({ active: true }).byId("user-123");
857
+
858
+ // All operations execute atomically
859
+ const result = await db.batch([listQuery, insertOp, updateOp]).execute();
860
+
861
+ if (result.data) {
862
+ const [contactsList, insertResult, updateResult] = result.data;
863
+
864
+ console.log("Fetched contacts:", contactsList);
865
+ console.log("Inserted contact:", insertResult);
866
+ console.log("Updated user:", updateResult);
867
+ }
868
+ ```
869
+
870
+ ### Transactional Behavior
871
+
872
+ Batch operations are transactional for write operations (inserts, updates, deletes). If any operation in the batch fails, all write operations are rolled back:
873
+
874
+ ```typescript
875
+ const result = await db
876
+ .batch([
877
+ db.from("users").insert({ username: "alice", email: "alice@example.com" }),
878
+ db.from("users").insert({ username: "bob", email: "bob@example.com" }),
879
+ db.from("users").insert({ username: "charlie", email: "invalid" }), // This fails
880
+ ])
881
+ .execute();
882
+
883
+ if (result.error) {
884
+ // All three inserts are rolled back - no users were created
885
+ console.error("Batch failed:", result.error);
886
+ }
887
+ ```
888
+
889
+ **Note:** Batch operations automatically group write operations (POST, PATCH, DELETE) into changesets for transactional behavior, while read operations (GET) are executed individually within the batch.
890
+
891
+ ## Schema Management
892
+
893
+ The library provides methods for managing database schema through the `db.schema` property. You can create and delete tables, add and remove fields, and manage indexes.
894
+
895
+ ### Creating Tables
896
+
897
+ Create a new table with field definitions:
898
+
899
+ ```typescript
900
+ import type { Field } from "@proofkit/fmodata";
901
+
902
+ const fields: Field[] = [
903
+ {
904
+ name: "id",
905
+ type: "string",
906
+ primary: true,
907
+ maxLength: 36,
908
+ },
909
+ {
910
+ name: "username",
911
+ type: "string",
912
+ nullable: false,
913
+ unique: true,
914
+ maxLength: 50,
915
+ },
916
+ {
917
+ name: "email",
918
+ type: "string",
919
+ nullable: false,
920
+ maxLength: 255,
921
+ },
922
+ {
923
+ name: "age",
924
+ type: "numeric",
925
+ nullable: true,
926
+ },
927
+ {
928
+ name: "created_at",
929
+ type: "timestamp",
930
+ default: "CURRENT_TIMESTAMP",
931
+ },
932
+ ];
933
+
934
+ const tableDefinition = await db.schema.createTable("users", fields);
935
+ console.log(tableDefinition.tableName); // "users"
936
+ console.log(tableDefinition.fields); // Array of field definitions
937
+ ```
938
+
939
+ ### Field Types
940
+
941
+ The library supports various field types:
942
+
943
+ **String Fields:**
944
+
945
+ ```typescript
946
+ {
947
+ name: "username",
948
+ type: "string",
949
+ maxLength: 100, // Optional: varchar(100)
950
+ nullable: true,
951
+ unique: true,
952
+ default: "USER" | "USERNAME" | "CURRENT_USER", // Optional
953
+ repetitions: 5, // Optional: for repeating fields
954
+ }
955
+ ```
956
+
957
+ **Numeric Fields:**
958
+
959
+ ```typescript
960
+ {
961
+ name: "age",
962
+ type: "numeric",
963
+ nullable: true,
964
+ primary: false,
965
+ unique: false,
966
+ }
967
+ ```
968
+
969
+ **Date Fields:**
970
+
971
+ ```typescript
972
+ {
973
+ name: "birth_date",
974
+ type: "date",
975
+ default: "CURRENT_DATE" | "CURDATE", // Optional
976
+ nullable: true,
977
+ }
978
+ ```
979
+
980
+ **Time Fields:**
981
+
982
+ ```typescript
983
+ {
984
+ name: "start_time",
985
+ type: "time",
986
+ default: "CURRENT_TIME" | "CURTIME", // Optional
987
+ nullable: true,
988
+ }
989
+ ```
990
+
991
+ **Timestamp Fields:**
992
+
993
+ ```typescript
994
+ {
995
+ name: "created_at",
996
+ type: "timestamp",
997
+ default: "CURRENT_TIMESTAMP" | "CURTIMESTAMP", // Optional
998
+ nullable: false,
999
+ }
1000
+ ```
1001
+
1002
+ **Container Fields:**
1003
+
1004
+ ```typescript
1005
+ {
1006
+ name: "avatar",
1007
+ type: "container",
1008
+ externalSecurePath: "/secure/path", // Optional
1009
+ nullable: true,
1010
+ }
1011
+ ```
1012
+
1013
+ ### Adding Fields to Existing Tables
1014
+
1015
+ Add new fields to an existing table:
1016
+
1017
+ ```typescript
1018
+ const newFields: Field[] = [
1019
+ {
1020
+ name: "phone",
1021
+ type: "string",
1022
+ nullable: true,
1023
+ maxLength: 20,
1024
+ },
1025
+ {
1026
+ name: "bio",
1027
+ type: "string",
1028
+ nullable: true,
1029
+ maxLength: 1000,
1030
+ },
1031
+ ];
1032
+
1033
+ const updatedTable = await db.schema.addFields("users", newFields);
1034
+ ```
1035
+
1036
+ ### Deleting Tables and Fields
1037
+
1038
+ Delete an entire table:
1039
+
1040
+ ```typescript
1041
+ await db.schema.deleteTable("old_table");
1042
+ ```
1043
+
1044
+ Delete a specific field from a table:
1045
+
1046
+ ```typescript
1047
+ await db.schema.deleteField("users", "old_field");
1048
+ ```
1049
+
1050
+ ### Managing Indexes
1051
+
1052
+ Create an index on a field:
1053
+
1054
+ ```typescript
1055
+ const index = await db.schema.createIndex("users", "email");
1056
+ console.log(index.indexName); // "email"
1057
+ ```
1058
+
1059
+ Delete an index:
1060
+
1061
+ ```typescript
1062
+ await db.schema.deleteIndex("users", "email");
1063
+ ```
1064
+
1065
+ ### Complete Example
1066
+
1067
+ Here's a complete example of creating a table with various field types:
1068
+
1069
+ ```typescript
1070
+ const fields: Field[] = [
1071
+ // Primary key
1072
+ {
1073
+ name: "id",
1074
+ type: "string",
1075
+ primary: true,
1076
+ maxLength: 36,
1077
+ },
1078
+
1079
+ // String fields
1080
+ {
1081
+ name: "username",
1082
+ type: "string",
1083
+ nullable: false,
1084
+ unique: true,
1085
+ maxLength: 50,
1086
+ },
1087
+ {
1088
+ name: "email",
1089
+ type: "string",
1090
+ nullable: false,
1091
+ maxLength: 255,
1092
+ },
1093
+
1094
+ // Numeric field
1095
+ {
1096
+ name: "age",
1097
+ type: "numeric",
1098
+ nullable: true,
1099
+ },
1100
+
1101
+ // Date/time fields
1102
+ {
1103
+ name: "birth_date",
1104
+ type: "date",
1105
+ nullable: true,
1106
+ },
1107
+ {
1108
+ name: "created_at",
1109
+ type: "timestamp",
1110
+ default: "CURRENT_TIMESTAMP",
1111
+ nullable: false,
1112
+ },
1113
+
1114
+ // Container field
1115
+ {
1116
+ name: "avatar",
1117
+ type: "container",
1118
+ nullable: true,
1119
+ },
1120
+
1121
+ // Repeating field
1122
+ {
1123
+ name: "tags",
1124
+ type: "string",
1125
+ repetitions: 5,
1126
+ maxLength: 50,
1127
+ },
1128
+ ];
1129
+
1130
+ // Create the table
1131
+ const table = await db.schema.createTable("users", fields);
1132
+
1133
+ // Later, add more fields
1134
+ await db.schema.addFields("users", [
1135
+ {
1136
+ name: "phone",
1137
+ type: "string",
1138
+ nullable: true,
1139
+ },
1140
+ ]);
1141
+
1142
+ // Create an index on email
1143
+ await db.schema.createIndex("users", "email");
1144
+ ```
1145
+
1146
+ **Note:** Schema management operations require appropriate access privileges on your FileMaker account. Operations will throw errors if you don't have the necessary permissions.
1147
+
1148
+ ## Advanced Features
1149
+
1150
+ ### Type Safety
1151
+
1152
+ The library provides full TypeScript type inference:
1153
+
1154
+ ```typescript
1155
+ const usersBase = defineBaseTable({
1156
+ schema: {
1157
+ id: z.string(),
1158
+ username: z.string(),
1159
+ email: z.string(),
1160
+ },
1161
+ idField: "id",
1162
+ });
1163
+
1164
+ const usersTO = defineTableOccurrence({
1165
+ name: "users",
1166
+ baseTable: usersBase,
1167
+ });
1168
+
1169
+ const db = connection.database("MyDB", {
1170
+ occurrences: [usersTO],
1171
+ });
1172
+
1173
+ // TypeScript knows these are valid field names
1174
+ db.from("users").list().select("username", "email");
1175
+
1176
+ // TypeScript error: "invalid" is not a field name
1177
+ db.from("users").list().select("invalid"); // TS Error
1178
+
1179
+ // Type-safe filters
1180
+ db.from("users")
1181
+ .list()
1182
+ .filter({ username: { eq: "john" } }); // ✓
1183
+ db.from("users")
1184
+ .list()
1185
+ .filter({ invalid: { eq: "john" } }); // TS Error
1186
+ ```
1187
+
1188
+ ### Required and Read-Only Fields
1189
+
1190
+ The library automatically infers which fields are required based on whether their validator allows `null` or `undefined`:
1191
+
1192
+ ```typescript
1193
+ const usersBase = defineBaseTable({
1194
+ schema: {
1195
+ id: z.string(), // Auto-required, auto-readOnly (idField)
1196
+ username: z.string(), // Auto-required (not nullable)
1197
+ email: z.string(), // Auto-required (not nullable)
1198
+ status: z.string().nullable(), // Optional (nullable)
1199
+ createdAt: z.string(), // Read-only system field
1200
+ updatedAt: z.string().nullable(), // Optional
1201
+ },
1202
+ idField: "id", // Automatically excluded from insert/update
1203
+ required: ["status"], // Make status required despite being nullable
1204
+ readOnly: ["createdAt"], // Exclude createdAt from insert/update
1205
+ });
1206
+
1207
+ // Insert: username, email, and status are required
1208
+ // Insert: id and createdAt are excluded (cannot be provided)
1209
+ db.from("users").insert({
1210
+ username: "john",
1211
+ email: "john@example.com",
1212
+ status: "active", // Required due to 'required' array
1213
+ updatedAt: new Date().toISOString(), // Optional
1214
+ });
1215
+
1216
+ // Update: all fields are optional except id and createdAt are excluded
1217
+ db.from("users")
1218
+ .update({
1219
+ status: "active", // Optional
1220
+ // id and createdAt cannot be modified
1221
+ })
1222
+ .byId("user-123");
1223
+ ```
1224
+
1225
+ **Key Features:**
1226
+
1227
+ - **Auto-inference:** Non-nullable fields are automatically required for insert
1228
+ - **Additional requirements:** Use `required` to make nullable fields required for new records
1229
+ - **Read-only fields:** Use `readOnly` to exclude fields from insert/update (e.g., timestamps)
1230
+ - **Automatic ID exclusion:** The `idField` is always read-only without needing to specify it
1231
+ - **Update flexibility:** All fields are optional for updates (except read-only fields)
1232
+
1233
+ ### Prefer: fmodata.entity-ids
1234
+
1235
+ This library supports using FileMaker's internal field identifiers (FMFID) and table occurrence identifiers (FMTID) instead of names. This protects your integration from both field and table occurrence name changes.
1236
+
1237
+ To enable this feature, simply define your schema with entity IDs using the `defineBaseTable` and `defineTableOccurrence` functions. Behind the scenes, the library will transform your request and the response back to the names you specify in these schemas. This is an all-or-nothing feature. For it to work properly, you must define all table occurrences passed to a `Database` with entity IDs (both `fmfIds` on the base table and `fmtId` on the table occurrence).
1238
+
1239
+ _Note for OttoFMS proxy: This feature requires version 4.14 or later of OttoFMS_
1240
+
1241
+ How do I find these ids? They can be found in the XML version of the `$metadata` endpoint for your database, or you can calculate them using these [custom functions](https://github.com/rwu2359/CFforID) from John Renfrew
1242
+
1243
+ #### Basic Usage
1244
+
1245
+ ```typescript
1246
+ import { defineBaseTable, defineTableOccurrence } from "@proofkit/fmodata";
1247
+ import { z } from "zod/v4";
1248
+
1249
+ // Define a base table with FileMaker field IDs
1250
+ const usersBase = defineBaseTable({
1251
+ schema: {
1252
+ id: z.string(),
1253
+ username: z.string(),
1254
+ email: z.string().nullable(),
1255
+ createdAt: z.string(),
1256
+ },
1257
+ idField: "id",
1258
+ fmfIds: {
1259
+ id: "FMFID:12039485",
1260
+ username: "FMFID:34323433",
1261
+ email: "FMFID:12232424",
1262
+ createdAt: "FMFID:43234355",
1263
+ },
1264
+ });
1265
+
1266
+ // Create a table occurrence with a FileMaker table occurrence ID
1267
+ const usersTO = defineTableOccurrence({
1268
+ name: "users",
1269
+ baseTable: usersBase,
1270
+ fmtId: "FMTID:12432533",
1271
+ });
1272
+ ```
1273
+
1274
+ ### Error Handling
1275
+
1276
+ All operations return a `Result` type with either `data` or `error`. The library provides rich error types that help you handle different error scenarios appropriately.
1277
+
1278
+ #### Basic Error Checking
1279
+
1280
+ ```typescript
1281
+ const result = await db.from("users").list().execute();
1282
+
1283
+ if (result.error) {
1284
+ console.error("Query failed:", result.error.message);
1285
+ return;
1286
+ }
1287
+
1288
+ if (result.data) {
1289
+ console.log("Query succeeded:", result.data);
1290
+ }
1291
+ ```
1292
+
1293
+ #### HTTP Errors
1294
+
1295
+ Handle HTTP status codes (4xx, 5xx) with the `HTTPError` class:
1296
+
1297
+ ```typescript
1298
+ import { HTTPError, isHTTPError } from "@proofkit/fmodata";
1299
+
1300
+ const result = await db.from("users").list().execute();
1301
+
1302
+ if (result.error) {
1303
+ if (isHTTPError(result.error)) {
1304
+ // TypeScript knows this is HTTPError
1305
+ console.log("HTTP Status:", result.error.status);
1306
+
1307
+ if (result.error.isNotFound()) {
1308
+ console.log("Resource not found");
1309
+ } else if (result.error.isUnauthorized()) {
1310
+ console.log("Authentication required");
1311
+ } else if (result.error.is5xx()) {
1312
+ console.log("Server error - try again later");
1313
+ } else if (result.error.is4xx()) {
1314
+ console.log("Client error:", result.error.statusText);
1315
+ }
1316
+
1317
+ // Access the response body if available
1318
+ if (result.error.response) {
1319
+ console.log("Error details:", result.error.response);
1320
+ }
1321
+ }
1322
+ }
1323
+ ```
1324
+
1325
+ #### Network Errors
1326
+
1327
+ Handle network-level errors (timeouts, connection issues, etc.):
1328
+
1329
+ ```typescript
1330
+ import {
1331
+ TimeoutError,
1332
+ NetworkError,
1333
+ RetryLimitError,
1334
+ CircuitOpenError,
1335
+ } from "@proofkit/fmodata";
1336
+
1337
+ const result = await db.from("users").list().execute();
1338
+
1339
+ if (result.error) {
1340
+ if (result.error instanceof TimeoutError) {
1341
+ console.log("Request timed out");
1342
+ // Show user-friendly timeout message
1343
+ } else if (result.error instanceof NetworkError) {
1344
+ console.log("Network connectivity issue");
1345
+ // Show offline message
1346
+ } else if (result.error instanceof RetryLimitError) {
1347
+ console.log("Request failed after retries");
1348
+ // Log the underlying error: result.error.cause
1349
+ } else if (result.error instanceof CircuitOpenError) {
1350
+ console.log("Service is currently unavailable");
1351
+ // Show maintenance message
1352
+ }
1353
+ }
33
1354
  ```
34
1355
 
35
- ## License
1356
+ #### Validation Errors
1357
+
1358
+ When schema validation fails, you get a `ValidationError` with rich context:
1359
+
1360
+ ```typescript
1361
+ import { ValidationError, isValidationError } from "@proofkit/fmodata";
1362
+
1363
+ const result = await db.from("users").list().execute();
1364
+
1365
+ if (result.error) {
1366
+ if (isValidationError(result.error)) {
1367
+ // Access validation issues (Standard Schema format)
1368
+ console.log("Validation failed for field:", result.error.field);
1369
+ console.log("Issues:", result.error.issues);
1370
+ console.log("Failed value:", result.error.value);
1371
+ }
1372
+ }
1373
+ ```
1374
+
1375
+ **Validator-Agnostic Error Handling**
1376
+
1377
+ The library uses [Standard Schema](https://github.com/standard-schema/standard-schema) to support any validation library (Zod, Valibot, ArkType, etc.). Following the same pattern as [uploadthing](https://github.com/pingdotgg/uploadthing), the `ValidationError.cause` property contains the normalized Standard Schema issues array:
1378
+
1379
+ ```typescript
1380
+ import { ValidationError } from "@proofkit/fmodata";
1381
+
1382
+ const result = await db.from("users").list().execute();
1383
+
1384
+ if (result.error instanceof ValidationError) {
1385
+ // The cause property (ES2022 Error.cause) contains the Standard Schema issues array
1386
+ // This is validator-agnostic and works with Zod, Valibot, ArkType, etc.
1387
+ console.log("Validation issues:", result.error.cause);
1388
+ console.log("Issues are also available directly:", result.error.issues);
1389
+
1390
+ // Both point to the same array
1391
+ console.log(result.error.cause === result.error.issues); // true
1392
+
1393
+ // Access additional context
1394
+ console.log("Failed field:", result.error.field);
1395
+ console.log("Failed value:", result.error.value);
1396
+
1397
+ // Standard Schema issues have a normalized format
1398
+ result.error.issues.forEach((issue) => {
1399
+ console.log("Path:", issue.path);
1400
+ console.log("Message:", issue.message);
1401
+ });
1402
+ }
1403
+ ```
1404
+
1405
+ **Why Standard Schema Issues Instead of Original Validator Errors?**
1406
+
1407
+ By using Standard Schema's normalized issue format in the `cause` property, the library remains truly validator-agnostic. All validation libraries that implement Standard Schema (Zod, Valibot, ArkType, etc.) produce the same issue structure, making error handling consistent regardless of which validator you choose.
1408
+
1409
+ If you need validator-specific error formatting, you can still access your validator's methods during validation before the data reaches fmodata:
1410
+
1411
+ ```typescript
1412
+ import { z } from "zod";
1413
+
1414
+ const userSchema = z.object({
1415
+ email: z.string().email(),
1416
+ age: z.number().min(0).max(150),
1417
+ });
1418
+
1419
+ // Validate early if you need Zod-specific error handling
1420
+ const parseResult = userSchema.safeParse(userData);
1421
+ if (!parseResult.success) {
1422
+ // Use Zod's error formatting
1423
+ const formatted = parseResult.error.flatten();
1424
+ console.log("Zod-specific formatting:", formatted);
1425
+ }
1426
+ ```
1427
+
1428
+ #### OData Errors
1429
+
1430
+ Handle OData-specific protocol errors:
1431
+
1432
+ ```typescript
1433
+ import { ODataError, isODataError } from "@proofkit/fmodata";
1434
+
1435
+ const result = await db.from("users").list().execute();
36
1436
 
37
- MIT
1437
+ if (result.error) {
1438
+ if (isODataError(result.error)) {
1439
+ console.log("OData Error Code:", result.error.code);
1440
+ console.log("OData Error Message:", result.error.message);
1441
+ console.log("OData Error Details:", result.error.details);
1442
+ }
1443
+ }
1444
+ ```
1445
+
1446
+ #### Error Handling Patterns
1447
+
1448
+ **Pattern 1: Using instanceof (like ffetch):**
1449
+
1450
+ ```typescript
1451
+ import {
1452
+ HTTPError,
1453
+ ValidationError,
1454
+ TimeoutError,
1455
+ NetworkError,
1456
+ } from "@proofkit/fmodata";
1457
+
1458
+ const result = await db.from("users").list().execute();
1459
+
1460
+ if (result.error) {
1461
+ if (result.error instanceof TimeoutError) {
1462
+ showTimeoutMessage();
1463
+ } else if (result.error instanceof HTTPError) {
1464
+ if (result.error.isNotFound()) {
1465
+ showNotFoundMessage();
1466
+ } else if (result.error.is5xx()) {
1467
+ showServerErrorMessage();
1468
+ }
1469
+ } else if (result.error instanceof ValidationError) {
1470
+ showValidationError(result.error.field, result.error.issues);
1471
+ } else if (result.error instanceof NetworkError) {
1472
+ showOfflineMessage();
1473
+ }
1474
+ }
1475
+ ```
1476
+
1477
+ **Pattern 2: Using kind property (for exhaustive matching):**
1478
+
1479
+ ```typescript
1480
+ const result = await db.from("users").list().execute();
1481
+
1482
+ if (result.error) {
1483
+ switch (result.error.kind) {
1484
+ case "TimeoutError":
1485
+ showTimeoutMessage();
1486
+ break;
1487
+ case "HTTPError":
1488
+ handleHTTPError(result.error.status);
1489
+ break;
1490
+ case "ValidationError":
1491
+ showValidationError(result.error.field, result.error.issues);
1492
+ break;
1493
+ case "NetworkError":
1494
+ showOfflineMessage();
1495
+ break;
1496
+ case "ODataError":
1497
+ handleODataError(result.error.code);
1498
+ break;
1499
+ // TypeScript ensures exhaustive matching!
1500
+ }
1501
+ }
1502
+ ```
1503
+
1504
+ **Pattern 3: Using type guards:**
1505
+
1506
+ ```typescript
1507
+ import {
1508
+ isHTTPError,
1509
+ isValidationError,
1510
+ isODataError,
1511
+ isNetworkError,
1512
+ } from "@proofkit/fmodata";
1513
+
1514
+ const result = await db.from("users").list().execute();
1515
+
1516
+ if (result.error) {
1517
+ if (isHTTPError(result.error)) {
1518
+ // TypeScript knows this is HTTPError
1519
+ console.log("Status:", result.error.status);
1520
+ } else if (isValidationError(result.error)) {
1521
+ // TypeScript knows this is ValidationError
1522
+ console.log("Field:", result.error.field);
1523
+ console.log("Issues:", result.error.issues);
1524
+ } else if (isODataError(result.error)) {
1525
+ // TypeScript knows this is ODataError
1526
+ console.log("Code:", result.error.code);
1527
+ } else if (isNetworkError(result.error)) {
1528
+ // TypeScript knows this is NetworkError
1529
+ console.log("Network issue:", result.error.cause);
1530
+ }
1531
+ }
1532
+ ```
1533
+
1534
+ #### Error Properties
1535
+
1536
+ All errors include helpful metadata:
1537
+
1538
+ ```typescript
1539
+ if (result.error) {
1540
+ // All errors have a timestamp
1541
+ console.log("Error occurred at:", result.error.timestamp);
1542
+
1543
+ // All errors have a kind property for discriminated unions
1544
+ console.log("Error kind:", result.error.kind);
1545
+
1546
+ // All errors have a message
1547
+ console.log("Error message:", result.error.message);
1548
+ }
1549
+ ```
1550
+
1551
+ #### Available Error Types
1552
+
1553
+ - **`HTTPError`** - HTTP status errors (4xx, 5xx) with helper methods (`is4xx()`, `is5xx()`, `isNotFound()`, etc.)
1554
+ - **`ODataError`** - OData protocol errors with code and details
1555
+ - **`ValidationError`** - Schema validation failures with issues, schema reference, and failed value
1556
+ - **`ResponseStructureError`** - Malformed API responses
1557
+ - **`RecordCountMismatchError`** - When `single()` or `maybeSingle()` expectations aren't met
1558
+ - **`TimeoutError`** - Request timeout (from ffetch)
1559
+ - **`NetworkError`** - Network connectivity issues (from ffetch)
1560
+ - **`RetryLimitError`** - Request failed after retries (from ffetch)
1561
+ - **`CircuitOpenError`** - Circuit breaker is open (from ffetch)
1562
+ - **`AbortError`** - Request was aborted (from ffetch)
1563
+
1564
+ ### OData Annotations and Validation
1565
+
1566
+ By default, the library automatically strips OData annotations fields (`@id` and `@editLink`) from responses. If you need these fields, you can include them by passing `includeODataAnnotations: true`:
1567
+
1568
+ ```typescript
1569
+ const result = await db.from("users").list().execute({
1570
+ includeODataAnnotations: true,
1571
+ });
1572
+ ```
1573
+
1574
+ You can also skip runtime validation by passing `skipValidation: true`.
1575
+
1576
+ ```typescript
1577
+ const result = await db.from("users").list().execute({
1578
+ skipValidation: true,
1579
+ });
1580
+
1581
+ // Response is returned without schema validation
1582
+ ```
1583
+
1584
+ **Note:** Skipping validation means the response won't be validated OR transformed against your schema, so you lose runtime type safety guarantees. Use with caution.
1585
+
1586
+ ### Custom Fetch Handlers
1587
+
1588
+ You can provide custom fetch handlers for testing or custom networking:
1589
+
1590
+ ```typescript
1591
+ const customFetch = async (url, options) => {
1592
+ console.log("Fetching:", url);
1593
+ return fetch(url, options);
1594
+ };
1595
+
1596
+ const result = await db.from("users").list().execute({
1597
+ fetchHandler: customFetch,
1598
+ });
1599
+ ```
1600
+
1601
+ ## Testing
1602
+
1603
+ The library supports testing with custom fetch handlers. You can create mock fetch functions to return test data:
1604
+
1605
+ ```typescript
1606
+ const mockResponse = {
1607
+ "@odata.context": "...",
1608
+ value: [
1609
+ { id: "1", username: "john", email: "john@example.com" },
1610
+ { id: "2", username: "jane", email: "jane@example.com" },
1611
+ ],
1612
+ };
1613
+
1614
+ const mockFetch = async () => {
1615
+ return new Response(JSON.stringify(mockResponse), {
1616
+ status: 200,
1617
+ headers: { "content-type": "application/json" },
1618
+ });
1619
+ };
1620
+
1621
+ const result = await db.from("users").list().execute({
1622
+ fetchHandler: mockFetch,
1623
+ });
1624
+
1625
+ expect(result.data).toHaveLength(2);
1626
+ expect(result.data[0].username).toBe("john");
1627
+ ```
1628
+
1629
+ You can also inspect query strings without executing:
1630
+
1631
+ ```typescript
1632
+ const queryString = db
1633
+ .from("users")
1634
+ .list()
1635
+ .select("username", "email")
1636
+ .filter({ active: true })
1637
+ .orderBy("username")
1638
+ .top(10)
1639
+ .getQueryString();
1640
+
1641
+ console.log(queryString);
1642
+ // Output: "/users?$select=username,email&$filter=active eq true&$orderby=username&$top=10"
1643
+ ```