@proofkit/fmodata 0.1.0-alpha.0

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 (54) hide show
  1. package/README.md +37 -0
  2. package/dist/esm/client/base-table.d.ts +13 -0
  3. package/dist/esm/client/base-table.js +19 -0
  4. package/dist/esm/client/base-table.js.map +1 -0
  5. package/dist/esm/client/database.d.ts +49 -0
  6. package/dist/esm/client/database.js +90 -0
  7. package/dist/esm/client/database.js.map +1 -0
  8. package/dist/esm/client/delete-builder.d.ts +61 -0
  9. package/dist/esm/client/delete-builder.js +121 -0
  10. package/dist/esm/client/delete-builder.js.map +1 -0
  11. package/dist/esm/client/entity-set.d.ts +43 -0
  12. package/dist/esm/client/entity-set.js +120 -0
  13. package/dist/esm/client/entity-set.js.map +1 -0
  14. package/dist/esm/client/filemaker-odata.d.ts +26 -0
  15. package/dist/esm/client/filemaker-odata.js +85 -0
  16. package/dist/esm/client/filemaker-odata.js.map +1 -0
  17. package/dist/esm/client/insert-builder.d.ts +23 -0
  18. package/dist/esm/client/insert-builder.js +69 -0
  19. package/dist/esm/client/insert-builder.js.map +1 -0
  20. package/dist/esm/client/query-builder.d.ts +94 -0
  21. package/dist/esm/client/query-builder.js +649 -0
  22. package/dist/esm/client/query-builder.js.map +1 -0
  23. package/dist/esm/client/record-builder.d.ts +43 -0
  24. package/dist/esm/client/record-builder.js +121 -0
  25. package/dist/esm/client/record-builder.js.map +1 -0
  26. package/dist/esm/client/table-occurrence.d.ts +25 -0
  27. package/dist/esm/client/table-occurrence.js +47 -0
  28. package/dist/esm/client/table-occurrence.js.map +1 -0
  29. package/dist/esm/client/update-builder.d.ts +69 -0
  30. package/dist/esm/client/update-builder.js +134 -0
  31. package/dist/esm/client/update-builder.js.map +1 -0
  32. package/dist/esm/filter-types.d.ts +76 -0
  33. package/dist/esm/index.d.ts +4 -0
  34. package/dist/esm/index.js +10 -0
  35. package/dist/esm/index.js.map +1 -0
  36. package/dist/esm/types.d.ts +67 -0
  37. package/dist/esm/validation.d.ts +41 -0
  38. package/dist/esm/validation.js +270 -0
  39. package/dist/esm/validation.js.map +1 -0
  40. package/package.json +68 -0
  41. package/src/client/base-table.ts +25 -0
  42. package/src/client/database.ts +177 -0
  43. package/src/client/delete-builder.ts +193 -0
  44. package/src/client/entity-set.ts +310 -0
  45. package/src/client/filemaker-odata.ts +119 -0
  46. package/src/client/insert-builder.ts +93 -0
  47. package/src/client/query-builder.ts +1076 -0
  48. package/src/client/record-builder.ts +240 -0
  49. package/src/client/table-occurrence.ts +100 -0
  50. package/src/client/update-builder.ts +212 -0
  51. package/src/filter-types.ts +97 -0
  52. package/src/index.ts +17 -0
  53. package/src/types.ts +123 -0
  54. package/src/validation.ts +397 -0
package/src/types.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { type FFetchOptions } from "@fetchkit/ffetch";
2
+ import { z } from "zod/v4";
3
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
4
+
5
+ export type Auth = { username: string; password: string } | { apiKey: string };
6
+
7
+ export interface ExecutableBuilder<T> {
8
+ execute(): Promise<Result<T>>;
9
+ getRequestConfig(): { method: string; url: string; body?: any };
10
+ }
11
+
12
+ export interface ExecutionContext {
13
+ _makeRequest<T>(
14
+ url: string,
15
+ options?: RequestInit & FFetchOptions,
16
+ ): Promise<T>;
17
+ }
18
+
19
+ export type InferSchemaType<Schema extends Record<string, StandardSchemaV1>> = {
20
+ [K in keyof Schema]: Schema[K] extends StandardSchemaV1<any, infer Output>
21
+ ? Output
22
+ : never;
23
+ };
24
+
25
+ export type WithSystemFields<T> =
26
+ T extends Record<string, any>
27
+ ? T & {
28
+ ROWID: number;
29
+ ROWMODID: number;
30
+ }
31
+ : never;
32
+
33
+ // Helper type to exclude system fields from a union of keys
34
+ export type ExcludeSystemFields<T extends keyof any> = Exclude<
35
+ T,
36
+ "ROWID" | "ROWMODID"
37
+ >;
38
+
39
+ // Helper type to omit system fields from an object type
40
+ export type OmitSystemFields<T> = Omit<T, "ROWID" | "ROWMODID">;
41
+
42
+ // OData record metadata fields (present on each record)
43
+ export type ODataRecordMetadata = {
44
+ "@id": string;
45
+ "@editLink": string;
46
+ };
47
+
48
+ // OData response wrapper (top-level, internal use only)
49
+ export type ODataListResponse<T> = {
50
+ "@context": string;
51
+ value: (T & ODataRecordMetadata)[];
52
+ };
53
+
54
+ export type ODataSingleResponse<T> = T &
55
+ ODataRecordMetadata & {
56
+ "@context": string;
57
+ };
58
+
59
+ // OData response for single field values
60
+ export type ODataFieldResponse<T> = {
61
+ "@context": string;
62
+ value: T;
63
+ };
64
+
65
+ // Result pattern for execute responses
66
+ export type Result<T, E = Error> =
67
+ | { data: T; error: undefined }
68
+ | { data: undefined; error: E };
69
+
70
+ // Make specific keys required, rest optional
71
+ export type MakeFieldsRequired<T, Keys extends keyof T> = Partial<T> &
72
+ Required<Pick<T, Keys>>;
73
+
74
+ // Extract insert data type from BaseTable
75
+ // This type makes the insertRequired fields required while keeping others optional
76
+ export type InsertData<BT> = BT extends import("./client/base-table").BaseTable<
77
+ infer Schema extends Record<string, z.ZodType>,
78
+ any,
79
+ infer InsertRequired extends readonly any[],
80
+ any
81
+ >
82
+ ? [InsertRequired[number]] extends [keyof InferSchemaType<Schema>]
83
+ ? InsertRequired extends readonly (keyof InferSchemaType<Schema>)[]
84
+ ? MakeFieldsRequired<InferSchemaType<Schema>, InsertRequired[number]>
85
+ : Partial<InferSchemaType<Schema>>
86
+ : Partial<InferSchemaType<Schema>>
87
+ : Partial<Record<string, any>>;
88
+
89
+ // Extract update data type from BaseTable
90
+ // This type makes the updateRequired fields required while keeping others optional
91
+ export type UpdateData<BT> = BT extends import("./client/base-table").BaseTable<
92
+ infer Schema extends Record<string, z.ZodType>,
93
+ any,
94
+ any,
95
+ infer UpdateRequired extends readonly any[]
96
+ >
97
+ ? [UpdateRequired[number]] extends [keyof InferSchemaType<Schema>]
98
+ ? UpdateRequired extends readonly (keyof InferSchemaType<Schema>)[]
99
+ ? MakeFieldsRequired<InferSchemaType<Schema>, UpdateRequired[number]>
100
+ : Partial<InferSchemaType<Schema>>
101
+ : Partial<InferSchemaType<Schema>>
102
+ : Partial<Record<string, any>>;
103
+
104
+ export type ExecuteOptions = {
105
+ includeODataAnnotations?: boolean;
106
+ skipValidation?: boolean;
107
+ };
108
+
109
+ export type ConditionallyWithODataAnnotations<T, IncludeODataAnnotations extends boolean> =
110
+ IncludeODataAnnotations extends true
111
+ ? T & {
112
+ "@id": string;
113
+ "@editLink": string;
114
+ }
115
+ : T;
116
+
117
+ // Helper type to extract schema from a TableOccurrence
118
+ export type ExtractSchemaFromOccurrence<Occ> =
119
+ Occ extends { baseTable: { schema: infer S } }
120
+ ? S extends Record<string, StandardSchemaV1>
121
+ ? S
122
+ : Record<string, StandardSchemaV1>
123
+ : Record<string, StandardSchemaV1>;
@@ -0,0 +1,397 @@
1
+ import type { ODataRecordMetadata } from "./types";
2
+ import { StandardSchemaV1 } from "@standard-schema/spec";
3
+ import type { TableOccurrence } from "./client/table-occurrence";
4
+
5
+ // Type for expand validation configuration
6
+ export type ExpandValidationConfig = {
7
+ relation: string;
8
+ targetSchema?: Record<string, StandardSchemaV1>;
9
+ targetOccurrence?: TableOccurrence<any, any, any, any>;
10
+ selectedFields?: string[];
11
+ nestedExpands?: ExpandValidationConfig[];
12
+ };
13
+
14
+ /**
15
+ * Validates a single record against a schema, only validating selected fields.
16
+ * Also validates expanded relations if expandConfigs are provided.
17
+ */
18
+ export async function validateRecord<T extends Record<string, any>>(
19
+ record: any,
20
+ schema: Record<string, StandardSchemaV1> | undefined,
21
+ selectedFields?: (keyof T)[],
22
+ expandConfigs?: ExpandValidationConfig[],
23
+ ): Promise<
24
+ | { valid: true; data: T & ODataRecordMetadata }
25
+ | { valid: false; error: Error }
26
+ > {
27
+ // Extract OData metadata fields (don't validate them - include if present)
28
+ const { "@id": id, "@editLink": editLink, ...rest } = record;
29
+
30
+ // Include metadata fields if present (don't validate they exist)
31
+ const metadata: ODataRecordMetadata = {
32
+ "@id": id || "",
33
+ "@editLink": editLink || "",
34
+ };
35
+
36
+ // If no schema, just return the data with metadata
37
+ if (!schema) {
38
+ return {
39
+ valid: true,
40
+ data: { ...rest, ...metadata } as T & ODataRecordMetadata,
41
+ };
42
+ }
43
+
44
+ // Filter out FileMaker system fields that shouldn't be in responses by default
45
+ const { ROWID, ROWMODID, ...restWithoutSystemFields } = rest;
46
+
47
+ // If selected fields are specified, validate only those fields
48
+ if (selectedFields && selectedFields.length > 0) {
49
+ const validatedRecord: Record<string, any> = {};
50
+
51
+ for (const field of selectedFields) {
52
+ const fieldName = String(field);
53
+ const fieldSchema = schema[fieldName];
54
+
55
+ if (fieldSchema) {
56
+ const input = rest[fieldName];
57
+ let result = fieldSchema["~standard"].validate(input);
58
+ if (result instanceof Promise) result = await result;
59
+
60
+ // if the `issues` field exists, the validation failed
61
+ if (result.issues) {
62
+ return {
63
+ valid: false,
64
+ error: new Error(
65
+ `Validation failed for field '${fieldName}': ${JSON.stringify(result.issues, null, 2)}`,
66
+ ),
67
+ };
68
+ }
69
+
70
+ validatedRecord[fieldName] = result.value;
71
+ } else {
72
+ // For fields not in schema (like when explicitly selecting ROWID/ROWMODID)
73
+ // include them from the original response
74
+ validatedRecord[fieldName] = rest[fieldName];
75
+ }
76
+ }
77
+
78
+ // Validate expanded relations
79
+ if (expandConfigs && expandConfigs.length > 0) {
80
+ for (const expandConfig of expandConfigs) {
81
+ const expandValue = rest[expandConfig.relation];
82
+
83
+ // Check if expand field is missing
84
+ if (expandValue === undefined) {
85
+ // Check for inline error array (FileMaker returns errors inline when expand fails)
86
+ if (Array.isArray(rest.error) && rest.error.length > 0) {
87
+ // Extract error message from inline error
88
+ const errorDetail = rest.error[0]?.error;
89
+ if (errorDetail?.message) {
90
+ const errorMessage = errorDetail.message;
91
+ // Check if the error is related to this expand by checking if:
92
+ // 1. The error mentions the relation name, OR
93
+ // 2. The error mentions any of the selected fields
94
+ const isRelatedToExpand =
95
+ errorMessage
96
+ .toLowerCase()
97
+ .includes(expandConfig.relation.toLowerCase()) ||
98
+ (expandConfig.selectedFields &&
99
+ expandConfig.selectedFields.some((field) =>
100
+ errorMessage.toLowerCase().includes(field.toLowerCase()),
101
+ ));
102
+
103
+ if (isRelatedToExpand) {
104
+ return {
105
+ valid: false,
106
+ error: new Error(
107
+ `Validation failed for expanded relation '${expandConfig.relation}': ${errorMessage}`,
108
+ ),
109
+ };
110
+ }
111
+ }
112
+ }
113
+ // If no inline error but expand was expected, that's also an issue
114
+ // However, this might be a legitimate case (e.g., no related records)
115
+ // So we'll only fail if there's an explicit error array
116
+ } else {
117
+ // Original validation logic for when expand exists
118
+ if (Array.isArray(expandValue)) {
119
+ // Validate each item in the expanded array
120
+ const validatedExpandedItems: any[] = [];
121
+ for (let i = 0; i < expandValue.length; i++) {
122
+ const item = expandValue[i];
123
+ const itemValidation = await validateRecord(
124
+ item,
125
+ expandConfig.targetSchema,
126
+ expandConfig.selectedFields as string[] | undefined,
127
+ expandConfig.nestedExpands,
128
+ );
129
+ if (!itemValidation.valid) {
130
+ return {
131
+ valid: false,
132
+ error: new Error(
133
+ `Validation failed for expanded relation '${expandConfig.relation}' at index ${i}: ${itemValidation.error.message}`,
134
+ ),
135
+ };
136
+ }
137
+ validatedExpandedItems.push(itemValidation.data);
138
+ }
139
+ validatedRecord[expandConfig.relation] = validatedExpandedItems;
140
+ } else {
141
+ // Single expanded item (shouldn't happen in OData, but handle it)
142
+ const itemValidation = await validateRecord(
143
+ expandValue,
144
+ expandConfig.targetSchema,
145
+ expandConfig.selectedFields as string[] | undefined,
146
+ expandConfig.nestedExpands,
147
+ );
148
+ if (!itemValidation.valid) {
149
+ return {
150
+ valid: false,
151
+ error: new Error(
152
+ `Validation failed for expanded relation '${expandConfig.relation}': ${itemValidation.error.message}`,
153
+ ),
154
+ };
155
+ }
156
+ validatedRecord[expandConfig.relation] = itemValidation.data;
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ // Merge validated data with metadata
163
+ return {
164
+ valid: true,
165
+ data: { ...validatedRecord, ...metadata } as T & ODataRecordMetadata,
166
+ };
167
+ }
168
+
169
+ // Validate all fields in schema, but exclude ROWID/ROWMODID by default
170
+ const validatedRecord: Record<string, any> = { ...restWithoutSystemFields };
171
+
172
+ for (const [fieldName, fieldSchema] of Object.entries(schema)) {
173
+ const input = rest[fieldName];
174
+ let result = fieldSchema["~standard"].validate(input);
175
+ if (result instanceof Promise) result = await result;
176
+
177
+ // if the `issues` field exists, the validation failed
178
+ if (result.issues) {
179
+ return {
180
+ valid: false,
181
+ error: new Error(
182
+ `Validation failed for field '${fieldName}': ${JSON.stringify(result.issues, null, 2)}`,
183
+ ),
184
+ };
185
+ }
186
+
187
+ validatedRecord[fieldName] = result.value;
188
+ }
189
+
190
+ // Validate expanded relations even when not using selected fields
191
+ if (expandConfigs && expandConfigs.length > 0) {
192
+ for (const expandConfig of expandConfigs) {
193
+ const expandValue = rest[expandConfig.relation];
194
+
195
+ // Check if expand field is missing
196
+ if (expandValue === undefined) {
197
+ // Check for inline error array (FileMaker returns errors inline when expand fails)
198
+ if (Array.isArray(rest.error) && rest.error.length > 0) {
199
+ // Extract error message from inline error
200
+ const errorDetail = rest.error[0]?.error;
201
+ if (errorDetail?.message) {
202
+ const errorMessage = errorDetail.message;
203
+ // Check if the error is related to this expand by checking if:
204
+ // 1. The error mentions the relation name, OR
205
+ // 2. The error mentions any of the selected fields
206
+ const isRelatedToExpand =
207
+ errorMessage
208
+ .toLowerCase()
209
+ .includes(expandConfig.relation.toLowerCase()) ||
210
+ (expandConfig.selectedFields &&
211
+ expandConfig.selectedFields.some((field) =>
212
+ errorMessage.toLowerCase().includes(field.toLowerCase()),
213
+ ));
214
+
215
+ if (isRelatedToExpand) {
216
+ return {
217
+ valid: false,
218
+ error: new Error(
219
+ `Validation failed for expanded relation '${expandConfig.relation}': ${errorMessage}`,
220
+ ),
221
+ };
222
+ }
223
+ }
224
+ }
225
+ // If no inline error but expand was expected, that's also an issue
226
+ // However, this might be a legitimate case (e.g., no related records)
227
+ // So we'll only fail if there's an explicit error array
228
+ } else {
229
+ // Original validation logic for when expand exists
230
+ if (Array.isArray(expandValue)) {
231
+ // Validate each item in the expanded array
232
+ const validatedExpandedItems: any[] = [];
233
+ for (let i = 0; i < expandValue.length; i++) {
234
+ const item = expandValue[i];
235
+ const itemValidation = await validateRecord(
236
+ item,
237
+ expandConfig.targetSchema,
238
+ expandConfig.selectedFields as string[] | undefined,
239
+ expandConfig.nestedExpands,
240
+ );
241
+ if (!itemValidation.valid) {
242
+ return {
243
+ valid: false,
244
+ error: new Error(
245
+ `Validation failed for expanded relation '${expandConfig.relation}' at index ${i}: ${itemValidation.error.message}`,
246
+ ),
247
+ };
248
+ }
249
+ validatedExpandedItems.push(itemValidation.data);
250
+ }
251
+ validatedRecord[expandConfig.relation] = validatedExpandedItems;
252
+ } else {
253
+ // Single expanded item (shouldn't happen in OData, but handle it)
254
+ const itemValidation = await validateRecord(
255
+ expandValue,
256
+ expandConfig.targetSchema,
257
+ expandConfig.selectedFields as string[] | undefined,
258
+ expandConfig.nestedExpands,
259
+ );
260
+ if (!itemValidation.valid) {
261
+ return {
262
+ valid: false,
263
+ error: new Error(
264
+ `Validation failed for expanded relation '${expandConfig.relation}': ${itemValidation.error.message}`,
265
+ ),
266
+ };
267
+ }
268
+ validatedRecord[expandConfig.relation] = itemValidation.data;
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ return {
275
+ valid: true,
276
+ data: { ...validatedRecord, ...metadata } as T & ODataRecordMetadata,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Validates a list response against a schema.
282
+ */
283
+ export async function validateListResponse<T extends Record<string, any>>(
284
+ response: any,
285
+ schema: Record<string, StandardSchemaV1> | undefined,
286
+ selectedFields?: (keyof T)[],
287
+ expandConfigs?: ExpandValidationConfig[],
288
+ ): Promise<
289
+ | { valid: true; data: (T & ODataRecordMetadata)[] }
290
+ | { valid: false; error: Error }
291
+ > {
292
+ // Check if response has the expected structure
293
+ if (!response || typeof response !== "object") {
294
+ return {
295
+ valid: false,
296
+ error: new Error("Invalid response: expected an object"),
297
+ };
298
+ }
299
+
300
+ // Extract @context (for internal validation, but we won't return it)
301
+ const { "@context": context, value, ...rest } = response;
302
+
303
+ if (!Array.isArray(value)) {
304
+ return {
305
+ valid: false,
306
+ error: new Error(
307
+ "Invalid response: expected 'value' property to be an array",
308
+ ),
309
+ };
310
+ }
311
+
312
+ // Validate each record in the array
313
+ const validatedRecords: (T & ODataRecordMetadata)[] = [];
314
+
315
+ for (let i = 0; i < value.length; i++) {
316
+ const record = value[i];
317
+ const validation = await validateRecord<T>(
318
+ record,
319
+ schema,
320
+ selectedFields,
321
+ expandConfigs,
322
+ );
323
+
324
+ if (!validation.valid) {
325
+ return {
326
+ valid: false,
327
+ error: new Error(
328
+ `Validation failed for record at index ${i}: ${validation.error.message}`,
329
+ ),
330
+ };
331
+ }
332
+
333
+ validatedRecords.push(validation.data);
334
+ }
335
+
336
+ return {
337
+ valid: true,
338
+ data: validatedRecords,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Validates a single record response against a schema.
344
+ */
345
+ export async function validateSingleResponse<T extends Record<string, any>>(
346
+ response: any,
347
+ schema: Record<string, StandardSchemaV1> | undefined,
348
+ selectedFields?: (keyof T)[],
349
+ expandConfigs?: ExpandValidationConfig[],
350
+ mode: "exact" | "maybe" = "maybe",
351
+ ): Promise<
352
+ | { valid: true; data: (T & ODataRecordMetadata) | null }
353
+ | { valid: false; error: Error }
354
+ > {
355
+ // Check for multiple records (error in both modes)
356
+ if (response.value && Array.isArray(response.value) && response.value.length > 1) {
357
+ return {
358
+ valid: false,
359
+ error: new Error(
360
+ `Expected ${mode === "exact" ? "exactly one" : "at most one"} record, but received ${response.value.length}`
361
+ ),
362
+ };
363
+ }
364
+
365
+ // Handle empty responses
366
+ if (!response || (response.value && response.value.length === 0)) {
367
+ if (mode === "exact") {
368
+ return {
369
+ valid: false,
370
+ error: new Error("Expected exactly one record, but received none"),
371
+ };
372
+ }
373
+ // mode === "maybe" - return null for empty
374
+ return {
375
+ valid: true,
376
+ data: null,
377
+ };
378
+ }
379
+
380
+ // Single record validation
381
+ const record = response.value?.[0] ?? response;
382
+ const validation = await validateRecord<T>(
383
+ record,
384
+ schema,
385
+ selectedFields,
386
+ expandConfigs,
387
+ );
388
+
389
+ if (!validation.valid) {
390
+ return validation as { valid: false; error: Error };
391
+ }
392
+
393
+ return {
394
+ valid: true,
395
+ data: validation.data,
396
+ };
397
+ }