@proofkit/fmodata 0.1.0-alpha.4 → 0.1.0-alpha.7

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 (74) hide show
  1. package/README.md +690 -31
  2. package/dist/esm/client/base-table.d.ts +122 -5
  3. package/dist/esm/client/base-table.js +46 -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 +54 -5
  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 +22 -8
  18. package/dist/esm/client/entity-set.js +28 -8
  19. package/dist/esm/client/entity-set.js.map +1 -1
  20. package/dist/esm/client/filemaker-odata.d.ts +22 -3
  21. package/dist/esm/client/filemaker-odata.js +122 -27
  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 +26 -5
  27. package/dist/esm/client/query-builder.js +455 -208
  28. package/dist/esm/client/query-builder.js.map +1 -1
  29. package/dist/esm/client/record-builder.d.ts +19 -4
  30. package/dist/esm/client/record-builder.js +132 -40
  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 +66 -2
  37. package/dist/esm/client/table-occurrence.js +36 -1
  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 +7 -3
  46. package/dist/esm/index.js +27 -3
  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 +155 -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 +173 -16
  60. package/src/client/delete-builder.ts +149 -48
  61. package/src/client/entity-set.ts +99 -15
  62. package/src/client/filemaker-odata.ts +178 -34
  63. package/src/client/insert-builder.ts +350 -40
  64. package/src/client/query-builder.ts +609 -236
  65. package/src/client/record-builder.ts +186 -53
  66. package/src/client/response-processor.ts +103 -0
  67. package/src/client/schema-manager.ts +246 -0
  68. package/src/client/table-occurrence.ts +118 -4
  69. package/src/client/update-builder.ts +235 -49
  70. package/src/errors.ts +217 -0
  71. package/src/index.ts +43 -1
  72. package/src/transform.ts +249 -0
  73. package/src/types.ts +201 -35
  74. package/src/validation.ts +120 -36
package/README.md CHANGED
@@ -2,11 +2,14 @@
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 on [GitHub](https://github.com/proofgeist/proofkit/issues).
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
6
 
7
7
  Roadmap:
8
8
 
9
- - [ ] Batch operations
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)
10
13
  - [ ] Proper docs at proofkit.dev
11
14
  - [ ] @proofkit/typegen integration
12
15
 
@@ -78,7 +81,7 @@ if (data) {
78
81
 
79
82
  ## Core Concepts
80
83
 
81
- 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 is designed to support batch operations in the future, allowing you to execute multiple operations in a single request as supported by the FileMaker OData API. **Note:** Batch operations are not yet supported but are planned before the production release. It's also helpful for testing the library, as you can 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.
82
85
 
83
86
  As such, there are layers to the library to help you build your queries and operations.
84
87
 
@@ -140,9 +143,9 @@ const contactsBase = new BaseTable({
140
143
  phone: z.string().optional(),
141
144
  createdAt: z.string(),
142
145
  },
143
- idField: "id", // The primary key field
144
- insertRequired: ["name", "email"], // optional: fields that are required on insert
145
- updateRequired: ["email"], // optional: fields that are required on update
146
+ idField: "id", // The primary key field (automatically read-only)
147
+ required: ["phone"], // optional: additional required fields for insert (beyond auto-inferred)
148
+ readOnly: ["createdAt"], // optional: fields excluded from insert/update
146
149
  });
147
150
  ```
148
151
 
@@ -524,27 +527,30 @@ if (result.data) {
524
527
  }
525
528
  ```
526
529
 
527
- If you specify `insertRequired` fields in your base table, those fields become required:
530
+ Fields are automatically required for insert if their validator doesn't allow `null` or `undefined`. You can specify additional required fields:
528
531
 
529
532
  ```typescript
530
533
  const usersBase = new BaseTable({
531
534
  schema: {
532
- id: z.string(),
533
- username: z.string(),
534
- email: z.string(),
535
- createdAt: z.string().optional(),
535
+ id: z.string(), // Auto-required (not nullable), but excluded from insert (idField)
536
+ username: z.string(), // Auto-required (not nullable)
537
+ email: z.string(), // Auto-required (not nullable)
538
+ phone: z.string().nullable(), // Optional by default
539
+ createdAt: z.string(), // Auto-required, but excluded (readOnly)
536
540
  },
537
- idField: "id",
538
- insertRequired: ["username", "email"], // These fields are required on insert
541
+ idField: "id", // Automatically excluded from insert/update
542
+ required: ["phone"], // Make phone required for inserts despite being nullable
543
+ readOnly: ["createdAt"], // Exclude from insert/update operations
539
544
  });
540
545
 
541
- // TypeScript will enforce that username and email are provided
546
+ // TypeScript enforces: username, email, and phone are required
547
+ // TypeScript excludes: id and createdAt cannot be provided
542
548
  const result = await db
543
549
  .from("users")
544
550
  .insert({
545
551
  username: "johndoe",
546
552
  email: "john@example.com",
547
- // createdAt is optional
553
+ phone: "+1234567890", // Required because specified in 'required' array
548
554
  })
549
555
  .execute();
550
556
  ```
@@ -799,6 +805,334 @@ console.log(result.result.recordId);
799
805
 
800
806
  **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.
801
807
 
808
+ ## Batch Operations
809
+
810
+ 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.
811
+
812
+ ### Basic Batch with Multiple Queries
813
+
814
+ Execute multiple read operations in a single batch:
815
+
816
+ ```typescript
817
+ // Create query builders
818
+ const contactsQuery = db.from("contacts").list().top(5);
819
+ const usersQuery = db.from("users").list().top(5);
820
+
821
+ // Execute both queries in a single batch
822
+ const result = await db.batch([contactsQuery, usersQuery]).execute();
823
+
824
+ if (result.data) {
825
+ // Result is a tuple matching the input builders
826
+ const [contacts, users] = result.data;
827
+
828
+ console.log("Contacts:", contacts);
829
+ console.log("Users:", users);
830
+ }
831
+ ```
832
+
833
+ ### Mixed Operations (Reads and Writes)
834
+
835
+ Combine queries, inserts, updates, and deletes in a single batch:
836
+
837
+ ```typescript
838
+ // Mix different operation types
839
+ const listQuery = db.from("contacts").list().top(10);
840
+ const insertOp = db.from("contacts").insert({
841
+ name: "John Doe",
842
+ email: "john@example.com",
843
+ });
844
+ const updateOp = db.from("users").update({ active: true }).byId("user-123");
845
+
846
+ // All operations execute atomically
847
+ const result = await db.batch([listQuery, insertOp, updateOp]).execute();
848
+
849
+ if (result.data) {
850
+ const [contactsList, insertResult, updateResult] = result.data;
851
+
852
+ console.log("Fetched contacts:", contactsList);
853
+ console.log("Inserted contact:", insertResult);
854
+ console.log("Updated user:", updateResult);
855
+ }
856
+ ```
857
+
858
+ ### Transactional Behavior
859
+
860
+ Batch operations are transactional for write operations (inserts, updates, deletes). If any operation in the batch fails, all write operations are rolled back:
861
+
862
+ ```typescript
863
+ const result = await db
864
+ .batch([
865
+ db.from("users").insert({ username: "alice", email: "alice@example.com" }),
866
+ db.from("users").insert({ username: "bob", email: "bob@example.com" }),
867
+ db.from("users").insert({ username: "charlie", email: "invalid" }), // This fails
868
+ ])
869
+ .execute();
870
+
871
+ if (result.error) {
872
+ // All three inserts are rolled back - no users were created
873
+ console.error("Batch failed:", result.error);
874
+ }
875
+ ```
876
+
877
+ **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.
878
+
879
+ ## Schema Management
880
+
881
+ 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.
882
+
883
+ ### Creating Tables
884
+
885
+ Create a new table with field definitions:
886
+
887
+ ```typescript
888
+ import type { Field } from "@proofkit/fmodata";
889
+
890
+ const fields: Field[] = [
891
+ {
892
+ name: "id",
893
+ type: "string",
894
+ primary: true,
895
+ maxLength: 36,
896
+ },
897
+ {
898
+ name: "username",
899
+ type: "string",
900
+ nullable: false,
901
+ unique: true,
902
+ maxLength: 50,
903
+ },
904
+ {
905
+ name: "email",
906
+ type: "string",
907
+ nullable: false,
908
+ maxLength: 255,
909
+ },
910
+ {
911
+ name: "age",
912
+ type: "numeric",
913
+ nullable: true,
914
+ },
915
+ {
916
+ name: "created_at",
917
+ type: "timestamp",
918
+ default: "CURRENT_TIMESTAMP",
919
+ },
920
+ ];
921
+
922
+ const tableDefinition = await db.schema.createTable("users", fields);
923
+ console.log(tableDefinition.tableName); // "users"
924
+ console.log(tableDefinition.fields); // Array of field definitions
925
+ ```
926
+
927
+ ### Field Types
928
+
929
+ The library supports various field types:
930
+
931
+ **String Fields:**
932
+
933
+ ```typescript
934
+ {
935
+ name: "username",
936
+ type: "string",
937
+ maxLength: 100, // Optional: varchar(100)
938
+ nullable: true,
939
+ unique: true,
940
+ default: "USER" | "USERNAME" | "CURRENT_USER", // Optional
941
+ repetitions: 5, // Optional: for repeating fields
942
+ }
943
+ ```
944
+
945
+ **Numeric Fields:**
946
+
947
+ ```typescript
948
+ {
949
+ name: "age",
950
+ type: "numeric",
951
+ nullable: true,
952
+ primary: false,
953
+ unique: false,
954
+ }
955
+ ```
956
+
957
+ **Date Fields:**
958
+
959
+ ```typescript
960
+ {
961
+ name: "birth_date",
962
+ type: "date",
963
+ default: "CURRENT_DATE" | "CURDATE", // Optional
964
+ nullable: true,
965
+ }
966
+ ```
967
+
968
+ **Time Fields:**
969
+
970
+ ```typescript
971
+ {
972
+ name: "start_time",
973
+ type: "time",
974
+ default: "CURRENT_TIME" | "CURTIME", // Optional
975
+ nullable: true,
976
+ }
977
+ ```
978
+
979
+ **Timestamp Fields:**
980
+
981
+ ```typescript
982
+ {
983
+ name: "created_at",
984
+ type: "timestamp",
985
+ default: "CURRENT_TIMESTAMP" | "CURTIMESTAMP", // Optional
986
+ nullable: false,
987
+ }
988
+ ```
989
+
990
+ **Container Fields:**
991
+
992
+ ```typescript
993
+ {
994
+ name: "avatar",
995
+ type: "container",
996
+ externalSecurePath: "/secure/path", // Optional
997
+ nullable: true,
998
+ }
999
+ ```
1000
+
1001
+ ### Adding Fields to Existing Tables
1002
+
1003
+ Add new fields to an existing table:
1004
+
1005
+ ```typescript
1006
+ const newFields: Field[] = [
1007
+ {
1008
+ name: "phone",
1009
+ type: "string",
1010
+ nullable: true,
1011
+ maxLength: 20,
1012
+ },
1013
+ {
1014
+ name: "bio",
1015
+ type: "string",
1016
+ nullable: true,
1017
+ maxLength: 1000,
1018
+ },
1019
+ ];
1020
+
1021
+ const updatedTable = await db.schema.addFields("users", newFields);
1022
+ ```
1023
+
1024
+ ### Deleting Tables and Fields
1025
+
1026
+ Delete an entire table:
1027
+
1028
+ ```typescript
1029
+ await db.schema.deleteTable("old_table");
1030
+ ```
1031
+
1032
+ Delete a specific field from a table:
1033
+
1034
+ ```typescript
1035
+ await db.schema.deleteField("users", "old_field");
1036
+ ```
1037
+
1038
+ ### Managing Indexes
1039
+
1040
+ Create an index on a field:
1041
+
1042
+ ```typescript
1043
+ const index = await db.schema.createIndex("users", "email");
1044
+ console.log(index.indexName); // "email"
1045
+ ```
1046
+
1047
+ Delete an index:
1048
+
1049
+ ```typescript
1050
+ await db.schema.deleteIndex("users", "email");
1051
+ ```
1052
+
1053
+ ### Complete Example
1054
+
1055
+ Here's a complete example of creating a table with various field types:
1056
+
1057
+ ```typescript
1058
+ const fields: Field[] = [
1059
+ // Primary key
1060
+ {
1061
+ name: "id",
1062
+ type: "string",
1063
+ primary: true,
1064
+ maxLength: 36,
1065
+ },
1066
+
1067
+ // String fields
1068
+ {
1069
+ name: "username",
1070
+ type: "string",
1071
+ nullable: false,
1072
+ unique: true,
1073
+ maxLength: 50,
1074
+ },
1075
+ {
1076
+ name: "email",
1077
+ type: "string",
1078
+ nullable: false,
1079
+ maxLength: 255,
1080
+ },
1081
+
1082
+ // Numeric field
1083
+ {
1084
+ name: "age",
1085
+ type: "numeric",
1086
+ nullable: true,
1087
+ },
1088
+
1089
+ // Date/time fields
1090
+ {
1091
+ name: "birth_date",
1092
+ type: "date",
1093
+ nullable: true,
1094
+ },
1095
+ {
1096
+ name: "created_at",
1097
+ type: "timestamp",
1098
+ default: "CURRENT_TIMESTAMP",
1099
+ nullable: false,
1100
+ },
1101
+
1102
+ // Container field
1103
+ {
1104
+ name: "avatar",
1105
+ type: "container",
1106
+ nullable: true,
1107
+ },
1108
+
1109
+ // Repeating field
1110
+ {
1111
+ name: "tags",
1112
+ type: "string",
1113
+ repetitions: 5,
1114
+ maxLength: 50,
1115
+ },
1116
+ ];
1117
+
1118
+ // Create the table
1119
+ const table = await db.schema.createTable("users", fields);
1120
+
1121
+ // Later, add more fields
1122
+ await db.schema.addFields("users", [
1123
+ {
1124
+ name: "phone",
1125
+ type: "string",
1126
+ nullable: true,
1127
+ },
1128
+ ]);
1129
+
1130
+ // Create an index on email
1131
+ await db.schema.createIndex("users", "email");
1132
+ ```
1133
+
1134
+ **Note:** Schema management operations require appropriate access privileges on your FileMaker account. Operations will throw errors if you don't have the necessary permissions.
1135
+
802
1136
  ## Advanced Features
803
1137
 
804
1138
  ### Type Safety
@@ -839,43 +1173,97 @@ db.from("users")
839
1173
  .filter({ invalid: { eq: "john" } }); // TS Error
840
1174
  ```
841
1175
 
842
- ### Required Fields
1176
+ ### Required and Read-Only Fields
843
1177
 
844
- Control which fields are required for insert and update operations:
1178
+ The library automatically infers which fields are required based on whether their validator allows `null` or `undefined`:
845
1179
 
846
1180
  ```typescript
847
1181
  const usersBase = new BaseTable({
848
1182
  schema: {
849
- id: z.string(),
850
- username: z.string(),
851
- email: z.string(),
852
- status: z.string(),
853
- updatedAt: z.string().optional(),
1183
+ id: z.string(), // Auto-required, auto-readOnly (idField)
1184
+ username: z.string(), // Auto-required (not nullable)
1185
+ email: z.string(), // Auto-required (not nullable)
1186
+ status: z.string().nullable(), // Optional (nullable)
1187
+ createdAt: z.string(), // Read-only system field
1188
+ updatedAt: z.string().nullable(), // Optional
854
1189
  },
855
- idField: "id",
856
- insertRequired: ["username", "email"], // Required on insert
857
- updateRequired: ["status"], // Required on update
1190
+ idField: "id", // Automatically excluded from insert/update
1191
+ required: ["status"], // Make status required despite being nullable
1192
+ readOnly: ["createdAt"], // Exclude createdAt from insert/update
858
1193
  });
859
1194
 
860
- // Insert requires username and email
1195
+ // Insert: username, email, and status are required
1196
+ // Insert: id and createdAt are excluded (cannot be provided)
861
1197
  db.from("users").insert({
862
1198
  username: "john",
863
1199
  email: "john@example.com",
864
- // updatedAt is optional
1200
+ status: "active", // Required due to 'required' array
1201
+ updatedAt: new Date().toISOString(), // Optional
865
1202
  });
866
1203
 
867
- // Update requires status
1204
+ // Update: all fields are optional except id and createdAt are excluded
868
1205
  db.from("users")
869
1206
  .update({
870
- status: "active",
871
- // other fields are optional
1207
+ status: "active", // Optional
1208
+ // id and createdAt cannot be modified
872
1209
  })
873
1210
  .byId("user-123");
874
1211
  ```
875
1212
 
1213
+ **Key Features:**
1214
+
1215
+ - **Auto-inference:** Non-nullable fields are automatically required for insert
1216
+ - **Additional requirements:** Use `required` to make nullable fields required for new records
1217
+ - **Read-only fields:** Use `readOnly` to exclude fields from insert/update (e.g., timestamps)
1218
+ - **Automatic ID exclusion:** The `idField` is always read-only without needing to specify it
1219
+ - **Update flexibility:** All fields are optional for updates (except read-only fields)
1220
+
1221
+ ### Prefer: fmodata.entity-ids
1222
+
1223
+ 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.
1224
+
1225
+ To enable this feature, simply define your schema with the `BaseTableWithIds` and `TableOccurrenceWithIds` classes. 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 the `TableOccurrenceWithIds` class.
1226
+
1227
+ _Note for OttoFMS proxy: This feature requires version 4.14 or later of OttoFMS_
1228
+
1229
+ 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
1230
+
1231
+ #### Basic Usage
1232
+
1233
+ ```typescript
1234
+ import { BaseTableWithIds, TableOccurrenceWithIds } from "@proofkit/fmodata";
1235
+ import { z } from "zod/v4";
1236
+
1237
+ // Define a base table with FileMaker field IDs
1238
+ const usersBase = new BaseTableWithIds({
1239
+ schema: {
1240
+ id: z.string(),
1241
+ username: z.string(),
1242
+ email: z.string().nullable(),
1243
+ createdAt: z.string(),
1244
+ },
1245
+ idField: "id",
1246
+ fmfIds: {
1247
+ id: "FMFID:12039485",
1248
+ username: "FMFID:34323433",
1249
+ email: "FMFID:12232424",
1250
+ createdAt: "FMFID:43234355",
1251
+ },
1252
+ });
1253
+
1254
+ // Create a table occurrence with a FileMaker table occurrence ID
1255
+ const usersTO = new TableOccurrenceWithIds({
1256
+ name: "users",
1257
+ baseTable: usersBase, // Must be a BaseTableWithIds
1258
+ fmtId: "FMTID:12432533",
1259
+ });
1260
+ ```
1261
+
876
1262
  ### Error Handling
877
1263
 
878
- All operations return a `Result` type with either `data` or `error`:
1264
+ 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.
1265
+
1266
+ #### Basic Error Checking
879
1267
 
880
1268
  ```typescript
881
1269
  const result = await db.from("users").list().execute();
@@ -890,6 +1278,277 @@ if (result.data) {
890
1278
  }
891
1279
  ```
892
1280
 
1281
+ #### HTTP Errors
1282
+
1283
+ Handle HTTP status codes (4xx, 5xx) with the `HTTPError` class:
1284
+
1285
+ ```typescript
1286
+ import { HTTPError, isHTTPError } from "@proofkit/fmodata";
1287
+
1288
+ const result = await db.from("users").list().execute();
1289
+
1290
+ if (result.error) {
1291
+ if (isHTTPError(result.error)) {
1292
+ // TypeScript knows this is HTTPError
1293
+ console.log("HTTP Status:", result.error.status);
1294
+
1295
+ if (result.error.isNotFound()) {
1296
+ console.log("Resource not found");
1297
+ } else if (result.error.isUnauthorized()) {
1298
+ console.log("Authentication required");
1299
+ } else if (result.error.is5xx()) {
1300
+ console.log("Server error - try again later");
1301
+ } else if (result.error.is4xx()) {
1302
+ console.log("Client error:", result.error.statusText);
1303
+ }
1304
+
1305
+ // Access the response body if available
1306
+ if (result.error.response) {
1307
+ console.log("Error details:", result.error.response);
1308
+ }
1309
+ }
1310
+ }
1311
+ ```
1312
+
1313
+ #### Network Errors
1314
+
1315
+ Handle network-level errors (timeouts, connection issues, etc.):
1316
+
1317
+ ```typescript
1318
+ import {
1319
+ TimeoutError,
1320
+ NetworkError,
1321
+ RetryLimitError,
1322
+ CircuitOpenError,
1323
+ } from "@proofkit/fmodata";
1324
+
1325
+ const result = await db.from("users").list().execute();
1326
+
1327
+ if (result.error) {
1328
+ if (result.error instanceof TimeoutError) {
1329
+ console.log("Request timed out");
1330
+ // Show user-friendly timeout message
1331
+ } else if (result.error instanceof NetworkError) {
1332
+ console.log("Network connectivity issue");
1333
+ // Show offline message
1334
+ } else if (result.error instanceof RetryLimitError) {
1335
+ console.log("Request failed after retries");
1336
+ // Log the underlying error: result.error.cause
1337
+ } else if (result.error instanceof CircuitOpenError) {
1338
+ console.log("Service is currently unavailable");
1339
+ // Show maintenance message
1340
+ }
1341
+ }
1342
+ ```
1343
+
1344
+ #### Validation Errors
1345
+
1346
+ When schema validation fails, you get a `ValidationError` with rich context:
1347
+
1348
+ ```typescript
1349
+ import { ValidationError, isValidationError } from "@proofkit/fmodata";
1350
+
1351
+ const result = await db.from("users").list().execute();
1352
+
1353
+ if (result.error) {
1354
+ if (isValidationError(result.error)) {
1355
+ // Access validation issues (Standard Schema format)
1356
+ console.log("Validation failed for field:", result.error.field);
1357
+ console.log("Issues:", result.error.issues);
1358
+ console.log("Failed value:", result.error.value);
1359
+ }
1360
+ }
1361
+ ```
1362
+
1363
+ **Validator-Agnostic Error Handling**
1364
+
1365
+ 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:
1366
+
1367
+ ```typescript
1368
+ import { ValidationError } from "@proofkit/fmodata";
1369
+
1370
+ const result = await db.from("users").list().execute();
1371
+
1372
+ if (result.error instanceof ValidationError) {
1373
+ // The cause property (ES2022 Error.cause) contains the Standard Schema issues array
1374
+ // This is validator-agnostic and works with Zod, Valibot, ArkType, etc.
1375
+ console.log("Validation issues:", result.error.cause);
1376
+ console.log("Issues are also available directly:", result.error.issues);
1377
+
1378
+ // Both point to the same array
1379
+ console.log(result.error.cause === result.error.issues); // true
1380
+
1381
+ // Access additional context
1382
+ console.log("Failed field:", result.error.field);
1383
+ console.log("Failed value:", result.error.value);
1384
+
1385
+ // Standard Schema issues have a normalized format
1386
+ result.error.issues.forEach((issue) => {
1387
+ console.log("Path:", issue.path);
1388
+ console.log("Message:", issue.message);
1389
+ });
1390
+ }
1391
+ ```
1392
+
1393
+ **Why Standard Schema Issues Instead of Original Validator Errors?**
1394
+
1395
+ 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.
1396
+
1397
+ If you need validator-specific error formatting, you can still access your validator's methods during validation before the data reaches fmodata:
1398
+
1399
+ ```typescript
1400
+ import { z } from "zod";
1401
+
1402
+ const userSchema = z.object({
1403
+ email: z.string().email(),
1404
+ age: z.number().min(0).max(150),
1405
+ });
1406
+
1407
+ // Validate early if you need Zod-specific error handling
1408
+ const parseResult = userSchema.safeParse(userData);
1409
+ if (!parseResult.success) {
1410
+ // Use Zod's error formatting
1411
+ const formatted = parseResult.error.flatten();
1412
+ console.log("Zod-specific formatting:", formatted);
1413
+ }
1414
+ ```
1415
+
1416
+ #### OData Errors
1417
+
1418
+ Handle OData-specific protocol errors:
1419
+
1420
+ ```typescript
1421
+ import { ODataError, isODataError } from "@proofkit/fmodata";
1422
+
1423
+ const result = await db.from("users").list().execute();
1424
+
1425
+ if (result.error) {
1426
+ if (isODataError(result.error)) {
1427
+ console.log("OData Error Code:", result.error.code);
1428
+ console.log("OData Error Message:", result.error.message);
1429
+ console.log("OData Error Details:", result.error.details);
1430
+ }
1431
+ }
1432
+ ```
1433
+
1434
+ #### Error Handling Patterns
1435
+
1436
+ **Pattern 1: Using instanceof (like ffetch):**
1437
+
1438
+ ```typescript
1439
+ import {
1440
+ HTTPError,
1441
+ ValidationError,
1442
+ TimeoutError,
1443
+ NetworkError,
1444
+ } from "@proofkit/fmodata";
1445
+
1446
+ const result = await db.from("users").list().execute();
1447
+
1448
+ if (result.error) {
1449
+ if (result.error instanceof TimeoutError) {
1450
+ showTimeoutMessage();
1451
+ } else if (result.error instanceof HTTPError) {
1452
+ if (result.error.isNotFound()) {
1453
+ showNotFoundMessage();
1454
+ } else if (result.error.is5xx()) {
1455
+ showServerErrorMessage();
1456
+ }
1457
+ } else if (result.error instanceof ValidationError) {
1458
+ showValidationError(result.error.field, result.error.issues);
1459
+ } else if (result.error instanceof NetworkError) {
1460
+ showOfflineMessage();
1461
+ }
1462
+ }
1463
+ ```
1464
+
1465
+ **Pattern 2: Using kind property (for exhaustive matching):**
1466
+
1467
+ ```typescript
1468
+ const result = await db.from("users").list().execute();
1469
+
1470
+ if (result.error) {
1471
+ switch (result.error.kind) {
1472
+ case "TimeoutError":
1473
+ showTimeoutMessage();
1474
+ break;
1475
+ case "HTTPError":
1476
+ handleHTTPError(result.error.status);
1477
+ break;
1478
+ case "ValidationError":
1479
+ showValidationError(result.error.field, result.error.issues);
1480
+ break;
1481
+ case "NetworkError":
1482
+ showOfflineMessage();
1483
+ break;
1484
+ case "ODataError":
1485
+ handleODataError(result.error.code);
1486
+ break;
1487
+ // TypeScript ensures exhaustive matching!
1488
+ }
1489
+ }
1490
+ ```
1491
+
1492
+ **Pattern 3: Using type guards:**
1493
+
1494
+ ```typescript
1495
+ import {
1496
+ isHTTPError,
1497
+ isValidationError,
1498
+ isODataError,
1499
+ isNetworkError,
1500
+ } from "@proofkit/fmodata";
1501
+
1502
+ const result = await db.from("users").list().execute();
1503
+
1504
+ if (result.error) {
1505
+ if (isHTTPError(result.error)) {
1506
+ // TypeScript knows this is HTTPError
1507
+ console.log("Status:", result.error.status);
1508
+ } else if (isValidationError(result.error)) {
1509
+ // TypeScript knows this is ValidationError
1510
+ console.log("Field:", result.error.field);
1511
+ console.log("Issues:", result.error.issues);
1512
+ } else if (isODataError(result.error)) {
1513
+ // TypeScript knows this is ODataError
1514
+ console.log("Code:", result.error.code);
1515
+ } else if (isNetworkError(result.error)) {
1516
+ // TypeScript knows this is NetworkError
1517
+ console.log("Network issue:", result.error.cause);
1518
+ }
1519
+ }
1520
+ ```
1521
+
1522
+ #### Error Properties
1523
+
1524
+ All errors include helpful metadata:
1525
+
1526
+ ```typescript
1527
+ if (result.error) {
1528
+ // All errors have a timestamp
1529
+ console.log("Error occurred at:", result.error.timestamp);
1530
+
1531
+ // All errors have a kind property for discriminated unions
1532
+ console.log("Error kind:", result.error.kind);
1533
+
1534
+ // All errors have a message
1535
+ console.log("Error message:", result.error.message);
1536
+ }
1537
+ ```
1538
+
1539
+ #### Available Error Types
1540
+
1541
+ - **`HTTPError`** - HTTP status errors (4xx, 5xx) with helper methods (`is4xx()`, `is5xx()`, `isNotFound()`, etc.)
1542
+ - **`ODataError`** - OData protocol errors with code and details
1543
+ - **`ValidationError`** - Schema validation failures with issues, schema reference, and failed value
1544
+ - **`ResponseStructureError`** - Malformed API responses
1545
+ - **`RecordCountMismatchError`** - When `single()` or `maybeSingle()` expectations aren't met
1546
+ - **`TimeoutError`** - Request timeout (from ffetch)
1547
+ - **`NetworkError`** - Network connectivity issues (from ffetch)
1548
+ - **`RetryLimitError`** - Request failed after retries (from ffetch)
1549
+ - **`CircuitOpenError`** - Circuit breaker is open (from ffetch)
1550
+ - **`AbortError`** - Request was aborted (from ffetch)
1551
+
893
1552
  ### OData Annotations and Validation
894
1553
 
895
1554
  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`: