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

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 (79) hide show
  1. package/README.md +760 -69
  2. package/dist/esm/client/base-table.d.ts +120 -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/build-occurrences.d.ts +74 -0
  12. package/dist/esm/client/build-occurrences.js +31 -0
  13. package/dist/esm/client/build-occurrences.js.map +1 -0
  14. package/dist/esm/client/database.d.ts +55 -6
  15. package/dist/esm/client/database.js +118 -15
  16. package/dist/esm/client/database.js.map +1 -1
  17. package/dist/esm/client/delete-builder.d.ts +21 -2
  18. package/dist/esm/client/delete-builder.js +96 -32
  19. package/dist/esm/client/delete-builder.js.map +1 -1
  20. package/dist/esm/client/entity-set.d.ts +26 -12
  21. package/dist/esm/client/entity-set.js +43 -12
  22. package/dist/esm/client/entity-set.js.map +1 -1
  23. package/dist/esm/client/filemaker-odata.d.ts +23 -4
  24. package/dist/esm/client/filemaker-odata.js +124 -29
  25. package/dist/esm/client/filemaker-odata.js.map +1 -1
  26. package/dist/esm/client/insert-builder.d.ts +38 -3
  27. package/dist/esm/client/insert-builder.js +231 -34
  28. package/dist/esm/client/insert-builder.js.map +1 -1
  29. package/dist/esm/client/query-builder.d.ts +28 -7
  30. package/dist/esm/client/query-builder.js +470 -212
  31. package/dist/esm/client/query-builder.js.map +1 -1
  32. package/dist/esm/client/record-builder.d.ts +96 -10
  33. package/dist/esm/client/record-builder.js +378 -39
  34. package/dist/esm/client/record-builder.js.map +1 -1
  35. package/dist/esm/client/response-processor.d.ts +38 -0
  36. package/dist/esm/client/schema-manager.d.ts +57 -0
  37. package/dist/esm/client/schema-manager.js +132 -0
  38. package/dist/esm/client/schema-manager.js.map +1 -0
  39. package/dist/esm/client/table-occurrence.d.ts +69 -8
  40. package/dist/esm/client/table-occurrence.js +35 -24
  41. package/dist/esm/client/table-occurrence.js.map +1 -1
  42. package/dist/esm/client/update-builder.d.ts +34 -11
  43. package/dist/esm/client/update-builder.js +135 -31
  44. package/dist/esm/client/update-builder.js.map +1 -1
  45. package/dist/esm/errors.d.ts +73 -0
  46. package/dist/esm/errors.js +148 -0
  47. package/dist/esm/errors.js.map +1 -0
  48. package/dist/esm/index.d.ts +13 -3
  49. package/dist/esm/index.js +29 -7
  50. package/dist/esm/index.js.map +1 -1
  51. package/dist/esm/transform.d.ts +65 -0
  52. package/dist/esm/transform.js +114 -0
  53. package/dist/esm/transform.js.map +1 -0
  54. package/dist/esm/types.d.ts +89 -5
  55. package/dist/esm/validation.d.ts +6 -3
  56. package/dist/esm/validation.js +104 -33
  57. package/dist/esm/validation.js.map +1 -1
  58. package/package.json +10 -1
  59. package/src/client/base-table.ts +161 -8
  60. package/src/client/batch-builder.ts +265 -0
  61. package/src/client/batch-request.ts +485 -0
  62. package/src/client/build-occurrences.ts +155 -0
  63. package/src/client/database.ts +175 -18
  64. package/src/client/delete-builder.ts +149 -48
  65. package/src/client/entity-set.ts +134 -28
  66. package/src/client/filemaker-odata.ts +179 -35
  67. package/src/client/insert-builder.ts +350 -40
  68. package/src/client/query-builder.ts +632 -244
  69. package/src/client/query-builder.ts.bak +1457 -0
  70. package/src/client/record-builder.ts +692 -68
  71. package/src/client/response-processor.ts +103 -0
  72. package/src/client/schema-manager.ts +246 -0
  73. package/src/client/table-occurrence.ts +107 -51
  74. package/src/client/update-builder.ts +235 -49
  75. package/src/errors.ts +217 -0
  76. package/src/index.ts +63 -6
  77. package/src/transform.ts +249 -0
  78. package/src/types.ts +201 -35
  79. package/src/validation.ts +120 -36
package/README.md CHANGED
@@ -2,12 +2,21 @@
2
2
 
3
3
  A strongly-typed FileMaker OData API client.
4
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.
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
6
15
 
7
16
  ## Installation
8
17
 
9
18
  ```bash
10
- pnpm add @proofkit/fmodata
19
+ pnpm add @proofkit/fmodata@alpha
11
20
  ```
12
21
 
13
22
  ## Quick Start
@@ -15,11 +24,15 @@ pnpm add @proofkit/fmodata
15
24
  Here's a minimal example to get you started:
16
25
 
17
26
  ```typescript
18
- import { FileMakerOData, BaseTable, TableOccurrence } from "@proofkit/fmodata";
27
+ import {
28
+ FMServerConnection,
29
+ defineBaseTable,
30
+ defineTableOccurrence,
31
+ } from "@proofkit/fmodata";
19
32
  import { z } from "zod/v4";
20
33
 
21
- // 1. Create a client
22
- const client = new FileMakerOData({
34
+ // 1. Create a connection to the server
35
+ const connection = new FMServerConnection({
23
36
  serverUrl: "https://your-server.com",
24
37
  auth: {
25
38
  // OttoFMS API key
@@ -32,7 +45,7 @@ const client = new FileMakerOData({
32
45
  });
33
46
 
34
47
  // 2. Define your table schema
35
- const usersBase = new BaseTable({
48
+ const usersBase = defineBaseTable({
36
49
  schema: {
37
50
  id: z.string(),
38
51
  username: z.string(),
@@ -43,13 +56,13 @@ const usersBase = new BaseTable({
43
56
  });
44
57
 
45
58
  // 3. Create a table occurrence
46
- const usersTO = new TableOccurrence({
59
+ const usersTO = defineTableOccurrence({
47
60
  name: "users",
48
61
  baseTable: usersBase,
49
62
  });
50
63
 
51
64
  // 4. Create a database instance
52
- const db = client.database("MyDatabase.fmp12", {
65
+ const db = connection.database("MyDatabase.fmp12", {
53
66
  occurrences: [usersTO],
54
67
  });
55
68
 
@@ -68,11 +81,11 @@ if (data) {
68
81
 
69
82
  ## Core Concepts
70
83
 
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.
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.
72
85
 
73
86
  As such, there are layers to the library to help you build your queries and operations.
74
87
 
75
- - `FileMakerOData` - hold server connection details and authentication
88
+ - `FMServerConnection` - hold server connection details and authentication
76
89
  - `BaseTable` - defines the fields and validators for a base table
77
90
  - `TableOccurrence` - references a base table, and other table occurrences for navigation
78
91
  - `Database` - connects the table occurrences to the server connection
@@ -95,7 +108,7 @@ The client can authenticate using username/password or API key:
95
108
 
96
109
  ```typescript
97
110
  // Username and password authentication
98
- const client = new FileMakerOData({
111
+ const connection = new FMServerConnection({
99
112
  serverUrl: "https://api.example.com",
100
113
  auth: {
101
114
  username: "test",
@@ -104,7 +117,7 @@ const client = new FileMakerOData({
104
117
  });
105
118
 
106
119
  // API key authentication
107
- const client = new FileMakerOData({
120
+ const connection = new FMServerConnection({
108
121
  serverUrl: "https://api.example.com",
109
122
  auth: {
110
123
  apiKey: "your-api-key",
@@ -114,15 +127,17 @@ const client = new FileMakerOData({
114
127
 
115
128
  ### Schema Definitions
116
129
 
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.
130
+ This library relies on a schema-first approach for good type-safety and optional runtime validation. These are abstracted into BaseTable and TableOccurrence types to match FileMaker concepts.
131
+
132
+ Use **`defineBaseTable()`** and **`defineTableOccurrence()`** to create your schemas. These functions provide full TypeScript type inference for field names in queries.
118
133
 
119
134
  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
135
 
121
136
  ```typescript
122
137
  import { z } from "zod/v4";
123
- import { BaseTable } from "@proofkit/fmodata";
138
+ import { defineBaseTable } from "@proofkit/fmodata";
124
139
 
125
- const contactsBase = new BaseTable({
140
+ const contactsBase = defineBaseTable({
126
141
  schema: {
127
142
  id: z.string(),
128
143
  name: z.string(),
@@ -130,18 +145,18 @@ const contactsBase = new BaseTable({
130
145
  phone: z.string().optional(),
131
146
  createdAt: z.string(),
132
147
  },
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
148
+ idField: "id", // The primary key field (automatically read-only)
149
+ required: ["phone"], // optional: additional required fields for insert (beyond auto-inferred)
150
+ readOnly: ["createdAt"], // optional: fields excluded from insert/update
136
151
  });
137
152
  ```
138
153
 
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.
154
+ A `TableOccurrence` is the actual entry point for the OData service on the FileMaker server. It allows you to reference the same base table multiple times with different names.
140
155
 
141
156
  ```typescript
142
- import { TableOccurrence } from "@proofkit/fmodata";
157
+ import { defineTableOccurrence } from "@proofkit/fmodata";
143
158
 
144
- const contactsTO = new TableOccurrence({
159
+ const contactsTO = defineTableOccurrence({
145
160
  name: "contacts", // The table occurrence name in FileMaker
146
161
  baseTable: contactsBase,
147
162
  });
@@ -153,21 +168,21 @@ FileMaker will automatically return all non-container fields from a schema if yo
153
168
 
154
169
  ```typescript
155
170
  // Option 1 (default): "schema" - Select all fields from the schema (same as "all" but more explicit)
156
- const usersTO = new TableOccurrence({
171
+ const usersTO = defineTableOccurrence({
157
172
  name: "users",
158
173
  baseTable: usersBase,
159
174
  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
175
  });
161
176
 
162
177
  // Option 2: "all" - Select all fields (default behavior)
163
- const usersTO = new TableOccurrence({
178
+ const usersTO = defineTableOccurrence({
164
179
  name: "users",
165
180
  baseTable: usersBase,
166
181
  defaultSelect: "all", // Don't always a $select parameter to the query; FileMaker will return all non-container fields from the table
167
182
  });
168
183
 
169
184
  // Option 3: Array of field names - Select only specific fields by default
170
- const usersTO = new TableOccurrence({
185
+ const usersTO = defineTableOccurrence({
171
186
  name: "users",
172
187
  baseTable: usersBase,
173
188
  defaultSelect: ["username", "email"], // Only select these fields by default
@@ -185,10 +200,10 @@ const result = await db
185
200
  .execute();
186
201
  ```
187
202
 
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.
203
+ 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.
189
204
 
190
205
  ```typescript
191
- const db = client.database("MyDatabase.fmp12", {
206
+ const db = connection.database("MyDatabase.fmp12", {
192
207
  occurrences: [contactsTO, usersTO], // Register your table occurrences
193
208
  });
194
209
  ```
@@ -514,27 +529,30 @@ if (result.data) {
514
529
  }
515
530
  ```
516
531
 
517
- If you specify `insertRequired` fields in your base table, those fields become required:
532
+ Fields are automatically required for insert if their validator doesn't allow `null` or `undefined`. You can specify additional required fields:
518
533
 
519
534
  ```typescript
520
- const usersBase = new BaseTable({
535
+ const usersBase = defineBaseTable({
521
536
  schema: {
522
- id: z.string(),
523
- username: z.string(),
524
- email: z.string(),
525
- createdAt: z.string().optional(),
537
+ id: z.string(), // Auto-required (not nullable), but excluded from insert (idField)
538
+ username: z.string(), // Auto-required (not nullable)
539
+ email: z.string(), // Auto-required (not nullable)
540
+ phone: z.string().nullable(), // Optional by default
541
+ createdAt: z.string(), // Auto-required, but excluded (readOnly)
526
542
  },
527
- idField: "id",
528
- insertRequired: ["username", "email"], // These fields are required on insert
543
+ idField: "id", // Automatically excluded from insert/update
544
+ required: ["phone"], // Make phone required for inserts despite being nullable
545
+ readOnly: ["createdAt"], // Exclude from insert/update operations
529
546
  });
530
547
 
531
- // TypeScript will enforce that username and email are provided
548
+ // TypeScript enforces: username, email, and phone are required
549
+ // TypeScript excludes: id and createdAt cannot be provided
532
550
  const result = await db
533
551
  .from("users")
534
552
  .insert({
535
553
  username: "johndoe",
536
554
  email: "john@example.com",
537
- // createdAt is optional
555
+ phone: "+1234567890", // Required because specified in 'required' array
538
556
  })
539
557
  .execute();
540
558
  ```
@@ -616,10 +634,16 @@ const result = await db
616
634
 
617
635
  ### Defining Navigation
618
636
 
619
- Define relationships between tables using the `navigation` option:
637
+ Use `buildOccurrences()` to define relationships between tables. This function takes an array of table occurrences and a configuration object that specifies navigation relationships using type-safe string references:
620
638
 
621
639
  ```typescript
622
- const contactsBase = new BaseTable({
640
+ import {
641
+ defineBaseTable,
642
+ defineTableOccurrence,
643
+ buildOccurrences,
644
+ } from "@proofkit/fmodata";
645
+
646
+ const contactsBase = defineBaseTable({
623
647
  schema: {
624
648
  id: z.string(),
625
649
  name: z.string(),
@@ -628,7 +652,7 @@ const contactsBase = new BaseTable({
628
652
  idField: "id",
629
653
  });
630
654
 
631
- const usersBase = new BaseTable({
655
+ const usersBase = defineBaseTable({
632
656
  schema: {
633
657
  id: z.string(),
634
658
  username: z.string(),
@@ -637,29 +661,43 @@ const usersBase = new BaseTable({
637
661
  idField: "id",
638
662
  });
639
663
 
640
- // Define navigation using functions to handle circular dependencies
641
- const contactsTO = new TableOccurrence({
664
+ // Step 1: Define base table occurrences (without navigation)
665
+ const _contactsTO = defineTableOccurrence({
642
666
  name: "contacts",
643
667
  baseTable: contactsBase,
644
- navigation: {
645
- users: () => usersTO, // Relationship to users table
646
- },
647
668
  });
648
669
 
649
- const usersTO = new TableOccurrence({
670
+ const _usersTO = defineTableOccurrence({
650
671
  name: "users",
651
672
  baseTable: usersBase,
673
+ });
674
+
675
+ // Step 2: Build occurrences with navigation using string references
676
+ // The strings autocomplete to valid table occurrence names!
677
+ const occurrences = buildOccurrences({
678
+ occurrences: [_contactsTO, _usersTO],
652
679
  navigation: {
653
- contacts: () => contactsTO, // Relationship to contacts table
680
+ contacts: ["users"],
681
+ users: ["contacts"],
654
682
  },
655
683
  });
656
684
 
657
- // You can also add navigation after creation
658
- const updatedUsersTO = usersTO.addNavigation({
659
- profile: () => profileTO,
685
+ // Use with your database
686
+ const db = connection.database("MyDB", {
687
+ occurrences: occurrences,
660
688
  });
661
689
  ```
662
690
 
691
+ The `buildOccurrences` function accepts an object with:
692
+
693
+ - `occurrences` - Array of TableOccurrences to build
694
+ - `navigation` - Optional object mapping TO names to arrays of navigation targets
695
+
696
+ It returns a tuple in the same order as the input array, with full autocomplete for navigation target names. Self-navigation is prevented at the type level.
697
+
698
+ - Handles circular references automatically
699
+ - Returns fully typed `TableOccurrence` instances with resolved navigation
700
+
663
701
  ### Navigating Between Tables
664
702
 
665
703
  Navigate to related records:
@@ -789,6 +827,334 @@ console.log(result.result.recordId);
789
827
 
790
828
  **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
829
 
830
+ ## Batch Operations
831
+
832
+ 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.
833
+
834
+ ### Basic Batch with Multiple Queries
835
+
836
+ Execute multiple read operations in a single batch:
837
+
838
+ ```typescript
839
+ // Create query builders
840
+ const contactsQuery = db.from("contacts").list().top(5);
841
+ const usersQuery = db.from("users").list().top(5);
842
+
843
+ // Execute both queries in a single batch
844
+ const result = await db.batch([contactsQuery, usersQuery]).execute();
845
+
846
+ if (result.data) {
847
+ // Result is a tuple matching the input builders
848
+ const [contacts, users] = result.data;
849
+
850
+ console.log("Contacts:", contacts);
851
+ console.log("Users:", users);
852
+ }
853
+ ```
854
+
855
+ ### Mixed Operations (Reads and Writes)
856
+
857
+ Combine queries, inserts, updates, and deletes in a single batch:
858
+
859
+ ```typescript
860
+ // Mix different operation types
861
+ const listQuery = db.from("contacts").list().top(10);
862
+ const insertOp = db.from("contacts").insert({
863
+ name: "John Doe",
864
+ email: "john@example.com",
865
+ });
866
+ const updateOp = db.from("users").update({ active: true }).byId("user-123");
867
+
868
+ // All operations execute atomically
869
+ const result = await db.batch([listQuery, insertOp, updateOp]).execute();
870
+
871
+ if (result.data) {
872
+ const [contactsList, insertResult, updateResult] = result.data;
873
+
874
+ console.log("Fetched contacts:", contactsList);
875
+ console.log("Inserted contact:", insertResult);
876
+ console.log("Updated user:", updateResult);
877
+ }
878
+ ```
879
+
880
+ ### Transactional Behavior
881
+
882
+ Batch operations are transactional for write operations (inserts, updates, deletes). If any operation in the batch fails, all write operations are rolled back:
883
+
884
+ ```typescript
885
+ const result = await db
886
+ .batch([
887
+ db.from("users").insert({ username: "alice", email: "alice@example.com" }),
888
+ db.from("users").insert({ username: "bob", email: "bob@example.com" }),
889
+ db.from("users").insert({ username: "charlie", email: "invalid" }), // This fails
890
+ ])
891
+ .execute();
892
+
893
+ if (result.error) {
894
+ // All three inserts are rolled back - no users were created
895
+ console.error("Batch failed:", result.error);
896
+ }
897
+ ```
898
+
899
+ **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.
900
+
901
+ ## Schema Management
902
+
903
+ 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.
904
+
905
+ ### Creating Tables
906
+
907
+ Create a new table with field definitions:
908
+
909
+ ```typescript
910
+ import type { Field } from "@proofkit/fmodata";
911
+
912
+ const fields: Field[] = [
913
+ {
914
+ name: "id",
915
+ type: "string",
916
+ primary: true,
917
+ maxLength: 36,
918
+ },
919
+ {
920
+ name: "username",
921
+ type: "string",
922
+ nullable: false,
923
+ unique: true,
924
+ maxLength: 50,
925
+ },
926
+ {
927
+ name: "email",
928
+ type: "string",
929
+ nullable: false,
930
+ maxLength: 255,
931
+ },
932
+ {
933
+ name: "age",
934
+ type: "numeric",
935
+ nullable: true,
936
+ },
937
+ {
938
+ name: "created_at",
939
+ type: "timestamp",
940
+ default: "CURRENT_TIMESTAMP",
941
+ },
942
+ ];
943
+
944
+ const tableDefinition = await db.schema.createTable("users", fields);
945
+ console.log(tableDefinition.tableName); // "users"
946
+ console.log(tableDefinition.fields); // Array of field definitions
947
+ ```
948
+
949
+ ### Field Types
950
+
951
+ The library supports various field types:
952
+
953
+ **String Fields:**
954
+
955
+ ```typescript
956
+ {
957
+ name: "username",
958
+ type: "string",
959
+ maxLength: 100, // Optional: varchar(100)
960
+ nullable: true,
961
+ unique: true,
962
+ default: "USER" | "USERNAME" | "CURRENT_USER", // Optional
963
+ repetitions: 5, // Optional: for repeating fields
964
+ }
965
+ ```
966
+
967
+ **Numeric Fields:**
968
+
969
+ ```typescript
970
+ {
971
+ name: "age",
972
+ type: "numeric",
973
+ nullable: true,
974
+ primary: false,
975
+ unique: false,
976
+ }
977
+ ```
978
+
979
+ **Date Fields:**
980
+
981
+ ```typescript
982
+ {
983
+ name: "birth_date",
984
+ type: "date",
985
+ default: "CURRENT_DATE" | "CURDATE", // Optional
986
+ nullable: true,
987
+ }
988
+ ```
989
+
990
+ **Time Fields:**
991
+
992
+ ```typescript
993
+ {
994
+ name: "start_time",
995
+ type: "time",
996
+ default: "CURRENT_TIME" | "CURTIME", // Optional
997
+ nullable: true,
998
+ }
999
+ ```
1000
+
1001
+ **Timestamp Fields:**
1002
+
1003
+ ```typescript
1004
+ {
1005
+ name: "created_at",
1006
+ type: "timestamp",
1007
+ default: "CURRENT_TIMESTAMP" | "CURTIMESTAMP", // Optional
1008
+ nullable: false,
1009
+ }
1010
+ ```
1011
+
1012
+ **Container Fields:**
1013
+
1014
+ ```typescript
1015
+ {
1016
+ name: "avatar",
1017
+ type: "container",
1018
+ externalSecurePath: "/secure/path", // Optional
1019
+ nullable: true,
1020
+ }
1021
+ ```
1022
+
1023
+ ### Adding Fields to Existing Tables
1024
+
1025
+ Add new fields to an existing table:
1026
+
1027
+ ```typescript
1028
+ const newFields: Field[] = [
1029
+ {
1030
+ name: "phone",
1031
+ type: "string",
1032
+ nullable: true,
1033
+ maxLength: 20,
1034
+ },
1035
+ {
1036
+ name: "bio",
1037
+ type: "string",
1038
+ nullable: true,
1039
+ maxLength: 1000,
1040
+ },
1041
+ ];
1042
+
1043
+ const updatedTable = await db.schema.addFields("users", newFields);
1044
+ ```
1045
+
1046
+ ### Deleting Tables and Fields
1047
+
1048
+ Delete an entire table:
1049
+
1050
+ ```typescript
1051
+ await db.schema.deleteTable("old_table");
1052
+ ```
1053
+
1054
+ Delete a specific field from a table:
1055
+
1056
+ ```typescript
1057
+ await db.schema.deleteField("users", "old_field");
1058
+ ```
1059
+
1060
+ ### Managing Indexes
1061
+
1062
+ Create an index on a field:
1063
+
1064
+ ```typescript
1065
+ const index = await db.schema.createIndex("users", "email");
1066
+ console.log(index.indexName); // "email"
1067
+ ```
1068
+
1069
+ Delete an index:
1070
+
1071
+ ```typescript
1072
+ await db.schema.deleteIndex("users", "email");
1073
+ ```
1074
+
1075
+ ### Complete Example
1076
+
1077
+ Here's a complete example of creating a table with various field types:
1078
+
1079
+ ```typescript
1080
+ const fields: Field[] = [
1081
+ // Primary key
1082
+ {
1083
+ name: "id",
1084
+ type: "string",
1085
+ primary: true,
1086
+ maxLength: 36,
1087
+ },
1088
+
1089
+ // String fields
1090
+ {
1091
+ name: "username",
1092
+ type: "string",
1093
+ nullable: false,
1094
+ unique: true,
1095
+ maxLength: 50,
1096
+ },
1097
+ {
1098
+ name: "email",
1099
+ type: "string",
1100
+ nullable: false,
1101
+ maxLength: 255,
1102
+ },
1103
+
1104
+ // Numeric field
1105
+ {
1106
+ name: "age",
1107
+ type: "numeric",
1108
+ nullable: true,
1109
+ },
1110
+
1111
+ // Date/time fields
1112
+ {
1113
+ name: "birth_date",
1114
+ type: "date",
1115
+ nullable: true,
1116
+ },
1117
+ {
1118
+ name: "created_at",
1119
+ type: "timestamp",
1120
+ default: "CURRENT_TIMESTAMP",
1121
+ nullable: false,
1122
+ },
1123
+
1124
+ // Container field
1125
+ {
1126
+ name: "avatar",
1127
+ type: "container",
1128
+ nullable: true,
1129
+ },
1130
+
1131
+ // Repeating field
1132
+ {
1133
+ name: "tags",
1134
+ type: "string",
1135
+ repetitions: 5,
1136
+ maxLength: 50,
1137
+ },
1138
+ ];
1139
+
1140
+ // Create the table
1141
+ const table = await db.schema.createTable("users", fields);
1142
+
1143
+ // Later, add more fields
1144
+ await db.schema.addFields("users", [
1145
+ {
1146
+ name: "phone",
1147
+ type: "string",
1148
+ nullable: true,
1149
+ },
1150
+ ]);
1151
+
1152
+ // Create an index on email
1153
+ await db.schema.createIndex("users", "email");
1154
+ ```
1155
+
1156
+ **Note:** Schema management operations require appropriate access privileges on your FileMaker account. Operations will throw errors if you don't have the necessary permissions.
1157
+
792
1158
  ## Advanced Features
793
1159
 
794
1160
  ### Type Safety
@@ -796,7 +1162,7 @@ console.log(result.result.recordId);
796
1162
  The library provides full TypeScript type inference:
797
1163
 
798
1164
  ```typescript
799
- const usersBase = new BaseTable({
1165
+ const usersBase = defineBaseTable({
800
1166
  schema: {
801
1167
  id: z.string(),
802
1168
  username: z.string(),
@@ -805,12 +1171,12 @@ const usersBase = new BaseTable({
805
1171
  idField: "id",
806
1172
  });
807
1173
 
808
- const usersTO = new TableOccurrence({
1174
+ const usersTO = defineTableOccurrence({
809
1175
  name: "users",
810
1176
  baseTable: usersBase,
811
1177
  });
812
1178
 
813
- const db = client.database("MyDB", {
1179
+ const db = connection.database("MyDB", {
814
1180
  occurrences: [usersTO],
815
1181
  });
816
1182
 
@@ -829,43 +1195,97 @@ db.from("users")
829
1195
  .filter({ invalid: { eq: "john" } }); // TS Error
830
1196
  ```
831
1197
 
832
- ### Required Fields
1198
+ ### Required and Read-Only Fields
833
1199
 
834
- Control which fields are required for insert and update operations:
1200
+ The library automatically infers which fields are required based on whether their validator allows `null` or `undefined`:
835
1201
 
836
1202
  ```typescript
837
- const usersBase = new BaseTable({
1203
+ const usersBase = defineBaseTable({
838
1204
  schema: {
839
- id: z.string(),
840
- username: z.string(),
841
- email: z.string(),
842
- status: z.string(),
843
- updatedAt: z.string().optional(),
1205
+ id: z.string(), // Auto-required, auto-readOnly (idField)
1206
+ username: z.string(), // Auto-required (not nullable)
1207
+ email: z.string(), // Auto-required (not nullable)
1208
+ status: z.string().nullable(), // Optional (nullable)
1209
+ createdAt: z.string(), // Read-only system field
1210
+ updatedAt: z.string().nullable(), // Optional
844
1211
  },
845
- idField: "id",
846
- insertRequired: ["username", "email"], // Required on insert
847
- updateRequired: ["status"], // Required on update
1212
+ idField: "id", // Automatically excluded from insert/update
1213
+ required: ["status"], // Make status required despite being nullable
1214
+ readOnly: ["createdAt"], // Exclude createdAt from insert/update
848
1215
  });
849
1216
 
850
- // Insert requires username and email
1217
+ // Insert: username, email, and status are required
1218
+ // Insert: id and createdAt are excluded (cannot be provided)
851
1219
  db.from("users").insert({
852
1220
  username: "john",
853
1221
  email: "john@example.com",
854
- // updatedAt is optional
1222
+ status: "active", // Required due to 'required' array
1223
+ updatedAt: new Date().toISOString(), // Optional
855
1224
  });
856
1225
 
857
- // Update requires status
1226
+ // Update: all fields are optional except id and createdAt are excluded
858
1227
  db.from("users")
859
1228
  .update({
860
- status: "active",
861
- // other fields are optional
1229
+ status: "active", // Optional
1230
+ // id and createdAt cannot be modified
862
1231
  })
863
1232
  .byId("user-123");
864
1233
  ```
865
1234
 
1235
+ **Key Features:**
1236
+
1237
+ - **Auto-inference:** Non-nullable fields are automatically required for insert
1238
+ - **Additional requirements:** Use `required` to make nullable fields required for new records
1239
+ - **Read-only fields:** Use `readOnly` to exclude fields from insert/update (e.g., timestamps)
1240
+ - **Automatic ID exclusion:** The `idField` is always read-only without needing to specify it
1241
+ - **Update flexibility:** All fields are optional for updates (except read-only fields)
1242
+
1243
+ ### Prefer: fmodata.entity-ids
1244
+
1245
+ 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.
1246
+
1247
+ 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).
1248
+
1249
+ _Note for OttoFMS proxy: This feature requires version 4.14 or later of OttoFMS_
1250
+
1251
+ 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
1252
+
1253
+ #### Basic Usage
1254
+
1255
+ ```typescript
1256
+ import { defineBaseTable, defineTableOccurrence } from "@proofkit/fmodata";
1257
+ import { z } from "zod/v4";
1258
+
1259
+ // Define a base table with FileMaker field IDs
1260
+ const usersBase = defineBaseTable({
1261
+ schema: {
1262
+ id: z.string(),
1263
+ username: z.string(),
1264
+ email: z.string().nullable(),
1265
+ createdAt: z.string(),
1266
+ },
1267
+ idField: "id",
1268
+ fmfIds: {
1269
+ id: "FMFID:12039485",
1270
+ username: "FMFID:34323433",
1271
+ email: "FMFID:12232424",
1272
+ createdAt: "FMFID:43234355",
1273
+ },
1274
+ });
1275
+
1276
+ // Create a table occurrence with a FileMaker table occurrence ID
1277
+ const usersTO = defineTableOccurrence({
1278
+ name: "users",
1279
+ baseTable: usersBase,
1280
+ fmtId: "FMTID:12432533",
1281
+ });
1282
+ ```
1283
+
866
1284
  ### Error Handling
867
1285
 
868
- All operations return a `Result` type with either `data` or `error`:
1286
+ 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.
1287
+
1288
+ #### Basic Error Checking
869
1289
 
870
1290
  ```typescript
871
1291
  const result = await db.from("users").list().execute();
@@ -880,6 +1300,277 @@ if (result.data) {
880
1300
  }
881
1301
  ```
882
1302
 
1303
+ #### HTTP Errors
1304
+
1305
+ Handle HTTP status codes (4xx, 5xx) with the `HTTPError` class:
1306
+
1307
+ ```typescript
1308
+ import { HTTPError, isHTTPError } from "@proofkit/fmodata";
1309
+
1310
+ const result = await db.from("users").list().execute();
1311
+
1312
+ if (result.error) {
1313
+ if (isHTTPError(result.error)) {
1314
+ // TypeScript knows this is HTTPError
1315
+ console.log("HTTP Status:", result.error.status);
1316
+
1317
+ if (result.error.isNotFound()) {
1318
+ console.log("Resource not found");
1319
+ } else if (result.error.isUnauthorized()) {
1320
+ console.log("Authentication required");
1321
+ } else if (result.error.is5xx()) {
1322
+ console.log("Server error - try again later");
1323
+ } else if (result.error.is4xx()) {
1324
+ console.log("Client error:", result.error.statusText);
1325
+ }
1326
+
1327
+ // Access the response body if available
1328
+ if (result.error.response) {
1329
+ console.log("Error details:", result.error.response);
1330
+ }
1331
+ }
1332
+ }
1333
+ ```
1334
+
1335
+ #### Network Errors
1336
+
1337
+ Handle network-level errors (timeouts, connection issues, etc.):
1338
+
1339
+ ```typescript
1340
+ import {
1341
+ TimeoutError,
1342
+ NetworkError,
1343
+ RetryLimitError,
1344
+ CircuitOpenError,
1345
+ } from "@proofkit/fmodata";
1346
+
1347
+ const result = await db.from("users").list().execute();
1348
+
1349
+ if (result.error) {
1350
+ if (result.error instanceof TimeoutError) {
1351
+ console.log("Request timed out");
1352
+ // Show user-friendly timeout message
1353
+ } else if (result.error instanceof NetworkError) {
1354
+ console.log("Network connectivity issue");
1355
+ // Show offline message
1356
+ } else if (result.error instanceof RetryLimitError) {
1357
+ console.log("Request failed after retries");
1358
+ // Log the underlying error: result.error.cause
1359
+ } else if (result.error instanceof CircuitOpenError) {
1360
+ console.log("Service is currently unavailable");
1361
+ // Show maintenance message
1362
+ }
1363
+ }
1364
+ ```
1365
+
1366
+ #### Validation Errors
1367
+
1368
+ When schema validation fails, you get a `ValidationError` with rich context:
1369
+
1370
+ ```typescript
1371
+ import { ValidationError, isValidationError } from "@proofkit/fmodata";
1372
+
1373
+ const result = await db.from("users").list().execute();
1374
+
1375
+ if (result.error) {
1376
+ if (isValidationError(result.error)) {
1377
+ // Access validation issues (Standard Schema format)
1378
+ console.log("Validation failed for field:", result.error.field);
1379
+ console.log("Issues:", result.error.issues);
1380
+ console.log("Failed value:", result.error.value);
1381
+ }
1382
+ }
1383
+ ```
1384
+
1385
+ **Validator-Agnostic Error Handling**
1386
+
1387
+ 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:
1388
+
1389
+ ```typescript
1390
+ import { ValidationError } from "@proofkit/fmodata";
1391
+
1392
+ const result = await db.from("users").list().execute();
1393
+
1394
+ if (result.error instanceof ValidationError) {
1395
+ // The cause property (ES2022 Error.cause) contains the Standard Schema issues array
1396
+ // This is validator-agnostic and works with Zod, Valibot, ArkType, etc.
1397
+ console.log("Validation issues:", result.error.cause);
1398
+ console.log("Issues are also available directly:", result.error.issues);
1399
+
1400
+ // Both point to the same array
1401
+ console.log(result.error.cause === result.error.issues); // true
1402
+
1403
+ // Access additional context
1404
+ console.log("Failed field:", result.error.field);
1405
+ console.log("Failed value:", result.error.value);
1406
+
1407
+ // Standard Schema issues have a normalized format
1408
+ result.error.issues.forEach((issue) => {
1409
+ console.log("Path:", issue.path);
1410
+ console.log("Message:", issue.message);
1411
+ });
1412
+ }
1413
+ ```
1414
+
1415
+ **Why Standard Schema Issues Instead of Original Validator Errors?**
1416
+
1417
+ 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.
1418
+
1419
+ If you need validator-specific error formatting, you can still access your validator's methods during validation before the data reaches fmodata:
1420
+
1421
+ ```typescript
1422
+ import { z } from "zod";
1423
+
1424
+ const userSchema = z.object({
1425
+ email: z.string().email(),
1426
+ age: z.number().min(0).max(150),
1427
+ });
1428
+
1429
+ // Validate early if you need Zod-specific error handling
1430
+ const parseResult = userSchema.safeParse(userData);
1431
+ if (!parseResult.success) {
1432
+ // Use Zod's error formatting
1433
+ const formatted = parseResult.error.flatten();
1434
+ console.log("Zod-specific formatting:", formatted);
1435
+ }
1436
+ ```
1437
+
1438
+ #### OData Errors
1439
+
1440
+ Handle OData-specific protocol errors:
1441
+
1442
+ ```typescript
1443
+ import { ODataError, isODataError } from "@proofkit/fmodata";
1444
+
1445
+ const result = await db.from("users").list().execute();
1446
+
1447
+ if (result.error) {
1448
+ if (isODataError(result.error)) {
1449
+ console.log("OData Error Code:", result.error.code);
1450
+ console.log("OData Error Message:", result.error.message);
1451
+ console.log("OData Error Details:", result.error.details);
1452
+ }
1453
+ }
1454
+ ```
1455
+
1456
+ #### Error Handling Patterns
1457
+
1458
+ **Pattern 1: Using instanceof (like ffetch):**
1459
+
1460
+ ```typescript
1461
+ import {
1462
+ HTTPError,
1463
+ ValidationError,
1464
+ TimeoutError,
1465
+ NetworkError,
1466
+ } from "@proofkit/fmodata";
1467
+
1468
+ const result = await db.from("users").list().execute();
1469
+
1470
+ if (result.error) {
1471
+ if (result.error instanceof TimeoutError) {
1472
+ showTimeoutMessage();
1473
+ } else if (result.error instanceof HTTPError) {
1474
+ if (result.error.isNotFound()) {
1475
+ showNotFoundMessage();
1476
+ } else if (result.error.is5xx()) {
1477
+ showServerErrorMessage();
1478
+ }
1479
+ } else if (result.error instanceof ValidationError) {
1480
+ showValidationError(result.error.field, result.error.issues);
1481
+ } else if (result.error instanceof NetworkError) {
1482
+ showOfflineMessage();
1483
+ }
1484
+ }
1485
+ ```
1486
+
1487
+ **Pattern 2: Using kind property (for exhaustive matching):**
1488
+
1489
+ ```typescript
1490
+ const result = await db.from("users").list().execute();
1491
+
1492
+ if (result.error) {
1493
+ switch (result.error.kind) {
1494
+ case "TimeoutError":
1495
+ showTimeoutMessage();
1496
+ break;
1497
+ case "HTTPError":
1498
+ handleHTTPError(result.error.status);
1499
+ break;
1500
+ case "ValidationError":
1501
+ showValidationError(result.error.field, result.error.issues);
1502
+ break;
1503
+ case "NetworkError":
1504
+ showOfflineMessage();
1505
+ break;
1506
+ case "ODataError":
1507
+ handleODataError(result.error.code);
1508
+ break;
1509
+ // TypeScript ensures exhaustive matching!
1510
+ }
1511
+ }
1512
+ ```
1513
+
1514
+ **Pattern 3: Using type guards:**
1515
+
1516
+ ```typescript
1517
+ import {
1518
+ isHTTPError,
1519
+ isValidationError,
1520
+ isODataError,
1521
+ isNetworkError,
1522
+ } from "@proofkit/fmodata";
1523
+
1524
+ const result = await db.from("users").list().execute();
1525
+
1526
+ if (result.error) {
1527
+ if (isHTTPError(result.error)) {
1528
+ // TypeScript knows this is HTTPError
1529
+ console.log("Status:", result.error.status);
1530
+ } else if (isValidationError(result.error)) {
1531
+ // TypeScript knows this is ValidationError
1532
+ console.log("Field:", result.error.field);
1533
+ console.log("Issues:", result.error.issues);
1534
+ } else if (isODataError(result.error)) {
1535
+ // TypeScript knows this is ODataError
1536
+ console.log("Code:", result.error.code);
1537
+ } else if (isNetworkError(result.error)) {
1538
+ // TypeScript knows this is NetworkError
1539
+ console.log("Network issue:", result.error.cause);
1540
+ }
1541
+ }
1542
+ ```
1543
+
1544
+ #### Error Properties
1545
+
1546
+ All errors include helpful metadata:
1547
+
1548
+ ```typescript
1549
+ if (result.error) {
1550
+ // All errors have a timestamp
1551
+ console.log("Error occurred at:", result.error.timestamp);
1552
+
1553
+ // All errors have a kind property for discriminated unions
1554
+ console.log("Error kind:", result.error.kind);
1555
+
1556
+ // All errors have a message
1557
+ console.log("Error message:", result.error.message);
1558
+ }
1559
+ ```
1560
+
1561
+ #### Available Error Types
1562
+
1563
+ - **`HTTPError`** - HTTP status errors (4xx, 5xx) with helper methods (`is4xx()`, `is5xx()`, `isNotFound()`, etc.)
1564
+ - **`ODataError`** - OData protocol errors with code and details
1565
+ - **`ValidationError`** - Schema validation failures with issues, schema reference, and failed value
1566
+ - **`ResponseStructureError`** - Malformed API responses
1567
+ - **`RecordCountMismatchError`** - When `single()` or `maybeSingle()` expectations aren't met
1568
+ - **`TimeoutError`** - Request timeout (from ffetch)
1569
+ - **`NetworkError`** - Network connectivity issues (from ffetch)
1570
+ - **`RetryLimitError`** - Request failed after retries (from ffetch)
1571
+ - **`CircuitOpenError`** - Circuit breaker is open (from ffetch)
1572
+ - **`AbortError`** - Request was aborted (from ffetch)
1573
+
883
1574
  ### OData Annotations and Validation
884
1575
 
885
1576
  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`: