@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.
- package/README.md +357 -28
- package/dist/esm/client/base-table.d.ts +122 -5
- package/dist/esm/client/base-table.js +46 -5
- package/dist/esm/client/base-table.js.map +1 -1
- package/dist/esm/client/database.d.ts +20 -3
- package/dist/esm/client/database.js +62 -13
- package/dist/esm/client/database.js.map +1 -1
- package/dist/esm/client/delete-builder.js +24 -27
- package/dist/esm/client/delete-builder.js.map +1 -1
- package/dist/esm/client/entity-set.d.ts +9 -6
- package/dist/esm/client/entity-set.js +5 -1
- package/dist/esm/client/entity-set.js.map +1 -1
- package/dist/esm/client/filemaker-odata.d.ts +17 -4
- package/dist/esm/client/filemaker-odata.js +90 -27
- package/dist/esm/client/filemaker-odata.js.map +1 -1
- package/dist/esm/client/insert-builder.js +45 -34
- package/dist/esm/client/insert-builder.js.map +1 -1
- package/dist/esm/client/query-builder.d.ts +7 -2
- package/dist/esm/client/query-builder.js +273 -202
- package/dist/esm/client/query-builder.js.map +1 -1
- package/dist/esm/client/record-builder.d.ts +2 -2
- package/dist/esm/client/record-builder.js +50 -40
- package/dist/esm/client/record-builder.js.map +1 -1
- package/dist/esm/client/table-occurrence.d.ts +66 -2
- package/dist/esm/client/table-occurrence.js +36 -1
- package/dist/esm/client/table-occurrence.js.map +1 -1
- package/dist/esm/client/update-builder.js +39 -35
- package/dist/esm/client/update-builder.js.map +1 -1
- package/dist/esm/errors.d.ts +60 -0
- package/dist/esm/errors.js +122 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/index.d.ts +5 -2
- package/dist/esm/index.js +25 -3
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/transform.d.ts +56 -0
- package/dist/esm/transform.js +107 -0
- package/dist/esm/transform.js.map +1 -0
- package/dist/esm/types.d.ts +21 -5
- package/dist/esm/validation.d.ts +6 -3
- package/dist/esm/validation.js +104 -33
- package/dist/esm/validation.js.map +1 -1
- package/package.json +10 -1
- package/src/client/base-table.ts +155 -8
- package/src/client/database.ts +116 -13
- package/src/client/delete-builder.ts +42 -43
- package/src/client/entity-set.ts +21 -11
- package/src/client/filemaker-odata.ts +132 -34
- package/src/client/insert-builder.ts +69 -37
- package/src/client/query-builder.ts +345 -233
- package/src/client/record-builder.ts +84 -59
- package/src/client/table-occurrence.ts +118 -4
- package/src/client/update-builder.ts +77 -49
- package/src/errors.ts +185 -0
- package/src/index.ts +30 -1
- package/src/transform.ts +236 -0
- package/src/types.ts +112 -34
- package/src/validation.ts +120 -36
package/src/client/database.ts
CHANGED
|
@@ -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?: {
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
105
|
-
`/${this.databaseName}`,
|
|
106
|
-
)) as {
|
|
200
|
+
const result = await this.context._makeRequest<{
|
|
107
201
|
value?: Array<{ name: string }>;
|
|
108
|
-
};
|
|
109
|
-
if (
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 } {
|
package/src/client/entity-set.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
123
|
+
OccurrenceSchema extends Record<string, StandardSchemaV1>,
|
|
121
124
|
Occ extends
|
|
122
|
-
| TableOccurrence<
|
|
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,
|
|
293
|
+
> extends Record<string, StandardSchemaV1>
|
|
284
294
|
? ExtractSchemaFromOccurrence<FindNavigationTarget<Occ, RelationName>>
|
|
285
|
-
: Record<string,
|
|
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,
|
|
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, {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
try {
|
|
92
|
+
const resp = await clientToUse(fullUrl, {
|
|
93
|
+
...restOptions,
|
|
94
|
+
headers,
|
|
95
|
+
});
|
|
68
96
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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?: {
|
|
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
|
|
206
|
+
const result = await this._makeRequest<{
|
|
112
207
|
value?: Array<{ name: string }>;
|
|
113
|
-
};
|
|
114
|
-
if (
|
|
115
|
-
|
|
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
|
}
|