@proofkit/fmodata 0.1.0-alpha.1 → 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 +746 -65
  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
@@ -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,21 @@ 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 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.
118
137
 
119
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.
120
139
 
121
140
  ```typescript
122
141
  import { z } from "zod/v4";
123
- import { BaseTable } from "@proofkit/fmodata";
142
+ import { defineBaseTable } from "@proofkit/fmodata";
124
143
 
125
- const contactsBase = new BaseTable({
144
+ const contactsBase = defineBaseTable({
126
145
  schema: {
127
146
  id: z.string(),
128
147
  name: z.string(),
@@ -130,18 +149,20 @@ const contactsBase = new BaseTable({
130
149
  phone: z.string().optional(),
131
150
  createdAt: z.string(),
132
151
  },
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
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
136
155
  });
137
156
  ```
138
157
 
139
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.
140
159
 
160
+ **Recommended:** Use `defineTableOccurrence()` for better type inference. You can also use `new TableOccurrence()` directly.
161
+
141
162
  ```typescript
142
- import { TableOccurrence } from "@proofkit/fmodata";
163
+ import { defineTableOccurrence } from "@proofkit/fmodata";
143
164
 
144
- const contactsTO = new TableOccurrence({
165
+ const contactsTO = defineTableOccurrence({
145
166
  name: "contacts", // The table occurrence name in FileMaker
146
167
  baseTable: contactsBase,
147
168
  });
@@ -153,21 +174,21 @@ FileMaker will automatically return all non-container fields from a schema if yo
153
174
 
154
175
  ```typescript
155
176
  // Option 1 (default): "schema" - Select all fields from the schema (same as "all" but more explicit)
156
- const usersTO = new TableOccurrence({
177
+ const usersTO = defineTableOccurrence({
157
178
  name: "users",
158
179
  baseTable: usersBase,
159
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
160
181
  });
161
182
 
162
183
  // Option 2: "all" - Select all fields (default behavior)
163
- const usersTO = new TableOccurrence({
184
+ const usersTO = defineTableOccurrence({
164
185
  name: "users",
165
186
  baseTable: usersBase,
166
187
  defaultSelect: "all", // Don't always a $select parameter to the query; FileMaker will return all non-container fields from the table
167
188
  });
168
189
 
169
190
  // Option 3: Array of field names - Select only specific fields by default
170
- const usersTO = new TableOccurrence({
191
+ const usersTO = defineTableOccurrence({
171
192
  name: "users",
172
193
  baseTable: usersBase,
173
194
  defaultSelect: ["username", "email"], // Only select these fields by default
@@ -185,10 +206,10 @@ const result = await db
185
206
  .execute();
186
207
  ```
187
208
 
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.
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.
189
210
 
190
211
  ```typescript
191
- const db = client.database("MyDatabase.fmp12", {
212
+ const db = connection.database("MyDatabase.fmp12", {
192
213
  occurrences: [contactsTO, usersTO], // Register your table occurrences
193
214
  });
194
215
  ```
@@ -514,27 +535,30 @@ if (result.data) {
514
535
  }
515
536
  ```
516
537
 
517
- If you specify `insertRequired` fields in your base table, those fields become required:
538
+ Fields are automatically required for insert if their validator doesn't allow `null` or `undefined`. You can specify additional required fields:
518
539
 
519
540
  ```typescript
520
- const usersBase = new BaseTable({
541
+ const usersBase = defineBaseTable({
521
542
  schema: {
522
- id: z.string(),
523
- username: z.string(),
524
- email: z.string(),
525
- createdAt: z.string().optional(),
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)
526
548
  },
527
- idField: "id",
528
- insertRequired: ["username", "email"], // These fields are required on insert
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
529
552
  });
530
553
 
531
- // TypeScript will enforce that username and email are provided
554
+ // TypeScript enforces: username, email, and phone are required
555
+ // TypeScript excludes: id and createdAt cannot be provided
532
556
  const result = await db
533
557
  .from("users")
534
558
  .insert({
535
559
  username: "johndoe",
536
560
  email: "john@example.com",
537
- // createdAt is optional
561
+ phone: "+1234567890", // Required because specified in 'required' array
538
562
  })
539
563
  .execute();
540
564
  ```
@@ -619,7 +643,7 @@ const result = await db
619
643
  Define relationships between tables using the `navigation` option:
620
644
 
621
645
  ```typescript
622
- const contactsBase = new BaseTable({
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(),
@@ -638,20 +662,24 @@ const usersBase = new BaseTable({
638
662
  });
639
663
 
640
664
  // Define navigation using functions to handle circular dependencies
641
- const contactsTO = new TableOccurrence({
665
+ // Create base occurrences first, then add navigation
666
+ const _contactsTO = defineTableOccurrence({
642
667
  name: "contacts",
643
668
  baseTable: contactsBase,
644
- navigation: {
645
- users: () => usersTO, // Relationship to users table
646
- },
647
669
  });
648
670
 
649
- const usersTO = new TableOccurrence({
671
+ const _usersTO = defineTableOccurrence({
650
672
  name: "users",
651
673
  baseTable: usersBase,
652
- navigation: {
653
- contacts: () => contactsTO, // Relationship to contacts table
654
- },
674
+ });
675
+
676
+ // Then add navigation
677
+ const contactsTO = _contactsTO.addNavigation({
678
+ users: () => _usersTO,
679
+ });
680
+
681
+ const usersTO = _usersTO.addNavigation({
682
+ contacts: () => _contactsTO,
655
683
  });
656
684
 
657
685
  // You can also add navigation after creation
@@ -789,6 +817,334 @@ console.log(result.result.recordId);
789
817
 
790
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.
791
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.
823
+
824
+ ### Basic Batch with Multiple Queries
825
+
826
+ Execute multiple read operations in a single batch:
827
+
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
+
792
1148
  ## Advanced Features
793
1149
 
794
1150
  ### Type Safety
@@ -796,7 +1152,7 @@ console.log(result.result.recordId);
796
1152
  The library provides full TypeScript type inference:
797
1153
 
798
1154
  ```typescript
799
- const usersBase = new BaseTable({
1155
+ const usersBase = defineBaseTable({
800
1156
  schema: {
801
1157
  id: z.string(),
802
1158
  username: z.string(),
@@ -805,12 +1161,12 @@ const usersBase = new BaseTable({
805
1161
  idField: "id",
806
1162
  });
807
1163
 
808
- const usersTO = new TableOccurrence({
1164
+ const usersTO = defineTableOccurrence({
809
1165
  name: "users",
810
1166
  baseTable: usersBase,
811
1167
  });
812
1168
 
813
- const db = client.database("MyDB", {
1169
+ const db = connection.database("MyDB", {
814
1170
  occurrences: [usersTO],
815
1171
  });
816
1172
 
@@ -829,43 +1185,97 @@ db.from("users")
829
1185
  .filter({ invalid: { eq: "john" } }); // TS Error
830
1186
  ```
831
1187
 
832
- ### Required Fields
1188
+ ### Required and Read-Only Fields
833
1189
 
834
- Control which fields are required for insert and update operations:
1190
+ The library automatically infers which fields are required based on whether their validator allows `null` or `undefined`:
835
1191
 
836
1192
  ```typescript
837
- const usersBase = new BaseTable({
1193
+ const usersBase = defineBaseTable({
838
1194
  schema: {
839
- id: z.string(),
840
- username: z.string(),
841
- email: z.string(),
842
- status: z.string(),
843
- updatedAt: z.string().optional(),
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
844
1201
  },
845
- idField: "id",
846
- insertRequired: ["username", "email"], // Required on insert
847
- updateRequired: ["status"], // Required on update
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
848
1205
  });
849
1206
 
850
- // Insert requires username and email
1207
+ // Insert: username, email, and status are required
1208
+ // Insert: id and createdAt are excluded (cannot be provided)
851
1209
  db.from("users").insert({
852
1210
  username: "john",
853
1211
  email: "john@example.com",
854
- // updatedAt is optional
1212
+ status: "active", // Required due to 'required' array
1213
+ updatedAt: new Date().toISOString(), // Optional
855
1214
  });
856
1215
 
857
- // Update requires status
1216
+ // Update: all fields are optional except id and createdAt are excluded
858
1217
  db.from("users")
859
1218
  .update({
860
- status: "active",
861
- // other fields are optional
1219
+ status: "active", // Optional
1220
+ // id and createdAt cannot be modified
862
1221
  })
863
1222
  .byId("user-123");
864
1223
  ```
865
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
+
866
1274
  ### Error Handling
867
1275
 
868
- All operations return a `Result` type with either `data` or `error`:
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
869
1279
 
870
1280
  ```typescript
871
1281
  const result = await db.from("users").list().execute();
@@ -880,6 +1290,277 @@ if (result.data) {
880
1290
  }
881
1291
  ```
882
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
+ }
1354
+ ```
1355
+
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();
1436
+
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
+
883
1564
  ### OData Annotations and Validation
884
1565
 
885
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`: