@proofkit/fmodata 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (79) hide show
  1. package/README.md +760 -69
  2. package/dist/esm/client/base-table.d.ts +120 -5
  3. package/dist/esm/client/base-table.js +43 -5
  4. package/dist/esm/client/base-table.js.map +1 -1
  5. package/dist/esm/client/batch-builder.d.ts +54 -0
  6. package/dist/esm/client/batch-builder.js +179 -0
  7. package/dist/esm/client/batch-builder.js.map +1 -0
  8. package/dist/esm/client/batch-request.d.ts +61 -0
  9. package/dist/esm/client/batch-request.js +252 -0
  10. package/dist/esm/client/batch-request.js.map +1 -0
  11. package/dist/esm/client/build-occurrences.d.ts +74 -0
  12. package/dist/esm/client/build-occurrences.js +31 -0
  13. package/dist/esm/client/build-occurrences.js.map +1 -0
  14. package/dist/esm/client/database.d.ts +55 -6
  15. package/dist/esm/client/database.js +118 -15
  16. package/dist/esm/client/database.js.map +1 -1
  17. package/dist/esm/client/delete-builder.d.ts +21 -2
  18. package/dist/esm/client/delete-builder.js +96 -32
  19. package/dist/esm/client/delete-builder.js.map +1 -1
  20. package/dist/esm/client/entity-set.d.ts +26 -12
  21. package/dist/esm/client/entity-set.js +43 -12
  22. package/dist/esm/client/entity-set.js.map +1 -1
  23. package/dist/esm/client/filemaker-odata.d.ts +23 -4
  24. package/dist/esm/client/filemaker-odata.js +124 -29
  25. package/dist/esm/client/filemaker-odata.js.map +1 -1
  26. package/dist/esm/client/insert-builder.d.ts +38 -3
  27. package/dist/esm/client/insert-builder.js +231 -34
  28. package/dist/esm/client/insert-builder.js.map +1 -1
  29. package/dist/esm/client/query-builder.d.ts +28 -7
  30. package/dist/esm/client/query-builder.js +470 -212
  31. package/dist/esm/client/query-builder.js.map +1 -1
  32. package/dist/esm/client/record-builder.d.ts +96 -10
  33. package/dist/esm/client/record-builder.js +378 -39
  34. package/dist/esm/client/record-builder.js.map +1 -1
  35. package/dist/esm/client/response-processor.d.ts +38 -0
  36. package/dist/esm/client/schema-manager.d.ts +57 -0
  37. package/dist/esm/client/schema-manager.js +132 -0
  38. package/dist/esm/client/schema-manager.js.map +1 -0
  39. package/dist/esm/client/table-occurrence.d.ts +69 -8
  40. package/dist/esm/client/table-occurrence.js +35 -24
  41. package/dist/esm/client/table-occurrence.js.map +1 -1
  42. package/dist/esm/client/update-builder.d.ts +34 -11
  43. package/dist/esm/client/update-builder.js +135 -31
  44. package/dist/esm/client/update-builder.js.map +1 -1
  45. package/dist/esm/errors.d.ts +73 -0
  46. package/dist/esm/errors.js +148 -0
  47. package/dist/esm/errors.js.map +1 -0
  48. package/dist/esm/index.d.ts +13 -3
  49. package/dist/esm/index.js +29 -7
  50. package/dist/esm/index.js.map +1 -1
  51. package/dist/esm/transform.d.ts +65 -0
  52. package/dist/esm/transform.js +114 -0
  53. package/dist/esm/transform.js.map +1 -0
  54. package/dist/esm/types.d.ts +89 -5
  55. package/dist/esm/validation.d.ts +6 -3
  56. package/dist/esm/validation.js +104 -33
  57. package/dist/esm/validation.js.map +1 -1
  58. package/package.json +10 -1
  59. package/src/client/base-table.ts +161 -8
  60. package/src/client/batch-builder.ts +265 -0
  61. package/src/client/batch-request.ts +485 -0
  62. package/src/client/build-occurrences.ts +155 -0
  63. package/src/client/database.ts +175 -18
  64. package/src/client/delete-builder.ts +149 -48
  65. package/src/client/entity-set.ts +134 -28
  66. package/src/client/filemaker-odata.ts +179 -35
  67. package/src/client/insert-builder.ts +350 -40
  68. package/src/client/query-builder.ts +632 -244
  69. package/src/client/query-builder.ts.bak +1457 -0
  70. package/src/client/record-builder.ts +692 -68
  71. package/src/client/response-processor.ts +103 -0
  72. package/src/client/schema-manager.ts +246 -0
  73. package/src/client/table-occurrence.ts +107 -51
  74. package/src/client/update-builder.ts +235 -49
  75. package/src/errors.ts +217 -0
  76. package/src/index.ts +63 -6
  77. package/src/transform.ts +249 -0
  78. package/src/types.ts +201 -35
  79. package/src/validation.ts +120 -36
@@ -3,11 +3,17 @@ import type {
3
3
  ExecutableBuilder,
4
4
  Result,
5
5
  WithSystemFields,
6
+ ExecuteOptions,
6
7
  } from "../types";
7
8
  import type { TableOccurrence } from "./table-occurrence";
8
9
  import type { BaseTable } from "./base-table";
9
10
  import { QueryBuilder } from "./query-builder";
10
11
  import { type FFetchOptions } from "@fetchkit/ffetch";
12
+ import {
13
+ transformFieldNamesToIds,
14
+ transformTableName,
15
+ getTableIdentifiers,
16
+ } from "../transform";
11
17
 
12
18
  /**
13
19
  * Initial update builder returned from EntitySet.update(data)
@@ -16,12 +22,16 @@ import { type FFetchOptions } from "@fetchkit/ffetch";
16
22
  export class UpdateBuilder<
17
23
  T extends Record<string, any>,
18
24
  BT extends BaseTable<any, any, any, any>,
25
+ ReturnPreference extends "minimal" | "representation" = "minimal",
19
26
  > {
20
27
  private tableName: string;
21
28
  private databaseName: string;
22
29
  private context: ExecutionContext;
23
30
  private occurrence?: TableOccurrence<any, any, any, any>;
24
31
  private data: Partial<T>;
32
+ private returnPreference: ReturnPreference;
33
+
34
+ private databaseUseEntityIds: boolean;
25
35
 
26
36
  constructor(config: {
27
37
  occurrence?: TableOccurrence<any, any, any, any>;
@@ -29,20 +39,26 @@ export class UpdateBuilder<
29
39
  databaseName: string;
30
40
  context: ExecutionContext;
31
41
  data: Partial<T>;
42
+ returnPreference: ReturnPreference;
43
+ databaseUseEntityIds?: boolean;
32
44
  }) {
33
45
  this.occurrence = config.occurrence;
34
46
  this.tableName = config.tableName;
35
47
  this.databaseName = config.databaseName;
36
48
  this.context = config.context;
37
49
  this.data = config.data;
50
+ this.returnPreference = config.returnPreference;
51
+ this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
38
52
  }
39
53
 
40
54
  /**
41
55
  * Update a single record by ID
42
- * Returns the count of updated records (0 or 1)
56
+ * Returns updated count by default, or full record if returnFullRecord was set to true
43
57
  */
44
- byId(id: string | number): ExecutableUpdateBuilder<T, true> {
45
- return new ExecutableUpdateBuilder<T, true>({
58
+ byId(
59
+ id: string | number,
60
+ ): ExecutableUpdateBuilder<T, true, ReturnPreference> {
61
+ return new ExecutableUpdateBuilder<T, true, ReturnPreference>({
46
62
  occurrence: this.occurrence,
47
63
  tableName: this.tableName,
48
64
  databaseName: this.databaseName,
@@ -50,19 +66,21 @@ export class UpdateBuilder<
50
66
  data: this.data,
51
67
  mode: "byId",
52
68
  recordId: id,
69
+ returnPreference: this.returnPreference,
70
+ databaseUseEntityIds: this.databaseUseEntityIds,
53
71
  });
54
72
  }
55
73
 
56
74
  /**
57
75
  * Update records matching a filter query
58
- * Returns the count of updated records
76
+ * Returns updated count by default, or full record if returnFullRecord was set to true
59
77
  * @param fn Callback that receives a QueryBuilder for building the filter
60
78
  */
61
79
  where(
62
80
  fn: (
63
81
  q: QueryBuilder<WithSystemFields<T>>,
64
82
  ) => QueryBuilder<WithSystemFields<T>>,
65
- ): ExecutableUpdateBuilder<T, true> {
83
+ ): ExecutableUpdateBuilder<T, true, ReturnPreference> {
66
84
  // Create a QueryBuilder for the user to configure
67
85
  const queryBuilder = new QueryBuilder<
68
86
  WithSystemFields<T>,
@@ -80,7 +98,7 @@ export class UpdateBuilder<
80
98
  // Let the user configure it
81
99
  const configuredBuilder = fn(queryBuilder);
82
100
 
83
- return new ExecutableUpdateBuilder<T, true>({
101
+ return new ExecutableUpdateBuilder<T, true, ReturnPreference>({
84
102
  occurrence: this.occurrence,
85
103
  tableName: this.tableName,
86
104
  databaseName: this.databaseName,
@@ -88,6 +106,8 @@ export class UpdateBuilder<
88
106
  data: this.data,
89
107
  mode: "byFilter",
90
108
  queryBuilder: configuredBuilder,
109
+ returnPreference: this.returnPreference,
110
+ databaseUseEntityIds: this.databaseUseEntityIds,
91
111
  });
92
112
  }
93
113
  }
@@ -95,12 +115,16 @@ export class UpdateBuilder<
95
115
  /**
96
116
  * Executable update builder - has execute() method
97
117
  * Returned after calling .byId() or .where()
98
- * Both modes return the count of updated records
118
+ * Can return either updated count or full record based on returnFullRecord option
99
119
  */
100
120
  export class ExecutableUpdateBuilder<
101
121
  T extends Record<string, any>,
102
122
  IsByFilter extends boolean,
103
- > implements ExecutableBuilder<{ updatedCount: number }>
123
+ ReturnPreference extends "minimal" | "representation" = "minimal",
124
+ > implements
125
+ ExecutableBuilder<
126
+ ReturnPreference extends "minimal" ? { updatedCount: number } : T
127
+ >
104
128
  {
105
129
  private tableName: string;
106
130
  private databaseName: string;
@@ -110,6 +134,8 @@ export class ExecutableUpdateBuilder<
110
134
  private mode: "byId" | "byFilter";
111
135
  private recordId?: string | number;
112
136
  private queryBuilder?: QueryBuilder<any>;
137
+ private returnPreference: ReturnPreference;
138
+ private databaseUseEntityIds: boolean;
113
139
 
114
140
  constructor(config: {
115
141
  occurrence?: TableOccurrence<any, any, any, any>;
@@ -120,6 +146,8 @@ export class ExecutableUpdateBuilder<
120
146
  mode: "byId" | "byFilter";
121
147
  recordId?: string | number;
122
148
  queryBuilder?: QueryBuilder<any>;
149
+ returnPreference: ReturnPreference;
150
+ databaseUseEntityIds?: boolean;
123
151
  }) {
124
152
  this.occurrence = config.occurrence;
125
153
  this.tableName = config.tableName;
@@ -129,44 +157,126 @@ export class ExecutableUpdateBuilder<
129
157
  this.mode = config.mode;
130
158
  this.recordId = config.recordId;
131
159
  this.queryBuilder = config.queryBuilder;
160
+ this.returnPreference = config.returnPreference;
161
+ this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
162
+ }
163
+
164
+ /**
165
+ * Helper to merge database-level useEntityIds with per-request options
166
+ */
167
+ private mergeExecuteOptions(
168
+ options?: RequestInit & FFetchOptions & ExecuteOptions,
169
+ ): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
170
+ // If useEntityIds is not set in options, use the database-level setting
171
+ return {
172
+ ...options,
173
+ useEntityIds: options?.useEntityIds ?? this.databaseUseEntityIds,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
179
+ * @param useEntityIds - Optional override for entity ID usage
180
+ */
181
+ private getTableId(useEntityIds?: boolean): string {
182
+ if (!this.occurrence) {
183
+ return this.tableName;
184
+ }
185
+
186
+ const contextDefault = this.context._getUseEntityIds?.() ?? false;
187
+ const shouldUseIds = useEntityIds ?? contextDefault;
188
+
189
+ if (shouldUseIds) {
190
+ const identifiers = getTableIdentifiers(this.occurrence);
191
+ if (!identifiers.id) {
192
+ throw new Error(
193
+ `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`,
194
+ );
195
+ }
196
+ return identifiers.id;
197
+ }
198
+
199
+ return this.occurrence.getTableName();
132
200
  }
133
201
 
134
202
  async execute(
135
- options?: RequestInit & FFetchOptions,
136
- ): Promise<Result<{ updatedCount: number }>> {
137
- try {
138
- let url: string;
139
-
140
- if (this.mode === "byId") {
141
- // Update single record by ID: PATCH /{database}/{table}('id')
142
- url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
143
- } else {
144
- // Update by filter: PATCH /{database}/{table}?$filter=...
145
- if (!this.queryBuilder) {
146
- throw new Error("Query builder is required for filter-based update");
147
- }
148
-
149
- // Get the query string from the configured QueryBuilder
150
- const queryString = this.queryBuilder.getQueryString();
151
- // Remove the leading "/" from the query string as we'll build our own URL
152
- const queryParams = queryString.startsWith(`/${this.tableName}`)
203
+ options?: RequestInit & FFetchOptions & { useEntityIds?: boolean },
204
+ ): Promise<
205
+ Result<ReturnPreference extends "minimal" ? { updatedCount: number } : T>
206
+ > {
207
+ // Merge database-level useEntityIds with per-request options
208
+ const mergedOptions = this.mergeExecuteOptions(options);
209
+
210
+ // Get table identifier with override support
211
+ const tableId = this.getTableId(mergedOptions.useEntityIds);
212
+
213
+ // Transform field names to FMFIDs if using entity IDs
214
+ // Only transform if useEntityIds resolves to true (respects per-request override)
215
+ const shouldUseIds = mergedOptions.useEntityIds ?? false;
216
+
217
+ const transformedData =
218
+ this.occurrence?.baseTable && shouldUseIds
219
+ ? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
220
+ : this.data;
221
+
222
+ let url: string;
223
+
224
+ if (this.mode === "byId") {
225
+ // Update single record by ID: PATCH /{database}/{table}('id')
226
+ url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
227
+ } else {
228
+ // Update by filter: PATCH /{database}/{table}?$filter=...
229
+ if (!this.queryBuilder) {
230
+ throw new Error("Query builder is required for filter-based update");
231
+ }
232
+
233
+ // Get the query string from the configured QueryBuilder
234
+ const queryString = this.queryBuilder.getQueryString();
235
+ // The query string will have the tableId already transformed by QueryBuilder
236
+ // Remove the leading "/" and table name from the query string as we'll build our own URL
237
+ const queryParams = queryString.startsWith(`/${tableId}`)
238
+ ? queryString.slice(`/${tableId}`.length)
239
+ : queryString.startsWith(`/${this.tableName}`)
153
240
  ? queryString.slice(`/${this.tableName}`.length)
154
241
  : queryString;
155
242
 
156
- url = `/${this.databaseName}/${this.tableName}${queryParams}`;
157
- }
243
+ url = `/${this.databaseName}/${tableId}${queryParams}`;
244
+ }
245
+
246
+ // Set Prefer header based on returnPreference
247
+ const headers: Record<string, string> = {
248
+ "Content-Type": "application/json",
249
+ };
250
+
251
+ if (this.returnPreference === "representation") {
252
+ headers["Prefer"] = "return=representation";
253
+ }
158
254
 
159
- // Make PATCH request with JSON body
160
- const response = await this.context._makeRequest(url, {
161
- method: "PATCH",
162
- headers: {
163
- "Content-Type": "application/json",
164
- },
165
- body: JSON.stringify(this.data),
166
- ...options,
167
- });
168
-
169
- // Both byId and byFilter return affected row count
255
+ // Make PATCH request with JSON body
256
+ const result = await this.context._makeRequest(url, {
257
+ method: "PATCH",
258
+ headers,
259
+ body: JSON.stringify(transformedData),
260
+ ...mergedOptions,
261
+ });
262
+
263
+ if (result.error) {
264
+ return { data: undefined, error: result.error };
265
+ }
266
+
267
+ const response = result.data;
268
+
269
+ // Handle based on return preference
270
+ if (this.returnPreference === "representation") {
271
+ // Return the full updated record
272
+ return {
273
+ data: response as ReturnPreference extends "minimal"
274
+ ? { updatedCount: number }
275
+ : T,
276
+ error: undefined,
277
+ };
278
+ } else {
279
+ // Return updated count (minimal)
170
280
  let updatedCount = 0;
171
281
 
172
282
  if (typeof response === "number") {
@@ -176,37 +286,113 @@ export class ExecutableUpdateBuilder<
176
286
  updatedCount = (response as any).updatedCount || 0;
177
287
  }
178
288
 
179
- return { data: { updatedCount }, error: undefined };
180
- } catch (error) {
181
289
  return {
182
- data: undefined,
183
- error: error instanceof Error ? error : new Error(String(error)),
290
+ data: { updatedCount } as ReturnPreference extends "minimal"
291
+ ? { updatedCount: number }
292
+ : T,
293
+ error: undefined,
184
294
  };
185
295
  }
186
296
  }
187
297
 
188
298
  getRequestConfig(): { method: string; url: string; body?: any } {
299
+ // For batch operations, use database-level setting (no per-request override available here)
300
+ const tableId = this.getTableId(this.databaseUseEntityIds);
301
+
302
+ // Transform field names to FMFIDs if using entity IDs
303
+ const transformedData =
304
+ this.occurrence?.baseTable && this.databaseUseEntityIds
305
+ ? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
306
+ : this.data;
307
+
189
308
  let url: string;
190
309
 
191
310
  if (this.mode === "byId") {
192
- url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
311
+ url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
193
312
  } else {
194
313
  if (!this.queryBuilder) {
195
314
  throw new Error("Query builder is required for filter-based update");
196
315
  }
197
316
 
198
317
  const queryString = this.queryBuilder.getQueryString();
199
- const queryParams = queryString.startsWith(`/${this.tableName}`)
200
- ? queryString.slice(`/${this.tableName}`.length)
201
- : queryString;
318
+ const queryParams = queryString.startsWith(`/${tableId}`)
319
+ ? queryString.slice(`/${tableId}`.length)
320
+ : queryString.startsWith(`/${this.tableName}`)
321
+ ? queryString.slice(`/${this.tableName}`.length)
322
+ : queryString;
202
323
 
203
- url = `/${this.databaseName}/${this.tableName}${queryParams}`;
324
+ url = `/${this.databaseName}/${tableId}${queryParams}`;
204
325
  }
205
326
 
206
327
  return {
207
328
  method: "PATCH",
208
329
  url,
209
- body: JSON.stringify(this.data),
330
+ body: JSON.stringify(transformedData),
210
331
  };
211
332
  }
333
+
334
+ toRequest(baseUrl: string): Request {
335
+ const config = this.getRequestConfig();
336
+ const fullUrl = `${baseUrl}${config.url}`;
337
+
338
+ return new Request(fullUrl, {
339
+ method: config.method,
340
+ headers: {
341
+ "Content-Type": "application/json",
342
+ Accept: "application/json",
343
+ },
344
+ body: config.body,
345
+ });
346
+ }
347
+
348
+ async processResponse(
349
+ response: Response,
350
+ options?: ExecuteOptions,
351
+ ): Promise<
352
+ Result<ReturnPreference extends "minimal" ? { updatedCount: number } : T>
353
+ > {
354
+ // Check for empty response (204 No Content)
355
+ const text = await response.text();
356
+ if (!text || text.trim() === "") {
357
+ // For 204 No Content, check the fmodata.affected_rows header
358
+ const affectedRows = response.headers.get("fmodata.affected_rows");
359
+ const updatedCount = affectedRows ? parseInt(affectedRows, 10) : 1;
360
+ return {
361
+ data: { updatedCount } as ReturnPreference extends "minimal"
362
+ ? { updatedCount: number }
363
+ : T,
364
+ error: undefined,
365
+ };
366
+ }
367
+
368
+ const rawResponse = JSON.parse(text);
369
+
370
+ // Handle based on return preference
371
+ if (this.returnPreference === "representation") {
372
+ // Return the full updated record
373
+ return {
374
+ data: rawResponse as ReturnPreference extends "minimal"
375
+ ? { updatedCount: number }
376
+ : T,
377
+ error: undefined,
378
+ };
379
+ } else {
380
+ // Return updated count (minimal)
381
+ let updatedCount = 0;
382
+
383
+ if (typeof rawResponse === "number") {
384
+ updatedCount = rawResponse;
385
+ } else if (rawResponse && typeof rawResponse === "object") {
386
+ // Check if the response has a count property (fallback)
387
+ updatedCount = (rawResponse as any).updatedCount || 0;
388
+ }
389
+
390
+ return {
391
+ data: { updatedCount } as ReturnPreference extends "minimal"
392
+ ? { updatedCount: number }
393
+ : T,
394
+ error: undefined,
395
+ };
396
+ }
397
+ }
212
398
  }
package/src/errors.ts ADDED
@@ -0,0 +1,217 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+
3
+ /**
4
+ * Base class for all fmodata errors
5
+ */
6
+ export abstract class FMODataError extends Error {
7
+ abstract readonly kind: string;
8
+ readonly timestamp: Date;
9
+
10
+ constructor(message: string, options?: ErrorOptions) {
11
+ super(message, options);
12
+ this.name = this.constructor.name;
13
+ this.timestamp = new Date();
14
+ }
15
+ }
16
+
17
+ // ============================================
18
+ // HTTP Errors (with status codes)
19
+ // ============================================
20
+
21
+ export class HTTPError extends FMODataError {
22
+ readonly kind = "HTTPError" as const;
23
+ readonly url: string;
24
+ readonly status: number;
25
+ readonly statusText: string;
26
+ readonly response?: any;
27
+
28
+ constructor(url: string, status: number, statusText: string, response?: any) {
29
+ super(`HTTP ${status} ${statusText} for ${url}`);
30
+ this.url = url;
31
+ this.status = status;
32
+ this.statusText = statusText;
33
+ this.response = response;
34
+ }
35
+
36
+ // Helper methods for common status checks
37
+ is4xx(): boolean {
38
+ return this.status >= 400 && this.status < 500;
39
+ }
40
+
41
+ is5xx(): boolean {
42
+ return this.status >= 500 && this.status < 600;
43
+ }
44
+
45
+ isNotFound(): boolean {
46
+ return this.status === 404;
47
+ }
48
+
49
+ isUnauthorized(): boolean {
50
+ return this.status === 401;
51
+ }
52
+
53
+ isForbidden(): boolean {
54
+ return this.status === 403;
55
+ }
56
+ }
57
+
58
+ // ============================================
59
+ // OData Specific Errors
60
+ // ============================================
61
+
62
+ export class ODataError extends FMODataError {
63
+ readonly kind = "ODataError" as const;
64
+ readonly url: string;
65
+ readonly code?: string;
66
+ readonly details?: any;
67
+
68
+ constructor(url: string, message: string, code?: string, details?: any) {
69
+ super(`OData error: ${message}`);
70
+ this.url = url;
71
+ this.code = code;
72
+ this.details = details;
73
+ }
74
+ }
75
+
76
+ export class SchemaLockedError extends FMODataError {
77
+ readonly kind = "SchemaLockedError" as const;
78
+ readonly url: string;
79
+ readonly code: string;
80
+ readonly details?: any;
81
+
82
+ constructor(url: string, message: string, details?: any) {
83
+ super(`OData error: ${message}`);
84
+ this.url = url;
85
+ this.code = "303";
86
+ this.details = details;
87
+ }
88
+ }
89
+
90
+ // ============================================
91
+ // Validation Errors
92
+ // ============================================
93
+
94
+ export class ValidationError extends FMODataError {
95
+ readonly kind = "ValidationError" as const;
96
+ readonly field?: string;
97
+ readonly issues: readonly StandardSchemaV1.Issue[];
98
+ readonly value?: unknown;
99
+
100
+ constructor(
101
+ message: string,
102
+ issues: readonly StandardSchemaV1.Issue[],
103
+ options?: {
104
+ field?: string;
105
+ value?: unknown;
106
+ cause?: Error["cause"];
107
+ },
108
+ ) {
109
+ super(
110
+ message,
111
+ options?.cause !== undefined ? { cause: options.cause } : undefined,
112
+ );
113
+ this.field = options?.field;
114
+ this.issues = issues;
115
+ this.value = options?.value;
116
+ }
117
+ }
118
+
119
+ export class ResponseStructureError extends FMODataError {
120
+ readonly kind = "ResponseStructureError" as const;
121
+ readonly expected: string;
122
+ readonly received: any;
123
+
124
+ constructor(expected: string, received: any) {
125
+ super(`Invalid response structure: expected ${expected}`);
126
+ this.expected = expected;
127
+ this.received = received;
128
+ }
129
+ }
130
+
131
+ export class RecordCountMismatchError extends FMODataError {
132
+ readonly kind = "RecordCountMismatchError" as const;
133
+ readonly expected: number | "one" | "at-most-one";
134
+ readonly received: number;
135
+
136
+ constructor(expected: number | "one" | "at-most-one", received: number) {
137
+ const expectedStr = typeof expected === "number" ? expected : expected;
138
+ super(`Expected ${expectedStr} record(s), but received ${received}`);
139
+ this.expected = expected;
140
+ this.received = received;
141
+ }
142
+ }
143
+
144
+ export class InvalidLocationHeaderError extends FMODataError {
145
+ readonly kind = "InvalidLocationHeaderError" as const;
146
+ readonly locationHeader?: string;
147
+
148
+ constructor(message: string, locationHeader?: string) {
149
+ super(message);
150
+ this.locationHeader = locationHeader;
151
+ }
152
+ }
153
+
154
+ // ============================================
155
+ // Type Guards
156
+ // ============================================
157
+
158
+ export function isHTTPError(error: unknown): error is HTTPError {
159
+ return error instanceof HTTPError;
160
+ }
161
+
162
+ export function isValidationError(error: unknown): error is ValidationError {
163
+ return error instanceof ValidationError;
164
+ }
165
+
166
+ export function isODataError(error: unknown): error is ODataError {
167
+ return error instanceof ODataError;
168
+ }
169
+
170
+ export function isSchemaLockedError(
171
+ error: unknown,
172
+ ): error is SchemaLockedError {
173
+ return error instanceof SchemaLockedError;
174
+ }
175
+
176
+ export function isResponseStructureError(
177
+ error: unknown,
178
+ ): error is ResponseStructureError {
179
+ return error instanceof ResponseStructureError;
180
+ }
181
+
182
+ export function isRecordCountMismatchError(
183
+ error: unknown,
184
+ ): error is RecordCountMismatchError {
185
+ return error instanceof RecordCountMismatchError;
186
+ }
187
+
188
+ export function isFMODataError(error: unknown): error is FMODataError {
189
+ return error instanceof FMODataError;
190
+ }
191
+
192
+ // ============================================
193
+ // Union type for all possible errors
194
+ // ============================================
195
+
196
+ // Re-export ffetch errors (they'll be imported from @fetchkit/ffetch)
197
+ export type {
198
+ TimeoutError,
199
+ AbortError,
200
+ NetworkError,
201
+ RetryLimitError,
202
+ CircuitOpenError,
203
+ } from "@fetchkit/ffetch";
204
+
205
+ export type FMODataErrorType =
206
+ | import("@fetchkit/ffetch").TimeoutError
207
+ | import("@fetchkit/ffetch").AbortError
208
+ | import("@fetchkit/ffetch").NetworkError
209
+ | import("@fetchkit/ffetch").RetryLimitError
210
+ | import("@fetchkit/ffetch").CircuitOpenError
211
+ | HTTPError
212
+ | ODataError
213
+ | SchemaLockedError
214
+ | ValidationError
215
+ | ResponseStructureError
216
+ | RecordCountMismatchError
217
+ | InvalidLocationHeaderError;