From 0f5d1013ec3bdca1b1878f9d5e53fdc68aea190d Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:28:54 +0100 Subject: [PATCH 01/10] adding new error strategies: failLate and skip, clarifying behavior for failure and adding onFailure hook --- CLAUDE.md | 4 +- docs/errors.md | 208 +++++++++++++++ docs/functional.md | 472 ++++++++++----------------------- src/functional.js | 117 +++++++- tests/error-strategies.test.js | 193 +++++++++++++- tests/onFailure.test.js | 263 ++++++++++++++++++ 6 files changed, 910 insertions(+), 347 deletions(-) create mode 100644 docs/errors.md create mode 100644 tests/onFailure.test.js 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/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..94757ec --- /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`, `safeScan`) support four error strategies: + +### `failFast` (default for `filter`, `safeScan`) + +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`) + +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 index a563d81..c571dd0 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -1,4 +1,4 @@ -# functional.js Reference Documentation +# Pipelean Reference Documentation **Overview** @@ -7,28 +7,43 @@ **Key Principles** - **Pragmatic**: Plain JavaScript, eager execution, sequential processing. -- **First class error handling**: Two distinct strategies (`failFast` and `collect`) for different use cases +- **First class error handling**: Multiple error strategies 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 +### 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 --- -## 1. failFast +## Error strategies + +### failFast **Purpose**: Error strategy identifier used throughout the library to indicate immediate failure without partial results. @@ -38,169 +53,85 @@ 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. +**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. --- -## 2. collect +### fail -**Purpose**: Error strategy identifier used throughout the library to indicate that errors should be gathered and processing should continue. +**Purpose**: Alias for `failFast` error strategy. **Type**: `Object` ```javascript -export const collect = Object.freeze({name: 'collect'}) +export const fail = Object.freeze({name: 'failFast'}) ``` -**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. +**Usage**: Use as a shorthand for `failFast`. --- -## 3. tryCatch +### collect -**Purpose**: Wraps individual async functions with lifecycle hooks for comprehensive error handling and telemetry. +**Purpose**: Error strategy identifier used throughout the library to indicate that errors should be gathered and processing should continue. -**Type**: `(fn, options) => wrapperFunction` +**Type**: `Object` -**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) +```javascript +export const collect = Object.freeze({name: 'collect'}) +``` -**Return Type**: Returns a wrapper function with the same signature as `fn`. +**Usage**: Passed as the `strategy` parameter to iteration functions to collect all errors and continue processing. -**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' +### failLate -// 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) - } - } -) +**Purpose**: Error strategy identifier that collects all errors and returns `failure: true` at the end. -// 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 - } -) -``` +**Type**: `Object` ---- +```javascript +export const failLate = Object.freeze({name: 'failLate'}) +``` -## 4. delay +**Usage**: Use when the application-layer needs to detect if *any* error occurred, while still collecting all errors. -**Purpose**: Promise-based delay utility. +--- -**Type**: `(ms: number) => Promise` +### skip -**Parameters**: -- `ms`: Number of milliseconds to delay (required) +**Purpose**: Error strategy identifier that ignores errors entirely (no collection), but `onError` is still called if present. -**Return Type**: A Promise that resolves after the specified delay. +**Type**: `Object` -**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 -} +export const skip = Object.freeze({name: 'skip'}) ``` ---- - -## 5. retry +**Usage**: Use for best-effort processing where some failures are acceptable. -**Purpose**: Retry async functions with configurable attempts and delays between attempts. +--- -**Type**: `(fn, options) => retryFunction` +### stopOnError -**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 +**Purpose**: Alias for `failFast` error strategy. -**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 +**Type**: `Object` -**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 - } -) +export const stopOnError = Object.freeze({name: 'failFast'}) ``` +**Usage**: Use as a shorthand for `failFast` with a more descriptive name. + --- -## 6. series +## Iterators + +### series -**Purpose**: Horizontal composition tool - executes multiple functions in sequence, passing the output of one as input to the next. +**Purpose**: Horizontal composition tool - executes multiple functions in sequence, passing output of one as input to the next. **Type**: `(...fns) => (input) => Promise>` @@ -216,9 +147,16 @@ const result = await retry( - 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 } from './functional.js' +import { series, failFast } from './functional.js' // Transform a value through multiple steps const result = await series( @@ -236,11 +174,9 @@ const result = await series( ) ``` -**Best Practice**: Use `series()` when you need to chain async operations and ensure each completes before the next starts. - --- -## 7. scan +### scan **Purpose**: Stateful sequential transformation - transforms each item and accumulates results. @@ -258,7 +194,6 @@ const result = await series( **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 @@ -275,37 +210,11 @@ const { results, errors } = await scan( }, 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 +### filter **Purpose**: Stateless selection tool - filters items from an iterable based on a predicate function. @@ -316,17 +225,21 @@ const { results, errors, failure } = await safeScan( - 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) +**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 -**Behavior**: -- With `failFast`: Returns `{ results, errors, failure: { item, error } }` object -- With `collect`: Returns `{ results, errors, failure: null }` object +**Return Type**: Returns `{ results, errors, failure }` object: +- With `failFast`: `{ results, errors: [], failure: { item, error } }` +- With `collect`: `{ results, errors: [...], failure: null }` +- With `failLate`: `{ results, errors: [...], failure: true }` +- With `skip`: `{ results, errors: [], failure: null }` **Usage Example**: ```javascript -import { filter } from './functional.js' +import { filter, failFast } from './functional.js' // Filter valid emails from a list const validEmails = await filter( @@ -335,59 +248,16 @@ const validEmails = await filter( }, 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 + strategy: failFast // Stop on first invalid email } ) ``` --- -## 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 } -) -``` - ---- +## Composition -## 10. pipe +### pipe **Purpose**: Vertical composition tool - chains functions left-to-right (Unix pipe pattern). @@ -430,147 +300,91 @@ const result = await pipe( --- -## 11. safeAsyncIterator +## Misc -**Purpose**: Lazy async iterator wrapper with comprehensive error handling. +### retry -**Type**: `iterable => asyncGenerator` +**Purpose**: Retry async functions with configurable attempts and delays between attempts. -**Parameters**: -- `iterable`: An async iterable (array, generator, or any object implementing iteration protocol) -- `options`: Configuration object - - `onError`: Error handler ('failFast' by default) +**Type**: `(fn, options) => retryFunction` -**Return Type**: An async generator that yields items one by one. +**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**: -- 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) +- 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 { safeAsyncIterator } from './functional.js' +import { retry } from './functional.js' -// Transform items lazily with error handling -const iter = safeAsyncIterator( - async fetchDataPages(), // Generator - async (page) => transformPage(page), // Transformer - { onError: failFast } +// 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 + } ) - -for await (const item of iter) { - console.log('Processing:', item) -} ``` --- -## 12. collectAsync +### tryCatch -**Purpose**: Collect all items from an async iterator/generator into an array. +**Purpose**: Wraps individual async functions with lifecycle hooks for comprehensive error handling and telemetry. -**Type**: `iterator => collectAsync(iterator) => Promise>` +**Type**: `(fn, options) => wrapperFunction` **Parameters**: -- `iterator`: An async iterator, generator, or iterable +- `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**: A Promise that resolves to an array of all yielded items. +**Return Type**: Returns a wrapper function with the same signature as `fn`. -**Behavior**: -- Executes iterator until completion -- Resolves with array of all items -- Handles both normal values and `{ error, item }` objects if iterator yields them +**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 { 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++ +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) } } ) - -// 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 +- **[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/src/functional.js b/src/functional.js index de88a36..cb44cb0 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 for failFast (same reference) +export const fail = failFast +export const stopOnError = failFast export const tryCatch = (fn, { onStart, onSuccess, onError, onFinally, @@ -53,8 +59,8 @@ export const series = (...args) => { const run = async inputItems => { const { - strategy = 'collect', - take, onProgress, onError, + strategy = collect, + take, onProgress, onError, onFailure, } = opts const results = [] const errors = [] @@ -69,6 +75,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 +86,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 +125,25 @@ export const filter = (...args) => { // eslint-disable-next-line complexity, max-statements const run = async inputItems => { - const {onError = 'failFast', take} = opts || {} + const { + strategy = failFast, + onError: onErrorParam, + take, + onFailure, + } = opts || {} + + // Support backward compatibility: onError can be a strategy (old API) or callback (new API) + const isErrorCallback = typeof onErrorParam === 'function' + const strategyFromOnError = !isErrorCallback ? onErrorParam : null + const finalStrategy = strategyFromOnError ?? strategy + const errorCallback = isErrorCallback ? onErrorParam : null + 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,39 +156,86 @@ export const filter = (...args) => { results.push(item) } } catch (error) { - if (onError === 'failFast') { + const strategyName = finalStrategy?.name ?? finalStrategy + + if (errorCallback) { + await errorCallback(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 = finalStrategy?.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) => { +export const safeScan = 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 + + if (failure && onFailure) { + onFailure(true) + } + + return {results, errors, failure} } export const scan = safeScan diff --git a/tests/error-strategies.test.js b/tests/error-strategies.test.js index 3aac2c2..fa5922d 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,188 @@ 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 = async 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 = async 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 = async 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 = async 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 = async 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 = async 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 = async 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 = async 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 = async 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 = async 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/onFailure.test.js b/tests/onFailure.test.js new file mode 100644 index 0000000..67f2563 --- /dev/null +++ b/tests/onFailure.test.js @@ -0,0 +1,263 @@ +import {test, expect} from 'vitest' +import {series, filter, safeScan, failFast, failLate, collect, skip} from '$src/functional' + +test('onFailure called for failFast with {item, error}', async () => { + const onFailureCalls = [] + const items = [1, 2, 3] + + const fn = async item => { + if (item === 2) throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, { + strategy: failFast, + onFailure: (failure) => onFailureCalls.push(failure) + }) + + expect(onFailureCalls).toHaveLength(1) + expect(onFailureCalls[0]).toEqual({ + 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 onFailureCalls = [] + const items = [1, 2, 3, 4] + + const fn = async item => { + if (item === 2 || item === 4) throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, { + strategy: failLate, + onFailure: (failure) => onFailureCalls.push(failure) + }) + + expect(onFailureCalls).toHaveLength(1) + expect(onFailureCalls[0]).toBe(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 onFailureCalls = [] + const items = [1, 2, 3] + + const fn = async item => { + if (item === 2) throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, { + strategy: collect, + onFailure: (failure) => onFailureCalls.push(failure) + }) + + expect(onFailureCalls).toHaveLength(0) + 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 onFailureCalls = [] + const items = [1, 2, 3] + + const fn = async item => { + if (item === 2) throw new Error(`Error at ${item}`) + return item * 2 + } + + const result = await series(items, fn, { + strategy: skip, + onFailure: (failure) => onFailureCalls.push(failure) + }) + + expect(onFailureCalls).toHaveLength(0) + 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] + + const fn = async item => { + if (item === 2) throw new Error(`Error at ${item}`) + return item * 2 + } + + // Should not throw without onFailure + const result = await series(items, fn, {strategy: failFast}) + + expect(result.failure).toEqual({ + item: 2, + error: new Error('Error at 2') + }) +}) + +test('onFailure works with filter', async () => { + const onFailureCalls = [] + const items = [1, 2, 3, 4] + + const predicate = async item => { + if (item === 3) throw new Error(`Error at ${item}`) + return item % 2 === 0 + } + + const result = await filter(items, predicate, { + strategy: failFast, + onFailure: (failure) => onFailureCalls.push(failure) + }) + + expect(onFailureCalls).toHaveLength(1) + expect(onFailureCalls[0]).toEqual({ + 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 onFailureCalls = [] + const items = [1, 2, 3, 4, 5] + + const predicate = async item => { + if (item === 2 || item === 4) throw new Error(`Error at ${item}`) + return item % 2 === 1 + } + + const result = await filter(items, predicate, { + strategy: failLate, + onFailure: (failure) => onFailureCalls.push(failure) + }) + + expect(onFailureCalls).toHaveLength(1) + expect(onFailureCalls[0]).toBe(true) + expect(result.failure).toBe(true) + expect(result.results).toEqual([1, 3, 5]) + expect(result.errors).toHaveLength(2) +}) + +test('onFailure works with safeScan', async () => { + const onFailureCalls = [] + const items = [1, 2, 3] + + const scanner = async (acc, item) => { + if (item === 2) throw new Error(`Error at ${item}`) + return acc + item + } + + const result = await safeScan(items, scanner, 0, { + strategy: failFast, + onFailure: (failure) => onFailureCalls.push(failure) + }) + + expect(onFailureCalls).toHaveLength(1) + expect(onFailureCalls[0]).toEqual({ + 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('onFailure works with safeScan and failLate', async () => { + const onFailureCalls = [] + const items = [1, 2, 3, 4] + + const scanner = async (acc, item) => { + if (item === 2 || item === 3) throw new Error(`Error at ${item}`) + return acc + item + } + + const result = await safeScan(items, scanner, 0, { + strategy: failLate, + onFailure: (failure) => onFailureCalls.push(failure) + }) + + expect(onFailureCalls).toHaveLength(1) + expect(onFailureCalls[0]).toBe(true) + expect(result.failure).toBe(true) + expect(result.results).toEqual([1]) + expect(result.errors).toHaveLength(2) +}) + +test('Application-layer wrapper with default onFailure', async () => { + // Simulating an application-layer wrapper + let lastFailure = null + + const withDefaultOnFailure = (fn, opts) => { + return { + ...opts, + onFailure: (failure) => { + lastFailure = failure + // Could trigger UI update, notification, etc. + if (opts.onFailure) { + opts.onFailure(failure) + } + } + } + } + + const items = [1, 2, 3] + const fn = async item => { + if (item === 2) throw new Error(`Error at ${item}`) + return item * 2 + } + + const opts = withDefaultOnFailure(fn, {strategy: failFast}) + const result = await series(items, fn, 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 onErrorCalls = [] + const onFailureCalls = [] + const items = [1, 2, 3] + + const fn = async 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), + onFailure: (failure) => onFailureCalls.push(failure) + }) + + // onError should be called even with skip + expect(onErrorCalls).toHaveLength(1) + expect(onErrorCalls[0]).toEqual(new Error('Error at 2')) + + // onFailure should NOT be called (failure is null) + expect(onFailureCalls).toHaveLength(0) + expect(result.failure).toBe(null) + expect(result.errors).toHaveLength(0) + expect(result.results).toEqual([2, 6]) +}) From ed0dff53a677b2408de32cbc0273908f3e5fd896 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:31:55 +0100 Subject: [PATCH 02/10] reorg the guide --- docs/guide.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) 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) From 28e9c0bf28719820beac5cf34fbf632586e031c1 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:42:53 +0100 Subject: [PATCH 03/10] fixed error management for scan and remove safeSCan forever --- src/functional.js | 4 +--- tests/onFailure.test.js | 20 -------------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/functional.js b/src/functional.js index cb44cb0..8a23198 100644 --- a/src/functional.js +++ b/src/functional.js @@ -238,10 +238,8 @@ export const safeScan = async (iterable, scanner, initialValue, opts = {}) => { return {results, errors, failure} } -export const scan = safeScan - export const scanSeries = async (iterable, scanner, initialValue) => { - const {results} = await scan(iterable, scanner, initialValue) + const {results} = await safeScan(iterable, scanner, initialValue) return results } export const pipe = (...fns) => input => diff --git a/tests/onFailure.test.js b/tests/onFailure.test.js index 67f2563..e07af25 100644 --- a/tests/onFailure.test.js +++ b/tests/onFailure.test.js @@ -178,26 +178,6 @@ test('onFailure works with safeScan', async () => { expect(result.results).toEqual([1]) }) -test('onFailure works with safeScan and failLate', async () => { - const onFailureCalls = [] - const items = [1, 2, 3, 4] - - const scanner = async (acc, item) => { - if (item === 2 || item === 3) throw new Error(`Error at ${item}`) - return acc + item - } - - const result = await safeScan(items, scanner, 0, { - strategy: failLate, - onFailure: (failure) => onFailureCalls.push(failure) - }) - - expect(onFailureCalls).toHaveLength(1) - expect(onFailureCalls[0]).toBe(true) - expect(result.failure).toBe(true) - expect(result.results).toEqual([1]) - expect(result.errors).toHaveLength(2) -}) test('Application-layer wrapper with default onFailure', async () => { // Simulating an application-layer wrapper From 9212475a94c463d019adf789845659342a77d27c Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:47:39 +0100 Subject: [PATCH 04/10] removed all safeSCan and fixed lint issues --- docs/errors.md | 4 +- eslint.config.js | 4 +- src/functional.js | 9 ++-- tests/error-strategies.test.js | 33 +++++++----- tests/onFailure.test.js | 95 +++++++++++++++++++--------------- 5 files changed, 84 insertions(+), 61 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 94757ec..f696c8e 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -4,9 +4,9 @@ Pipelean provides comprehensive error handling through named strategies and call ## Error Strategies -All iteration functions (`series`, `filter`, `safeScan`) support four error strategies: +All iteration functions (`series`, `filter`, `scan`) support four error strategies: -### `failFast` (default for `filter`, `safeScan`) +### `failFast` (default for `filter`, `scan`) Stop immediately on first error. diff --git a/eslint.config.js b/eslint.config.js index 16ef62f..02d3266 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], }, }, { diff --git a/src/functional.js b/src/functional.js index 8a23198..1684ff9 100644 --- a/src/functional.js +++ b/src/functional.js @@ -3,7 +3,7 @@ export const collect = Object.freeze({name: 'collect'}) export const failLate = Object.freeze({name: 'failLate'}) export const skip = Object.freeze({name: 'skip'}) -// Aliases for failFast (same reference) +// Aliases export const fail = failFast export const stopOnError = failFast @@ -192,7 +192,8 @@ export const filter = (...args) => { return immediate ? run(items) : run } -export const safeScan = async (iterable, scanner, initialValue, opts = {}) => { +// eslint-disable-next-line complexity +export const scan = async (iterable, scanner, initialValue, opts = {}) => { const {strategy = failFast, onError, onFailure} = opts const results = [] let acc = initialValue @@ -239,9 +240,10 @@ export const safeScan = async (iterable, scanner, initialValue, opts = {}) => { } export const scanSeries = async (iterable, scanner, initialValue) => { - const {results} = await safeScan(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) @@ -251,6 +253,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 fa5922d..a9df3c5 100644 --- a/tests/error-strategies.test.js +++ b/tests/error-strategies.test.js @@ -54,7 +54,8 @@ test('strategies are distinct references', () => { test('failLate: collects all errors and returns failure: true', async () => { const items = [1, 2, 3, 4] const fn = async item => { - if (item === 2 || item === 4) throw new Error(`Error at ${item}`) + if (item === 2 || item === 4) + throw new Error(`Error at ${item}`) return item * 2 } @@ -81,7 +82,8 @@ test('failLate: no errors returns failure: null', async () => { test('skip: ignores errors without collection', async () => { const items = [1, 2, 3, 4] const fn = async item => { - if (item === 2 || item === 4) throw new Error(`Error at ${item}`) + if (item === 2 || item === 4) + throw new Error(`Error at ${item}`) return item * 2 } @@ -97,13 +99,14 @@ test('skip: calls onError if present', async () => { const items = [1, 2, 3] const fn = async item => { - if (item === 2) throw new Error(`Error at ${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) + onError: error => onErrorCalls.push(error), }) expect(onErrorCalls).toHaveLength(1) @@ -116,7 +119,8 @@ test('skip: calls onError if present', async () => { test('fail alias works as failFast in series', async () => { const items = [1, 2, 3] const fn = async item => { - if (item === 2) throw new Error(`Error at ${item}`) + if (item === 2) + throw new Error(`Error at ${item}`) return item * 2 } @@ -126,14 +130,15 @@ test('fail alias works as failFast in series', async () => { expect(result.errors).toHaveLength(0) expect(result.failure).toEqual({ item: 2, - error: new Error('Error at 2') + error: new Error('Error at 2'), }) }) test('stopOnError alias works as failFast in series', async () => { const items = [1, 2, 3] const fn = async item => { - if (item === 2) throw new Error(`Error at ${item}`) + if (item === 2) + throw new Error(`Error at ${item}`) return item * 2 } @@ -143,14 +148,15 @@ test('stopOnError alias works as failFast in series', async () => { expect(result.errors).toHaveLength(0) expect(result.failure).toEqual({ item: 2, - error: new Error('Error at 2') + error: new Error('Error at 2'), }) }) test('failLate works with filter', async () => { const items = [1, 2, 3, 4, 5] const predicate = async item => { - if (item === 2 || item === 4) throw new Error(`Error at ${item}`) + if (item === 2 || item === 4) + throw new Error(`Error at ${item}`) return item % 2 === 1 } @@ -166,7 +172,8 @@ test('failLate works with filter', async () => { test('skip works with filter', async () => { const items = [1, 2, 3, 4] const predicate = async item => { - if (item === 2 || item === 4) throw new Error(`Error at ${item}`) + if (item === 2 || item === 4) + throw new Error(`Error at ${item}`) return item % 2 === 0 } @@ -180,7 +187,8 @@ test('skip works with filter', async () => { test('failLate with series returns all successful results before any error', async () => { const items = [1, 2, 3, 4, 5] const fn = async item => { - if (item === 3) throw new Error(`Error at ${item}`) + if (item === 3) + throw new Error(`Error at ${item}`) return item * 2 } @@ -195,7 +203,8 @@ test('failLate with series returns all successful results before any error', asy test('collect strategy still works (failure: null)', async () => { const items = [1, 2, 3] const fn = async item => { - if (item === 2) throw new Error(`Error at ${item}`) + if (item === 2) + throw new Error(`Error at ${item}`) return item * 2 } diff --git a/tests/onFailure.test.js b/tests/onFailure.test.js index e07af25..d592d3a 100644 --- a/tests/onFailure.test.js +++ b/tests/onFailure.test.js @@ -1,28 +1,31 @@ import {test, expect} from 'vitest' -import {series, filter, safeScan, failFast, failLate, collect, skip} from '$src/functional' +import { + series, filter, scan, failFast, failLate, collect, skip, +} from '$src/functional' test('onFailure called for failFast with {item, error}', async () => { const onFailureCalls = [] const items = [1, 2, 3] const fn = async item => { - if (item === 2) throw new Error(`Error at ${item}`) + if (item === 2) + throw new Error(`Error at ${item}`) return item * 2 } const result = await series(items, fn, { strategy: failFast, - onFailure: (failure) => onFailureCalls.push(failure) + onFailure: failure => onFailureCalls.push(failure), }) expect(onFailureCalls).toHaveLength(1) expect(onFailureCalls[0]).toEqual({ item: 2, - error: new Error('Error at 2') + error: new Error('Error at 2'), }) expect(result.failure).toEqual({ item: 2, - error: new Error('Error at 2') + error: new Error('Error at 2'), }) expect(result.results).toEqual([2]) }) @@ -32,13 +35,14 @@ test('onFailure called for failLate with true', async () => { const items = [1, 2, 3, 4] const fn = async item => { - if (item === 2 || item === 4) throw new Error(`Error at ${item}`) + if (item === 2 || item === 4) + throw new Error(`Error at ${item}`) return item * 2 } const result = await series(items, fn, { strategy: failLate, - onFailure: (failure) => onFailureCalls.push(failure) + onFailure: failure => onFailureCalls.push(failure), }) expect(onFailureCalls).toHaveLength(1) @@ -53,13 +57,14 @@ test('onFailure NOT called for collect (failure: null)', async () => { const items = [1, 2, 3] const fn = async item => { - if (item === 2) throw new Error(`Error at ${item}`) + if (item === 2) + throw new Error(`Error at ${item}`) return item * 2 } const result = await series(items, fn, { strategy: collect, - onFailure: (failure) => onFailureCalls.push(failure) + onFailure: failure => onFailureCalls.push(failure), }) expect(onFailureCalls).toHaveLength(0) @@ -73,13 +78,14 @@ test('onFailure NOT called for skip (failure: null)', async () => { const items = [1, 2, 3] const fn = async item => { - if (item === 2) throw new Error(`Error at ${item}`) + if (item === 2) + throw new Error(`Error at ${item}`) return item * 2 } const result = await series(items, fn, { strategy: skip, - onFailure: (failure) => onFailureCalls.push(failure) + onFailure: failure => onFailureCalls.push(failure), }) expect(onFailureCalls).toHaveLength(0) @@ -92,7 +98,8 @@ test('onFailure is optional', async () => { const items = [1, 2, 3] const fn = async item => { - if (item === 2) throw new Error(`Error at ${item}`) + if (item === 2) + throw new Error(`Error at ${item}`) return item * 2 } @@ -101,7 +108,7 @@ test('onFailure is optional', async () => { expect(result.failure).toEqual({ item: 2, - error: new Error('Error at 2') + error: new Error('Error at 2'), }) }) @@ -110,23 +117,24 @@ test('onFailure works with filter', async () => { const items = [1, 2, 3, 4] const predicate = async item => { - if (item === 3) throw new Error(`Error at ${item}`) + if (item === 3) + throw new Error(`Error at ${item}`) return item % 2 === 0 } const result = await filter(items, predicate, { strategy: failFast, - onFailure: (failure) => onFailureCalls.push(failure) + onFailure: failure => onFailureCalls.push(failure), }) expect(onFailureCalls).toHaveLength(1) expect(onFailureCalls[0]).toEqual({ item: 3, - error: new Error('Error at 3') + error: new Error('Error at 3'), }) expect(result.failure).toEqual({ item: 3, - error: new Error('Error at 3') + error: new Error('Error at 3'), }) expect(result.results).toEqual([2]) }) @@ -136,13 +144,14 @@ test('onFailure works with filter and failLate', async () => { const items = [1, 2, 3, 4, 5] const predicate = async item => { - if (item === 2 || item === 4) throw new Error(`Error at ${item}`) + if (item === 2 || item === 4) + throw new Error(`Error at ${item}`) return item % 2 === 1 } const result = await filter(items, predicate, { strategy: failLate, - onFailure: (failure) => onFailureCalls.push(failure) + onFailure: failure => onFailureCalls.push(failure), }) expect(onFailureCalls).toHaveLength(1) @@ -152,53 +161,52 @@ test('onFailure works with filter and failLate', async () => { expect(result.errors).toHaveLength(2) }) -test('onFailure works with safeScan', async () => { +test('onFailure works with scan', async () => { const onFailureCalls = [] const items = [1, 2, 3] const scanner = async (acc, item) => { - if (item === 2) throw new Error(`Error at ${item}`) + if (item === 2) + throw new Error(`Error at ${item}`) return acc + item } - const result = await safeScan(items, scanner, 0, { + const result = await scan(items, scanner, 0, { strategy: failFast, - onFailure: (failure) => onFailureCalls.push(failure) + onFailure: failure => onFailureCalls.push(failure), }) expect(onFailureCalls).toHaveLength(1) expect(onFailureCalls[0]).toEqual({ item: 2, - error: new Error('Error at 2') + error: new Error('Error at 2'), }) expect(result.failure).toEqual({ item: 2, - error: new Error('Error at 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) => { - return { - ...opts, - onFailure: (failure) => { - lastFailure = failure - // Could trigger UI update, notification, etc. - if (opts.onFailure) { - opts.onFailure(failure) - } + 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 fn = async item => { - if (item === 2) throw new Error(`Error at ${item}`) + if (item === 2) + throw new Error(`Error at ${item}`) return item * 2 } @@ -207,11 +215,11 @@ test('Application-layer wrapper with default onFailure', async () => { expect(lastFailure).toEqual({ item: 2, - error: new Error('Error at 2') + error: new Error('Error at 2'), }) expect(result.failure).toEqual({ item: 2, - error: new Error('Error at 2') + error: new Error('Error at 2'), }) }) @@ -221,14 +229,15 @@ test('onFailure with skip strategy still allows onError', async () => { const items = [1, 2, 3] const fn = async item => { - if (item === 2) throw new Error(`Error at ${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), - onFailure: (failure) => onFailureCalls.push(failure) + onError: error => onErrorCalls.push(error), + onFailure: failure => onFailureCalls.push(failure), }) // onError should be called even with skip From c3e8460431f08fc812bd0dba0a149e8962780270 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:50:13 +0100 Subject: [PATCH 05/10] removed all usesess await --- tests/error-strategies.test.js | 20 ++++++++++---------- tests/onFailure.test.js | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/error-strategies.test.js b/tests/error-strategies.test.js index a9df3c5..a7f45d1 100644 --- a/tests/error-strategies.test.js +++ b/tests/error-strategies.test.js @@ -53,7 +53,7 @@ test('strategies are distinct references', () => { test('failLate: collects all errors and returns failure: true', async () => { const items = [1, 2, 3, 4] - const fn = async item => { + const fn = item => { if (item === 2 || item === 4) throw new Error(`Error at ${item}`) return item * 2 @@ -70,7 +70,7 @@ test('failLate: collects all errors and returns failure: true', async () => { test('failLate: no errors returns failure: null', async () => { const items = [1, 2, 3] - const fn = async item => item * 2 + const fn = item => item * 2 const result = await series(items, fn, {strategy: failLate}) @@ -81,7 +81,7 @@ test('failLate: no errors returns failure: null', async () => { test('skip: ignores errors without collection', async () => { const items = [1, 2, 3, 4] - const fn = async item => { + const fn = item => { if (item === 2 || item === 4) throw new Error(`Error at ${item}`) return item * 2 @@ -98,7 +98,7 @@ test('skip: calls onError if present', async () => { const onErrorCalls = [] const items = [1, 2, 3] - const fn = async item => { + const fn = item => { if (item === 2) throw new Error(`Error at ${item}`) return item * 2 @@ -118,7 +118,7 @@ test('skip: calls onError if present', async () => { test('fail alias works as failFast in series', async () => { const items = [1, 2, 3] - const fn = async item => { + const fn = item => { if (item === 2) throw new Error(`Error at ${item}`) return item * 2 @@ -136,7 +136,7 @@ test('fail alias works as failFast in series', async () => { test('stopOnError alias works as failFast in series', async () => { const items = [1, 2, 3] - const fn = async item => { + const fn = item => { if (item === 2) throw new Error(`Error at ${item}`) return item * 2 @@ -154,7 +154,7 @@ test('stopOnError alias works as failFast in series', async () => { test('failLate works with filter', async () => { const items = [1, 2, 3, 4, 5] - const predicate = async item => { + const predicate = item => { if (item === 2 || item === 4) throw new Error(`Error at ${item}`) return item % 2 === 1 @@ -171,7 +171,7 @@ test('failLate works with filter', async () => { test('skip works with filter', async () => { const items = [1, 2, 3, 4] - const predicate = async item => { + const predicate = item => { if (item === 2 || item === 4) throw new Error(`Error at ${item}`) return item % 2 === 0 @@ -186,7 +186,7 @@ test('skip works with filter', async () => { test('failLate with series returns all successful results before any error', async () => { const items = [1, 2, 3, 4, 5] - const fn = async item => { + const fn = item => { if (item === 3) throw new Error(`Error at ${item}`) return item * 2 @@ -202,7 +202,7 @@ test('failLate with series returns all successful results before any error', asy test('collect strategy still works (failure: null)', async () => { const items = [1, 2, 3] - const fn = async item => { + const fn = item => { if (item === 2) throw new Error(`Error at ${item}`) return item * 2 diff --git a/tests/onFailure.test.js b/tests/onFailure.test.js index d592d3a..8f3ff79 100644 --- a/tests/onFailure.test.js +++ b/tests/onFailure.test.js @@ -7,7 +7,7 @@ test('onFailure called for failFast with {item, error}', async () => { const onFailureCalls = [] const items = [1, 2, 3] - const fn = async item => { + const fn = item => { if (item === 2) throw new Error(`Error at ${item}`) return item * 2 @@ -34,7 +34,7 @@ test('onFailure called for failLate with true', async () => { const onFailureCalls = [] const items = [1, 2, 3, 4] - const fn = async item => { + const fn = item => { if (item === 2 || item === 4) throw new Error(`Error at ${item}`) return item * 2 @@ -56,7 +56,7 @@ test('onFailure NOT called for collect (failure: null)', async () => { const onFailureCalls = [] const items = [1, 2, 3] - const fn = async item => { + const fn = item => { if (item === 2) throw new Error(`Error at ${item}`) return item * 2 @@ -77,7 +77,7 @@ test('onFailure NOT called for skip (failure: null)', async () => { const onFailureCalls = [] const items = [1, 2, 3] - const fn = async item => { + const fn = item => { if (item === 2) throw new Error(`Error at ${item}`) return item * 2 @@ -97,7 +97,7 @@ test('onFailure NOT called for skip (failure: null)', async () => { test('onFailure is optional', async () => { const items = [1, 2, 3] - const fn = async item => { + const fn = item => { if (item === 2) throw new Error(`Error at ${item}`) return item * 2 @@ -116,7 +116,7 @@ test('onFailure works with filter', async () => { const onFailureCalls = [] const items = [1, 2, 3, 4] - const predicate = async item => { + const predicate = item => { if (item === 3) throw new Error(`Error at ${item}`) return item % 2 === 0 @@ -143,7 +143,7 @@ test('onFailure works with filter and failLate', async () => { const onFailureCalls = [] const items = [1, 2, 3, 4, 5] - const predicate = async item => { + const predicate = item => { if (item === 2 || item === 4) throw new Error(`Error at ${item}`) return item % 2 === 1 @@ -165,7 +165,7 @@ test('onFailure works with scan', async () => { const onFailureCalls = [] const items = [1, 2, 3] - const scanner = async (acc, item) => { + const scanner = (acc, item) => { if (item === 2) throw new Error(`Error at ${item}`) return acc + item @@ -204,7 +204,7 @@ test('Application-layer wrapper with default onFailure', async () => { }) const items = [1, 2, 3] - const fn = async item => { + const fn = item => { if (item === 2) throw new Error(`Error at ${item}`) return item * 2 @@ -228,7 +228,7 @@ test('onFailure with skip strategy still allows onError', async () => { const onFailureCalls = [] const items = [1, 2, 3] - const fn = async item => { + const fn = item => { if (item === 2) throw new Error(`Error at ${item}`) return item * 2 From 7a3c2129024520136334bc81e4b914bf7df4411f Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:56:47 +0100 Subject: [PATCH 06/10] compacted tesst and also relaxed max-lines for test --- eslint.config.js | 4 +- src/functional.js | 1 + tests/onFailure.test.js | 180 +++++++++++++++++----------------------- 3 files changed, 79 insertions(+), 106 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 02d3266..9b85206 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -37,6 +37,8 @@ export default [ { name: 'Tests', files: ['tests/**/*.js'], - rules: {}, + rules: { + 'max-lines': ['warn', 250], + }, }, ] diff --git a/src/functional.js b/src/functional.js index 1684ff9..76e8c87 100644 --- a/src/functional.js +++ b/src/functional.js @@ -57,6 +57,7 @@ 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, diff --git a/tests/onFailure.test.js b/tests/onFailure.test.js index 8f3ff79..3b8c59e 100644 --- a/tests/onFailure.test.js +++ b/tests/onFailure.test.js @@ -1,25 +1,49 @@ -import {test, expect} from 'vitest' +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 onFailureCalls = [] + const onFailure = vi.fn() 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, { + const result = await series(items, fnThatFailsAt(2), { strategy: failFast, - onFailure: failure => onFailureCalls.push(failure), + onFailure, }) - expect(onFailureCalls).toHaveLength(1) - expect(onFailureCalls[0]).toEqual({ + expect(onFailure).toHaveBeenCalledTimes(1) + expect(onFailure).toHaveBeenCalledWith({ item: 2, error: new Error('Error at 2'), }) @@ -31,64 +55,46 @@ test('onFailure called for failFast with {item, error}', async () => { }) test('onFailure called for failLate with true', async () => { - const onFailureCalls = [] + const onFailure = vi.fn() 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, { + const result = await series(items, fnThatFailsAtItems([2, 4]), { strategy: failLate, - onFailure: failure => onFailureCalls.push(failure), + onFailure, }) - expect(onFailureCalls).toHaveLength(1) - expect(onFailureCalls[0]).toBe(true) + 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 onFailureCalls = [] + const onFailure = vi.fn() 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, { + const result = await series(items, fnThatFailsAt(2), { strategy: collect, - onFailure: failure => onFailureCalls.push(failure), + onFailure, }) - expect(onFailureCalls).toHaveLength(0) + 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 onFailureCalls = [] + const onFailure = vi.fn() 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, { + const result = await series(items, fnThatFailsAt(2), { strategy: skip, - onFailure: failure => onFailureCalls.push(failure), + onFailure, }) - expect(onFailureCalls).toHaveLength(0) + expect(onFailure).not.toHaveBeenCalled() expect(result.failure).toBe(null) expect(result.results).toEqual([2, 6]) expect(result.errors).toHaveLength(0) @@ -97,14 +103,8 @@ test('onFailure NOT called for skip (failure: null)', async () => { test('onFailure is optional', async () => { const items = [1, 2, 3] - const fn = item => { - if (item === 2) - throw new Error(`Error at ${item}`) - return item * 2 - } - // Should not throw without onFailure - const result = await series(items, fn, {strategy: failFast}) + const result = await series(items, fnThatFailsAt(2), {strategy: failFast}) expect(result.failure).toEqual({ item: 2, @@ -113,22 +113,16 @@ test('onFailure is optional', async () => { }) test('onFailure works with filter', async () => { - const onFailureCalls = [] + const onFailure = vi.fn() const items = [1, 2, 3, 4] - const predicate = item => { - if (item === 3) - throw new Error(`Error at ${item}`) - return item % 2 === 0 - } - - const result = await filter(items, predicate, { + const result = await filter(items, predicateThatFailsAt(3), { strategy: failFast, - onFailure: failure => onFailureCalls.push(failure), + onFailure, }) - expect(onFailureCalls).toHaveLength(1) - expect(onFailureCalls[0]).toEqual({ + expect(onFailure).toHaveBeenCalledTimes(1) + expect(onFailure).toHaveBeenCalledWith({ item: 3, error: new Error('Error at 3'), }) @@ -140,44 +134,32 @@ test('onFailure works with filter', async () => { }) test('onFailure works with filter and failLate', async () => { - const onFailureCalls = [] + const onFailure = vi.fn() 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, { + const result = await filter(items, predicateThatFailsAtItems([2, 4]), { strategy: failLate, - onFailure: failure => onFailureCalls.push(failure), + onFailure, }) - expect(onFailureCalls).toHaveLength(1) - expect(onFailureCalls[0]).toBe(true) + 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 onFailureCalls = [] + const onFailure = vi.fn() const items = [1, 2, 3] - const scanner = (acc, item) => { - if (item === 2) - throw new Error(`Error at ${item}`) - return acc + item - } - - const result = await scan(items, scanner, 0, { + const result = await scan(items, scannerThatFailsAt(2), 0, { strategy: failFast, - onFailure: failure => onFailureCalls.push(failure), + onFailure, }) - expect(onFailureCalls).toHaveLength(1) - expect(onFailureCalls[0]).toEqual({ + expect(onFailure).toHaveBeenCalledTimes(1) + expect(onFailure).toHaveBeenCalledWith({ item: 2, error: new Error('Error at 2'), }) @@ -204,14 +186,8 @@ test('Application-layer wrapper with default onFailure', async () => { }) const items = [1, 2, 3] - const fn = item => { - if (item === 2) - throw new Error(`Error at ${item}`) - return item * 2 - } - - const opts = withDefaultOnFailure(fn, {strategy: failFast}) - const result = await series(items, fn, opts) + const opts = withDefaultOnFailure(fnThatFailsAt(2), {strategy: failFast}) + const result = await series(items, fnThatFailsAt(2), opts) expect(lastFailure).toEqual({ item: 2, @@ -224,28 +200,22 @@ test('Application-layer wrapper with default onFailure', async () => { }) test('onFailure with skip strategy still allows onError', async () => { - const onErrorCalls = [] - const onFailureCalls = [] + const onError = vi.fn() + const onFailure = vi.fn() 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, { + const result = await series(items, fnThatFailsAt(2), { strategy: skip, - onError: error => onErrorCalls.push(error), - onFailure: failure => onFailureCalls.push(failure), + onError, + onFailure, }) // onError should be called even with skip - expect(onErrorCalls).toHaveLength(1) - expect(onErrorCalls[0]).toEqual(new Error('Error at 2')) + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith(new Error('Error at 2')) // onFailure should NOT be called (failure is null) - expect(onFailureCalls).toHaveLength(0) + expect(onFailure).not.toHaveBeenCalled() expect(result.failure).toBe(null) expect(result.errors).toHaveLength(0) expect(result.results).toEqual([2, 6]) From eb8d7cbbfd05bc421422fbdee7ba7fc0f40d6c48 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:01:20 +0100 Subject: [PATCH 07/10] removed onError backward compatibility --- docs/functional.md | 2 +- package.json | 4 ++-- src/functional.js | 14 ++++---------- tests/filter.test.js | 14 +------------- 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/docs/functional.md b/docs/functional.md index c571dd0..f2a70e5 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -221,7 +221,7 @@ const { results, errors } = await scan( **Type**: `(...args) => filteredItems | filterFunction` **Parameters**: -- First argument (optional): If a function, specifies `take` and `onError` strategy +- 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 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 76e8c87..7fc3059 100644 --- a/src/functional.js +++ b/src/functional.js @@ -133,12 +133,6 @@ export const filter = (...args) => { onFailure, } = opts || {} - // Support backward compatibility: onError can be a strategy (old API) or callback (new API) - const isErrorCallback = typeof onErrorParam === 'function' - const strategyFromOnError = !isErrorCallback ? onErrorParam : null - const finalStrategy = strategyFromOnError ?? strategy - const errorCallback = isErrorCallback ? onErrorParam : null - const results = [] const errors = [] @@ -157,10 +151,10 @@ export const filter = (...args) => { results.push(item) } } catch (error) { - const strategyName = finalStrategy?.name ?? finalStrategy + const strategyName = strategy?.name ?? strategy - if (errorCallback) { - await errorCallback(error) + if (onErrorParam) { + await onErrorParam(error) } if (strategyName === 'failFast') { @@ -181,7 +175,7 @@ export const filter = (...args) => { index++ } - failure = finalStrategy?.name === 'failLate' && errors.length > 0 ? true : null + failure = strategy?.name === 'failLate' && errors.length > 0 ? true : null if (failure && onFailure) { onFailure(true) diff --git a/tests/filter.test.js b/tests/filter.test.js index f62ecb4..cacbe29 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) @@ -23,18 +23,6 @@ test('predicate throws with failFast stops and populates failure', async () => { expect(result.errors).toEqual([]) }) -test('predicate throws with collect continues and collects error', async () => { - const bang = new Error('bang') - const result = await filter([1, 2, 3], x => { - if (x === 2) - throw bang - return true - }, {onError: collect}) - expect(result.results).toEqual([1, 3]) - expect(result.errors).toEqual([{item: 2, error: bang}]) - expect(result.failure).toBeNull() -}) - test('async predicates work', async () => { const result = await filter([1, 2, 3], x => Promise.resolve(x % 2 === 1)) expect(result.results).toEqual([1, 3]) From 1435db17be8e5d27af80dcf51bced44fd2085a10 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:14:25 +0100 Subject: [PATCH 08/10] renamed reference doc file --- docs/{functional.md => reference.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{functional.md => reference.md} (100%) diff --git a/docs/functional.md b/docs/reference.md similarity index 100% rename from docs/functional.md rename to docs/reference.md From eedac37c47ad700f6731f16344be17822a258bdf Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:14:55 +0100 Subject: [PATCH 09/10] updated links --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 058a16705d514078e1b00b2f087a8933ea89cce1 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:17:46 +0100 Subject: [PATCH 10/10] filter default to collect strategy --- docs/errors.md | 4 ++-- docs/reference.md | 8 ++++---- src/functional.js | 2 +- tests/filter.test.js | 17 ++++++++++++++++- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index f696c8e..585001a 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -6,7 +6,7 @@ Pipelean provides comprehensive error handling through named strategies and call All iteration functions (`series`, `filter`, `scan`) support four error strategies: -### `failFast` (default for `filter`, `scan`) +### `failFast` (default for `scan`) Stop immediately on first error. @@ -29,7 +29,7 @@ const result = await series([1, 2, 3], async item => { --- -### `collect` (default for `series`) +### `collect` (default for `series` and `filter`) Continue through all items, collect errors. diff --git a/docs/reference.md b/docs/reference.md index f2a70e5..fa13896 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -231,9 +231,9 @@ const { results, errors } = await scan( - `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: +**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 `collect`: `{ results, errors: [...], failure: null }` - With `failLate`: `{ results, errors: [...], failure: true }` - With `skip`: `{ results, errors: [], failure: null }` @@ -241,14 +241,14 @@ const { results, errors } = await scan( ```javascript import { filter, failFast } from './functional.js' -// Filter valid emails from a list +// Filter valid emails from a list (default is collect) const validEmails = await filter( async (email) => { return email.includes('@') }, emails, { - strategy: failFast // Stop on first invalid email + strategy: failFast // Override default to stop on first invalid email } ) ``` diff --git a/src/functional.js b/src/functional.js index 7fc3059..bfc27b9 100644 --- a/src/functional.js +++ b/src/functional.js @@ -127,7 +127,7 @@ export const filter = (...args) => { // eslint-disable-next-line complexity, max-statements const run = async inputItems => { const { - strategy = failFast, + strategy = collect, onError: onErrorParam, take, onFailure, diff --git a/tests/filter.test.js b/tests/filter.test.js index cacbe29..3b3f483 100644 --- a/tests/filter.test.js +++ b/tests/filter.test.js @@ -17,12 +17,27 @@ 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 default collect continues and collects errors', async () => { + const bang = new Error('bang') + const result = await filter([1, 2, 3, 4], x => { + if (x === 2 || x === 4) + throw bang + return true + }) + expect(result.results).toEqual([1, 3]) + expect(result.failure).toBe(null) + expect(result.errors).toEqual([ + {item: 2, error: bang}, + {item: 4, error: bang}, + ]) +}) + test('async predicates work', async () => { const result = await filter([1, 2, 3], x => Promise.resolve(x % 2 === 1)) expect(result.results).toEqual([1, 3])