@proofkit/fmodata
Version:
FileMaker OData API client
445 lines (407 loc) • 17.3 kB
text/typescript
import type { FFetchOptions } from "@fetchkit/ffetch";
import { Effect } from "effect";
import { fromValidation, requestFromService, runLayerResult, tryEffect } from "../effect";
import type { FMODataErrorType } from "../errors";
import { BuilderInvariantError, InvalidLocationHeaderError } from "../errors";
import type { FMTable } from "../orm/table";
import { getBaseTableConfig, getTableName } from "../orm/table";
import type { FMODataLayer, ODataConfig } from "../services";
import { transformFieldNamesToIds, transformResponseFields } from "../transform";
import type {
ConditionallyWithODataAnnotations,
ConditionallyWithSpecialColumns,
ExecutableBuilder,
ExecuteMethodOptions,
ExecuteOptions,
NormalizeIncludeSpecialColumns,
Result,
} from "../types";
import { getAcceptHeader } from "../types";
import { validateAndTransformInput, validateSingleResponse } from "../validation";
import {
getLocationHeader,
mergeMutationExecuteOptions,
mergePreferHeaderValues,
parseRowIdFromLocationHeader,
resolveMutationTableId,
} from "./builders/mutation-helpers";
import { normalizeDatabasePath } from "./database-name";
import { parseErrorResponse } from "./error-parser";
import { createClientRuntime } from "./runtime";
import { safeJsonParse } from "./sanitize-json";
export interface InsertOptions {
return?: "minimal" | "representation";
}
import type { InferSchemaOutputFromFMTable } from "../orm/table";
export class InsertBuilder<
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
Occ extends FMTable<any, any> | undefined = undefined,
ReturnPreference extends "minimal" | "representation" = "representation",
DatabaseIncludeSpecialColumns extends boolean = false,
> implements
ExecutableBuilder<
ReturnPreference extends "minimal"
? { ROWID: number }
: ConditionallyWithSpecialColumns<
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
DatabaseIncludeSpecialColumns,
false
>
>
{
private readonly table?: Occ;
private readonly data: Partial<InferSchemaOutputFromFMTable<NonNullable<Occ>>>;
private readonly returnPreference: ReturnPreference;
private readonly layer: FMODataLayer;
private readonly config: ODataConfig;
constructor(config: {
occurrence?: Occ;
layer: FMODataLayer;
data: Partial<InferSchemaOutputFromFMTable<NonNullable<Occ>>>;
returnPreference?: ReturnPreference;
}) {
this.table = config.occurrence;
this.layer = config.layer;
this.data = config.data;
this.returnPreference = (config.returnPreference || "representation") as ReturnPreference;
// Extract config from layer for sync method access
const runtime = createClientRuntime(this.layer);
this.config = runtime.config;
}
/**
* Helper to merge database-level useEntityIds with per-request options
*/
private mergeExecuteOptions(
options?: RequestInit & FFetchOptions & ExecuteOptions,
): RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean } {
return mergeMutationExecuteOptions(options, this.config.useEntityIds, this.config.includeSpecialColumns);
}
/**
* Parse ROWID from Location header
* Expected formats:
* - contacts(ROWID=4583)
* - contacts('some-uuid')
*/
private parseLocationHeader(locationHeader: string | undefined): number {
return parseRowIdFromLocationHeader(locationHeader);
}
/**
* Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
* @param useEntityIds - Optional override for entity ID usage
*/
private getTableId(useEntityIds?: boolean): string {
if (!this.table) {
throw new BuilderInvariantError("InsertBuilder", "table occurrence is required");
}
return resolveMutationTableId(this.table, useEntityIds ?? this.config.useEntityIds, "InsertBuilder");
}
/**
* Builds the schema for validation, excluding container fields.
*/
// biome-ignore lint/suspicious/noExplicitAny: Dynamic schema shape from table configuration
private getValidationSchema(): Record<string, any> | undefined {
if (!this.table) {
return undefined;
}
const baseTableConfig = getBaseTableConfig(this.table);
const containerFields = baseTableConfig.containerFields || [];
// biome-ignore lint/suspicious/noExplicitAny: Dynamic schema shape from table configuration
const schema: Record<string, any> = { ...baseTableConfig.schema };
for (const containerField of containerFields) {
delete schema[containerField as string];
}
return schema;
}
execute<EO extends ExecuteOptions>(
options?: ExecuteMethodOptions<EO>,
): Promise<
Result<
ReturnPreference extends "minimal"
? { ROWID: number }
: ConditionallyWithODataAnnotations<
ConditionallyWithSpecialColumns<
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>,
false
>,
EO["includeODataAnnotations"] extends true ? true : false
>
>
> {
const mergedOptions = this.mergeExecuteOptions(options);
// Prevent caller options from overriding required request shape
// biome-ignore lint/suspicious/noExplicitAny: Execute options include dynamic fetch fields
const { method: _method, headers: callerHeaders, body: _body, ...requestOptions } = mergedOptions as any;
const tableId = this.getTableId(mergedOptions.useEntityIds);
const url = `/${this.config.databaseName}/${tableId}`;
const shouldUseIds = mergedOptions.useEntityIds ?? this.config.useEntityIds;
const includeSpecialColumns = mergedOptions.includeSpecialColumns ?? this.config.includeSpecialColumns;
const canonicalHeaders = new Headers(callerHeaders || {});
const preferHeader = mergePreferHeaderValues(
this.returnPreference === "minimal" ? "return=minimal" : "return=representation",
shouldUseIds ? "fmodata.entity-ids" : undefined,
includeSpecialColumns ? "fmodata.include-specialcolumns" : undefined,
canonicalHeaders.get("Prefer") ?? undefined,
);
canonicalHeaders.set("Content-Type", "application/json");
if (preferHeader) {
canonicalHeaders.set("Prefer", preferHeader);
} else {
canonicalHeaders.delete("Prefer");
}
const pipeline = Effect.gen(this, function* () {
// Step 1: Validate input
let validatedData = this.data;
if (this.table) {
const baseTableConfig = getBaseTableConfig(this.table);
validatedData = yield* tryEffect(
() => validateAndTransformInput(this.data, baseTableConfig.inputSchema),
(e) =>
(e instanceof Error
? e
: new BuilderInvariantError("InsertBuilder.execute", String(e))) as FMODataErrorType,
);
}
// Step 2: Transform field names to entity IDs if needed
const transformedData =
this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData;
// Step 3: Make HTTP request via DI
// biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API
const responseData = yield* requestFromService<any>(url, {
...requestOptions,
method: "POST",
headers: canonicalHeaders,
body: JSON.stringify(transformedData),
});
// Step 4: Handle return=minimal case
if (this.returnPreference === "minimal") {
if (!responseData?._location) {
return yield* Effect.fail(
new InvalidLocationHeaderError(
"Location header is required when using return=minimal but was not found in response",
) as FMODataErrorType,
);
}
const rowid = this.parseLocationHeader(responseData._location);
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
return { ROWID: rowid } as any;
}
// Step 5: Transform response field IDs back to names
let response = responseData;
if (this.table && shouldUseIds) {
response = transformResponseFields(response, this.table, undefined);
}
// Step 6: Validate response
const schema = this.getValidationSchema();
const validated = yield* fromValidation(() =>
validateSingleResponse<InferSchemaOutputFromFMTable<NonNullable<Occ>>>(
response,
schema,
undefined,
undefined,
"exact",
includeSpecialColumns,
),
);
if (validated === null) {
return yield* Effect.fail(
new BuilderInvariantError(
"InsertBuilder.execute",
"insert operation returned null response",
) as FMODataErrorType,
);
}
return validated;
});
return runLayerResult(
this.layer,
pipeline,
"fmodata.insert",
this.table ? { "fmodata.table": getTableName(this.table) } : undefined,
) as Promise<
Result<
ReturnPreference extends "minimal"
? { ROWID: number }
: ConditionallyWithODataAnnotations<
ConditionallyWithSpecialColumns<
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>,
false
>,
EO["includeODataAnnotations"] extends true ? true : false
>
>
>;
}
// biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value
getRequestConfig(): { method: string; url: string; body?: any } {
const tableId = this.getTableId(this.config.useEntityIds);
// Transform field names to FMFIDs if using entity IDs
const transformedData =
this.table && this.config.useEntityIds ? transformFieldNamesToIds(this.data, this.table) : this.data;
return {
method: "POST",
url: `/${this.config.databaseName}/${tableId}`,
body: JSON.stringify(transformedData),
};
}
toRequest(baseUrl: string, options?: ExecuteOptions): Request {
const config = this.getRequestConfig();
const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, {
normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName,
})}`;
const preferHeader = mergePreferHeaderValues(
this.returnPreference === "minimal" ? "return=minimal" : "return=representation",
(options?.useEntityIds ?? this.config.useEntityIds) ? "fmodata.entity-ids" : undefined,
(options?.includeSpecialColumns ?? this.config.includeSpecialColumns)
? "fmodata.include-specialcolumns"
: undefined,
);
return new Request(fullUrl, {
method: config.method,
headers: {
"Content-Type": "application/json",
Accept: getAcceptHeader(options?.includeODataAnnotations),
...(preferHeader ? { Prefer: preferHeader } : {}),
},
body: config.body,
});
}
async processResponse<EO extends ExecuteOptions | undefined = undefined>(
response: Response,
options?: EO,
): Promise<
Result<
ReturnPreference extends "minimal"
? { ROWID: number }
: ConditionallyWithSpecialColumns<
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
NormalizeIncludeSpecialColumns<
EO extends ExecuteOptions ? EO["includeSpecialColumns"] : undefined,
DatabaseIncludeSpecialColumns
>,
false
>
>
> {
// Check for error responses (important for batch operations)
if (!response.ok) {
const tableName = this.table ? getTableName(this.table) : "unknown";
const error = await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`);
return { data: undefined, error };
}
// Handle 204 No Content (common in batch/changeset operations)
// FileMaker uses return=minimal for changeset operations regardless of Prefer header
if (response.status === 204) {
// Check for Location header (for return=minimal)
if (this.returnPreference === "minimal") {
const locationHeader = getLocationHeader(response.headers);
const rowid = locationHeader ? this.parseLocationHeader(locationHeader) : -1;
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
return { data: { ROWID: rowid } as any, error: undefined };
}
// For 204 responses without return=minimal, FileMaker doesn't return the created entity
// This is valid OData behavior for changeset operations
// We return a success indicator but no actual data
return {
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
data: {} as any,
error: undefined,
};
}
// If we expected return=minimal but got a body (e.g. batch sub-responses
// where FM returns 204-with-body, converted to 200 by parsedToResponse),
// try to extract ROWID from the Location header or return -1.
if (this.returnPreference === "minimal") {
const locationHeader = getLocationHeader(response.headers);
const rowid = locationHeader ? this.parseLocationHeader(locationHeader) : -1;
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
return { data: { ROWID: rowid } as any, error: undefined };
}
// Use safeJsonParse to handle FileMaker's invalid JSON with unquoted ? values
let rawResponse: unknown;
try {
rawResponse = await safeJsonParse(response);
} catch (err) {
// If parsing fails with 204, handle it gracefully
if (response.status === 204) {
return {
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
data: {} as any,
error: undefined,
};
}
return {
data: undefined,
error: {
name: "ResponseParseError",
message: `Failed to parse response JSON: ${err instanceof Error ? err.message : "Unknown error"}`,
timestamp: new Date(),
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for error object
} as any,
};
}
// Validate and transform input data using input validators (writeValidators)
// This is needed for processResponse because it's called from batch operations
// where the data hasn't been validated yet
let _validatedData = this.data;
if (this.table) {
const baseTableConfig = getBaseTableConfig(this.table);
const inputSchema = baseTableConfig.inputSchema;
try {
_validatedData = await validateAndTransformInput(this.data, inputSchema);
} catch (error) {
return {
data: undefined,
error:
error instanceof Error ? error : new BuilderInvariantError("InsertBuilder.processResponse", String(error)),
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
} as any;
}
}
// Transform response field IDs back to names if using entity IDs
const shouldUseIds = options?.useEntityIds ?? this.config.useEntityIds;
const includeSpecialColumns = options?.includeSpecialColumns ?? this.config.includeSpecialColumns;
let transformedResponse = rawResponse;
if (this.table && shouldUseIds) {
transformedResponse = transformResponseFields(
rawResponse,
this.table,
undefined, // No expand configs for insert
);
}
// Get schema from table if available, excluding container fields
// biome-ignore lint/suspicious/noExplicitAny: Dynamic schema shape from table configuration
let schema: Record<string, any> | undefined;
if (this.table) {
const baseTableConfig = getBaseTableConfig(this.table);
const containerFields = baseTableConfig.containerFields || [];
// Filter out container fields from schema
schema = { ...baseTableConfig.schema };
for (const containerField of containerFields) {
delete schema[containerField as string];
}
}
// Validate the response (FileMaker returns the created record)
const validation = await validateSingleResponse<InferSchemaOutputFromFMTable<NonNullable<Occ>>>(
transformedResponse,
schema,
undefined, // No selected fields for insert
undefined, // No expand configs
"exact", // Expect exactly one record
includeSpecialColumns,
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
// Handle null response (shouldn't happen for insert, but handle it)
if (validation.data === null) {
return {
data: undefined,
error: new BuilderInvariantError("InsertBuilder.processResponse", "insert operation returned null response"),
};
}
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
return { data: validation.data as any, error: undefined };
}
}