UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

207 lines (189 loc) 6.8 kB
/** * Effect.ts integration for fmodata. * * Provides Effect-based wrappers around the core fmodata operations, * enabling composable error handling, retry policies, and typed error channels. * * This module is used internally by builders to reduce error-threading boilerplate. * The public API surface (Result<T>) remains unchanged. */ import type { FFetchOptions } from "@fetchkit/ffetch"; import { Effect, Schedule } from "effect"; import type { DatabaseNameNormalizationMode } from "./client/database-name"; import type { FMODataErrorType } from "./errors"; import { BuilderInvariantError, isFMODataError, isTransientError } from "./errors"; import type { FMODataLayer, HttpClient as HttpClientService, ODataConfig as ODataConfigService, ODataLogger as ODataLoggerService, } from "./services"; import { HttpClient, ODataConfig } from "./services"; import type { Result, RetryPolicy } from "./types"; type FMODataServices = HttpClientService | ODataConfigService | ODataLoggerService; /** * Converts a Promise<Result<T>> into an Effect with typed error channel. * This is the bridge between the existing Result pattern and Effect pipelines. */ export function fromResult<T>(promise: Promise<Result<T>>): Effect.Effect<T, FMODataErrorType> { return Effect.tryPromise({ try: () => promise, catch: (e) => e as FMODataErrorType, }).pipe(Effect.flatMap((result) => (result.error ? Effect.fail(result.error) : Effect.succeed(result.data)))); } /** * Creates an Effect that yields the HttpClient service and makes a request. * This is the primary way builders should make HTTP requests. */ export function requestFromService<T>( url: string, options?: RequestInit & FFetchOptions & { normalizeDatabaseName?: boolean; databaseNameNormalizationMode?: DatabaseNameNormalizationMode; useEntityIds?: boolean; includeSpecialColumns?: boolean; includeODataAnnotations?: boolean; retryPolicy?: RetryPolicy; }, ): Effect.Effect<T, FMODataErrorType, HttpClient | ODataConfig> { return Effect.gen(function* () { const client = yield* HttpClient; const config = yield* ODataConfig; return yield* client.request<T>(url, { ...options, normalizeDatabaseName: options?.normalizeDatabaseName ?? config.normalizeDatabaseName, }); }); } /** * Runs an Effect pipeline and converts the result back to the fmodata Result type. * This is the exit point from Effect back to the public API. */ export function runAsResult<T>(effect: Effect.Effect<T, FMODataErrorType>): Promise<Result<T>> { return Effect.runPromise( effect.pipe( Effect.map((data): Result<T> => ({ data, error: undefined })), Effect.catchAll((error) => Effect.succeed<Result<T>>({ data: undefined, error })), ), ).catch((defect) => ({ data: undefined, error: isFMODataError(defect) ? defect : new BuilderInvariantError("runAsResult", String(defect)), })); } function withOptionalSpan<T, E, R>( effect: Effect.Effect<T, E, R>, spanName?: string, attributes?: Record<string, string>, ): Effect.Effect<T, E, R> { if (!spanName) { return effect; } return withSpan(effect, spanName, attributes); } /** * Runs an Effect by providing the shared DI layer and returns fmodata Result<T>. */ export function runLayerResult<T>( layer: FMODataLayer, effect: Effect.Effect<T, FMODataErrorType, FMODataServices>, spanName?: string, attributes?: Record<string, string>, ): Promise<Result<T>> { const provided = Effect.provide(withOptionalSpan(effect, spanName, attributes), layer); return runAsResult(provided); } /** * Runs an Effect by providing the shared DI layer and throws on fmodata errors. */ export async function runLayerOrThrow<T>( layer: FMODataLayer, effect: Effect.Effect<T, FMODataErrorType, FMODataServices>, spanName?: string, attributes?: Record<string, string>, ): Promise<T> { const result = await runLayerResult(layer, effect, spanName, attributes); if (result.error) { throw result.error; } return result.data; } /** * Convenience wrapper for request-like effects where span instrumentation is always desired. */ export function requestWithSpan<T>( layer: FMODataLayer, spanName: string, requestEffect: Effect.Effect<T, FMODataErrorType, FMODataServices>, attributes?: Record<string, string>, ): Promise<Result<T>> { return runLayerResult(layer, requestEffect, spanName, attributes); } /** * Wraps a sync/async function that may throw into an Effect that captures * the error as a typed FMODataErrorType. */ export function tryEffect<T>( fn: () => T | Promise<T>, mapError: (e: unknown) => FMODataErrorType, ): Effect.Effect<T, FMODataErrorType> { return Effect.tryPromise({ try: () => Promise.resolve(fn()), catch: mapError, }); } /** * Wraps a function that returns a validation-style result * ({ valid: true, data } | { valid: false, error }) into an Effect. */ export function fromValidation<T>( fn: () => Promise<{ valid: true; data: T } | { valid: false; error: FMODataErrorType }>, ): Effect.Effect<T, FMODataErrorType> { return Effect.tryPromise({ try: fn, catch: (e) => e as FMODataErrorType, }).pipe(Effect.flatMap((result) => (result.valid ? Effect.succeed(result.data) : Effect.fail(result.error)))); } /** * Builds an Effect Schedule from a RetryPolicy configuration. * Uses exponential backoff with optional jitter, only retrying transient errors. */ export function buildRetrySchedule(policy: RetryPolicy) { const maxRetries = policy.maxRetries ?? 3; const baseDelay = `${policy.baseDelay ?? 500} millis` as const; const useJitter = policy.jitter !== false; const base = Schedule.exponential(baseDelay); const withJitter = useJitter ? Schedule.jittered(base) : base; return withJitter.pipe( Schedule.intersect(Schedule.recurs(maxRetries)), Schedule.whileInput((error: FMODataErrorType) => isTransientError(error)), ); } /** * Applies a retry policy to an Effect if the policy is defined. * Only retries transient errors (SchemaLockedError, NetworkError, TimeoutError, HTTP 5xx). */ export function withRetryPolicy<T>( effect: Effect.Effect<T, FMODataErrorType>, retryPolicy?: RetryPolicy, ): Effect.Effect<T, FMODataErrorType> { if (!retryPolicy) { return effect; } return effect.pipe(Effect.retry(buildRetrySchedule(retryPolicy))); } /** * Wraps an Effect with a tracing span for observability. * Zero overhead when no OpenTelemetry tracer is configured. */ export function withSpan<T, E, R>( effect: Effect.Effect<T, E, R>, name: string, attributes?: Record<string, string>, ): Effect.Effect<T, E, R> { return effect.pipe( Effect.withSpan(name, { attributes: attributes ? attributes : undefined, }), ); }