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

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 (70) hide show
  1. package/README.md +376 -34
  2. package/dist/esm/client/base-table.d.ts +24 -29
  3. package/dist/esm/client/base-table.js +4 -7
  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 +44 -12
  12. package/dist/esm/client/database.js +64 -10
  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 +76 -9
  16. package/dist/esm/client/delete-builder.js.map +1 -1
  17. package/dist/esm/client/entity-set.d.ts +17 -6
  18. package/dist/esm/client/entity-set.js +26 -10
  19. package/dist/esm/client/entity-set.js.map +1 -1
  20. package/dist/esm/client/filemaker-odata.d.ts +11 -5
  21. package/dist/esm/client/filemaker-odata.js +46 -14
  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 +195 -9
  25. package/dist/esm/client/insert-builder.js.map +1 -1
  26. package/dist/esm/client/query-builder.d.ts +20 -4
  27. package/dist/esm/client/query-builder.js +195 -19
  28. package/dist/esm/client/query-builder.js.map +1 -1
  29. package/dist/esm/client/record-builder.d.ts +18 -3
  30. package/dist/esm/client/record-builder.js +87 -5
  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 +25 -42
  37. package/dist/esm/client/table-occurrence.js +9 -17
  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 +119 -19
  41. package/dist/esm/client/update-builder.js.map +1 -1
  42. package/dist/esm/errors.d.ts +14 -1
  43. package/dist/esm/errors.js +26 -0
  44. package/dist/esm/errors.js.map +1 -1
  45. package/dist/esm/index.d.ts +5 -4
  46. package/dist/esm/index.js +7 -6
  47. package/dist/esm/transform.d.ts +9 -0
  48. package/dist/esm/transform.js +7 -0
  49. package/dist/esm/transform.js.map +1 -1
  50. package/dist/esm/types.d.ts +69 -1
  51. package/package.json +1 -1
  52. package/src/client/base-table.ts +30 -36
  53. package/src/client/batch-builder.ts +265 -0
  54. package/src/client/batch-request.ts +485 -0
  55. package/src/client/database.ts +110 -56
  56. package/src/client/delete-builder.ts +116 -14
  57. package/src/client/entity-set.ts +89 -12
  58. package/src/client/filemaker-odata.ts +65 -19
  59. package/src/client/insert-builder.ts +296 -18
  60. package/src/client/query-builder.ts +285 -18
  61. package/src/client/query-builder.ts.bak +1457 -0
  62. package/src/client/record-builder.ts +120 -12
  63. package/src/client/response-processor.ts +103 -0
  64. package/src/client/schema-manager.ts +246 -0
  65. package/src/client/table-occurrence.ts +41 -80
  66. package/src/client/update-builder.ts +195 -37
  67. package/src/errors.ts +33 -1
  68. package/src/index.ts +15 -3
  69. package/src/transform.ts +19 -6
  70. package/src/types.ts +89 -1
@@ -5,10 +5,11 @@ import type {
5
5
  ODataRecordMetadata,
6
6
  ODataFieldResponse,
7
7
  InferSchemaType,
8
+ ExecuteOptions,
8
9
  } from "../types";
9
10
  import type { TableOccurrence } from "./table-occurrence";
10
11
  import type { BaseTable } from "./base-table";
11
- import { transformTableName, transformResponseFields } from "../transform";
12
+ import { transformTableName, transformResponseFields, getTableIdentifiers } from "../transform";
12
13
  import { QueryBuilder } from "./query-builder";
13
14
  import { validateSingleResponse } from "../validation";
14
15
  import { type FFetchOptions } from "@fetchkit/ffetch";
@@ -18,7 +19,7 @@ import { StandardSchemaV1 } from "@standard-schema/spec";
18
19
  // Helper type to extract schema from a TableOccurrence
19
20
  type ExtractSchemaFromOccurrence<O> =
20
21
  O extends TableOccurrence<infer BT, any, any, any>
21
- ? BT extends BaseTable<infer S, any>
22
+ ? BT extends BaseTable<infer S, any, any, any>
22
23
  ? S
23
24
  : never
24
25
  : never;
@@ -70,18 +71,60 @@ export class RecordBuilder<
70
71
  private navigateRelation?: string;
71
72
  private navigateSourceTableName?: string;
72
73
 
74
+ private databaseUseEntityIds: boolean;
75
+
73
76
  constructor(config: {
74
77
  occurrence?: Occ;
75
78
  tableName: string;
76
79
  databaseName: string;
77
80
  context: ExecutionContext;
78
81
  recordId: string | number;
82
+ databaseUseEntityIds?: boolean;
79
83
  }) {
80
84
  this.occurrence = config.occurrence;
81
85
  this.tableName = config.tableName;
82
86
  this.databaseName = config.databaseName;
83
87
  this.context = config.context;
84
88
  this.recordId = config.recordId;
89
+ this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
90
+ }
91
+
92
+ /**
93
+ * Helper to merge database-level useEntityIds with per-request options
94
+ */
95
+ private mergeExecuteOptions(
96
+ options?: RequestInit & FFetchOptions & ExecuteOptions,
97
+ ): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
98
+ // If useEntityIds is not set in options, use the database-level setting
99
+ return {
100
+ ...options,
101
+ useEntityIds: options?.useEntityIds ?? this.databaseUseEntityIds,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
107
+ * @param useEntityIds - Optional override for entity ID usage
108
+ */
109
+ private getTableId(useEntityIds?: boolean): string {
110
+ if (!this.occurrence) {
111
+ return this.tableName;
112
+ }
113
+
114
+ const contextDefault = this.context._getUseEntityIds?.() ?? false;
115
+ const shouldUseIds = useEntityIds ?? contextDefault;
116
+
117
+ if (shouldUseIds) {
118
+ const identifiers = getTableIdentifiers(this.occurrence);
119
+ if (!identifiers.id) {
120
+ throw new Error(
121
+ `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`
122
+ );
123
+ }
124
+ return identifiers.id;
125
+ }
126
+
127
+ return this.occurrence.getTableName();
85
128
  }
86
129
 
87
130
  getSingleField<K extends keyof T>(field: K): RecordBuilder<T, true, K, Occ> {
@@ -160,7 +203,7 @@ export class RecordBuilder<
160
203
  }
161
204
 
162
205
  async execute(
163
- options?: RequestInit & FFetchOptions,
206
+ options?: RequestInit & FFetchOptions & { useEntityIds?: boolean },
164
207
  ): Promise<
165
208
  Result<IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata>
166
209
  > {
@@ -176,9 +219,7 @@ export class RecordBuilder<
176
219
  url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
177
220
  } else {
178
221
  // Normal record: /tableName('recordId') - use FMTID if configured
179
- const tableId = this.occurrence
180
- ? transformTableName(this.occurrence)
181
- : this.tableName;
222
+ const tableId = this.getTableId(options?.useEntityIds ?? this.databaseUseEntityIds);
182
223
  url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
183
224
  }
184
225
 
@@ -186,7 +227,8 @@ export class RecordBuilder<
186
227
  url += `/${this.operationParam}`;
187
228
  }
188
229
 
189
- const result = await this.context._makeRequest(url, options);
230
+ const mergedOptions = this.mergeExecuteOptions(options);
231
+ const result = await this.context._makeRequest(url, mergedOptions);
190
232
 
191
233
  if (result.error) {
192
234
  return { data: undefined, error: result.error };
@@ -202,7 +244,10 @@ export class RecordBuilder<
202
244
  }
203
245
 
204
246
  // Transform response field IDs back to names if using entity IDs
205
- if (this.occurrence?.baseTable) {
247
+ // Only transform if useEntityIds resolves to true (respects per-request override)
248
+ const shouldUseIds = mergedOptions.useEntityIds ?? false;
249
+
250
+ if (this.occurrence?.baseTable && shouldUseIds) {
206
251
  response = transformResponseFields(
207
252
  response,
208
253
  this.occurrence.baseTable,
@@ -246,10 +291,8 @@ export class RecordBuilder<
246
291
  // From navigated EntitySet: /sourceTable/relation('recordId')
247
292
  url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
248
293
  } else {
249
- // Normal record: /tableName('recordId') - use FMTID if configured
250
- const tableId = this.occurrence
251
- ? transformTableName(this.occurrence)
252
- : this.tableName;
294
+ // For batch operations, use database-level setting (no per-request override available here)
295
+ const tableId = this.getTableId(this.databaseUseEntityIds);
253
296
  url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
254
297
  }
255
298
 
@@ -262,4 +305,69 @@ export class RecordBuilder<
262
305
  url,
263
306
  };
264
307
  }
308
+
309
+ toRequest(baseUrl: string): Request {
310
+ const config = this.getRequestConfig();
311
+ const fullUrl = `${baseUrl}${config.url}`;
312
+
313
+ return new Request(fullUrl, {
314
+ method: config.method,
315
+ headers: {
316
+ "Content-Type": "application/json",
317
+ Accept: "application/json",
318
+ },
319
+ });
320
+ }
321
+
322
+ async processResponse(
323
+ response: Response,
324
+ options?: ExecuteOptions,
325
+ ): Promise<
326
+ Result<IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata>
327
+ > {
328
+ const rawResponse = await response.json();
329
+
330
+ // Handle single field operation
331
+ if (this.operation === "getSingleField") {
332
+ // Single field returns a JSON object with @context and value
333
+ const fieldResponse = rawResponse as ODataFieldResponse<T>;
334
+ return { data: fieldResponse.value as any, error: undefined };
335
+ }
336
+
337
+ // Transform response field IDs back to names if using entity IDs
338
+ // Only transform if useEntityIds resolves to true (respects per-request override)
339
+ const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
340
+
341
+ let transformedResponse = rawResponse;
342
+ if (this.occurrence?.baseTable && shouldUseIds) {
343
+ transformedResponse = transformResponseFields(
344
+ rawResponse,
345
+ this.occurrence.baseTable,
346
+ undefined, // No expand configs for simple get
347
+ );
348
+ }
349
+
350
+ // Get schema from occurrence if available
351
+ const schema = this.occurrence?.baseTable?.schema;
352
+
353
+ // Validate the single record response
354
+ const validation = await validateSingleResponse<any>(
355
+ transformedResponse,
356
+ schema,
357
+ undefined, // No selected fields for record.get()
358
+ undefined, // No expand configs
359
+ "exact", // Expect exactly one record
360
+ );
361
+
362
+ if (!validation.valid) {
363
+ return { data: undefined, error: validation.error };
364
+ }
365
+
366
+ // Handle null response
367
+ if (validation.data === null) {
368
+ return { data: null as any, error: undefined };
369
+ }
370
+
371
+ return { data: validation.data, error: undefined };
372
+ }
265
373
  }
@@ -0,0 +1,103 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { BaseTable } from "./base-table";
3
+ import type { ExecuteOptions } from "../types";
4
+ import type { ExpandValidationConfig } from "../validation";
5
+ import { ValidationError, ResponseStructureError } from "../errors";
6
+ import { transformResponseFields } from "../transform";
7
+ import { validateListResponse, validateRecord } from "../validation";
8
+
9
+ // Type for raw OData responses
10
+ export type ODataResponse<T = unknown> = T & {
11
+ "@odata.context"?: string;
12
+ "@odata.count"?: number;
13
+ };
14
+
15
+ export type ODataListResponse<T = unknown> = ODataResponse<{
16
+ value: T[];
17
+ }>;
18
+
19
+ export type ODataRecordResponse<T = unknown> = ODataResponse<
20
+ T & {
21
+ "@id"?: string;
22
+ "@editLink"?: string;
23
+ }
24
+ >;
25
+
26
+ /**
27
+ * Strip OData annotations from a single record
28
+ */
29
+ export function stripODataAnnotations<T extends Record<string, unknown>>(
30
+ record: ODataRecordResponse<T>,
31
+ options?: ExecuteOptions,
32
+ ): T {
33
+ if (options?.includeODataAnnotations === true) {
34
+ return record as T;
35
+ }
36
+ const { "@id": _id, "@editLink": _editLink, ...rest } = record;
37
+ return rest as T;
38
+ }
39
+
40
+ /**
41
+ * Transform field IDs back to names using the base table configuration
42
+ */
43
+ export function applyFieldTransformation<T extends Record<string, unknown>>(
44
+ response: ODataResponse<T> | ODataListResponse<T>,
45
+ baseTable: BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
46
+ expandConfigs?: ExpandValidationConfig[],
47
+ ): ODataResponse<T> | ODataListResponse<T> {
48
+ return transformResponseFields(response, baseTable, expandConfigs) as
49
+ | ODataResponse<T>
50
+ | ODataListResponse<T>;
51
+ }
52
+
53
+ /**
54
+ * Apply schema validation and transformation to data
55
+ */
56
+ export async function applyValidation<T extends Record<string, unknown>>(
57
+ data: T | T[],
58
+ schema?: Record<string, StandardSchemaV1>,
59
+ selectedFields?: (keyof T)[],
60
+ expandConfigs?: ExpandValidationConfig[],
61
+ ): Promise<
62
+ | { valid: true; data: T | T[] }
63
+ | { valid: false; error: ValidationError | ResponseStructureError }
64
+ > {
65
+ if (Array.isArray(data)) {
66
+ // Validate as a list
67
+ const validation = await validateListResponse<T>(
68
+ { value: data },
69
+ schema,
70
+ selectedFields as string[] | undefined,
71
+ expandConfigs,
72
+ );
73
+ if (!validation.valid) {
74
+ return { valid: false, error: validation.error };
75
+ }
76
+ return { valid: true, data: validation.data };
77
+ } else {
78
+ // Validate as a single record
79
+ const validation = await validateRecord<T>(
80
+ data,
81
+ schema,
82
+ selectedFields,
83
+ expandConfigs,
84
+ );
85
+ if (!validation.valid) {
86
+ return { valid: false, error: validation.error };
87
+ }
88
+ return { valid: true, data: validation.data };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Extract value array from OData list response, or wrap single record in array
94
+ */
95
+ export function extractListValue<T>(
96
+ response: ODataListResponse<T> | ODataRecordResponse<T>,
97
+ ): T[] {
98
+ if ("value" in response && Array.isArray(response.value)) {
99
+ return response.value;
100
+ }
101
+ // Single record responses return the record directly
102
+ return [response as T];
103
+ }
@@ -0,0 +1,246 @@
1
+ import type { FFetchOptions } from "@fetchkit/ffetch";
2
+ import type { ExecutionContext } from "../types";
3
+
4
+ type GenericField = {
5
+ name: string;
6
+ nullable?: boolean;
7
+ primary?: boolean;
8
+ unique?: boolean;
9
+ global?: boolean;
10
+ repetitions?: number;
11
+ };
12
+
13
+ type StringField = GenericField & {
14
+ type: "string";
15
+ maxLength?: number;
16
+ default?: "USER" | "USERNAME" | "CURRENT_USER";
17
+ };
18
+
19
+ type NumericField = GenericField & {
20
+ type: "numeric";
21
+ };
22
+
23
+ type DateField = GenericField & {
24
+ type: "date";
25
+ default?: "CURRENT_DATE" | "CURDATE";
26
+ };
27
+
28
+ type TimeField = GenericField & {
29
+ type: "time";
30
+ default?: "CURRENT_TIME" | "CURTIME";
31
+ };
32
+
33
+ type TimestampField = GenericField & {
34
+ type: "timestamp";
35
+ default?: "CURRENT_TIMESTAMP" | "CURTIMESTAMP";
36
+ };
37
+
38
+ type ContainerField = GenericField & {
39
+ type: "container";
40
+ externalSecurePath?: string;
41
+ };
42
+
43
+ export type Field =
44
+ | StringField
45
+ | NumericField
46
+ | DateField
47
+ | TimeField
48
+ | TimestampField
49
+ | ContainerField;
50
+
51
+ export type {
52
+ StringField,
53
+ NumericField,
54
+ DateField,
55
+ TimeField,
56
+ TimestampField,
57
+ ContainerField,
58
+ };
59
+
60
+ type FileMakerField = Omit<Field, "type" | "repetitions" | "maxLength"> & {
61
+ type: string;
62
+ };
63
+
64
+ type TableDefinition = {
65
+ tableName: string;
66
+ fields: FileMakerField[];
67
+ };
68
+
69
+ export class SchemaManager {
70
+ public constructor(
71
+ private readonly databaseName: string,
72
+ private readonly context: ExecutionContext,
73
+ ) {}
74
+
75
+ public async createTable(
76
+ tableName: string,
77
+ fields: Field[],
78
+ options?: RequestInit & FFetchOptions,
79
+ ): Promise<TableDefinition> {
80
+ const result = await this.context._makeRequest<TableDefinition>(
81
+ `/${this.databaseName}/FileMaker_Tables`,
82
+ {
83
+ method: "POST",
84
+ body: JSON.stringify({
85
+ tableName,
86
+ fields: fields.map(SchemaManager.compileFieldDefinition),
87
+ }),
88
+ ...options,
89
+ },
90
+ );
91
+
92
+ if (result.error) {
93
+ throw result.error;
94
+ }
95
+
96
+ return result.data;
97
+ }
98
+
99
+ public async addFields(
100
+ tableName: string,
101
+ fields: Field[],
102
+ options?: RequestInit & FFetchOptions,
103
+ ): Promise<TableDefinition> {
104
+ const result = await this.context._makeRequest<TableDefinition>(
105
+ `/${this.databaseName}/FileMaker_Tables/${tableName}`,
106
+ {
107
+ method: "PATCH",
108
+ body: JSON.stringify({
109
+ fields: fields.map(SchemaManager.compileFieldDefinition),
110
+ }),
111
+ ...options,
112
+ },
113
+ );
114
+
115
+ if (result.error) {
116
+ throw result.error;
117
+ }
118
+
119
+ return result.data;
120
+ }
121
+
122
+ public async deleteTable(
123
+ tableName: string,
124
+ options?: RequestInit & FFetchOptions,
125
+ ): Promise<void> {
126
+ const result = await this.context._makeRequest(
127
+ `/${this.databaseName}/FileMaker_Tables/${tableName}`,
128
+ { method: "DELETE", ...options },
129
+ );
130
+
131
+ if (result.error) {
132
+ throw result.error;
133
+ }
134
+ }
135
+
136
+ public async deleteField(
137
+ tableName: string,
138
+ fieldName: string,
139
+ options?: RequestInit & FFetchOptions,
140
+ ): Promise<void> {
141
+ const result = await this.context._makeRequest(
142
+ `/${this.databaseName}/FileMaker_Tables/${tableName}/${fieldName}`,
143
+ {
144
+ method: "DELETE",
145
+ ...options,
146
+ },
147
+ );
148
+
149
+ if (result.error) {
150
+ throw result.error;
151
+ }
152
+ }
153
+
154
+ public async createIndex(
155
+ tableName: string,
156
+ fieldName: string,
157
+ options?: RequestInit & FFetchOptions,
158
+ ): Promise<{ indexName: string }> {
159
+ const result = await this.context._makeRequest<{ indexName: string }>(
160
+ `/${this.databaseName}/FileMaker_Indexes/${tableName}`,
161
+ {
162
+ method: "POST",
163
+ body: JSON.stringify({ indexName: fieldName }),
164
+ ...options,
165
+ },
166
+ );
167
+
168
+ if (result.error) {
169
+ throw result.error;
170
+ }
171
+
172
+ return result.data;
173
+ }
174
+
175
+ public async deleteIndex(
176
+ tableName: string,
177
+ fieldName: string,
178
+ options?: RequestInit & FFetchOptions,
179
+ ): Promise<void> {
180
+ const result = await this.context._makeRequest(
181
+ `/${this.databaseName}/FileMaker_Indexes/${tableName}/${fieldName}`,
182
+ {
183
+ method: "DELETE",
184
+ ...options,
185
+ },
186
+ );
187
+
188
+ if (result.error) {
189
+ throw result.error;
190
+ }
191
+ }
192
+
193
+ private static compileFieldDefinition(field: Field): FileMakerField {
194
+ let type: string = field.type;
195
+ const repetitions = field.repetitions;
196
+
197
+ // Handle string fields - convert to varchar and add maxLength if present
198
+ if (field.type === "string") {
199
+ type = "varchar";
200
+ const stringField = field as StringField;
201
+ if (stringField.maxLength !== undefined) {
202
+ type += `(${stringField.maxLength})`;
203
+ }
204
+ }
205
+
206
+ // Add repetitions suffix if present
207
+ if (repetitions !== undefined) {
208
+ type += `[${repetitions}]`;
209
+ }
210
+
211
+ // Build the result object, excluding type, maxLength, and repetitions
212
+ const result: any = {
213
+ name: field.name,
214
+ type,
215
+ };
216
+
217
+ // Add optional properties that FileMaker expects
218
+ if (field.nullable !== undefined) result.nullable = field.nullable;
219
+ if (field.primary !== undefined) result.primary = field.primary;
220
+ if (field.unique !== undefined) result.unique = field.unique;
221
+ if (field.global !== undefined) result.global = field.global;
222
+
223
+ // Add type-specific properties
224
+ if (field.type === "string") {
225
+ const stringField = field as StringField;
226
+ if (stringField.default !== undefined)
227
+ result.default = stringField.default;
228
+ } else if (field.type === "date") {
229
+ const dateField = field as DateField;
230
+ if (dateField.default !== undefined) result.default = dateField.default;
231
+ } else if (field.type === "time") {
232
+ const timeField = field as TimeField;
233
+ if (timeField.default !== undefined) result.default = timeField.default;
234
+ } else if (field.type === "timestamp") {
235
+ const timestampField = field as TimestampField;
236
+ if (timestampField.default !== undefined)
237
+ result.default = timestampField.default;
238
+ } else if (field.type === "container") {
239
+ const containerField = field as ContainerField;
240
+ if (containerField.externalSecurePath !== undefined)
241
+ result.externalSecurePath = containerField.externalSecurePath;
242
+ }
243
+
244
+ return result as FileMakerField;
245
+ }
246
+ }