import type { z } from 'zod' import type { PluginBus } from '../plugins/bus' import type { EngineStore } from '../store' import type { SourceDescriptor, TriggerError, TriggerResult, ValidationWarning, WriteContext, ZodValidationMode, } from '../types' import { PipelineError } from '../types' import { splitStage } from './stages/split' import { transformStage } from './stages/transform' import { validateStage } from './stages/validate' import { writeStage } from './stages/write' export async function runPipeline( descriptor: SourceDescriptor, rawData: unknown, store: EngineStore, bus: PluginBus, engineZodMode: ZodValidationMode = 'warn', lastParamsKey?: string, ): Promise> { const { key } = descriptor const perKey = descriptor.plugins ?? [] const effectiveZodMode: ZodValidationMode = descriptor.zodMode ?? engineZodMode try { // 1. validate const responseCtx = await bus.run('beforeValidate', perKey, { key, response: rawData, duration: 0, }) let validatedCtx = await validateStage( responseCtx, descriptor.schema, effectiveZodMode, ) validatedCtx = await bus.run('afterValidate', perKey, validatedCtx) // 2. transform validatedCtx = await bus.run('beforeTransform', perKey, validatedCtx) const transformedCtx = await transformStage( validatedCtx, descriptor.transform, ) const afterTransformCtx = await bus.run( 'afterTransform', perKey, transformedCtx, ) // 3a. split path — use raw transformed data for extraction so partial top-level // validation (which strips parent keys when a nested field fails) doesn't block // sibling split keys. Each split entry's schema is the true validation boundary. // // If descriptor.merge is defined it is applied to the raw BFF response BEFORE // splitting so that every child key receives data from the accumulated merged // state, not just the latest response in isolation. if (descriptor.split) { const dataToSplit = descriptor.merge ? descriptor.merge(store.getState().slices[key]?.data ?? null, rawData) : rawData const splitCtx = { key: afterTransformCtx.key, data: dataToSplit, warnings: afterTransformCtx.warnings, } const splitResults = await splitStage( splitCtx, descriptor.split, effectiveZodMode, ) let overallStatus: 'success' | 'partialSuccess' = 'success' const allWarnings: ValidationWarning[] = [] for (const [splitKey, splitResult] of Object.entries(splitResults)) { const hasWarnings = splitResult.warnings.length > 0 if (hasWarnings) overallStatus = 'partialSuccess' allWarnings.push(...splitResult.warnings) const splitEntry = descriptor.split?.[splitKey] const writeCtx: WriteContext = { key: splitKey, data: splitResult.data, warnings: splitResult.warnings, status: hasWarnings ? 'partialSuccess' : 'success', syncToRedux: descriptor.syncToRedux, } const finalWriteCtx = await bus.run('beforeWrite', perKey, writeCtx) // splitEntry.merge handles per-child accumulation independent of descriptor.merge await writeStage(finalWriteCtx, store, splitEntry?.merge, lastParamsKey) await bus.runObserver('afterWrite', perKey, finalWriteCtx) } const combinedData = Object.fromEntries( Object.entries(splitResults).map(([k, v]) => [k, v.data]), ) // Reset the parent trigger key — engine.trigger() set it to isLoading: true // but only child keys received writeStage calls above. Merge combinedData // onto the existing parent data so partial responses (subset of split keys) // accumulate rather than overwrite the full parent slice. const existingParent = store.getState().slices[key]?.data const parentData = typeof existingParent === 'object' && existingParent !== null ? { ...(existingParent as Record), ...combinedData } : combinedData const parentWriteCtx: WriteContext = { key, data: parentData, warnings: allWarnings, status: overallStatus, syncToRedux: descriptor.syncToRedux, } await writeStage(parentWriteCtx, store, undefined, lastParamsKey) if (overallStatus === 'partialSuccess') { await bus.runObserver('onPartialSuccess', perKey, { key, data: combinedData, warnings: allWarnings, }) return { status: 'partialSuccess', data: combinedData as Partial, warnings: allWarnings, } } return { status: 'success', data: combinedData as T, warnings: [] } } // 3b. direct write path const warnings = afterTransformCtx.warnings const writeStatus: 'success' | 'partialSuccess' = warnings.length > 0 ? 'partialSuccess' : 'success' let writeCtx: WriteContext = { key, data: afterTransformCtx.data, warnings, status: writeStatus, syncToRedux: descriptor.syncToRedux, } writeCtx = await bus.run('beforeWrite', perKey, writeCtx) await writeStage(writeCtx, store, descriptor.merge, lastParamsKey) await bus.runObserver('afterWrite', perKey, writeCtx) if (writeStatus === 'partialSuccess') { await bus.runObserver('onPartialSuccess', perKey, { key, data: writeCtx.data, warnings, }) return { status: 'partialSuccess', data: writeCtx.data as Partial, warnings, } } return { status: 'success', data: writeCtx.data as T, warnings: [] } } catch (err) { const isPipelineError = err instanceof PipelineError const phase: TriggerError['phase'] = isPipelineError ? err.phase : 'transform' const cause = err instanceof Error ? err : new Error(String(err)) const triggerError: TriggerError = { phase, cause } store.getState().actions.setSlice(key, { isLoading: false, isFetching: false, isError: true, error: triggerError, status: 'error', }) await bus.runObserver('onError', perKey, { key, error: triggerError }) return { status: 'error', data: null, error: triggerError } } }