@proofkit/fmodata 0.1.0-alpha.4 → 0.1.0-alpha.7

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 (74) hide show
  1. package/README.md +690 -31
  2. package/dist/esm/client/base-table.d.ts +122 -5
  3. package/dist/esm/client/base-table.js +46 -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/database.d.ts +54 -5
  12. package/dist/esm/client/database.js +118 -15
  13. package/dist/esm/client/database.js.map +1 -1
  14. package/dist/esm/client/delete-builder.d.ts +21 -2
  15. package/dist/esm/client/delete-builder.js +96 -32
  16. package/dist/esm/client/delete-builder.js.map +1 -1
  17. package/dist/esm/client/entity-set.d.ts +22 -8
  18. package/dist/esm/client/entity-set.js +28 -8
  19. package/dist/esm/client/entity-set.js.map +1 -1
  20. package/dist/esm/client/filemaker-odata.d.ts +22 -3
  21. package/dist/esm/client/filemaker-odata.js +122 -27
  22. package/dist/esm/client/filemaker-odata.js.map +1 -1
  23. package/dist/esm/client/insert-builder.d.ts +38 -3
  24. package/dist/esm/client/insert-builder.js +231 -34
  25. package/dist/esm/client/insert-builder.js.map +1 -1
  26. package/dist/esm/client/query-builder.d.ts +26 -5
  27. package/dist/esm/client/query-builder.js +455 -208
  28. package/dist/esm/client/query-builder.js.map +1 -1
  29. package/dist/esm/client/record-builder.d.ts +19 -4
  30. package/dist/esm/client/record-builder.js +132 -40
  31. package/dist/esm/client/record-builder.js.map +1 -1
  32. package/dist/esm/client/response-processor.d.ts +38 -0
  33. package/dist/esm/client/schema-manager.d.ts +57 -0
  34. package/dist/esm/client/schema-manager.js +132 -0
  35. package/dist/esm/client/schema-manager.js.map +1 -0
  36. package/dist/esm/client/table-occurrence.d.ts +66 -2
  37. package/dist/esm/client/table-occurrence.js +36 -1
  38. package/dist/esm/client/table-occurrence.js.map +1 -1
  39. package/dist/esm/client/update-builder.d.ts +34 -11
  40. package/dist/esm/client/update-builder.js +135 -31
  41. package/dist/esm/client/update-builder.js.map +1 -1
  42. package/dist/esm/errors.d.ts +73 -0
  43. package/dist/esm/errors.js +148 -0
  44. package/dist/esm/errors.js.map +1 -0
  45. package/dist/esm/index.d.ts +7 -3
  46. package/dist/esm/index.js +27 -3
  47. package/dist/esm/index.js.map +1 -1
  48. package/dist/esm/transform.d.ts +65 -0
  49. package/dist/esm/transform.js +114 -0
  50. package/dist/esm/transform.js.map +1 -0
  51. package/dist/esm/types.d.ts +89 -5
  52. package/dist/esm/validation.d.ts +6 -3
  53. package/dist/esm/validation.js +104 -33
  54. package/dist/esm/validation.js.map +1 -1
  55. package/package.json +10 -1
  56. package/src/client/base-table.ts +155 -8
  57. package/src/client/batch-builder.ts +265 -0
  58. package/src/client/batch-request.ts +485 -0
  59. package/src/client/database.ts +173 -16
  60. package/src/client/delete-builder.ts +149 -48
  61. package/src/client/entity-set.ts +99 -15
  62. package/src/client/filemaker-odata.ts +178 -34
  63. package/src/client/insert-builder.ts +350 -40
  64. package/src/client/query-builder.ts +609 -236
  65. package/src/client/record-builder.ts +186 -53
  66. package/src/client/response-processor.ts +103 -0
  67. package/src/client/schema-manager.ts +246 -0
  68. package/src/client/table-occurrence.ts +118 -4
  69. package/src/client/update-builder.ts +235 -49
  70. package/src/errors.ts +217 -0
  71. package/src/index.ts +43 -1
  72. package/src/transform.ts +249 -0
  73. package/src/types.ts +201 -35
  74. package/src/validation.ts +120 -36
package/src/types.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { type FFetchOptions } from "@fetchkit/ffetch";
2
- import { z } from "zod/v4";
3
2
  import type { StandardSchemaV1 } from "@standard-schema/spec";
4
3
 
5
4
  export type Auth = { username: string; password: string } | { apiKey: string };
@@ -7,18 +6,40 @@ export type Auth = { username: string; password: string } | { apiKey: string };
7
6
  export interface ExecutableBuilder<T> {
8
7
  execute(): Promise<Result<T>>;
9
8
  getRequestConfig(): { method: string; url: string; body?: any };
9
+
10
+ /**
11
+ * Convert this builder to a native Request object for batch processing.
12
+ * @param baseUrl - The base URL for the OData service
13
+ * @returns A native Request object
14
+ */
15
+ toRequest(baseUrl: string): Request;
16
+
17
+ /**
18
+ * Process a raw Response object into a typed Result.
19
+ * This allows builders to apply their own validation and transformation logic.
20
+ * @param response - The native Response object from the batch operation
21
+ * @param options - Optional execution options (e.g., skipValidation, includeODataAnnotations)
22
+ * @returns A typed Result with the builder's expected return type
23
+ */
24
+ processResponse(
25
+ response: Response,
26
+ options?: ExecuteOptions,
27
+ ): Promise<Result<T>>;
10
28
  }
11
29
 
12
30
  export interface ExecutionContext {
13
31
  _makeRequest<T>(
14
32
  url: string,
15
- options?: RequestInit & FFetchOptions,
16
- ): Promise<T>;
33
+ options?: RequestInit & FFetchOptions & { useEntityIds?: boolean },
34
+ ): Promise<Result<T>>;
35
+ _setUseEntityIds?(useEntityIds: boolean): void;
36
+ _getUseEntityIds?(): boolean;
37
+ _getBaseUrl?(): string;
17
38
  }
18
39
 
19
40
  export type InferSchemaType<Schema extends Record<string, StandardSchemaV1>> = {
20
- [K in keyof Schema]: Schema[K] extends StandardSchemaV1<any, infer Output>
21
- ? Output
41
+ [K in keyof Schema]: Schema[K] extends StandardSchemaV1<any, infer Output>
42
+ ? Output
22
43
  : never;
23
44
  };
24
45
 
@@ -63,7 +84,7 @@ export type ODataFieldResponse<T> = {
63
84
  };
64
85
 
65
86
  // Result pattern for execute responses
66
- export type Result<T, E = Error> =
87
+ export type Result<T, E = import("./errors").FMODataErrorType> =
67
88
  | { data: T; error: undefined }
68
89
  | { data: undefined; error: E };
69
90
 
@@ -71,53 +92,198 @@ export type Result<T, E = Error> =
71
92
  export type MakeFieldsRequired<T, Keys extends keyof T> = Partial<T> &
72
93
  Required<Pick<T, Keys>>;
73
94
 
95
+ // Extract keys from schema where validator doesn't allow null/undefined (auto-required fields)
96
+ export type AutoRequiredKeys<Schema extends Record<string, StandardSchemaV1>> =
97
+ {
98
+ [K in keyof Schema]: Extract<
99
+ StandardSchemaV1.InferOutput<Schema[K]>,
100
+ null | undefined
101
+ > extends never
102
+ ? K
103
+ : never;
104
+ }[keyof Schema];
105
+
106
+ // Helper type to compute excluded fields (readOnly fields + idField)
107
+ export type ExcludedFields<
108
+ IdField extends keyof any | undefined,
109
+ ReadOnly extends readonly any[],
110
+ > = IdField extends keyof any ? IdField | ReadOnly[number] : ReadOnly[number];
111
+
112
+ // Helper type for InsertData computation
113
+ type ComputeInsertData<
114
+ Schema extends Record<string, StandardSchemaV1>,
115
+ IdField extends keyof Schema | undefined,
116
+ Required extends readonly any[],
117
+ ReadOnly extends readonly any[],
118
+ > = [Required[number]] extends [keyof InferSchemaType<Schema>]
119
+ ? Required extends readonly (keyof InferSchemaType<Schema>)[]
120
+ ? MakeFieldsRequired<
121
+ Omit<InferSchemaType<Schema>, ExcludedFields<IdField, ReadOnly>>,
122
+ Exclude<
123
+ AutoRequiredKeys<Schema> | Required[number],
124
+ ExcludedFields<IdField, ReadOnly>
125
+ >
126
+ >
127
+ : MakeFieldsRequired<
128
+ Omit<InferSchemaType<Schema>, ExcludedFields<IdField, ReadOnly>>,
129
+ Exclude<AutoRequiredKeys<Schema>, ExcludedFields<IdField, ReadOnly>>
130
+ >
131
+ : MakeFieldsRequired<
132
+ Omit<InferSchemaType<Schema>, ExcludedFields<IdField, ReadOnly>>,
133
+ Exclude<AutoRequiredKeys<Schema>, ExcludedFields<IdField, ReadOnly>>
134
+ >;
135
+
74
136
  // Extract insert data type from BaseTable
75
- // This type makes the insertRequired fields required while keeping others optional
137
+ // Auto-infers required fields from validator nullability + user-specified required fields
138
+ // Excludes readOnly fields and idField
76
139
  export type InsertData<BT> = BT extends import("./client/base-table").BaseTable<
77
- infer Schema extends Record<string, z.ZodType>,
78
140
  any,
79
- infer InsertRequired extends readonly any[],
141
+ any,
142
+ any,
80
143
  any
81
144
  >
82
- ? [InsertRequired[number]] extends [keyof InferSchemaType<Schema>]
83
- ? InsertRequired extends readonly (keyof InferSchemaType<Schema>)[]
84
- ? MakeFieldsRequired<InferSchemaType<Schema>, InsertRequired[number]>
85
- : Partial<InferSchemaType<Schema>>
86
- : Partial<InferSchemaType<Schema>>
145
+ ? BT extends {
146
+ schema: infer Schema;
147
+ idField?: infer IdField;
148
+ required?: infer Required;
149
+ readOnly?: infer ReadOnly;
150
+ }
151
+ ? Schema extends Record<string, StandardSchemaV1>
152
+ ? IdField extends keyof Schema | undefined
153
+ ? Required extends readonly any[]
154
+ ? ReadOnly extends readonly any[]
155
+ ? ComputeInsertData<
156
+ Schema,
157
+ Extract<IdField, keyof Schema | undefined>,
158
+ Required,
159
+ ReadOnly
160
+ >
161
+ : Partial<Record<string, any>>
162
+ : Partial<Record<string, any>>
163
+ : Partial<Record<string, any>>
164
+ : Partial<Record<string, any>>
165
+ : Partial<Record<string, any>>
87
166
  : Partial<Record<string, any>>;
88
167
 
89
168
  // Extract update data type from BaseTable
90
- // This type makes the updateRequired fields required while keeping others optional
169
+ // All fields are optional for updates, excludes readOnly fields and idField
91
170
  export type UpdateData<BT> = BT extends import("./client/base-table").BaseTable<
92
- infer Schema extends Record<string, z.ZodType>,
93
171
  any,
94
172
  any,
95
- infer UpdateRequired extends readonly any[]
173
+ any,
174
+ any
96
175
  >
97
- ? [UpdateRequired[number]] extends [keyof InferSchemaType<Schema>]
98
- ? UpdateRequired extends readonly (keyof InferSchemaType<Schema>)[]
99
- ? MakeFieldsRequired<InferSchemaType<Schema>, UpdateRequired[number]>
100
- : Partial<InferSchemaType<Schema>>
101
- : Partial<InferSchemaType<Schema>>
176
+ ? BT extends {
177
+ schema: infer Schema;
178
+ idField?: infer IdField;
179
+ readOnly?: infer ReadOnly;
180
+ }
181
+ ? Schema extends Record<string, StandardSchemaV1>
182
+ ? IdField extends keyof Schema | undefined
183
+ ? ReadOnly extends readonly any[]
184
+ ? Partial<
185
+ Omit<
186
+ InferSchemaType<Schema>,
187
+ ExcludedFields<
188
+ Extract<IdField, keyof Schema | undefined>,
189
+ ReadOnly
190
+ >
191
+ >
192
+ >
193
+ : Partial<Record<string, any>>
194
+ : Partial<Record<string, any>>
195
+ : Partial<Record<string, any>>
196
+ : Partial<Record<string, any>>
102
197
  : Partial<Record<string, any>>;
103
198
 
104
199
  export type ExecuteOptions = {
105
200
  includeODataAnnotations?: boolean;
106
201
  skipValidation?: boolean;
202
+ /**
203
+ * Overrides the default behavior of the database to use entity IDs (rather than field names) in THIS REQUEST ONLY
204
+ */
205
+ useEntityIds?: boolean;
107
206
  };
108
207
 
109
- export type ConditionallyWithODataAnnotations<T, IncludeODataAnnotations extends boolean> =
110
- IncludeODataAnnotations extends true
111
- ? T & {
112
- "@id": string;
113
- "@editLink": string;
114
- }
115
- : T;
208
+ export type ConditionallyWithODataAnnotations<
209
+ T,
210
+ IncludeODataAnnotations extends boolean,
211
+ > = IncludeODataAnnotations extends true
212
+ ? T & {
213
+ "@id": string;
214
+ "@editLink": string;
215
+ }
216
+ : T;
116
217
 
117
218
  // Helper type to extract schema from a TableOccurrence
118
- export type ExtractSchemaFromOccurrence<Occ> =
119
- Occ extends { baseTable: { schema: infer S } }
120
- ? S extends Record<string, StandardSchemaV1>
121
- ? S
122
- : Record<string, StandardSchemaV1>
123
- : Record<string, StandardSchemaV1>;
219
+ export type ExtractSchemaFromOccurrence<Occ> = Occ extends {
220
+ baseTable: { schema: infer S };
221
+ }
222
+ ? S extends Record<string, StandardSchemaV1>
223
+ ? S
224
+ : Record<string, StandardSchemaV1>
225
+ : Record<string, StandardSchemaV1>;
226
+
227
+ export type GenericFieldMetadata = {
228
+ $Nullable?: boolean;
229
+ "@Index"?: boolean;
230
+ "@Calculation"?: boolean;
231
+ "@Summary"?: boolean;
232
+ "@Global"?: boolean;
233
+ "@Org.OData.Core.V1.Permissions"?: "Org.OData.Core.V1.Permission@Read";
234
+ };
235
+
236
+ export type StringFieldMetadata = GenericFieldMetadata & {
237
+ $Type: "Edm.String";
238
+ $DefaultValue?: "USER" | "USERNAME" | "CURRENT_USER";
239
+ $MaxLength?: number;
240
+ };
241
+
242
+ export type DecimalFieldMetadata = GenericFieldMetadata & {
243
+ $Type: "Edm.Decimal";
244
+ "@AutoGenerated"?: boolean;
245
+ };
246
+
247
+ export type DateFieldMetadata = GenericFieldMetadata & {
248
+ $Type: "Edm.Date";
249
+ $DefaultValue?: "CURDATE" | "CURRENT_DATE";
250
+ };
251
+
252
+ export type TimeOfDayFieldMetadata = GenericFieldMetadata & {
253
+ $Type: "Edm.TimeOfDay";
254
+ $DefaultValue?: "CURTIME" | "CURRENT_TIME";
255
+ };
256
+
257
+ export type DateTimeOffsetFieldMetadata = GenericFieldMetadata & {
258
+ $Type: "Edm.Date";
259
+ $DefaultValue?: "CURTIMESTAMP" | "CURRENT_TIMESTAMP";
260
+ "@VersionId"?: boolean;
261
+ };
262
+
263
+ export type StreamFieldMetadata = {
264
+ $Type: "Edm.Stream";
265
+ $Nullable?: boolean;
266
+ "@EnclosedPath": string;
267
+ "@ExternalOpenPath": string;
268
+ "@ExternalSecurePath"?: string;
269
+ };
270
+
271
+ export type FieldMetadata =
272
+ | StringFieldMetadata
273
+ | DecimalFieldMetadata
274
+ | DateFieldMetadata
275
+ | TimeOfDayFieldMetadata
276
+ | DateTimeOffsetFieldMetadata
277
+ | StreamFieldMetadata;
278
+
279
+ export type EntityType = {
280
+ $Kind: "EntityType";
281
+ $Key: string[];
282
+ } & Record<string, FieldMetadata>;
283
+
284
+ export type EntitySet = {
285
+ $Kind: "EntitySet";
286
+ $Type: string;
287
+ };
288
+
289
+ export type Metadata = Record<string, EntityType | EntitySet>;
package/src/validation.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import type { ODataRecordMetadata } from "./types";
2
2
  import { StandardSchemaV1 } from "@standard-schema/spec";
3
3
  import type { TableOccurrence } from "./client/table-occurrence";
4
+ import {
5
+ ValidationError,
6
+ ResponseStructureError,
7
+ RecordCountMismatchError,
8
+ } from "./errors";
4
9
 
5
10
  // Type for expand validation configuration
6
11
  export type ExpandValidationConfig = {
7
12
  relation: string;
8
13
  targetSchema?: Record<string, StandardSchemaV1>;
9
14
  targetOccurrence?: TableOccurrence<any, any, any, any>;
15
+ targetBaseTable?: any; // BaseTable instance for transformation
16
+ occurrence?: TableOccurrence<any, any, any, any>; // For transformation
10
17
  selectedFields?: string[];
11
18
  nestedExpands?: ExpandValidationConfig[];
12
19
  };
@@ -22,7 +29,7 @@ export async function validateRecord<T extends Record<string, any>>(
22
29
  expandConfigs?: ExpandValidationConfig[],
23
30
  ): Promise<
24
31
  | { valid: true; data: T & ODataRecordMetadata }
25
- | { valid: false; error: Error }
32
+ | { valid: false; error: ValidationError }
26
33
  > {
27
34
  // Extract OData metadata fields (don't validate them - include if present)
28
35
  const { "@id": id, "@editLink": editLink, ...rest } = record;
@@ -54,20 +61,42 @@ export async function validateRecord<T extends Record<string, any>>(
54
61
 
55
62
  if (fieldSchema) {
56
63
  const input = rest[fieldName];
57
- let result = fieldSchema["~standard"].validate(input);
58
- if (result instanceof Promise) result = await result;
64
+ try {
65
+ let result = fieldSchema["~standard"].validate(input);
66
+ if (result instanceof Promise) result = await result;
59
67
 
60
- // if the `issues` field exists, the validation failed
61
- if (result.issues) {
68
+ // if the `issues` field exists, the validation failed
69
+ if (result.issues) {
70
+ return {
71
+ valid: false,
72
+ error: new ValidationError(
73
+ `Validation failed for field '${fieldName}'`,
74
+ result.issues,
75
+ {
76
+ field: fieldName,
77
+ value: input,
78
+ cause: result.issues,
79
+ },
80
+ ),
81
+ };
82
+ }
83
+
84
+ validatedRecord[fieldName] = result.value;
85
+ } catch (originalError) {
86
+ // If the validator throws directly, wrap it
62
87
  return {
63
88
  valid: false,
64
- error: new Error(
65
- `Validation failed for field '${fieldName}': ${JSON.stringify(result.issues, null, 2)}`,
89
+ error: new ValidationError(
90
+ `Validation failed for field '${fieldName}'`,
91
+ [],
92
+ {
93
+ field: fieldName,
94
+ value: input,
95
+ cause: originalError,
96
+ },
66
97
  ),
67
98
  };
68
99
  }
69
-
70
- validatedRecord[fieldName] = result.value;
71
100
  } else {
72
101
  // For fields not in schema (like when explicitly selecting ROWID/ROWMODID)
73
102
  // include them from the original response
@@ -103,8 +132,12 @@ export async function validateRecord<T extends Record<string, any>>(
103
132
  if (isRelatedToExpand) {
104
133
  return {
105
134
  valid: false,
106
- error: new Error(
135
+ error: new ValidationError(
107
136
  `Validation failed for expanded relation '${expandConfig.relation}': ${errorMessage}`,
137
+ [],
138
+ {
139
+ field: expandConfig.relation,
140
+ },
108
141
  ),
109
142
  };
110
143
  }
@@ -129,8 +162,13 @@ export async function validateRecord<T extends Record<string, any>>(
129
162
  if (!itemValidation.valid) {
130
163
  return {
131
164
  valid: false,
132
- error: new Error(
165
+ error: new ValidationError(
133
166
  `Validation failed for expanded relation '${expandConfig.relation}' at index ${i}: ${itemValidation.error.message}`,
167
+ itemValidation.error.issues,
168
+ {
169
+ field: expandConfig.relation,
170
+ cause: itemValidation.error.cause,
171
+ },
134
172
  ),
135
173
  };
136
174
  }
@@ -148,8 +186,13 @@ export async function validateRecord<T extends Record<string, any>>(
148
186
  if (!itemValidation.valid) {
149
187
  return {
150
188
  valid: false,
151
- error: new Error(
189
+ error: new ValidationError(
152
190
  `Validation failed for expanded relation '${expandConfig.relation}': ${itemValidation.error.message}`,
191
+ itemValidation.error.issues,
192
+ {
193
+ field: expandConfig.relation,
194
+ cause: itemValidation.error.cause,
195
+ },
153
196
  ),
154
197
  };
155
198
  }
@@ -171,20 +214,43 @@ export async function validateRecord<T extends Record<string, any>>(
171
214
 
172
215
  for (const [fieldName, fieldSchema] of Object.entries(schema)) {
173
216
  const input = rest[fieldName];
174
- let result = fieldSchema["~standard"].validate(input);
175
- if (result instanceof Promise) result = await result;
217
+ try {
218
+ let result = fieldSchema["~standard"].validate(input);
219
+ if (result instanceof Promise) result = await result;
220
+
221
+ // if the `issues` field exists, the validation failed
222
+ if (result.issues) {
223
+ return {
224
+ valid: false,
225
+ error: new ValidationError(
226
+ `Validation failed for field '${fieldName}'`,
227
+ result.issues,
228
+ {
229
+ field: fieldName,
230
+ value: input,
231
+ cause: result.issues,
232
+ },
233
+ ),
234
+ };
235
+ }
176
236
 
177
- // if the `issues` field exists, the validation failed
178
- if (result.issues) {
237
+ validatedRecord[fieldName] = result.value;
238
+ } catch (originalError) {
239
+ // If the validator throws an error directly, catch and wrap it
240
+ // This preserves the original error instance for instanceof checks
179
241
  return {
180
242
  valid: false,
181
- error: new Error(
182
- `Validation failed for field '${fieldName}': ${JSON.stringify(result.issues, null, 2)}`,
243
+ error: new ValidationError(
244
+ `Validation failed for field '${fieldName}'`,
245
+ [],
246
+ {
247
+ field: fieldName,
248
+ value: input,
249
+ cause: originalError,
250
+ },
183
251
  ),
184
252
  };
185
253
  }
186
-
187
- validatedRecord[fieldName] = result.value;
188
254
  }
189
255
 
190
256
  // Validate expanded relations even when not using selected fields
@@ -215,8 +281,12 @@ export async function validateRecord<T extends Record<string, any>>(
215
281
  if (isRelatedToExpand) {
216
282
  return {
217
283
  valid: false,
218
- error: new Error(
284
+ error: new ValidationError(
219
285
  `Validation failed for expanded relation '${expandConfig.relation}': ${errorMessage}`,
286
+ [],
287
+ {
288
+ field: expandConfig.relation,
289
+ },
220
290
  ),
221
291
  };
222
292
  }
@@ -241,8 +311,13 @@ export async function validateRecord<T extends Record<string, any>>(
241
311
  if (!itemValidation.valid) {
242
312
  return {
243
313
  valid: false,
244
- error: new Error(
314
+ error: new ValidationError(
245
315
  `Validation failed for expanded relation '${expandConfig.relation}' at index ${i}: ${itemValidation.error.message}`,
316
+ itemValidation.error.issues,
317
+ {
318
+ field: expandConfig.relation,
319
+ cause: itemValidation.error.cause,
320
+ },
246
321
  ),
247
322
  };
248
323
  }
@@ -260,8 +335,13 @@ export async function validateRecord<T extends Record<string, any>>(
260
335
  if (!itemValidation.valid) {
261
336
  return {
262
337
  valid: false,
263
- error: new Error(
338
+ error: new ValidationError(
264
339
  `Validation failed for expanded relation '${expandConfig.relation}': ${itemValidation.error.message}`,
340
+ itemValidation.error.issues,
341
+ {
342
+ field: expandConfig.relation,
343
+ cause: itemValidation.error.cause,
344
+ },
265
345
  ),
266
346
  };
267
347
  }
@@ -287,13 +367,13 @@ export async function validateListResponse<T extends Record<string, any>>(
287
367
  expandConfigs?: ExpandValidationConfig[],
288
368
  ): Promise<
289
369
  | { valid: true; data: (T & ODataRecordMetadata)[] }
290
- | { valid: false; error: Error }
370
+ | { valid: false; error: ResponseStructureError | ValidationError }
291
371
  > {
292
372
  // Check if response has the expected structure
293
373
  if (!response || typeof response !== "object") {
294
374
  return {
295
375
  valid: false,
296
- error: new Error("Invalid response: expected an object"),
376
+ error: new ResponseStructureError("an object", response),
297
377
  };
298
378
  }
299
379
 
@@ -303,8 +383,9 @@ export async function validateListResponse<T extends Record<string, any>>(
303
383
  if (!Array.isArray(value)) {
304
384
  return {
305
385
  valid: false,
306
- error: new Error(
307
- "Invalid response: expected 'value' property to be an array",
386
+ error: new ResponseStructureError(
387
+ "'value' property to be an array",
388
+ value,
308
389
  ),
309
390
  };
310
391
  }
@@ -324,9 +405,7 @@ export async function validateListResponse<T extends Record<string, any>>(
324
405
  if (!validation.valid) {
325
406
  return {
326
407
  valid: false,
327
- error: new Error(
328
- `Validation failed for record at index ${i}: ${validation.error.message}`,
329
- ),
408
+ error: validation.error,
330
409
  };
331
410
  }
332
411
 
@@ -350,14 +429,19 @@ export async function validateSingleResponse<T extends Record<string, any>>(
350
429
  mode: "exact" | "maybe" = "maybe",
351
430
  ): Promise<
352
431
  | { valid: true; data: (T & ODataRecordMetadata) | null }
353
- | { valid: false; error: Error }
432
+ | { valid: false; error: RecordCountMismatchError | ValidationError }
354
433
  > {
355
434
  // Check for multiple records (error in both modes)
356
- if (response.value && Array.isArray(response.value) && response.value.length > 1) {
435
+ if (
436
+ response.value &&
437
+ Array.isArray(response.value) &&
438
+ response.value.length > 1
439
+ ) {
357
440
  return {
358
441
  valid: false,
359
- error: new Error(
360
- `Expected ${mode === "exact" ? "exactly one" : "at most one"} record, but received ${response.value.length}`
442
+ error: new RecordCountMismatchError(
443
+ mode === "exact" ? "one" : "at-most-one",
444
+ response.value.length,
361
445
  ),
362
446
  };
363
447
  }
@@ -367,7 +451,7 @@ export async function validateSingleResponse<T extends Record<string, any>>(
367
451
  if (mode === "exact") {
368
452
  return {
369
453
  valid: false,
370
- error: new Error("Expected exactly one record, but received none"),
454
+ error: new RecordCountMismatchError("one", 0),
371
455
  };
372
456
  }
373
457
  // mode === "maybe" - return null for empty
@@ -387,7 +471,7 @@ export async function validateSingleResponse<T extends Record<string, any>>(
387
471
  );
388
472
 
389
473
  if (!validation.valid) {
390
- return validation as { valid: false; error: Error };
474
+ return validation as { valid: false; error: ValidationError };
391
475
  }
392
476
 
393
477
  return {