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

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 (52) hide show
  1. package/dist/esm/client/batch-builder.d.ts +1 -1
  2. package/dist/esm/client/batch-builder.js +2 -2
  3. package/dist/esm/client/batch-builder.js.map +1 -1
  4. package/dist/esm/client/delete-builder.d.ts +1 -1
  5. package/dist/esm/client/delete-builder.js +3 -2
  6. package/dist/esm/client/delete-builder.js.map +1 -1
  7. package/dist/esm/client/entity-set.d.ts +1 -1
  8. package/dist/esm/client/entity-set.js +26 -0
  9. package/dist/esm/client/entity-set.js.map +1 -1
  10. package/dist/esm/client/filemaker-odata.js +25 -9
  11. package/dist/esm/client/filemaker-odata.js.map +1 -1
  12. package/dist/esm/client/insert-builder.d.ts +1 -5
  13. package/dist/esm/client/insert-builder.js +7 -23
  14. package/dist/esm/client/insert-builder.js.map +1 -1
  15. package/dist/esm/client/query-builder.d.ts +1 -5
  16. package/dist/esm/client/query-builder.js +56 -66
  17. package/dist/esm/client/query-builder.js.map +1 -1
  18. package/dist/esm/client/record-builder.d.ts +1 -5
  19. package/dist/esm/client/record-builder.js +38 -25
  20. package/dist/esm/client/record-builder.js.map +1 -1
  21. package/dist/esm/client/response-processor.d.ts +0 -5
  22. package/dist/esm/client/sanitize-json.d.ts +35 -0
  23. package/dist/esm/client/sanitize-json.js +27 -0
  24. package/dist/esm/client/sanitize-json.js.map +1 -0
  25. package/dist/esm/client/update-builder.d.ts +1 -1
  26. package/dist/esm/client/update-builder.js +3 -2
  27. package/dist/esm/client/update-builder.js.map +1 -1
  28. package/dist/esm/errors.d.ts +11 -1
  29. package/dist/esm/errors.js +15 -0
  30. package/dist/esm/errors.js.map +1 -1
  31. package/dist/esm/index.d.ts +1 -1
  32. package/dist/esm/index.js +3 -1
  33. package/dist/esm/types.d.ts +8 -1
  34. package/dist/esm/types.js +7 -0
  35. package/dist/esm/types.js.map +1 -0
  36. package/dist/esm/validation.js +3 -4
  37. package/dist/esm/validation.js.map +1 -1
  38. package/package.json +3 -2
  39. package/src/client/batch-builder.ts +2 -2
  40. package/src/client/delete-builder.ts +3 -2
  41. package/src/client/entity-set.ts +40 -2
  42. package/src/client/filemaker-odata.ts +39 -10
  43. package/src/client/insert-builder.ts +8 -33
  44. package/src/client/query-builder.ts +65 -82
  45. package/src/client/record-builder.ts +50 -38
  46. package/src/client/response-processor.ts +0 -13
  47. package/src/client/sanitize-json.ts +66 -0
  48. package/src/client/update-builder.ts +3 -2
  49. package/src/errors.ts +24 -1
  50. package/src/index.ts +2 -0
  51. package/src/types.ts +13 -1
  52. package/src/validation.ts +4 -5
@@ -10,6 +10,7 @@ import type {
10
10
  ConditionallyWithODataAnnotations,
11
11
  ExtractSchemaFromOccurrence,
12
12
  } from "../types";
13
+ import { getAcceptHeader } from "../types";
13
14
  import type { Filter } from "../filter-types";
14
15
  import type { TableOccurrence } from "./table-occurrence";
15
16
  import type { BaseTable } from "./base-table";
@@ -24,6 +25,7 @@ import {
24
25
  transformResponseFields,
25
26
  getTableIdentifiers,
26
27
  } from "../transform";
28
+ import { safeJsonParse } from "./sanitize-json";
27
29
 
28
30
  /**
29
31
  * Default maximum number of records to return in a list query.
@@ -182,23 +184,6 @@ export class QueryBuilder<
182
184
  };
183
185
  }
184
186
 
185
- /**
186
- * Helper to conditionally strip OData annotations based on options
187
- */
188
- private stripODataAnnotationsIfNeeded<T extends Record<string, any>>(
189
- data: T,
190
- options?: ExecuteOptions,
191
- ): T {
192
- // Only include annotations if explicitly requested
193
- if (options?.includeODataAnnotations === true) {
194
- return data;
195
- }
196
-
197
- // Strip OData annotations
198
- const { "@id": _id, "@editLink": _editLink, ...rest } = data;
199
- return rest as T;
200
- }
201
-
202
187
  /**
203
188
  * Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
204
189
  * @param useEntityIds - Optional override for entity ID usage
@@ -592,6 +577,22 @@ export class QueryBuilder<
592
577
  // Look up target occurrence from navigation
593
578
  const targetOccurrence = this.occurrence?.navigation[relation as string];
594
579
 
580
+ // Helper function to get defaultSelect fields from target occurrence
581
+ const getDefaultSelectFields = (): string[] | undefined => {
582
+ if (!targetOccurrence) return undefined;
583
+ const defaultSelect = targetOccurrence.defaultSelect;
584
+ if (defaultSelect === "schema") {
585
+ const schema = targetOccurrence.baseTable?.schema;
586
+ if (schema) {
587
+ return [...new Set(Object.keys(schema))];
588
+ }
589
+ } else if (Array.isArray(defaultSelect)) {
590
+ return [...new Set(defaultSelect)];
591
+ }
592
+ // If "all", return undefined (no select restriction)
593
+ return undefined;
594
+ };
595
+
595
596
  if (callback) {
596
597
  // Create a new QueryBuilder for the target occurrence
597
598
  const targetBuilder = new QueryBuilder<any>({
@@ -622,6 +623,14 @@ export class QueryBuilder<
622
623
  ...configuredBuilder.queryOptions,
623
624
  };
624
625
 
626
+ // If callback didn't provide select, apply defaultSelect from target occurrence
627
+ if (!expandOptions.select) {
628
+ const defaultFields = getDefaultSelectFields();
629
+ if (defaultFields) {
630
+ expandOptions.select = defaultFields;
631
+ }
632
+ }
633
+
625
634
  // If the configured builder has nested expands, we need to include them
626
635
  if (configuredBuilder.expandConfigs.length > 0) {
627
636
  // Build nested expand string from the configured builder's expand configs
@@ -641,8 +650,16 @@ export class QueryBuilder<
641
650
 
642
651
  this.expandConfigs.push(expandConfig);
643
652
  } else {
644
- // Simple expand without callback
645
- this.expandConfigs.push({ relation: relation as string });
653
+ // Simple expand without callback - apply defaultSelect if available
654
+ const defaultFields = getDefaultSelectFields();
655
+ if (defaultFields) {
656
+ this.expandConfigs.push({
657
+ relation: relation as string,
658
+ options: { select: defaultFields },
659
+ });
660
+ } else {
661
+ this.expandConfigs.push({ relation: relation as string });
662
+ }
646
663
  }
647
664
 
648
665
  return this as any;
@@ -858,13 +875,10 @@ export class QueryBuilder<
858
875
  }
859
876
 
860
877
  const record = Array.isArray(records) ? records[0] : records;
861
- const stripped = this.stripODataAnnotationsIfNeeded(record, options);
862
- return { data: stripped as any, error: undefined };
878
+ return { data: record as any, error: undefined };
863
879
  } else {
864
880
  const records = resp.value ?? [];
865
- const stripped = records.map((record: any) =>
866
- this.stripODataAnnotationsIfNeeded(record, options),
867
- );
881
+ const stripped = records.map((record: any) => record);
868
882
  return { data: stripped as any, error: undefined };
869
883
  }
870
884
  }
@@ -889,10 +903,7 @@ export class QueryBuilder<
889
903
  if (!validation.valid) {
890
904
  return { data: undefined, error: validation.error };
891
905
  }
892
- const stripped = validation.data
893
- ? this.stripODataAnnotationsIfNeeded(validation.data, options)
894
- : null;
895
- return { data: stripped as any, error: undefined };
906
+ return { data: validation.data as any, error: undefined };
896
907
  } else {
897
908
  const validation = await validateListResponse<T>(
898
909
  response,
@@ -903,10 +914,7 @@ export class QueryBuilder<
903
914
  if (!validation.valid) {
904
915
  return { data: undefined, error: validation.error };
905
916
  }
906
- const stripped = validation.data.map((record) =>
907
- this.stripODataAnnotationsIfNeeded(record, options),
908
- );
909
- return { data: stripped as any, error: undefined };
917
+ return { data: validation.data as any, error: undefined };
910
918
  }
911
919
  }
912
920
 
@@ -971,13 +979,10 @@ export class QueryBuilder<
971
979
  }
972
980
 
973
981
  const record = Array.isArray(records) ? records[0] : records;
974
- const stripped = this.stripODataAnnotationsIfNeeded(record, options);
975
- return { data: stripped as any, error: undefined };
982
+ return { data: record as any, error: undefined };
976
983
  } else {
977
984
  const records = resp.value ?? [];
978
- const stripped = records.map((record: any) =>
979
- this.stripODataAnnotationsIfNeeded(record, options),
980
- );
985
+ const stripped = records.map((record: any) => record);
981
986
  return { data: stripped as any, error: undefined };
982
987
  }
983
988
  }
@@ -1002,10 +1007,7 @@ export class QueryBuilder<
1002
1007
  if (!validation.valid) {
1003
1008
  return { data: undefined, error: validation.error };
1004
1009
  }
1005
- const stripped = validation.data
1006
- ? this.stripODataAnnotationsIfNeeded(validation.data, options)
1007
- : null;
1008
- return { data: stripped as any, error: undefined };
1010
+ return { data: validation.data as any, error: undefined };
1009
1011
  } else {
1010
1012
  const validation = await validateListResponse<T>(
1011
1013
  response,
@@ -1016,10 +1018,7 @@ export class QueryBuilder<
1016
1018
  if (!validation.valid) {
1017
1019
  return { data: undefined, error: validation.error };
1018
1020
  }
1019
- const stripped = validation.data.map((record) =>
1020
- this.stripODataAnnotationsIfNeeded(record, options),
1021
- );
1022
- return { data: stripped as any, error: undefined };
1021
+ return { data: validation.data as any, error: undefined };
1023
1022
  }
1024
1023
  }
1025
1024
 
@@ -1096,15 +1095,11 @@ export class QueryBuilder<
1096
1095
  }
1097
1096
 
1098
1097
  const record = Array.isArray(records) ? records[0] : records;
1099
- const stripped = this.stripODataAnnotationsIfNeeded(record, options);
1100
- return { data: stripped as any, error: undefined };
1098
+ return { data: record as any, error: undefined };
1101
1099
  } else {
1102
1100
  // Handle list response structure
1103
1101
  const records = resp.value ?? [];
1104
- const stripped = records.map((record: any) =>
1105
- this.stripODataAnnotationsIfNeeded(record, options),
1106
- );
1107
- return { data: stripped as any, error: undefined };
1102
+ return { data: records as any, error: undefined };
1108
1103
  }
1109
1104
  }
1110
1105
 
@@ -1126,11 +1121,8 @@ export class QueryBuilder<
1126
1121
  if (!validation.valid) {
1127
1122
  return { data: undefined, error: validation.error };
1128
1123
  }
1129
- const stripped = validation.data
1130
- ? this.stripODataAnnotationsIfNeeded(validation.data, options)
1131
- : null;
1132
1124
  return {
1133
- data: stripped as any,
1125
+ data: validation.data as any,
1134
1126
  error: undefined,
1135
1127
  };
1136
1128
  } else {
@@ -1143,11 +1135,8 @@ export class QueryBuilder<
1143
1135
  if (!validation.valid) {
1144
1136
  return { data: undefined, error: validation.error };
1145
1137
  }
1146
- const stripped = validation.data.map((record) =>
1147
- this.stripODataAnnotationsIfNeeded(record, options),
1148
- );
1149
1138
  return {
1150
- data: stripped as any,
1139
+ data: validation.data as any,
1151
1140
  error: undefined,
1152
1141
  };
1153
1142
  }
@@ -1223,8 +1212,9 @@ export class QueryBuilder<
1223
1212
  return queryParams ? `${path}${queryParams}` : path;
1224
1213
  }
1225
1214
 
1226
- // Default case: return table name with query params
1227
- return `/${this.tableName}${queryParams}`;
1215
+ // Default case: return table ID (respects entity ID settings) with query params
1216
+ const tableId = this.getTableId(this.databaseUseEntityIds);
1217
+ return `/${tableId}${queryParams}`;
1228
1218
  }
1229
1219
 
1230
1220
  getRequestConfig(): { method: string; url: string; body?: any } {
@@ -1280,9 +1270,13 @@ export class QueryBuilder<
1280
1270
  url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`;
1281
1271
  }
1282
1272
  } else if (this.isCountMode) {
1283
- url = `/${this.databaseName}/${this.tableName}/$count${queryString}`;
1273
+ // Use getTableId to respect entity ID settings (for batch operations)
1274
+ const tableId = this.getTableId(this.databaseUseEntityIds);
1275
+ url = `/${this.databaseName}/${tableId}/$count${queryString}`;
1284
1276
  } else {
1285
- url = `/${this.databaseName}/${this.tableName}${queryString}`;
1277
+ // Use getTableId to respect entity ID settings (for batch operations)
1278
+ const tableId = this.getTableId(this.databaseUseEntityIds);
1279
+ url = `/${this.databaseName}/${tableId}${queryString}`;
1286
1280
  }
1287
1281
 
1288
1282
  return {
@@ -1291,7 +1285,7 @@ export class QueryBuilder<
1291
1285
  };
1292
1286
  }
1293
1287
 
1294
- toRequest(baseUrl: string): Request {
1288
+ toRequest(baseUrl: string, options?: ExecuteOptions): Request {
1295
1289
  const config = this.getRequestConfig();
1296
1290
  const fullUrl = `${baseUrl}${config.url}`;
1297
1291
 
@@ -1299,7 +1293,7 @@ export class QueryBuilder<
1299
1293
  method: config.method,
1300
1294
  headers: {
1301
1295
  "Content-Type": "application/json",
1302
- Accept: "application/json",
1296
+ Accept: getAcceptHeader(options?.includeODataAnnotations),
1303
1297
  },
1304
1298
  });
1305
1299
  }
@@ -1325,10 +1319,10 @@ export class QueryBuilder<
1325
1319
  return { data: [] as any, error: undefined };
1326
1320
  }
1327
1321
 
1328
- // Parse the response body
1322
+ // Parse the response body (using safeJsonParse to handle FileMaker's invalid JSON with unquoted ? values)
1329
1323
  let rawData;
1330
1324
  try {
1331
- rawData = await response.json();
1325
+ rawData = await safeJsonParse(response);
1332
1326
  } catch (err) {
1333
1327
  // Check if it's an empty body error (common with 204 responses)
1334
1328
  if (err instanceof SyntaxError && response.status === 204) {
@@ -1400,15 +1394,11 @@ export class QueryBuilder<
1400
1394
  }
1401
1395
 
1402
1396
  const record = Array.isArray(records) ? records[0] : records;
1403
- const stripped = this.stripODataAnnotationsIfNeeded(record, options);
1404
- return { data: stripped as any, error: undefined };
1397
+ return { data: record as any, error: undefined };
1405
1398
  } else {
1406
1399
  // Handle list response structure
1407
1400
  const records = resp.value ?? [];
1408
- const stripped = records.map((record: any) =>
1409
- this.stripODataAnnotationsIfNeeded(record, options),
1410
- );
1411
- return { data: stripped as any, error: undefined };
1401
+ return { data: records as any, error: undefined };
1412
1402
  }
1413
1403
  }
1414
1404
 
@@ -1437,11 +1427,7 @@ export class QueryBuilder<
1437
1427
  return { data: null as any, error: undefined };
1438
1428
  }
1439
1429
 
1440
- const stripped = this.stripODataAnnotationsIfNeeded(
1441
- validation.data,
1442
- options,
1443
- );
1444
- return { data: stripped as any, error: undefined };
1430
+ return { data: validation.data as any, error: undefined };
1445
1431
  }
1446
1432
 
1447
1433
  // List mode
@@ -1456,9 +1442,6 @@ export class QueryBuilder<
1456
1442
  return { data: undefined, error: validation.error };
1457
1443
  }
1458
1444
 
1459
- const stripped = validation.data.map((record) =>
1460
- this.stripODataAnnotationsIfNeeded(record, options),
1461
- );
1462
- return { data: stripped as any, error: undefined };
1445
+ return { data: validation.data as any, error: undefined };
1463
1446
  }
1464
1447
  }
@@ -9,6 +9,7 @@ import type {
9
9
  WithSystemFields,
10
10
  ConditionallyWithODataAnnotations,
11
11
  } from "../types";
12
+ import { getAcceptHeader } from "../types";
12
13
  import type { TableOccurrence } from "./table-occurrence";
13
14
  import type { BaseTable } from "./base-table";
14
15
  import {
@@ -17,8 +18,12 @@ import {
17
18
  getTableIdentifiers,
18
19
  transformFieldNamesArray,
19
20
  } from "../transform";
21
+ import { safeJsonParse } from "./sanitize-json";
20
22
  import { QueryBuilder } from "./query-builder";
21
- import { validateSingleResponse, type ExpandValidationConfig } from "../validation";
23
+ import {
24
+ validateSingleResponse,
25
+ type ExpandValidationConfig,
26
+ } from "../validation";
22
27
  import { type FFetchOptions } from "@fetchkit/ffetch";
23
28
  import { StandardSchemaV1 } from "@standard-schema/spec";
24
29
  import { QueryOptions } from "odata-query";
@@ -185,7 +190,7 @@ export class RecordBuilder<
185
190
  const identifiers = getTableIdentifiers(this.occurrence);
186
191
  if (!identifiers.id) {
187
192
  throw new Error(
188
- `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`
193
+ `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`,
189
194
  );
190
195
  }
191
196
  return identifiers.id;
@@ -303,6 +308,22 @@ export class RecordBuilder<
303
308
  // Look up target occurrence from navigation
304
309
  const targetOccurrence = this.occurrence?.navigation[relation as string];
305
310
 
311
+ // Helper function to get defaultSelect fields from target occurrence
312
+ const getDefaultSelectFields = (): string[] | undefined => {
313
+ if (!targetOccurrence) return undefined;
314
+ const defaultSelect = targetOccurrence.defaultSelect;
315
+ if (defaultSelect === "schema") {
316
+ const schema = targetOccurrence.baseTable?.schema;
317
+ if (schema) {
318
+ return [...new Set(Object.keys(schema))];
319
+ }
320
+ } else if (Array.isArray(defaultSelect)) {
321
+ return [...new Set(defaultSelect)];
322
+ }
323
+ // If "all", return undefined (no select restriction)
324
+ return undefined;
325
+ };
326
+
306
327
  // Create new builder with updated types
307
328
  const newBuilder = new RecordBuilder<
308
329
  T,
@@ -359,6 +380,14 @@ export class RecordBuilder<
359
380
  ...(configuredBuilder as any).queryOptions,
360
381
  };
361
382
 
383
+ // If callback didn't provide select, apply defaultSelect from target occurrence
384
+ if (!expandOptions.select) {
385
+ const defaultFields = getDefaultSelectFields();
386
+ if (defaultFields) {
387
+ expandOptions.select = defaultFields;
388
+ }
389
+ }
390
+
362
391
  // If the configured builder has nested expands, we need to include them
363
392
  if ((configuredBuilder as any).expandConfigs?.length > 0) {
364
393
  // Build nested expand string from the configured builder's expand configs
@@ -378,8 +407,16 @@ export class RecordBuilder<
378
407
 
379
408
  newBuilder.expandConfigs.push(expandConfig);
380
409
  } else {
381
- // Simple expand without callback
382
- newBuilder.expandConfigs.push({ relation: relation as string });
410
+ // Simple expand without callback - apply defaultSelect if available
411
+ const defaultFields = getDefaultSelectFields();
412
+ if (defaultFields) {
413
+ newBuilder.expandConfigs.push({
414
+ relation: relation as string,
415
+ options: { select: defaultFields },
416
+ });
417
+ } else {
418
+ newBuilder.expandConfigs.push({ relation: relation as string });
419
+ }
383
420
  }
384
421
 
385
422
  return newBuilder;
@@ -612,22 +649,6 @@ export class RecordBuilder<
612
649
  return `?${parts.join("&")}`;
613
650
  }
614
651
 
615
- /**
616
- * Helper to conditionally strip OData annotations based on options
617
- */
618
- private stripODataAnnotationsIfNeeded<R extends Record<string, any>>(
619
- data: R,
620
- options?: ExecuteOptions,
621
- ): R {
622
- // Only include annotations if explicitly requested
623
- if (options?.includeODataAnnotations === true) {
624
- return data;
625
- }
626
-
627
- // Strip OData annotations
628
- const { "@id": _id, "@editLink": _editLink, ...rest } = data;
629
- return rest as R;
630
- }
631
652
 
632
653
  async execute<EO extends ExecuteOptions>(
633
654
  options?: RequestInit & FFetchOptions & EO,
@@ -720,13 +741,7 @@ export class RecordBuilder<
720
741
  return { data: null as any, error: undefined };
721
742
  }
722
743
 
723
- // Strip OData annotations if not requested
724
- const stripped = this.stripODataAnnotationsIfNeeded(
725
- validation.data,
726
- options,
727
- );
728
-
729
- return { data: stripped as any, error: undefined };
744
+ return { data: validation.data as any, error: undefined };
730
745
  }
731
746
 
732
747
  getRequestConfig(): { method: string; url: string; body?: any } {
@@ -774,7 +789,9 @@ export class RecordBuilder<
774
789
  ) {
775
790
  path = `/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
776
791
  } else {
777
- path = `/${this.tableName}('${this.recordId}')`;
792
+ // Use getTableId to respect entity ID settings (same as getRequestConfig)
793
+ const tableId = this.getTableId(this.databaseUseEntityIds);
794
+ path = `/${tableId}('${this.recordId}')`;
778
795
  }
779
796
 
780
797
  if (this.operation === "getSingleField" && this.operationParam) {
@@ -785,7 +802,7 @@ export class RecordBuilder<
785
802
  return `${path}${queryString}`;
786
803
  }
787
804
 
788
- toRequest(baseUrl: string): Request {
805
+ toRequest(baseUrl: string, options?: ExecuteOptions): Request {
789
806
  const config = this.getRequestConfig();
790
807
  const fullUrl = `${baseUrl}${config.url}`;
791
808
 
@@ -793,7 +810,7 @@ export class RecordBuilder<
793
810
  method: config.method,
794
811
  headers: {
795
812
  "Content-Type": "application/json",
796
- Accept: "application/json",
813
+ Accept: getAcceptHeader(options?.includeODataAnnotations),
797
814
  },
798
815
  });
799
816
  }
@@ -804,7 +821,8 @@ export class RecordBuilder<
804
821
  ): Promise<
805
822
  Result<RecordReturnType<T, IsSingleField, FieldKey, Selected, Expands>>
806
823
  > {
807
- const rawResponse = await response.json();
824
+ // Use safeJsonParse to handle FileMaker's invalid JSON with unquoted ? values
825
+ const rawResponse = await safeJsonParse(response);
808
826
 
809
827
  // Handle single field operation
810
828
  if (this.operation === "getSingleField") {
@@ -853,12 +871,6 @@ export class RecordBuilder<
853
871
  return { data: null as any, error: undefined };
854
872
  }
855
873
 
856
- // Strip OData annotations if not requested
857
- const stripped = this.stripODataAnnotationsIfNeeded(
858
- validation.data,
859
- options,
860
- );
861
-
862
- return { data: stripped as any, error: undefined };
874
+ return { data: validation.data as any, error: undefined };
863
875
  }
864
876
  }
@@ -23,19 +23,6 @@ export type ODataRecordResponse<T = unknown> = ODataResponse<
23
23
  }
24
24
  >;
25
25
 
26
- /**
27
- * Strip OData annotations from a single record
28
- */
29
- export function stripODataAnnotations<T extends Record<string, unknown>>(
30
- record: ODataRecordResponse<T>,
31
- options?: ExecuteOptions,
32
- ): T {
33
- if (options?.includeODataAnnotations === true) {
34
- return record as T;
35
- }
36
- const { "@id": _id, "@editLink": _editLink, ...rest } = record;
37
- return rest as T;
38
- }
39
26
 
40
27
  /**
41
28
  * Transform field IDs back to names using the base table configuration
@@ -0,0 +1,66 @@
1
+ /**
2
+ * FileMaker OData API sometimes returns invalid JSON containing unquoted `?`
3
+ * characters as field values (e.g., `"fieldName": ?`), which causes JSON.parse()
4
+ * to fail. This module provides utilities to sanitize such responses before parsing.
5
+ */
6
+
7
+ import { ResponseParseError } from "../errors";
8
+
9
+ /**
10
+ * Sanitizes FileMaker OData JSON responses by replacing unquoted `?` values with `null`.
11
+ *
12
+ * FileMaker uses `?` to represent undefined/null values in its OData responses,
13
+ * but this is not valid JSON. This function converts those to proper `null` values.
14
+ *
15
+ * The regex uses two patterns:
16
+ * 1. `/:\s*\?(?=\s*[,}\]])/g` - for values in objects (after `:`)
17
+ * 2. `/(?<=[\[,])\s*\?(?=\s*[,\]])/g` - for values in arrays (after `[` or `,`)
18
+ *
19
+ * @param text - The raw response text from FileMaker OData API
20
+ * @returns Sanitized JSON string with `?` values replaced by `null`
21
+ *
22
+ * @example
23
+ * sanitizeFileMakerJson('{"field1": "valid", "field2": ?, "field3": null}')
24
+ * // Returns: '{"field1": "valid", "field2": null, "field3": null}'
25
+ */
26
+ export function sanitizeFileMakerJson(text: string): string {
27
+ // Replace unquoted ? values in objects (after colon)
28
+ // Also handles arrays when the array is a value in an object
29
+ let result = text.replace(/:\s*\?(?=\s*[,}\]])/g, ": null");
30
+
31
+ // Replace unquoted ? values directly in arrays (not after colon)
32
+ // e.g., [1, ?, 3] -> [1, null, 3]
33
+ result = result.replace(/(?<=[\[,])\s*\?(?=\s*[,\]])/g, " null");
34
+
35
+ return result;
36
+ }
37
+
38
+ /**
39
+ * Safely parses a Response body as JSON, handling FileMaker's invalid JSON responses.
40
+ *
41
+ * This function reads the response as text first, sanitizes any invalid `?` values,
42
+ * and then parses the sanitized JSON. This approach handles the case where FileMaker
43
+ * returns a Content-Type of application/json but the body contains invalid JSON.
44
+ *
45
+ * @param response - The fetch Response object
46
+ * @returns Parsed JSON data
47
+ * @throws ResponseParseError if the JSON is still invalid after sanitization (includes sanitized text for debugging)
48
+ */
49
+ export async function safeJsonParse<T = unknown>(
50
+ response: Response,
51
+ ): Promise<T> {
52
+ const text = await response.text();
53
+ const sanitized = sanitizeFileMakerJson(text);
54
+ try {
55
+ return JSON.parse(sanitized) as T;
56
+ } catch (err) {
57
+ throw new ResponseParseError(
58
+ response.url,
59
+ `Failed to parse response as JSON: ${err instanceof Error ? err.message : "Unknown error"}`,
60
+ {
61
+ rawText: sanitized,
62
+ cause: err instanceof Error ? err : undefined,
63
+ },
64
+ );
65
+ }
66
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  WithSystemFields,
6
6
  ExecuteOptions,
7
7
  } from "../types";
8
+ import { getAcceptHeader } from "../types";
8
9
  import type { TableOccurrence } from "./table-occurrence";
9
10
  import type { BaseTable } from "./base-table";
10
11
  import { QueryBuilder } from "./query-builder";
@@ -331,7 +332,7 @@ export class ExecutableUpdateBuilder<
331
332
  };
332
333
  }
333
334
 
334
- toRequest(baseUrl: string): Request {
335
+ toRequest(baseUrl: string, options?: ExecuteOptions): Request {
335
336
  const config = this.getRequestConfig();
336
337
  const fullUrl = `${baseUrl}${config.url}`;
337
338
 
@@ -339,7 +340,7 @@ export class ExecutableUpdateBuilder<
339
340
  method: config.method,
340
341
  headers: {
341
342
  "Content-Type": "application/json",
342
- Accept: "application/json",
343
+ Accept: getAcceptHeader(options?.includeODataAnnotations),
343
344
  },
344
345
  body: config.body,
345
346
  });
package/src/errors.ts CHANGED
@@ -151,6 +151,22 @@ export class InvalidLocationHeaderError extends FMODataError {
151
151
  }
152
152
  }
153
153
 
154
+ export class ResponseParseError extends FMODataError {
155
+ readonly kind = "ResponseParseError" as const;
156
+ readonly url: string;
157
+ readonly rawText?: string;
158
+
159
+ constructor(
160
+ url: string,
161
+ message: string,
162
+ options?: { rawText?: string; cause?: Error },
163
+ ) {
164
+ super(message, options?.cause ? { cause: options.cause } : undefined);
165
+ this.url = url;
166
+ this.rawText = options?.rawText;
167
+ }
168
+ }
169
+
154
170
  // ============================================
155
171
  // Type Guards
156
172
  // ============================================
@@ -185,6 +201,12 @@ export function isRecordCountMismatchError(
185
201
  return error instanceof RecordCountMismatchError;
186
202
  }
187
203
 
204
+ export function isResponseParseError(
205
+ error: unknown,
206
+ ): error is ResponseParseError {
207
+ return error instanceof ResponseParseError;
208
+ }
209
+
188
210
  export function isFMODataError(error: unknown): error is FMODataError {
189
211
  return error instanceof FMODataError;
190
212
  }
@@ -214,4 +236,5 @@ export type FMODataErrorType =
214
236
  | ValidationError
215
237
  | ResponseStructureError
216
238
  | RecordCountMismatchError
217
- | InvalidLocationHeaderError;
239
+ | InvalidLocationHeaderError
240
+ | ResponseParseError;
package/src/index.ts CHANGED
@@ -62,12 +62,14 @@ export {
62
62
  ValidationError,
63
63
  ResponseStructureError,
64
64
  RecordCountMismatchError,
65
+ ResponseParseError,
65
66
  isHTTPError,
66
67
  isValidationError,
67
68
  isODataError,
68
69
  isSchemaLockedError,
69
70
  isResponseStructureError,
70
71
  isRecordCountMismatchError,
72
+ isResponseParseError,
71
73
  isFMODataError,
72
74
  } from "./errors";
73
75