@proofkit/fmodata 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (162) hide show
  1. package/README.md +1250 -377
  2. package/dist/esm/client/batch-builder.d.ts +56 -0
  3. package/dist/esm/client/batch-builder.js +238 -0
  4. package/dist/esm/client/batch-builder.js.map +1 -0
  5. package/dist/esm/client/batch-request.d.ts +61 -0
  6. package/dist/esm/client/batch-request.js +252 -0
  7. package/dist/esm/client/batch-request.js.map +1 -0
  8. package/dist/esm/client/builders/default-select.d.ts +10 -0
  9. package/dist/esm/client/builders/default-select.js +43 -0
  10. package/dist/esm/client/builders/default-select.js.map +1 -0
  11. package/dist/esm/client/builders/expand-builder.d.ts +45 -0
  12. package/dist/esm/client/builders/expand-builder.js +174 -0
  13. package/dist/esm/client/builders/expand-builder.js.map +1 -0
  14. package/dist/esm/client/builders/index.d.ts +8 -0
  15. package/dist/esm/client/builders/query-string-builder.d.ts +18 -0
  16. package/dist/esm/client/builders/query-string-builder.js +25 -0
  17. package/dist/esm/client/builders/query-string-builder.js.map +1 -0
  18. package/dist/esm/client/builders/response-processor.d.ts +43 -0
  19. package/dist/esm/client/builders/response-processor.js +176 -0
  20. package/dist/esm/client/builders/response-processor.js.map +1 -0
  21. package/dist/esm/client/builders/select-mixin.d.ts +32 -0
  22. package/dist/esm/client/builders/select-mixin.js +30 -0
  23. package/dist/esm/client/builders/select-mixin.js.map +1 -0
  24. package/dist/esm/client/builders/select-utils.d.ts +18 -0
  25. package/dist/esm/client/builders/select-utils.js +23 -0
  26. package/dist/esm/client/builders/select-utils.js.map +1 -0
  27. package/dist/esm/client/builders/shared-types.d.ts +40 -0
  28. package/dist/esm/client/builders/table-utils.d.ts +35 -0
  29. package/dist/esm/client/builders/table-utils.js +45 -0
  30. package/dist/esm/client/builders/table-utils.js.map +1 -0
  31. package/dist/esm/client/database.d.ts +68 -15
  32. package/dist/esm/client/database.js +88 -34
  33. package/dist/esm/client/database.js.map +1 -1
  34. package/dist/esm/client/delete-builder.d.ts +31 -17
  35. package/dist/esm/client/delete-builder.js +114 -47
  36. package/dist/esm/client/delete-builder.js.map +1 -1
  37. package/dist/esm/client/entity-set.d.ts +33 -27
  38. package/dist/esm/client/entity-set.js +123 -45
  39. package/dist/esm/client/entity-set.js.map +1 -1
  40. package/dist/esm/client/error-parser.d.ts +12 -0
  41. package/dist/esm/client/error-parser.js +30 -0
  42. package/dist/esm/client/error-parser.js.map +1 -0
  43. package/dist/esm/client/filemaker-odata.d.ts +44 -6
  44. package/dist/esm/client/filemaker-odata.js +172 -28
  45. package/dist/esm/client/filemaker-odata.js.map +1 -1
  46. package/dist/esm/client/insert-builder.d.ts +39 -9
  47. package/dist/esm/client/insert-builder.js +265 -36
  48. package/dist/esm/client/insert-builder.js.map +1 -1
  49. package/dist/esm/client/query/expand-builder.d.ts +35 -0
  50. package/dist/esm/client/query/index.d.ts +3 -0
  51. package/dist/esm/client/query/query-builder.d.ts +139 -0
  52. package/dist/esm/client/query/query-builder.js +481 -0
  53. package/dist/esm/client/query/query-builder.js.map +1 -0
  54. package/dist/esm/client/query/response-processor.d.ts +25 -0
  55. package/dist/esm/client/query/types.d.ts +77 -0
  56. package/dist/esm/client/query/url-builder.d.ts +71 -0
  57. package/dist/esm/client/query/url-builder.js +107 -0
  58. package/dist/esm/client/query/url-builder.js.map +1 -0
  59. package/dist/esm/client/query-builder.d.ts +1 -94
  60. package/dist/esm/client/record-builder.d.ts +107 -22
  61. package/dist/esm/client/record-builder.js +342 -64
  62. package/dist/esm/client/record-builder.js.map +1 -1
  63. package/dist/esm/client/response-processor.d.ts +33 -0
  64. package/dist/esm/client/sanitize-json.d.ts +35 -0
  65. package/dist/esm/client/sanitize-json.js +27 -0
  66. package/dist/esm/client/sanitize-json.js.map +1 -0
  67. package/dist/esm/client/schema-manager.d.ts +57 -0
  68. package/dist/esm/client/schema-manager.js +132 -0
  69. package/dist/esm/client/schema-manager.js.map +1 -0
  70. package/dist/esm/client/update-builder.d.ts +42 -25
  71. package/dist/esm/client/update-builder.js +179 -46
  72. package/dist/esm/client/update-builder.js.map +1 -1
  73. package/dist/esm/client/webhook-builder.d.ts +126 -0
  74. package/dist/esm/client/webhook-builder.js +197 -0
  75. package/dist/esm/client/webhook-builder.js.map +1 -0
  76. package/dist/esm/errors.d.ts +90 -0
  77. package/dist/esm/errors.js +180 -0
  78. package/dist/esm/errors.js.map +1 -0
  79. package/dist/esm/index.d.ts +12 -4
  80. package/dist/esm/index.js +59 -6
  81. package/dist/esm/index.js.map +1 -1
  82. package/dist/esm/logger.d.ts +47 -0
  83. package/dist/esm/logger.js +72 -0
  84. package/dist/esm/logger.js.map +1 -0
  85. package/dist/esm/logger.test.d.ts +1 -0
  86. package/dist/esm/orm/column.d.ts +62 -0
  87. package/dist/esm/orm/column.js +62 -0
  88. package/dist/esm/orm/column.js.map +1 -0
  89. package/dist/esm/orm/field-builders.d.ts +164 -0
  90. package/dist/esm/orm/field-builders.js +168 -0
  91. package/dist/esm/orm/field-builders.js.map +1 -0
  92. package/dist/esm/orm/index.d.ts +4 -0
  93. package/dist/esm/orm/operators.d.ts +175 -0
  94. package/dist/esm/orm/operators.js +242 -0
  95. package/dist/esm/orm/operators.js.map +1 -0
  96. package/dist/esm/orm/table.d.ts +355 -0
  97. package/dist/esm/orm/table.js +200 -0
  98. package/dist/esm/orm/table.js.map +1 -0
  99. package/dist/esm/transform.d.ts +64 -0
  100. package/dist/esm/transform.js +110 -0
  101. package/dist/esm/transform.js.map +1 -0
  102. package/dist/esm/types.d.ts +157 -7
  103. package/dist/esm/types.js +7 -0
  104. package/dist/esm/types.js.map +1 -0
  105. package/dist/esm/validation.d.ts +22 -9
  106. package/dist/esm/validation.js +195 -50
  107. package/dist/esm/validation.js.map +1 -1
  108. package/package.json +19 -4
  109. package/src/client/batch-builder.ts +334 -0
  110. package/src/client/batch-request.ts +485 -0
  111. package/src/client/builders/default-select.ts +80 -0
  112. package/src/client/builders/expand-builder.ts +245 -0
  113. package/src/client/builders/index.ts +11 -0
  114. package/src/client/builders/query-string-builder.ts +49 -0
  115. package/src/client/builders/response-processor.ts +286 -0
  116. package/src/client/builders/select-mixin.ts +75 -0
  117. package/src/client/builders/select-utils.ts +56 -0
  118. package/src/client/builders/shared-types.ts +42 -0
  119. package/src/client/builders/table-utils.ts +87 -0
  120. package/src/client/database.ts +147 -89
  121. package/src/client/delete-builder.ts +189 -87
  122. package/src/client/entity-set.ts +316 -205
  123. package/src/client/error-parser.ts +59 -0
  124. package/src/client/filemaker-odata.ts +254 -41
  125. package/src/client/insert-builder.ts +420 -49
  126. package/src/client/query/expand-builder.ts +164 -0
  127. package/src/client/query/index.ts +13 -0
  128. package/src/client/query/query-builder.ts +905 -0
  129. package/src/client/query/response-processor.ts +236 -0
  130. package/src/client/query/types.ts +128 -0
  131. package/src/client/query/url-builder.ts +179 -0
  132. package/src/client/query-builder.ts +8 -1076
  133. package/src/client/record-builder.ts +704 -139
  134. package/src/client/response-processor.ts +89 -0
  135. package/src/client/sanitize-json.ts +66 -0
  136. package/src/client/schema-manager.ts +246 -0
  137. package/src/client/update-builder.ts +318 -90
  138. package/src/client/webhook-builder.ts +285 -0
  139. package/src/errors.ts +261 -0
  140. package/src/index.ts +122 -14
  141. package/src/logger.test.ts +34 -0
  142. package/src/logger.ts +140 -0
  143. package/src/orm/column.ts +106 -0
  144. package/src/orm/field-builders.ts +318 -0
  145. package/src/orm/index.ts +60 -0
  146. package/src/orm/operators.ts +487 -0
  147. package/src/orm/table.ts +759 -0
  148. package/src/transform.ts +263 -0
  149. package/src/types.ts +275 -55
  150. package/src/validation.ts +255 -55
  151. package/dist/esm/client/base-table.d.ts +0 -13
  152. package/dist/esm/client/base-table.js +0 -19
  153. package/dist/esm/client/base-table.js.map +0 -1
  154. package/dist/esm/client/query-builder.js +0 -649
  155. package/dist/esm/client/query-builder.js.map +0 -1
  156. package/dist/esm/client/table-occurrence.d.ts +0 -25
  157. package/dist/esm/client/table-occurrence.js +0 -47
  158. package/dist/esm/client/table-occurrence.js.map +0 -1
  159. package/dist/esm/filter-types.d.ts +0 -76
  160. package/src/client/base-table.ts +0 -25
  161. package/src/client/table-occurrence.ts +0 -100
  162. package/src/filter-types.ts +0 -97
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
 
@@ -23,8 +26,10 @@ Here's a minimal example to get you started:
23
26
  ```typescript
24
27
  import {
25
28
  FMServerConnection,
26
- BaseTable,
27
- TableOccurrence,
29
+ fmTableOccurrence,
30
+ textField,
31
+ numberField,
32
+ eq,
28
33
  } from "@proofkit/fmodata";
29
34
  import { z } from "zod/v4";
30
35
 
@@ -41,30 +46,21 @@ const connection = new FMServerConnection({
41
46
  },
42
47
  });
43
48
 
44
- // 2. Define your table schema
45
- const usersBase = new BaseTable({
46
- schema: {
47
- id: z.string(),
48
- username: z.string(),
49
- email: z.string(),
50
- active: z.boolean(),
51
- },
52
- idField: "id",
53
- });
54
-
55
- // 3. Create a table occurrence
56
- const usersTO = new TableOccurrence({
57
- name: "users",
58
- baseTable: usersBase,
49
+ // 2. Define your table schema using field builders
50
+ const users = fmTableOccurrence("users", {
51
+ id: textField().primaryKey(),
52
+ username: textField().notNull(),
53
+ email: textField().notNull(),
54
+ active: numberField()
55
+ .readValidator(z.coerce.boolean())
56
+ .writeValidator(z.boolean().transform((v) => (v ? 1 : 0))),
59
57
  });
60
58
 
61
- // 4. Create a database instance
62
- const db = connection.database("MyDatabase.fmp12", {
63
- occurrences: [usersTO],
64
- });
59
+ // 3. Create a database instance
60
+ const db = connection.database("MyDatabase.fmp12");
65
61
 
66
- // 5. Query your data
67
- const { data, error } = await db.from("users").list().execute();
62
+ // 4. Query your data
63
+ const { data, error } = await db.from(users).list().execute();
68
64
 
69
65
  if (error) {
70
66
  console.error(error);
@@ -78,13 +74,12 @@ if (data) {
78
74
 
79
75
  ## Core Concepts
80
76
 
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.
77
+ 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
78
 
83
79
  As such, there are layers to the library to help you build your queries and operations.
84
80
 
85
81
  - `FMServerConnection` - hold server connection details and authentication
86
- - `BaseTable` - defines the fields and validators for a base table
87
- - `TableOccurrence` - references a base table, and other table occurrences for navigation
82
+ - `FMTable` (created via `fmTableOccurrence()`) - defines the fields, validators, and metadata for a table occurrence
88
83
  - `Database` - connects the table occurrences to the server connection
89
84
 
90
85
  ### FileMaker Server prerequisites
@@ -97,7 +92,7 @@ To use this library you need:
97
92
 
98
93
  A note on best practices:
99
94
 
100
- OData relies entirely on the table occurances in the relationship graph for data access. Relationships between table occurrences are also used, but maybe not as you expect (in short, only the simplest relationships are supported). Given these constraints, it may be best for you to have a seperate FileMaker file for your OData connection, using external data sources to link to your actual data. We've found this especially helpful for larger projects that have very large graphs with lots of duplicated table occurances compared to actual base tables.
95
+ OData relies entirely on the table occurances in the relationship graph for data access. Relationships between table occurrences are also used, but maybe not as you expect (in short, only the simplest relationships are supported). Given these constraints, it may be best for you to have a seperate FileMaker file for your OData connection, using external data sources to link to your actual data file. We've found this especially helpful for larger projects that have very large graphs with lots of redundant table occurances compared to actual number of base tables.
101
96
 
102
97
  ### Server Connection
103
98
 
@@ -124,85 +119,118 @@ const connection = new FMServerConnection({
124
119
 
125
120
  ### Schema Definitions
126
121
 
127
- 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.
122
+ This library relies on a schema-first approach for good type-safety and optional runtime validation. Use **`fmTableOccurrence()`** with field builders to create your schemas. This provides full TypeScript type inference for field names in queries.
128
123
 
129
- 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.
124
+ #### Field Builders
130
125
 
131
- ```typescript
132
- import { z } from "zod/v4";
133
- import { BaseTable } from "@proofkit/fmodata";
134
-
135
- const contactsBase = new BaseTable({
136
- schema: {
137
- id: z.string(),
138
- name: z.string(),
139
- email: z.string(),
140
- phone: z.string().optional(),
141
- createdAt: z.string(),
142
- },
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
- });
147
- ```
126
+ Field builders provide a fluent API for defining table fields with type-safe metadata. These field types map directly to the FileMaker field types
148
127
 
149
- 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.
128
+ - `textField()`
129
+ - `numberField()`
130
+ - `dateField()`
131
+ - `timeField()`
132
+ - `timestampField()`
133
+ - `containerField()`
134
+ - `calcField()`
135
+
136
+ Each field builder supports chainable methods:
137
+
138
+ - `.primaryKey()` - Mark as primary key (automatically read-only)
139
+ - `.notNull()` - Make field non-nullable (required for inserts)
140
+ - `.readOnly()` - Exclude from insert/update operations
141
+ - `.entityId(id)` - Assign FileMaker field ID (FMFID), allowing your API calls to survive FileMaker name changes
142
+ - `.readValidator(validator)` - Transform/validate data when reading from database
143
+ - `.writeValidator(validator)` - Transform/validate data when writing to database
144
+
145
+ #### Defining Tables
146
+
147
+ Use `fmTableOccurrence()` to define a table with field builders:
150
148
 
151
149
  ```typescript
152
- import { TableOccurrence } from "@proofkit/fmodata";
150
+ import { z } from "zod/v4";
151
+ import {
152
+ fmTableOccurrence,
153
+ textField,
154
+ numberField,
155
+ timestampField,
156
+ } from "@proofkit/fmodata";
153
157
 
154
- const contactsTO = new TableOccurrence({
155
- name: "contacts", // The table occurrence name in FileMaker
156
- baseTable: contactsBase,
157
- });
158
+ const contacts = fmTableOccurrence(
159
+ "contacts",
160
+ {
161
+ id: textField().primaryKey().entityId("FMFID:1"),
162
+ name: textField().notNull().entityId("FMFID:2"),
163
+ email: textField().notNull().entityId("FMFID:3"),
164
+ phone: textField().entityId("FMFID:4"), // Optional (nullable by default)
165
+ createdAt: timestampField().readOnly().entityId("FMFID:5"),
166
+ },
167
+ {
168
+ entityId: "FMTID:100", // Optional: FileMaker table occurrence ID
169
+ defaultSelect: "schema", // Optional: "all", "schema", or function. Defaults to "schema".
170
+ navigationPaths: ["users"], // Optional: valid navigation targets to provide type-errors when navigating/expanding
171
+ },
172
+ );
158
173
  ```
159
174
 
175
+ The function returns a table object that provides:
176
+
177
+ - Column references for each field (e.g., `contacts.id`, `contacts.name`)
178
+ - Type-safe schema for queries and operations
179
+ - Metadata stored via Symbols (hidden from IDE autocomplete)
180
+
160
181
  #### Default Field Selection
161
182
 
162
- FileMaker will automatically return all non-container fields from a schema if you don't specify a $select parameter in your query. This library forces you to be a bit more explicit about what fields you want to return so that the types will more accurately reflect the full data you will get back. To modify this behavior, change the `defaultSelect` option when creating the `TableOccurrence`.
183
+ FileMaker will automatically return all non-container fields from a schema if you don't specify a $select parameter in your query. This library allows you to configure default field selection behavior using the `defaultSelect` option:
163
184
 
164
185
  ```typescript
165
- // Option 1 (default): "schema" - Select all fields from the schema (same as "all" but more explicit)
166
- const usersTO = new TableOccurrence({
167
- name: "users",
168
- baseTable: usersBase,
169
- defaultSelect: "schema", // a $select parameter will be always be added to the query for only the fields you've defined in the BaseTable schema
170
- });
186
+ // Option 1 (default): "schema" - Select all fields from the schema
187
+ const users = fmTableOccurrence(
188
+ "users",
189
+ {
190
+ /* fields */
191
+ },
192
+ {
193
+ defaultSelect: "schema", // A $select parameter will always be added for only the fields defined in the schema
194
+ },
195
+ );
171
196
 
172
- // Option 2: "all" - Select all fields (default behavior)
173
- const usersTO = new TableOccurrence({
174
- name: "users",
175
- baseTable: usersBase,
176
- defaultSelect: "all", // Don't always a $select parameter to the query; FileMaker will return all non-container fields from the table
177
- });
197
+ // Option 2: "all" - Select all fields (FileMaker default behavior)
198
+ const users = fmTableOccurrence(
199
+ "users",
200
+ {
201
+ /* fields */
202
+ },
203
+ {
204
+ defaultSelect: "all", // No $select parameter by default; FileMaker returns all non-container fields
205
+ },
206
+ );
178
207
 
179
- // Option 3: Array of field names - Select only specific fields by default
180
- const usersTO = new TableOccurrence({
181
- name: "users",
182
- baseTable: usersBase,
183
- defaultSelect: ["username", "email"], // Only select these fields by default
184
- });
208
+ // Option 3: Function - Select specific columns by default
209
+ const users = fmTableOccurrence(
210
+ "users",
211
+ {
212
+ /* fields */
213
+ },
214
+ {
215
+ defaultSelect: (cols) => ({
216
+ username: cols.username,
217
+ email: cols.email,
218
+ }), // Only select these fields by default
219
+ },
220
+ );
185
221
 
186
- // When you call list(), the defaultSelect is applied automatically
187
- const result = await db.from("users").list().execute();
188
- // If defaultSelect is ["username", "email"], result.data will only contain those fields
222
+ // When you call list() or get(), the defaultSelect is applied automatically
223
+ const result = await db.from(users).list().execute();
224
+ // If defaultSelect is a function returning { username, email }, result.data will only contain those fields
189
225
 
190
226
  // You can still override with explicit select()
191
227
  const result = await db
192
- .from("users")
228
+ .from(users)
193
229
  .list()
194
- .select("username", "email", "age") // Always overrides at the per-request level
230
+ .select({ username: users.username, email: users.email, age: users.age }) // Always overrides at the per-request level
195
231
  .execute();
196
232
  ```
197
233
 
198
- 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.
199
-
200
- ```typescript
201
- const db = connection.database("MyDatabase.fmp12", {
202
- occurrences: [contactsTO, usersTO], // Register your table occurrences
203
- });
204
- ```
205
-
206
234
  ## Querying Data
207
235
 
208
236
  ### Basic Queries
@@ -234,9 +262,9 @@ Get a single field value:
234
262
 
235
263
  ```typescript
236
264
  const result = await db
237
- .from("users")
265
+ .from(users)
238
266
  .get("user-123")
239
- .getSingleField("email")
267
+ .getSingleField(users.email)
240
268
  .execute();
241
269
 
242
270
  if (result.data) {
@@ -246,173 +274,103 @@ if (result.data) {
246
274
 
247
275
  ### Filtering
248
276
 
249
- fmodata provides type-safe filter operations that prevent common errors at compile time. The filter system supports three syntaxes: shorthand, single operator objects, and arrays for multiple operators.
250
-
251
- #### Operator Syntax
252
-
253
- You can use filters in three ways:
254
-
255
- **1. Shorthand (direct value):**
256
-
257
- ```typescript
258
- .filter({ name: "John" })
259
- // Equivalent to: { name: [{ eq: "John" }] }
260
- ```
261
-
262
- **2. Single operator object:**
263
-
264
- ```typescript
265
- .filter({ age: { gt: 18 } })
266
- ```
267
-
268
- **3. Array of operators (for multiple operators on same field):**
269
-
270
- ```typescript
271
- .filter({ age: [{ gt: 18 }, { lt: 65 }] })
272
- // Result: age gt 18 and age lt 65
273
- ```
274
-
275
- The array pattern prevents duplicate operators on the same field and allows multiple conditions with implicit AND.
276
-
277
- #### Available Operators
278
-
279
- **String fields:**
280
-
281
- - `eq`, `ne` - equality/inequality
282
- - `contains`, `startswith`, `endswith` - string functions
283
- - `gt`, `ge`, `lt`, `le` - comparison
284
- - `in` - match any value in array
285
-
286
- **Number fields:**
287
-
288
- - `eq`, `ne`, `gt`, `ge`, `lt`, `le` - comparisons
289
- - `in` - match any value in array
290
-
291
- **Boolean fields:**
292
-
293
- - `eq`, `ne` - equality only
277
+ fmodata provides type-safe filter operations that prevent common errors at compile time. You can use either the new ORM-style API with operators and column references, or the legacy filter API.
294
278
 
295
- **Date fields:**
279
+ #### New ORM-Style API (Recommended)
296
280
 
297
- - `eq`, `ne`, `gt`, `ge`, `lt`, `le` - date comparisons
298
- - `in` - match any date in array
299
-
300
- #### Shorthand Syntax
301
-
302
- For simple equality checks, use the shorthand:
303
-
304
- ```typescript
305
- const result = await db.from("users").list().filter({ name: "John" }).execute();
306
- // Equivalent to: { name: [{ eq: "John" }] }
307
- ```
308
-
309
- #### Examples
281
+ Use the `where()` method with filter operators and column references for type-safe filtering:
310
282
 
311
283
  ```typescript
312
- // Equality filter (single operator)
313
- const activeUsers = await db
314
- .from("users")
315
- .list()
316
- .filter({ active: { eq: true } })
317
- .execute();
318
-
319
- // Comparison operators (single operator)
320
- const adultUsers = await db
321
- .from("users")
322
- .list()
323
- .filter({ age: { gt: 18 } })
324
- .execute();
325
-
326
- // String operators (single operator)
327
- const johns = await db
328
- .from("users")
329
- .list()
330
- .filter({ name: { contains: "John" } })
331
- .execute();
332
-
333
- // Multiple operators on same field (array syntax, implicit AND)
334
- const rangeQuery = await db
335
- .from("users")
336
- .list()
337
- .filter({ age: [{ gt: 18 }, { lt: 65 }] })
338
- .execute();
284
+ import { eq, gt, and, or, contains } from "@proofkit/fmodata";
339
285
 
340
- // Combine filters with AND
286
+ // Simple equality
341
287
  const result = await db
342
- .from("users")
288
+ .from(users)
343
289
  .list()
344
- .filter({
345
- and: [{ active: [{ eq: true }] }, { age: [{ gt: 18 }] }],
346
- })
290
+ .where(eq(users.active, true))
347
291
  .execute();
348
292
 
349
- // Combine filters with OR
293
+ // Comparison operators
294
+ const result = await db.from(users).list().where(gt(users.age, 18)).execute();
295
+
296
+ // String operators
350
297
  const result = await db
351
- .from("users")
298
+ .from(users)
352
299
  .list()
353
- .filter({
354
- or: [{ name: [{ eq: "John" }] }, { name: [{ eq: "Jane" }] }],
355
- })
300
+ .where(contains(users.name, "John"))
356
301
  .execute();
357
302
 
358
- // IN operator
303
+ // Combine with AND
359
304
  const result = await db
360
- .from("users")
305
+ .from(users)
361
306
  .list()
362
- .filter({ age: [{ in: [18, 21, 25] }] })
307
+ .where(and(eq(users.active, true), gt(users.age, 18)))
363
308
  .execute();
364
309
 
365
- // Null checks
310
+ // Combine with OR
366
311
  const result = await db
367
- .from("users")
312
+ .from(users)
368
313
  .list()
369
- .filter({ deletedAt: [{ eq: null }] })
314
+ .where(or(eq(users.role, "admin"), eq(users.role, "moderator")))
370
315
  .execute();
371
316
  ```
372
317
 
373
- #### Logical Operators
318
+ Available operators:
319
+
320
+ - **Comparison**: `eq()`, `ne()`, `gt()`, `gte()`, `lt()`, `lte()`
321
+ - **String**: `contains()`, `startsWith()`, `endsWith()`
322
+ - **Array**: `inArray()`, `notInArray()`
323
+ - **Null**: `isNull()`, `isNotNull()`
324
+ - **Logical**: `and()`, `or()`, `not()`
325
+
326
+ ### Sorting
327
+
328
+ Sort results using `orderBy()`. The method supports both column references (new ORM API) and string field names (legacy API).
374
329
 
375
- Combine multiple conditions with `and`, `or`, `not`:
330
+ #### Using Column References (New ORM API)
376
331
 
377
332
  ```typescript
333
+ import { asc, desc } from "@proofkit/fmodata";
334
+
335
+ // Single field (ascending by default)
336
+ const result = await db.from(users).list().orderBy(users.name).execute();
337
+
338
+ // Single field with explicit direction
339
+ const result = await db.from(users).list().orderBy(asc(users.name)).execute();
340
+ const result = await db.from(users).list().orderBy(desc(users.age)).execute();
341
+
342
+ // Multiple fields (variadic)
378
343
  const result = await db
379
- .from("users")
344
+ .from(users)
380
345
  .list()
381
- .filter({
382
- and: [{ name: [{ contains: "John" }] }, { age: [{ gt: 18 }] }],
383
- })
346
+ .orderBy(asc(users.lastName), desc(users.firstName))
384
347
  .execute();
385
- ```
386
-
387
- #### Escape Hatch
388
-
389
- For unsupported edge cases, pass a raw OData filter string:
390
348
 
391
- ```typescript
349
+ // Multiple fields (array syntax)
392
350
  const result = await db
393
- .from("users")
351
+ .from(users)
394
352
  .list()
395
- .filter("substringof('John', name)")
353
+ .orderBy([
354
+ [users.lastName, "asc"],
355
+ [users.firstName, "desc"],
356
+ ])
396
357
  .execute();
397
358
  ```
398
359
 
399
- ### Sorting
360
+ #### Type Safety
400
361
 
401
- Sort results using `orderBy()`:
362
+ For typed databases, `orderBy()` provides full type safety:
402
363
 
403
364
  ```typescript
404
- // Sort ascending
405
- const result = await db.from("users").list().orderBy("name").execute();
365
+ // Valid - "name" is a field in the schema
366
+ db.from(users).list().orderBy(users.name);
406
367
 
407
- // Sort descending
408
- const result = await db.from("users").list().orderBy("name desc").execute();
368
+ // Valid - tuple with field and direction
369
+ db.from(users).list().orderBy(asc(users.name));
370
+ db.from(users).list().orderBy(desc(users.name));
409
371
 
410
- // Multiple sort fields
411
- const result = await db
412
- .from("users")
413
- .list()
414
- .orderBy("lastName, firstName desc")
415
- .execute();
372
+ // Valid - multiple fields
373
+ db.from(users).list().orderBy(asc(users.lastName), desc(users.firstName));
416
374
  ```
417
375
 
418
376
  ### Pagination
@@ -421,24 +379,29 @@ Control the number of records returned and pagination:
421
379
 
422
380
  ```typescript
423
381
  // Limit results
424
- const result = await db.from("users").list().top(10).execute();
382
+ const result = await db.from(users).list().top(10).execute();
425
383
 
426
384
  // Skip records (pagination)
427
- const result = await db.from("users").list().top(10).skip(20).execute();
385
+ const result = await db.from(users).list().top(10).skip(20).execute();
428
386
 
429
387
  // Count total records
430
- const result = await db.from("users").list().count().execute();
388
+ const result = await db.from(users).list().count().execute();
431
389
  ```
432
390
 
433
391
  ### Selecting Fields
434
392
 
435
- Select specific fields to return:
393
+ Select specific fields to return. You can use either column references (new ORM API) or string field names (legacy API):
436
394
 
437
395
  ```typescript
396
+ // New ORM API: Using column references (type-safe, supports renaming)
438
397
  const result = await db
439
- .from("users")
398
+ .from(users)
440
399
  .list()
441
- .select("username", "email")
400
+ .select({
401
+ username: users.username,
402
+ email: users.email,
403
+ userId: users.id, // Renamed from "id" to "userId"
404
+ })
442
405
  .execute();
443
406
 
444
407
  // result.data[0] will only have username and email fields
@@ -450,9 +413,9 @@ Use `single()` to ensure exactly one record is returned (returns an error if zer
450
413
 
451
414
  ```typescript
452
415
  const result = await db
453
- .from("users")
416
+ .from(users)
454
417
  .list()
455
- .filter({ email: { eq: "user@example.com" } })
418
+ .where(eq(users.email, "user@example.com"))
456
419
  .single()
457
420
  .execute();
458
421
 
@@ -466,9 +429,9 @@ Use `maybeSingle()` when you want at most one record (returns `null` if no recor
466
429
 
467
430
  ```typescript
468
431
  const result = await db
469
- .from("users")
432
+ .from(users)
470
433
  .list()
471
- .filter({ email: { eq: "user@example.com" } })
434
+ .where(eq(users.email, "user@example.com"))
472
435
  .maybeSingle()
473
436
  .execute();
474
437
 
@@ -491,12 +454,17 @@ if (result.data) {
491
454
  All query methods can be chained together:
492
455
 
493
456
  ```typescript
457
+ // Using new ORM API
494
458
  const result = await db
495
- .from("users")
459
+ .from(users)
496
460
  .list()
497
- .select("username", "email", "age")
498
- .filter({ age: { gt: 18 } })
499
- .orderBy("username")
461
+ .select({
462
+ username: users.username,
463
+ email: users.email,
464
+ age: users.age,
465
+ })
466
+ .where(gt(users.age, 18))
467
+ .orderBy(asc(users.username))
500
468
  .top(10)
501
469
  .skip(0)
502
470
  .execute();
@@ -511,7 +479,7 @@ Insert new records with type-safe data:
511
479
  ```typescript
512
480
  // Insert a new user
513
481
  const result = await db
514
- .from("users")
482
+ .from(users)
515
483
  .insert({
516
484
  username: "johndoe",
517
485
  email: "john@example.com",
@@ -524,27 +492,25 @@ if (result.data) {
524
492
  }
525
493
  ```
526
494
 
527
- If you specify `insertRequired` fields in your base table, those fields become required:
495
+ Fields are automatically required for insert if they use `.notNull()`. Read-only fields (including primary keys) are automatically excluded:
528
496
 
529
497
  ```typescript
530
- const usersBase = new BaseTable({
531
- schema: {
532
- id: z.string(),
533
- username: z.string(),
534
- email: z.string(),
535
- createdAt: z.string().optional(),
536
- },
537
- idField: "id",
538
- insertRequired: ["username", "email"], // These fields are required on insert
498
+ const users = fmTableOccurrence("users", {
499
+ id: textField().primaryKey(), // Auto-required, but excluded from insert (primaryKey)
500
+ username: textField().notNull(), // Auto-required (notNull)
501
+ email: textField().notNull(), // Auto-required (notNull)
502
+ phone: textField(), // Optional by default (nullable)
503
+ createdAt: timestampField().readOnly(), // Excluded from insert/update
539
504
  });
540
505
 
541
- // TypeScript will enforce that username and email are provided
506
+ // TypeScript enforces: username and email are required
507
+ // TypeScript excludes: id and createdAt cannot be provided
542
508
  const result = await db
543
- .from("users")
509
+ .from(users)
544
510
  .insert({
545
511
  username: "johndoe",
546
512
  email: "john@example.com",
547
- // createdAt is optional
513
+ phone: "+1234567890", // Optional
548
514
  })
549
515
  .execute();
550
516
  ```
@@ -556,7 +522,7 @@ Update records by ID or filter:
556
522
  ```typescript
557
523
  // Update by ID
558
524
  const result = await db
559
- .from("users")
525
+ .from(users)
560
526
  .update({ username: "newname" })
561
527
  .byId("user-123")
562
528
  .execute();
@@ -565,29 +531,27 @@ if (result.data) {
565
531
  console.log(`Updated ${result.data.updatedCount} record(s)`);
566
532
  }
567
533
 
568
- // Update by filter
534
+ // Update by filter (using new ORM API)
535
+ import { lt, and, eq } from "@proofkit/fmodata";
536
+
569
537
  const result = await db
570
- .from("users")
538
+ .from(users)
571
539
  .update({ active: false })
572
- .where((q) => q.filter({ lastLogin: { lt: "2023-01-01" } }))
540
+ .where(lt(users.lastLogin, "2023-01-01"))
573
541
  .execute();
574
542
 
575
543
  // Complex filter example
576
544
  const result = await db
577
- .from("users")
545
+ .from(users)
578
546
  .update({ active: false })
579
- .where((q) =>
580
- q.filter({
581
- and: [{ active: true }, { count: { lt: 5 } }],
582
- }),
583
- )
547
+ .where(and(eq(users.active, true), lt(users.count, 5)))
584
548
  .execute();
585
549
 
586
- // Update with additional query options
550
+ // Update with additional query options (legacy filter API)
587
551
  const result = await db
588
552
  .from("users")
589
553
  .update({ active: false })
590
- .where((q) => q.filter({ active: true }).top(10))
554
+ .where((q) => q.where(eq(users.active, true)).top(10))
591
555
  .execute();
592
556
  ```
593
557
 
@@ -597,28 +561,26 @@ Delete records by ID or filter:
597
561
 
598
562
  ```typescript
599
563
  // Delete by ID
600
- const result = await db.from("users").delete().byId("user-123").execute();
564
+ const result = await db.from(users).delete().byId("user-123").execute();
601
565
 
602
566
  if (result.data) {
603
567
  console.log(`Deleted ${result.data.deletedCount} record(s)`);
604
568
  }
605
569
 
606
- // Delete by filter
570
+ // Delete by filter (using new ORM API)
571
+ import { eq, and, lt } from "@proofkit/fmodata";
572
+
607
573
  const result = await db
608
- .from("users")
574
+ .from(users)
609
575
  .delete()
610
- .where((q) => q.filter({ active: false }))
576
+ .where(eq(users.active, false))
611
577
  .execute();
612
578
 
613
579
  // Delete with complex filters
614
580
  const result = await db
615
- .from("users")
581
+ .from(users)
616
582
  .delete()
617
- .where((q) =>
618
- q.filter({
619
- and: [{ active: false }, { lastLogin: { lt: "2023-01-01" } }],
620
- }),
621
- )
583
+ .where(and(eq(users.active, false), lt(users.lastLogin, "2023-01-01")))
622
584
  .execute();
623
585
  ```
624
586
 
@@ -626,128 +588,145 @@ const result = await db
626
588
 
627
589
  ### Defining Navigation
628
590
 
629
- Define relationships between tables using the `navigation` option:
591
+ Define navigation relationships using the `navigationPaths` option when creating table occurrences:
630
592
 
631
593
  ```typescript
632
- const contactsBase = new BaseTable({
633
- schema: {
634
- id: z.string(),
635
- name: z.string(),
636
- userId: z.string(),
594
+ import { fmTableOccurrence, textField } from "@proofkit/fmodata";
595
+
596
+ const contacts = fmTableOccurrence(
597
+ "contacts",
598
+ {
599
+ id: textField().primaryKey(),
600
+ name: textField().notNull(),
601
+ userId: textField().notNull(),
637
602
  },
638
- idField: "id",
639
- });
640
-
641
- const usersBase = new BaseTable({
642
- schema: {
643
- id: z.string(),
644
- username: z.string(),
645
- email: z.string(),
603
+ {
604
+ navigationPaths: ["users"], // Valid navigation targets
646
605
  },
647
- idField: "id",
648
- });
606
+ );
649
607
 
650
- // Define navigation using functions to handle circular dependencies
651
- const contactsTO = new TableOccurrence({
652
- name: "contacts",
653
- baseTable: contactsBase,
654
- navigation: {
655
- users: () => usersTO, // Relationship to users table
608
+ const users = fmTableOccurrence(
609
+ "users",
610
+ {
611
+ id: textField().primaryKey(),
612
+ username: textField().notNull(),
613
+ email: textField().notNull(),
656
614
  },
657
- });
658
-
659
- const usersTO = new TableOccurrence({
660
- name: "users",
661
- baseTable: usersBase,
662
- navigation: {
663
- contacts: () => contactsTO, // Relationship to contacts table
615
+ {
616
+ navigationPaths: ["contacts"], // Valid navigation targets
664
617
  },
665
- });
618
+ );
666
619
 
667
- // You can also add navigation after creation
668
- const updatedUsersTO = usersTO.addNavigation({
669
- profile: () => profileTO,
620
+ // Use with your database
621
+ const db = connection.database("MyDB", {
622
+ occurrences: [contacts, users],
670
623
  });
671
624
  ```
672
625
 
626
+ The `navigationPaths` option:
627
+
628
+ - Specifies which table occurrences can be navigated to from this table
629
+ - Enables runtime validation when using `expand()` or `navigate()`
630
+ - Throws descriptive errors if you try to navigate to an invalid path
631
+
673
632
  ### Navigating Between Tables
674
633
 
675
634
  Navigate to related records:
676
635
 
677
636
  ```typescript
678
- // Navigate from a specific record
637
+ // Navigate from a specific record (using column references)
679
638
  const result = await db
680
- .from("contacts")
639
+ .from(contacts)
681
640
  .get("contact-123")
682
- .navigate("users")
683
- .select("username", "email")
641
+ .navigate(users)
642
+ .select({
643
+ username: users.username,
644
+ email: users.email,
645
+ })
684
646
  .execute();
685
647
 
686
648
  // Navigate without specifying a record first
687
- const result = await db.from("contacts").navigate("users").list().execute();
649
+ const result = await db.from(contacts).navigate(users).list().execute();
688
650
 
689
- // You can navigate to arbitrary tables not in your schema
651
+ // Using legacy API with string field names
690
652
  const result = await db
691
- .from("contacts")
692
- .navigate("some_other_table")
693
- .list()
653
+ .from(contacts)
654
+ .get("contact-123")
655
+ .navigate(users)
656
+ .select({ username: users.username, email: users.email })
694
657
  .execute();
695
658
  ```
696
659
 
697
660
  ### Expanding Related Records
698
661
 
699
- Use `expand()` to include related records in your query results:
662
+ Use `expand()` to include related records in your query results. The library validates that the target table is in the source table's `navigationPaths`:
700
663
 
701
664
  ```typescript
702
665
  // Simple expand
703
- const result = await db.from("contacts").list().expand("users").execute();
666
+ const result = await db.from(contacts).list().expand(users).execute();
704
667
 
705
- // Expand with field selection
668
+ // Expand with field selection (using column references)
706
669
  const result = await db
707
- .from("contacts")
670
+ .from(contacts)
708
671
  .list()
709
- .expand("users", (b) => b.select("username", "email"))
672
+ .expand(users, (b) =>
673
+ b.select({
674
+ username: users.username,
675
+ email: users.email,
676
+ }),
677
+ )
710
678
  .execute();
711
679
 
712
- // Expand with filtering
680
+ // Expand with filtering (using new ORM API)
681
+ import { eq } from "@proofkit/fmodata";
682
+
713
683
  const result = await db
714
- .from("contacts")
684
+ .from(contacts)
715
685
  .list()
716
- .expand("users", (b) => b.filter({ active: true }))
686
+ .expand(users, (b) => b.where(eq(users.active, true)))
717
687
  .execute();
718
688
 
719
689
  // Multiple expands
720
690
  const result = await db
721
- .from("contacts")
691
+ .from(contacts)
722
692
  .list()
723
- .expand("users", (b) => b.select("username"))
724
- .expand("orders", (b) => b.select("total").top(5))
693
+ .expand(users, (b) => b.select({ username: users.username }))
694
+ .expand(orders, (b) => b.select({ total: orders.total }).top(5))
725
695
  .execute();
726
696
 
727
697
  // Nested expands
728
698
  const result = await db
729
- .from("contacts")
699
+ .from(contacts)
730
700
  .list()
731
- .expand("users", (usersBuilder) =>
701
+ .expand(users, (usersBuilder) =>
732
702
  usersBuilder
733
- .select("username", "email")
734
- .expand("customer", (customerBuilder) =>
735
- customerBuilder.select("name", "tier"),
703
+ .select({
704
+ username: users.username,
705
+ email: users.email,
706
+ })
707
+ .expand(customers, (customerBuilder) =>
708
+ customerBuilder.select({
709
+ name: customers.name,
710
+ tier: customers.tier,
711
+ }),
736
712
  ),
737
713
  )
738
714
  .execute();
739
715
 
740
716
  // Complex expand with multiple options
741
717
  const result = await db
742
- .from("contacts")
718
+ .from(contacts)
743
719
  .list()
744
- .expand("users", (b) =>
720
+ .expand(users, (b) =>
745
721
  b
746
- .select("username", "email")
747
- .filter({ active: true })
748
- .orderBy("username")
722
+ .select({
723
+ username: users.username,
724
+ email: users.email,
725
+ })
726
+ .where(eq(users.active, true))
727
+ .orderBy(asc(users.username))
749
728
  .top(10)
750
- .expand("customer", (nested) => nested.select("name")),
729
+ .expand(customers, (nested) => nested.select({ name: customers.name })),
751
730
  )
752
731
  .execute();
753
732
  ```
@@ -799,86 +778,709 @@ console.log(result.result.recordId);
799
778
 
800
779
  **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
780
 
802
- ## Advanced Features
781
+ ## Webhooks
782
+
783
+ Webhooks allow you to receive notifications when data changes in your FileMaker database. The library provides a type-safe API for managing webhooks through the `db.webhook` property.
803
784
 
804
- ### Type Safety
785
+ ### Adding a Webhook
805
786
 
806
- The library provides full TypeScript type inference:
787
+ Create a new webhook to monitor a table for changes:
807
788
 
808
789
  ```typescript
809
- const usersBase = new BaseTable({
810
- schema: {
811
- id: z.string(),
812
- username: z.string(),
813
- email: z.string(),
790
+ // Basic webhook
791
+ const result = await db.webhook.add({
792
+ webhook: "https://example.com/webhook",
793
+ tableName: contactsTable,
794
+ });
795
+
796
+ // Access the created webhook ID
797
+ console.log(result.webHookResult.webHookID);
798
+ ```
799
+
800
+ ### Webhook Configuration Options
801
+
802
+ Webhooks support various configuration options:
803
+
804
+ ```typescript
805
+ // With custom headers
806
+ const result = await db.webhook.add({
807
+ webhook: "https://example.com/webhook",
808
+ tableName: contactsTable,
809
+ headers: {
810
+ "X-Custom-Header": "value",
811
+ Authorization: "Bearer token",
814
812
  },
815
- idField: "id",
813
+ notifySchemaChanges: true, // Notify when schema changes
816
814
  });
817
815
 
818
- const usersTO = new TableOccurrence({
819
- name: "users",
820
- baseTable: usersBase,
816
+ // With field selection (using column references)
817
+ const result = await db.webhook.add({
818
+ webhook: "https://example.com/webhook",
819
+ tableName: contacts,
820
+ select: [contacts.name, contacts.email, contacts.PrimaryKey],
821
821
  });
822
822
 
823
- const db = connection.database("MyDB", {
824
- occurrences: [usersTO],
823
+ // With filtering (using filter expressions)
824
+ import { eq, gt } from "@proofkit/fmodata";
825
+
826
+ const result = await db.webhook.add({
827
+ webhook: "https://example.com/webhook",
828
+ tableName: contacts,
829
+ filter: eq(contacts.active, true),
830
+ select: [contacts.name, contacts.email],
825
831
  });
826
832
 
827
- // TypeScript knows these are valid field names
828
- db.from("users").list().select("username", "email");
833
+ // Complex filter example
834
+ const result = await db.webhook.add({
835
+ webhook: "https://example.com/webhook",
836
+ tableName: users,
837
+ filter: and(eq(users.active, true), gt(users.age, 18)),
838
+ select: [users.username, users.email],
839
+ });
840
+ ```
829
841
 
830
- // TypeScript error: "invalid" is not a field name
831
- db.from("users").list().select("invalid"); // TS Error
842
+ **Webhook Configuration Properties:**
832
843
 
833
- // Type-safe filters
834
- db.from("users")
835
- .list()
836
- .filter({ username: { eq: "john" } }); // ✓
837
- db.from("users")
838
- .list()
839
- .filter({ invalid: { eq: "john" } }); // TS Error
844
+ - `webhook` (required) - The URL to call when the webhook is triggered
845
+ - `tableName` (required) - The `FMTable` instance for the table to monitor
846
+ - `headers` (optional) - Custom headers to include in webhook requests
847
+ - `notifySchemaChanges` (optional) - Whether to notify on schema changes
848
+ - `select` (optional) - Field selection as a string or array of `Column` references
849
+ - `filter` (optional) - Filter expression (string or `FilterExpression`) to limit which records trigger the webhook
850
+
851
+ ### Listing Webhooks
852
+
853
+ Get all webhooks configured for the database:
854
+
855
+ ```typescript
856
+ const result = await db.webhook.list();
857
+
858
+ console.log(result.Status); // Status of the operation
859
+ console.log(result.WebHook); // Array of webhook configurations
860
+
861
+ result.WebHook.forEach((webhook) => {
862
+ console.log(`Webhook ${webhook.webHookID}:`);
863
+ console.log(` Table: ${webhook.tableName}`);
864
+ console.log(` URL: ${webhook.url}`);
865
+ console.log(` Notify Schema Changes: ${webhook.notifySchemaChanges}`);
866
+ console.log(` Select: ${webhook.select}`);
867
+ console.log(` Filter: ${webhook.filter}`);
868
+ console.log(` Pending Operations: ${webhook.pendingOperations.length}`);
869
+ });
840
870
  ```
841
871
 
842
- ### Required Fields
872
+ ### Getting a Webhook
843
873
 
844
- Control which fields are required for insert and update operations:
874
+ Retrieve a specific webhook by ID:
845
875
 
846
876
  ```typescript
847
- const usersBase = new BaseTable({
848
- schema: {
849
- id: z.string(),
850
- username: z.string(),
851
- email: z.string(),
852
- status: z.string(),
853
- updatedAt: z.string().optional(),
877
+ const webhook = await db.webhook.get(1);
878
+
879
+ console.log(webhook.webHookID);
880
+ console.log(webhook.tableName);
881
+ console.log(webhook.url);
882
+ console.log(webhook.headers);
883
+ console.log(webhook.notifySchemaChanges);
884
+ console.log(webhook.select);
885
+ console.log(webhook.filter);
886
+ console.log(webhook.pendingOperations);
887
+ ```
888
+
889
+ ### Removing a Webhook
890
+
891
+ Delete a webhook by ID:
892
+
893
+ ```typescript
894
+ await db.webhook.remove(1);
895
+ ```
896
+
897
+ ### Invoking a Webhook
898
+
899
+ Manually trigger a webhook. This is useful for testing or triggering webhooks on-demand:
900
+
901
+ ```typescript
902
+ // Invoke for all rows matching the webhook's filter
903
+ await db.webhook.invoke(1);
904
+
905
+ // Invoke for specific row IDs
906
+ await db.webhook.invoke(1, { rowIDs: [63, 61] });
907
+ ```
908
+
909
+ ### Complete Example
910
+
911
+ Here's a complete example of setting up and managing webhooks:
912
+
913
+ ```typescript
914
+ import { eq } from "@proofkit/fmodata";
915
+
916
+ // Add a webhook to monitor active contacts
917
+ const addResult = await db.webhook.add({
918
+ webhook: "https://api.example.com/webhooks/contacts",
919
+ tableName: contacts,
920
+ headers: {
921
+ "X-API-Key": "your-api-key",
854
922
  },
855
- idField: "id",
856
- insertRequired: ["username", "email"], // Required on insert
857
- updateRequired: ["status"], // Required on update
923
+ filter: eq(contacts.active, true),
924
+ select: [contacts.name, contacts.email, contacts.PrimaryKey],
925
+ notifySchemaChanges: false,
858
926
  });
859
927
 
860
- // Insert requires username and email
861
- db.from("users").insert({
928
+ const webhookId = addResult.webHookResult.webHookID;
929
+ console.log(`Created webhook with ID: ${webhookId}`);
930
+
931
+ // List all webhooks
932
+ const listResult = await db.webhook.list();
933
+ console.log(`Total webhooks: ${listResult.WebHook.length}`);
934
+
935
+ // Get the webhook we just created
936
+ const webhook = await db.webhook.get(webhookId);
937
+ console.log(`Webhook URL: ${webhook.url}`);
938
+
939
+ // Manually invoke the webhook for specific records
940
+ await db.webhook.invoke(webhookId, { rowIDs: [1, 2, 3] });
941
+
942
+ // Remove the webhook when done
943
+ await db.webhook.remove(webhookId);
944
+ ```
945
+
946
+ **Note:** Webhooks are triggered automatically by FileMaker when records matching the webhook's filter are created, updated, or deleted. The `invoke()` method allows you to manually trigger webhooks for testing or on-demand processing.
947
+
948
+ ## Batch Operations
949
+
950
+ 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.
951
+
952
+ ### Batch Result Structure
953
+
954
+ Batch operations return a `BatchResult` object that contains individual results for each operation. Each result has its own `data`, `error`, and `status` properties, allowing you to handle success and failure on a per-operation basis:
955
+
956
+ ```typescript
957
+ type BatchItemResult<T> = {
958
+ data: T | undefined;
959
+ error: FMODataErrorType | undefined;
960
+ status: number; // HTTP status code (0 for truncated operations)
961
+ };
962
+
963
+ type BatchResult<T extends readonly any[]> = {
964
+ results: { [K in keyof T]: BatchItemResult<T[K]> };
965
+ successCount: number;
966
+ errorCount: number;
967
+ truncated: boolean; // true if FileMaker stopped processing due to an error
968
+ firstErrorIndex: number | null; // Index of the first operation that failed
969
+ };
970
+ ```
971
+
972
+ ### Basic Batch with Multiple Queries
973
+
974
+ Execute multiple read operations in a single batch:
975
+
976
+ ```typescript
977
+ // Create query builders
978
+ const contactsQuery = db.from(contacts).list().top(5);
979
+ const usersQuery = db.from(users).list().top(5);
980
+
981
+ // Execute both queries in a single batch
982
+ const result = await db.batch([contactsQuery, usersQuery]).execute();
983
+
984
+ // Access individual results
985
+ const [r1, r2] = result.results;
986
+
987
+ if (r1.error) {
988
+ console.error("Contacts query failed:", r1.error);
989
+ } else {
990
+ console.log("Contacts:", r1.data);
991
+ }
992
+
993
+ if (r2.error) {
994
+ console.error("Users query failed:", r2.error);
995
+ } else {
996
+ console.log("Users:", r2.data);
997
+ }
998
+
999
+ // Check summary statistics
1000
+ console.log(`Success: ${result.successCount}, Errors: ${result.errorCount}`);
1001
+ ```
1002
+
1003
+ ### Mixed Operations (Reads and Writes)
1004
+
1005
+ Combine queries, inserts, updates, and deletes in a single batch:
1006
+
1007
+ ```typescript
1008
+ // Mix different operation types
1009
+ const listQuery = db.from(contacts).list().top(10);
1010
+ const insertOp = db.from(contacts).insert({
1011
+ name: "John Doe",
1012
+ email: "john@example.com",
1013
+ });
1014
+ const updateOp = db.from(users).update({ active: true }).byId("user-123");
1015
+
1016
+ // All operations execute atomically
1017
+ const result = await db.batch([listQuery, insertOp, updateOp]).execute();
1018
+
1019
+ // Access individual results
1020
+ const [r1, r2, r3] = result.results;
1021
+
1022
+ if (r1.error) {
1023
+ console.error("List query failed:", r1.error);
1024
+ } else {
1025
+ console.log("Fetched contacts:", r1.data);
1026
+ }
1027
+
1028
+ if (r2.error) {
1029
+ console.error("Insert failed:", r2.error);
1030
+ } else {
1031
+ console.log("Inserted contact:", r2.data);
1032
+ }
1033
+
1034
+ if (r3.error) {
1035
+ console.error("Update failed:", r3.error);
1036
+ } else {
1037
+ console.log("Updated user:", r3.data);
1038
+ }
1039
+ ```
1040
+
1041
+ ### Handling Errors in Batches
1042
+
1043
+ When FileMaker encounters an error in a batch operation, it **stops processing** subsequent operations. Operations that were never executed due to an earlier error will have a `BatchTruncatedError`:
1044
+
1045
+ ```typescript
1046
+ import { BatchTruncatedError, isBatchTruncatedError } from "@proofkit/fmodata";
1047
+
1048
+ const result = await db.batch([query1, query2, query3]).execute();
1049
+
1050
+ const [r1, r2, r3] = result.results;
1051
+
1052
+ // First operation succeeded
1053
+ if (r1.error) {
1054
+ console.error("First query failed:", r1.error);
1055
+ } else {
1056
+ console.log("First query succeeded:", r1.data);
1057
+ }
1058
+
1059
+ // Second operation failed
1060
+ if (r2.error) {
1061
+ console.error("Second query failed:", r2.error);
1062
+ console.log("HTTP Status:", r2.status); // e.g., 404
1063
+ }
1064
+
1065
+ // Third operation was never executed (truncated)
1066
+ if (r3.error && isBatchTruncatedError(r3.error)) {
1067
+ console.log("Third operation was not executed");
1068
+ console.log(`Failed at operation ${r3.error.failedAtIndex}`);
1069
+ console.log(`This operation index: ${r3.error.operationIndex}`);
1070
+ console.log("Status:", r3.status); // 0 (never executed)
1071
+ }
1072
+
1073
+ // Check if batch was truncated
1074
+ if (result.truncated) {
1075
+ console.log(`Batch stopped early at index ${result.firstErrorIndex}`);
1076
+ }
1077
+ ```
1078
+
1079
+ ### Transactional Behavior
1080
+
1081
+ Batch operations are transactional for write operations (inserts, updates, deletes). If any operation in the batch fails, all write operations are rolled back:
1082
+
1083
+ ```typescript
1084
+ const result = await db
1085
+ .batch([
1086
+ db.from(users).insert({ username: "alice", email: "alice@example.com" }),
1087
+ db.from(users).insert({ username: "bob", email: "bob@example.com" }),
1088
+ db.from(users).insert({ username: "charlie", email: "invalid" }), // This fails
1089
+ ])
1090
+ .execute();
1091
+
1092
+ // Check individual results
1093
+ const [r1, r2, r3] = result.results;
1094
+
1095
+ if (r1.error || r2.error || r3.error) {
1096
+ // All three inserts are rolled back - no users were created
1097
+ console.error("Batch had errors:");
1098
+ if (r1.error) console.error("Operation 1:", r1.error);
1099
+ if (r2.error) console.error("Operation 2:", r2.error);
1100
+ if (r3.error) console.error("Operation 3:", r3.error);
1101
+ }
1102
+ ```
1103
+
1104
+ ### Important Notes
1105
+
1106
+ - **FileMaker stops on first error**: When an error occurs, FileMaker stops processing subsequent operations in the batch. Truncated operations will have `BatchTruncatedError` with `status: 0`.
1107
+ - **Insert operations in batches**: FileMaker ignores `Prefer: return=representation` in batch requests. Insert operations return `{}` or `{ ROWID?: number }` instead of the full created record.
1108
+ - **All results are always defined**: Every operation in the batch will have a corresponding result in `result.results`, even if it was never executed (truncated operations).
1109
+ - **Summary statistics**: Use `result.successCount`, `result.errorCount`, `result.truncated`, and `result.firstErrorIndex` for quick batch status checks.
1110
+
1111
+ **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.
1112
+
1113
+ ## Schema Management
1114
+
1115
+ 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.
1116
+
1117
+ ### Creating Tables
1118
+
1119
+ Create a new table with field definitions:
1120
+
1121
+ ```typescript
1122
+ import type { Field } from "@proofkit/fmodata";
1123
+
1124
+ const fields: Field[] = [
1125
+ {
1126
+ name: "id",
1127
+ type: "string",
1128
+ primary: true,
1129
+ maxLength: 36,
1130
+ },
1131
+ {
1132
+ name: "username",
1133
+ type: "string",
1134
+ nullable: false,
1135
+ unique: true,
1136
+ maxLength: 50,
1137
+ },
1138
+ {
1139
+ name: "email",
1140
+ type: "string",
1141
+ nullable: false,
1142
+ maxLength: 255,
1143
+ },
1144
+ {
1145
+ name: "age",
1146
+ type: "numeric",
1147
+ nullable: true,
1148
+ },
1149
+ {
1150
+ name: "created_at",
1151
+ type: "timestamp",
1152
+ default: "CURRENT_TIMESTAMP",
1153
+ },
1154
+ ];
1155
+
1156
+ const tableDefinition = await db.schema.createTable("users", fields);
1157
+ console.log(tableDefinition.tableName); // "users"
1158
+ console.log(tableDefinition.fields); // Array of field definitions
1159
+ ```
1160
+
1161
+ ### Field Types
1162
+
1163
+ The library supports various field types:
1164
+
1165
+ **String Fields:**
1166
+
1167
+ ```typescript
1168
+ {
1169
+ name: "username",
1170
+ type: "string",
1171
+ maxLength: 100, // Optional: varchar(100)
1172
+ nullable: true,
1173
+ unique: true,
1174
+ default: "USER" | "USERNAME" | "CURRENT_USER", // Optional
1175
+ repetitions: 5, // Optional: for repeating fields
1176
+ }
1177
+ ```
1178
+
1179
+ **Numeric Fields:**
1180
+
1181
+ ```typescript
1182
+ {
1183
+ name: "age",
1184
+ type: "numeric",
1185
+ nullable: true,
1186
+ primary: false,
1187
+ unique: false,
1188
+ }
1189
+ ```
1190
+
1191
+ **Date Fields:**
1192
+
1193
+ ```typescript
1194
+ {
1195
+ name: "birth_date",
1196
+ type: "date",
1197
+ default: "CURRENT_DATE" | "CURDATE", // Optional
1198
+ nullable: true,
1199
+ }
1200
+ ```
1201
+
1202
+ **Time Fields:**
1203
+
1204
+ ```typescript
1205
+ {
1206
+ name: "start_time",
1207
+ type: "time",
1208
+ default: "CURRENT_TIME" | "CURTIME", // Optional
1209
+ nullable: true,
1210
+ }
1211
+ ```
1212
+
1213
+ **Timestamp Fields:**
1214
+
1215
+ ```typescript
1216
+ {
1217
+ name: "created_at",
1218
+ type: "timestamp",
1219
+ default: "CURRENT_TIMESTAMP" | "CURTIMESTAMP", // Optional
1220
+ nullable: false,
1221
+ }
1222
+ ```
1223
+
1224
+ **Container Fields:**
1225
+
1226
+ ```typescript
1227
+ {
1228
+ name: "avatar",
1229
+ type: "container",
1230
+ externalSecurePath: "/secure/path", // Optional
1231
+ nullable: true,
1232
+ }
1233
+ ```
1234
+
1235
+ ### Adding Fields to Existing Tables
1236
+
1237
+ Add new fields to an existing table:
1238
+
1239
+ ```typescript
1240
+ const newFields: Field[] = [
1241
+ {
1242
+ name: "phone",
1243
+ type: "string",
1244
+ nullable: true,
1245
+ maxLength: 20,
1246
+ },
1247
+ {
1248
+ name: "bio",
1249
+ type: "string",
1250
+ nullable: true,
1251
+ maxLength: 1000,
1252
+ },
1253
+ ];
1254
+
1255
+ const updatedTable = await db.schema.addFields("users", newFields);
1256
+ ```
1257
+
1258
+ ### Deleting Tables and Fields
1259
+
1260
+ Delete an entire table:
1261
+
1262
+ ```typescript
1263
+ await db.schema.deleteTable("old_table");
1264
+ ```
1265
+
1266
+ Delete a specific field from a table:
1267
+
1268
+ ```typescript
1269
+ await db.schema.deleteField("users", "old_field");
1270
+ ```
1271
+
1272
+ ### Managing Indexes
1273
+
1274
+ Create an index on a field:
1275
+
1276
+ ```typescript
1277
+ const index = await db.schema.createIndex("users", "email");
1278
+ console.log(index.indexName); // "email"
1279
+ ```
1280
+
1281
+ Delete an index:
1282
+
1283
+ ```typescript
1284
+ await db.schema.deleteIndex("users", "email");
1285
+ ```
1286
+
1287
+ ### Complete Example
1288
+
1289
+ Here's a complete example of creating a table with various field types:
1290
+
1291
+ ```typescript
1292
+ const fields: Field[] = [
1293
+ // Primary key
1294
+ {
1295
+ name: "id",
1296
+ type: "string",
1297
+ primary: true,
1298
+ maxLength: 36,
1299
+ },
1300
+
1301
+ // String fields
1302
+ {
1303
+ name: "username",
1304
+ type: "string",
1305
+ nullable: false,
1306
+ unique: true,
1307
+ maxLength: 50,
1308
+ },
1309
+ {
1310
+ name: "email",
1311
+ type: "string",
1312
+ nullable: false,
1313
+ maxLength: 255,
1314
+ },
1315
+
1316
+ // Numeric field
1317
+ {
1318
+ name: "age",
1319
+ type: "numeric",
1320
+ nullable: true,
1321
+ },
1322
+
1323
+ // Date/time fields
1324
+ {
1325
+ name: "birth_date",
1326
+ type: "date",
1327
+ nullable: true,
1328
+ },
1329
+ {
1330
+ name: "created_at",
1331
+ type: "timestamp",
1332
+ default: "CURRENT_TIMESTAMP",
1333
+ nullable: false,
1334
+ },
1335
+
1336
+ // Container field
1337
+ {
1338
+ name: "avatar",
1339
+ type: "container",
1340
+ nullable: true,
1341
+ },
1342
+
1343
+ // Repeating field
1344
+ {
1345
+ name: "tags",
1346
+ type: "string",
1347
+ repetitions: 5,
1348
+ maxLength: 50,
1349
+ },
1350
+ ];
1351
+
1352
+ // Create the table
1353
+ const table = await db.schema.createTable("users", fields);
1354
+
1355
+ // Later, add more fields
1356
+ await db.schema.addFields("users", [
1357
+ {
1358
+ name: "phone",
1359
+ type: "string",
1360
+ nullable: true,
1361
+ },
1362
+ ]);
1363
+
1364
+ // Create an index on email
1365
+ await db.schema.createIndex("users", "email");
1366
+ ```
1367
+
1368
+ **Note:** Schema management operations require appropriate access privileges on your FileMaker account. Operations will throw errors if you don't have the necessary permissions.
1369
+
1370
+ ## Advanced Features
1371
+
1372
+ ### Required and Read-Only Fields
1373
+
1374
+ The library automatically infers which fields are required based on field builder configuration:
1375
+
1376
+ ```typescript
1377
+ const users = fmTableOccurrence("users", {
1378
+ id: textField().primaryKey(), // Auto-required, auto-readOnly (primaryKey)
1379
+ username: textField().notNull(), // Auto-required (notNull)
1380
+ email: textField().notNull(), // Auto-required (notNull)
1381
+ status: textField(), // Optional (nullable by default)
1382
+ createdAt: timestampField().readOnly(), // Read-only system field
1383
+ updatedAt: timestampField(), // Optional (nullable)
1384
+ });
1385
+
1386
+ // Insert: username and email are required
1387
+ // Insert: id and createdAt are excluded (cannot be provided - read-only)
1388
+ db.from(users).insert({
862
1389
  username: "john",
863
1390
  email: "john@example.com",
864
- // updatedAt is optional
1391
+ status: "active", // Optional
1392
+ updatedAt: new Date().toISOString(), // Optional
865
1393
  });
866
1394
 
867
- // Update requires status
868
- db.from("users")
1395
+ // Update: all fields are optional except id and createdAt are excluded
1396
+ db.from(users)
869
1397
  .update({
870
- status: "active",
871
- // other fields are optional
1398
+ status: "active", // Optional
1399
+ // id and createdAt cannot be modified (read-only)
872
1400
  })
873
1401
  .byId("user-123");
874
1402
  ```
875
1403
 
1404
+ **Key Features:**
1405
+
1406
+ - **Auto-inference:** Fields with `.notNull()` are automatically required for insert
1407
+ - **Primary keys:** Fields with `.primaryKey()` are automatically read-only
1408
+ - **Read-only fields:** Use `.readOnly()` to exclude fields from insert/update (e.g., timestamps, calculated fields)
1409
+ - **Update flexibility:** All fields are optional for updates (except read-only fields)
1410
+
1411
+ ### Prefer: fmodata.entity-ids
1412
+
1413
+ 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.
1414
+
1415
+ To enable this feature, simply define your schema with entity IDs using the `.entityId()` method on field builders and the `entityId` option in `fmTableOccurrence()`. Behind the scenes, the library will transform your request and the response back to the names you specify in your schema. 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 field IDs via `.entityId()` and table ID via the `entityId` option).
1416
+
1417
+ _Note for OttoFMS proxy: This feature requires version 4.14 or later of OttoFMS_
1418
+
1419
+ 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
1420
+
1421
+ #### Basic Usage
1422
+
1423
+ ```typescript
1424
+ import {
1425
+ fmTableOccurrence,
1426
+ textField,
1427
+ timestampField,
1428
+ } from "@proofkit/fmodata";
1429
+
1430
+ // Define a table with FileMaker field IDs and table occurrence ID
1431
+ const users = fmTableOccurrence(
1432
+ "users",
1433
+ {
1434
+ id: textField().primaryKey().entityId("FMFID:12039485"),
1435
+ username: textField().notNull().entityId("FMFID:34323433"),
1436
+ email: textField().entityId("FMFID:12232424"),
1437
+ createdAt: timestampField().readOnly().entityId("FMFID:43234355"),
1438
+ },
1439
+ {
1440
+ entityId: "FMTID:12432533", // FileMaker table occurrence ID
1441
+ },
1442
+ );
1443
+ ```
1444
+
1445
+ ### Special Columns (ROWID and ROWMODID)
1446
+
1447
+ FileMaker provides special columns `ROWID` and `ROWMODID` that uniquely identify records and track modifications. These can be included in query responses when enabled.
1448
+
1449
+ Enable special columns at the database level:
1450
+
1451
+ ```typescript
1452
+ const db = connection.database("MyDatabase", {
1453
+ includeSpecialColumns: true,
1454
+ });
1455
+
1456
+ const result = await db.from(users).list().execute();
1457
+ // result.data[0] will have ROWID and ROWMODID properties
1458
+ ```
1459
+
1460
+ Override at the request level:
1461
+
1462
+ ```typescript
1463
+ // Enable for this request only
1464
+ const result = await db.from(users).list().execute({
1465
+ includeSpecialColumns: true,
1466
+ });
1467
+
1468
+ // Disable for this request
1469
+ const result = await db.from(users).list().execute({
1470
+ includeSpecialColumns: false,
1471
+ });
1472
+ ```
1473
+
1474
+ **Important:** Special columns are only included when no `$select` query is applied (per OData specification). When using `.select()`, special columns are excluded even if `includeSpecialColumns` is enabled.
1475
+
876
1476
  ### Error Handling
877
1477
 
878
- All operations return a `Result` type with either `data` or `error`:
1478
+ 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.
1479
+
1480
+ #### Basic Error Checking
879
1481
 
880
1482
  ```typescript
881
- const result = await db.from("users").list().execute();
1483
+ const result = await db.from(users).list().execute();
882
1484
 
883
1485
  if (result.error) {
884
1486
  console.error("Query failed:", result.error.message);
@@ -890,6 +1492,277 @@ if (result.data) {
890
1492
  }
891
1493
  ```
892
1494
 
1495
+ #### HTTP Errors
1496
+
1497
+ Handle HTTP status codes (4xx, 5xx) with the `HTTPError` class:
1498
+
1499
+ ```typescript
1500
+ import { HTTPError, isHTTPError } 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("HTTP Status:", result.error.status);
1508
+
1509
+ if (result.error.isNotFound()) {
1510
+ console.log("Resource not found");
1511
+ } else if (result.error.isUnauthorized()) {
1512
+ console.log("Authentication required");
1513
+ } else if (result.error.is5xx()) {
1514
+ console.log("Server error - try again later");
1515
+ } else if (result.error.is4xx()) {
1516
+ console.log("Client error:", result.error.statusText);
1517
+ }
1518
+
1519
+ // Access the response body if available
1520
+ if (result.error.response) {
1521
+ console.log("Error details:", result.error.response);
1522
+ }
1523
+ }
1524
+ }
1525
+ ```
1526
+
1527
+ #### Network Errors
1528
+
1529
+ Handle network-level errors (timeouts, connection issues, etc.):
1530
+
1531
+ ```typescript
1532
+ import {
1533
+ TimeoutError,
1534
+ NetworkError,
1535
+ RetryLimitError,
1536
+ CircuitOpenError,
1537
+ } from "@proofkit/fmodata";
1538
+
1539
+ const result = await db.from(users).list().execute();
1540
+
1541
+ if (result.error) {
1542
+ if (result.error instanceof TimeoutError) {
1543
+ console.log("Request timed out");
1544
+ // Show user-friendly timeout message
1545
+ } else if (result.error instanceof NetworkError) {
1546
+ console.log("Network connectivity issue");
1547
+ // Show offline message
1548
+ } else if (result.error instanceof RetryLimitError) {
1549
+ console.log("Request failed after retries");
1550
+ // Log the underlying error: result.error.cause
1551
+ } else if (result.error instanceof CircuitOpenError) {
1552
+ console.log("Service is currently unavailable");
1553
+ // Show maintenance message
1554
+ }
1555
+ }
1556
+ ```
1557
+
1558
+ #### Validation Errors
1559
+
1560
+ When schema validation fails, you get a `ValidationError` with rich context:
1561
+
1562
+ ```typescript
1563
+ import { ValidationError, isValidationError } from "@proofkit/fmodata";
1564
+
1565
+ const result = await db.from(users).list().execute();
1566
+
1567
+ if (result.error) {
1568
+ if (isValidationError(result.error)) {
1569
+ // Access validation issues (Standard Schema format)
1570
+ console.log("Validation failed for field:", result.error.field);
1571
+ console.log("Issues:", result.error.issues);
1572
+ console.log("Failed value:", result.error.value);
1573
+ }
1574
+ }
1575
+ ```
1576
+
1577
+ **Validator-Agnostic Error Handling**
1578
+
1579
+ 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:
1580
+
1581
+ ```typescript
1582
+ import { ValidationError } from "@proofkit/fmodata";
1583
+
1584
+ const result = await db.from(users).list().execute();
1585
+
1586
+ if (result.error instanceof ValidationError) {
1587
+ // The cause property (ES2022 Error.cause) contains the Standard Schema issues array
1588
+ // This is validator-agnostic and works with Zod, Valibot, ArkType, etc.
1589
+ console.log("Validation issues:", result.error.cause);
1590
+ console.log("Issues are also available directly:", result.error.issues);
1591
+
1592
+ // Both point to the same array
1593
+ console.log(result.error.cause === result.error.issues); // true
1594
+
1595
+ // Access additional context
1596
+ console.log("Failed field:", result.error.field);
1597
+ console.log("Failed value:", result.error.value);
1598
+
1599
+ // Standard Schema issues have a normalized format
1600
+ result.error.issues.forEach((issue) => {
1601
+ console.log("Path:", issue.path);
1602
+ console.log("Message:", issue.message);
1603
+ });
1604
+ }
1605
+ ```
1606
+
1607
+ **Why Standard Schema Issues Instead of Original Validator Errors?**
1608
+
1609
+ 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.
1610
+
1611
+ If you need validator-specific error formatting, you can still access your validator's methods during validation before the data reaches fmodata:
1612
+
1613
+ ```typescript
1614
+ import { z } from "zod";
1615
+
1616
+ const userSchema = z.object({
1617
+ email: z.string().email(),
1618
+ age: z.number().min(0).max(150),
1619
+ });
1620
+
1621
+ // Validate early if you need Zod-specific error handling
1622
+ const parseResult = userSchema.safeParse(userData);
1623
+ if (!parseResult.success) {
1624
+ // Use Zod's error formatting
1625
+ const formatted = parseResult.error.flatten();
1626
+ console.log("Zod-specific formatting:", formatted);
1627
+ }
1628
+ ```
1629
+
1630
+ #### OData Errors
1631
+
1632
+ Handle OData-specific protocol errors:
1633
+
1634
+ ```typescript
1635
+ import { ODataError, isODataError } from "@proofkit/fmodata";
1636
+
1637
+ const result = await db.from(users).list().execute();
1638
+
1639
+ if (result.error) {
1640
+ if (isODataError(result.error)) {
1641
+ console.log("OData Error Code:", result.error.code);
1642
+ console.log("OData Error Message:", result.error.message);
1643
+ console.log("OData Error Details:", result.error.details);
1644
+ }
1645
+ }
1646
+ ```
1647
+
1648
+ #### Error Handling Patterns
1649
+
1650
+ **Pattern 1: Using instanceof (like ffetch):**
1651
+
1652
+ ```typescript
1653
+ import {
1654
+ HTTPError,
1655
+ ValidationError,
1656
+ TimeoutError,
1657
+ NetworkError,
1658
+ } from "@proofkit/fmodata";
1659
+
1660
+ const result = await db.from(users).list().execute();
1661
+
1662
+ if (result.error) {
1663
+ if (result.error instanceof TimeoutError) {
1664
+ showTimeoutMessage();
1665
+ } else if (result.error instanceof HTTPError) {
1666
+ if (result.error.isNotFound()) {
1667
+ showNotFoundMessage();
1668
+ } else if (result.error.is5xx()) {
1669
+ showServerErrorMessage();
1670
+ }
1671
+ } else if (result.error instanceof ValidationError) {
1672
+ showValidationError(result.error.field, result.error.issues);
1673
+ } else if (result.error instanceof NetworkError) {
1674
+ showOfflineMessage();
1675
+ }
1676
+ }
1677
+ ```
1678
+
1679
+ **Pattern 2: Using kind property (for exhaustive matching):**
1680
+
1681
+ ```typescript
1682
+ const result = await db.from(users).list().execute();
1683
+
1684
+ if (result.error) {
1685
+ switch (result.error.kind) {
1686
+ case "TimeoutError":
1687
+ showTimeoutMessage();
1688
+ break;
1689
+ case "HTTPError":
1690
+ handleHTTPError(result.error.status);
1691
+ break;
1692
+ case "ValidationError":
1693
+ showValidationError(result.error.field, result.error.issues);
1694
+ break;
1695
+ case "NetworkError":
1696
+ showOfflineMessage();
1697
+ break;
1698
+ case "ODataError":
1699
+ handleODataError(result.error.code);
1700
+ break;
1701
+ // TypeScript ensures exhaustive matching!
1702
+ }
1703
+ }
1704
+ ```
1705
+
1706
+ **Pattern 3: Using type guards:**
1707
+
1708
+ ```typescript
1709
+ import {
1710
+ isHTTPError,
1711
+ isValidationError,
1712
+ isODataError,
1713
+ isNetworkError,
1714
+ } from "@proofkit/fmodata";
1715
+
1716
+ const result = await db.from("users").list().execute();
1717
+
1718
+ if (result.error) {
1719
+ if (isHTTPError(result.error)) {
1720
+ // TypeScript knows this is HTTPError
1721
+ console.log("Status:", result.error.status);
1722
+ } else if (isValidationError(result.error)) {
1723
+ // TypeScript knows this is ValidationError
1724
+ console.log("Field:", result.error.field);
1725
+ console.log("Issues:", result.error.issues);
1726
+ } else if (isODataError(result.error)) {
1727
+ // TypeScript knows this is ODataError
1728
+ console.log("Code:", result.error.code);
1729
+ } else if (isNetworkError(result.error)) {
1730
+ // TypeScript knows this is NetworkError
1731
+ console.log("Network issue:", result.error.cause);
1732
+ }
1733
+ }
1734
+ ```
1735
+
1736
+ #### Error Properties
1737
+
1738
+ All errors include helpful metadata:
1739
+
1740
+ ```typescript
1741
+ if (result.error) {
1742
+ // All errors have a timestamp
1743
+ console.log("Error occurred at:", result.error.timestamp);
1744
+
1745
+ // All errors have a kind property for discriminated unions
1746
+ console.log("Error kind:", result.error.kind);
1747
+
1748
+ // All errors have a message
1749
+ console.log("Error message:", result.error.message);
1750
+ }
1751
+ ```
1752
+
1753
+ #### Available Error Types
1754
+
1755
+ - **`HTTPError`** - HTTP status errors (4xx, 5xx) with helper methods (`is4xx()`, `is5xx()`, `isNotFound()`, etc.)
1756
+ - **`ODataError`** - OData protocol errors with code and details
1757
+ - **`ValidationError`** - Schema validation failures with issues, schema reference, and failed value
1758
+ - **`ResponseStructureError`** - Malformed API responses
1759
+ - **`RecordCountMismatchError`** - When `single()` or `maybeSingle()` expectations aren't met
1760
+ - **`TimeoutError`** - Request timeout (from ffetch)
1761
+ - **`NetworkError`** - Network connectivity issues (from ffetch)
1762
+ - **`RetryLimitError`** - Request failed after retries (from ffetch)
1763
+ - **`CircuitOpenError`** - Circuit breaker is open (from ffetch)
1764
+ - **`AbortError`** - Request was aborted (from ffetch)
1765
+
893
1766
  ### OData Annotations and Validation
894
1767
 
895
1768
  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`:
@@ -962,7 +1835,7 @@ const queryString = db
962
1835
  .from("users")
963
1836
  .list()
964
1837
  .select("username", "email")
965
- .filter({ active: true })
1838
+ .where(eq(users.active, true))
966
1839
  .orderBy("username")
967
1840
  .top(10)
968
1841
  .getQueryString();