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

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 (2) hide show
  1. package/README.md +942 -17
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,8 @@
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. The API is subject to change. Feedback is welcome on the [community forum](https://community.ottomatic.cloud/c/proofkit/13) or here on GitHub.
4
6
 
5
7
  ## Installation
6
8
 
@@ -8,30 +10,953 @@ FileMaker OData API client
8
10
  pnpm add @proofkit/fmodata
9
11
  ```
10
12
 
11
- ## Usage
13
+ ## Quick Start
14
+
15
+ Here's a minimal example to get you started:
12
16
 
13
17
  ```typescript
14
- import { createODataClient } from "@proofkit/fmodata";
18
+ import { FileMakerOData, BaseTable, TableOccurrence } from "@proofkit/fmodata";
19
+ import { z } from "zod/v4";
20
+
21
+ // 1. Create a client
22
+ const client = new FileMakerOData({
23
+ serverUrl: "https://your-server.com",
24
+ auth: {
25
+ // OttoFMS API key
26
+ apiKey: "your-api-key",
27
+
28
+ // or username and password
29
+ // username: "admin",
30
+ // password: "password",
31
+ },
32
+ });
33
+
34
+ // 2. Define your table schema
35
+ const usersBase = new BaseTable({
36
+ schema: {
37
+ id: z.string(),
38
+ username: z.string(),
39
+ email: z.string(),
40
+ active: z.boolean(),
41
+ },
42
+ idField: "id",
43
+ });
44
+
45
+ // 3. Create a table occurrence
46
+ const usersTO = new TableOccurrence({
47
+ name: "users",
48
+ baseTable: usersBase,
49
+ });
50
+
51
+ // 4. Create a database instance
52
+ const db = client.database("MyDatabase.fmp12", {
53
+ occurrences: [usersTO],
54
+ });
55
+
56
+ // 5. Query your data
57
+ const { data, error } = await db.from("users").list().execute();
58
+
59
+ if (error) {
60
+ console.error(error);
61
+ return;
62
+ }
15
63
 
16
- // Usage example coming soon
64
+ if (data) {
65
+ console.log(data); // Array of users, properly typed
66
+ }
17
67
  ```
18
68
 
19
- ## Development
69
+ ## Core Concepts
20
70
 
21
- ```bash
22
- # Install dependencies
23
- pnpm install
71
+ 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. This will allow for each batch operations in case you want to execute multiple operations in a single request, as supported by the FileMaker OData API. It's also helpful to testing the library, as you can also call `getQueryString()` to get the OData query string without executing the request.
72
+
73
+ As such, there are layers to the library to help you build your queries and operations.
74
+
75
+ - `FileMakerOData` - hold server connection details and authentication
76
+ - `BaseTable` - defines the fields and validators for a base table
77
+ - `TableOccurrence` - references a base table, and other table occurrences for navigation
78
+ - `Database` - connects the table occurrences to the server connection
79
+
80
+ ### FileMaker Server prerequisites
81
+
82
+ To use this library you need:
83
+
84
+ - OData service enabled on your FileMaker server
85
+ - A FileMaker account with `fmodata` privilege enabled
86
+ - (if using OttoFMS) a Data API key setup for your FileMaker account with OData enabled
87
+
88
+ A note on best practices:
89
+
90
+ 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.
91
+
92
+ ### Server Connection
93
+
94
+ The client can authenticate using username/password or API key:
95
+
96
+ ```typescript
97
+ // Username and password authentication
98
+ const client = new FileMakerOData({
99
+ serverUrl: "https://api.example.com",
100
+ auth: {
101
+ username: "test",
102
+ password: "test",
103
+ },
104
+ });
105
+
106
+ // API key authentication
107
+ const client = new FileMakerOData({
108
+ serverUrl: "https://api.example.com",
109
+ auth: {
110
+ apiKey: "your-api-key",
111
+ },
112
+ });
113
+ ```
114
+
115
+ ### Schema Definitions
116
+
117
+ This library relies on a schema-first approach for good type-safety and optional runtime validation. These are absracted into BaseTable and TableOccurrence classes to match FileMaker concepts.
118
+
119
+ 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.
120
+
121
+ ```typescript
122
+ import { z } from "zod/v4";
123
+ import { BaseTable } from "@proofkit/fmodata";
124
+
125
+ const contactsBase = new BaseTable({
126
+ schema: {
127
+ id: z.string(),
128
+ name: z.string(),
129
+ email: z.string(),
130
+ phone: z.string().optional(),
131
+ createdAt: z.string(),
132
+ },
133
+ idField: "id", // The primary key field
134
+ insertRequired: ["name", "email"], // optional: fields that are required on insert
135
+ updateRequired: ["email"], // optional: fields that are required on update
136
+ });
137
+ ```
138
+
139
+ 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.
140
+
141
+ ```typescript
142
+ import { TableOccurrence } from "@proofkit/fmodata";
143
+
144
+ const contactsTO = new TableOccurrence({
145
+ name: "contacts", // The table occurrence name in FileMaker
146
+ baseTable: contactsBase,
147
+ });
148
+ ```
24
149
 
25
- # Build the package
26
- pnpm build
150
+ #### Default Field Selection
27
151
 
28
- # Run tests
29
- pnpm test
152
+ 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`.
30
153
 
31
- # Watch mode for development
32
- pnpm dev
154
+ ```typescript
155
+ // Option 1 (default): "schema" - Select all fields from the schema (same as "all" but more explicit)
156
+ const usersTO = new TableOccurrence({
157
+ name: "users",
158
+ baseTable: usersBase,
159
+ defaultSelect: "schema", // a $select parameter will be always be added to the query for only the fields you've defined in the BaseTable schema
160
+ });
161
+
162
+ // Option 2: "all" - Select all fields (default behavior)
163
+ const usersTO = new TableOccurrence({
164
+ name: "users",
165
+ baseTable: usersBase,
166
+ defaultSelect: "all", // Don't always a $select parameter to the query; FileMaker will return all non-container fields from the table
167
+ });
168
+
169
+ // Option 3: Array of field names - Select only specific fields by default
170
+ const usersTO = new TableOccurrence({
171
+ name: "users",
172
+ baseTable: usersBase,
173
+ defaultSelect: ["username", "email"], // Only select these fields by default
174
+ });
175
+
176
+ // When you call list(), the defaultSelect is applied automatically
177
+ const result = await db.from("users").list().execute();
178
+ // If defaultSelect is ["username", "email"], result.data will only contain those fields
179
+
180
+ // You can still override with explicit select()
181
+ const result = await db
182
+ .from("users")
183
+ .list()
184
+ .select("username", "email", "age") // Always overrides at the per-request level
185
+ .execute();
186
+ ```
187
+
188
+ Lastly, you can combine all table occurrences into a database instance for the full type-safe experience. This is a method on the main `FileMakerOData` client class.
189
+
190
+ ```typescript
191
+ const db = client.database("MyDatabase.fmp12", {
192
+ occurrences: [contactsTO, usersTO], // Register your table occurrences
193
+ });
194
+ ```
195
+
196
+ ## Querying Data
197
+
198
+ ### Basic Queries
199
+
200
+ Use `list()` to retrieve multiple records:
201
+
202
+ ```typescript
203
+ // Get all users
204
+ const result = await db.from("users").list().execute();
205
+
206
+ if (result.data) {
207
+ result.data.forEach((user) => {
208
+ console.log(user.username);
209
+ });
210
+ }
211
+ ```
212
+
213
+ Get a specific record by ID:
214
+
215
+ ```typescript
216
+ const result = await db.from("users").get("user-123").execute();
217
+
218
+ if (result.data) {
219
+ console.log(result.data.username);
220
+ }
221
+ ```
222
+
223
+ Get a single field value:
224
+
225
+ ```typescript
226
+ const result = await db
227
+ .from("users")
228
+ .get("user-123")
229
+ .getSingleField("email")
230
+ .execute();
231
+
232
+ if (result.data) {
233
+ console.log(result.data); // "user@example.com"
234
+ }
235
+ ```
236
+
237
+ ### Filtering
238
+
239
+ 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.
240
+
241
+ #### Operator Syntax
242
+
243
+ You can use filters in three ways:
244
+
245
+ **1. Shorthand (direct value):**
246
+
247
+ ```typescript
248
+ .filter({ name: "John" })
249
+ // Equivalent to: { name: [{ eq: "John" }] }
33
250
  ```
34
251
 
35
- ## License
252
+ **2. Single operator object:**
36
253
 
37
- MIT
254
+ ```typescript
255
+ .filter({ age: { gt: 18 } })
256
+ ```
257
+
258
+ **3. Array of operators (for multiple operators on same field):**
259
+
260
+ ```typescript
261
+ .filter({ age: [{ gt: 18 }, { lt: 65 }] })
262
+ // Result: age gt 18 and age lt 65
263
+ ```
264
+
265
+ The array pattern prevents duplicate operators on the same field and allows multiple conditions with implicit AND.
266
+
267
+ #### Available Operators
268
+
269
+ **String fields:**
270
+
271
+ - `eq`, `ne` - equality/inequality
272
+ - `contains`, `startswith`, `endswith` - string functions
273
+ - `gt`, `ge`, `lt`, `le` - comparison
274
+ - `in` - match any value in array
275
+
276
+ **Number fields:**
277
+
278
+ - `eq`, `ne`, `gt`, `ge`, `lt`, `le` - comparisons
279
+ - `in` - match any value in array
280
+
281
+ **Boolean fields:**
282
+
283
+ - `eq`, `ne` - equality only
284
+
285
+ **Date fields:**
286
+
287
+ - `eq`, `ne`, `gt`, `ge`, `lt`, `le` - date comparisons
288
+ - `in` - match any date in array
289
+
290
+ #### Shorthand Syntax
291
+
292
+ For simple equality checks, use the shorthand:
293
+
294
+ ```typescript
295
+ const result = await db.from("users").list().filter({ name: "John" }).execute();
296
+ // Equivalent to: { name: [{ eq: "John" }] }
297
+ ```
298
+
299
+ #### Examples
300
+
301
+ ```typescript
302
+ // Equality filter (single operator)
303
+ const activeUsers = await db
304
+ .from("users")
305
+ .list()
306
+ .filter({ active: { eq: true } })
307
+ .execute();
308
+
309
+ // Comparison operators (single operator)
310
+ const adultUsers = await db
311
+ .from("users")
312
+ .list()
313
+ .filter({ age: { gt: 18 } })
314
+ .execute();
315
+
316
+ // String operators (single operator)
317
+ const johns = await db
318
+ .from("users")
319
+ .list()
320
+ .filter({ name: { contains: "John" } })
321
+ .execute();
322
+
323
+ // Multiple operators on same field (array syntax, implicit AND)
324
+ const rangeQuery = await db
325
+ .from("users")
326
+ .list()
327
+ .filter({ age: [{ gt: 18 }, { lt: 65 }] })
328
+ .execute();
329
+
330
+ // Combine filters with AND
331
+ const result = await db
332
+ .from("users")
333
+ .list()
334
+ .filter({
335
+ and: [{ active: [{ eq: true }] }, { age: [{ gt: 18 }] }],
336
+ })
337
+ .execute();
338
+
339
+ // Combine filters with OR
340
+ const result = await db
341
+ .from("users")
342
+ .list()
343
+ .filter({
344
+ or: [{ name: [{ eq: "John" }] }, { name: [{ eq: "Jane" }] }],
345
+ })
346
+ .execute();
347
+
348
+ // IN operator
349
+ const result = await db
350
+ .from("users")
351
+ .list()
352
+ .filter({ age: [{ in: [18, 21, 25] }] })
353
+ .execute();
354
+
355
+ // Null checks
356
+ const result = await db
357
+ .from("users")
358
+ .list()
359
+ .filter({ deletedAt: [{ eq: null }] })
360
+ .execute();
361
+ ```
362
+
363
+ #### Logical Operators
364
+
365
+ Combine multiple conditions with `and`, `or`, `not`:
366
+
367
+ ```typescript
368
+ const result = await db
369
+ .from("users")
370
+ .list()
371
+ .filter({
372
+ and: [{ name: [{ contains: "John" }] }, { age: [{ gt: 18 }] }],
373
+ })
374
+ .execute();
375
+ ```
376
+
377
+ #### Escape Hatch
378
+
379
+ For unsupported edge cases, pass a raw OData filter string:
380
+
381
+ ```typescript
382
+ const result = await db
383
+ .from("users")
384
+ .list()
385
+ .filter("substringof('John', name)")
386
+ .execute();
387
+ ```
388
+
389
+ ### Sorting
390
+
391
+ Sort results using `orderBy()`:
392
+
393
+ ```typescript
394
+ // Sort ascending
395
+ const result = await db.from("users").list().orderBy("name").execute();
396
+
397
+ // Sort descending
398
+ const result = await db.from("users").list().orderBy("name desc").execute();
399
+
400
+ // Multiple sort fields
401
+ const result = await db
402
+ .from("users")
403
+ .list()
404
+ .orderBy("lastName, firstName desc")
405
+ .execute();
406
+ ```
407
+
408
+ ### Pagination
409
+
410
+ Control the number of records returned and pagination:
411
+
412
+ ```typescript
413
+ // Limit results
414
+ const result = await db.from("users").list().top(10).execute();
415
+
416
+ // Skip records (pagination)
417
+ const result = await db.from("users").list().top(10).skip(20).execute();
418
+
419
+ // Count total records
420
+ const result = await db.from("users").list().count().execute();
421
+ ```
422
+
423
+ ### Selecting Fields
424
+
425
+ Select specific fields to return:
426
+
427
+ ```typescript
428
+ const result = await db
429
+ .from("users")
430
+ .list()
431
+ .select("username", "email")
432
+ .execute();
433
+
434
+ // result.data[0] will only have username and email fields
435
+ ```
436
+
437
+ ### Single Records
438
+
439
+ Use `single()` to ensure exactly one record is returned (returns an error if zero or multiple records are found):
440
+
441
+ ```typescript
442
+ const result = await db
443
+ .from("users")
444
+ .list()
445
+ .filter({ email: { eq: "user@example.com" } })
446
+ .single()
447
+ .execute();
448
+
449
+ if (result.data) {
450
+ // result.data is a single record, not an array
451
+ console.log(result.data.username);
452
+ }
453
+ ```
454
+
455
+ Use `maybeSingle()` when you want at most one record (returns `null` if no record is found, returns an error if multiple records are found):
456
+
457
+ ```typescript
458
+ const result = await db
459
+ .from("users")
460
+ .list()
461
+ .filter({ email: { eq: "user@example.com" } })
462
+ .maybeSingle()
463
+ .execute();
464
+
465
+ if (result.data) {
466
+ // result.data is a single record or null
467
+ console.log(result.data?.username);
468
+ } else {
469
+ // No record found - result.data would be null
470
+ console.log("User not found");
471
+ }
472
+ ```
473
+
474
+ **Difference between `single()` and `maybeSingle()`:**
475
+
476
+ - `single()` - Requires exactly one record. Returns an error if zero or multiple records are found.
477
+ - `maybeSingle()` - Allows zero or one record. Returns `null` if no record is found, returns an error only if multiple records are found.
478
+
479
+ ### Chaining Methods
480
+
481
+ All query methods can be chained together:
482
+
483
+ ```typescript
484
+ const result = await db
485
+ .from("users")
486
+ .list()
487
+ .select("username", "email", "age")
488
+ .filter({ age: { gt: 18 } })
489
+ .orderBy("username")
490
+ .top(10)
491
+ .skip(0)
492
+ .execute();
493
+ ```
494
+
495
+ ## CRUD Operations
496
+
497
+ ### Insert
498
+
499
+ Insert new records with type-safe data:
500
+
501
+ ```typescript
502
+ // Insert a new user
503
+ const result = await db
504
+ .from("users")
505
+ .insert({
506
+ username: "johndoe",
507
+ email: "john@example.com",
508
+ active: true,
509
+ })
510
+ .execute();
511
+
512
+ if (result.data) {
513
+ console.log("Created user:", result.data);
514
+ }
515
+ ```
516
+
517
+ If you specify `insertRequired` fields in your base table, those fields become required:
518
+
519
+ ```typescript
520
+ const usersBase = new BaseTable({
521
+ schema: {
522
+ id: z.string(),
523
+ username: z.string(),
524
+ email: z.string(),
525
+ createdAt: z.string().optional(),
526
+ },
527
+ idField: "id",
528
+ insertRequired: ["username", "email"], // These fields are required on insert
529
+ });
530
+
531
+ // TypeScript will enforce that username and email are provided
532
+ const result = await db
533
+ .from("users")
534
+ .insert({
535
+ username: "johndoe",
536
+ email: "john@example.com",
537
+ // createdAt is optional
538
+ })
539
+ .execute();
540
+ ```
541
+
542
+ ### Update
543
+
544
+ Update records by ID or filter:
545
+
546
+ ```typescript
547
+ // Update by ID
548
+ const result = await db
549
+ .from("users")
550
+ .update({ username: "newname" })
551
+ .byId("user-123")
552
+ .execute();
553
+
554
+ if (result.data) {
555
+ console.log(`Updated ${result.data.updatedCount} record(s)`);
556
+ }
557
+
558
+ // Update by filter
559
+ const result = await db
560
+ .from("users")
561
+ .update({ active: false })
562
+ .where((q) => q.filter({ lastLogin: { lt: "2023-01-01" } }))
563
+ .execute();
564
+
565
+ // Complex filter example
566
+ const result = await db
567
+ .from("users")
568
+ .update({ active: false })
569
+ .where((q) =>
570
+ q.filter({
571
+ and: [{ active: true }, { count: { lt: 5 } }],
572
+ }),
573
+ )
574
+ .execute();
575
+
576
+ // Update with additional query options
577
+ const result = await db
578
+ .from("users")
579
+ .update({ active: false })
580
+ .where((q) => q.filter({ active: true }).top(10))
581
+ .execute();
582
+ ```
583
+
584
+ ### Delete
585
+
586
+ Delete records by ID or filter:
587
+
588
+ ```typescript
589
+ // Delete by ID
590
+ const result = await db.from("users").delete().byId("user-123").execute();
591
+
592
+ if (result.data) {
593
+ console.log(`Deleted ${result.data.deletedCount} record(s)`);
594
+ }
595
+
596
+ // Delete by filter
597
+ const result = await db
598
+ .from("users")
599
+ .delete()
600
+ .where((q) => q.filter({ active: false }))
601
+ .execute();
602
+
603
+ // Delete with complex filters
604
+ const result = await db
605
+ .from("users")
606
+ .delete()
607
+ .where((q) =>
608
+ q.filter({
609
+ and: [{ active: false }, { lastLogin: { lt: "2023-01-01" } }],
610
+ }),
611
+ )
612
+ .execute();
613
+ ```
614
+
615
+ ## Navigation & Relationships
616
+
617
+ ### Defining Navigation
618
+
619
+ Define relationships between tables using the `navigation` option:
620
+
621
+ ```typescript
622
+ const contactsBase = new BaseTable({
623
+ schema: {
624
+ id: z.string(),
625
+ name: z.string(),
626
+ userId: z.string(),
627
+ },
628
+ idField: "id",
629
+ });
630
+
631
+ const usersBase = new BaseTable({
632
+ schema: {
633
+ id: z.string(),
634
+ username: z.string(),
635
+ email: z.string(),
636
+ },
637
+ idField: "id",
638
+ });
639
+
640
+ // Define navigation using functions to handle circular dependencies
641
+ const contactsTO = new TableOccurrence({
642
+ name: "contacts",
643
+ baseTable: contactsBase,
644
+ navigation: {
645
+ users: () => usersTO, // Relationship to users table
646
+ },
647
+ });
648
+
649
+ const usersTO = new TableOccurrence({
650
+ name: "users",
651
+ baseTable: usersBase,
652
+ navigation: {
653
+ contacts: () => contactsTO, // Relationship to contacts table
654
+ },
655
+ });
656
+
657
+ // You can also add navigation after creation
658
+ const updatedUsersTO = usersTO.addNavigation({
659
+ profile: () => profileTO,
660
+ });
661
+ ```
662
+
663
+ ### Navigating Between Tables
664
+
665
+ Navigate to related records:
666
+
667
+ ```typescript
668
+ // Navigate from a specific record
669
+ const result = await db
670
+ .from("contacts")
671
+ .get("contact-123")
672
+ .navigate("users")
673
+ .select("username", "email")
674
+ .execute();
675
+
676
+ // Navigate without specifying a record first
677
+ const result = await db.from("contacts").navigate("users").list().execute();
678
+
679
+ // You can navigate to arbitrary tables not in your schema
680
+ const result = await db
681
+ .from("contacts")
682
+ .navigate("some_other_table")
683
+ .list()
684
+ .execute();
685
+ ```
686
+
687
+ ### Expanding Related Records
688
+
689
+ Use `expand()` to include related records in your query results:
690
+
691
+ ```typescript
692
+ // Simple expand
693
+ const result = await db.from("contacts").list().expand("users").execute();
694
+
695
+ // Expand with field selection
696
+ const result = await db
697
+ .from("contacts")
698
+ .list()
699
+ .expand("users", (b) => b.select("username", "email"))
700
+ .execute();
701
+
702
+ // Expand with filtering
703
+ const result = await db
704
+ .from("contacts")
705
+ .list()
706
+ .expand("users", (b) => b.filter({ active: true }))
707
+ .execute();
708
+
709
+ // Multiple expands
710
+ const result = await db
711
+ .from("contacts")
712
+ .list()
713
+ .expand("users", (b) => b.select("username"))
714
+ .expand("orders", (b) => b.select("total").top(5))
715
+ .execute();
716
+
717
+ // Nested expands
718
+ const result = await db
719
+ .from("contacts")
720
+ .list()
721
+ .expand("users", (usersBuilder) =>
722
+ usersBuilder
723
+ .select("username", "email")
724
+ .expand("customer", (customerBuilder) =>
725
+ customerBuilder.select("name", "tier"),
726
+ ),
727
+ )
728
+ .execute();
729
+
730
+ // Complex expand with multiple options
731
+ const result = await db
732
+ .from("contacts")
733
+ .list()
734
+ .expand("users", (b) =>
735
+ b
736
+ .select("username", "email")
737
+ .filter({ active: true })
738
+ .orderBy("username")
739
+ .top(10)
740
+ .expand("customer", (nested) => nested.select("name")),
741
+ )
742
+ .execute();
743
+ ```
744
+
745
+ ## Running Scripts
746
+
747
+ Execute FileMaker scripts via OData:
748
+
749
+ ```typescript
750
+ // Simple script execution
751
+ const result = await db.runScript("MyScriptName");
752
+
753
+ console.log(result.resultCode); // Script result code
754
+ console.log(result.result); // Optional script result string
755
+
756
+ // Pass parameters to script
757
+ const result = await db.runScript("MyScriptName", {
758
+ scriptParam: "some value",
759
+ });
760
+
761
+ // Script parameters can be strings, numbers, or objects
762
+ const result = await db.runScript("ProcessOrder", {
763
+ scriptParam: {
764
+ orderId: "12345",
765
+ action: "approve",
766
+ }, // Will be JSON stringified
767
+ });
768
+
769
+ // Validate script result with Zod schema
770
+ // NOTE: Your validator must be able to parse a string.
771
+ // See Zod codecs for how to build a jsonCodec function that does this
772
+ // https://zod.dev/codecs?id=jsonschema
773
+
774
+ const schema = jsonCodec(
775
+ z.object({
776
+ success: z.boolean(),
777
+ message: z.string(),
778
+ recordId: z.string(),
779
+ }),
780
+ );
781
+
782
+ const result = await db.runScript("CreateRecord", {
783
+ resultSchema: schema,
784
+ });
785
+
786
+ // result.result is now typed based on your schema
787
+ console.log(result.result.recordId);
788
+ ```
789
+
790
+ **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.
791
+
792
+ ## Advanced Features
793
+
794
+ ### Type Safety
795
+
796
+ The library provides full TypeScript type inference:
797
+
798
+ ```typescript
799
+ const usersBase = new BaseTable({
800
+ schema: {
801
+ id: z.string(),
802
+ username: z.string(),
803
+ email: z.string(),
804
+ },
805
+ idField: "id",
806
+ });
807
+
808
+ const usersTO = new TableOccurrence({
809
+ name: "users",
810
+ baseTable: usersBase,
811
+ });
812
+
813
+ const db = client.database("MyDB", {
814
+ occurrences: [usersTO],
815
+ });
816
+
817
+ // TypeScript knows these are valid field names
818
+ db.from("users").list().select("username", "email");
819
+
820
+ // TypeScript error: "invalid" is not a field name
821
+ db.from("users").list().select("invalid"); // TS Error
822
+
823
+ // Type-safe filters
824
+ db.from("users")
825
+ .list()
826
+ .filter({ username: { eq: "john" } }); // ✓
827
+ db.from("users")
828
+ .list()
829
+ .filter({ invalid: { eq: "john" } }); // TS Error
830
+ ```
831
+
832
+ ### Required Fields
833
+
834
+ Control which fields are required for insert and update operations:
835
+
836
+ ```typescript
837
+ const usersBase = new BaseTable({
838
+ schema: {
839
+ id: z.string(),
840
+ username: z.string(),
841
+ email: z.string(),
842
+ status: z.string(),
843
+ updatedAt: z.string().optional(),
844
+ },
845
+ idField: "id",
846
+ insertRequired: ["username", "email"], // Required on insert
847
+ updateRequired: ["status"], // Required on update
848
+ });
849
+
850
+ // Insert requires username and email
851
+ db.from("users").insert({
852
+ username: "john",
853
+ email: "john@example.com",
854
+ // updatedAt is optional
855
+ });
856
+
857
+ // Update requires status
858
+ db.from("users")
859
+ .update({
860
+ status: "active",
861
+ // other fields are optional
862
+ })
863
+ .byId("user-123");
864
+ ```
865
+
866
+ ### Error Handling
867
+
868
+ All operations return a `Result` type with either `data` or `error`:
869
+
870
+ ```typescript
871
+ const result = await db.from("users").list().execute();
872
+
873
+ if (result.error) {
874
+ console.error("Query failed:", result.error.message);
875
+ return;
876
+ }
877
+
878
+ if (result.data) {
879
+ console.log("Query succeeded:", result.data);
880
+ }
881
+ ```
882
+
883
+ ### OData Annotations and Validation
884
+
885
+ 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`:
886
+
887
+ ```typescript
888
+ const result = await db.from("users").list().execute({
889
+ includeODataAnnotations: true,
890
+ });
891
+ ```
892
+
893
+ You can also skip runtime validation by passing `skipValidation: true`.
894
+
895
+ ```typescript
896
+ const result = await db.from("users").list().execute({
897
+ skipValidation: true,
898
+ });
899
+
900
+ // Response is returned without schema validation
901
+ ```
902
+
903
+ **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.
904
+
905
+ ### Custom Fetch Handlers
906
+
907
+ You can provide custom fetch handlers for testing or custom networking:
908
+
909
+ ```typescript
910
+ const customFetch = async (url, options) => {
911
+ console.log("Fetching:", url);
912
+ return fetch(url, options);
913
+ };
914
+
915
+ const result = await db.from("users").list().execute({
916
+ fetchHandler: customFetch,
917
+ });
918
+ ```
919
+
920
+ ## Testing
921
+
922
+ The library supports testing with custom fetch handlers. You can create mock fetch functions to return test data:
923
+
924
+ ```typescript
925
+ const mockResponse = {
926
+ "@odata.context": "...",
927
+ value: [
928
+ { id: "1", username: "john", email: "john@example.com" },
929
+ { id: "2", username: "jane", email: "jane@example.com" },
930
+ ],
931
+ };
932
+
933
+ const mockFetch = async () => {
934
+ return new Response(JSON.stringify(mockResponse), {
935
+ status: 200,
936
+ headers: { "content-type": "application/json" },
937
+ });
938
+ };
939
+
940
+ const result = await db.from("users").list().execute({
941
+ fetchHandler: mockFetch,
942
+ });
943
+
944
+ expect(result.data).toHaveLength(2);
945
+ expect(result.data[0].username).toBe("john");
946
+ ```
947
+
948
+ You can also inspect query strings without executing:
949
+
950
+ ```typescript
951
+ const queryString = db
952
+ .from("users")
953
+ .list()
954
+ .select("username", "email")
955
+ .filter({ active: true })
956
+ .orderBy("username")
957
+ .top(10)
958
+ .getQueryString();
959
+
960
+ console.log(queryString);
961
+ // Output: "/users?$select=username,email&$filter=active eq true&$orderby=username&$top=10"
962
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proofkit/fmodata",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.1",
4
4
  "description": "FileMaker OData API client",
5
5
  "repository": "git@github.com:proofgeist/proofkit.git",
6
6
  "author": "Eric <37158449+eluce2@users.noreply.github.com>",