@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.
- package/dist/esm/client/batch-builder.d.ts +1 -1
- package/dist/esm/client/batch-builder.js +2 -2
- package/dist/esm/client/batch-builder.js.map +1 -1
- package/dist/esm/client/delete-builder.d.ts +1 -1
- package/dist/esm/client/delete-builder.js +3 -2
- package/dist/esm/client/delete-builder.js.map +1 -1
- package/dist/esm/client/entity-set.d.ts +1 -1
- package/dist/esm/client/entity-set.js +26 -0
- package/dist/esm/client/entity-set.js.map +1 -1
- package/dist/esm/client/filemaker-odata.js +25 -9
- package/dist/esm/client/filemaker-odata.js.map +1 -1
- package/dist/esm/client/insert-builder.d.ts +1 -5
- package/dist/esm/client/insert-builder.js +7 -23
- package/dist/esm/client/insert-builder.js.map +1 -1
- package/dist/esm/client/query-builder.d.ts +1 -5
- package/dist/esm/client/query-builder.js +56 -66
- package/dist/esm/client/query-builder.js.map +1 -1
- package/dist/esm/client/record-builder.d.ts +1 -5
- package/dist/esm/client/record-builder.js +38 -25
- package/dist/esm/client/record-builder.js.map +1 -1
- package/dist/esm/client/response-processor.d.ts +0 -5
- package/dist/esm/client/sanitize-json.d.ts +35 -0
- package/dist/esm/client/sanitize-json.js +27 -0
- package/dist/esm/client/sanitize-json.js.map +1 -0
- package/dist/esm/client/update-builder.d.ts +1 -1
- package/dist/esm/client/update-builder.js +3 -2
- package/dist/esm/client/update-builder.js.map +1 -1
- package/dist/esm/errors.d.ts +11 -1
- package/dist/esm/errors.js +15 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +3 -1
- package/dist/esm/types.d.ts +8 -1
- package/dist/esm/types.js +7 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/validation.js +3 -4
- package/dist/esm/validation.js.map +1 -1
- package/package.json +3 -2
- package/src/client/batch-builder.ts +2 -2
- package/src/client/delete-builder.ts +3 -2
- package/src/client/entity-set.ts +40 -2
- package/src/client/filemaker-odata.ts +39 -10
- package/src/client/insert-builder.ts +8 -33
- package/src/client/query-builder.ts +65 -82
- package/src/client/record-builder.ts +50 -38
- package/src/client/response-processor.ts +0 -13
- package/src/client/sanitize-json.ts +66 -0
- package/src/client/update-builder.ts +3 -2
- package/src/errors.ts +24 -1
- package/src/index.ts +2 -0
- package/src/types.ts +13 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
1227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|