diff --git a/docs/architecture.md b/docs/architecture.md index 9c90422..462adc7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,13 @@ # Design Principles & Architecture -Pragmatism Over Purity. +## Core motivations + +1) do NOT swallow errors and inconsistencies during arrays sync and async operations +2) avoid boilerplate error management (and BAD error management) + +Point number one includes *thrown errors* as well as JavaScript *built-in inconsistencies*: undefined and null. + +## Pragmatism Over Purity. We built this library to be a practical tool for executing tasks. @@ -13,3 +20,19 @@ Many functional libraries are "Iterator-First" (lazy, yielding generators). We e Our Approach: Eager Execution. We prefer Explicit Results over Lazy Iterables. When you run series or scan, the work happens immediately. You get a structured report { results, errors, failure } back. No surprises. + +## Terminology + +The vocabulary we have established for the pipelean project: + + * Iteration (Horizontal): The process of traversing a list of items one by one. This is the "width" of the process. + Implementations: series, scan, filter. + + * Composition (Vertical): The process of chaining functions together to run sequentially on a single item. This is the "depth" of the process. + Implementation: pipe. + + * Operation: The function passed to an iterator (like series). It can be a simple function or a composed function (pipe). + * Transform (Mapping): An operation that changes the shape or value of an item. (A→B). + * Selection (Filtering): An operation that decides whether to keep or drop an item. (A→A or A→∅ + ). In our merged model, this is signaled by returning undefined. + * Outcome: The structural result returned by iterators: {results, errors, failure}. diff --git a/src/functional.js b/src/functional.js index 96e2f59..8007e39 100644 --- a/src/functional.js +++ b/src/functional.js @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ export const failFast = Object.freeze({name: 'failFast'}) export const collect = Object.freeze({name: 'collect'}) export const failLate = Object.freeze({name: 'failLate'}) @@ -60,7 +61,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 + // eslint-disable-next-line complexity, max-statements const run = async inputItems => { const { strategy = collect, @@ -79,7 +80,7 @@ export const series = (...args) => { }) let index = 0 - let failure = null + let failure = false for await (const item of inputItems) { // eslint-disable-next-line no-undefined @@ -88,9 +89,16 @@ export const series = (...args) => { try { const result = await safeFn(item, index) - results.push(result) + // Why the next line: + // If the operation (or pipe) returns undefined, we drop the item. + // Is this a temporary hack? + // Should we collect drops as we do for errors. + // eslint-disable-next-line no-undefined + if (result !== undefined) { + results.push(result) + } } catch (error) { - const strategyName = strategy?.name ?? strategy + const strategyName = strategy.name ?? strategy if (strategyName === 'failFast') { if (onFailure) { @@ -111,7 +119,7 @@ export const series = (...args) => { index++ } - failure = strategy?.name === 'failLate' && errors.length > 0 ? true : null + failure = !!(strategy.name === 'failLate' && errors.length > 0) if (failure && onFailure) { onFailure(true) @@ -163,7 +171,7 @@ export const filter = (...args) => { results.push(item) } } catch (error) { - const strategyName = strategy?.name ?? strategy + const strategyName = strategy.name ?? strategy if (onErrorParam) { await onErrorParam(error) @@ -187,7 +195,7 @@ export const filter = (...args) => { index++ } - failure = strategy?.name === 'failLate' && errors.length > 0 ? true : null + failure = strategy.name === 'failLate' && errors.length > 0 ? true : null if (failure && onFailure) { onFailure(true) @@ -211,7 +219,7 @@ export const scan = async (iterable, scanner, initialValue, opts = {}) => { acc = await scanner(acc, item) results.push(acc) } catch (error) { - const strategyName = strategy?.name ?? strategy + const strategyName = strategy.name ?? strategy if (onError) { await onError(error) @@ -238,7 +246,7 @@ export const scan = async (iterable, scanner, initialValue, opts = {}) => { } const failure = - strategy?.name === 'failLate' && errors.length > 0 ? true : null + strategy.name === 'failLate' && errors.length > 0 ? true : null if (failure && onFailure) { onFailure(true) @@ -248,7 +256,11 @@ export const scan = async (iterable, scanner, initialValue, opts = {}) => { } export const pipe = (...fns) => input => - fns.reduce(async (acc, fn) => fn(await acc), input) + fns.reduce(async (acc, fn) => { + const value = await acc + // eslint-disable-next-line no-undefined + return value === undefined ? undefined : fn(value) + }, input) export const compose = pipe diff --git a/tests/error-strategies.test.js b/tests/error-strategies.test.js index 105ce2c..c6d1ca6 100644 --- a/tests/error-strategies.test.js +++ b/tests/error-strategies.test.js @@ -76,7 +76,7 @@ test('failLate: no errors returns failure: null', async () => { expect(result.results).toEqual([2, 4, 6]) expect(result.errors).toHaveLength(0) - expect(result.failure).toBe(null) + expect(result.failure).toBe(false) }) test('skip: ignores errors without collection', async () => { @@ -91,7 +91,7 @@ test('skip: ignores errors without collection', async () => { expect(result.results).toEqual([2, 6]) expect(result.errors).toHaveLength(0) - expect(result.failure).toBe(null) + expect(result.failure).toBe(false) }) test('skip: calls onError if present', async () => { @@ -113,7 +113,7 @@ test('skip: calls onError if present', async () => { 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) + expect(result.failure).toBe(false) }) test('fail alias works as failFast in series', async () => { @@ -181,7 +181,7 @@ test('skip works with filter', async () => { expect(result.results).toEqual([]) // 2 and 4 failed expect(result.errors).toHaveLength(0) - expect(result.failure).toBe(null) + expect(result.failure).toBe(false) }) test('failLate with series returns success before error', async () => { @@ -200,7 +200,7 @@ test('failLate with series returns success before error', async () => { expect(result.failure).toBe(true) }) -test('collect strategy still works (failure: null)', async () => { +test('collect strategy still works (failure: false)', async () => { const items = [1, 2, 3] const fn = item => { if (item === 2) @@ -212,5 +212,5 @@ test('collect strategy still works (failure: null)', async () => { expect(result.results).toEqual([2, 6]) expect(result.errors).toHaveLength(1) - expect(result.failure).toBe(null) + expect(result.failure).toBe(false) }) diff --git a/tests/onFailure.test.js b/tests/onFailure.test.js index 3b8c59e..082a0b2 100644 --- a/tests/onFailure.test.js +++ b/tests/onFailure.test.js @@ -70,7 +70,7 @@ test('onFailure called for failLate with true', async () => { expect(result.errors).toHaveLength(2) }) -test('onFailure NOT called for collect (failure: null)', async () => { +test('onFailure NOT called for collect (failure: false)', async () => { const onFailure = vi.fn() const items = [1, 2, 3] @@ -80,12 +80,12 @@ test('onFailure NOT called for collect (failure: null)', async () => { }) expect(onFailure).not.toHaveBeenCalled() - expect(result.failure).toBe(null) + expect(result.failure).toBe(false) expect(result.results).toEqual([2, 6]) expect(result.errors).toHaveLength(1) }) -test('onFailure NOT called for skip (failure: null)', async () => { +test('onFailure NOT called for skip (failure: false)', async () => { const onFailure = vi.fn() const items = [1, 2, 3] @@ -95,7 +95,7 @@ test('onFailure NOT called for skip (failure: null)', async () => { }) expect(onFailure).not.toHaveBeenCalled() - expect(result.failure).toBe(null) + expect(result.failure).toBe(false) expect(result.results).toEqual([2, 6]) expect(result.errors).toHaveLength(0) }) @@ -172,7 +172,7 @@ test('onFailure works with scan', async () => { test('Application-layer wrapper with default onFailure', async () => { // Simulating an application-layer wrapper - let lastFailure = null + let lastFailure = false const withDefaultOnFailure = (fn, opts) => ({ ...opts, @@ -214,9 +214,9 @@ test('onFailure with skip strategy still allows onError', async () => { expect(onError).toHaveBeenCalledTimes(1) expect(onError).toHaveBeenCalledWith(new Error('Error at 2')) - // onFailure should NOT be called (failure is null) + // onFailure should NOT be called (failure is false) expect(onFailure).not.toHaveBeenCalled() - expect(result.failure).toBe(null) + expect(result.failure).toBe(false) expect(result.errors).toHaveLength(0) expect(result.results).toEqual([2, 6]) }) diff --git a/tests/pipe.test.js b/tests/pipe.test.js index 71dac6e..b8ba9d0 100644 --- a/tests/pipe.test.js +++ b/tests/pipe.test.js @@ -28,6 +28,19 @@ test('propagates errors', async () => { await expect(pipeline(1)).rejects.toThrow('pipe broke') }) +test('short-circuits on undefined', async () => { + let called = false + const result = await pipe( + () => undefined, + () => { + called = true + return 'should not reach' + }, + )(1) + expect(result).toBeUndefined() + expect(called).toBe(false) +}) + test('passes result through async chain', async () => { const result = await pipe( x => Promise.resolve(x + 1), diff --git a/tests/series-pipe.test.js b/tests/series-pipe.test.js new file mode 100644 index 0000000..71bb45b --- /dev/null +++ b/tests/series-pipe.test.js @@ -0,0 +1,96 @@ +import {test, expect} from 'vitest' +import {series, pipe} from '$src/functional' + +const double = x => x * 2 +const increment = x => x + 1 +const isEven = x => x % 2 === 0 + +// A curried function for testing curry support +const multiplyBy = factor => x => x * factor + +test('accept a pipe with a single mapping function', async () => { + const items = [1, 2, 3] + const operation = pipe(double) + + const result = await series(items, operation) + + expect(result).toEqual({ + results: [2, 4, 6], + errors: [], + failure: false, + }) +}) + +test('accept a pipe with multiple mapping functions', async () => { + const items = [1, 2, 3] + // double first, then increment: 1->2->3 + const operation = pipe(double, increment) + + const result = await series(items, operation) + + expect(result).toEqual({ + results: [3, 5, 7], + errors: [], + failure: false, + }) +}) + +test('curried functions inside the pipe', async () => { + const items = [1, 2, 3] + // Using the curried multiplyBy function + const operation = pipe(increment, multiplyBy(10)) + + const result = await series(items, operation) + + expect(result).toEqual({ + results: [20, 30, 40], // (1+1)*10, etc. + errors: [], + failure: false, + }) +}) + +test( + 'filter after transform in a pipe (short-circuit mid-pipe)', + async () => { + const items = [1, 2, 3, 4, 5, 6] + + // NOTE: double(n) is always even for integers, so the + // filter step (drop odds) is a no-op here. + // 1->2->3, 2->4->5, 3->6->7, 4->8->9, 5->10->11, 6->12->13 + const operation = pipe( + double, + x => isEven(x) ? x : undefined, + increment, + ) + + const result = await series(items, operation) + + expect(result).toEqual({ + results: [3, 5, 7, 9, 11, 13], + errors: [], + failure: false, + }) + }, +) + +test('mixed mapping and filtering logic within a pipe', async () => { + const items = [1, 2, 3, 4, 5, 6] + + // Goal: Keep even numbers, double them, then increment. + // 1 (odd) -> drop + // 2 (even) -> 4 -> 5 + // 3 (odd) -> drop + const operation = pipe( + x => isEven(x) ? x : undefined, // Filter FIRST + double, + increment + ) + + const result = await series(items, operation) + + expect(result).toEqual({ + results: [5, 9, 13], // inputs: 2, 4, 6 + errors: [], + failure: false, + }) +}) diff --git a/tests/series.test.js b/tests/series.test.js index dd4c39e..64627cc 100644 --- a/tests/series.test.js +++ b/tests/series.test.js @@ -3,7 +3,7 @@ import {series, collect} from '$src/functional' test('all items succeed returns results with no errors', async () => { const result = await series([1, 2, 3], x => x * 2) - expect(result).toEqual({results: [2, 4, 6], errors: [], failure: null}) + expect(result).toEqual({results: [2, 4, 6], errors: [], failure: false}) }) test('failFast stops on first error with partial results', async () => { @@ -27,7 +27,7 @@ test('collect continues past errors same as skip', async () => { }, {strategy: collect}) expect(result.results).toEqual([10, 30]) expect(result.errors).toEqual([{item: 2, error: bang}]) - expect(result.failure).toBeNull() + expect(result.failure).toBe(false) }) test('async mapping functions work', async () => { @@ -46,7 +46,7 @@ test('passes index as second arg to fn', async () => { test('empty array returns empty result shape', async () => { const result = await series([], x => x) - expect(result).toEqual({results: [], errors: [], failure: null}) + expect(result).toEqual({results: [], errors: [], failure: false}) }) test('curried form returns a function', () => { @@ -57,5 +57,5 @@ test('curried form returns a function', () => { test('curried form executes when called with items', async () => { const double = series(x => x * 2) const result = await double([1, 2, 3]) - expect(result).toEqual({results: [2, 4, 6], errors: [], failure: null}) + expect(result).toEqual({results: [2, 4, 6], errors: [], failure: false}) })