@proofkit/fmodata 0.1.0-alpha.12 → 0.1.0-alpha.14

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 (142) hide show
  1. package/README.md +489 -334
  2. package/dist/esm/client/batch-builder.d.ts +7 -4
  3. package/dist/esm/client/batch-builder.js +84 -25
  4. package/dist/esm/client/batch-builder.js.map +1 -1
  5. package/dist/esm/client/builders/default-select.d.ts +7 -0
  6. package/dist/esm/client/builders/default-select.js +42 -0
  7. package/dist/esm/client/builders/default-select.js.map +1 -0
  8. package/dist/esm/client/builders/expand-builder.d.ts +43 -0
  9. package/dist/esm/client/builders/expand-builder.js +173 -0
  10. package/dist/esm/client/builders/expand-builder.js.map +1 -0
  11. package/dist/esm/client/builders/index.d.ts +8 -0
  12. package/dist/esm/client/builders/query-string-builder.d.ts +15 -0
  13. package/dist/esm/client/builders/query-string-builder.js +25 -0
  14. package/dist/esm/client/builders/query-string-builder.js.map +1 -0
  15. package/dist/esm/client/builders/response-processor.d.ts +39 -0
  16. package/dist/esm/client/builders/response-processor.js +170 -0
  17. package/dist/esm/client/builders/response-processor.js.map +1 -0
  18. package/dist/esm/client/builders/select-mixin.d.ts +31 -0
  19. package/dist/esm/client/builders/select-mixin.js +30 -0
  20. package/dist/esm/client/builders/select-mixin.js.map +1 -0
  21. package/dist/esm/client/builders/select-utils.d.ts +8 -0
  22. package/dist/esm/client/builders/select-utils.js +15 -0
  23. package/dist/esm/client/builders/select-utils.js.map +1 -0
  24. package/dist/esm/client/builders/shared-types.d.ts +39 -0
  25. package/dist/esm/client/builders/table-utils.d.ts +35 -0
  26. package/dist/esm/client/builders/table-utils.js +45 -0
  27. package/dist/esm/client/builders/table-utils.js.map +1 -0
  28. package/dist/esm/client/database.d.ts +3 -22
  29. package/dist/esm/client/database.js +14 -76
  30. package/dist/esm/client/database.js.map +1 -1
  31. package/dist/esm/client/delete-builder.d.ts +11 -15
  32. package/dist/esm/client/delete-builder.js +26 -26
  33. package/dist/esm/client/delete-builder.js.map +1 -1
  34. package/dist/esm/client/entity-set.d.ts +32 -32
  35. package/dist/esm/client/entity-set.js +92 -69
  36. package/dist/esm/client/entity-set.js.map +1 -1
  37. package/dist/esm/client/error-parser.d.ts +12 -0
  38. package/dist/esm/client/error-parser.js +30 -0
  39. package/dist/esm/client/error-parser.js.map +1 -0
  40. package/dist/esm/client/filemaker-odata.d.ts +2 -4
  41. package/dist/esm/client/filemaker-odata.js +1 -5
  42. package/dist/esm/client/filemaker-odata.js.map +1 -1
  43. package/dist/esm/client/insert-builder.d.ts +7 -9
  44. package/dist/esm/client/insert-builder.js +70 -24
  45. package/dist/esm/client/insert-builder.js.map +1 -1
  46. package/dist/esm/client/query/expand-builder.d.ts +35 -0
  47. package/dist/esm/client/query/index.d.ts +3 -0
  48. package/dist/esm/client/query/query-builder.d.ts +134 -0
  49. package/dist/esm/client/query/query-builder.js +505 -0
  50. package/dist/esm/client/query/query-builder.js.map +1 -0
  51. package/dist/esm/client/query/response-processor.d.ts +22 -0
  52. package/dist/esm/client/query/types.d.ts +52 -0
  53. package/dist/esm/client/query/url-builder.d.ts +71 -0
  54. package/dist/esm/client/query/url-builder.js +107 -0
  55. package/dist/esm/client/query/url-builder.js.map +1 -0
  56. package/dist/esm/client/query-builder.d.ts +1 -111
  57. package/dist/esm/client/record-builder.d.ts +56 -63
  58. package/dist/esm/client/record-builder.js +158 -296
  59. package/dist/esm/client/record-builder.js.map +1 -1
  60. package/dist/esm/client/response-processor.d.ts +3 -3
  61. package/dist/esm/client/update-builder.d.ts +16 -21
  62. package/dist/esm/client/update-builder.js +56 -30
  63. package/dist/esm/client/update-builder.js.map +1 -1
  64. package/dist/esm/errors.d.ts +8 -1
  65. package/dist/esm/errors.js +17 -0
  66. package/dist/esm/errors.js.map +1 -1
  67. package/dist/esm/index.d.ts +3 -7
  68. package/dist/esm/index.js +37 -8
  69. package/dist/esm/index.js.map +1 -1
  70. package/dist/esm/orm/column.d.ts +45 -0
  71. package/dist/esm/orm/column.js +59 -0
  72. package/dist/esm/orm/column.js.map +1 -0
  73. package/dist/esm/orm/field-builders.d.ts +154 -0
  74. package/dist/esm/orm/field-builders.js +152 -0
  75. package/dist/esm/orm/field-builders.js.map +1 -0
  76. package/dist/esm/orm/index.d.ts +4 -0
  77. package/dist/esm/orm/operators.d.ts +175 -0
  78. package/dist/esm/orm/operators.js +221 -0
  79. package/dist/esm/orm/operators.js.map +1 -0
  80. package/dist/esm/orm/table.d.ts +341 -0
  81. package/dist/esm/orm/table.js +211 -0
  82. package/dist/esm/orm/table.js.map +1 -0
  83. package/dist/esm/transform.d.ts +20 -21
  84. package/dist/esm/transform.js +34 -34
  85. package/dist/esm/transform.js.map +1 -1
  86. package/dist/esm/types.d.ts +16 -13
  87. package/dist/esm/types.js.map +1 -1
  88. package/dist/esm/validation.d.ts +14 -4
  89. package/dist/esm/validation.js +45 -1
  90. package/dist/esm/validation.js.map +1 -1
  91. package/package.json +5 -2
  92. package/src/client/batch-builder.ts +100 -32
  93. package/src/client/builders/default-select.ts +69 -0
  94. package/src/client/builders/expand-builder.ts +236 -0
  95. package/src/client/builders/index.ts +11 -0
  96. package/src/client/builders/query-string-builder.ts +41 -0
  97. package/src/client/builders/response-processor.ts +273 -0
  98. package/src/client/builders/select-mixin.ts +74 -0
  99. package/src/client/builders/select-utils.ts +34 -0
  100. package/src/client/builders/shared-types.ts +41 -0
  101. package/src/client/builders/table-utils.ts +87 -0
  102. package/src/client/database.ts +19 -160
  103. package/src/client/delete-builder.ts +46 -51
  104. package/src/client/entity-set.ts +227 -302
  105. package/src/client/error-parser.ts +59 -0
  106. package/src/client/filemaker-odata.ts +3 -14
  107. package/src/client/insert-builder.ts +124 -43
  108. package/src/client/query/expand-builder.ts +164 -0
  109. package/src/client/query/index.ts +13 -0
  110. package/src/client/query/query-builder.ts +816 -0
  111. package/src/client/query/response-processor.ts +244 -0
  112. package/src/client/query/types.ts +102 -0
  113. package/src/client/query/url-builder.ts +179 -0
  114. package/src/client/query-builder.ts +8 -1447
  115. package/src/client/record-builder.ts +325 -583
  116. package/src/client/response-processor.ts +4 -5
  117. package/src/client/update-builder.ts +102 -73
  118. package/src/errors.ts +22 -1
  119. package/src/index.ts +55 -5
  120. package/src/orm/column.ts +78 -0
  121. package/src/orm/field-builders.ts +296 -0
  122. package/src/orm/index.ts +60 -0
  123. package/src/orm/operators.ts +428 -0
  124. package/src/orm/table.ts +759 -0
  125. package/src/transform.ts +62 -48
  126. package/src/types.ts +20 -63
  127. package/src/validation.ts +76 -4
  128. package/dist/esm/client/base-table.d.ts +0 -128
  129. package/dist/esm/client/base-table.js +0 -57
  130. package/dist/esm/client/base-table.js.map +0 -1
  131. package/dist/esm/client/build-occurrences.d.ts +0 -74
  132. package/dist/esm/client/build-occurrences.js +0 -31
  133. package/dist/esm/client/build-occurrences.js.map +0 -1
  134. package/dist/esm/client/query-builder.js +0 -897
  135. package/dist/esm/client/query-builder.js.map +0 -1
  136. package/dist/esm/client/table-occurrence.d.ts +0 -86
  137. package/dist/esm/client/table-occurrence.js +0 -58
  138. package/dist/esm/client/table-occurrence.js.map +0 -1
  139. package/src/client/base-table.ts +0 -178
  140. package/src/client/build-occurrences.ts +0 -155
  141. package/src/client/query-builder.ts.bak +0 -1457
  142. package/src/client/table-occurrence.ts +0 -156
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",
56
- });
57
-
58
- // 3. Create a table occurrence
59
- const usersTO = defineTableOccurrence({
60
- name: "users",
61
- 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))),
62
57
  });
63
58
 
64
- // 4. Create a database instance
65
- const db = connection.database("MyDatabase.fmp12", {
66
- occurrences: [usersTO],
67
- });
59
+ // 3. Create a database instance
60
+ const db = connection.database("MyDatabase.fmp12");
68
61
 
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,87 +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 types 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.
123
+
124
+ #### Field Builders
125
+
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
127
+
128
+ - `textField()`
129
+ - `numberField()`
130
+ - `dateField()`
131
+ - `timeField()`
132
+ - `timestampField()`
133
+ - `containerField()`
134
+ - `calcField()`
131
135
 
132
- Use **`defineBaseTable()`** and **`defineTableOccurrence()`** to create your schemas. These functions provide full TypeScript type inference for field names in queries.
136
+ Each field builder supports chainable methods:
133
137
 
134
- A `BaseTable` defines the schema for your FileMaker table using Standard Schema. These examples show zod, but you can use any other validation library that supports Standard Schema.
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:
135
148
 
136
149
  ```typescript
137
150
  import { z } from "zod/v4";
138
- import { defineBaseTable } from "@proofkit/fmodata";
139
-
140
- const contactsBase = defineBaseTable({
141
- schema: {
142
- id: z.string(),
143
- name: z.string(),
144
- email: z.string(),
145
- phone: z.string().optional(),
146
- createdAt: z.string(),
151
+ import {
152
+ fmTableOccurrence,
153
+ textField,
154
+ numberField,
155
+ timestampField,
156
+ } from "@proofkit/fmodata";
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"),
147
166
  },
148
- idField: "id", // The primary key field (automatically read-only)
149
- required: ["phone"], // optional: additional required fields for insert (beyond auto-inferred)
150
- readOnly: ["createdAt"], // optional: fields excluded from insert/update
151
- });
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
+ );
152
173
  ```
153
174
 
154
- A `TableOccurrence` is the actual entry point for the OData service on the FileMaker server. It allows you to reference the same base table multiple times with different names.
175
+ The function returns a table object that provides:
155
176
 
156
- ```typescript
157
- import { defineTableOccurrence } from "@proofkit/fmodata";
158
-
159
- const contactsTO = defineTableOccurrence({
160
- name: "contacts", // The table occurrence name in FileMaker
161
- baseTable: contactsBase,
162
- });
163
- ```
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)
164
180
 
165
181
  #### Default Field Selection
166
182
 
167
- 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:
168
184
 
169
185
  ```typescript
170
- // Option 1 (default): "schema" - Select all fields from the schema (same as "all" but more explicit)
171
- const usersTO = defineTableOccurrence({
172
- name: "users",
173
- baseTable: usersBase,
174
- defaultSelect: "schema", // a $select parameter will be always be added to the query for only the fields you've defined in the BaseTable schema
175
- });
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
+ );
176
196
 
177
- // Option 2: "all" - Select all fields (default behavior)
178
- const usersTO = defineTableOccurrence({
179
- name: "users",
180
- baseTable: usersBase,
181
- defaultSelect: "all", // Don't always a $select parameter to the query; FileMaker will return all non-container fields from the table
182
- });
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
+ );
183
207
 
184
- // Option 3: Array of field names - Select only specific fields by default
185
- const usersTO = defineTableOccurrence({
186
- name: "users",
187
- baseTable: usersBase,
188
- defaultSelect: ["username", "email"], // Only select these fields by default
189
- });
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
+ );
190
221
 
191
- // When you call list(), the defaultSelect is applied automatically
192
- const result = await db.from("users").list().execute();
193
- // 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
194
225
 
195
226
  // You can still override with explicit select()
196
227
  const result = await db
197
- .from("users")
228
+ .from(users)
198
229
  .list()
199
- .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
200
231
  .execute();
201
232
  ```
202
233
 
203
- Lastly, you can combine all table occurrences into a database instance for the full type-safe experience. This is a method on the main `FMServerConnection` client class.
204
-
205
- ```typescript
206
- const db = connection.database("MyDatabase.fmp12", {
207
- occurrences: [contactsTO, usersTO], // Register your table occurrences
208
- });
209
- ```
210
-
211
234
  ## Querying Data
212
235
 
213
236
  ### Basic Queries
@@ -251,11 +274,60 @@ if (result.data) {
251
274
 
252
275
  ### Filtering
253
276
 
254
- 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.
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.
278
+
279
+ #### New ORM-Style API (Recommended)
280
+
281
+ Use the `where()` method with filter operators and column references for type-safe filtering:
282
+
283
+ ```typescript
284
+ import { eq, gt, and, or, contains } from "@proofkit/fmodata";
285
+
286
+ // Simple equality
287
+ const result = await db
288
+ .from(users)
289
+ .list()
290
+ .where(eq(users.active, true))
291
+ .execute();
292
+
293
+ // Comparison operators
294
+ const result = await db.from(users).list().where(gt(users.age, 18)).execute();
295
+
296
+ // String operators
297
+ const result = await db
298
+ .from(users)
299
+ .list()
300
+ .where(contains(users.name, "John"))
301
+ .execute();
302
+
303
+ // Combine with AND
304
+ const result = await db
305
+ .from(users)
306
+ .list()
307
+ .where(and(eq(users.active, true), gt(users.age, 18)))
308
+ .execute();
309
+
310
+ // Combine with OR
311
+ const result = await db
312
+ .from(users)
313
+ .list()
314
+ .where(or(eq(users.role, "admin"), eq(users.role, "moderator")))
315
+ .execute();
316
+ ```
255
317
 
256
- #### Operator Syntax
318
+ Available operators:
257
319
 
258
- You can use filters in three ways:
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
+ #### Legacy Filter API (DO NOT USE, will be removed shortly)
327
+
328
+ The filter system supports three syntaxes: shorthand, single operator objects, and arrays for multiple operators.
329
+
330
+ You can use the legacy `filter()` method in three ways:
259
331
 
260
332
  **1. Shorthand (direct value):**
261
333
 
@@ -403,21 +475,52 @@ const result = await db
403
475
 
404
476
  ### Sorting
405
477
 
406
- Sort results using `orderBy()`:
478
+ Sort results using `orderBy()`. The method supports both column references (new ORM API) and string field names (legacy API).
479
+
480
+ #### Using Column References (New ORM API)
407
481
 
408
482
  ```typescript
409
- // Sort ascending
410
- const result = await db.from("users").list().orderBy("name").execute();
483
+ import { asc, desc } from "@proofkit/fmodata";
411
484
 
412
- // Sort descending
413
- const result = await db.from("users").list().orderBy("name desc").execute();
485
+ // Single field (ascending by default)
486
+ const result = await db.from(users).list().orderBy(users.name).execute();
414
487
 
415
- // Multiple sort fields
488
+ // Single field with explicit direction
489
+ const result = await db.from(users).list().orderBy(asc(users.name)).execute();
490
+ const result = await db.from(users).list().orderBy(desc(users.age)).execute();
491
+
492
+ // Multiple fields (variadic)
416
493
  const result = await db
417
- .from("users")
494
+ .from(users)
418
495
  .list()
419
- .orderBy("lastName, firstName desc")
496
+ .orderBy(asc(users.lastName), desc(users.firstName))
420
497
  .execute();
498
+
499
+ // Multiple fields (array syntax)
500
+ const result = await db
501
+ .from(users)
502
+ .list()
503
+ .orderBy([
504
+ [users.lastName, "asc"],
505
+ [users.firstName, "desc"],
506
+ ])
507
+ .execute();
508
+ ```
509
+
510
+ #### Type Safety
511
+
512
+ For typed databases, `orderBy()` provides full type safety:
513
+
514
+ ```typescript
515
+ // ✅ Valid - "name" is a field in the schema
516
+ db.from(users).list().orderBy(users.name);
517
+
518
+ // ✅ Valid - tuple with field and direction
519
+ db.from(users).list().orderBy(asc(users.name));
520
+ db.from(users).list().orderBy(desc(users.name));
521
+
522
+ // ✅ Valid - multiple fields
523
+ db.from(users).list().orderBy(asc(users.lastName), desc(users.firstName));
421
524
  ```
422
525
 
423
526
  ### Pagination
@@ -426,24 +529,29 @@ Control the number of records returned and pagination:
426
529
 
427
530
  ```typescript
428
531
  // Limit results
429
- const result = await db.from("users").list().top(10).execute();
532
+ const result = await db.from(users).list().top(10).execute();
430
533
 
431
534
  // Skip records (pagination)
432
- const result = await db.from("users").list().top(10).skip(20).execute();
535
+ const result = await db.from(users).list().top(10).skip(20).execute();
433
536
 
434
537
  // Count total records
435
- const result = await db.from("users").list().count().execute();
538
+ const result = await db.from(users).list().count().execute();
436
539
  ```
437
540
 
438
541
  ### Selecting Fields
439
542
 
440
- Select specific fields to return:
543
+ Select specific fields to return. You can use either column references (new ORM API) or string field names (legacy API):
441
544
 
442
545
  ```typescript
546
+ // New ORM API: Using column references (type-safe, supports renaming)
443
547
  const result = await db
444
- .from("users")
548
+ .from(users)
445
549
  .list()
446
- .select("username", "email")
550
+ .select({
551
+ username: users.username,
552
+ email: users.email,
553
+ userId: users.id, // Renamed from "id" to "userId"
554
+ })
447
555
  .execute();
448
556
 
449
557
  // result.data[0] will only have username and email fields
@@ -455,9 +563,9 @@ Use `single()` to ensure exactly one record is returned (returns an error if zer
455
563
 
456
564
  ```typescript
457
565
  const result = await db
458
- .from("users")
566
+ .from(users)
459
567
  .list()
460
- .filter({ email: { eq: "user@example.com" } })
568
+ .filter(eq(users.email, "user@example.com"))
461
569
  .single()
462
570
  .execute();
463
571
 
@@ -471,9 +579,9 @@ Use `maybeSingle()` when you want at most one record (returns `null` if no recor
471
579
 
472
580
  ```typescript
473
581
  const result = await db
474
- .from("users")
582
+ .from(users)
475
583
  .list()
476
- .filter({ email: { eq: "user@example.com" } })
584
+ .filter(eq(users.email, "user@example.com"))
477
585
  .maybeSingle()
478
586
  .execute();
479
587
 
@@ -496,6 +604,22 @@ if (result.data) {
496
604
  All query methods can be chained together:
497
605
 
498
606
  ```typescript
607
+ // Using new ORM API
608
+ const result = await db
609
+ .from(users)
610
+ .list()
611
+ .select({
612
+ username: users.username,
613
+ email: users.email,
614
+ age: users.age,
615
+ })
616
+ .where(gt(users.age, 18))
617
+ .orderBy(asc(users.username))
618
+ .top(10)
619
+ .skip(0)
620
+ .execute();
621
+
622
+ // Using legacy API
499
623
  const result = await db
500
624
  .from("users")
501
625
  .list()
@@ -516,7 +640,7 @@ Insert new records with type-safe data:
516
640
  ```typescript
517
641
  // Insert a new user
518
642
  const result = await db
519
- .from("users")
643
+ .from(users)
520
644
  .insert({
521
645
  username: "johndoe",
522
646
  email: "john@example.com",
@@ -529,30 +653,25 @@ if (result.data) {
529
653
  }
530
654
  ```
531
655
 
532
- Fields are automatically required for insert if their validator doesn't allow `null` or `undefined`. You can specify additional required fields:
656
+ Fields are automatically required for insert if they use `.notNull()`. Read-only fields (including primary keys) are automatically excluded:
533
657
 
534
658
  ```typescript
535
- const usersBase = defineBaseTable({
536
- schema: {
537
- id: z.string(), // Auto-required (not nullable), but excluded from insert (idField)
538
- username: z.string(), // Auto-required (not nullable)
539
- email: z.string(), // Auto-required (not nullable)
540
- phone: z.string().nullable(), // Optional by default
541
- createdAt: z.string(), // Auto-required, but excluded (readOnly)
542
- },
543
- idField: "id", // Automatically excluded from insert/update
544
- required: ["phone"], // Make phone required for inserts despite being nullable
545
- readOnly: ["createdAt"], // Exclude from insert/update operations
659
+ const users = fmTableOccurrence("users", {
660
+ id: textField().primaryKey(), // Auto-required, but excluded from insert (primaryKey)
661
+ username: textField().notNull(), // Auto-required (notNull)
662
+ email: textField().notNull(), // Auto-required (notNull)
663
+ phone: textField(), // Optional by default (nullable)
664
+ createdAt: timestampField().readOnly(), // Excluded from insert/update
546
665
  });
547
666
 
548
- // TypeScript enforces: username, email, and phone are required
667
+ // TypeScript enforces: username and email are required
549
668
  // TypeScript excludes: id and createdAt cannot be provided
550
669
  const result = await db
551
- .from("users")
670
+ .from(users)
552
671
  .insert({
553
672
  username: "johndoe",
554
673
  email: "john@example.com",
555
- phone: "+1234567890", // Required because specified in 'required' array
674
+ phone: "+1234567890", // Optional
556
675
  })
557
676
  .execute();
558
677
  ```
@@ -564,7 +683,7 @@ Update records by ID or filter:
564
683
  ```typescript
565
684
  // Update by ID
566
685
  const result = await db
567
- .from("users")
686
+ .from(users)
568
687
  .update({ username: "newname" })
569
688
  .byId("user-123")
570
689
  .execute();
@@ -573,25 +692,23 @@ if (result.data) {
573
692
  console.log(`Updated ${result.data.updatedCount} record(s)`);
574
693
  }
575
694
 
576
- // Update by filter
695
+ // Update by filter (using new ORM API)
696
+ import { lt, and, eq } from "@proofkit/fmodata";
697
+
577
698
  const result = await db
578
- .from("users")
699
+ .from(users)
579
700
  .update({ active: false })
580
- .where((q) => q.filter({ lastLogin: { lt: "2023-01-01" } }))
701
+ .where(lt(users.lastLogin, "2023-01-01"))
581
702
  .execute();
582
703
 
583
704
  // Complex filter example
584
705
  const result = await db
585
- .from("users")
706
+ .from(users)
586
707
  .update({ active: false })
587
- .where((q) =>
588
- q.filter({
589
- and: [{ active: true }, { count: { lt: 5 } }],
590
- }),
591
- )
708
+ .where(and(eq(users.active, true), lt(users.count, 5)))
592
709
  .execute();
593
710
 
594
- // Update with additional query options
711
+ // Update with additional query options (legacy filter API)
595
712
  const result = await db
596
713
  .from("users")
597
714
  .update({ active: false })
@@ -605,28 +722,26 @@ Delete records by ID or filter:
605
722
 
606
723
  ```typescript
607
724
  // Delete by ID
608
- const result = await db.from("users").delete().byId("user-123").execute();
725
+ const result = await db.from(users).delete().byId("user-123").execute();
609
726
 
610
727
  if (result.data) {
611
728
  console.log(`Deleted ${result.data.deletedCount} record(s)`);
612
729
  }
613
730
 
614
- // Delete by filter
731
+ // Delete by filter (using new ORM API)
732
+ import { eq, and, lt } from "@proofkit/fmodata";
733
+
615
734
  const result = await db
616
- .from("users")
735
+ .from(users)
617
736
  .delete()
618
- .where((q) => q.filter({ active: false }))
737
+ .where(eq(users.active, false))
619
738
  .execute();
620
739
 
621
740
  // Delete with complex filters
622
741
  const result = await db
623
- .from("users")
742
+ .from(users)
624
743
  .delete()
625
- .where((q) =>
626
- q.filter({
627
- and: [{ active: false }, { lastLogin: { lt: "2023-01-01" } }],
628
- }),
629
- )
744
+ .where(and(eq(users.active, false), lt(users.lastLogin, "2023-01-01")))
630
745
  .execute();
631
746
  ```
632
747
 
@@ -634,148 +749,145 @@ const result = await db
634
749
 
635
750
  ### Defining Navigation
636
751
 
637
- Use `buildOccurrences()` to define relationships between tables. This function takes an array of table occurrences and a configuration object that specifies navigation relationships using type-safe string references:
752
+ Define navigation relationships using the `navigationPaths` option when creating table occurrences:
638
753
 
639
754
  ```typescript
640
- import {
641
- defineBaseTable,
642
- defineTableOccurrence,
643
- buildOccurrences,
644
- } from "@proofkit/fmodata";
755
+ import { fmTableOccurrence, textField } from "@proofkit/fmodata";
645
756
 
646
- const contactsBase = defineBaseTable({
647
- schema: {
648
- id: z.string(),
649
- name: z.string(),
650
- userId: z.string(),
757
+ const contacts = fmTableOccurrence(
758
+ "contacts",
759
+ {
760
+ id: textField().primaryKey(),
761
+ name: textField().notNull(),
762
+ userId: textField().notNull(),
651
763
  },
652
- idField: "id",
653
- });
654
-
655
- const usersBase = defineBaseTable({
656
- schema: {
657
- id: z.string(),
658
- username: z.string(),
659
- email: z.string(),
764
+ {
765
+ navigationPaths: ["users"], // Valid navigation targets
660
766
  },
661
- idField: "id",
662
- });
663
-
664
- // Step 1: Define base table occurrences (without navigation)
665
- const _contactsTO = defineTableOccurrence({
666
- name: "contacts",
667
- baseTable: contactsBase,
668
- });
669
-
670
- const _usersTO = defineTableOccurrence({
671
- name: "users",
672
- baseTable: usersBase,
673
- });
767
+ );
674
768
 
675
- // Step 2: Build occurrences with navigation using string references
676
- // The strings autocomplete to valid table occurrence names!
677
- const occurrences = buildOccurrences({
678
- occurrences: [_contactsTO, _usersTO],
679
- navigation: {
680
- contacts: ["users"],
681
- users: ["contacts"],
769
+ const users = fmTableOccurrence(
770
+ "users",
771
+ {
772
+ id: textField().primaryKey(),
773
+ username: textField().notNull(),
774
+ email: textField().notNull(),
682
775
  },
683
- });
776
+ {
777
+ navigationPaths: ["contacts"], // Valid navigation targets
778
+ },
779
+ );
684
780
 
685
781
  // Use with your database
686
782
  const db = connection.database("MyDB", {
687
- occurrences: occurrences,
783
+ occurrences: [contacts, users],
688
784
  });
689
785
  ```
690
786
 
691
- The `buildOccurrences` function accepts an object with:
787
+ The `navigationPaths` option:
692
788
 
693
- - `occurrences` - Array of TableOccurrences to build
694
- - `navigation` - Optional object mapping TO names to arrays of navigation targets
695
-
696
- It returns a tuple in the same order as the input array, with full autocomplete for navigation target names. Self-navigation is prevented at the type level.
697
-
698
- - Handles circular references automatically
699
- - Returns fully typed `TableOccurrence` instances with resolved navigation
789
+ - Specifies which table occurrences can be navigated to from this table
790
+ - Enables runtime validation when using `expand()` or `navigate()`
791
+ - Throws descriptive errors if you try to navigate to an invalid path
700
792
 
701
793
  ### Navigating Between Tables
702
794
 
703
795
  Navigate to related records:
704
796
 
705
797
  ```typescript
706
- // Navigate from a specific record
798
+ // Navigate from a specific record (using column references)
707
799
  const result = await db
708
- .from("contacts")
800
+ .from(contacts)
709
801
  .get("contact-123")
710
- .navigate("users")
711
- .select("username", "email")
802
+ .navigate(users)
803
+ .select({
804
+ username: users.username,
805
+ email: users.email,
806
+ })
712
807
  .execute();
713
808
 
714
809
  // Navigate without specifying a record first
715
- const result = await db.from("contacts").navigate("users").list().execute();
810
+ const result = await db.from(contacts).navigate(users).list().execute();
716
811
 
717
- // You can navigate to arbitrary tables not in your schema
812
+ // Using legacy API with string field names
718
813
  const result = await db
719
- .from("contacts")
720
- .navigate("some_other_table")
721
- .list()
814
+ .from(contacts)
815
+ .get("contact-123")
816
+ .navigate(users)
817
+ .select({ username: users.username, email: users.email })
722
818
  .execute();
723
819
  ```
724
820
 
725
821
  ### Expanding Related Records
726
822
 
727
- Use `expand()` to include related records in your query results:
823
+ Use `expand()` to include related records in your query results. The library validates that the target table is in the source table's `navigationPaths`:
728
824
 
729
825
  ```typescript
730
826
  // Simple expand
731
- const result = await db.from("contacts").list().expand("users").execute();
827
+ const result = await db.from(contacts).list().expand(users).execute();
732
828
 
733
- // Expand with field selection
829
+ // Expand with field selection (using column references)
734
830
  const result = await db
735
- .from("contacts")
831
+ .from(contacts)
736
832
  .list()
737
- .expand("users", (b) => b.select("username", "email"))
833
+ .expand(users, (b) =>
834
+ b.select({
835
+ username: users.username,
836
+ email: users.email,
837
+ }),
838
+ )
738
839
  .execute();
739
840
 
740
- // Expand with filtering
841
+ // Expand with filtering (using new ORM API)
842
+ import { eq } from "@proofkit/fmodata";
843
+
741
844
  const result = await db
742
- .from("contacts")
845
+ .from(contacts)
743
846
  .list()
744
- .expand("users", (b) => b.filter({ active: true }))
847
+ .expand(users, (b) => b.where(eq(users.active, true)))
745
848
  .execute();
746
849
 
747
850
  // Multiple expands
748
851
  const result = await db
749
- .from("contacts")
852
+ .from(contacts)
750
853
  .list()
751
- .expand("users", (b) => b.select("username"))
752
- .expand("orders", (b) => b.select("total").top(5))
854
+ .expand(users, (b) => b.select({ username: users.username }))
855
+ .expand(orders, (b) => b.select({ total: orders.total }).top(5))
753
856
  .execute();
754
857
 
755
858
  // Nested expands
756
859
  const result = await db
757
- .from("contacts")
860
+ .from(contacts)
758
861
  .list()
759
- .expand("users", (usersBuilder) =>
862
+ .expand(users, (usersBuilder) =>
760
863
  usersBuilder
761
- .select("username", "email")
762
- .expand("customer", (customerBuilder) =>
763
- customerBuilder.select("name", "tier"),
864
+ .select({
865
+ username: users.username,
866
+ email: users.email,
867
+ })
868
+ .expand(customers, (customerBuilder) =>
869
+ customerBuilder.select({
870
+ name: customers.name,
871
+ tier: customers.tier,
872
+ }),
764
873
  ),
765
874
  )
766
875
  .execute();
767
876
 
768
877
  // Complex expand with multiple options
769
878
  const result = await db
770
- .from("contacts")
879
+ .from(contacts)
771
880
  .list()
772
- .expand("users", (b) =>
881
+ .expand(users, (b) =>
773
882
  b
774
- .select("username", "email")
775
- .filter({ active: true })
776
- .orderBy("username")
883
+ .select({
884
+ username: users.username,
885
+ email: users.email,
886
+ })
887
+ .where(eq(users.active, true))
888
+ .orderBy(asc(users.username))
777
889
  .top(10)
778
- .expand("customer", (nested) => nested.select("name")),
890
+ .expand(customers, (nested) => nested.select({ name: customers.name })),
779
891
  )
780
892
  .execute();
781
893
  ```
@@ -831,25 +943,55 @@ console.log(result.result.recordId);
831
943
 
832
944
  Batch operations allow you to execute multiple queries and operations together in a single request. All operations in a batch are executed atomically - they all succeed or all fail together. This is both more efficient (fewer network round-trips) and ensures data consistency across related operations.
833
945
 
946
+ ### Batch Result Structure
947
+
948
+ 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:
949
+
950
+ ```typescript
951
+ type BatchItemResult<T> = {
952
+ data: T | undefined;
953
+ error: FMODataErrorType | undefined;
954
+ status: number; // HTTP status code (0 for truncated operations)
955
+ };
956
+
957
+ type BatchResult<T extends readonly any[]> = {
958
+ results: { [K in keyof T]: BatchItemResult<T[K]> };
959
+ successCount: number;
960
+ errorCount: number;
961
+ truncated: boolean; // true if FileMaker stopped processing due to an error
962
+ firstErrorIndex: number | null; // Index of the first operation that failed
963
+ };
964
+ ```
965
+
834
966
  ### Basic Batch with Multiple Queries
835
967
 
836
968
  Execute multiple read operations in a single batch:
837
969
 
838
970
  ```typescript
839
971
  // Create query builders
840
- const contactsQuery = db.from("contacts").list().top(5);
841
- const usersQuery = db.from("users").list().top(5);
972
+ const contactsQuery = db.from(contacts).list().top(5);
973
+ const usersQuery = db.from(users).list().top(5);
842
974
 
843
975
  // Execute both queries in a single batch
844
976
  const result = await db.batch([contactsQuery, usersQuery]).execute();
845
977
 
846
- if (result.data) {
847
- // Result is a tuple matching the input builders
848
- const [contacts, users] = result.data;
978
+ // Access individual results
979
+ const [r1, r2] = result.results;
849
980
 
850
- console.log("Contacts:", contacts);
851
- console.log("Users:", users);
981
+ if (r1.error) {
982
+ console.error("Contacts query failed:", r1.error);
983
+ } else {
984
+ console.log("Contacts:", r1.data);
852
985
  }
986
+
987
+ if (r2.error) {
988
+ console.error("Users query failed:", r2.error);
989
+ } else {
990
+ console.log("Users:", r2.data);
991
+ }
992
+
993
+ // Check summary statistics
994
+ console.log(`Success: ${result.successCount}, Errors: ${result.errorCount}`);
853
995
  ```
854
996
 
855
997
  ### Mixed Operations (Reads and Writes)
@@ -858,22 +1000,73 @@ Combine queries, inserts, updates, and deletes in a single batch:
858
1000
 
859
1001
  ```typescript
860
1002
  // Mix different operation types
861
- const listQuery = db.from("contacts").list().top(10);
862
- const insertOp = db.from("contacts").insert({
1003
+ const listQuery = db.from(contacts).list().top(10);
1004
+ const insertOp = db.from(contacts).insert({
863
1005
  name: "John Doe",
864
1006
  email: "john@example.com",
865
1007
  });
866
- const updateOp = db.from("users").update({ active: true }).byId("user-123");
1008
+ const updateOp = db.from(users).update({ active: true }).byId("user-123");
867
1009
 
868
1010
  // All operations execute atomically
869
1011
  const result = await db.batch([listQuery, insertOp, updateOp]).execute();
870
1012
 
871
- if (result.data) {
872
- const [contactsList, insertResult, updateResult] = result.data;
1013
+ // Access individual results
1014
+ const [r1, r2, r3] = result.results;
873
1015
 
874
- console.log("Fetched contacts:", contactsList);
875
- console.log("Inserted contact:", insertResult);
876
- console.log("Updated user:", updateResult);
1016
+ if (r1.error) {
1017
+ console.error("List query failed:", r1.error);
1018
+ } else {
1019
+ console.log("Fetched contacts:", r1.data);
1020
+ }
1021
+
1022
+ if (r2.error) {
1023
+ console.error("Insert failed:", r2.error);
1024
+ } else {
1025
+ console.log("Inserted contact:", r2.data);
1026
+ }
1027
+
1028
+ if (r3.error) {
1029
+ console.error("Update failed:", r3.error);
1030
+ } else {
1031
+ console.log("Updated user:", r3.data);
1032
+ }
1033
+ ```
1034
+
1035
+ ### Handling Errors in Batches
1036
+
1037
+ 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`:
1038
+
1039
+ ```typescript
1040
+ import { BatchTruncatedError, isBatchTruncatedError } from "@proofkit/fmodata";
1041
+
1042
+ const result = await db.batch([query1, query2, query3]).execute();
1043
+
1044
+ const [r1, r2, r3] = result.results;
1045
+
1046
+ // First operation succeeded
1047
+ if (r1.error) {
1048
+ console.error("First query failed:", r1.error);
1049
+ } else {
1050
+ console.log("First query succeeded:", r1.data);
1051
+ }
1052
+
1053
+ // Second operation failed
1054
+ if (r2.error) {
1055
+ console.error("Second query failed:", r2.error);
1056
+ console.log("HTTP Status:", r2.status); // e.g., 404
1057
+ }
1058
+
1059
+ // Third operation was never executed (truncated)
1060
+ if (r3.error && isBatchTruncatedError(r3.error)) {
1061
+ console.log("Third operation was not executed");
1062
+ console.log(`Failed at operation ${r3.error.failedAtIndex}`);
1063
+ console.log(`This operation index: ${r3.error.operationIndex}`);
1064
+ console.log("Status:", r3.status); // 0 (never executed)
1065
+ }
1066
+
1067
+ // Check if batch was truncated
1068
+ if (result.truncated) {
1069
+ console.log(`Batch stopped early at index ${result.firstErrorIndex}`);
877
1070
  }
878
1071
  ```
879
1072
 
@@ -884,18 +1077,31 @@ Batch operations are transactional for write operations (inserts, updates, delet
884
1077
  ```typescript
885
1078
  const result = await db
886
1079
  .batch([
887
- db.from("users").insert({ username: "alice", email: "alice@example.com" }),
888
- db.from("users").insert({ username: "bob", email: "bob@example.com" }),
889
- db.from("users").insert({ username: "charlie", email: "invalid" }), // This fails
1080
+ db.from(users).insert({ username: "alice", email: "alice@example.com" }),
1081
+ db.from(users).insert({ username: "bob", email: "bob@example.com" }),
1082
+ db.from(users).insert({ username: "charlie", email: "invalid" }), // This fails
890
1083
  ])
891
1084
  .execute();
892
1085
 
893
- if (result.error) {
1086
+ // Check individual results
1087
+ const [r1, r2, r3] = result.results;
1088
+
1089
+ if (r1.error || r2.error || r3.error) {
894
1090
  // All three inserts are rolled back - no users were created
895
- console.error("Batch failed:", result.error);
1091
+ console.error("Batch had errors:");
1092
+ if (r1.error) console.error("Operation 1:", r1.error);
1093
+ if (r2.error) console.error("Operation 2:", r2.error);
1094
+ if (r3.error) console.error("Operation 3:", r3.error);
896
1095
  }
897
1096
  ```
898
1097
 
1098
+ ### Important Notes
1099
+
1100
+ - **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`.
1101
+ - **Insert operations in batches**: FileMaker ignores `Prefer: return=representation` in batch requests. Insert operations return `{}` or `{ ROWID?: number }` instead of the full created record.
1102
+ - **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).
1103
+ - **Summary statistics**: Use `result.successCount`, `result.errorCount`, `result.truncated`, and `result.firstErrorIndex` for quick batch status checks.
1104
+
899
1105
  **Note:** Batch operations automatically group write operations (POST, PATCH, DELETE) into changesets for transactional behavior, while read operations (GET) are executed individually within the batch.
900
1106
 
901
1107
  ## Schema Management
@@ -1157,94 +1363,50 @@ await db.schema.createIndex("users", "email");
1157
1363
 
1158
1364
  ## Advanced Features
1159
1365
 
1160
- ### Type Safety
1161
-
1162
- The library provides full TypeScript type inference:
1163
-
1164
- ```typescript
1165
- const usersBase = defineBaseTable({
1166
- schema: {
1167
- id: z.string(),
1168
- username: z.string(),
1169
- email: z.string(),
1170
- },
1171
- idField: "id",
1172
- });
1173
-
1174
- const usersTO = defineTableOccurrence({
1175
- name: "users",
1176
- baseTable: usersBase,
1177
- });
1178
-
1179
- const db = connection.database("MyDB", {
1180
- occurrences: [usersTO],
1181
- });
1182
-
1183
- // TypeScript knows these are valid field names
1184
- db.from("users").list().select("username", "email");
1185
-
1186
- // TypeScript error: "invalid" is not a field name
1187
- db.from("users").list().select("invalid"); // TS Error
1188
-
1189
- // Type-safe filters
1190
- db.from("users")
1191
- .list()
1192
- .filter({ username: { eq: "john" } }); // ✓
1193
- db.from("users")
1194
- .list()
1195
- .filter({ invalid: { eq: "john" } }); // TS Error
1196
- ```
1197
-
1198
1366
  ### Required and Read-Only Fields
1199
1367
 
1200
- The library automatically infers which fields are required based on whether their validator allows `null` or `undefined`:
1368
+ The library automatically infers which fields are required based on field builder configuration:
1201
1369
 
1202
1370
  ```typescript
1203
- const usersBase = defineBaseTable({
1204
- schema: {
1205
- id: z.string(), // Auto-required, auto-readOnly (idField)
1206
- username: z.string(), // Auto-required (not nullable)
1207
- email: z.string(), // Auto-required (not nullable)
1208
- status: z.string().nullable(), // Optional (nullable)
1209
- createdAt: z.string(), // Read-only system field
1210
- updatedAt: z.string().nullable(), // Optional
1211
- },
1212
- idField: "id", // Automatically excluded from insert/update
1213
- required: ["status"], // Make status required despite being nullable
1214
- readOnly: ["createdAt"], // Exclude createdAt from insert/update
1371
+ const users = fmTableOccurrence("users", {
1372
+ id: textField().primaryKey(), // Auto-required, auto-readOnly (primaryKey)
1373
+ username: textField().notNull(), // Auto-required (notNull)
1374
+ email: textField().notNull(), // Auto-required (notNull)
1375
+ status: textField(), // Optional (nullable by default)
1376
+ createdAt: timestampField().readOnly(), // Read-only system field
1377
+ updatedAt: timestampField(), // Optional (nullable)
1215
1378
  });
1216
1379
 
1217
- // Insert: username, email, and status are required
1218
- // Insert: id and createdAt are excluded (cannot be provided)
1219
- db.from("users").insert({
1380
+ // Insert: username and email are required
1381
+ // Insert: id and createdAt are excluded (cannot be provided - read-only)
1382
+ db.from(users).insert({
1220
1383
  username: "john",
1221
1384
  email: "john@example.com",
1222
- status: "active", // Required due to 'required' array
1385
+ status: "active", // Optional
1223
1386
  updatedAt: new Date().toISOString(), // Optional
1224
1387
  });
1225
1388
 
1226
1389
  // Update: all fields are optional except id and createdAt are excluded
1227
- db.from("users")
1390
+ db.from(users)
1228
1391
  .update({
1229
1392
  status: "active", // Optional
1230
- // id and createdAt cannot be modified
1393
+ // id and createdAt cannot be modified (read-only)
1231
1394
  })
1232
1395
  .byId("user-123");
1233
1396
  ```
1234
1397
 
1235
1398
  **Key Features:**
1236
1399
 
1237
- - **Auto-inference:** Non-nullable fields are automatically required for insert
1238
- - **Additional requirements:** Use `required` to make nullable fields required for new records
1239
- - **Read-only fields:** Use `readOnly` to exclude fields from insert/update (e.g., timestamps)
1240
- - **Automatic ID exclusion:** The `idField` is always read-only without needing to specify it
1400
+ - **Auto-inference:** Fields with `.notNull()` are automatically required for insert
1401
+ - **Primary keys:** Fields with `.primaryKey()` are automatically read-only
1402
+ - **Read-only fields:** Use `.readOnly()` to exclude fields from insert/update (e.g., timestamps, calculated fields)
1241
1403
  - **Update flexibility:** All fields are optional for updates (except read-only fields)
1242
1404
 
1243
1405
  ### Prefer: fmodata.entity-ids
1244
1406
 
1245
1407
  This library supports using FileMaker's internal field identifiers (FMFID) and table occurrence identifiers (FMTID) instead of names. This protects your integration from both field and table occurrence name changes.
1246
1408
 
1247
- To enable this feature, simply define your schema with entity IDs using the `defineBaseTable` and `defineTableOccurrence` functions. Behind the scenes, the library will transform your request and the response back to the names you specify in these schemas. This is an all-or-nothing feature. For it to work properly, you must define all table occurrences passed to a `Database` with entity IDs (both `fmfIds` on the base table and `fmtId` on the table occurrence).
1409
+ 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).
1248
1410
 
1249
1411
  _Note for OttoFMS proxy: This feature requires version 4.14 or later of OttoFMS_
1250
1412
 
@@ -1253,32 +1415,25 @@ How do I find these ids? They can be found in the XML version of the `$metadata`
1253
1415
  #### Basic Usage
1254
1416
 
1255
1417
  ```typescript
1256
- import { defineBaseTable, defineTableOccurrence } from "@proofkit/fmodata";
1257
- import { z } from "zod/v4";
1418
+ import {
1419
+ fmTableOccurrence,
1420
+ textField,
1421
+ timestampField,
1422
+ } from "@proofkit/fmodata";
1258
1423
 
1259
- // Define a base table with FileMaker field IDs
1260
- const usersBase = defineBaseTable({
1261
- schema: {
1262
- id: z.string(),
1263
- username: z.string(),
1264
- email: z.string().nullable(),
1265
- createdAt: z.string(),
1424
+ // Define a table with FileMaker field IDs and table occurrence ID
1425
+ const users = fmTableOccurrence(
1426
+ "users",
1427
+ {
1428
+ id: textField().primaryKey().entityId("FMFID:12039485"),
1429
+ username: textField().notNull().entityId("FMFID:34323433"),
1430
+ email: textField().entityId("FMFID:12232424"),
1431
+ createdAt: timestampField().readOnly().entityId("FMFID:43234355"),
1266
1432
  },
1267
- idField: "id",
1268
- fmfIds: {
1269
- id: "FMFID:12039485",
1270
- username: "FMFID:34323433",
1271
- email: "FMFID:12232424",
1272
- createdAt: "FMFID:43234355",
1433
+ {
1434
+ entityId: "FMTID:12432533", // FileMaker table occurrence ID
1273
1435
  },
1274
- });
1275
-
1276
- // Create a table occurrence with a FileMaker table occurrence ID
1277
- const usersTO = defineTableOccurrence({
1278
- name: "users",
1279
- baseTable: usersBase,
1280
- fmtId: "FMTID:12432533",
1281
- });
1436
+ );
1282
1437
  ```
1283
1438
 
1284
1439
  ### Error Handling
@@ -1288,7 +1443,7 @@ All operations return a `Result` type with either `data` or `error`. The library
1288
1443
  #### Basic Error Checking
1289
1444
 
1290
1445
  ```typescript
1291
- const result = await db.from("users").list().execute();
1446
+ const result = await db.from(users).list().execute();
1292
1447
 
1293
1448
  if (result.error) {
1294
1449
  console.error("Query failed:", result.error.message);
@@ -1307,7 +1462,7 @@ Handle HTTP status codes (4xx, 5xx) with the `HTTPError` class:
1307
1462
  ```typescript
1308
1463
  import { HTTPError, isHTTPError } from "@proofkit/fmodata";
1309
1464
 
1310
- const result = await db.from("users").list().execute();
1465
+ const result = await db.from(users).list().execute();
1311
1466
 
1312
1467
  if (result.error) {
1313
1468
  if (isHTTPError(result.error)) {
@@ -1344,7 +1499,7 @@ import {
1344
1499
  CircuitOpenError,
1345
1500
  } from "@proofkit/fmodata";
1346
1501
 
1347
- const result = await db.from("users").list().execute();
1502
+ const result = await db.from(users).list().execute();
1348
1503
 
1349
1504
  if (result.error) {
1350
1505
  if (result.error instanceof TimeoutError) {
@@ -1370,7 +1525,7 @@ When schema validation fails, you get a `ValidationError` with rich context:
1370
1525
  ```typescript
1371
1526
  import { ValidationError, isValidationError } from "@proofkit/fmodata";
1372
1527
 
1373
- const result = await db.from("users").list().execute();
1528
+ const result = await db.from(users).list().execute();
1374
1529
 
1375
1530
  if (result.error) {
1376
1531
  if (isValidationError(result.error)) {
@@ -1389,7 +1544,7 @@ The library uses [Standard Schema](https://github.com/standard-schema/standard-s
1389
1544
  ```typescript
1390
1545
  import { ValidationError } from "@proofkit/fmodata";
1391
1546
 
1392
- const result = await db.from("users").list().execute();
1547
+ const result = await db.from(users).list().execute();
1393
1548
 
1394
1549
  if (result.error instanceof ValidationError) {
1395
1550
  // The cause property (ES2022 Error.cause) contains the Standard Schema issues array
@@ -1442,7 +1597,7 @@ Handle OData-specific protocol errors:
1442
1597
  ```typescript
1443
1598
  import { ODataError, isODataError } from "@proofkit/fmodata";
1444
1599
 
1445
- const result = await db.from("users").list().execute();
1600
+ const result = await db.from(users).list().execute();
1446
1601
 
1447
1602
  if (result.error) {
1448
1603
  if (isODataError(result.error)) {
@@ -1465,7 +1620,7 @@ import {
1465
1620
  NetworkError,
1466
1621
  } from "@proofkit/fmodata";
1467
1622
 
1468
- const result = await db.from("users").list().execute();
1623
+ const result = await db.from(users).list().execute();
1469
1624
 
1470
1625
  if (result.error) {
1471
1626
  if (result.error instanceof TimeoutError) {
@@ -1487,7 +1642,7 @@ if (result.error) {
1487
1642
  **Pattern 2: Using kind property (for exhaustive matching):**
1488
1643
 
1489
1644
  ```typescript
1490
- const result = await db.from("users").list().execute();
1645
+ const result = await db.from(users).list().execute();
1491
1646
 
1492
1647
  if (result.error) {
1493
1648
  switch (result.error.kind) {