@proofkit/fmodata 0.1.0-alpha.0 → 0.1.0-alpha.10

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 (75) hide show
  1. package/README.md +1624 -18
  2. package/dist/esm/client/base-table.d.ts +117 -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/database.d.ts +55 -6
  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 +25 -11
  18. package/dist/esm/client/entity-set.js +31 -11
  19. package/dist/esm/client/entity-set.js.map +1 -1
  20. package/dist/esm/client/filemaker-odata.d.ts +23 -4
  21. package/dist/esm/client/filemaker-odata.js +124 -29
  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 +27 -6
  27. package/dist/esm/client/query-builder.js +457 -210
  28. package/dist/esm/client/query-builder.js.map +1 -1
  29. package/dist/esm/client/record-builder.d.ts +96 -9
  30. package/dist/esm/client/record-builder.js +378 -39
  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 +48 -1
  37. package/dist/esm/client/table-occurrence.js +29 -2
  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 +10 -3
  46. package/dist/esm/index.js +28 -5
  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 +158 -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 +175 -18
  60. package/src/client/delete-builder.ts +149 -48
  61. package/src/client/entity-set.ts +114 -23
  62. package/src/client/filemaker-odata.ts +179 -35
  63. package/src/client/insert-builder.ts +350 -40
  64. package/src/client/query-builder.ts +616 -237
  65. package/src/client/query-builder.ts.bak +1457 -0
  66. package/src/client/record-builder.ts +692 -65
  67. package/src/client/response-processor.ts +103 -0
  68. package/src/client/schema-manager.ts +246 -0
  69. package/src/client/table-occurrence.ts +78 -3
  70. package/src/client/update-builder.ts +235 -49
  71. package/src/errors.ts +217 -0
  72. package/src/index.ts +59 -2
  73. package/src/transform.ts +249 -0
  74. package/src/types.ts +201 -35
  75. package/src/validation.ts +120 -36
@@ -4,7 +4,6 @@ import type {
4
4
  ExecutionContext,
5
5
  ExecutableBuilder,
6
6
  WithSystemFields,
7
- ODataRecordMetadata,
8
7
  Result,
9
8
  InferSchemaType,
10
9
  ExecuteOptions,
@@ -15,9 +14,24 @@ import type { Filter } from "../filter-types";
15
14
  import type { TableOccurrence } from "./table-occurrence";
16
15
  import type { BaseTable } from "./base-table";
17
16
  import { validateListResponse, validateSingleResponse } from "../validation";
17
+ import { RecordCountMismatchError } from "../errors";
18
18
  import { type FFetchOptions } from "@fetchkit/ffetch";
19
- import { z } from "zod/v4";
20
19
  import type { StandardSchemaV1 } from "@standard-schema/spec";
20
+ import {
21
+ transformFieldNamesArray,
22
+ transformFieldName,
23
+ transformOrderByField,
24
+ transformResponseFields,
25
+ getTableIdentifiers,
26
+ } from "../transform";
27
+
28
+
29
+ /**
30
+ * Default maximum number of records to return in a list query.
31
+ * This prevents stack overflow issues with large datasets while still
32
+ * allowing substantial data retrieval. Users can override with .top().
33
+ */
34
+ const DEFAULT_TOP = 1000;
21
35
 
22
36
  // Helper type to extract navigation relation names from an occurrence
23
37
  type ExtractNavigationNames<
@@ -42,19 +56,19 @@ type FindNavigationTarget<
42
56
  ? Name extends keyof Nav
43
57
  ? ResolveNavigationItem<Nav[Name]>
44
58
  : TableOccurrence<
45
- BaseTable<Record<string, z.ZodTypeAny>, any>,
59
+ BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
46
60
  any,
47
61
  any,
48
62
  any
49
63
  >
50
64
  : TableOccurrence<
51
- BaseTable<Record<string, z.ZodTypeAny>, any>,
65
+ BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
52
66
  any,
53
67
  any,
54
68
  any
55
69
  >
56
70
  : TableOccurrence<
57
- BaseTable<Record<string, z.ZodTypeAny>, any>,
71
+ BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
58
72
  any,
59
73
  any,
60
74
  any
@@ -67,7 +81,7 @@ type GetTargetSchemaType<
67
81
  > = [FindNavigationTarget<O, Rel>] extends [
68
82
  TableOccurrence<infer BT, any, any, any>,
69
83
  ]
70
- ? [BT] extends [BaseTable<infer S, any>]
84
+ ? [BT] extends [BaseTable<infer S, any, any, any>]
71
85
  ? [S] extends [Record<string, StandardSchemaV1>]
72
86
  ? InferSchemaType<S>
73
87
  : Record<string, any>
@@ -81,7 +95,38 @@ type ExpandConfig = {
81
95
  };
82
96
 
83
97
  // Type to represent expanded relations
84
- type ExpandedRelations = Record<string, { schema: any; selected: any }>;
98
+ export type ExpandedRelations = Record<string, { schema: any; selected: any }>;
99
+
100
+ export type QueryReturnType<
101
+ T extends Record<string, any>,
102
+ Selected extends keyof T,
103
+ SingleMode extends "exact" | "maybe" | false,
104
+ IsCount extends boolean,
105
+ Expands extends ExpandedRelations,
106
+ > = IsCount extends true
107
+ ? number
108
+ : SingleMode extends "exact"
109
+ ? Pick<T, Selected> & {
110
+ [K in keyof Expands]: Pick<
111
+ Expands[K]["schema"],
112
+ Expands[K]["selected"]
113
+ >[];
114
+ }
115
+ : SingleMode extends "maybe"
116
+ ?
117
+ | (Pick<T, Selected> & {
118
+ [K in keyof Expands]: Pick<
119
+ Expands[K]["schema"],
120
+ Expands[K]["selected"]
121
+ >[];
122
+ })
123
+ | null
124
+ : (Pick<T, Selected> & {
125
+ [K in keyof Expands]: Pick<
126
+ Expands[K]["schema"],
127
+ Expands[K]["selected"]
128
+ >[];
129
+ })[];
85
130
 
86
131
  export class QueryBuilder<
87
132
  T extends Record<string, any>,
@@ -90,7 +135,11 @@ export class QueryBuilder<
90
135
  IsCount extends boolean = false,
91
136
  Occ extends TableOccurrence<any, any, any, any> | undefined = undefined,
92
137
  Expands extends ExpandedRelations = {},
93
- > {
138
+ > implements
139
+ ExecutableBuilder<
140
+ QueryReturnType<T, Selected, SingleMode, IsCount, Expands>
141
+ >
142
+ {
94
143
  private queryOptions: Partial<QueryOptions<T>> = {};
95
144
  private expandConfigs: ExpandConfig[] = [];
96
145
  private singleMode: SingleMode = false as SingleMode;
@@ -104,16 +153,36 @@ export class QueryBuilder<
104
153
  private navigateRelation?: string;
105
154
  private navigateSourceTableName?: string;
106
155
  private navigateBaseRelation?: string;
156
+ private databaseUseEntityIds: boolean;
157
+
107
158
  constructor(config: {
108
159
  occurrence?: Occ;
109
160
  tableName: string;
110
161
  databaseName: string;
111
162
  context: ExecutionContext;
163
+ databaseUseEntityIds?: boolean;
112
164
  }) {
113
165
  this.occurrence = config.occurrence;
114
166
  this.tableName = config.tableName;
115
167
  this.databaseName = config.databaseName;
116
168
  this.context = config.context;
169
+ this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
170
+ }
171
+
172
+ /**
173
+ * Helper to merge database-level useEntityIds with per-request options
174
+ */
175
+ private mergeExecuteOptions(
176
+ options?: RequestInit & FFetchOptions & ExecuteOptions,
177
+ ): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
178
+ // If useEntityIds is not set in options, use the database-level setting
179
+ return {
180
+ ...options,
181
+ useEntityIds:
182
+ options?.useEntityIds === undefined
183
+ ? this.databaseUseEntityIds
184
+ : options.useEntityIds,
185
+ };
117
186
  }
118
187
 
119
188
  /**
@@ -133,6 +202,31 @@ export class QueryBuilder<
133
202
  return rest as T;
134
203
  }
135
204
 
205
+ /**
206
+ * Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
207
+ * @param useEntityIds - Optional override for entity ID usage
208
+ */
209
+ private getTableId(useEntityIds?: boolean): string {
210
+ if (!this.occurrence) {
211
+ return this.tableName;
212
+ }
213
+
214
+ const contextDefault = this.context._getUseEntityIds?.() ?? false;
215
+ const shouldUseIds = useEntityIds ?? contextDefault;
216
+
217
+ if (shouldUseIds) {
218
+ const identifiers = getTableIdentifiers(this.occurrence);
219
+ if (!identifiers.id) {
220
+ throw new Error(
221
+ `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`,
222
+ );
223
+ }
224
+ return identifiers.id;
225
+ }
226
+
227
+ return this.occurrence.getTableName();
228
+ }
229
+
136
230
  select<K extends keyof T>(
137
231
  ...fields: K[]
138
232
  ): QueryBuilder<T, K, SingleMode, IsCount, Occ, Expands> {
@@ -149,6 +243,7 @@ export class QueryBuilder<
149
243
  tableName: this.tableName,
150
244
  databaseName: this.databaseName,
151
245
  context: this.context,
246
+ databaseUseEntityIds: this.databaseUseEntityIds,
152
247
  });
153
248
  newBuilder.queryOptions = {
154
249
  ...this.queryOptions,
@@ -205,16 +300,21 @@ export class QueryBuilder<
205
300
  const andConditions: any[] = [];
206
301
 
207
302
  for (const [field, value] of Object.entries(filter)) {
303
+ // Transform field name to FMFID if using entity IDs
304
+ const fieldId = this.occurrence?.baseTable
305
+ ? transformFieldName(field, this.occurrence.baseTable)
306
+ : field;
307
+
208
308
  if (Array.isArray(value)) {
209
309
  // Array of operators - convert to AND conditions
210
310
  if (value.length === 1) {
211
311
  // Single operator in array - unwrap it
212
- result[field] = value[0];
312
+ result[fieldId] = value[0];
213
313
  } else {
214
314
  // Multiple operators - combine with AND
215
315
  // Create separate conditions for each operator
216
316
  for (const op of value) {
217
- andConditions.push({ [field]: op });
317
+ andConditions.push({ [fieldId]: op });
218
318
  }
219
319
  }
220
320
  } else if (
@@ -240,14 +340,14 @@ export class QueryBuilder<
240
340
 
241
341
  if (isOperatorObject) {
242
342
  // Single operator object - pass through
243
- result[field] = value;
343
+ result[fieldId] = value;
244
344
  } else {
245
345
  // Regular object - might be nested filter, pass through
246
- result[field] = value;
346
+ result[fieldId] = value;
247
347
  }
248
348
  } else {
249
349
  // Primitive value (shorthand) - pass through
250
- result[field] = value;
350
+ result[fieldId] = value;
251
351
  }
252
352
  }
253
353
 
@@ -277,7 +377,21 @@ export class QueryBuilder<
277
377
  orderBy(
278
378
  orderBy: QueryOptions<T>["orderBy"],
279
379
  ): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
280
- this.queryOptions.orderBy = orderBy;
380
+ // Transform field names to FMFIDs if using entity IDs
381
+ if (this.occurrence?.baseTable && orderBy) {
382
+ if (Array.isArray(orderBy)) {
383
+ this.queryOptions.orderBy = orderBy.map((field) =>
384
+ transformOrderByField(String(field), this.occurrence!.baseTable),
385
+ );
386
+ } else {
387
+ this.queryOptions.orderBy = transformOrderByField(
388
+ String(orderBy),
389
+ this.occurrence.baseTable,
390
+ );
391
+ }
392
+ } else {
393
+ this.queryOptions.orderBy = orderBy;
394
+ }
281
395
  return this;
282
396
  }
283
397
 
@@ -297,13 +411,26 @@ export class QueryBuilder<
297
411
 
298
412
  /**
299
413
  * Formats select fields for use in query strings.
414
+ * - Transforms field names to FMFIDs if using entity IDs
300
415
  * - Wraps "id" fields in double quotes
301
416
  * - URL-encodes special characters but preserves spaces
302
417
  */
303
- private formatSelectFields(select: QueryOptions<any>["select"]): string {
418
+ private formatSelectFields(
419
+ select: QueryOptions<any>["select"],
420
+ baseTable?: BaseTable<any, any, any, any>,
421
+ ): string {
304
422
  if (!select) return "";
305
423
  const selectFieldsArray = Array.isArray(select) ? select : [select];
306
- return selectFieldsArray
424
+
425
+ // Transform to field IDs if using entity IDs
426
+ const transformedFields = baseTable
427
+ ? transformFieldNamesArray(
428
+ selectFieldsArray.map((f) => String(f)),
429
+ baseTable,
430
+ )
431
+ : selectFieldsArray.map((f) => String(f));
432
+
433
+ return transformedFields
307
434
  .map((field) => {
308
435
  if (field === "id") return `"id"`;
309
436
  const encodedField = encodeURIComponent(String(field));
@@ -335,6 +462,8 @@ export class QueryBuilder<
335
462
  relation: config.relation,
336
463
  targetSchema: targetSchema,
337
464
  targetOccurrence: targetOccurrence,
465
+ targetBaseTable: targetOccurrence?.baseTable,
466
+ occurrence: targetOccurrence, // Add occurrence for transformation
338
467
  selectedFields: selectedFields,
339
468
  nestedExpands: undefined, // TODO: Handle nested expands if needed
340
469
  };
@@ -344,6 +473,7 @@ export class QueryBuilder<
344
473
  /**
345
474
  * Builds OData expand query string from expand configurations.
346
475
  * Handles nested expands recursively.
476
+ * Transforms relation names to FMTIDs if using entity IDs.
347
477
  */
348
478
  private buildExpandString(configs: ExpandConfig[]): string {
349
479
  if (configs.length === 0) {
@@ -352,20 +482,35 @@ export class QueryBuilder<
352
482
 
353
483
  return configs
354
484
  .map((config) => {
485
+ // Get target occurrence for this relation
486
+ const targetOccurrence = this.occurrence?.navigation[config.relation];
487
+
488
+ // When using entity IDs, use the target table's FMTID in the expand parameter
489
+ // FileMaker expects FMTID in $expand when Prefer header is set
490
+ const relationName =
491
+ targetOccurrence && targetOccurrence.isUsingTableId()
492
+ ? targetOccurrence.getTableId()
493
+ : config.relation;
494
+
355
495
  if (!config.options || Object.keys(config.options).length === 0) {
356
496
  // Simple expand without options
357
- return config.relation;
497
+ return relationName;
358
498
  }
359
499
 
360
500
  // Build query options for this expand
361
501
  const parts: string[] = [];
362
502
 
363
503
  if (config.options.select) {
364
- const selectFields = this.formatSelectFields(config.options.select);
504
+ // Pass target base table for field transformation
505
+ const selectFields = this.formatSelectFields(
506
+ config.options.select,
507
+ targetOccurrence?.baseTable,
508
+ );
365
509
  parts.push(`$select=${selectFields}`);
366
510
  }
367
511
 
368
512
  if (config.options.filter) {
513
+ // Filter should already be transformed by the nested builder
369
514
  // Use odata-query to build filter string
370
515
  const filterQuery = buildQuery({ filter: config.options.filter });
371
516
  const filterMatch = filterQuery.match(/\$filter=([^&]+)/);
@@ -375,6 +520,7 @@ export class QueryBuilder<
375
520
  }
376
521
 
377
522
  if (config.options.orderBy) {
523
+ // OrderBy should already be transformed by the nested builder
378
524
  const orderByValue = Array.isArray(config.options.orderBy)
379
525
  ? config.options.orderBy.join(",")
380
526
  : config.options.orderBy;
@@ -398,10 +544,10 @@ export class QueryBuilder<
398
544
  }
399
545
 
400
546
  if (parts.length === 0) {
401
- return config.relation;
547
+ return relationName;
402
548
  }
403
549
 
404
- return `${config.relation}(${parts.join(";")})`;
550
+ return `${relationName}(${parts.join(";")})`;
405
551
  })
406
552
  .join(",");
407
553
  }
@@ -456,6 +602,7 @@ export class QueryBuilder<
456
602
  tableName: targetOccurrence?.name ?? (relation as string),
457
603
  databaseName: this.databaseName,
458
604
  context: this.context,
605
+ databaseUseEntityIds: this.databaseUseEntityIds,
459
606
  });
460
607
 
461
608
  // Cast to the expected type for the callback
@@ -517,6 +664,7 @@ export class QueryBuilder<
517
664
  tableName: this.tableName,
518
665
  databaseName: this.databaseName,
519
666
  context: this.context,
667
+ databaseUseEntityIds: this.databaseUseEntityIds,
520
668
  });
521
669
  newBuilder.queryOptions = { ...this.queryOptions };
522
670
  newBuilder.expandConfigs = [...this.expandConfigs];
@@ -544,6 +692,7 @@ export class QueryBuilder<
544
692
  tableName: this.tableName,
545
693
  databaseName: this.databaseName,
546
694
  context: this.context,
695
+ databaseUseEntityIds: this.databaseUseEntityIds,
547
696
  });
548
697
  newBuilder.queryOptions = { ...this.queryOptions };
549
698
  newBuilder.expandConfigs = [...this.expandConfigs];
@@ -571,6 +720,7 @@ export class QueryBuilder<
571
720
  tableName: this.tableName,
572
721
  databaseName: this.databaseName,
573
722
  context: this.context,
723
+ databaseUseEntityIds: this.databaseUseEntityIds,
574
724
  });
575
725
  newBuilder.queryOptions = { ...this.queryOptions, count: true };
576
726
  newBuilder.expandConfigs = [...this.expandConfigs];
@@ -622,240 +772,180 @@ export class QueryBuilder<
622
772
  >[]
623
773
  >
624
774
  > {
625
- try {
626
- // Build query without expand (we'll add it manually)
627
- const queryOptionsWithoutExpand = { ...this.queryOptions };
628
- delete queryOptionsWithoutExpand.expand;
629
-
630
- // Format select fields before building query
631
- if (queryOptionsWithoutExpand.select) {
632
- queryOptionsWithoutExpand.select = this.formatSelectFields(
633
- queryOptionsWithoutExpand.select,
634
- ) as any;
635
- }
775
+ // Build query without expand (we'll add it manually)
776
+ const queryOptionsWithoutExpand = { ...this.queryOptions };
777
+ delete queryOptionsWithoutExpand.expand;
636
778
 
637
- let queryString = buildQuery(queryOptionsWithoutExpand);
779
+ const mergedOptions = this.mergeExecuteOptions(options);
638
780
 
639
- // Build custom expand string
640
- const expandString = this.buildExpandString(this.expandConfigs);
641
- if (expandString) {
642
- const separator = queryString.includes("?") ? "&" : "?";
643
- queryString = `${queryString}${separator}$expand=${expandString}`;
644
- }
781
+ // Format select fields before building query
782
+ if (queryOptionsWithoutExpand.select) {
783
+ queryOptionsWithoutExpand.select = this.formatSelectFields(
784
+ queryOptionsWithoutExpand.select,
785
+ this.occurrence?.baseTable,
786
+ ) as any;
787
+ }
645
788
 
646
- // Handle navigation from RecordBuilder
647
- if (
648
- this.isNavigate &&
649
- this.navigateRecordId &&
650
- this.navigateRelation &&
651
- this.navigateSourceTableName
652
- ) {
653
- let url: string;
654
- if (this.navigateBaseRelation) {
655
- // Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
656
- url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
657
- } else {
658
- // Normal navigation: /sourceTable('recordId')/relation
659
- url = `/${this.databaseName}/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
660
- }
661
- const response = await this.context._makeRequest(url, options);
789
+ let queryString = buildQuery(queryOptionsWithoutExpand);
662
790
 
663
- // Skip validation if requested
664
- if (options?.skipValidation === true) {
665
- const resp = response as any;
666
- if (this.singleMode !== false) {
667
- const records = resp.value ?? [resp];
668
- const count = Array.isArray(records) ? records.length : 1;
791
+ // Build custom expand string
792
+ const expandString = this.buildExpandString(this.expandConfigs);
793
+ if (expandString) {
794
+ const separator = queryString.includes("?") ? "&" : "?";
795
+ queryString = `${queryString}${separator}$expand=${expandString}`;
796
+ }
669
797
 
670
- if (count > 1) {
671
- return {
672
- data: undefined,
673
- error: new Error(
674
- `Expected ${this.singleMode === "exact" ? "exactly one" : "at most one"} record, but received ${count}`,
675
- ),
676
- };
677
- }
798
+ // Handle navigation from RecordBuilder
799
+ if (
800
+ this.isNavigate &&
801
+ this.navigateRecordId &&
802
+ this.navigateRelation &&
803
+ this.navigateSourceTableName
804
+ ) {
805
+ let url: string;
806
+ if (this.navigateBaseRelation) {
807
+ // Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
808
+ url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
809
+ } else {
810
+ // Normal navigation: /sourceTable('recordId')/relation
811
+ url = `/${this.databaseName}/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
812
+ }
813
+ const result = await this.context._makeRequest(url, mergedOptions);
678
814
 
679
- if (count === 0) {
680
- if (this.singleMode === "exact") {
681
- return {
682
- data: undefined,
683
- error: new Error(
684
- "Expected exactly one record, but received none",
685
- ),
686
- };
687
- }
688
- return { data: null as any, error: undefined };
689
- }
815
+ if (result.error) {
816
+ return { data: undefined, error: result.error };
817
+ }
690
818
 
691
- const record = Array.isArray(records) ? records[0] : records;
692
- const stripped = this.stripODataAnnotationsIfNeeded(
693
- record,
694
- options,
695
- );
696
- return { data: stripped as any, error: undefined };
697
- } else {
698
- const records = resp.value ?? [];
699
- const stripped = records.map((record: any) =>
700
- this.stripODataAnnotationsIfNeeded(record, options),
701
- );
702
- return { data: stripped as any, error: undefined };
703
- }
704
- }
819
+ let response = result.data;
705
820
 
706
- // Get schema from occurrence if available
707
- const schema = this.occurrence?.baseTable?.schema;
708
- const selectedFields = this.queryOptions.select as
709
- | (keyof T)[]
710
- | undefined;
821
+ // Transform response field IDs back to names if using entity IDs
822
+ // Only transform if useEntityIds resolves to true (respects per-request override)
823
+ const shouldUseIds = mergedOptions.useEntityIds ?? false;
824
+
825
+ if (this.occurrence?.baseTable && shouldUseIds) {
711
826
  const expandValidationConfigs = this.buildExpandValidationConfigs(
712
827
  this.expandConfigs,
713
828
  );
714
-
715
- if (this.singleMode !== false) {
716
- const validation = await validateSingleResponse<T>(
717
- response,
718
- schema,
719
- selectedFields,
720
- expandValidationConfigs,
721
- this.singleMode,
722
- );
723
- if (!validation.valid) {
724
- return { data: undefined, error: validation.error };
725
- }
726
- const stripped = validation.data
727
- ? this.stripODataAnnotationsIfNeeded(validation.data, options)
728
- : null;
729
- return { data: stripped as any, error: undefined };
730
- } else {
731
- const validation = await validateListResponse<T>(
732
- response,
733
- schema,
734
- selectedFields,
735
- expandValidationConfigs,
736
- );
737
- if (!validation.valid) {
738
- return { data: undefined, error: validation.error };
739
- }
740
- const stripped = validation.data.map((record) =>
741
- this.stripODataAnnotationsIfNeeded(record, options),
742
- );
743
- return { data: stripped as any, error: undefined };
744
- }
829
+ response = transformResponseFields(
830
+ response,
831
+ this.occurrence.baseTable,
832
+ expandValidationConfigs,
833
+ );
745
834
  }
746
835
 
747
- // Handle navigation from EntitySet (without record ID)
748
- if (
749
- this.isNavigate &&
750
- !this.navigateRecordId &&
751
- this.navigateRelation &&
752
- this.navigateSourceTableName
753
- ) {
754
- const response = await this.context._makeRequest(
755
- `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`,
756
- options,
757
- );
836
+ // Skip validation if requested
837
+ if (options?.skipValidation === true) {
838
+ const resp = response as any;
839
+ if (this.singleMode !== false) {
840
+ const records = resp.value ?? [resp];
841
+ const count = Array.isArray(records) ? records.length : 1;
758
842
 
759
- // Skip validation if requested
760
- if (options?.skipValidation === true) {
761
- const resp = response as any;
762
- if (this.singleMode !== false) {
763
- const records = resp.value ?? [resp];
764
- const count = Array.isArray(records) ? records.length : 1;
843
+ if (count > 1) {
844
+ return {
845
+ data: undefined,
846
+ error: new RecordCountMismatchError(
847
+ this.singleMode === "exact" ? "one" : "at-most-one",
848
+ count,
849
+ ),
850
+ };
851
+ }
765
852
 
766
- if (count > 1) {
853
+ if (count === 0) {
854
+ if (this.singleMode === "exact") {
767
855
  return {
768
856
  data: undefined,
769
- error: new Error(
770
- `Expected ${this.singleMode === "exact" ? "exactly one" : "at most one"} record, but received ${count}`,
771
- ),
857
+ error: new RecordCountMismatchError("one", 0),
772
858
  };
773
859
  }
774
-
775
- if (count === 0) {
776
- if (this.singleMode === "exact") {
777
- return {
778
- data: undefined,
779
- error: new Error(
780
- "Expected exactly one record, but received none",
781
- ),
782
- };
783
- }
784
- return { data: null as any, error: undefined };
785
- }
786
-
787
- const record = Array.isArray(records) ? records[0] : records;
788
- const stripped = this.stripODataAnnotationsIfNeeded(
789
- record,
790
- options,
791
- );
792
- return { data: stripped as any, error: undefined };
793
- } else {
794
- const records = resp.value ?? [];
795
- const stripped = records.map((record: any) =>
796
- this.stripODataAnnotationsIfNeeded(record, options),
797
- );
798
- return { data: stripped as any, error: undefined };
860
+ return { data: null as any, error: undefined };
799
861
  }
800
- }
801
862
 
802
- // Get schema from occurrence if available
803
- const schema = this.occurrence?.baseTable?.schema;
804
- const selectedFields = this.queryOptions.select as
805
- | (keyof T)[]
806
- | undefined;
807
- const expandValidationConfigs = this.buildExpandValidationConfigs(
808
- this.expandConfigs,
809
- );
810
-
811
- if (this.singleMode !== false) {
812
- const validation = await validateSingleResponse<T>(
813
- response,
814
- schema,
815
- selectedFields,
816
- expandValidationConfigs,
817
- this.singleMode,
818
- );
819
- if (!validation.valid) {
820
- return { data: undefined, error: validation.error };
821
- }
822
- const stripped = validation.data
823
- ? this.stripODataAnnotationsIfNeeded(validation.data, options)
824
- : null;
863
+ const record = Array.isArray(records) ? records[0] : records;
864
+ const stripped = this.stripODataAnnotationsIfNeeded(record, options);
825
865
  return { data: stripped as any, error: undefined };
826
866
  } else {
827
- const validation = await validateListResponse<T>(
828
- response,
829
- schema,
830
- selectedFields,
831
- expandValidationConfigs,
832
- );
833
- if (!validation.valid) {
834
- return { data: undefined, error: validation.error };
835
- }
836
- const stripped = validation.data.map((record) =>
867
+ const records = resp.value ?? [];
868
+ const stripped = records.map((record: any) =>
837
869
  this.stripODataAnnotationsIfNeeded(record, options),
838
870
  );
839
871
  return { data: stripped as any, error: undefined };
840
872
  }
841
873
  }
842
874
 
843
- // Handle $count endpoint
844
- if (this.isCountMode) {
845
- const result = await this.context._makeRequest(
846
- `/${this.databaseName}/${this.tableName}/$count${queryString}`,
847
- options,
875
+ // Get schema from occurrence if available
876
+ const schema = this.occurrence?.baseTable?.schema;
877
+ const selectedFields = this.queryOptions.select as
878
+ | (keyof T)[]
879
+ | undefined;
880
+ const expandValidationConfigs = this.buildExpandValidationConfigs(
881
+ this.expandConfigs,
882
+ );
883
+
884
+ if (this.singleMode !== false) {
885
+ const validation = await validateSingleResponse<T>(
886
+ response,
887
+ schema,
888
+ selectedFields,
889
+ expandValidationConfigs,
890
+ this.singleMode,
891
+ );
892
+ if (!validation.valid) {
893
+ return { data: undefined, error: validation.error };
894
+ }
895
+ const stripped = validation.data
896
+ ? this.stripODataAnnotationsIfNeeded(validation.data, options)
897
+ : null;
898
+ return { data: stripped as any, error: undefined };
899
+ } else {
900
+ const validation = await validateListResponse<T>(
901
+ response,
902
+ schema,
903
+ selectedFields,
904
+ expandValidationConfigs,
848
905
  );
849
- // OData returns count as a string, convert to number
850
- const count = typeof result === "string" ? Number(result) : result;
851
- return { data: count as number, error: undefined } as any;
906
+ if (!validation.valid) {
907
+ return { data: undefined, error: validation.error };
908
+ }
909
+ const stripped = validation.data.map((record) =>
910
+ this.stripODataAnnotationsIfNeeded(record, options),
911
+ );
912
+ return { data: stripped as any, error: undefined };
852
913
  }
914
+ }
853
915
 
854
- const response = await this.context._makeRequest(
855
- `/${this.databaseName}/${this.tableName}${queryString}`,
856
- options,
916
+ // Handle navigation from EntitySet (without record ID)
917
+ if (
918
+ this.isNavigate &&
919
+ !this.navigateRecordId &&
920
+ this.navigateRelation &&
921
+ this.navigateSourceTableName
922
+ ) {
923
+ const result = await this.context._makeRequest(
924
+ `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`,
925
+ mergedOptions,
857
926
  );
858
927
 
928
+ if (result.error) {
929
+ return { data: undefined, error: result.error };
930
+ }
931
+
932
+ let response = result.data;
933
+
934
+ // Transform response field IDs back to names if using entity IDs
935
+ // Only transform if useEntityIds resolves to true (respects per-request override)
936
+ const shouldUseIds = mergedOptions.useEntityIds ?? false;
937
+
938
+ if (this.occurrence?.baseTable && shouldUseIds) {
939
+ const expandValidationConfigs = this.buildExpandValidationConfigs(
940
+ this.expandConfigs,
941
+ );
942
+ response = transformResponseFields(
943
+ response,
944
+ this.occurrence.baseTable,
945
+ expandValidationConfigs,
946
+ );
947
+ }
948
+
859
949
  // Skip validation if requested
860
950
  if (options?.skipValidation === true) {
861
951
  const resp = response as any;
@@ -866,8 +956,9 @@ export class QueryBuilder<
866
956
  if (count > 1) {
867
957
  return {
868
958
  data: undefined,
869
- error: new Error(
870
- `Expected ${this.singleMode === "exact" ? "exactly one" : "at most one"} record, but received ${count}`,
959
+ error: new RecordCountMismatchError(
960
+ this.singleMode === "exact" ? "one" : "at-most-one",
961
+ count,
871
962
  ),
872
963
  };
873
964
  }
@@ -876,9 +967,7 @@ export class QueryBuilder<
876
967
  if (this.singleMode === "exact") {
877
968
  return {
878
969
  data: undefined,
879
- error: new Error(
880
- "Expected exactly one record, but received none",
881
- ),
970
+ error: new RecordCountMismatchError("one", 0),
882
971
  };
883
972
  }
884
973
  return { data: null as any, error: undefined };
@@ -888,7 +977,6 @@ export class QueryBuilder<
888
977
  const stripped = this.stripODataAnnotationsIfNeeded(record, options);
889
978
  return { data: stripped as any, error: undefined };
890
979
  } else {
891
- // Handle list response structure
892
980
  const records = resp.value ?? [];
893
981
  const stripped = records.map((record: any) =>
894
982
  this.stripODataAnnotationsIfNeeded(record, options),
@@ -920,10 +1008,7 @@ export class QueryBuilder<
920
1008
  const stripped = validation.data
921
1009
  ? this.stripODataAnnotationsIfNeeded(validation.data, options)
922
1010
  : null;
923
- return {
924
- data: stripped as any,
925
- error: undefined,
926
- };
1011
+ return { data: stripped as any, error: undefined };
927
1012
  } else {
928
1013
  const validation = await validateListResponse<T>(
929
1014
  response,
@@ -937,15 +1022,136 @@ export class QueryBuilder<
937
1022
  const stripped = validation.data.map((record) =>
938
1023
  this.stripODataAnnotationsIfNeeded(record, options),
939
1024
  );
940
- return {
941
- data: stripped as any,
942
- error: undefined,
943
- };
1025
+ return { data: stripped as any, error: undefined };
1026
+ }
1027
+ }
1028
+
1029
+ // Handle $count endpoint
1030
+ if (this.isCountMode) {
1031
+ const tableId = this.getTableId(mergedOptions.useEntityIds);
1032
+ const result = await this.context._makeRequest(
1033
+ `/${this.databaseName}/${tableId}/$count${queryString}`,
1034
+ mergedOptions,
1035
+ );
1036
+
1037
+ if (result.error) {
1038
+ return { data: undefined, error: result.error };
944
1039
  }
945
- } catch (error) {
1040
+
1041
+ // OData returns count as a string, convert to number
1042
+ const count =
1043
+ typeof result.data === "string" ? Number(result.data) : result.data;
1044
+ return { data: count as number, error: undefined } as any;
1045
+ }
1046
+
1047
+ const tableId = this.getTableId(mergedOptions.useEntityIds);
1048
+ const result = await this.context._makeRequest(
1049
+ `/${this.databaseName}/${tableId}${queryString}`,
1050
+ mergedOptions,
1051
+ );
1052
+
1053
+ if (result.error) {
1054
+ return { data: undefined, error: result.error };
1055
+ }
1056
+
1057
+ let response = result.data;
1058
+
1059
+ // Transform response field IDs back to names if using entity IDs
1060
+ // Only transform if useEntityIds resolves to true (respects per-request override)
1061
+ const shouldUseIds = mergedOptions.useEntityIds ?? false;
1062
+
1063
+ if (this.occurrence?.baseTable && shouldUseIds) {
1064
+ const expandValidationConfigs = this.buildExpandValidationConfigs(
1065
+ this.expandConfigs,
1066
+ );
1067
+ response = transformResponseFields(
1068
+ response,
1069
+ this.occurrence.baseTable,
1070
+ expandValidationConfigs,
1071
+ );
1072
+ }
1073
+
1074
+ // Skip validation if requested
1075
+ if (options?.skipValidation === true) {
1076
+ const resp = response as any;
1077
+ if (this.singleMode !== false) {
1078
+ const records = resp.value ?? [resp];
1079
+ const count = Array.isArray(records) ? records.length : 1;
1080
+
1081
+ if (count > 1) {
1082
+ return {
1083
+ data: undefined,
1084
+ error: new RecordCountMismatchError(
1085
+ this.singleMode === "exact" ? "one" : "at-most-one",
1086
+ count,
1087
+ ),
1088
+ };
1089
+ }
1090
+
1091
+ if (count === 0) {
1092
+ if (this.singleMode === "exact") {
1093
+ return {
1094
+ data: undefined,
1095
+ error: new RecordCountMismatchError("one", 0),
1096
+ };
1097
+ }
1098
+ return { data: null as any, error: undefined };
1099
+ }
1100
+
1101
+ const record = Array.isArray(records) ? records[0] : records;
1102
+ const stripped = this.stripODataAnnotationsIfNeeded(record, options);
1103
+ return { data: stripped as any, error: undefined };
1104
+ } else {
1105
+ // Handle list response structure
1106
+ const records = resp.value ?? [];
1107
+ const stripped = records.map((record: any) =>
1108
+ this.stripODataAnnotationsIfNeeded(record, options),
1109
+ );
1110
+ return { data: stripped as any, error: undefined };
1111
+ }
1112
+ }
1113
+
1114
+ // Get schema from occurrence if available
1115
+ const schema = this.occurrence?.baseTable?.schema;
1116
+ const selectedFields = this.queryOptions.select as (keyof T)[] | undefined;
1117
+ const expandValidationConfigs = this.buildExpandValidationConfigs(
1118
+ this.expandConfigs,
1119
+ );
1120
+
1121
+ if (this.singleMode !== false) {
1122
+ const validation = await validateSingleResponse<T>(
1123
+ response,
1124
+ schema,
1125
+ selectedFields,
1126
+ expandValidationConfigs,
1127
+ this.singleMode,
1128
+ );
1129
+ if (!validation.valid) {
1130
+ return { data: undefined, error: validation.error };
1131
+ }
1132
+ const stripped = validation.data
1133
+ ? this.stripODataAnnotationsIfNeeded(validation.data, options)
1134
+ : null;
946
1135
  return {
947
- data: undefined,
948
- error: error instanceof Error ? error : new Error(String(error)),
1136
+ data: stripped as any,
1137
+ error: undefined,
1138
+ };
1139
+ } else {
1140
+ const validation = await validateListResponse<T>(
1141
+ response,
1142
+ schema,
1143
+ selectedFields,
1144
+ expandValidationConfigs,
1145
+ );
1146
+ if (!validation.valid) {
1147
+ return { data: undefined, error: validation.error };
1148
+ }
1149
+ const stripped = validation.data.map((record) =>
1150
+ this.stripODataAnnotationsIfNeeded(record, options),
1151
+ );
1152
+ return {
1153
+ data: stripped as any,
1154
+ error: undefined,
949
1155
  };
950
1156
  }
951
1157
  }
@@ -960,6 +1166,7 @@ export class QueryBuilder<
960
1166
  if (queryOptionsWithoutExpand.select) {
961
1167
  queryOptionsWithoutExpand.select = this.formatSelectFields(
962
1168
  queryOptionsWithoutExpand.select,
1169
+ this.occurrence?.baseTable,
963
1170
  ) as any;
964
1171
  }
965
1172
 
@@ -1026,6 +1233,7 @@ export class QueryBuilder<
1026
1233
  if (queryOptionsWithoutExpand.select) {
1027
1234
  queryOptionsWithoutExpand.select = this.formatSelectFields(
1028
1235
  queryOptionsWithoutExpand.select,
1236
+ this.occurrence?.baseTable,
1029
1237
  ) as any;
1030
1238
  }
1031
1239
 
@@ -1073,4 +1281,175 @@ export class QueryBuilder<
1073
1281
  url,
1074
1282
  };
1075
1283
  }
1284
+
1285
+ toRequest(baseUrl: string): Request {
1286
+ const config = this.getRequestConfig();
1287
+ const fullUrl = `${baseUrl}${config.url}`;
1288
+
1289
+ return new Request(fullUrl, {
1290
+ method: config.method,
1291
+ headers: {
1292
+ "Content-Type": "application/json",
1293
+ Accept: "application/json",
1294
+ },
1295
+ });
1296
+ }
1297
+
1298
+ async processResponse(
1299
+ response: Response,
1300
+ options?: ExecuteOptions,
1301
+ ): Promise<
1302
+ Result<QueryReturnType<T, Selected, SingleMode, IsCount, Expands>>
1303
+ > {
1304
+ // Handle 204 No Content (shouldn't happen for queries, but handle it gracefully)
1305
+ if (response.status === 204) {
1306
+ // Return empty list for list queries, null for single queries
1307
+ if (this.singleMode !== false) {
1308
+ if (this.singleMode === "maybe") {
1309
+ return { data: null as any, error: undefined };
1310
+ }
1311
+ return {
1312
+ data: undefined,
1313
+ error: new RecordCountMismatchError("one", 0),
1314
+ };
1315
+ }
1316
+ return { data: [] as any, error: undefined };
1317
+ }
1318
+
1319
+ // Parse the response body
1320
+ let rawData;
1321
+ try {
1322
+ rawData = await response.json();
1323
+ } catch (err) {
1324
+ // Check if it's an empty body error (common with 204 responses)
1325
+ if (err instanceof SyntaxError && response.status === 204) {
1326
+ // Handled above, but just in case
1327
+ return { data: [] as any, error: undefined };
1328
+ }
1329
+ return {
1330
+ data: undefined,
1331
+ error: {
1332
+ name: "ResponseParseError",
1333
+ message: `Failed to parse response JSON: ${err instanceof Error ? err.message : "Unknown error"}`,
1334
+ timestamp: new Date(),
1335
+ } as any,
1336
+ };
1337
+ }
1338
+
1339
+ if (!rawData) {
1340
+ return {
1341
+ data: undefined,
1342
+ error: {
1343
+ name: "ResponseError",
1344
+ message: "Response body was empty or null",
1345
+ timestamp: new Date(),
1346
+ } as any,
1347
+ };
1348
+ }
1349
+
1350
+ // Transform response field IDs back to names if using entity IDs
1351
+ // Only transform if useEntityIds resolves to true (respects per-request override)
1352
+ const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
1353
+
1354
+ let transformedData = rawData;
1355
+ if (this.occurrence?.baseTable && shouldUseIds) {
1356
+ const expandValidationConfigs = this.buildExpandValidationConfigs(
1357
+ this.expandConfigs,
1358
+ );
1359
+ transformedData = transformResponseFields(
1360
+ rawData,
1361
+ this.occurrence.baseTable,
1362
+ expandValidationConfigs,
1363
+ );
1364
+ }
1365
+
1366
+ // Skip validation if requested
1367
+ if (options?.skipValidation === true) {
1368
+ const resp = transformedData as any;
1369
+ if (this.singleMode !== false) {
1370
+ const records = resp.value ?? [resp];
1371
+ const count = Array.isArray(records) ? records.length : 1;
1372
+
1373
+ if (count > 1) {
1374
+ return {
1375
+ data: undefined,
1376
+ error: new RecordCountMismatchError(
1377
+ this.singleMode === "exact" ? "one" : "at-most-one",
1378
+ count,
1379
+ ),
1380
+ };
1381
+ }
1382
+
1383
+ if (count === 0) {
1384
+ if (this.singleMode === "exact") {
1385
+ return {
1386
+ data: undefined,
1387
+ error: new RecordCountMismatchError("one", 0),
1388
+ };
1389
+ }
1390
+ return { data: null as any, error: undefined };
1391
+ }
1392
+
1393
+ const record = Array.isArray(records) ? records[0] : records;
1394
+ const stripped = this.stripODataAnnotationsIfNeeded(record, options);
1395
+ return { data: stripped as any, error: undefined };
1396
+ } else {
1397
+ // Handle list response structure
1398
+ const records = resp.value ?? [];
1399
+ const stripped = records.map((record: any) =>
1400
+ this.stripODataAnnotationsIfNeeded(record, options),
1401
+ );
1402
+ return { data: stripped as any, error: undefined };
1403
+ }
1404
+ }
1405
+
1406
+ // Get schema from occurrence if available
1407
+ const schema = this.occurrence?.baseTable?.schema;
1408
+ const selectedFields = this.queryOptions.select as (keyof T)[] | undefined;
1409
+ const expandValidationConfigs = this.buildExpandValidationConfigs(
1410
+ this.expandConfigs,
1411
+ );
1412
+
1413
+ if (this.singleMode !== false) {
1414
+ // Single mode (one() or oneOrNull())
1415
+ const validation = await validateSingleResponse<T>(
1416
+ transformedData,
1417
+ schema,
1418
+ selectedFields,
1419
+ expandValidationConfigs,
1420
+ this.singleMode,
1421
+ );
1422
+
1423
+ if (!validation.valid) {
1424
+ return { data: undefined, error: validation.error };
1425
+ }
1426
+
1427
+ if (validation.data === null) {
1428
+ return { data: null as any, error: undefined };
1429
+ }
1430
+
1431
+ const stripped = this.stripODataAnnotationsIfNeeded(
1432
+ validation.data,
1433
+ options,
1434
+ );
1435
+ return { data: stripped as any, error: undefined };
1436
+ }
1437
+
1438
+ // List mode
1439
+ const validation = await validateListResponse<T>(
1440
+ transformedData,
1441
+ schema,
1442
+ selectedFields,
1443
+ expandValidationConfigs,
1444
+ );
1445
+
1446
+ if (!validation.valid) {
1447
+ return { data: undefined, error: validation.error };
1448
+ }
1449
+
1450
+ const stripped = validation.data.map((record) =>
1451
+ this.stripODataAnnotationsIfNeeded(record, options),
1452
+ );
1453
+ return { data: stripped as any, error: undefined };
1454
+ }
1076
1455
  }