@proofkit/fmodata
Version:
FileMaker OData API client
298 lines (268 loc) • 11.3 kB
text/typescript
import type { FFetchOptions } from "@fetchkit/ffetch";
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { requestFromService, runLayerOrThrow, runLayerResult } from "../effect";
import { BuilderInvariantError, MetadataNotFoundError, SchemaValidationFailedError } from "../errors";
import { FMTable } from "../orm/table";
import { createDatabaseLayer, type FMODataLayer } from "../services";
import type { ExecutableBuilder, ExecutionContext, Metadata, Result } from "../types";
import { BatchBuilder } from "./batch-builder";
import { stripFmp12Extension } from "./database-name";
import { EntitySet } from "./entity-set";
import { SchemaManager } from "./schema-manager";
import { WebhookManager } from "./webhook-builder";
interface MetadataArgs {
format?: "xml" | "json";
/**
* If provided, only the metadata for the specified table will be returned.
* Requires FileMaker Server 22.0.4 or later.
*/
tableName?: string;
/**
* If true, a reduced payload size will be returned by omitting certain annotations.
*/
reduceAnnotations?: boolean;
}
export class Database<IncludeSpecialColumns extends boolean = false> {
readonly schema: SchemaManager;
readonly webhook: WebhookManager;
private readonly databaseName: string;
private readonly _normalizeDatabaseName: boolean;
private readonly _useEntityIds: boolean;
private readonly _includeSpecialColumns: IncludeSpecialColumns;
/** @internal Database-scoped Effect Layer for dependency injection */
readonly _layer: FMODataLayer;
constructor(
databaseName: string,
context: ExecutionContext,
config?: {
/**
* Whether to normalize the database name in requests.
* Defaults to true.
*/
normalizeDatabaseName?: boolean;
/**
* Whether to use entity IDs instead of field names in the actual requests to the server
* Defaults to true if all occurrences use entity IDs, false otherwise
* If set to false but some occurrences do not use entity IDs, an error will be thrown
*/
useEntityIds?: boolean;
/**
* Whether to include special columns (ROWID and ROWMODID) in responses.
* Note: Special columns are only included when there is no $select query.
*/
includeSpecialColumns?: IncludeSpecialColumns;
},
) {
this.databaseName = databaseName;
this._normalizeDatabaseName = config?.normalizeDatabaseName ?? true;
this._useEntityIds = config?.useEntityIds ?? false;
this._includeSpecialColumns = (config?.includeSpecialColumns ?? false) as IncludeSpecialColumns;
// Create database-scoped layer from connection's base layer
const baseLayer = context._getLayer?.();
if (baseLayer) {
this._layer = createDatabaseLayer(baseLayer, {
databaseName: this.databaseName,
normalizeDatabaseName: this._normalizeDatabaseName,
useEntityIds: this._useEntityIds,
includeSpecialColumns: this._includeSpecialColumns,
});
} else {
throw new BuilderInvariantError(
"Database",
"ExecutionContext must implement _getLayer() for dependency injection",
);
}
// Initialize schema and webhook managers with the database layer
this.schema = new SchemaManager(this._layer);
this.webhook = new WebhookManager(this._layer);
}
/**
* @internal Used by adapter packages to access the database filename.
*/
get _getDatabaseName(): string {
return this.databaseName;
}
/**
* @internal Used by EntitySet to access database configuration
*/
get _getUseEntityIds(): boolean {
return this._useEntityIds;
}
/**
* @internal Used by EntitySet to access database configuration
*/
get _getNormalizeDatabaseName(): boolean {
return this._normalizeDatabaseName;
}
/**
* @internal Used by EntitySet to access database configuration
*/
get _getIncludeSpecialColumns(): IncludeSpecialColumns {
return this._includeSpecialColumns;
}
/**
* @internal Used by adapter packages for raw OData requests.
* Makes requests through the Effect DI layer.
*/
_makeRequest<T>(path: string, options?: RequestInit & FFetchOptions): Promise<Result<T>> {
const pipeline = requestFromService<T>(`/${this.databaseName}${path}`, options);
return runLayerResult(this._layer, pipeline);
}
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
from<T extends FMTable<any, any>>(table: T): EntitySet<T, IncludeSpecialColumns> {
// Resolve useEntityIds per-call without mutating shared Database state
let useEntityIds = this._useEntityIds;
if (Object.hasOwn(table, FMTable.Symbol.UseEntityIds)) {
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for Symbol property access
const tableUseEntityIds = (table as any)[FMTable.Symbol.UseEntityIds];
if (typeof tableUseEntityIds === "boolean") {
useEntityIds = tableUseEntityIds;
}
}
// If table overrides useEntityIds, create a new layer with the override
const layer =
useEntityIds !== this._useEntityIds
? createDatabaseLayer(this._layer, {
databaseName: this.databaseName,
normalizeDatabaseName: this._normalizeDatabaseName,
useEntityIds,
includeSpecialColumns: this._includeSpecialColumns,
})
: this._layer;
return new EntitySet<T, IncludeSpecialColumns>({
occurrence: table as T,
layer,
database: this,
});
}
/**
* Retrieves the OData metadata for this database.
* @param args Optional configuration object
* @param args.format The format to retrieve metadata in. Defaults to "json".
* @param args.tableName If provided, only the metadata for the specified table will be returned. Requires FileMaker Server 22.0.4 or later.
* @param args.reduceAnnotations If true, a reduced payload size will be returned by omitting certain annotations.
* @returns The metadata in the specified format
*/
async getMetadata(args: { format: "xml" } & MetadataArgs): Promise<string>;
async getMetadata(args?: { format?: "json" } & MetadataArgs): Promise<Metadata>;
async getMetadata(args?: MetadataArgs): Promise<string | Metadata> {
// Build the URL - if tableName is provided, append %23{tableName} to the path
let url = `/${this.databaseName}/$metadata`;
if (args?.tableName) {
url = `/${this.databaseName}/$metadata%23${args.tableName}`;
}
// Build headers
const headers: Record<string, string> = {
Accept: args?.format === "xml" ? "application/xml" : "application/json",
};
// Add Prefer header if reduceAnnotations is true
if (args?.reduceAnnotations) {
headers.Prefer = 'include-annotations="-*"';
}
const pipeline = requestFromService<Record<string, Metadata> | string>(url, { headers });
const data = await runLayerOrThrow(this._layer, pipeline, "fmodata.metadata");
if (args?.format === "xml") {
return data as string;
}
const metadataMap = data as Record<string, Metadata>;
const metadata = metadataMap[this.databaseName] ?? metadataMap[stripFmp12Extension(this.databaseName)];
if (!metadata) {
throw new MetadataNotFoundError(this.databaseName);
}
return metadata;
}
/**
* Lists all available tables (entity sets) in this database.
* @returns Promise resolving to an array of table names
*/
async listTableNames(): Promise<string[]> {
const pipeline = requestFromService<{
value?: Array<{ name: string }>;
}>(`/${this.databaseName}`);
const data = await runLayerOrThrow(this._layer, pipeline, "fmodata.listTableNames");
if (data.value && Array.isArray(data.value)) {
return data.value.map((item) => item.name);
}
return [];
}
/**
* Executes a FileMaker script.
* @param scriptName - The name of the script to execute (must be valid according to OData rules)
* @param options - Optional script parameter and result schema
* @returns Promise resolving to script execution result
*/
// biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer
async runScript<ResultSchema extends StandardSchemaV1<string, any> = never>(
scriptName: string,
options?: {
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any record shape
scriptParam?: string | number | Record<string, any>;
resultSchema?: ResultSchema;
},
): Promise<
[ResultSchema] extends [never]
? { resultCode: number; result?: string }
: ResultSchema extends StandardSchemaV1<string, infer Output>
? { resultCode: number; result: Output }
: { resultCode: number; result?: string }
> {
const body: { scriptParameterValue?: unknown } = {};
if (options?.scriptParam !== undefined) {
body.scriptParameterValue = options.scriptParam;
}
const pipeline = requestFromService<{
scriptResult: {
code: number;
resultParameter?: string;
};
}>(`/${this.databaseName}/Script.${scriptName}`, {
method: "POST",
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
});
const response = await runLayerOrThrow(this._layer, pipeline, "fmodata.runScript");
// If resultSchema is provided, validate the result through it
if (options?.resultSchema && response.scriptResult !== undefined) {
const validationResult = options.resultSchema["~standard"].validate(response.scriptResult.resultParameter);
// Handle both sync and async validation
const validated = validationResult instanceof Promise ? await validationResult : validationResult;
if (validated.issues) {
throw new SchemaValidationFailedError("Database.runScript", JSON.stringify(validated.issues), {
issues: validated.issues,
});
}
return {
resultCode: response.scriptResult.code,
result: validated.value,
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
} as any;
}
return {
resultCode: response.scriptResult.code,
result: response.scriptResult.resultParameter,
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
} as any;
}
/**
* Create a batch operation builder that allows multiple queries to be executed together
* in a single atomic request. All operations succeed or fail together (transactional).
*
* @param builders - Array of executable query builders to batch
* @returns A BatchBuilder that can be executed
* @example
* ```ts
* const result = await db.batch([
* db.from('contacts').list().top(5),
* db.from('users').list().top(5),
* db.from('contacts').insert({ name: 'John' })
* ]).execute();
*
* if (result.data) {
* const [contacts, users, insertResult] = result.data;
* }
* ```
*/
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any ExecutableBuilder result type
batch<const Builders extends readonly ExecutableBuilder<any>[]>(builders: Builders): BatchBuilder<Builders> {
return new BatchBuilder(builders, this._layer);
}
}