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

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 +655 -453
  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 +126 -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 +34 -29
  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 +286 -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
@@ -1,62 +1,64 @@
1
+ import type { FFetchOptions } from "@fetchkit/ffetch";
2
+ import { InvalidLocationHeaderError } from "../errors";
3
+ import type { FMTable } from "../orm/table";
4
+ import { getBaseTableConfig, getTableId as getTableIdHelper, getTableName, isUsingEntityIds } from "../orm/table";
5
+ import { transformFieldNamesToIds, transformResponseFields } from "../transform";
1
6
  import type {
2
- ExecutionContext,
7
+ ConditionallyWithODataAnnotations,
3
8
  ExecutableBuilder,
4
- Result,
5
- ODataRecordMetadata,
6
- InferSchemaType,
9
+ ExecuteMethodOptions,
7
10
  ExecuteOptions,
8
- ConditionallyWithODataAnnotations,
11
+ ExecutionContext,
12
+ Result,
9
13
  } from "../types";
10
- import type { TableOccurrence } from "./table-occurrence";
11
- import { validateSingleResponse } from "../validation";
12
- import { type FFetchOptions } from "@fetchkit/ffetch";
13
- import {
14
- transformFieldNamesToIds,
15
- transformTableName,
16
- transformResponseFields,
17
- getTableIdentifiers,
18
- } from "../transform";
19
- import { InvalidLocationHeaderError } from "../errors";
14
+ import { getAcceptHeader } from "../types";
15
+ import { validateAndTransformInput, validateSingleResponse } from "../validation";
16
+ import { parseErrorResponse } from "./error-parser";
17
+ import { safeJsonParse } from "./sanitize-json";
18
+
19
+ const ROWID_MATCH_REGEX = /ROWID=(\d+)/;
20
+ const PAREN_VALUE_REGEX = /\(['"]?([^'"]+)['"]?\)/;
20
21
 
21
- export type InsertOptions = {
22
+ export interface InsertOptions {
22
23
  return?: "minimal" | "representation";
23
- };
24
+ }
25
+
26
+ import type { InferSchemaOutputFromFMTable } from "../orm/table";
24
27
 
25
28
  export class InsertBuilder<
26
- T extends Record<string, any>,
27
- Occ extends TableOccurrence<any, any, any, any> | undefined = undefined,
29
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
30
+ Occ extends FMTable<any, any> | undefined = undefined,
28
31
  ReturnPreference extends "minimal" | "representation" = "representation",
29
32
  > implements
30
33
  ExecutableBuilder<
31
- ReturnPreference extends "minimal" ? { ROWID: number } : T
34
+ ReturnPreference extends "minimal" ? { ROWID: number } : InferSchemaOutputFromFMTable<NonNullable<Occ>>
32
35
  >
33
36
  {
34
- private occurrence?: Occ;
35
- private tableName: string;
36
- private databaseName: string;
37
- private context: ExecutionContext;
38
- private data: Partial<T>;
39
- private returnPreference: ReturnPreference;
37
+ private readonly table?: Occ;
38
+ private readonly databaseName: string;
39
+ private readonly context: ExecutionContext;
40
+ private readonly data: Partial<InferSchemaOutputFromFMTable<NonNullable<Occ>>>;
41
+ private readonly returnPreference: ReturnPreference;
40
42
 
41
- private databaseUseEntityIds: boolean;
43
+ private readonly databaseUseEntityIds: boolean;
44
+ private readonly databaseIncludeSpecialColumns: boolean;
42
45
 
43
46
  constructor(config: {
44
47
  occurrence?: Occ;
45
- tableName: string;
46
48
  databaseName: string;
47
49
  context: ExecutionContext;
48
- data: Partial<T>;
50
+ data: Partial<InferSchemaOutputFromFMTable<NonNullable<Occ>>>;
49
51
  returnPreference?: ReturnPreference;
50
52
  databaseUseEntityIds?: boolean;
53
+ databaseIncludeSpecialColumns?: boolean;
51
54
  }) {
52
- this.occurrence = config.occurrence;
53
- this.tableName = config.tableName;
55
+ this.table = config.occurrence;
54
56
  this.databaseName = config.databaseName;
55
57
  this.context = config.context;
56
58
  this.data = config.data;
57
- this.returnPreference = (config.returnPreference ||
58
- "representation") as ReturnPreference;
59
+ this.returnPreference = (config.returnPreference || "representation") as ReturnPreference;
59
60
  this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
61
+ this.databaseIncludeSpecialColumns = config.databaseIncludeSpecialColumns ?? false;
60
62
  }
61
63
 
62
64
  /**
@@ -72,23 +74,6 @@ export class InsertBuilder<
72
74
  };
73
75
  }
74
76
 
75
- /**
76
- * Helper to conditionally strip OData annotations based on options
77
- */
78
- private stripODataAnnotationsIfNeeded<T extends Record<string, any>>(
79
- data: T,
80
- options?: ExecuteOptions,
81
- ): T {
82
- // Only include annotations if explicitly requested
83
- if (options?.includeODataAnnotations === true) {
84
- return data;
85
- }
86
-
87
- // Strip OData annotations
88
- const { "@id": _id, "@editLink": _editLink, ...rest } = data;
89
- return rest as T;
90
- }
91
-
92
77
  /**
93
78
  * Parse ROWID from Location header
94
79
  * Expected formats:
@@ -97,23 +82,21 @@ export class InsertBuilder<
97
82
  */
98
83
  private parseLocationHeader(locationHeader: string | undefined): number {
99
84
  if (!locationHeader) {
100
- throw new InvalidLocationHeaderError(
101
- "Location header is required but was not provided",
102
- );
85
+ throw new InvalidLocationHeaderError("Location header is required but was not provided");
103
86
  }
104
87
 
105
88
  // Try to match ROWID=number pattern
106
- const rowidMatch = locationHeader.match(/ROWID=(\d+)/);
107
- if (rowidMatch && rowidMatch[1]) {
108
- return parseInt(rowidMatch[1], 10);
89
+ const rowidMatch = locationHeader.match(ROWID_MATCH_REGEX);
90
+ if (rowidMatch?.[1]) {
91
+ return Number.parseInt(rowidMatch[1], 10);
109
92
  }
110
93
 
111
94
  // Try to extract value from parentheses and parse as number
112
- const parenMatch = locationHeader.match(/\(['"]?([^'"]+)['"]?\)/);
113
- if (parenMatch && parenMatch[1]) {
95
+ const parenMatch = locationHeader.match(PAREN_VALUE_REGEX);
96
+ if (parenMatch?.[1]) {
114
97
  const value = parenMatch[1];
115
- const numValue = parseInt(value, 10);
116
- if (!isNaN(numValue)) {
98
+ const numValue = Number.parseInt(value, 10);
99
+ if (!Number.isNaN(numValue)) {
117
100
  return numValue;
118
101
  }
119
102
  }
@@ -129,65 +112,80 @@ export class InsertBuilder<
129
112
  * @param useEntityIds - Optional override for entity ID usage
130
113
  */
131
114
  private getTableId(useEntityIds?: boolean): string {
132
- if (!this.occurrence) {
133
- return this.tableName;
115
+ if (!this.table) {
116
+ throw new Error("Table occurrence is required");
134
117
  }
135
118
 
136
119
  const contextDefault = this.context._getUseEntityIds?.() ?? false;
137
120
  const shouldUseIds = useEntityIds ?? contextDefault;
138
121
 
139
122
  if (shouldUseIds) {
140
- const identifiers = getTableIdentifiers(this.occurrence);
141
- if (!identifiers.id) {
123
+ if (!isUsingEntityIds(this.table)) {
142
124
  throw new Error(
143
- `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`
125
+ `useEntityIds is true but table "${getTableName(this.table)}" does not have entity IDs configured`,
144
126
  );
145
127
  }
146
- return identifiers.id;
128
+ return getTableIdHelper(this.table);
147
129
  }
148
130
 
149
- return this.occurrence.getTableName();
131
+ return getTableName(this.table);
150
132
  }
151
133
 
152
134
  async execute<EO extends ExecuteOptions>(
153
- options?: RequestInit & FFetchOptions & EO,
135
+ options?: ExecuteMethodOptions<EO>,
154
136
  ): Promise<
155
137
  Result<
156
138
  ReturnPreference extends "minimal"
157
139
  ? { ROWID: number }
158
140
  : ConditionallyWithODataAnnotations<
159
- T,
141
+ InferSchemaOutputFromFMTable<NonNullable<Occ>>,
160
142
  EO["includeODataAnnotations"] extends true ? true : false
161
143
  >
162
144
  >
163
145
  > {
164
146
  // Merge database-level useEntityIds with per-request options
165
147
  const mergedOptions = this.mergeExecuteOptions(options);
166
-
148
+
167
149
  // Get table identifier with override support
168
150
  const tableId = this.getTableId(mergedOptions.useEntityIds);
169
151
  const url = `/${this.databaseName}/${tableId}`;
170
152
 
153
+ // Validate and transform input data using input validators (writeValidators)
154
+ let validatedData = this.data;
155
+ if (this.table) {
156
+ const baseTableConfig = getBaseTableConfig(this.table);
157
+ const inputSchema = baseTableConfig.inputSchema;
158
+
159
+ try {
160
+ validatedData = await validateAndTransformInput(this.data, inputSchema);
161
+ } catch (error) {
162
+ // If validation fails, return error immediately
163
+ return {
164
+ data: undefined,
165
+ error: error instanceof Error ? error : new Error(String(error)),
166
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
167
+ } as any;
168
+ }
169
+ }
170
+
171
171
  // Transform field names to FMFIDs if using entity IDs
172
172
  // Only transform if useEntityIds resolves to true (respects per-request override)
173
173
  const shouldUseIds = mergedOptions.useEntityIds ?? false;
174
-
175
- const transformedData = this.occurrence?.baseTable && shouldUseIds
176
- ? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
177
- : this.data;
174
+
175
+ const transformedData =
176
+ this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData;
178
177
 
179
178
  // Set Prefer header based on return preference
180
- const preferHeader =
181
- this.returnPreference === "minimal"
182
- ? "return=minimal"
183
- : "return=representation";
179
+ const preferHeader = this.returnPreference === "minimal" ? "return=minimal" : "return=representation";
184
180
 
185
181
  // Make POST request with JSON body
182
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API
186
183
  const result = await this.context._makeRequest<any>(url, {
187
184
  method: "POST",
188
185
  headers: {
189
186
  "Content-Type": "application/json",
190
187
  Prefer: preferHeader,
188
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for headers object
191
189
  ...((mergedOptions as any)?.headers || {}),
192
190
  },
193
191
  body: JSON.stringify(transformedData),
@@ -202,15 +200,17 @@ export class InsertBuilder<
202
200
  if (this.returnPreference === "minimal") {
203
201
  // The response should be empty (204 No Content)
204
202
  // _makeRequest will return { _location: string } when there's a Location header
203
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API
205
204
  const responseData = result.data as any;
206
205
 
207
- if (!responseData || !responseData._location) {
206
+ if (!responseData?._location) {
208
207
  throw new InvalidLocationHeaderError(
209
208
  "Location header is required when using return=minimal but was not found in response",
210
209
  );
211
210
  }
212
211
 
213
212
  const rowid = this.parseLocationHeader(responseData._location);
213
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
214
214
  return { data: { ROWID: rowid } as any, error: undefined };
215
215
  }
216
216
 
@@ -218,19 +218,30 @@ export class InsertBuilder<
218
218
 
219
219
  // Transform response field IDs back to names if using entity IDs
220
220
  // Only transform if useEntityIds resolves to true (respects per-request override)
221
- if (this.occurrence?.baseTable && shouldUseIds) {
221
+ if (this.table && shouldUseIds) {
222
222
  response = transformResponseFields(
223
223
  response,
224
- this.occurrence.baseTable,
224
+ this.table,
225
225
  undefined, // No expand configs for insert
226
226
  );
227
227
  }
228
228
 
229
- // Get schema from occurrence if available
230
- const schema = this.occurrence?.baseTable?.schema;
229
+ // Get schema from table if available, excluding container fields
230
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic schema shape from table configuration
231
+ let schema: Record<string, any> | undefined;
232
+ if (this.table) {
233
+ const baseTableConfig = getBaseTableConfig(this.table);
234
+ const containerFields = baseTableConfig.containerFields || [];
235
+
236
+ // Filter out container fields from schema
237
+ schema = { ...baseTableConfig.schema };
238
+ for (const containerField of containerFields) {
239
+ delete schema[containerField as string];
240
+ }
241
+ }
231
242
 
232
243
  // Validate the response (FileMaker returns the created record)
233
- const validation = await validateSingleResponse<T>(
244
+ const validation = await validateSingleResponse<InferSchemaOutputFromFMTable<NonNullable<Occ>>>(
234
245
  response,
235
246
  schema,
236
247
  undefined, // No selected fields for insert
@@ -250,23 +261,19 @@ export class InsertBuilder<
250
261
  };
251
262
  }
252
263
 
253
- // Strip OData annotations unless explicitly requested
254
- const finalData = this.stripODataAnnotationsIfNeeded(
255
- validation.data,
256
- options,
257
- );
258
-
259
- return { data: finalData as any, error: undefined };
264
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
265
+ return { data: validation.data as any, error: undefined };
260
266
  }
261
267
 
268
+ // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value
262
269
  getRequestConfig(): { method: string; url: string; body?: any } {
263
270
  // For batch operations, use database-level setting (no per-request override available here)
271
+ // Note: Input validation happens in execute() and processResponse() for batch operations
264
272
  const tableId = this.getTableId(this.databaseUseEntityIds);
265
273
 
266
274
  // Transform field names to FMFIDs if using entity IDs
267
- const transformedData = this.occurrence?.baseTable && this.databaseUseEntityIds
268
- ? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
269
- : this.data;
275
+ const transformedData =
276
+ this.table && this.databaseUseEntityIds ? transformFieldNamesToIds(this.data, this.table) : this.data;
270
277
 
271
278
  return {
272
279
  method: "POST",
@@ -275,21 +282,18 @@ export class InsertBuilder<
275
282
  };
276
283
  }
277
284
 
278
- toRequest(baseUrl: string): Request {
285
+ toRequest(baseUrl: string, options?: ExecuteOptions): Request {
279
286
  const config = this.getRequestConfig();
280
287
  const fullUrl = `${baseUrl}${config.url}`;
281
288
 
282
289
  // Set Prefer header based on return preference
283
- const preferHeader =
284
- this.returnPreference === "minimal"
285
- ? "return=minimal"
286
- : "return=representation";
290
+ const preferHeader = this.returnPreference === "minimal" ? "return=minimal" : "return=representation";
287
291
 
288
292
  return new Request(fullUrl, {
289
293
  method: config.method,
290
294
  headers: {
291
295
  "Content-Type": "application/json",
292
- Accept: "application/json",
296
+ Accept: getAcceptHeader(options?.includeODataAnnotations),
293
297
  Prefer: preferHeader,
294
298
  },
295
299
  body: config.body,
@@ -300,17 +304,24 @@ export class InsertBuilder<
300
304
  response: Response,
301
305
  options?: ExecuteOptions,
302
306
  ): Promise<
303
- Result<ReturnPreference extends "minimal" ? { ROWID: number } : T>
307
+ Result<ReturnPreference extends "minimal" ? { ROWID: number } : InferSchemaOutputFromFMTable<NonNullable<Occ>>>
304
308
  > {
309
+ // Check for error responses (important for batch operations)
310
+ if (!response.ok) {
311
+ const tableName = this.table ? getTableName(this.table) : "unknown";
312
+ const error = await parseErrorResponse(response, response.url || `/${this.databaseName}/${tableName}`);
313
+ return { data: undefined, error };
314
+ }
315
+
305
316
  // Handle 204 No Content (common in batch/changeset operations)
306
317
  // FileMaker uses return=minimal for changeset operations regardless of Prefer header
307
318
  if (response.status === 204) {
308
319
  // Check for Location header (for return=minimal)
309
320
  if (this.returnPreference === "minimal") {
310
- const locationHeader =
311
- response.headers.get("Location") || response.headers.get("location");
321
+ const locationHeader = response.headers.get("Location") || response.headers.get("location");
312
322
  if (locationHeader) {
313
323
  const rowid = this.parseLocationHeader(locationHeader);
324
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
314
325
  return { data: { ROWID: rowid } as any, error: undefined };
315
326
  }
316
327
  throw new InvalidLocationHeaderError(
@@ -322,6 +333,7 @@ export class InsertBuilder<
322
333
  // This is valid OData behavior for changeset operations
323
334
  // We return a success indicator but no actual data
324
335
  return {
336
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
325
337
  data: {} as any,
326
338
  error: undefined,
327
339
  };
@@ -334,13 +346,15 @@ export class InsertBuilder<
334
346
  );
335
347
  }
336
348
 
337
- let rawResponse;
349
+ // Use safeJsonParse to handle FileMaker's invalid JSON with unquoted ? values
350
+ let rawResponse: unknown;
338
351
  try {
339
- rawResponse = await response.json();
352
+ rawResponse = await safeJsonParse(response);
340
353
  } catch (err) {
341
354
  // If parsing fails with 204, handle it gracefully
342
355
  if (response.status === 204) {
343
356
  return {
357
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
344
358
  data: {} as any,
345
359
  error: undefined,
346
360
  };
@@ -351,28 +365,58 @@ export class InsertBuilder<
351
365
  name: "ResponseParseError",
352
366
  message: `Failed to parse response JSON: ${err instanceof Error ? err.message : "Unknown error"}`,
353
367
  timestamp: new Date(),
368
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for error object
354
369
  } as any,
355
370
  };
356
371
  }
357
372
 
373
+ // Validate and transform input data using input validators (writeValidators)
374
+ // This is needed for processResponse because it's called from batch operations
375
+ // where the data hasn't been validated yet
376
+ let _validatedData = this.data;
377
+ if (this.table) {
378
+ const baseTableConfig = getBaseTableConfig(this.table);
379
+ const inputSchema = baseTableConfig.inputSchema;
380
+ try {
381
+ _validatedData = await validateAndTransformInput(this.data, inputSchema);
382
+ } catch (error) {
383
+ return {
384
+ data: undefined,
385
+ error: error instanceof Error ? error : new Error(String(error)),
386
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
387
+ } as any;
388
+ }
389
+ }
390
+
358
391
  // Transform response field IDs back to names if using entity IDs
359
392
  // Only transform if useEntityIds resolves to true (respects per-request override)
360
393
  const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
361
-
394
+
362
395
  let transformedResponse = rawResponse;
363
- if (this.occurrence?.baseTable && shouldUseIds) {
396
+ if (this.table && shouldUseIds) {
364
397
  transformedResponse = transformResponseFields(
365
398
  rawResponse,
366
- this.occurrence.baseTable,
399
+ this.table,
367
400
  undefined, // No expand configs for insert
368
401
  );
369
402
  }
370
403
 
371
- // Get schema from occurrence if available
372
- const schema = this.occurrence?.baseTable?.schema;
404
+ // Get schema from table if available, excluding container fields
405
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic schema shape from table configuration
406
+ let schema: Record<string, any> | undefined;
407
+ if (this.table) {
408
+ const baseTableConfig = getBaseTableConfig(this.table);
409
+ const containerFields = baseTableConfig.containerFields || [];
410
+
411
+ // Filter out container fields from schema
412
+ schema = { ...baseTableConfig.schema };
413
+ for (const containerField of containerFields) {
414
+ delete schema[containerField as string];
415
+ }
416
+ }
373
417
 
374
418
  // Validate the response (FileMaker returns the created record)
375
- const validation = await validateSingleResponse<T>(
419
+ const validation = await validateSingleResponse<InferSchemaOutputFromFMTable<NonNullable<Occ>>>(
376
420
  transformedResponse,
377
421
  schema,
378
422
  undefined, // No selected fields for insert
@@ -392,12 +436,7 @@ export class InsertBuilder<
392
436
  };
393
437
  }
394
438
 
395
- // Strip OData annotations unless explicitly requested
396
- const finalData = this.stripODataAnnotationsIfNeeded(
397
- validation.data,
398
- options,
399
- );
400
-
401
- return { data: finalData as any, error: undefined };
439
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
440
+ return { data: validation.data as any, error: undefined };
402
441
  }
403
442
  }
@@ -0,0 +1,160 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import buildQuery, { type QueryOptions } from "odata-query";
3
+ import { FMTable } from "../../orm/table";
4
+ import type { ExpandValidationConfig } from "../../validation";
5
+ import { formatSelectFields } from "../builders/select-utils";
6
+
7
+ const FILTER_QUERY_REGEX = /\$filter=([^&]+)/;
8
+
9
+ /**
10
+ * Internal type for expand configuration
11
+ */
12
+ export interface ExpandConfig {
13
+ relation: string;
14
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryOptions configuration
15
+ options?: Partial<QueryOptions<any>>;
16
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
17
+ targetTable?: FMTable<any, any>;
18
+ }
19
+
20
+ /**
21
+ * Builds OData expand query strings and validation configs.
22
+ * Handles nested expands recursively and transforms relation names to FMTIDs
23
+ * when using entity IDs.
24
+ */
25
+ export class ExpandBuilder {
26
+ private readonly useEntityIds: boolean;
27
+
28
+ constructor(useEntityIds: boolean) {
29
+ this.useEntityIds = useEntityIds;
30
+ }
31
+
32
+ /**
33
+ * Builds OData expand query string from expand configurations.
34
+ * Handles nested expands recursively.
35
+ * Transforms relation names to FMTIDs if using entity IDs.
36
+ */
37
+ buildExpandString(configs: ExpandConfig[]): string {
38
+ if (configs.length === 0) {
39
+ return "";
40
+ }
41
+
42
+ return configs.map((config) => this.buildSingleExpand(config)).join(",");
43
+ }
44
+
45
+ /**
46
+ * Builds a single expand string with its options.
47
+ */
48
+ private buildSingleExpand(config: ExpandConfig): string {
49
+ // Get target table/occurrence from config (stored during expand call)
50
+ const targetTable = config.targetTable;
51
+
52
+ // When using entity IDs, use the target table's FMTID in the expand parameter
53
+ // FileMaker expects FMTID in $expand when Prefer header is set
54
+ // Only use FMTID if databaseUseEntityIds is enabled
55
+ let relationName = config.relation;
56
+ if (this.useEntityIds && targetTable && FMTable.Symbol.EntityId in targetTable) {
57
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for Symbol property access
58
+ const tableId = (targetTable as any)[FMTable.Symbol.EntityId] as `FMTID:${string}` | undefined;
59
+ if (tableId) {
60
+ relationName = tableId;
61
+ }
62
+ }
63
+
64
+ if (!config.options || Object.keys(config.options).length === 0) {
65
+ // Simple expand without options
66
+ return relationName;
67
+ }
68
+
69
+ // Build query options for this expand
70
+ const parts: string[] = [];
71
+
72
+ if (config.options.select) {
73
+ // Use shared formatSelectFields function for consistent id field quoting
74
+ const selectArray = Array.isArray(config.options.select)
75
+ ? config.options.select.map(String)
76
+ : [String(config.options.select)];
77
+ const selectFields = formatSelectFields(selectArray, targetTable, this.useEntityIds);
78
+ parts.push(`$select=${selectFields}`);
79
+ }
80
+
81
+ if (config.options.filter) {
82
+ // Filter should already be transformed by the nested builder
83
+ // Use odata-query to build filter string
84
+ const filterQuery = buildQuery({ filter: config.options.filter });
85
+ const filterMatch = filterQuery.match(FILTER_QUERY_REGEX);
86
+ if (filterMatch) {
87
+ parts.push(`$filter=${filterMatch[1]}`);
88
+ }
89
+ }
90
+
91
+ if (config.options.orderBy) {
92
+ // OrderBy should already be transformed by the nested builder
93
+ const orderByValue = Array.isArray(config.options.orderBy)
94
+ ? config.options.orderBy.join(",")
95
+ : config.options.orderBy;
96
+ parts.push(`$orderby=${String(orderByValue)}`);
97
+ }
98
+
99
+ if (config.options.top !== undefined) {
100
+ parts.push(`$top=${config.options.top}`);
101
+ }
102
+
103
+ if (config.options.skip !== undefined) {
104
+ parts.push(`$skip=${config.options.skip}`);
105
+ }
106
+
107
+ // Handle nested expands (from expand configs)
108
+ if (config.options.expand && typeof config.options.expand === "string") {
109
+ // If expand is a string, it's already been built
110
+ parts.push(`$expand=${config.options.expand}`);
111
+ }
112
+
113
+ if (parts.length === 0) {
114
+ return relationName;
115
+ }
116
+
117
+ return `${relationName}(${parts.join(";")})`;
118
+ }
119
+
120
+ /**
121
+ * Builds expand validation configs from internal expand configurations.
122
+ * These are used to validate expanded navigation properties.
123
+ */
124
+ buildValidationConfigs(configs: ExpandConfig[]): ExpandValidationConfig[] {
125
+ return configs.map((config) => {
126
+ // Get target table/occurrence from config (stored during expand call)
127
+ const targetTable = config.targetTable;
128
+
129
+ // Extract schema from target table/occurrence
130
+ let targetSchema: Record<string, StandardSchemaV1> | undefined;
131
+ if (targetTable) {
132
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for Symbol property access
133
+ const tableSchema = (targetTable as any)[FMTable.Symbol.Schema];
134
+ if (tableSchema) {
135
+ const zodSchema = tableSchema["~standard"]?.schema;
136
+ if (zodSchema && typeof zodSchema === "object" && "shape" in zodSchema) {
137
+ targetSchema = zodSchema.shape as Record<string, StandardSchemaV1>;
138
+ }
139
+ }
140
+ }
141
+
142
+ // Extract selected fields from options
143
+ let selectedFields: string[] | undefined;
144
+ if (config.options?.select) {
145
+ selectedFields = Array.isArray(config.options.select)
146
+ ? config.options.select.map((f) => String(f))
147
+ : [String(config.options.select)];
148
+ }
149
+
150
+ return {
151
+ relation: config.relation,
152
+ targetSchema,
153
+ targetTable,
154
+ table: targetTable, // For transformation
155
+ selectedFields,
156
+ nestedExpands: undefined, // TODO: Handle nested expands if needed
157
+ };
158
+ });
159
+ }
160
+ }
@@ -0,0 +1,14 @@
1
+ /** biome-ignore-all lint/performance/noBarrelFile: Re-exporting QueryBuilder and types */
2
+
3
+ // Re-export QueryBuilder as the main export
4
+
5
+ // Export ExpandConfig from expand-builder
6
+ export type { ExpandConfig } from "./expand-builder";
7
+
8
+ // Export types
9
+ export type {
10
+ ExpandedRelations,
11
+ QueryReturnType,
12
+ TypeSafeOrderBy,
13
+ } from "./query-builder";
14
+ export { QueryBuilder } from "./query-builder";