@proofkit/fmodata 0.1.0-alpha.9 → 0.1.0-beta.23

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 (163) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +651 -449
  3. package/dist/esm/client/batch-builder.d.ts +10 -9
  4. package/dist/esm/client/batch-builder.js +119 -56
  5. package/dist/esm/client/batch-builder.js.map +1 -1
  6. package/dist/esm/client/batch-request.js +16 -21
  7. package/dist/esm/client/batch-request.js.map +1 -1
  8. package/dist/esm/client/builders/default-select.d.ts +10 -0
  9. package/dist/esm/client/builders/default-select.js +41 -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 +185 -0
  13. package/dist/esm/client/builders/expand-builder.js.map +1 -0
  14. package/dist/esm/client/builders/index.d.ts +9 -0
  15. package/dist/esm/client/builders/query-string-builder.d.ts +18 -0
  16. package/dist/esm/client/builders/query-string-builder.js +21 -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 +175 -0
  20. package/dist/esm/client/builders/response-processor.js.map +1 -0
  21. package/dist/esm/client/builders/select-mixin.d.ts +25 -0
  22. package/dist/esm/client/builders/select-mixin.js +28 -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 +30 -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 +44 -0
  30. package/dist/esm/client/builders/table-utils.js.map +1 -0
  31. package/dist/esm/client/database.d.ts +34 -22
  32. package/dist/esm/client/database.js +48 -84
  33. package/dist/esm/client/database.js.map +1 -1
  34. package/dist/esm/client/delete-builder.d.ts +25 -30
  35. package/dist/esm/client/delete-builder.js +45 -30
  36. package/dist/esm/client/delete-builder.js.map +1 -1
  37. package/dist/esm/client/entity-set.d.ts +35 -43
  38. package/dist/esm/client/entity-set.js +110 -52
  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 +25 -0
  42. package/dist/esm/client/error-parser.js.map +1 -0
  43. package/dist/esm/client/filemaker-odata.d.ts +26 -7
  44. package/dist/esm/client/filemaker-odata.js +65 -42
  45. package/dist/esm/client/filemaker-odata.js.map +1 -1
  46. package/dist/esm/client/insert-builder.d.ts +19 -24
  47. package/dist/esm/client/insert-builder.js +94 -58
  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 +4 -0
  51. package/dist/esm/client/query/query-builder.d.ts +132 -0
  52. package/dist/esm/client/query/query-builder.js +456 -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 +100 -0
  58. package/dist/esm/client/query/url-builder.js.map +1 -0
  59. package/dist/esm/client/query-builder.d.ts +2 -115
  60. package/dist/esm/client/record-builder.d.ts +108 -36
  61. package/dist/esm/client/record-builder.js +284 -119
  62. package/dist/esm/client/record-builder.js.map +1 -1
  63. package/dist/esm/client/response-processor.d.ts +4 -9
  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 +5 -5
  68. package/dist/esm/client/schema-manager.js +45 -31
  69. package/dist/esm/client/schema-manager.js.map +1 -1
  70. package/dist/esm/client/update-builder.d.ts +34 -40
  71. package/dist/esm/client/update-builder.js +99 -58
  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 +189 -0
  75. package/dist/esm/client/webhook-builder.js.map +1 -0
  76. package/dist/esm/errors.d.ts +19 -2
  77. package/dist/esm/errors.js +39 -4
  78. package/dist/esm/errors.js.map +1 -1
  79. package/dist/esm/index.d.ts +10 -8
  80. package/dist/esm/index.js +40 -10
  81. package/dist/esm/index.js.map +1 -1
  82. package/dist/esm/logger.d.ts +47 -0
  83. package/dist/esm/logger.js +69 -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 +63 -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 +158 -0
  91. package/dist/esm/orm/field-builders.js.map +1 -0
  92. package/dist/esm/orm/index.d.ts +5 -0
  93. package/dist/esm/orm/operators.d.ts +173 -0
  94. package/dist/esm/orm/operators.js +260 -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 +202 -0
  98. package/dist/esm/orm/table.js.map +1 -0
  99. package/dist/esm/transform.d.ts +20 -21
  100. package/dist/esm/transform.js +44 -45
  101. package/dist/esm/transform.js.map +1 -1
  102. package/dist/esm/types.d.ts +96 -30
  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 -12
  106. package/dist/esm/validation.js +132 -85
  107. package/dist/esm/validation.js.map +1 -1
  108. package/package.json +28 -20
  109. package/src/client/batch-builder.ts +153 -89
  110. package/src/client/batch-request.ts +25 -41
  111. package/src/client/builders/default-select.ts +75 -0
  112. package/src/client/builders/expand-builder.ts +246 -0
  113. package/src/client/builders/index.ts +11 -0
  114. package/src/client/builders/query-string-builder.ts +46 -0
  115. package/src/client/builders/response-processor.ts +279 -0
  116. package/src/client/builders/select-mixin.ts +65 -0
  117. package/src/client/builders/select-utils.ts +59 -0
  118. package/src/client/builders/shared-types.ts +45 -0
  119. package/src/client/builders/table-utils.ts +83 -0
  120. package/src/client/database.ts +89 -183
  121. package/src/client/delete-builder.ts +74 -84
  122. package/src/client/entity-set.ts +266 -293
  123. package/src/client/error-parser.ts +41 -0
  124. package/src/client/filemaker-odata.ts +98 -66
  125. package/src/client/insert-builder.ts +157 -118
  126. package/src/client/query/expand-builder.ts +160 -0
  127. package/src/client/query/index.ts +14 -0
  128. package/src/client/query/query-builder.ts +729 -0
  129. package/src/client/query/response-processor.ts +226 -0
  130. package/src/client/query/types.ts +126 -0
  131. package/src/client/query/url-builder.ts +151 -0
  132. package/src/client/query-builder.ts +10 -1455
  133. package/src/client/record-builder.ts +575 -240
  134. package/src/client/response-processor.ts +15 -42
  135. package/src/client/sanitize-json.ts +64 -0
  136. package/src/client/schema-manager.ts +61 -76
  137. package/src/client/update-builder.ts +161 -143
  138. package/src/client/webhook-builder.ts +265 -0
  139. package/src/errors.ts +49 -16
  140. package/src/index.ts +99 -54
  141. package/src/logger.test.ts +34 -0
  142. package/src/logger.ts +116 -0
  143. package/src/orm/column.ts +106 -0
  144. package/src/orm/field-builders.ts +250 -0
  145. package/src/orm/index.ts +61 -0
  146. package/src/orm/operators.ts +473 -0
  147. package/src/orm/table.ts +741 -0
  148. package/src/transform.ts +90 -70
  149. package/src/types.ts +154 -113
  150. package/src/validation.ts +200 -115
  151. package/dist/esm/client/base-table.d.ts +0 -125
  152. package/dist/esm/client/base-table.js +0 -57
  153. package/dist/esm/client/base-table.js.map +0 -1
  154. package/dist/esm/client/query-builder.js +0 -896
  155. package/dist/esm/client/query-builder.js.map +0 -1
  156. package/dist/esm/client/table-occurrence.d.ts +0 -72
  157. package/dist/esm/client/table-occurrence.js +0 -74
  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 -175
  161. package/src/client/query-builder.ts.bak +0 -1457
  162. package/src/client/table-occurrence.ts +0 -175
  163. package/src/filter-types.ts +0 -97
package/README.md CHANGED
@@ -26,8 +26,10 @@ Here's a minimal example to get you started:
26
26
  ```typescript
27
27
  import {
28
28
  FMServerConnection,
29
- defineBaseTable,
30
- defineTableOccurrence,
29
+ fmTableOccurrence,
30
+ textField,
31
+ numberField,
32
+ eq,
31
33
  } from "@proofkit/fmodata";
32
34
  import { z } from "zod/v4";
33
35
 
@@ -44,30 +46,21 @@ const connection = new FMServerConnection({
44
46
  },
45
47
  });
46
48
 
47
- // 2. Define your table schema
48
- const usersBase = defineBaseTable({
49
- schema: {
50
- id: z.string(),
51
- username: z.string(),
52
- email: z.string(),
53
- active: z.boolean(),
54
- },
55
- idField: "id",
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))),
56
57
  });
57
58
 
58
- // 3. Create a table occurrence
59
- const usersTO = defineTableOccurrence({
60
- name: "users",
61
- baseTable: usersBase,
62
- });
59
+ // 3. Create a database instance
60
+ const db = connection.database("MyDatabase.fmp12");
63
61
 
64
- // 4. Create a database instance
65
- const db = connection.database("MyDatabase.fmp12", {
66
- occurrences: [usersTO],
67
- });
68
-
69
- // 5. Query your data
70
- const { data, error } = await db.from("users").list().execute();
62
+ // 4. Query your data
63
+ const { data, error } = await db.from(users).list().execute();
71
64
 
72
65
  if (error) {
73
66
  console.error(error);
@@ -86,8 +79,7 @@ This library relies heavily on the builder pattern for defining your queries and
86
79
  As such, there are layers to the library to help you build your queries and operations.
87
80
 
88
81
  - `FMServerConnection` - hold server connection details and authentication
89
- - `BaseTable` - defines the fields and validators for a base table
90
- - `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
91
83
  - `Database` - connects the table occurrences to the server connection
92
84
 
93
85
  ### FileMaker Server prerequisites
@@ -100,7 +92,7 @@ To use this library you need:
100
92
 
101
93
  A note on best practices:
102
94
 
103
- 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.
104
96
 
105
97
  ### Server Connection
106
98
 
@@ -127,93 +119,118 @@ const connection = new FMServerConnection({
127
119
 
128
120
  ### Schema Definitions
129
121
 
130
- This library relies on a schema-first approach for good type-safety and optional runtime validation. These are abstracted into BaseTable and TableOccurrence classes to match FileMaker concepts.
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.
131
123
 
132
- **Helper Functions vs Constructors:**
124
+ #### Field Builders
133
125
 
134
- - **`defineBaseTable()`** and **`defineTableOccurrence()`** - Recommended for better type inference, especially when using entity IDs (FMFID/FMTID). These functions provide improved TypeScript type inference for field names in queries.
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
135
127
 
136
- - **`new BaseTable()`** and **`new TableOccurrence()`** - Still supported for backward compatibility, but may have slightly less precise type inference in some cases.
128
+ - `textField()`
129
+ - `numberField()`
130
+ - `dateField()`
131
+ - `timeField()`
132
+ - `timestampField()`
133
+ - `containerField()`
134
+ - `calcField()`
137
135
 
138
- A `BaseTable` defines the schema for your FileMaker table using Standard Schema. These examples show zod, but you can use any other validation library that supports Standard Schema.
136
+ Each field builder supports chainable methods:
139
137
 
140
- ```typescript
141
- import { z } from "zod/v4";
142
- import { defineBaseTable } from "@proofkit/fmodata";
143
-
144
- const contactsBase = defineBaseTable({
145
- schema: {
146
- id: z.string(),
147
- name: z.string(),
148
- email: z.string(),
149
- phone: z.string().optional(),
150
- createdAt: z.string(),
151
- },
152
- idField: "id", // The primary key field (automatically read-only)
153
- required: ["phone"], // optional: additional required fields for insert (beyond auto-inferred)
154
- readOnly: ["createdAt"], // optional: fields excluded from insert/update
155
- });
156
- ```
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
157
144
 
158
- A `TableOccurrence` is the actual entry point for the OData service on the FileMaker server. It's where you can define the relations between tables and also allows you to reference the same base table multiple times with different names.
145
+ #### Defining Tables
159
146
 
160
- **Recommended:** Use `defineTableOccurrence()` for better type inference. You can also use `new TableOccurrence()` directly.
147
+ Use `fmTableOccurrence()` to define a table with field builders:
161
148
 
162
149
  ```typescript
163
- import { defineTableOccurrence } from "@proofkit/fmodata";
150
+ import { z } from "zod/v4";
151
+ import {
152
+ fmTableOccurrence,
153
+ textField,
154
+ numberField,
155
+ timestampField,
156
+ } from "@proofkit/fmodata";
164
157
 
165
- const contactsTO = defineTableOccurrence({
166
- name: "contacts", // The table occurrence name in FileMaker
167
- baseTable: contactsBase,
168
- });
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
+ );
169
173
  ```
170
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
+
171
181
  #### Default Field Selection
172
182
 
173
- 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:
174
184
 
175
185
  ```typescript
176
- // Option 1 (default): "schema" - Select all fields from the schema (same as "all" but more explicit)
177
- const usersTO = defineTableOccurrence({
178
- name: "users",
179
- baseTable: usersBase,
180
- defaultSelect: "schema", // a $select parameter will be always be added to the query for only the fields you've defined in the BaseTable schema
181
- });
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
+ );
182
196
 
183
- // Option 2: "all" - Select all fields (default behavior)
184
- const usersTO = defineTableOccurrence({
185
- name: "users",
186
- baseTable: usersBase,
187
- defaultSelect: "all", // Don't always a $select parameter to the query; FileMaker will return all non-container fields from the table
188
- });
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
+ );
189
207
 
190
- // Option 3: Array of field names - Select only specific fields by default
191
- const usersTO = defineTableOccurrence({
192
- name: "users",
193
- baseTable: usersBase,
194
- defaultSelect: ["username", "email"], // Only select these fields by default
195
- });
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
+ );
196
221
 
197
- // When you call list(), the defaultSelect is applied automatically
198
- const result = await db.from("users").list().execute();
199
- // 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
200
225
 
201
226
  // You can still override with explicit select()
202
227
  const result = await db
203
- .from("users")
228
+ .from(users)
204
229
  .list()
205
- .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
206
231
  .execute();
207
232
  ```
208
233
 
209
- Lastly, you can combine all table occurrences into a database instance for the full type-safe experience. This is a method on the main `FMServerConnection` client class.
210
-
211
- ```typescript
212
- const db = connection.database("MyDatabase.fmp12", {
213
- occurrences: [contactsTO, usersTO], // Register your table occurrences
214
- });
215
- ```
216
-
217
234
  ## Querying Data
218
235
 
219
236
  ### Basic Queries
@@ -245,9 +262,9 @@ Get a single field value:
245
262
 
246
263
  ```typescript
247
264
  const result = await db
248
- .from("users")
265
+ .from(users)
249
266
  .get("user-123")
250
- .getSingleField("email")
267
+ .getSingleField(users.email)
251
268
  .execute();
252
269
 
253
270
  if (result.data) {
@@ -257,173 +274,103 @@ if (result.data) {
257
274
 
258
275
  ### Filtering
259
276
 
260
- 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.
261
-
262
- #### Operator Syntax
263
-
264
- You can use filters in three ways:
265
-
266
- **1. Shorthand (direct value):**
267
-
268
- ```typescript
269
- .filter({ name: "John" })
270
- // Equivalent to: { name: [{ eq: "John" }] }
271
- ```
272
-
273
- **2. Single operator object:**
274
-
275
- ```typescript
276
- .filter({ age: { gt: 18 } })
277
- ```
278
-
279
- **3. Array of operators (for multiple operators on same field):**
280
-
281
- ```typescript
282
- .filter({ age: [{ gt: 18 }, { lt: 65 }] })
283
- // Result: age gt 18 and age lt 65
284
- ```
285
-
286
- The array pattern prevents duplicate operators on the same field and allows multiple conditions with implicit AND.
287
-
288
- #### Available Operators
289
-
290
- **String fields:**
291
-
292
- - `eq`, `ne` - equality/inequality
293
- - `contains`, `startswith`, `endswith` - string functions
294
- - `gt`, `ge`, `lt`, `le` - comparison
295
- - `in` - match any value in array
296
-
297
- **Number fields:**
298
-
299
- - `eq`, `ne`, `gt`, `ge`, `lt`, `le` - comparisons
300
- - `in` - match any value in array
301
-
302
- **Boolean fields:**
303
-
304
- - `eq`, `ne` - equality only
305
-
306
- **Date fields:**
307
-
308
- - `eq`, `ne`, `gt`, `ge`, `lt`, `le` - date comparisons
309
- - `in` - match any date in array
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.
310
278
 
311
- #### Shorthand Syntax
279
+ #### New ORM-Style API (Recommended)
312
280
 
313
- For simple equality checks, use the shorthand:
281
+ Use the `where()` method with filter operators and column references for type-safe filtering:
314
282
 
315
283
  ```typescript
316
- const result = await db.from("users").list().filter({ name: "John" }).execute();
317
- // Equivalent to: { name: [{ eq: "John" }] }
318
- ```
319
-
320
- #### Examples
321
-
322
- ```typescript
323
- // Equality filter (single operator)
324
- const activeUsers = await db
325
- .from("users")
326
- .list()
327
- .filter({ active: { eq: true } })
328
- .execute();
284
+ import { eq, gt, and, or, contains } from "@proofkit/fmodata";
329
285
 
330
- // Comparison operators (single operator)
331
- const adultUsers = await db
332
- .from("users")
333
- .list()
334
- .filter({ age: { gt: 18 } })
335
- .execute();
336
-
337
- // String operators (single operator)
338
- const johns = await db
339
- .from("users")
340
- .list()
341
- .filter({ name: { contains: "John" } })
342
- .execute();
343
-
344
- // Multiple operators on same field (array syntax, implicit AND)
345
- const rangeQuery = await db
346
- .from("users")
347
- .list()
348
- .filter({ age: [{ gt: 18 }, { lt: 65 }] })
349
- .execute();
350
-
351
- // Combine filters with AND
286
+ // Simple equality
352
287
  const result = await db
353
- .from("users")
288
+ .from(users)
354
289
  .list()
355
- .filter({
356
- and: [{ active: [{ eq: true }] }, { age: [{ gt: 18 }] }],
357
- })
290
+ .where(eq(users.active, true))
358
291
  .execute();
359
292
 
360
- // 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
361
297
  const result = await db
362
- .from("users")
298
+ .from(users)
363
299
  .list()
364
- .filter({
365
- or: [{ name: [{ eq: "John" }] }, { name: [{ eq: "Jane" }] }],
366
- })
300
+ .where(contains(users.name, "John"))
367
301
  .execute();
368
302
 
369
- // IN operator
303
+ // Combine with AND
370
304
  const result = await db
371
- .from("users")
305
+ .from(users)
372
306
  .list()
373
- .filter({ age: [{ in: [18, 21, 25] }] })
307
+ .where(and(eq(users.active, true), gt(users.age, 18)))
374
308
  .execute();
375
309
 
376
- // Null checks
310
+ // Combine with OR
377
311
  const result = await db
378
- .from("users")
312
+ .from(users)
379
313
  .list()
380
- .filter({ deletedAt: [{ eq: null }] })
314
+ .where(or(eq(users.role, "admin"), eq(users.role, "moderator")))
381
315
  .execute();
382
316
  ```
383
317
 
384
- #### 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).
385
329
 
386
- Combine multiple conditions with `and`, `or`, `not`:
330
+ #### Using Column References (New ORM API)
387
331
 
388
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)
389
343
  const result = await db
390
- .from("users")
344
+ .from(users)
391
345
  .list()
392
- .filter({
393
- and: [{ name: [{ contains: "John" }] }, { age: [{ gt: 18 }] }],
394
- })
346
+ .orderBy(asc(users.lastName), desc(users.firstName))
395
347
  .execute();
396
- ```
397
-
398
- #### Escape Hatch
399
-
400
- For unsupported edge cases, pass a raw OData filter string:
401
348
 
402
- ```typescript
349
+ // Multiple fields (array syntax)
403
350
  const result = await db
404
- .from("users")
351
+ .from(users)
405
352
  .list()
406
- .filter("substringof('John', name)")
353
+ .orderBy([
354
+ [users.lastName, "asc"],
355
+ [users.firstName, "desc"],
356
+ ])
407
357
  .execute();
408
358
  ```
409
359
 
410
- ### Sorting
360
+ #### Type Safety
411
361
 
412
- Sort results using `orderBy()`:
362
+ For typed databases, `orderBy()` provides full type safety:
413
363
 
414
364
  ```typescript
415
- // Sort ascending
416
- 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);
417
367
 
418
- // Sort descending
419
- 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));
420
371
 
421
- // Multiple sort fields
422
- const result = await db
423
- .from("users")
424
- .list()
425
- .orderBy("lastName, firstName desc")
426
- .execute();
372
+ // Valid - multiple fields
373
+ db.from(users).list().orderBy(asc(users.lastName), desc(users.firstName));
427
374
  ```
428
375
 
429
376
  ### Pagination
@@ -432,24 +379,29 @@ Control the number of records returned and pagination:
432
379
 
433
380
  ```typescript
434
381
  // Limit results
435
- const result = await db.from("users").list().top(10).execute();
382
+ const result = await db.from(users).list().top(10).execute();
436
383
 
437
384
  // Skip records (pagination)
438
- 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();
439
386
 
440
387
  // Count total records
441
- const result = await db.from("users").list().count().execute();
388
+ const result = await db.from(users).list().count().execute();
442
389
  ```
443
390
 
444
391
  ### Selecting Fields
445
392
 
446
- 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):
447
394
 
448
395
  ```typescript
396
+ // New ORM API: Using column references (type-safe, supports renaming)
449
397
  const result = await db
450
- .from("users")
398
+ .from(users)
451
399
  .list()
452
- .select("username", "email")
400
+ .select({
401
+ username: users.username,
402
+ email: users.email,
403
+ userId: users.id, // Renamed from "id" to "userId"
404
+ })
453
405
  .execute();
454
406
 
455
407
  // result.data[0] will only have username and email fields
@@ -461,9 +413,9 @@ Use `single()` to ensure exactly one record is returned (returns an error if zer
461
413
 
462
414
  ```typescript
463
415
  const result = await db
464
- .from("users")
416
+ .from(users)
465
417
  .list()
466
- .filter({ email: { eq: "user@example.com" } })
418
+ .where(eq(users.email, "user@example.com"))
467
419
  .single()
468
420
  .execute();
469
421
 
@@ -477,9 +429,9 @@ Use `maybeSingle()` when you want at most one record (returns `null` if no recor
477
429
 
478
430
  ```typescript
479
431
  const result = await db
480
- .from("users")
432
+ .from(users)
481
433
  .list()
482
- .filter({ email: { eq: "user@example.com" } })
434
+ .where(eq(users.email, "user@example.com"))
483
435
  .maybeSingle()
484
436
  .execute();
485
437
 
@@ -502,12 +454,17 @@ if (result.data) {
502
454
  All query methods can be chained together:
503
455
 
504
456
  ```typescript
457
+ // Using new ORM API
505
458
  const result = await db
506
- .from("users")
459
+ .from(users)
507
460
  .list()
508
- .select("username", "email", "age")
509
- .filter({ age: { gt: 18 } })
510
- .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))
511
468
  .top(10)
512
469
  .skip(0)
513
470
  .execute();
@@ -522,7 +479,7 @@ Insert new records with type-safe data:
522
479
  ```typescript
523
480
  // Insert a new user
524
481
  const result = await db
525
- .from("users")
482
+ .from(users)
526
483
  .insert({
527
484
  username: "johndoe",
528
485
  email: "john@example.com",
@@ -535,30 +492,25 @@ if (result.data) {
535
492
  }
536
493
  ```
537
494
 
538
- Fields are automatically required for insert if their validator doesn't allow `null` or `undefined`. You can specify additional required fields:
495
+ Fields are automatically required for insert if they use `.notNull()`. Read-only fields (including primary keys) are automatically excluded:
539
496
 
540
497
  ```typescript
541
- const usersBase = defineBaseTable({
542
- schema: {
543
- id: z.string(), // Auto-required (not nullable), but excluded from insert (idField)
544
- username: z.string(), // Auto-required (not nullable)
545
- email: z.string(), // Auto-required (not nullable)
546
- phone: z.string().nullable(), // Optional by default
547
- createdAt: z.string(), // Auto-required, but excluded (readOnly)
548
- },
549
- idField: "id", // Automatically excluded from insert/update
550
- required: ["phone"], // Make phone required for inserts despite being nullable
551
- readOnly: ["createdAt"], // Exclude from insert/update operations
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
552
504
  });
553
505
 
554
- // TypeScript enforces: username, email, and phone are required
506
+ // TypeScript enforces: username and email are required
555
507
  // TypeScript excludes: id and createdAt cannot be provided
556
508
  const result = await db
557
- .from("users")
509
+ .from(users)
558
510
  .insert({
559
511
  username: "johndoe",
560
512
  email: "john@example.com",
561
- phone: "+1234567890", // Required because specified in 'required' array
513
+ phone: "+1234567890", // Optional
562
514
  })
563
515
  .execute();
564
516
  ```
@@ -570,7 +522,7 @@ Update records by ID or filter:
570
522
  ```typescript
571
523
  // Update by ID
572
524
  const result = await db
573
- .from("users")
525
+ .from(users)
574
526
  .update({ username: "newname" })
575
527
  .byId("user-123")
576
528
  .execute();
@@ -579,29 +531,27 @@ if (result.data) {
579
531
  console.log(`Updated ${result.data.updatedCount} record(s)`);
580
532
  }
581
533
 
582
- // Update by filter
534
+ // Update by filter (using new ORM API)
535
+ import { lt, and, eq } from "@proofkit/fmodata";
536
+
583
537
  const result = await db
584
- .from("users")
538
+ .from(users)
585
539
  .update({ active: false })
586
- .where((q) => q.filter({ lastLogin: { lt: "2023-01-01" } }))
540
+ .where(lt(users.lastLogin, "2023-01-01"))
587
541
  .execute();
588
542
 
589
543
  // Complex filter example
590
544
  const result = await db
591
- .from("users")
545
+ .from(users)
592
546
  .update({ active: false })
593
- .where((q) =>
594
- q.filter({
595
- and: [{ active: true }, { count: { lt: 5 } }],
596
- }),
597
- )
547
+ .where(and(eq(users.active, true), lt(users.count, 5)))
598
548
  .execute();
599
549
 
600
- // Update with additional query options
550
+ // Update with additional query options (legacy filter API)
601
551
  const result = await db
602
552
  .from("users")
603
553
  .update({ active: false })
604
- .where((q) => q.filter({ active: true }).top(10))
554
+ .where((q) => q.where(eq(users.active, true)).top(10))
605
555
  .execute();
606
556
  ```
607
557
 
@@ -611,28 +561,26 @@ Delete records by ID or filter:
611
561
 
612
562
  ```typescript
613
563
  // Delete by ID
614
- const result = await db.from("users").delete().byId("user-123").execute();
564
+ const result = await db.from(users).delete().byId("user-123").execute();
615
565
 
616
566
  if (result.data) {
617
567
  console.log(`Deleted ${result.data.deletedCount} record(s)`);
618
568
  }
619
569
 
620
- // Delete by filter
570
+ // Delete by filter (using new ORM API)
571
+ import { eq, and, lt } from "@proofkit/fmodata";
572
+
621
573
  const result = await db
622
- .from("users")
574
+ .from(users)
623
575
  .delete()
624
- .where((q) => q.filter({ active: false }))
576
+ .where(eq(users.active, false))
625
577
  .execute();
626
578
 
627
579
  // Delete with complex filters
628
580
  const result = await db
629
- .from("users")
581
+ .from(users)
630
582
  .delete()
631
- .where((q) =>
632
- q.filter({
633
- and: [{ active: false }, { lastLogin: { lt: "2023-01-01" } }],
634
- }),
635
- )
583
+ .where(and(eq(users.active, false), lt(users.lastLogin, "2023-01-01")))
636
584
  .execute();
637
585
  ```
638
586
 
@@ -640,132 +588,145 @@ const result = await db
640
588
 
641
589
  ### Defining Navigation
642
590
 
643
- Define relationships between tables using the `navigation` option:
591
+ Define navigation relationships using the `navigationPaths` option when creating table occurrences:
644
592
 
645
593
  ```typescript
646
- const contactsBase = defineBaseTable({
647
- schema: {
648
- id: z.string(),
649
- name: z.string(),
650
- userId: z.string(),
651
- },
652
- idField: "id",
653
- });
594
+ import { fmTableOccurrence, textField } from "@proofkit/fmodata";
654
595
 
655
- const usersBase = defineBaseTable({
656
- schema: {
657
- id: z.string(),
658
- username: z.string(),
659
- email: z.string(),
596
+ const contacts = fmTableOccurrence(
597
+ "contacts",
598
+ {
599
+ id: textField().primaryKey(),
600
+ name: textField().notNull(),
601
+ userId: textField().notNull(),
660
602
  },
661
- idField: "id",
662
- });
663
-
664
- // Define navigation using functions to handle circular dependencies
665
- // Create base occurrences first, then add navigation
666
- const _contactsTO = defineTableOccurrence({
667
- name: "contacts",
668
- baseTable: contactsBase,
669
- });
603
+ {
604
+ navigationPaths: ["users"], // Valid navigation targets
605
+ },
606
+ );
670
607
 
671
- const _usersTO = defineTableOccurrence({
672
- name: "users",
673
- baseTable: usersBase,
674
- });
608
+ const users = fmTableOccurrence(
609
+ "users",
610
+ {
611
+ id: textField().primaryKey(),
612
+ username: textField().notNull(),
613
+ email: textField().notNull(),
614
+ },
615
+ {
616
+ navigationPaths: ["contacts"], // Valid navigation targets
617
+ },
618
+ );
675
619
 
676
- // Then add navigation
677
- const contactsTO = _contactsTO.addNavigation({
678
- users: () => _usersTO,
620
+ // Use with your database
621
+ const db = connection.database("MyDB", {
622
+ occurrences: [contacts, users],
679
623
  });
624
+ ```
680
625
 
681
- const usersTO = _usersTO.addNavigation({
682
- contacts: () => _contactsTO,
683
- });
626
+ The `navigationPaths` option:
684
627
 
685
- // You can also add navigation after creation
686
- const updatedUsersTO = usersTO.addNavigation({
687
- profile: () => profileTO,
688
- });
689
- ```
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
690
631
 
691
632
  ### Navigating Between Tables
692
633
 
693
634
  Navigate to related records:
694
635
 
695
636
  ```typescript
696
- // Navigate from a specific record
637
+ // Navigate from a specific record (using column references)
697
638
  const result = await db
698
- .from("contacts")
639
+ .from(contacts)
699
640
  .get("contact-123")
700
- .navigate("users")
701
- .select("username", "email")
641
+ .navigate(users)
642
+ .select({
643
+ username: users.username,
644
+ email: users.email,
645
+ })
702
646
  .execute();
703
647
 
704
648
  // Navigate without specifying a record first
705
- const result = await db.from("contacts").navigate("users").list().execute();
649
+ const result = await db.from(contacts).navigate(users).list().execute();
706
650
 
707
- // You can navigate to arbitrary tables not in your schema
651
+ // Using legacy API with string field names
708
652
  const result = await db
709
- .from("contacts")
710
- .navigate("some_other_table")
711
- .list()
653
+ .from(contacts)
654
+ .get("contact-123")
655
+ .navigate(users)
656
+ .select({ username: users.username, email: users.email })
712
657
  .execute();
713
658
  ```
714
659
 
715
660
  ### Expanding Related Records
716
661
 
717
- 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`:
718
663
 
719
664
  ```typescript
720
665
  // Simple expand
721
- const result = await db.from("contacts").list().expand("users").execute();
666
+ const result = await db.from(contacts).list().expand(users).execute();
722
667
 
723
- // Expand with field selection
668
+ // Expand with field selection (using column references)
724
669
  const result = await db
725
- .from("contacts")
670
+ .from(contacts)
726
671
  .list()
727
- .expand("users", (b) => b.select("username", "email"))
672
+ .expand(users, (b) =>
673
+ b.select({
674
+ username: users.username,
675
+ email: users.email,
676
+ }),
677
+ )
728
678
  .execute();
729
679
 
730
- // Expand with filtering
680
+ // Expand with filtering (using new ORM API)
681
+ import { eq } from "@proofkit/fmodata";
682
+
731
683
  const result = await db
732
- .from("contacts")
684
+ .from(contacts)
733
685
  .list()
734
- .expand("users", (b) => b.filter({ active: true }))
686
+ .expand(users, (b) => b.where(eq(users.active, true)))
735
687
  .execute();
736
688
 
737
689
  // Multiple expands
738
690
  const result = await db
739
- .from("contacts")
691
+ .from(contacts)
740
692
  .list()
741
- .expand("users", (b) => b.select("username"))
742
- .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))
743
695
  .execute();
744
696
 
745
697
  // Nested expands
746
698
  const result = await db
747
- .from("contacts")
699
+ .from(contacts)
748
700
  .list()
749
- .expand("users", (usersBuilder) =>
701
+ .expand(users, (usersBuilder) =>
750
702
  usersBuilder
751
- .select("username", "email")
752
- .expand("customer", (customerBuilder) =>
753
- 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
+ }),
754
712
  ),
755
713
  )
756
714
  .execute();
757
715
 
758
716
  // Complex expand with multiple options
759
717
  const result = await db
760
- .from("contacts")
718
+ .from(contacts)
761
719
  .list()
762
- .expand("users", (b) =>
720
+ .expand(users, (b) =>
763
721
  b
764
- .select("username", "email")
765
- .filter({ active: true })
766
- .orderBy("username")
722
+ .select({
723
+ username: users.username,
724
+ email: users.email,
725
+ })
726
+ .where(eq(users.active, true))
727
+ .orderBy(asc(users.username))
767
728
  .top(10)
768
- .expand("customer", (nested) => nested.select("name")),
729
+ .expand(customers, (nested) => nested.select({ name: customers.name })),
769
730
  )
770
731
  .execute();
771
732
  ```
@@ -817,29 +778,226 @@ console.log(result.result.recordId);
817
778
 
818
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.
819
780
 
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.
784
+
785
+ ### Adding a Webhook
786
+
787
+ Create a new webhook to monitor a table for changes:
788
+
789
+ ```typescript
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",
812
+ },
813
+ notifySchemaChanges: true, // Notify when schema changes
814
+ });
815
+
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
+ });
822
+
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],
831
+ });
832
+
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
+ ```
841
+
842
+ **Webhook Configuration Properties:**
843
+
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
+ });
870
+ ```
871
+
872
+ ### Getting a Webhook
873
+
874
+ Retrieve a specific webhook by ID:
875
+
876
+ ```typescript
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",
922
+ },
923
+ filter: eq(contacts.active, true),
924
+ select: [contacts.name, contacts.email, contacts.PrimaryKey],
925
+ notifySchemaChanges: false,
926
+ });
927
+
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
+
820
948
  ## Batch Operations
821
949
 
822
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.
823
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
+
824
972
  ### Basic Batch with Multiple Queries
825
973
 
826
974
  Execute multiple read operations in a single batch:
827
975
 
828
976
  ```typescript
829
977
  // Create query builders
830
- const contactsQuery = db.from("contacts").list().top(5);
831
- const usersQuery = db.from("users").list().top(5);
978
+ const contactsQuery = db.from(contacts).list().top(5);
979
+ const usersQuery = db.from(users).list().top(5);
832
980
 
833
981
  // Execute both queries in a single batch
834
982
  const result = await db.batch([contactsQuery, usersQuery]).execute();
835
983
 
836
- if (result.data) {
837
- // Result is a tuple matching the input builders
838
- const [contacts, users] = result.data;
984
+ // Access individual results
985
+ const [r1, r2] = result.results;
839
986
 
840
- console.log("Contacts:", contacts);
841
- console.log("Users:", users);
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);
842
997
  }
998
+
999
+ // Check summary statistics
1000
+ console.log(`Success: ${result.successCount}, Errors: ${result.errorCount}`);
843
1001
  ```
844
1002
 
845
1003
  ### Mixed Operations (Reads and Writes)
@@ -848,22 +1006,73 @@ Combine queries, inserts, updates, and deletes in a single batch:
848
1006
 
849
1007
  ```typescript
850
1008
  // Mix different operation types
851
- const listQuery = db.from("contacts").list().top(10);
852
- const insertOp = db.from("contacts").insert({
1009
+ const listQuery = db.from(contacts).list().top(10);
1010
+ const insertOp = db.from(contacts).insert({
853
1011
  name: "John Doe",
854
1012
  email: "john@example.com",
855
1013
  });
856
- const updateOp = db.from("users").update({ active: true }).byId("user-123");
1014
+ const updateOp = db.from(users).update({ active: true }).byId("user-123");
857
1015
 
858
1016
  // All operations execute atomically
859
1017
  const result = await db.batch([listQuery, insertOp, updateOp]).execute();
860
1018
 
861
- if (result.data) {
862
- const [contactsList, insertResult, updateResult] = result.data;
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
+ }
863
1072
 
864
- console.log("Fetched contacts:", contactsList);
865
- console.log("Inserted contact:", insertResult);
866
- console.log("Updated user:", updateResult);
1073
+ // Check if batch was truncated
1074
+ if (result.truncated) {
1075
+ console.log(`Batch stopped early at index ${result.firstErrorIndex}`);
867
1076
  }
868
1077
  ```
869
1078
 
@@ -874,18 +1083,31 @@ Batch operations are transactional for write operations (inserts, updates, delet
874
1083
  ```typescript
875
1084
  const result = await db
876
1085
  .batch([
877
- db.from("users").insert({ username: "alice", email: "alice@example.com" }),
878
- db.from("users").insert({ username: "bob", email: "bob@example.com" }),
879
- db.from("users").insert({ username: "charlie", email: "invalid" }), // This fails
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
880
1089
  ])
881
1090
  .execute();
882
1091
 
883
- if (result.error) {
1092
+ // Check individual results
1093
+ const [r1, r2, r3] = result.results;
1094
+
1095
+ if (r1.error || r2.error || r3.error) {
884
1096
  // All three inserts are rolled back - no users were created
885
- console.error("Batch failed:", result.error);
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);
886
1101
  }
887
1102
  ```
888
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
+
889
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.
890
1112
 
891
1113
  ## Schema Management
@@ -1147,94 +1369,50 @@ await db.schema.createIndex("users", "email");
1147
1369
 
1148
1370
  ## Advanced Features
1149
1371
 
1150
- ### Type Safety
1151
-
1152
- The library provides full TypeScript type inference:
1153
-
1154
- ```typescript
1155
- const usersBase = defineBaseTable({
1156
- schema: {
1157
- id: z.string(),
1158
- username: z.string(),
1159
- email: z.string(),
1160
- },
1161
- idField: "id",
1162
- });
1163
-
1164
- const usersTO = defineTableOccurrence({
1165
- name: "users",
1166
- baseTable: usersBase,
1167
- });
1168
-
1169
- const db = connection.database("MyDB", {
1170
- occurrences: [usersTO],
1171
- });
1172
-
1173
- // TypeScript knows these are valid field names
1174
- db.from("users").list().select("username", "email");
1175
-
1176
- // TypeScript error: "invalid" is not a field name
1177
- db.from("users").list().select("invalid"); // TS Error
1178
-
1179
- // Type-safe filters
1180
- db.from("users")
1181
- .list()
1182
- .filter({ username: { eq: "john" } }); // ✓
1183
- db.from("users")
1184
- .list()
1185
- .filter({ invalid: { eq: "john" } }); // TS Error
1186
- ```
1187
-
1188
1372
  ### Required and Read-Only Fields
1189
1373
 
1190
- The library automatically infers which fields are required based on whether their validator allows `null` or `undefined`:
1374
+ The library automatically infers which fields are required based on field builder configuration:
1191
1375
 
1192
1376
  ```typescript
1193
- const usersBase = defineBaseTable({
1194
- schema: {
1195
- id: z.string(), // Auto-required, auto-readOnly (idField)
1196
- username: z.string(), // Auto-required (not nullable)
1197
- email: z.string(), // Auto-required (not nullable)
1198
- status: z.string().nullable(), // Optional (nullable)
1199
- createdAt: z.string(), // Read-only system field
1200
- updatedAt: z.string().nullable(), // Optional
1201
- },
1202
- idField: "id", // Automatically excluded from insert/update
1203
- required: ["status"], // Make status required despite being nullable
1204
- readOnly: ["createdAt"], // Exclude createdAt from insert/update
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)
1205
1384
  });
1206
1385
 
1207
- // Insert: username, email, and status are required
1208
- // Insert: id and createdAt are excluded (cannot be provided)
1209
- db.from("users").insert({
1386
+ // Insert: username and email are required
1387
+ // Insert: id and createdAt are excluded (cannot be provided - read-only)
1388
+ db.from(users).insert({
1210
1389
  username: "john",
1211
1390
  email: "john@example.com",
1212
- status: "active", // Required due to 'required' array
1391
+ status: "active", // Optional
1213
1392
  updatedAt: new Date().toISOString(), // Optional
1214
1393
  });
1215
1394
 
1216
1395
  // Update: all fields are optional except id and createdAt are excluded
1217
- db.from("users")
1396
+ db.from(users)
1218
1397
  .update({
1219
1398
  status: "active", // Optional
1220
- // id and createdAt cannot be modified
1399
+ // id and createdAt cannot be modified (read-only)
1221
1400
  })
1222
1401
  .byId("user-123");
1223
1402
  ```
1224
1403
 
1225
1404
  **Key Features:**
1226
1405
 
1227
- - **Auto-inference:** Non-nullable fields are automatically required for insert
1228
- - **Additional requirements:** Use `required` to make nullable fields required for new records
1229
- - **Read-only fields:** Use `readOnly` to exclude fields from insert/update (e.g., timestamps)
1230
- - **Automatic ID exclusion:** The `idField` is always read-only without needing to specify it
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)
1231
1409
  - **Update flexibility:** All fields are optional for updates (except read-only fields)
1232
1410
 
1233
1411
  ### Prefer: fmodata.entity-ids
1234
1412
 
1235
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.
1236
1414
 
1237
- To enable this feature, simply define your schema with entity IDs using the `defineBaseTable` and `defineTableOccurrence` functions. Behind the scenes, the library will transform your request and the response back to the names you specify in these schemas. This is an all-or-nothing feature. For it to work properly, you must define all table occurrences passed to a `Database` with entity IDs (both `fmfIds` on the base table and `fmtId` on the table occurrence).
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).
1238
1416
 
1239
1417
  _Note for OttoFMS proxy: This feature requires version 4.14 or later of OttoFMS_
1240
1418
 
@@ -1243,34 +1421,58 @@ How do I find these ids? They can be found in the XML version of the `$metadata`
1243
1421
  #### Basic Usage
1244
1422
 
1245
1423
  ```typescript
1246
- import { defineBaseTable, defineTableOccurrence } from "@proofkit/fmodata";
1247
- import { z } from "zod/v4";
1424
+ import {
1425
+ fmTableOccurrence,
1426
+ textField,
1427
+ timestampField,
1428
+ } from "@proofkit/fmodata";
1248
1429
 
1249
- // Define a base table with FileMaker field IDs
1250
- const usersBase = defineBaseTable({
1251
- schema: {
1252
- id: z.string(),
1253
- username: z.string(),
1254
- email: z.string().nullable(),
1255
- createdAt: z.string(),
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"),
1256
1438
  },
1257
- idField: "id",
1258
- fmfIds: {
1259
- id: "FMFID:12039485",
1260
- username: "FMFID:34323433",
1261
- email: "FMFID:12232424",
1262
- createdAt: "FMFID:43234355",
1439
+ {
1440
+ entityId: "FMTID:12432533", // FileMaker table occurrence ID
1263
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,
1264
1466
  });
1265
1467
 
1266
- // Create a table occurrence with a FileMaker table occurrence ID
1267
- const usersTO = defineTableOccurrence({
1268
- name: "users",
1269
- baseTable: usersBase,
1270
- fmtId: "FMTID:12432533",
1468
+ // Disable for this request
1469
+ const result = await db.from(users).list().execute({
1470
+ includeSpecialColumns: false,
1271
1471
  });
1272
1472
  ```
1273
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
+
1274
1476
  ### Error Handling
1275
1477
 
1276
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.
@@ -1278,7 +1480,7 @@ All operations return a `Result` type with either `data` or `error`. The library
1278
1480
  #### Basic Error Checking
1279
1481
 
1280
1482
  ```typescript
1281
- const result = await db.from("users").list().execute();
1483
+ const result = await db.from(users).list().execute();
1282
1484
 
1283
1485
  if (result.error) {
1284
1486
  console.error("Query failed:", result.error.message);
@@ -1297,7 +1499,7 @@ Handle HTTP status codes (4xx, 5xx) with the `HTTPError` class:
1297
1499
  ```typescript
1298
1500
  import { HTTPError, isHTTPError } from "@proofkit/fmodata";
1299
1501
 
1300
- const result = await db.from("users").list().execute();
1502
+ const result = await db.from(users).list().execute();
1301
1503
 
1302
1504
  if (result.error) {
1303
1505
  if (isHTTPError(result.error)) {
@@ -1334,7 +1536,7 @@ import {
1334
1536
  CircuitOpenError,
1335
1537
  } from "@proofkit/fmodata";
1336
1538
 
1337
- const result = await db.from("users").list().execute();
1539
+ const result = await db.from(users).list().execute();
1338
1540
 
1339
1541
  if (result.error) {
1340
1542
  if (result.error instanceof TimeoutError) {
@@ -1360,7 +1562,7 @@ When schema validation fails, you get a `ValidationError` with rich context:
1360
1562
  ```typescript
1361
1563
  import { ValidationError, isValidationError } from "@proofkit/fmodata";
1362
1564
 
1363
- const result = await db.from("users").list().execute();
1565
+ const result = await db.from(users).list().execute();
1364
1566
 
1365
1567
  if (result.error) {
1366
1568
  if (isValidationError(result.error)) {
@@ -1379,7 +1581,7 @@ The library uses [Standard Schema](https://github.com/standard-schema/standard-s
1379
1581
  ```typescript
1380
1582
  import { ValidationError } from "@proofkit/fmodata";
1381
1583
 
1382
- const result = await db.from("users").list().execute();
1584
+ const result = await db.from(users).list().execute();
1383
1585
 
1384
1586
  if (result.error instanceof ValidationError) {
1385
1587
  // The cause property (ES2022 Error.cause) contains the Standard Schema issues array
@@ -1432,7 +1634,7 @@ Handle OData-specific protocol errors:
1432
1634
  ```typescript
1433
1635
  import { ODataError, isODataError } from "@proofkit/fmodata";
1434
1636
 
1435
- const result = await db.from("users").list().execute();
1637
+ const result = await db.from(users).list().execute();
1436
1638
 
1437
1639
  if (result.error) {
1438
1640
  if (isODataError(result.error)) {
@@ -1455,7 +1657,7 @@ import {
1455
1657
  NetworkError,
1456
1658
  } from "@proofkit/fmodata";
1457
1659
 
1458
- const result = await db.from("users").list().execute();
1660
+ const result = await db.from(users).list().execute();
1459
1661
 
1460
1662
  if (result.error) {
1461
1663
  if (result.error instanceof TimeoutError) {
@@ -1477,7 +1679,7 @@ if (result.error) {
1477
1679
  **Pattern 2: Using kind property (for exhaustive matching):**
1478
1680
 
1479
1681
  ```typescript
1480
- const result = await db.from("users").list().execute();
1682
+ const result = await db.from(users).list().execute();
1481
1683
 
1482
1684
  if (result.error) {
1483
1685
  switch (result.error.kind) {
@@ -1633,7 +1835,7 @@ const queryString = db
1633
1835
  .from("users")
1634
1836
  .list()
1635
1837
  .select("username", "email")
1636
- .filter({ active: true })
1838
+ .where(eq(users.active, true))
1637
1839
  .orderBy("username")
1638
1840
  .top(10)
1639
1841
  .getQueryString();