diff --git a/CLAUDE.md b/CLAUDE.md index 699b72f..e7c345a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,7 @@ **Pipelean** is a pragmatic JavaScript library for sequential async operations with first-class error handling. It focuses on explicit execution, predictable results, and avoiding common async anti-patterns. -- **Homepage**: https://github.com/ildella/pipelean -- **License**: MIT -- **Main Entry**: `src/index.js` (exports from `src/functional.js`) +Checks with `yarn lint` and `yarn test`. ## Related Documentation diff --git a/README.md b/README.md index 303b79b..4affc76 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ We believe Pipelean is a pragmatic middle path: sequential by design, with built * [Architecture](docs/architecture.md) : The philosophy and design principles. * [Guide](docs/guide.md) : Core concepts and usage patterns. - + [examples.md](../examples.md) - Practical usage examples for all functions - + [functional.md](docs/functional.md) - Reference docs + + [Examples](../examples.md) - Practical usage examples for all functions + + [Reference](docs/reference.md) - Reference docs ## Example diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..585001a --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,208 @@ +# Error Handling in Pipelean + +Pipelean provides comprehensive error handling through named strategies and callbacks. + +## Error Strategies + +All iteration functions (`series`, `filter`, `scan`) support four error strategies: + +### `failFast` (default for `scan`) + +Stop immediately on first error. + +**Aliases:** `fail`, `stopOnError` + +**Returns:** `{results, errors: [], failure: {item, error}}` + +```javascript +import {series, failFast} from 'pipelean' + +const result = await series([1, 2, 3], async item => { + if (item === 2) throw new Error('Error') + return item * 2 +}, {strategy: failFast}) + +// result = {results: [2], errors: [], failure: {item: 2, error: Error(...)}} +``` + +**Use when:** Critical operations where failure means entire pipeline is invalid. + +--- + +### `collect` (default for `series` and `filter`) + +Continue through all items, collect errors. + +**Returns:** `{results, errors: [...], failure: null}` + +```javascript +import {series, collect} from 'pipelean' + +const result = await series([1, 2, 3], async item => { + if (item === 2 || item === 4) throw new Error('Error') + return item * 2 +}, {strategy: collect}) + +// result = {results: [2, 6], errors: [{item: 2, error: ...}, {item: 4, error: ...}], failure: null} +``` + +**Use when:** Batch operations, logging scenarios, background tasks. + +--- + +### `failLate` + +Continue through all items like `collect`, but return `failure: true` at the end. + +**Returns:** `{results, errors: [...], failure: true}` (if any errors occurred) + +```javascript +import {series, failLate} from 'pipelean' + +const result = await series([1, 2, 3], async item => { + if (item === 2 || item === 4) throw new Error('Error') + return item * 2 +}, {strategy: failLate}) + +// result = {results: [2, 6], errors: [{item: 2, error: ...}, {item: 4, error: ...}], failure: true} +``` + +**Use when:** Application-layer needs to detect if *any* error occurred. + +--- + +### `skip` + +Ignore errors entirely (no collection), but `onError` is still called if present. + +**Returns:** `{results, errors: [], failure: null}` + +```javascript +import {series, skip} from 'pipelean' + +const result = await series([1, 2, 3], async item => { + if (item === 2) throw new Error('Error') + return item * 2 +}, {strategy: skip}) + +// result = {results: [2, 6], errors: [], failure: null} +``` + +**Use when:** Best-effort processing, some failures are acceptable. + +--- + +## Callbacks + +### `onError` + +Optional callback for verification/telemetry (logging, metrics). + +- Called for **every** error +- Does NOT affect control flow +- Use for: logging, metrics, external error reporting + +```javascript +await series(items, fn, { + strategy: skip, + onError: (error) => console.error('Error:', error.message) // Called for each error +}) +``` + +--- + +### `onFailure` + +Optional callback for application-layer error handling (UI updates, notifications). + +- Called when `failure` is truthy +- Depends on strategy: + - `failFast`: called with `{item, error}` + - `failLate`: called with `true` + - `collect` / `skip`: NOT called (failure is null) + +```javascript +await series(items, fn, { + strategy: failFast, + onFailure: (failure) => { + if (failure === true) { + // failLate: show general error notification + showToast('Some items failed') + } else { + // failFast: show specific error with item + showToast(`Item ${failure.item} failed: ${failure.error.message}`) + } + } +}) +``` + +--- + +## Usage Patterns + +### Pattern 1: Logging + Detection (`collect` + `onError`) + +```javascript +await series(items, fn, { + strategy: collect, + onError: (error) => logger.error(error) +}) +// Check failure manually: if (result.errors.length > 0) { ... } +``` + +### Pattern 2: Best-effort + Monitoring (`skip` + `onError`) + +```javascript +await series(items, fn, { + strategy: skip, + onError: (error) => metrics.increment('errors') +}) +// Result has no errors array, failure is null +``` + +### Pattern 3: Critical Fail + UI (`failFast` + `onFailure`) + +```javascript +await series(items, fn, { + strategy: failFast, + onFailure: (failure) => { + showErrorModal(failure.error.message) + rollbackChanges() + } +}) +``` + +### Pattern 4: Application Wrapper with Default `onFailure` + +```javascript +const withErrorHandling = (opts) => ({ + ...opts, + onFailure: (failure) => { + if (failure === true) { + showToast('Some items failed') + } else { + showToast(`Error: ${failure.error.message}`) + } + if (opts.onFailure) opts.onFailure(failure) + } +}) + +await series(items, fn, withErrorHandling({strategy: failFast})) +``` + +--- + +## Key Principles + +1. **`onError` ≠ error strategy**: `onError` is a callback, not a strategy +2. **`failure` is truthy for**: `failFast` ({item, error}) and `failLate` (true) +3. **`failure` is null for**: `collect` and `skip` +4. **Strategy selection**: Choose based on whether failures are acceptable + +--- + +## Further Reading + +- **Examples:** See `tests/onFailure.test.js` and `tests/error-strategies.test.js` +- **Reference:** See `docs/functional.md` for function signatures +- **Guide:** See `docs/guide.md` for high-level patterns diff --git a/docs/functional.md b/docs/functional.md deleted file mode 100644 index a563d81..0000000 --- a/docs/functional.md +++ /dev/null @@ -1,576 +0,0 @@ -# functional.js Reference Documentation - -**Overview** - -`functional.js` is an async programming library that provides core utilities for handling asynchronous operations, error management, data transformations, and functional composition patterns in JavaScript. The library is designed with a pragmatic philosophy: avoid heavy abstractions, use eager execution, and provide clear, predictable error handling. - -**Key Principles** - -- **Pragmatic**: Plain JavaScript, eager execution, sequential processing. -- **First class error handling**: Two distinct strategies (`failFast` and `collect`) for different use cases - ---- - -## Table of Contents - -1. [failFast](#1-failfast) - Error Strategy Identifier -2. [collect](#2-collect) - Error Strategy Identifier -3. [tryCatch](#3-trycatch) - Single Function Lifecycle Hooks -4. [delay](#4-delay) - Promise-based Delays -5. [retry](#5-retry) - Configurable Retry Logic -6. [series](#6-series) - Horizontal Sequential Execution -7. [scan](#7-scan) - Stateful Sequential Transformation -8. [filter](#8-filter) - Stateless Selection -9. [safeScan](#9-safescan) - Safe Stateful Transformation -10. [pipe](#10-pipe) - Vertical Composition -11. [safeAsyncIterator](#11-safeasynciterator) - Lazy Async Iterator Wrapper -12. [collectAsync](#12-collectasync) - Async Iterator to Array - ---- - -## 1. failFast - -**Purpose**: Error strategy identifier used throughout the library to indicate immediate failure without partial results. - -**Type**: `Object` - -```javascript -export const failFast = Object.freeze({name: 'failFast'}) -``` - -**Usage**: Passed as the `onError` parameter to other functions (like `filter`, `tryCatch`, `scan`) to specify that errors should be handled with the `failFast` strategy. This strategy stops immediately on the first error and returns a structured failure object containing the failed item and error. - ---- - -## 2. collect - -**Purpose**: Error strategy identifier used throughout the library to indicate that errors should be gathered and processing should continue. - -**Type**: `Object` - -```javascript -export const collect = Object.freeze({name: 'collect'}) -``` - -**Usage**: Passed as the `onError` parameter to functions to specify that errors should be collected and processing should continue. This is used in `scan` and other operations where accumulating results is more important than failing fast. - ---- - -## 3. tryCatch - -**Purpose**: Wraps individual async functions with lifecycle hooks for comprehensive error handling and telemetry. - -**Type**: `(fn, options) => wrapperFunction` - -**Parameters**: -- `fn`: The async function to wrap -- `options`: Configuration object with the following properties: - - `onStart`: `(fn, args) => void` - Called before function execution - - `onSuccess`: `(fn, args, result) => void | Promise` - Called on successful completion - - `onError`: `(fn, args, error) => void` - Called on error - - `onFinally`: `(fn, args) => void` - Called regardless of success/failure - - `rethrow`: `boolean` (default: `false`) - Whether to rethrow errors after handling - - `isCatch`: `boolean` (default: `true`) - Whether to use try/catch (pass `false` for manual error handling) - -**Return Type**: Returns a wrapper function with the same signature as `fn`. - -**Features**: -- Automatic `async/await` wrapping -- Preserves original function signature -- Supports both try/catch and manual error handling modes -- Comprehensive lifecycle: onStart → onSuccess/onError → onFinally -- Optional rethrow for error propagation -- Deep telemetry support for debugging (tracks which step in a 5-step pipe failed) - -**Usage Example**: -```javascript -import { tryCatch } from './functional.js' - -// Example 1: Try/catch mode -const safeFetch = tryCatch( - async (url) => { - const response = await fetch(url) - return await response.json() - }, - { - onError: async (error) => { - console.error('Fetch failed:', error) - } - } -) - -// Example 2: Manual error handling mode -const customFetch = tryCatch( - async (url, options) => { - // Manual error handling with rethrow - const response = await fetch(url, options) - if (!response.ok) { - throw new Error('Network error') - } - return await response.json() - }, - { - onError: async (error) => { - console.error('Custom handler:', error) - return { customHandled: true } - }, - rethrow: true, - isCatch: false - } -) -``` - ---- - -## 4. delay - -**Purpose**: Promise-based delay utility. - -**Type**: `(ms: number) => Promise` - -**Parameters**: -- `ms`: Number of milliseconds to delay (required) - -**Return Type**: A Promise that resolves after the specified delay. - -**Usage Example**: -```javascript -import { delay } from './functional.js' - -// Wait 1000ms before retrying -await delay(1000) - -// Delay in a retry loop -for (let i = 0; i < 3; i++) { - await someOperation() - await delay(500) // Wait before next attempt -} -``` - ---- - -## 5. retry - -**Purpose**: Retry async functions with configurable attempts and delays between attempts. - -**Type**: `(fn, options) => retryFunction` - -**Parameters**: -- `fn`: The async function to retry (required) -- `options`: Configuration object with the following properties: - - `attempts`: `number` (default: `3`) - Number of retry attempts - - `delayMs`: `number` (default: `0`) - Delay between retry attempts in milliseconds - -**Behavior**: -- Retries only on specified errors (if `onError` is provided) -- Throws the last error after exhausting all attempts -- No delay before first attempt -- Applies configured delay between subsequent attempts - -**Usage Example**: -```javascript -import { retry } from './functional.js' - -// Retry with default 3 attempts and 500ms delay -const result = await retry( - async fetchWithRetry() => { - return await fetch('/api/data') - }, - { - onError: 'failFast', // Only retry on specific errors - attempts: 3, - delayMs: 500 - } -) - -// Retry with custom configuration -const result = await retry( - async flakyOperation() => { - return Math.random() > 0.5 // Simulate 50% failure rate - }, - { - attempts: 5, - delayMs: 1000, - onError: 'collect' // Collect all errors, don't fail fast - } -) -``` - ---- - -## 6. series - -**Purpose**: Horizontal composition tool - executes multiple functions in sequence, passing the output of one as input to the next. - -**Type**: `(...fns) => (input) => Promise>` - -**Parameters**: -- Variadic arguments: Any number of async functions to execute sequentially -- `input`: The initial value passed to the first function - -**Return Type**: A Promise that resolves to the final result. - -**Key Characteristics**: -- Functions execute **left-to-right** (first argument is applied to `input`) -- Each function receives the result of the previous function as its first argument -- Supports synchronous or asynchronous functions -- Can be nested to build complex transformation pipelines - -**Usage Example**: -```javascript -import { series } from './functional.js' - -// Transform a value through multiple steps -const result = await series( - async (x) => x * 2, // Step 1: Double - async (x) => x + 10, // Step 2: Add 10 - async (x) => x.toString(), // Step 3: Convert to string - 42 // Initial value -) - -// Example with async operations -const result = await series( - async (id) => fetchUser(id), // Get user data - async (user, data) => updateUser(user, data), // Update user - user.id // Pass ID to next step -) -``` - -**Best Practice**: Use `series()` when you need to chain async operations and ensure each completes before the next starts. - ---- - -## 7. scan - -**Purpose**: Stateful sequential transformation - transforms each item and accumulates results. - -**Type**: `(iterable, scanner, initialValue) => scanFunction` - -**Parameters**: -- `iterable`: An async iterable (array, generator, or any object implementing the iteration protocol) -- `scanner`: A function with signature `(accumulator, item, index) => newAccumulator` -- `initialValue`: The starting value for the accumulator - -**Return Type**: A Promise that resolves to an object containing: -- `results`: Array of all successful transformations -- `errors`: Array of errors encountered -- `failure`: The item/index where failure occurred (if scan stopped early) - -**Key Characteristics**: -- **Stateful**: Each transformation depends on the previous result -- **Stop on error**: Can be configured to stop on first error via `safeScan` -- **Accumulates**: Both successful results and errors for inspection -- **Index Tracking**: Provides index of each item for correlation - -**Usage Example**: -```javascript -import { scan } from './functional.js' - -// Track insertions in a database -const { results, errors } = await scan( - async records, - async (acc, record) => { - const inserted = await db.insert(record) - return acc + inserted // Accumulate count - }, - 0 // Initial count -) - -// Process items with error tracking -const { results, errors } = await scan( - async dataItems, - async (acc, item) => { - try { - const processed = await processItem(item) - return acc + processed.length - } catch (error) { - return acc // Return count without incrementing - } - }, - 0 -) - -// With safeScan (stops on first error) -const { results, errors, failure } = await safeScan( - itemsToProcess, - async (acc, item, index) => { - return await transformItem(item) - }, - 0, - { onError: failFast } -) -``` - -**When to Use**: Use `scan()` when you need to transform data sequentially and maintain state between steps, or when you need both results and errors for debugging. - ---- - -## 8. filter - -**Purpose**: Stateless selection tool - filters items from an iterable based on a predicate function. - -**Type**: `(...args) => filteredItems | filterFunction` - -**Parameters**: -- First argument (optional): If a function, specifies `take` and `onError` strategy -- Remaining arguments: Items to filter -- If first arg is NOT a function: Treated as `iterable` and processed with `take` strategy - -**Strategies**: -- **failFast**: Stops immediately on first error (returns structured failure) -- **collect**: Gathers all errors and continues (default) - -**Behavior**: -- With `failFast`: Returns `{ results, errors, failure: { item, error } }` object -- With `collect`: Returns `{ results, errors, failure: null }` object - -**Usage Example**: -```javascript -import { filter } from './functional.js' - -// Filter valid emails from a list -const validEmails = await filter( - async (email) => { - return email.includes('@') - }, - emails, - { - onError: failFast // Stop on first invalid email - } -) - -// Collect all errors with collect strategy -const { results, errors } = await filter( - async (items, callback) => { - return await callback(item) - }, - list, - { - onError: collect // Gather all errors - } -) -``` - ---- - -## 9. safeScan - -**Purpose**: Safe stateful transformation with immediate failure on error. - -**Type**: `(iterable, scanner, initialValue) => safeScanFunction` - -**Parameters**: -- `iterable`: An async iterable to process -- `scanner`: Transformation function `(accumulator, item, index) => newAccumulator` -- `initialValue`: Starting value for the accumulator - -**Key Difference from scan**: -- **Stops immediately on error**: Cannot continue processing if a step fails -- **Returns structured failure**: Always returns `{ results, errors, failure: { item, error }` object for debugging -- **Use case**: When pipeline must stop if any step fails (e.g., database transaction, critical data validation) - -**Usage Example**: -```javascript -import { safeScan } from './functional.js' - -// Safe database operations - stop on any error -const { results, errors, failure } = await safeScan( - async records, - async (acc, record, index) => { - const inserted = await db.insert(record) - return acc + inserted - }, - 0, - { onError: failFast } -) -``` - ---- - -## 10. pipe - -**Purpose**: Vertical composition tool - chains functions left-to-right (Unix pipe pattern). - -**Type**: `(...fns) => (input) => Promise>` - -**Parameters**: -- Variadic arguments: Any number of async functions to execute sequentially -- `input`: The initial value passed to the first function - -**Return Type**: A Promise that resolves to the final result. - -**Key Characteristics**: -- Functions execute **left-to-right** (first argument is applied to `input`) -- Output of one function becomes input to the next -- Supports both synchronous and asynchronous functions -- Natural data flow from input through transformations - -**Usage Example**: -```javascript -import { pipe } from './functional.js' - -// Process user through validation, transformation, and storage -const userId = await pipe( - async (id) => validateUserId(id), // Step 1 - async (id) => fetchUser(id), // Step 2 - async (user, data) => saveUser(user, data), // Step 3 - userId // Starting value -) - -// Compose operations in a readable pipeline -const result = await pipe( - async (data) => validate(data), - async (data) => transform(data), - async (data) => persist(data), - null // No initial data needed -) -``` - -**Best Practice**: Use `pipe()` when you need to chain operations that form a coherent data processing pipeline. - ---- - -## 11. safeAsyncIterator - -**Purpose**: Lazy async iterator wrapper with comprehensive error handling. - -**Type**: `iterable => asyncGenerator` - -**Parameters**: -- `iterable`: An async iterable (array, generator, or any object implementing iteration protocol) -- `options`: Configuration object - - `onError`: Error handler ('failFast' by default) - -**Return Type**: An async generator that yields items one by one. - -**Behavior**: -- Yields transformed items (via `transform`) or original items (if no transform) -- Forces error checking on every item -- Supports two modes: `failFast` (stop) or `collect` (continue) - -**Usage Example**: -```javascript -import { safeAsyncIterator } from './functional.js' - -// Transform items lazily with error handling -const iter = safeAsyncIterator( - async fetchDataPages(), // Generator - async (page) => transformPage(page), // Transformer - { onError: failFast } -) - -for await (const item of iter) { - console.log('Processing:', item) -} -``` - ---- - -## 12. collectAsync - -**Purpose**: Collect all items from an async iterator/generator into an array. - -**Type**: `iterator => collectAsync(iterator) => Promise>` - -**Parameters**: -- `iterator`: An async iterator, generator, or iterable - -**Return Type**: A Promise that resolves to an array of all yielded items. - -**Behavior**: -- Executes iterator until completion -- Resolves with array of all items -- Handles both normal values and `{ error, item }` objects if iterator yields them - -**Usage Example**: -```javascript -import { collectAsync } from './functional.js' - -// Collect all pages from a generator -const allRecords = await collectAsync( - async function* fetchAllPages() { - let page = 1 - while (true) { - const data = await fetchPage(page) - yield data - if (!data.hasMore) break - page++ - } - } -) - -// Collect from async iterable -const items = await collectAsync( - [fetchItem1(), fetchItem2(), fetchItem3()] -) -``` - ---- - -## Error Handling Strategies - -The library provides two distinct error handling approaches: - -### failFast Strategy -- **Behavior**: Stop immediately on first error -- **Use case**: Critical operations, validation failures, or when partial results are unacceptable -- **Returns**: `{ results, errors, failure: { item, error } }` - -### collect Strategy -- **Behavior**: Gather all errors and continue processing -- **Use case**: Accumulating results, data validation, or when you need complete error history -- **Returns**: `{ results, errors, failure: null }` - ---- - -## Best Practices - -1. **Choose the Right Strategy**: - - Use `failFast` for critical operations where any error means total failure - - Use `collect` when you need to accumulate errors or continue on validation failures - - Use `tryCatch` for single operations needing lifecycle hooks - -2. **Stateful vs Stateless**: - - `scan` and `safeScan` are stateful (maintain accumulator) - - `filter` is stateless (no accumulator maintained) - -3. **Composition**: - - Use `series()` for horizontal pipelines - - Use `pipe()` for vertical composition (data flows left-to-right) - -4. **Error Propagation**: - - Set `rethrow: true` in `tryCatch` to propagate errors up the call stack - - Set `rethrow: false` when you want to handle errors locally - -5. **Telemetry and Debugging**: - - Use lifecycle hooks (`onStart`, `onSuccess`, `onError`) to track execution flow - - Check structured error objects for `{ item, error }` properties to identify which step failed - -6. **Performance**: - - Avoid nesting `series()` calls unnecessarily - compose related operations together - - Use appropriate error strategy - don't always use `collect` if `failFast` is sufficient - ---- - -## Architecture Notes - -The library follows Unix philosophy: -- **Small, composable tools**: Each function does one thing well -- **Clear contracts**: Input types and output types are explicit -- **Eager execution**: Functions execute immediately, no lazy evaluation -- **Explicit error handling**: Errors are handled explicitly, not hidden - -This makes `functional.js` suitable for: -- Building robust async pipelines -- Implementing retry logic -- Creating stateful transformations -- Handling data validation -- Composing complex operations from simple primitives - ---- - -## Related Documentation - -- **[README.md](../README.md)** - Project overview and philosophy -- **[guide.md](../guide.md)** - Comprehensive development guide with examples -- **[examples.md](../examples.md)** - Practical usage examples for all functions diff --git a/docs/guide.md b/docs/guide.md index 816e105..fb1fd88 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -13,24 +13,6 @@ Pipelean provides four main tools, grouped by **data flow direction** (horizonta 3. filter (Horizontal / Stateless selection) 4. pipe (Vertical / Composition) -## The Function Wrappers - -Pipelean also provides lightweight wrappers that add behavior to **individual functions**. These act as reusable middleware / lifecycle hooks and compose naturally with `pipe`. - -- **`tryCatch(fn, options?)`** - Protects a single function with lifecycle hooks: - - `onStart`, `onSuccess`, `onError`, `onFinally` - - Captures errors without crashing the outer flow - - Enables deep telemetry (e.g., log exactly which step in a 5-step pipe failed) - - Works standalone or inside `series`/`scan`/`pipe` - - Ideal for centralized error reporting (Sentry, UI toasts, metrics) even outside pipelines - -- **`retry(fn, options?)`** - Specialized for automatic retries - - Configurable: times, delay - - Retries only on specified errors (or all by default) - - Composes cleanly in `pipe` chains (e.g. retry network calls but not validation) - ## Features #### Series / Scan / Filter @@ -55,3 +37,21 @@ Pipelean also provides lightweight wrappers that add behavior to **individual fu * **Termination Control (`take`)** - Allows processing a subset of data (e.g., "process only the first N items"). - Essential for working with infinite generators or streams. + +### Wrappers + +Pipelean also provides lightweight wrappers that add behavior to **individual functions**. These act as reusable middleware / lifecycle hooks and compose naturally with `pipe`. + +- **`tryCatch(fn, options?)`** + Protects a single function with lifecycle hooks: + - `onStart`, `onSuccess`, `onError`, `onFinally` + - Captures errors without crashing the outer flow + - Enables deep telemetry (e.g., log exactly which step in a 5-step pipe failed) + - Works standalone or inside `series`/`scan`/`pipe` + - Ideal for centralized error reporting (Sentry, UI toasts, metrics) even outside pipelines + +- **`retry(fn, options?)`** + Specialized for automatic retries + - Configurable: times, delay + - Retries only on specified errors (or all by default) + - Composes cleanly in `pipe` chains (e.g. retry network calls but not validation) diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..fa13896 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,390 @@ +# Pipelean Reference Documentation + +**Overview** + +`functional.js` is an async programming library that provides core utilities for handling asynchronous operations, error management, data transformations, and functional composition patterns in JavaScript. The library is designed with a pragmatic philosophy: avoid heavy abstractions, use eager execution, and provide clear, predictable error handling. + +**Key Principles** + +- **Pragmatic**: Plain JavaScript, eager execution, sequential processing. +- **First class error handling**: Multiple error strategies for different use cases + +--- + +## Table of Contents + +### Error strategies + +- [failFast](#failfast) - Error Strategy Identifier +- [fail](#fail) - Error Strategy Alias +- [collect](#collect) - Error Strategy Identifier +- [failLate](#faillate) - Error Strategy Identifier +- [skip](#skip) - Error Strategy Identifier +- [stopOnError](#stoponerror) - Error Strategy Alias + +### Iterators + +**Horizontal** + +- [series](#series) - Stateless Sequential Execution +- [scan](#scan) - Stateful Sequential Transformation +- [filter](#filter) - Stateless Selection + +### Composition + +- [pipe](#pipe) - Vertical Composition + +### Misc + +- [retry](#retry) - Configurable Retry Logic +- [tryCatch](#trycatch) - Single Function Lifecycle Hooks + +--- + +## Error strategies + +### failFast + +**Purpose**: Error strategy identifier used throughout the library to indicate immediate failure without partial results. + +**Type**: `Object` + +```javascript +export const failFast = Object.freeze({name: 'failFast'}) +``` + +**Usage**: Passed as the `strategy` parameter to iteration functions. Stops immediately on first error and returns a structured failure object containing the failed item and error. + +--- + +### fail + +**Purpose**: Alias for `failFast` error strategy. + +**Type**: `Object` + +```javascript +export const fail = Object.freeze({name: 'failFast'}) +``` + +**Usage**: Use as a shorthand for `failFast`. + +--- + +### collect + +**Purpose**: Error strategy identifier used throughout the library to indicate that errors should be gathered and processing should continue. + +**Type**: `Object` + +```javascript +export const collect = Object.freeze({name: 'collect'}) +``` + +**Usage**: Passed as the `strategy` parameter to iteration functions to collect all errors and continue processing. + +--- + +### failLate + +**Purpose**: Error strategy identifier that collects all errors and returns `failure: true` at the end. + +**Type**: `Object` + +```javascript +export const failLate = Object.freeze({name: 'failLate'}) +``` + +**Usage**: Use when the application-layer needs to detect if *any* error occurred, while still collecting all errors. + +--- + +### skip + +**Purpose**: Error strategy identifier that ignores errors entirely (no collection), but `onError` is still called if present. + +**Type**: `Object` + +```javascript +export const skip = Object.freeze({name: 'skip'}) +``` + +**Usage**: Use for best-effort processing where some failures are acceptable. + +--- + +### stopOnError + +**Purpose**: Alias for `failFast` error strategy. + +**Type**: `Object` + +```javascript +export const stopOnError = Object.freeze({name: 'failFast'}) +``` + +**Usage**: Use as a shorthand for `failFast` with a more descriptive name. + +--- + +## Iterators + +### series + +**Purpose**: Horizontal composition tool - executes multiple functions in sequence, passing output of one as input to the next. + +**Type**: `(...fns) => (input) => Promise>` + +**Parameters**: +- Variadic arguments: Any number of async functions to execute sequentially +- `input`: The initial value passed to the first function + +**Return Type**: A Promise that resolves to the final result. + +**Key Characteristics**: +- Functions execute **left-to-right** (first argument is applied to `input`) +- Each function receives the result of the previous function as its first argument +- Supports synchronous or asynchronous functions +- Can be nested to build complex transformation pipelines + +**Options**: +- `strategy`: Error strategy object (`failFast`, `collect`, `failLate`, `skip`, or aliases) +- `onProgress`: Optional callback called after each successful item +- `onError`: Optional callback called for each error +- `onFailure`: Optional callback called when `failure` is truthy (failFast: `{item, error}`, failLate: `true`) +- `take`: Optional number of items to process + +**Usage Example**: +```javascript +import { series, failFast } from './functional.js' + +// Transform a value through multiple steps +const result = await series( + async (x) => x * 2, // Step 1: Double + async (x) => x + 10, // Step 2: Add 10 + async (x) => x.toString(), // Step 3: Convert to string + 42 // Initial value +) + +// Example with async operations +const result = await series( + async (id) => fetchUser(id), // Get user data + async (user, data) => updateUser(user, data), // Update user + user.id // Pass ID to next step +) +``` + +--- + +### scan + +**Purpose**: Stateful sequential transformation - transforms each item and accumulates results. + +**Type**: `(iterable, scanner, initialValue) => scanFunction` + +**Parameters**: +- `iterable`: An async iterable (array, generator, or any object implementing the iteration protocol) +- `scanner`: A function with signature `(accumulator, item, index) => newAccumulator` +- `initialValue`: The starting value for the accumulator + +**Return Type**: A Promise that resolves to an object containing: +- `results`: Array of all successful transformations +- `errors`: Array of errors encountered +- `failure`: The item/index where failure occurred (if scan stopped early) + +**Key Characteristics**: +- **Stateful**: Each transformation depends on the previous result +- **Accumulates**: Both successful results and errors for inspection +- **Index Tracking**: Provides index of each item for correlation + +**Usage Example**: +```javascript +import { scan } from './functional.js' + +// Track insertions in a database +const { results, errors } = await scan( + async records, + async (acc, record) => { + const inserted = await db.insert(record) + return acc + inserted // Accumulate count + }, + 0 // Initial count +) +``` + +--- + +### filter + +**Purpose**: Stateless selection tool - filters items from an iterable based on a predicate function. + +**Type**: `(...args) => filteredItems | filterFunction` + +**Parameters**: +- First argument (optional): If a function, specifies `take` +- Remaining arguments: Items to filter +- If first arg is NOT a function: Treated as `iterable` and processed with `take` strategy + +**Options**: +- `strategy`: Error strategy object (`failFast`, `collect`, `failLate`, `skip`, or aliases) +- `onError`: Optional callback called for each error +- `onFailure`: Optional callback called when `failure` is truthy (failFast: `{item, error}`, failLate: `true`) +- `take`: Optional number of items to collect + +**Return Type**: Returns `{ results, errors, failure }` object (defaults to `collect`): +- With `collect` (default): `{ results, errors: [...], failure: null }` +- With `failFast`: `{ results, errors: [], failure: { item, error } }` +- With `failLate`: `{ results, errors: [...], failure: true }` +- With `skip`: `{ results, errors: [], failure: null }` + +**Usage Example**: +```javascript +import { filter, failFast } from './functional.js' + +// Filter valid emails from a list (default is collect) +const validEmails = await filter( + async (email) => { + return email.includes('@') + }, + emails, + { + strategy: failFast // Override default to stop on first invalid email + } +) +``` + +--- + +## Composition + +### pipe + +**Purpose**: Vertical composition tool - chains functions left-to-right (Unix pipe pattern). + +**Type**: `(...fns) => (input) => Promise>` + +**Parameters**: +- Variadic arguments: Any number of async functions to execute sequentially +- `input`: The initial value passed to the first function + +**Return Type**: A Promise that resolves to the final result. + +**Key Characteristics**: +- Functions execute **left-to-right** (first argument is applied to `input`) +- Output of one function becomes input to the next +- Supports both synchronous and asynchronous functions +- Natural data flow from input through transformations + +**Usage Example**: +```javascript +import { pipe } from './functional.js' + +// Process user through validation, transformation, and storage +const userId = await pipe( + async (id) => validateUserId(id), // Step 1 + async (id) => fetchUser(id), // Step 2 + async (user, data) => saveUser(user, data), // Step 3 + userId // Starting value +) + +// Compose operations in a readable pipeline +const result = await pipe( + async (data) => validate(data), + async (data) => transform(data), + async (data) => persist(data), + null // No initial data needed +) +``` + +**Best Practice**: Use `pipe()` when you need to chain operations that form a coherent data processing pipeline. + +--- + +## Misc + +### retry + +**Purpose**: Retry async functions with configurable attempts and delays between attempts. + +**Type**: `(fn, options) => retryFunction` + +**Parameters**: +- `fn`: The async function to retry (required) +- `options`: Configuration object with the following properties: + - `attempts`: `number` (default: `3`) - Number of retry attempts + - `delay`: `number` (default: `0`) - Delay between retry attempts in milliseconds + +**Behavior**: +- Retries on each attempt until successful or exhausted +- Throws the last error after exhausting all attempts +- No delay before first attempt +- Applies configured delay between subsequent attempts + +**Usage Example**: +```javascript +import { retry } from './functional.js' + +// Retry with default 3 attempts and 500ms delay +const result = await retry( + async flakyOperation() => { + return Math.random() > 0.5 // Simulate 50% failure rate + }, + { + attempts: 5, + delay: 1000 + } +) +``` + +--- + +### tryCatch + +**Purpose**: Wraps individual async functions with lifecycle hooks for comprehensive error handling and telemetry. + +**Type**: `(fn, options) => wrapperFunction` + +**Parameters**: +- `fn`: The async function to wrap +- `options`: Configuration object with the following properties: + - `onStart`: `(fn, args) => void` - Called before function execution + - `onSuccess`: `(fn, args, result) => void | Promise` - Called on successful completion + - `onError`: `(fn, args, error) => void` - Called on error + - `onFinally`: `(fn, args) => void` - Called regardless of success/failure + - `rethrow`: `boolean` (default: `false`) - Whether to rethrow errors after handling + +**Return Type**: Returns a wrapper function with the same signature as `fn`. + +**Features**: +- Automatic `async/await` wrapping +- Preserves original function signature +- Comprehensive lifecycle: onStart → onSuccess/onError → onFinally +- Optional rethrow for error propagation +- Deep telemetry support for debugging + +**Usage Example**: +```javascript +import { tryCatch } from './functional.js' + +// Example: Try/catch mode +const safeFetch = tryCatch( + async (url) => { + const response = await fetch(url) + return await response.json() + }, + { + onError: async (error) => { + console.error('Fetch failed:', error) + } + } +) +``` + +--- + +## Related Documentation + +- **[errors.md](./errors.md)** - Complete guide to error handling strategies and callbacks +- **[README.md](../README.md)** - Project overview and philosophy +- **[guide.md](../guide.md)** - Comprehensive development guide with examples +- **[examples.md](../examples.md)** - Practical usage examples for all functions diff --git a/eslint.config.js b/eslint.config.js index 16ef62f..9b85206 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,7 +24,9 @@ export default [ // }, }, rules: { - complexity: ['warn', {max: 8}], + 'complexity': ['warn', {max: 8}], + 'max-statements': ['warn', 25], + 'max-lines-per-function': ['warn', 80], }, }, { @@ -35,6 +37,8 @@ export default [ { name: 'Tests', files: ['tests/**/*.js'], - rules: {}, + rules: { + 'max-lines': ['warn', 250], + }, }, ] diff --git a/package.json b/package.json index 6e9eee6..7669392 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ ], "scripts": { "check:lint": "eslint --report-unused-disable-directives .", - "lint": "eslint . --cache --max-warnings 0", - "lint.nocache": "eslint . --max-warnings 0", + "lint": "eslint . --cache --max-warnings 1", + "lint.nocache": "eslint . --max-warnings 1", "lint.inspect": "npx @eslint/config-inspector", "test": "vitest", "coverage": "vitest run --coverage" diff --git a/src/functional.js b/src/functional.js index de88a36..bfc27b9 100644 --- a/src/functional.js +++ b/src/functional.js @@ -1,5 +1,11 @@ export const failFast = Object.freeze({name: 'failFast'}) export const collect = Object.freeze({name: 'collect'}) +export const failLate = Object.freeze({name: 'failLate'}) +export const skip = Object.freeze({name: 'skip'}) + +// Aliases +export const fail = failFast +export const stopOnError = failFast export const tryCatch = (fn, { onStart, onSuccess, onError, onFinally, @@ -51,10 +57,11 @@ export const series = (...args) => { const immediate = typeof args[0] !== 'function' const [items, fn, opts = {}] = immediate ? args : [null, args[0], args[1]] + // eslint-disable-next-line complexity const run = async inputItems => { const { - strategy = 'collect', - take, onProgress, onError, + strategy = collect, + take, onProgress, onError, onFailure, } = opts const results = [] const errors = [] @@ -69,6 +76,8 @@ export const series = (...args) => { }) let index = 0 + let failure = null + for await (const item of inputItems) { // eslint-disable-next-line no-undefined if (take !== undefined && index >= take) @@ -78,14 +87,34 @@ export const series = (...args) => { const result = await safeFn(item, index) results.push(result) } catch (error) { - if (strategy === 'failFast') { + const strategyName = strategy?.name ?? strategy + + if (strategyName === 'failFast') { + if (onFailure) { + onFailure({item, error}) + } return {results, errors, failure: {item, error}} } + + if (strategyName === 'skip') { + // Don't collect errors, just continue + // onError is still called via safeFn + index++ + continue + } + errors.push({item, error}) } index++ } - return {results, errors, failure: null} + + failure = strategy?.name === 'failLate' && errors.length > 0 ? true : null + + if (failure && onFailure) { + onFailure(true) + } + + return {results, errors, failure} } return immediate ? run(items) : run @@ -97,11 +126,19 @@ export const filter = (...args) => { // eslint-disable-next-line complexity, max-statements const run = async inputItems => { - const {onError = 'failFast', take} = opts || {} + const { + strategy = collect, + onError: onErrorParam, + take, + onFailure, + } = opts || {} + const results = [] const errors = [] let index = 0 + let failure = null + for await (const item of inputItems) { // eslint-disable-next-line no-undefined if (take !== undefined && results.length >= take) { @@ -114,47 +151,94 @@ export const filter = (...args) => { results.push(item) } } catch (error) { - if (onError === 'failFast') { + const strategyName = strategy?.name ?? strategy + + if (onErrorParam) { + await onErrorParam(error) + } + + if (strategyName === 'failFast') { + if (onFailure) { + onFailure({item, error}) + } return {results, errors, failure: {item, error}} } + + if (strategyName === 'skip') { + index++ + continue + } + errors.push({item, error}) } index++ } - return {results, errors, failure: null} + + failure = strategy?.name === 'failLate' && errors.length > 0 ? true : null + + if (failure && onFailure) { + onFailure(true) + } + + return {results, errors, failure} } return immediate ? run(items) : run } -export const safeScan = async (iterable, scanner, initialValue) => { +// eslint-disable-next-line complexity +export const scan = async (iterable, scanner, initialValue, opts = {}) => { + const {strategy = failFast, onError, onFailure} = opts const results = [] let acc = initialValue + const errors = [] for await (const item of iterable) { try { acc = await scanner(acc, item) results.push(acc) } catch (error) { - // We cannot continue if a step fails, so we return the failure immediately. - return { - results, - errors: [{item, error}], - failure: {item, error}, + const strategyName = strategy?.name ?? strategy + + if (onError) { + await onError(error) + } + + if (strategyName === 'failFast') { + if (onFailure) { + onFailure({item, error}) + } + errors.push({item, error}) + return { + results, + errors, + failure: {item, error}, + } } + + if (strategyName === 'skip') { + continue + } + + errors.push({item, error}) } } - return {results, errors: [], failure: null} -} + const failure = strategy?.name === 'failLate' && errors.length > 0 ? true : null -export const scan = safeScan + if (failure && onFailure) { + onFailure(true) + } + + return {results, errors, failure} +} export const scanSeries = async (iterable, scanner, initialValue) => { const {results} = await scan(iterable, scanner, initialValue) return results } + export const pipe = (...fns) => input => fns.reduce(async (acc, fn) => fn(await acc), input) @@ -164,6 +248,7 @@ export const pipe = (...fns) => input => see if it's an error or a valid result Alpha Code - not to be used yet. */ + export async function * safeAsyncIterator (iterable, transform, { onError = failFast, } = {}) { diff --git a/tests/error-strategies.test.js b/tests/error-strategies.test.js index 3aac2c2..a7f45d1 100644 --- a/tests/error-strategies.test.js +++ b/tests/error-strategies.test.js @@ -1,5 +1,14 @@ import {test, expect} from 'vitest' -import {failFast, collect} from '$src/functional' +import { + failFast, + collect, + failLate, + skip, + fail, + stopOnError, + series, + filter, +} from '$src/functional' test('failFast is a frozen object with name "failFast"', () => { expect(failFast).toEqual({name: 'failFast'}) @@ -11,6 +20,197 @@ test('collect is a frozen object with name "collect"', () => { expect(Object.isFrozen(collect)).toBe(true) }) +test('failLate is a frozen object with name "failLate"', () => { + expect(failLate).toEqual({name: 'failLate'}) + expect(Object.isFrozen(failLate)).toBe(true) +}) + +test('skip is a frozen object with name "skip"', () => { + expect(skip).toEqual({name: 'skip'}) + expect(Object.isFrozen(skip)).toBe(true) +}) + +test('fail is an alias for failFast', () => { + expect(fail).toEqual({name: 'failFast'}) + expect(fail).toBe(failFast) + expect(Object.isFrozen(fail)).toBe(true) +}) + +test('stopOnError is an alias for failFast', () => { + expect(stopOnError).toEqual({name: 'failFast'}) + expect(stopOnError).toBe(failFast) + expect(Object.isFrozen(stopOnError)).toBe(true) +}) + test('strategies are distinct references', () => { expect(failFast).not.toBe(collect) + expect(failFast).not.toBe(failLate) + expect(failFast).not.toBe(skip) + expect(collect).not.toBe(failLate) + expect(collect).not.toBe(skip) + expect(failLate).not.toBe(skip) +}) + +test('failLate: collects all errors and returns failure: true', async () => { + const items = [1, 2, 3, 4] + const fn = item => { + if (item === 2 || item === 4) + throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, {strategy: failLate}) + + expect(result.results).toEqual([2, 6]) + expect(result.errors).toHaveLength(2) + expect(result.errors[0]).toEqual({item: 2, error: new Error('Error at 2')}) + expect(result.errors[1]).toEqual({item: 4, error: new Error('Error at 4')}) + expect(result.failure).toBe(true) +}) + +test('failLate: no errors returns failure: null', async () => { + const items = [1, 2, 3] + const fn = item => item * 2 + + const result = await series(items, fn, {strategy: failLate}) + + expect(result.results).toEqual([2, 4, 6]) + expect(result.errors).toHaveLength(0) + expect(result.failure).toBe(null) +}) + +test('skip: ignores errors without collection', async () => { + const items = [1, 2, 3, 4] + const fn = item => { + if (item === 2 || item === 4) + throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, {strategy: skip}) + + expect(result.results).toEqual([2, 6]) + expect(result.errors).toHaveLength(0) + expect(result.failure).toBe(null) +}) + +test('skip: calls onError if present', async () => { + const onErrorCalls = [] + const items = [1, 2, 3] + + const fn = item => { + if (item === 2) + throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, { + strategy: skip, + onError: error => onErrorCalls.push(error), + }) + + expect(onErrorCalls).toHaveLength(1) + expect(onErrorCalls[0]).toEqual(new Error('Error at 2')) + expect(result.results).toEqual([2, 6]) + expect(result.errors).toHaveLength(0) + expect(result.failure).toBe(null) +}) + +test('fail alias works as failFast in series', async () => { + const items = [1, 2, 3] + const fn = item => { + if (item === 2) + throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, {strategy: fail}) + + expect(result.results).toEqual([2]) + expect(result.errors).toHaveLength(0) + expect(result.failure).toEqual({ + item: 2, + error: new Error('Error at 2'), + }) +}) + +test('stopOnError alias works as failFast in series', async () => { + const items = [1, 2, 3] + const fn = item => { + if (item === 2) + throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, {strategy: stopOnError}) + + expect(result.results).toEqual([2]) + expect(result.errors).toHaveLength(0) + expect(result.failure).toEqual({ + item: 2, + error: new Error('Error at 2'), + }) +}) + +test('failLate works with filter', async () => { + const items = [1, 2, 3, 4, 5] + const predicate = item => { + if (item === 2 || item === 4) + throw new Error(`Error at ${item}`) + return item % 2 === 1 + } + + const result = await filter(items, predicate, {strategy: failLate}) + + expect(result.results).toEqual([1, 3, 5]) + expect(result.errors).toHaveLength(2) + expect(result.errors[0]).toEqual({item: 2, error: new Error('Error at 2')}) + expect(result.errors[1]).toEqual({item: 4, error: new Error('Error at 4')}) + expect(result.failure).toBe(true) +}) + +test('skip works with filter', async () => { + const items = [1, 2, 3, 4] + const predicate = item => { + if (item === 2 || item === 4) + throw new Error(`Error at ${item}`) + return item % 2 === 0 + } + + const result = await filter(items, predicate, {strategy: skip}) + + expect(result.results).toEqual([]) // 2 and 4 failed + expect(result.errors).toHaveLength(0) + expect(result.failure).toBe(null) +}) + +test('failLate with series returns all successful results before any error', async () => { + const items = [1, 2, 3, 4, 5] + const fn = item => { + if (item === 3) + throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, {strategy: failLate}) + + // All items processed: 1, 2, 4, 5 succeed, 3 fails + expect(result.results).toEqual([2, 4, 8, 10]) + expect(result.errors).toHaveLength(1) + expect(result.failure).toBe(true) +}) + +test('collect strategy still works (failure: null)', async () => { + const items = [1, 2, 3] + const fn = item => { + if (item === 2) + throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, {strategy: collect}) + + expect(result.results).toEqual([2, 6]) + expect(result.errors).toHaveLength(1) + expect(result.failure).toBe(null) }) diff --git a/tests/filter.test.js b/tests/filter.test.js index f62ecb4..3b3f483 100644 --- a/tests/filter.test.js +++ b/tests/filter.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {filter, collect} from '$src/functional' +import {filter} from '$src/functional' test('predicate truthy keeps item in results', async () => { const result = await filter([1, 2, 3, 4], x => x > 2) @@ -17,22 +17,25 @@ test('predicate throws with failFast stops and populates failure', async () => { if (x === 2) throw bang return true - }) + }, {strategy: 'failFast'}) expect(result.results).toEqual([1]) expect(result.failure).toEqual({item: 2, error: bang}) expect(result.errors).toEqual([]) }) -test('predicate throws with collect continues and collects error', async () => { +test('predicate throws with default collect continues and collects errors', async () => { const bang = new Error('bang') - const result = await filter([1, 2, 3], x => { - if (x === 2) + const result = await filter([1, 2, 3, 4], x => { + if (x === 2 || x === 4) throw bang return true - }, {onError: collect}) + }) expect(result.results).toEqual([1, 3]) - expect(result.errors).toEqual([{item: 2, error: bang}]) - expect(result.failure).toBeNull() + expect(result.failure).toBe(null) + expect(result.errors).toEqual([ + {item: 2, error: bang}, + {item: 4, error: bang}, + ]) }) test('async predicates work', async () => { diff --git a/tests/onFailure.test.js b/tests/onFailure.test.js new file mode 100644 index 0000000..3b8c59e --- /dev/null +++ b/tests/onFailure.test.js @@ -0,0 +1,222 @@ +import {test, expect, vi} from 'vitest' +import { + series, filter, scan, failFast, failLate, collect, skip, +} from '$src/functional' + +const fnThatFailsAt = failItem => item => { + if (item === failItem) + throw new Error(`Error at ${item}`) + return item * 2 +} + +const fnThatFailsAtItems = failItems => item => { + if (failItems.includes(item)) + throw new Error(`Error at ${item}`) + return item * 2 +} + +const predicateThatFailsAt = failItem => item => { + if (item === failItem) + throw new Error(`Error at ${item}`) + return item % 2 === 0 +} + +const predicateThatFailsAtItems = failItems => item => { + if (failItems.includes(item)) + throw new Error(`Error at ${item}`) + return item % 2 === 1 +} + +const scannerThatFailsAt = failItem => (acc, item) => { + if (item === failItem) + throw new Error(`Error at ${item}`) + return acc + item +} + +test('onFailure called for failFast with {item, error}', async () => { + const onFailure = vi.fn() + const items = [1, 2, 3] + + const result = await series(items, fnThatFailsAt(2), { + strategy: failFast, + onFailure, + }) + + expect(onFailure).toHaveBeenCalledTimes(1) + expect(onFailure).toHaveBeenCalledWith({ + item: 2, + error: new Error('Error at 2'), + }) + expect(result.failure).toEqual({ + item: 2, + error: new Error('Error at 2'), + }) + expect(result.results).toEqual([2]) +}) + +test('onFailure called for failLate with true', async () => { + const onFailure = vi.fn() + const items = [1, 2, 3, 4] + + const result = await series(items, fnThatFailsAtItems([2, 4]), { + strategy: failLate, + onFailure, + }) + + expect(onFailure).toHaveBeenCalledTimes(1) + expect(onFailure).toHaveBeenCalledWith(true) + expect(result.failure).toBe(true) + expect(result.results).toEqual([2, 6]) + expect(result.errors).toHaveLength(2) +}) + +test('onFailure NOT called for collect (failure: null)', async () => { + const onFailure = vi.fn() + const items = [1, 2, 3] + + const result = await series(items, fnThatFailsAt(2), { + strategy: collect, + onFailure, + }) + + expect(onFailure).not.toHaveBeenCalled() + expect(result.failure).toBe(null) + expect(result.results).toEqual([2, 6]) + expect(result.errors).toHaveLength(1) +}) + +test('onFailure NOT called for skip (failure: null)', async () => { + const onFailure = vi.fn() + const items = [1, 2, 3] + + const result = await series(items, fnThatFailsAt(2), { + strategy: skip, + onFailure, + }) + + expect(onFailure).not.toHaveBeenCalled() + expect(result.failure).toBe(null) + expect(result.results).toEqual([2, 6]) + expect(result.errors).toHaveLength(0) +}) + +test('onFailure is optional', async () => { + const items = [1, 2, 3] + + // Should not throw without onFailure + const result = await series(items, fnThatFailsAt(2), {strategy: failFast}) + + expect(result.failure).toEqual({ + item: 2, + error: new Error('Error at 2'), + }) +}) + +test('onFailure works with filter', async () => { + const onFailure = vi.fn() + const items = [1, 2, 3, 4] + + const result = await filter(items, predicateThatFailsAt(3), { + strategy: failFast, + onFailure, + }) + + expect(onFailure).toHaveBeenCalledTimes(1) + expect(onFailure).toHaveBeenCalledWith({ + item: 3, + error: new Error('Error at 3'), + }) + expect(result.failure).toEqual({ + item: 3, + error: new Error('Error at 3'), + }) + expect(result.results).toEqual([2]) +}) + +test('onFailure works with filter and failLate', async () => { + const onFailure = vi.fn() + const items = [1, 2, 3, 4, 5] + + const result = await filter(items, predicateThatFailsAtItems([2, 4]), { + strategy: failLate, + onFailure, + }) + + expect(onFailure).toHaveBeenCalledTimes(1) + expect(onFailure).toHaveBeenCalledWith(true) + expect(result.failure).toBe(true) + expect(result.results).toEqual([1, 3, 5]) + expect(result.errors).toHaveLength(2) +}) + +test('onFailure works with scan', async () => { + const onFailure = vi.fn() + const items = [1, 2, 3] + + const result = await scan(items, scannerThatFailsAt(2), 0, { + strategy: failFast, + onFailure, + }) + + expect(onFailure).toHaveBeenCalledTimes(1) + expect(onFailure).toHaveBeenCalledWith({ + item: 2, + error: new Error('Error at 2'), + }) + expect(result.failure).toEqual({ + item: 2, + error: new Error('Error at 2'), + }) + expect(result.results).toEqual([1]) +}) + +test('Application-layer wrapper with default onFailure', async () => { + // Simulating an application-layer wrapper + let lastFailure = null + + const withDefaultOnFailure = (fn, opts) => ({ + ...opts, + onFailure: failure => { + lastFailure = failure + // Could trigger UI update, notification, etc. + if (opts.onFailure) { + opts.onFailure(failure) + } + }, + }) + + const items = [1, 2, 3] + const opts = withDefaultOnFailure(fnThatFailsAt(2), {strategy: failFast}) + const result = await series(items, fnThatFailsAt(2), opts) + + expect(lastFailure).toEqual({ + item: 2, + error: new Error('Error at 2'), + }) + expect(result.failure).toEqual({ + item: 2, + error: new Error('Error at 2'), + }) +}) + +test('onFailure with skip strategy still allows onError', async () => { + const onError = vi.fn() + const onFailure = vi.fn() + const items = [1, 2, 3] + + const result = await series(items, fnThatFailsAt(2), { + strategy: skip, + onError, + onFailure, + }) + + // onError should be called even with skip + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith(new Error('Error at 2')) + + // onFailure should NOT be called (failure is null) + expect(onFailure).not.toHaveBeenCalled() + expect(result.failure).toBe(null) + expect(result.errors).toHaveLength(0) + expect(result.results).toEqual([2, 6]) +})