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

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 (57) hide show
  1. package/README.md +357 -28
  2. package/dist/esm/client/base-table.d.ts +122 -5
  3. package/dist/esm/client/base-table.js +46 -5
  4. package/dist/esm/client/base-table.js.map +1 -1
  5. package/dist/esm/client/database.d.ts +20 -3
  6. package/dist/esm/client/database.js +62 -13
  7. package/dist/esm/client/database.js.map +1 -1
  8. package/dist/esm/client/delete-builder.js +24 -27
  9. package/dist/esm/client/delete-builder.js.map +1 -1
  10. package/dist/esm/client/entity-set.d.ts +9 -6
  11. package/dist/esm/client/entity-set.js +5 -1
  12. package/dist/esm/client/entity-set.js.map +1 -1
  13. package/dist/esm/client/filemaker-odata.d.ts +17 -4
  14. package/dist/esm/client/filemaker-odata.js +90 -27
  15. package/dist/esm/client/filemaker-odata.js.map +1 -1
  16. package/dist/esm/client/insert-builder.js +45 -34
  17. package/dist/esm/client/insert-builder.js.map +1 -1
  18. package/dist/esm/client/query-builder.d.ts +7 -2
  19. package/dist/esm/client/query-builder.js +273 -202
  20. package/dist/esm/client/query-builder.js.map +1 -1
  21. package/dist/esm/client/record-builder.d.ts +2 -2
  22. package/dist/esm/client/record-builder.js +50 -40
  23. package/dist/esm/client/record-builder.js.map +1 -1
  24. package/dist/esm/client/table-occurrence.d.ts +66 -2
  25. package/dist/esm/client/table-occurrence.js +36 -1
  26. package/dist/esm/client/table-occurrence.js.map +1 -1
  27. package/dist/esm/client/update-builder.js +39 -35
  28. package/dist/esm/client/update-builder.js.map +1 -1
  29. package/dist/esm/errors.d.ts +60 -0
  30. package/dist/esm/errors.js +122 -0
  31. package/dist/esm/errors.js.map +1 -0
  32. package/dist/esm/index.d.ts +5 -2
  33. package/dist/esm/index.js +25 -3
  34. package/dist/esm/index.js.map +1 -1
  35. package/dist/esm/transform.d.ts +56 -0
  36. package/dist/esm/transform.js +107 -0
  37. package/dist/esm/transform.js.map +1 -0
  38. package/dist/esm/types.d.ts +21 -5
  39. package/dist/esm/validation.d.ts +6 -3
  40. package/dist/esm/validation.js +104 -33
  41. package/dist/esm/validation.js.map +1 -1
  42. package/package.json +10 -1
  43. package/src/client/base-table.ts +155 -8
  44. package/src/client/database.ts +116 -13
  45. package/src/client/delete-builder.ts +42 -43
  46. package/src/client/entity-set.ts +21 -11
  47. package/src/client/filemaker-odata.ts +132 -34
  48. package/src/client/insert-builder.ts +69 -37
  49. package/src/client/query-builder.ts +345 -233
  50. package/src/client/record-builder.ts +84 -59
  51. package/src/client/table-occurrence.ts +118 -4
  52. package/src/client/update-builder.ts +77 -49
  53. package/src/errors.ts +185 -0
  54. package/src/index.ts +30 -1
  55. package/src/transform.ts +236 -0
  56. package/src/types.ts +112 -34
  57. package/src/validation.ts +120 -36
@@ -1,10 +1,40 @@
1
- import { z } from "zod/v4";
2
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
3
2
  import type { ExecutionContext } from "../types";
4
3
  import type { BaseTable } from "./base-table";
5
4
  import type { TableOccurrence } from "./table-occurrence";
6
5
  import { EntitySet } from "./entity-set";
7
6
 
7
+ // Type-level validation: Check if a TableOccurrence has fmtId (is TableOccurrenceWithIds)
8
+ type HasFmtId<T> = T extends { fmtId: string } ? true : false;
9
+
10
+ // Check if all occurrences in a tuple have fmtId
11
+ type AllHaveFmtId<Occurrences extends readonly any[]> =
12
+ Occurrences extends readonly [infer First, ...infer Rest]
13
+ ? HasFmtId<First> extends true
14
+ ? Rest extends readonly []
15
+ ? true
16
+ : AllHaveFmtId<Rest>
17
+ : false
18
+ : true; // empty array is valid
19
+
20
+ // Check if none have fmtId
21
+ type NoneHaveFmtId<Occurrences extends readonly any[]> =
22
+ Occurrences extends readonly [infer First, ...infer Rest]
23
+ ? HasFmtId<First> extends false
24
+ ? Rest extends readonly []
25
+ ? true
26
+ : NoneHaveFmtId<Rest>
27
+ : false
28
+ : true; // empty array is valid
29
+
30
+ // Valid if all have fmtId or none have fmtId (no mixing allowed)
31
+ export type ValidOccurrenceMix<Occurrences extends readonly any[]> =
32
+ AllHaveFmtId<Occurrences> extends true
33
+ ? true
34
+ : NoneHaveFmtId<Occurrences> extends true
35
+ ? true
36
+ : false;
37
+
8
38
  // Helper type to extract schema from a TableOccurrence
9
39
  type ExtractSchemaFromOccurrence<O> =
10
40
  O extends TableOccurrence<infer BT, any, any, any>
@@ -44,30 +74,88 @@ export class Database<
44
74
  >[] = readonly [],
45
75
  > {
46
76
  private occurrenceMap: Map<string, TableOccurrence<any, any, any, any>>;
77
+ private _useEntityIds: boolean = false;
47
78
 
48
79
  constructor(
49
80
  private readonly databaseName: string,
50
81
  private readonly context: ExecutionContext,
51
- config?: { occurrences?: Occurrences },
82
+ config?: {
83
+ occurrences?: ValidOccurrenceMix<Occurrences> extends true
84
+ ? Occurrences
85
+ : Occurrences & {
86
+ __type_error__: "❌ Cannot mix TableOccurrence with and without entity IDs. Either all occurrences must use TableOccurrenceWithIds (with fmtId and fmfIds) or all must be regular TableOccurrence.";
87
+ };
88
+ },
52
89
  ) {
53
90
  this.occurrenceMap = new Map();
54
91
  if (config?.occurrences) {
92
+ // Validate consistency: either all occurrences use entity IDs or none do
93
+ const occurrencesWithIds: string[] = [];
94
+ const occurrencesWithoutIds: string[] = [];
95
+
55
96
  for (const occ of config.occurrences) {
56
97
  this.occurrenceMap.set(occ.name, occ);
98
+
99
+ const hasTableId = occ.isUsingTableId();
100
+ const hasFieldIds = occ.baseTable.isUsingFieldIds();
101
+
102
+ // An occurrence uses entity IDs if it has both fmtId and fmfIds
103
+ if (hasTableId && hasFieldIds) {
104
+ occurrencesWithIds.push(occ.name);
105
+ this._useEntityIds = true;
106
+ } else if (!hasTableId && !hasFieldIds) {
107
+ occurrencesWithoutIds.push(occ.name);
108
+ } else {
109
+ // Partial entity ID usage (only one of fmtId or fmfIds) - this is an error
110
+ throw new Error(
111
+ `TableOccurrence "${occ.name}" has inconsistent entity ID configuration. ` +
112
+ `Both fmtId (${hasTableId ? "present" : "missing"}) and fmfIds (${hasFieldIds ? "present" : "missing"}) must be defined together.`,
113
+ );
114
+ }
57
115
  }
116
+
117
+ // Check for mixed usage
118
+ if (occurrencesWithIds.length > 0 && occurrencesWithoutIds.length > 0) {
119
+ throw new Error(
120
+ `Cannot mix TableOccurrence instances with and without entity IDs in the same database. ` +
121
+ `Occurrences with entity IDs: [${occurrencesWithIds.join(", ")}]. ` +
122
+ `Occurrences without entity IDs: [${occurrencesWithoutIds.join(", ")}]. ` +
123
+ `Either all table occurrences must use entity IDs (fmtId + fmfIds) or none should.`,
124
+ );
125
+ }
126
+ }
127
+
128
+ // Inform the execution context whether to use entity IDs
129
+ if (this.context._setUseEntityIds) {
130
+ this.context._setUseEntityIds(this._useEntityIds);
58
131
  }
59
132
  }
60
133
 
134
+ /**
135
+ * Returns true if any table occurrence in this database is using entity IDs.
136
+ */
137
+ isUsingEntityIds(): boolean {
138
+ return this._useEntityIds;
139
+ }
140
+
141
+ /**
142
+ * Gets a table occurrence by name.
143
+ * @internal
144
+ */
145
+ getOccurrence(name: string): TableOccurrence<any, any, any, any> | undefined {
146
+ return this.occurrenceMap.get(name);
147
+ }
148
+
61
149
  from<Name extends ExtractOccurrenceNames<Occurrences> | (string & {})>(
62
150
  name: Name,
63
151
  ): Occurrences extends readonly []
64
- ? EntitySet<Record<string, z.ZodTypeAny>, undefined>
152
+ ? EntitySet<Record<string, StandardSchemaV1>, undefined>
65
153
  : Name extends ExtractOccurrenceNames<Occurrences>
66
154
  ? EntitySet<
67
155
  ExtractSchemaFromOccurrence<FindOccurrenceByName<Occurrences, Name>>,
68
156
  FindOccurrenceByName<Occurrences, Name>
69
157
  >
70
- : EntitySet<Record<string, z.ZodTypeAny>, undefined> {
158
+ : EntitySet<Record<string, StandardSchemaV1>, undefined> {
71
159
  const occurrence = this.occurrenceMap.get(name as string);
72
160
 
73
161
  if (occurrence) {
@@ -80,20 +168,28 @@ export class Database<
80
168
  tableName: name as string,
81
169
  databaseName: this.databaseName,
82
170
  context: this.context,
171
+ database: this as any,
83
172
  }) as any;
84
173
  } else {
85
174
  // Return untyped EntitySet for dynamic table access
86
- return new EntitySet<Record<string, z.ZodTypeAny>, undefined>({
175
+ return new EntitySet<Record<string, StandardSchemaV1>, undefined>({
87
176
  tableName: name as string,
88
177
  databaseName: this.databaseName,
89
178
  context: this.context,
179
+ database: this as any,
90
180
  }) as any;
91
181
  }
92
182
  }
93
183
 
94
184
  // Example method showing how to use the request method
95
185
  async getMetadata() {
96
- return this.context._makeRequest(`/${this.databaseName}/$metadata`);
186
+ const result = await this.context._makeRequest(
187
+ `/${this.databaseName}/$metadata`,
188
+ );
189
+ if (result.error) {
190
+ throw result.error;
191
+ }
192
+ return result.data;
97
193
  }
98
194
 
99
195
  /**
@@ -101,13 +197,14 @@ export class Database<
101
197
  * @returns Promise resolving to an array of table names
102
198
  */
103
199
  async listTableNames(): Promise<string[]> {
104
- const response = (await this.context._makeRequest(
105
- `/${this.databaseName}`,
106
- )) as {
200
+ const result = await this.context._makeRequest<{
107
201
  value?: Array<{ name: string }>;
108
- };
109
- if (response.value && Array.isArray(response.value)) {
110
- return response.value.map((item) => item.name);
202
+ }>(`/${this.databaseName}`);
203
+ if (result.error) {
204
+ throw result.error;
205
+ }
206
+ if (result.data.value && Array.isArray(result.data.value)) {
207
+ return result.data.value.map((item) => item.name);
111
208
  }
112
209
  return [];
113
210
  }
@@ -136,7 +233,7 @@ export class Database<
136
233
  body.scriptParameterValue = options.scriptParam;
137
234
  }
138
235
 
139
- const response = await this.context._makeRequest<{
236
+ const result = await this.context._makeRequest<{
140
237
  scriptResult: {
141
238
  code: number;
142
239
  resultParameter?: string;
@@ -146,6 +243,12 @@ export class Database<
146
243
  body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
147
244
  });
148
245
 
246
+ if (result.error) {
247
+ throw result.error;
248
+ }
249
+
250
+ const response = result.data;
251
+
149
252
  // If resultSchema is provided, validate the result through it
150
253
  if (options?.resultSchema && response.scriptResult !== undefined) {
151
254
  const validationResult = options.resultSchema["~standard"].validate(
@@ -118,53 +118,52 @@ export class ExecutableDeleteBuilder<T extends Record<string, any>>
118
118
  async execute(
119
119
  options?: RequestInit & FFetchOptions,
120
120
  ): Promise<Result<{ deletedCount: number }>> {
121
- try {
122
- let url: string;
123
-
124
- if (this.mode === "byId") {
125
- // Delete single record by ID: DELETE /{database}/{table}('id')
126
- url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
127
- } else {
128
- // Delete by filter: DELETE /{database}/{table}?$filter=...
129
- if (!this.queryBuilder) {
130
- throw new Error("Query builder is required for filter-based delete");
131
- }
132
-
133
- // Get the query string from the configured QueryBuilder
134
- const queryString = this.queryBuilder.getQueryString();
135
- // Remove the leading "/" from the query string as we'll build our own URL
136
- const queryParams = queryString.startsWith(`/${this.tableName}`)
137
- ? queryString.slice(`/${this.tableName}`.length)
138
- : queryString;
139
-
140
- url = `/${this.databaseName}/${this.tableName}${queryParams}`;
141
- }
121
+ let url: string;
142
122
 
143
- // Make DELETE request
144
- const response = await this.context._makeRequest(url, {
145
- method: "DELETE",
146
- ...options,
147
- });
148
-
149
- // OData returns 204 No Content with fmodata.affected_rows header
150
- // The _makeRequest should handle extracting the header value
151
- // For now, we'll check if response contains the count
152
- let deletedCount = 0;
153
-
154
- if (typeof response === "number") {
155
- deletedCount = response;
156
- } else if (response && typeof response === "object") {
157
- // Check if the response has a count property (fallback)
158
- deletedCount = (response as any).deletedCount || 0;
123
+ if (this.mode === "byId") {
124
+ // Delete single record by ID: DELETE /{database}/{table}('id')
125
+ url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
126
+ } else {
127
+ // Delete by filter: DELETE /{database}/{table}?$filter=...
128
+ if (!this.queryBuilder) {
129
+ throw new Error("Query builder is required for filter-based delete");
159
130
  }
160
131
 
161
- return { data: { deletedCount }, error: undefined };
162
- } catch (error) {
163
- return {
164
- data: undefined,
165
- error: error instanceof Error ? error : new Error(String(error)),
166
- };
132
+ // Get the query string from the configured QueryBuilder
133
+ const queryString = this.queryBuilder.getQueryString();
134
+ // Remove the leading "/" from the query string as we'll build our own URL
135
+ const queryParams = queryString.startsWith(`/${this.tableName}`)
136
+ ? queryString.slice(`/${this.tableName}`.length)
137
+ : queryString;
138
+
139
+ url = `/${this.databaseName}/${this.tableName}${queryParams}`;
140
+ }
141
+
142
+ // Make DELETE request
143
+ const result = await this.context._makeRequest(url, {
144
+ method: "DELETE",
145
+ ...options,
146
+ });
147
+
148
+ if (result.error) {
149
+ return { data: undefined, error: result.error };
167
150
  }
151
+
152
+ const response = result.data;
153
+
154
+ // OData returns 204 No Content with fmodata.affected_rows header
155
+ // The _makeRequest should handle extracting the header value
156
+ // For now, we'll check if response contains the count
157
+ let deletedCount = 0;
158
+
159
+ if (typeof response === "number") {
160
+ deletedCount = response;
161
+ } else if (response && typeof response === "object") {
162
+ // Check if the response has a count property (fallback)
163
+ deletedCount = (response as any).deletedCount || 0;
164
+ }
165
+
166
+ return { data: { deletedCount }, error: undefined };
168
167
  }
169
168
 
170
169
  getRequestConfig(): { method: string; url: string; body?: any } {
@@ -1,4 +1,3 @@
1
- import { z } from "zod/v4";
2
1
  import type {
3
2
  ExecutionContext,
4
3
  InferSchemaType,
@@ -6,6 +5,7 @@ import type {
6
5
  InsertData,
7
6
  UpdateData,
8
7
  } from "../types";
8
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
9
9
  import type { BaseTable } from "./base-table";
10
10
  import type { TableOccurrence } from "./table-occurrence";
11
11
  import { QueryBuilder } from "./query-builder";
@@ -59,19 +59,19 @@ type FindNavigationTarget<
59
59
  ? Name extends keyof Nav
60
60
  ? ResolveNavigationItem<Nav[Name]>
61
61
  : TableOccurrence<
62
- BaseTable<Record<string, z.ZodTypeAny>, any>,
62
+ BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
63
63
  any,
64
64
  any,
65
65
  any
66
66
  >
67
67
  : TableOccurrence<
68
- BaseTable<Record<string, z.ZodTypeAny>, any>,
68
+ BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
69
69
  any,
70
70
  any,
71
71
  any
72
72
  >
73
73
  : TableOccurrence<
74
- BaseTable<Record<string, z.ZodTypeAny>, any>,
74
+ BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
75
75
  any,
76
76
  any,
77
77
  any
@@ -85,20 +85,21 @@ type GetTargetSchemaType<
85
85
  TableOccurrence<infer BT, any, any, any>,
86
86
  ]
87
87
  ? [BT] extends [BaseTable<infer S, any>]
88
- ? [S] extends [Record<string, z.ZodType>]
88
+ ? [S] extends [Record<string, StandardSchemaV1>]
89
89
  ? InferSchemaType<S>
90
90
  : Record<string, any>
91
91
  : Record<string, any>
92
92
  : Record<string, any>;
93
93
 
94
94
  export class EntitySet<
95
- Schema extends Record<string, z.ZodType> = any,
95
+ Schema extends Record<string, StandardSchemaV1> = any,
96
96
  Occ extends TableOccurrence<any, any, any, any> | undefined = undefined,
97
97
  > {
98
98
  private occurrence?: Occ;
99
99
  private tableName: string;
100
100
  private databaseName: string;
101
101
  private context: ExecutionContext;
102
+ private database?: any; // Database instance for accessing occurrences
102
103
  private isNavigateFromEntitySet?: boolean;
103
104
  private navigateRelation?: string;
104
105
  private navigateSourceTableName?: string;
@@ -108,30 +109,39 @@ export class EntitySet<
108
109
  tableName: string;
109
110
  databaseName: string;
110
111
  context: ExecutionContext;
112
+ database?: any;
111
113
  }) {
112
114
  this.occurrence = config.occurrence;
113
115
  this.tableName = config.tableName;
114
116
  this.databaseName = config.databaseName;
115
117
  this.context = config.context;
118
+ this.database = config.database;
116
119
  }
117
120
 
118
121
  // Type-only method to help TypeScript infer the schema from occurrence
119
122
  static create<
120
- OccurrenceSchema extends Record<string, z.ZodType>,
123
+ OccurrenceSchema extends Record<string, StandardSchemaV1>,
121
124
  Occ extends
122
- | TableOccurrence<BaseTable<OccurrenceSchema, any>, any, any, any>
125
+ | TableOccurrence<
126
+ BaseTable<OccurrenceSchema, any, any, any>,
127
+ any,
128
+ any,
129
+ any
130
+ >
123
131
  | undefined = undefined,
124
132
  >(config: {
125
133
  occurrence?: Occ;
126
134
  tableName: string;
127
135
  databaseName: string;
128
136
  context: ExecutionContext;
137
+ database?: any;
129
138
  }): EntitySet<OccurrenceSchema, Occ> {
130
139
  return new EntitySet<OccurrenceSchema, Occ>({
131
140
  occurrence: config.occurrence,
132
141
  tableName: config.tableName,
133
142
  databaseName: config.databaseName,
134
143
  context: config.context,
144
+ database: config.database,
135
145
  });
136
146
  }
137
147
 
@@ -280,15 +290,15 @@ export class EntitySet<
280
290
  ): EntitySet<
281
291
  ExtractSchemaFromOccurrence<
282
292
  FindNavigationTarget<Occ, RelationName>
283
- > extends Record<string, z.ZodType>
293
+ > extends Record<string, StandardSchemaV1>
284
294
  ? ExtractSchemaFromOccurrence<FindNavigationTarget<Occ, RelationName>>
285
- : Record<string, z.ZodTypeAny>,
295
+ : Record<string, StandardSchemaV1>,
286
296
  FindNavigationTarget<Occ, RelationName>
287
297
  >;
288
298
  // Overload for arbitrary strings - returns generic EntitySet
289
299
  navigate(
290
300
  relationName: string,
291
- ): EntitySet<Record<string, z.ZodTypeAny>, undefined>;
301
+ ): EntitySet<Record<string, StandardSchemaV1>, undefined>;
292
302
  // Implementation
293
303
  navigate(relationName: string): EntitySet<any, any> {
294
304
  // Use the target occurrence if available, otherwise allow untyped navigation
@@ -1,12 +1,21 @@
1
- import createClient, { FFetchOptions } from "@fetchkit/ffetch";
2
- import type { Auth, ExecutionContext } from "../types";
3
- import { Database } from "./database";
1
+ import createClient, {
2
+ FFetchOptions,
3
+ TimeoutError,
4
+ AbortError,
5
+ NetworkError,
6
+ RetryLimitError,
7
+ CircuitOpenError,
8
+ } from "@fetchkit/ffetch";
9
+ import type { Auth, ExecutionContext, Result } from "../types";
10
+ import { HTTPError, ODataError } from "../errors";
11
+ import { Database, type ValidOccurrenceMix } from "./database";
4
12
  import { TableOccurrence } from "./table-occurrence";
5
13
 
6
14
  export class FMServerConnection implements ExecutionContext {
7
15
  private fetchClient: ReturnType<typeof createClient>;
8
16
  private serverUrl: string;
9
17
  private auth: Auth;
18
+ private useEntityIds: boolean = false;
10
19
  constructor(config: {
11
20
  serverUrl: string;
12
21
  auth: Auth;
@@ -27,14 +36,31 @@ export class FMServerConnection implements ExecutionContext {
27
36
  this.auth = config.auth;
28
37
  }
29
38
 
39
+ /**
40
+ * @internal
41
+ * Sets whether to use FileMaker entity IDs (FMFID/FMTID) in requests
42
+ */
43
+ _setUseEntityIds(useEntityIds: boolean): void {
44
+ this.useEntityIds = useEntityIds;
45
+ }
46
+
47
+ /**
48
+ * @internal
49
+ * Gets whether to use FileMaker entity IDs (FMFID/FMTID) in requests
50
+ */
51
+ _getUseEntityIds(): boolean {
52
+ return this.useEntityIds;
53
+ }
54
+
30
55
  /**
31
56
  * @internal
32
57
  */
33
58
  async _makeRequest<T>(
34
59
  url: string,
35
60
  options?: RequestInit & FFetchOptions,
36
- ): Promise<T> {
61
+ ): Promise<Result<T>> {
37
62
  const baseUrl = `${this.serverUrl}${"apiKey" in this.auth ? `/otto` : ""}/fmi/odata/v4`;
63
+ const fullUrl = baseUrl + url;
38
64
 
39
65
  const headers = {
40
66
  Authorization:
@@ -43,6 +69,7 @@ export class FMServerConnection implements ExecutionContext {
43
69
  : `Basic ${btoa(`${this.auth.username}:${this.auth.password}`)}`,
44
70
  "Content-Type": "application/json",
45
71
  Accept: "application/json",
72
+ ...(this.useEntityIds ? { Prefer: "fmodata.entity-ids" } : {}),
46
73
  ...(options?.headers || {}),
47
74
  };
48
75
 
@@ -61,44 +88,112 @@ export class FMServerConnection implements ExecutionContext {
61
88
  ? createClient({ retries: 0, fetchHandler })
62
89
  : this.fetchClient;
63
90
 
64
- const resp = await clientToUse(baseUrl + url, {
65
- ...restOptions,
66
- headers,
67
- });
91
+ try {
92
+ const resp = await clientToUse(fullUrl, {
93
+ ...restOptions,
94
+ headers,
95
+ });
68
96
 
69
- if (!resp.ok) {
70
- throw new Error(
71
- `Failed to make request to ${baseUrl + url}: ${resp.statusText}`,
72
- );
73
- }
97
+ // Handle HTTP errors
98
+ if (!resp.ok) {
99
+ // Try to parse error body if it's JSON
100
+ let errorBody;
101
+ try {
102
+ if (resp.headers.get("content-type")?.includes("application/json")) {
103
+ errorBody = await resp.json();
104
+ }
105
+ } catch {
106
+ // Ignore JSON parse errors
107
+ }
74
108
 
75
- // Check for affected rows header (for DELETE and bulk PATCH operations)
76
- // FileMaker may return this with 204 No Content or 200 OK
77
- const affectedRows = resp.headers.get("fmodata.affected_rows");
78
- if (affectedRows !== null) {
79
- return parseInt(affectedRows, 10) as T;
80
- }
109
+ // Check if it's an OData error response
110
+ if (errorBody?.error) {
111
+ return {
112
+ data: undefined,
113
+ error: new ODataError(
114
+ fullUrl,
115
+ errorBody.error.message || resp.statusText,
116
+ errorBody.error.code,
117
+ errorBody.error,
118
+ ),
119
+ };
120
+ }
81
121
 
82
- // Handle 204 No Content with no body
83
- if (resp.status === 204) {
84
- return 0 as T;
85
- }
122
+ return {
123
+ data: undefined,
124
+ error: new HTTPError(
125
+ fullUrl,
126
+ resp.status,
127
+ resp.statusText,
128
+ errorBody,
129
+ ),
130
+ };
131
+ }
132
+
133
+ // Check for affected rows header (for DELETE and bulk PATCH operations)
134
+ // FileMaker may return this with 204 No Content or 200 OK
135
+ const affectedRows = resp.headers.get("fmodata.affected_rows");
136
+ if (affectedRows !== null) {
137
+ return { data: parseInt(affectedRows, 10) as T, error: undefined };
138
+ }
86
139
 
87
- if (resp.headers.get("content-type")?.includes("application/json")) {
88
- let data = await resp.json();
89
- if (data.error) {
90
- throw new Error(data.error);
140
+ // Handle 204 No Content with no body
141
+ if (resp.status === 204) {
142
+ return { data: 0 as T, error: undefined };
91
143
  }
92
- return data as T;
144
+
145
+ // Parse response
146
+ if (resp.headers.get("content-type")?.includes("application/json")) {
147
+ const data = await resp.json();
148
+
149
+ // Check for embedded OData errors
150
+ if (data.error) {
151
+ return {
152
+ data: undefined,
153
+ error: new ODataError(
154
+ fullUrl,
155
+ data.error.message || "Unknown OData error",
156
+ data.error.code,
157
+ data.error,
158
+ ),
159
+ };
160
+ }
161
+
162
+ return { data: data as T, error: undefined };
163
+ }
164
+
165
+ return { data: (await resp.text()) as T, error: undefined };
166
+ } catch (err) {
167
+ // Map ffetch errors - return them directly (no re-wrapping)
168
+ if (
169
+ err instanceof TimeoutError ||
170
+ err instanceof AbortError ||
171
+ err instanceof NetworkError ||
172
+ err instanceof RetryLimitError ||
173
+ err instanceof CircuitOpenError
174
+ ) {
175
+ return { data: undefined, error: err };
176
+ }
177
+
178
+ // Unknown error - wrap it as NetworkError
179
+ return {
180
+ data: undefined,
181
+ error: new NetworkError(fullUrl, err),
182
+ };
93
183
  }
94
- return (await resp.text()) as T;
95
184
  }
96
185
 
97
186
  database<
98
187
  const Occurrences extends readonly TableOccurrence<any, any, any, any>[],
99
188
  >(
100
189
  name: string,
101
- config?: { occurrences?: Occurrences },
190
+ config?: {
191
+ occurrences?: ValidOccurrenceMix<Occurrences> extends true
192
+ ? Occurrences
193
+ : Occurrences & {
194
+ __type_error__: "❌ Cannot mix TableOccurrence with and without entity IDs. Either all occurrences must use TableOccurrenceWithIds (with fmtId and fmfIds) or all must be regular TableOccurrence.";
195
+ };
196
+ },
102
197
  ): Database<Occurrences> {
103
198
  return new Database(name, this, config);
104
199
  }
@@ -108,11 +203,14 @@ export class FMServerConnection implements ExecutionContext {
108
203
  * @returns Promise resolving to an array of database names
109
204
  */
110
205
  async listDatabaseNames(): Promise<string[]> {
111
- const response = (await this._makeRequest("/")) as {
206
+ const result = await this._makeRequest<{
112
207
  value?: Array<{ name: string }>;
113
- };
114
- if (response.value && Array.isArray(response.value)) {
115
- return response.value.map((item) => item.name);
208
+ }>("/");
209
+ if (result.error) {
210
+ throw result.error;
211
+ }
212
+ if (result.data.value && Array.isArray(result.data.value)) {
213
+ return result.data.value.map((item) => item.name);
116
214
  }
117
215
  return [];
118
216
  }