UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

454 lines (401 loc) 17.4 kB
import type { StandardSchemaV1 } from "@standard-schema/spec"; /** * Branded type for container field's database type. * This allows TypeScript to distinguish container fields from regular string fields * at the type level, enabling compile-time exclusion from select operations. */ export type ContainerDbType = string & { readonly __container: true }; const FILEMAKER_LIST_DELIMITER = "\r"; const FILEMAKER_NEWLINE_REGEX = /\r\n|\n/g; type ValidationResult<T> = { value: T } | { issues: readonly StandardSchemaV1.Issue[] }; export interface ListFieldOptions<TItem = string, TAllowNull extends boolean = false> { itemValidator?: StandardSchemaV1<unknown, TItem>; allowNull?: TAllowNull; } function normalizeFileMakerNewlines(value: string): string { return value.replace(FILEMAKER_NEWLINE_REGEX, FILEMAKER_LIST_DELIMITER); } function splitFileMakerList(value: string): string[] { const normalized = normalizeFileMakerNewlines(value); if (normalized === "") { return []; } return normalized.split(FILEMAKER_LIST_DELIMITER); } function issue(message: string): StandardSchemaV1.Issue { return { message }; } function validateItemsWithSchema<TItem>( items: string[], itemValidator: StandardSchemaV1<unknown, TItem>, ): ValidationResult<TItem[]> | Promise<ValidationResult<TItem[]>> { const validations = items.map((item) => itemValidator["~standard"].validate(item)); const hasAsyncValidation = validations.some((result) => result instanceof Promise); const finalize = (results: Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>) => { const transformed: TItem[] = []; const issues: StandardSchemaV1.Issue[] = []; results.forEach((result, index) => { if ("issues" in result && result.issues) { for (const validationIssue of result.issues) { issues.push({ ...validationIssue, path: validationIssue.path ? [index, ...validationIssue.path] : [index], }); } return; } if ("value" in result) { transformed.push(result.value); } }); if (issues.length > 0) { return { issues }; } return { value: transformed }; }; if (hasAsyncValidation) { return Promise.all(validations).then((results) => finalize(results)); } return finalize(validations as Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>); } /** * FieldBuilder provides a fluent API for defining table fields with type-safe metadata. * Supports chaining methods to configure primary keys, nullability, read-only status, entity IDs, and validators. * * @template TOutput - The output type after applying outputValidator (what you get when reading) * @template TInput - The input type after applying inputValidator (what you pass when writing) * @template TDbType - The database type (what FileMaker stores/expects) * @template TReadOnly - Whether this field is read-only (for type-level exclusion from insert/update) */ // biome-ignore lint/suspicious/noExplicitAny: Default type parameter for flexibility export class FieldBuilder<TOutput = any, TInput = TOutput, TDbType = TOutput, TReadOnly extends boolean = false> { private _primaryKey = false; private _notNull = false; private _readOnly = false; private _entityId?: `FMFID:${string}`; // biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer private _outputValidator?: StandardSchemaV1<any, TOutput>; // biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer private _inputValidator?: StandardSchemaV1<TInput, any>; private readonly _fieldType: string; private _comment?: string; constructor(fieldType: string) { this._fieldType = fieldType; } /** * Mark this field as the primary key for the table. * Primary keys are automatically read-only and non-nullable. */ primaryKey(): FieldBuilder<NonNullable<TOutput>, NonNullable<TInput>, NonNullable<TDbType>, true> { // biome-ignore lint/suspicious/noExplicitAny: Type assertion for internal class mutation const builder = this._clone() as any; builder._primaryKey = true; builder._notNull = true; // Primary keys are automatically non-nullable builder._readOnly = true; // Primary keys are automatically read-only return builder; } /** * Mark this field as non-nullable. * Updates the type to exclude null/undefined. */ notNull(): FieldBuilder<NonNullable<TOutput>, NonNullable<TInput>, NonNullable<TDbType>, TReadOnly> { // biome-ignore lint/suspicious/noExplicitAny: Type assertion for internal class mutation const builder = this._clone() as any; builder._notNull = true; return builder; } /** * Mark this field as read-only. * Read-only fields are excluded from insert and update operations. */ readOnly(): FieldBuilder<TOutput, TInput, TDbType, true> { // biome-ignore lint/suspicious/noExplicitAny: Type assertion for internal class mutation const builder = this._clone() as any; builder._readOnly = true; return builder; } /** * Assign a FileMaker field ID (FMFID) to this field. * When useEntityIds is enabled, this ID will be used in API requests instead of the field name. */ entityId(id: `FMFID:${string}`): FieldBuilder<TOutput, TInput, TDbType, TReadOnly> { const builder = this._clone(); builder._entityId = id; return builder; } /** * Set a validator for the output (reading from database). * The output validator transforms/validates data coming FROM the database in list or get operations. * * @example * numberField().readValidator(z.coerce.boolean()) * // FileMaker returns 0/1, you get true/false */ readValidator<O, VInput = TDbType>( validator: StandardSchemaV1<VInput, O>, ): FieldBuilder<O, TInput, TDbType, TReadOnly> { // biome-ignore lint/suspicious/noExplicitAny: Type assertion for internal class mutation const builder = this._clone() as any; builder._outputValidator = validator; return builder; } /** * Set a validator for the input (writing to database). * The input validator transforms/validates data going TO the database in insert, update, and filter operations. * * @example * numberField().writeValidator(z.boolean().transform(v => v ? 1 : 0)) * // You pass true/false, FileMaker gets 1/0 */ writeValidator<I>(validator: StandardSchemaV1<I, TDbType>): FieldBuilder<TOutput, I, TDbType, TReadOnly> { // biome-ignore lint/suspicious/noExplicitAny: Type assertion for internal class mutation const builder = this._clone() as any; builder._inputValidator = validator; return builder; } /** * Add a comment to this field for metadata purposes. * This helps future developers understand the purpose of the field. * * @example * textField().comment("Account name of the user who last modified each record") */ comment(comment: string): FieldBuilder<TOutput, TInput, TDbType, TReadOnly> { const builder = this._clone(); builder._comment = comment; return builder; } /** * Get the metadata configuration for this field. * @internal Used by fmTableOccurrence to extract field configuration */ _getConfig() { return { fieldType: this._fieldType, primaryKey: this._primaryKey, notNull: this._notNull, readOnly: this._readOnly, entityId: this._entityId, outputValidator: this._outputValidator, inputValidator: this._inputValidator, comment: this._comment, }; } /** * Clone this builder to allow immutable chaining. * @private */ private _clone(): FieldBuilder<TOutput, TInput, TDbType, TReadOnly> { const builder = new FieldBuilder<TOutput, TInput, TDbType, TReadOnly>(this._fieldType); builder._primaryKey = this._primaryKey; builder._notNull = this._notNull; builder._readOnly = this._readOnly; builder._entityId = this._entityId; builder._outputValidator = this._outputValidator; builder._inputValidator = this._inputValidator; builder._comment = this._comment; return builder; } } /** * Create a text field (Edm.String in FileMaker OData). * By default, text fields are nullable. * * @example * textField() // string | null * textField().notNull() // string * textField().entityId("FMFID:1") // with entity ID */ export function textField(): FieldBuilder<string | null, string | null, string | null, false> { return new FieldBuilder<string | null, string | null, string | null, false>("text"); } type ListOutput<TItem, TAllowNull extends boolean> = TAllowNull extends true ? TItem[] | null : TItem[]; type ListInput<TItem, TAllowNull extends boolean> = TAllowNull extends true ? TItem[] | null : TItem[]; /** * Create a text-backed FileMaker return-delimited list field. * By default, null/empty input is normalized to an empty array (`allowNull: false`). * * @example * listField() // output: string[], input: string[] * listField({ allowNull: true }) // output/input: string[] | null * listField({ itemValidator: z.coerce.number().int() }) // output/input: number[] */ export function listField(): FieldBuilder<string[], string[], string | null, false>; export function listField<TAllowNull extends boolean = false>( options: ListFieldOptions<string, TAllowNull>, ): FieldBuilder<ListOutput<string, TAllowNull>, ListInput<string, TAllowNull>, string | null, false>; export function listField<TItem, TAllowNull extends boolean = false>(options: { itemValidator: StandardSchemaV1<unknown, TItem>; allowNull?: TAllowNull; }): FieldBuilder<ListOutput<TItem, TAllowNull>, ListInput<TItem, TAllowNull>, string | null, false>; export function listField<TItem = string, TAllowNull extends boolean = false>( options?: ListFieldOptions<TItem, TAllowNull>, ): FieldBuilder<ListOutput<TItem, TAllowNull>, ListInput<TItem, TAllowNull>, string | null, false> { const allowNull = options?.allowNull ?? false; const itemValidator = options?.itemValidator as StandardSchemaV1<unknown, TItem> | undefined; const readListSchema: StandardSchemaV1<string | null, ListOutput<TItem, TAllowNull>> = { "~standard": { version: 1, vendor: "proofkit", validate(input) { if (input === null || input === undefined || input === "") { return { value: (allowNull ? null : []) as ListOutput<TItem, TAllowNull> }; } if (typeof input !== "string") { return { issues: [issue("Expected a FileMaker list string or null")] }; } const items = splitFileMakerList(input); if (!itemValidator) { return { value: items as ListOutput<TItem, TAllowNull> }; } const validatedItems = validateItemsWithSchema(items, itemValidator); if (validatedItems instanceof Promise) { return validatedItems.then((result) => { if ("issues" in result) { return result; } return { value: result.value as ListOutput<TItem, TAllowNull> }; }); } if ("issues" in validatedItems) { return validatedItems; } return { value: validatedItems.value as ListOutput<TItem, TAllowNull> }; }, }, }; const writeListSchema: StandardSchemaV1<ListInput<TItem, TAllowNull>, string | null> = { "~standard": { version: 1, vendor: "proofkit", validate(input) { if (input === null || input === undefined) { return { value: allowNull ? null : "" }; } if (!Array.isArray(input)) { return { issues: [issue("Expected an array for FileMaker list field input")] }; } if (!itemValidator) { const hasNonStringItem = input.some((item) => typeof item !== "string"); if (hasNonStringItem) { return { issues: [issue("Expected all list items to be strings without an itemValidator")] }; } const serialized = input.map((item) => normalizeFileMakerNewlines(item)).join(FILEMAKER_LIST_DELIMITER); return { value: serialized }; } const validateInputItems = input.map((item) => itemValidator["~standard"].validate(item)); const hasAsyncValidation = validateInputItems.some((result) => result instanceof Promise); const serializeValidated = ( results: Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>, ): { value: string } | { issues: readonly StandardSchemaV1.Issue[] } => { const validatedItems: TItem[] = []; const issues: StandardSchemaV1.Issue[] = []; results.forEach((result, index) => { if ("issues" in result && result.issues) { for (const validationIssue of result.issues) { issues.push({ ...validationIssue, path: validationIssue.path ? [index, ...validationIssue.path] : [index], }); } return; } if ("value" in result) { validatedItems.push(result.value); } }); if (issues.length > 0) { return { issues }; } const serialized = validatedItems .map((item) => normalizeFileMakerNewlines(typeof item === "string" ? item : String(item))) .join(FILEMAKER_LIST_DELIMITER); return { value: serialized }; }; if (hasAsyncValidation) { return Promise.all(validateInputItems).then((results) => serializeValidated(results)); } return serializeValidated( validateInputItems as Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>, ); }, }, }; return textField().readValidator(readListSchema).writeValidator(writeListSchema); } /** * Create a number field (Edm.Decimal in FileMaker OData). * By default, number fields are nullable. * * @example * numberField() // number | null * numberField().notNull() // number * numberField().outputValidator(z.coerce.boolean()) // transform to boolean on read */ export function numberField(): FieldBuilder<number | null, number | null, number | null, false> { return new FieldBuilder<number | null, number | null, number | null, false>("number"); } /** * Create a date field (Edm.Date in FileMaker OData). * By default, date fields are nullable and represented as ISO date strings (YYYY-MM-DD), * while accepting either ISO strings or Date objects as input. * * @example * dateField() // output: string | null (ISO date format), input: string | Date | null * dateField().notNull() // string */ export function dateField(): FieldBuilder<string | null, string | Date | null, string | null, false> { return new FieldBuilder<string | null, string | Date | null, string | null, false>("date"); } /** * Create a time field (Edm.TimeOfDay in FileMaker OData). * By default, time fields are nullable and represented as ISO time strings (HH:mm:ss), * while accepting either ISO strings or Date objects as input. * * @example * timeField() // output: string | null (ISO time format), input: string | Date | null * timeField().notNull() // string */ export function timeField(): FieldBuilder<string | null, string | Date | null, string | null, false> { return new FieldBuilder<string | null, string | Date | null, string | null, false>("time"); } /** * Create a timestamp field (Edm.DateTimeOffset in FileMaker OData). * By default, timestamp fields are nullable and represented as ISO 8601 strings, * while accepting either ISO strings or Date objects as input. * * @example * timestampField() // output: string | null (ISO 8601 format), input: string | Date | null * timestampField().notNull() // string * timestampField().readOnly() // typical for CreationTimestamp */ export function timestampField(): FieldBuilder<string | null, string | Date | null, string | null, false> { return new FieldBuilder<string | null, string | Date | null, string | null, false>("timestamp"); } /** * Create a container field (Edm.Stream in FileMaker OData). * Container fields store binary data and are represented as base64 strings in the API. * By default, container fields are nullable. * * Note: Container fields cannot be selected via .select() - they can only be accessed * via .getSingleField() due to FileMaker OData API limitations. * * @example * containerField() // string | null (base64 encoded) * containerField().notNull() // string */ export function containerField(): FieldBuilder<string | null, string | null, ContainerDbType | null, false> { return new FieldBuilder<string | null, string | null, ContainerDbType | null, false>("container"); } /** * Create a calculated field (read-only field computed by FileMaker). * Calculated fields are automatically marked as read-only. * * @example * calcField() // string | null * calcField().notNull() // string */ export function calcField(): FieldBuilder<string | null, string | null, string | null, true> { const builder = new FieldBuilder<string | null, string | null, string | null, false>("calculated"); return builder.readOnly(); }