import type { QueryClient } from '@tanstack/react-query' import type { z } from 'zod' export type ZodValidationMode = 'warn' | 'error' | 'passthrough' // ── State shape ────────────────────────────────────────────── export interface ValidationWarning { path: string message: string received: unknown } export interface TriggerError { phase: 'fetch' | 'validate' | 'transform' | 'write' cause: Error } export interface SourceState { data: T | null status: 'idle' | 'success' | 'partialSuccess' | 'error' isLoading: boolean isFetching: boolean isError: boolean error: TriggerError | null isStale: boolean lastUpdated: number | null warnings: ValidationWarning[] /** Sorted-JSON fingerprint of the params used in the last successful cache write. null = never written. */ lastParamsKey: string | null } export type TriggerResult = | { status: 'success'; data: T; warnings: [] } | { status: 'partialSuccess' data: Partial warnings: ValidationWarning[] } | { status: 'error'; data: null; error: TriggerError } // ── Plugin context types ───────────────────────────────────── export interface TriggerOptions { force?: boolean /** Dynamic params merged into the fetch function's FetchContext options at call time */ params?: Record } // ── Native cache ───────────────────────────────────────────── /** Global native cache functions configured on the engine */ export interface NativeCacheConfig { /** Read a value from the native store by key. Return null/undefined on miss. */ retrieve: (key: string) => Promise /** Write a value to the native store after a successful API fetch. */ upsert: (key: string, data: unknown) => Promise } /** Per-descriptor native cache overrides. Set to false to disable for a specific key. */ export interface NativeCacheDescriptorConfig { /** Override the native store key. Defaults to the descriptor's own key. */ storeKey?: string /** Override the global retrieve function for this descriptor only. */ retrieve?: (key: string) => Promise /** Override the global upsert function for this descriptor only. */ upsert?: (key: string, data: unknown) => Promise /** Explicitly disable native cache for this descriptor even if a global config exists. */ enabled?: boolean } export interface FetchContext { key: string options: TriggerOptions queryClient: QueryClient /** Writable metadata bag. Fetch functions and adapters can set values here; * they are copied to ResponseContext.meta for downstream plugin access. */ meta: Record } export interface ResponseContext { key: string response: unknown duration: number /** Where the data originated. 'api' = fresh network fetch, 'native-cache' = native store hit, * 'query-cache' = react-query in-memory cache served the data without calling queryFn. */ source?: 'api' | 'native-cache' | 'query-cache' | string /** Metadata set by the fetch function or native cache adapter */ meta?: Record } export interface ValidatedContext { key: string data: unknown warnings: ValidationWarning[] } export interface TransformContext { key: string data: unknown warnings: ValidationWarning[] } export interface WriteContext { key: string data: unknown warnings: ValidationWarning[] status: 'success' | 'partialSuccess' syncToRedux?: boolean /** Where the data originated. Present only for cache-restore writes. */ source?: 'native-cache' } export interface ErrorContext { key: string error: TriggerError } export interface PartialContext { key: string data: unknown warnings: ValidationWarning[] } // ── Plugin interface ───────────────────────────────────────── export interface WorkflowPlugin { name: string beforeFetch?: (ctx: FetchContext) => Promise afterFetch?: (ctx: ResponseContext) => Promise beforeValidate?: (ctx: ResponseContext) => Promise afterValidate?: (ctx: ValidatedContext) => Promise beforeTransform?: (ctx: ValidatedContext) => Promise afterTransform?: (ctx: TransformContext) => Promise beforeWrite?: (ctx: WriteContext) => Promise afterWrite?: (ctx: WriteContext) => Promise onError?: (ctx: ErrorContext) => Promise onPartialSuccess?: (ctx: PartialContext) => Promise } // ── Source descriptor ──────────────────────────────────────── export interface SplitEntry { path?: string transform?: (response: unknown) => unknown schema: z.ZodTypeAny /** * Optional merge function for this split key. * Applied before writing, the same way descriptor.merge works for direct writes. * Use case: accumulating a paginated list that lives inside a BFF response. */ merge?: (existing: unknown | null, incoming: unknown) => unknown } export interface SourceDescriptor { key: string schema: TSchema fetch?: (ctx: FetchContext) => Promise transform?: (data: z.infer) => unknown queryOptions?: { queryKey?: readonly unknown[] staleTime?: number gcTime?: number retry?: number | boolean } split?: Record plugins?: WorkflowPlugin[] syncToRedux?: boolean /** Debounce trigger calls by this many milliseconds. Rapid calls coalesce into one fetch. */ debounce?: number /** * Throttle trigger calls: first call executes immediately; further calls * within the window and with the same payload share its promise. * Different payloads always start a new pipeline, ignoring the window. * Defaults to 200 ms. Set to 0 to disable throttling for this descriptor. */ throttle?: number /** * Native cache config for this descriptor. Merges with / overrides the engine-level global config. * Set to false to explicitly disable native cache for this key even when a global config exists. */ nativeCache?: NativeCacheDescriptorConfig | false /** * Controls how Zod schema mismatches are handled for this descriptor. * Overrides the engine-level zodMode. Defaults to 'warn'. * - 'warn': partial data + per-field warnings + partialSuccess (default) * - 'error': pipeline fails at the validate phase * - 'passthrough': raw data passes through + warnings surfaced + partialSuccess */ zodMode?: ZodValidationMode /** * Optional merge function applied before writing to the Zustand slice. * Receives the current slice data and the incoming (validated + transformed) data. * The return value is written to the slice and persisted to native cache. * * Use case: accumulating paginated results, partial-update merges. * Ensure the function handles existing === null (first load or after a reset). */ merge?: (existing: unknown | null, incoming: unknown) => unknown /** * Optional predicate evaluated at the start of every trigger, before the * native cache check and before any API call. Return false to skip the * fetch entirely and serve the current in-memory slice data as-is. * Bypassed when trigger is called with force: true. * * When split is defined and shouldFetch is not, the engine auto-derives this: * it skips the fetch if every top-level key in params that matches a split key * already has data in its split slice. Override only for custom logic. */ shouldFetch?: (options: TriggerOptions, existing: unknown | null) => boolean } // ── Type inference utilities ───────────────────────────────── export type InferSplitResult> = { [K in keyof TSplit]: z.infer } export type InferDescriptorResult> = D extends { split: infer TSplit extends Record } ? InferSplitResult : z.infer // ── PipelineError ──────────────────────────────────────────── export class PipelineError extends Error { readonly phase: TriggerError['phase'] readonly key: string /** The underlying error that caused this pipeline failure. Declared explicitly * so it type-checks on any TypeScript target (pre- and post-ES2022). */ readonly cause: Error /** Alias for cause — kept for backwards compatibility */ readonly errorCause: Error constructor(params: { phase: TriggerError['phase'] cause: Error key: string }) { super( `[${params.key}] pipeline failed at ${params.phase}: ${params.cause.message}`, ) this.name = 'PipelineError' this.phase = params.phase this.key = params.key this.cause = params.cause this.errorCause = params.cause } }