From 54ef8426f62e6e8e77b79198ecad76dd0e5d1304 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:34:15 +0100 Subject: [PATCH 1/6] mew tests using pipe in series: 2 issues 1) filter null is against our credo and 2) pipe with selection predicate do not seem to work --- docs/architecture.md | 9 ++++- tests/series-pipe.test.js | 74 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/series-pipe.test.js diff --git a/docs/architecture.md b/docs/architecture.md index 9c90422..3eccb1d 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. diff --git a/tests/series-pipe.test.js b/tests/series-pipe.test.js new file mode 100644 index 0000000..ea4eda7 --- /dev/null +++ b/tests/series-pipe.test.js @@ -0,0 +1,74 @@ +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('mixed mapping and filtering logic within a pipe', async () => { + const items = [1, 2, 3, 4, 5, 6] + + // This simulates the "Merge": mapping AND selecting in one pass + // Logic: Double it, then filter out odd numbers, then increment + // 1 -> 2 (drop) + // 2 -> 4 -> 5 + // 3 -> 6 -> 7 + // 4 -> 8 -> 9 + const operation = pipe( + double, + x => isEven(x) ? x : undefined, // Manual inline "filter" + increment + ) + + const result = await series(items, operation) + + expect(result).toEqual({ + results: [5, 7, 9], // Derived from inputs 2, 3, 4 + errors: [], + failure: false, + }) +}) From 32e4fdb8bae4cd3598367fdaa9751e7875c8a68f Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:42:10 +0200 Subject: [PATCH 2/6] failure specs: now is false, not null, when there is no failure --- src/functional.js | 14 +++++++------- tests/error-strategies.test.js | 10 +++++----- tests/series.test.js | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/functional.js b/src/functional.js index 96e2f59..1d96c1e 100644 --- a/src/functional.js +++ b/src/functional.js @@ -79,7 +79,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 @@ -90,7 +90,7 @@ export const series = (...args) => { const result = await safeFn(item, index) results.push(result) } catch (error) { - const strategyName = strategy?.name ?? strategy + const strategyName = strategy.name ?? strategy if (strategyName === 'failFast') { if (onFailure) { @@ -111,7 +111,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 +163,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 +187,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 +211,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 +238,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) diff --git a/tests/error-strategies.test.js b/tests/error-strategies.test.js index 105ce2c..76b09f1 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 () => { @@ -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/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}) }) From 6138af8b54e7948a72cccd7b182610d0aaff2710 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:54:34 +0200 Subject: [PATCH 3/6] added terminology --- docs/architecture.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 3eccb1d..462adc7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,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}. From 976bbdf4314dfa3b93104fd8c6cbfaa3fbab5229 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:54:46 +0200 Subject: [PATCH 4/6] fixed tests to update to new structure --- src/functional.js | 8 +++++-- tests/error-strategies.test.js | 2 +- tests/onFailure.test.js | 14 ++++++------ tests/series-pipe.test.js | 40 ++++++++++++++++++++++++++-------- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/functional.js b/src/functional.js index 1d96c1e..7473bc6 100644 --- a/src/functional.js +++ b/src/functional.js @@ -60,7 +60,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, @@ -88,7 +88,11 @@ 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. + // It is a temporary hack: we should collect drops as we do for errors. + if (result !== undefined) { + results.push(result) + } } catch (error) { const strategyName = strategy.name ?? strategy diff --git a/tests/error-strategies.test.js b/tests/error-strategies.test.js index 76b09f1..c6d1ca6 100644 --- a/tests/error-strategies.test.js +++ b/tests/error-strategies.test.js @@ -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) 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/series-pipe.test.js b/tests/series-pipe.test.js index ea4eda7..d62bc45 100644 --- a/tests/series-pipe.test.js +++ b/tests/series-pipe.test.js @@ -49,26 +49,48 @@ test('curried functions inside the pipe', async () => { }) }) +// test('mixed mapping and filtering logic within a pipe', async () => { +// const items = [1, 2, 3, 4, 5, 6] + +// // This simulates the "Merge": mapping AND selecting in one pass +// // Logic: Double it, then filter out odd numbers, then increment +// // 1 -> 2 (drop) +// // 2 -> 4 -> 5 +// // 3 -> 6 -> 7 +// // 4 -> 8 -> 9 +// const operation = pipe( +// double, +// x => isEven(x) ? x : undefined, // Manual inline "filter" +// increment +// ) + +// const result = await series(items, operation) + +// expect(result).toEqual({ +// results: [5, 7, 9], // Derived from inputs 2, 3, 4 +// errors: [], +// failure: false, +// }) +// }) + test('mixed mapping and filtering logic within a pipe', async () => { const items = [1, 2, 3, 4, 5, 6] - // This simulates the "Merge": mapping AND selecting in one pass - // Logic: Double it, then filter out odd numbers, then increment - // 1 -> 2 (drop) - // 2 -> 4 -> 5 - // 3 -> 6 -> 7 - // 4 -> 8 -> 9 + // 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, - x => isEven(x) ? x : undefined, // Manual inline "filter" increment ) const result = await series(items, operation) expect(result).toEqual({ - results: [5, 7, 9], // Derived from inputs 2, 3, 4 + results: [5, 9, 13], // Derived from inputs 2, 4, 6 errors: [], failure: false, }) -}) +}) \ No newline at end of file From cbfc5f1119b373454b02cdb3920046061d6cd19b Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:42:13 +0200 Subject: [PATCH 5/6] series apply a pipe with filter properly! --- src/functional.js | 12 +++++++--- tests/pipe.test.js | 13 ++++++++++ tests/series-pipe.test.js | 50 +++++++++++++++++++-------------------- 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/src/functional.js b/src/functional.js index 7473bc6..98f97ef 100644 --- a/src/functional.js +++ b/src/functional.js @@ -88,8 +88,10 @@ export const series = (...args) => { try { const result = await safeFn(item, index) - // Why the next line: If the operation (or pipe) returns undefined, we drop the item. - // It is a temporary hack: we should collect drops as we do for errors. + // 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. if (result !== undefined) { results.push(result) } @@ -252,7 +254,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/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 index d62bc45..71bb45b 100644 --- a/tests/series-pipe.test.js +++ b/tests/series-pipe.test.js @@ -49,29 +49,29 @@ test('curried functions inside the pipe', async () => { }) }) -// test('mixed mapping and filtering logic within a pipe', async () => { -// const items = [1, 2, 3, 4, 5, 6] - -// // This simulates the "Merge": mapping AND selecting in one pass -// // Logic: Double it, then filter out odd numbers, then increment -// // 1 -> 2 (drop) -// // 2 -> 4 -> 5 -// // 3 -> 6 -> 7 -// // 4 -> 8 -> 9 -// const operation = pipe( -// double, -// x => isEven(x) ? x : undefined, // Manual inline "filter" -// increment -// ) - -// const result = await series(items, operation) - -// expect(result).toEqual({ -// results: [5, 7, 9], // Derived from inputs 2, 3, 4 -// 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] @@ -89,8 +89,8 @@ test('mixed mapping and filtering logic within a pipe', async () => { const result = await series(items, operation) expect(result).toEqual({ - results: [5, 9, 13], // Derived from inputs 2, 4, 6 + results: [5, 9, 13], // inputs: 2, 4, 6 errors: [], failure: false, }) -}) \ No newline at end of file +}) From 1bff32bae0c5a2fd827cc70007c5d4c765979f3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 11:07:01 +0000 Subject: [PATCH 6/6] Add eslint-disable comments for max-lines and no-undefined warnings - Disable max-lines warning for functional.js file - Add no-undefined disable for result !== undefined check at line 95 https://claude.ai/code/session_01JvZCAdrnXopyqq5QMvQe3x --- src/functional.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/functional.js b/src/functional.js index 98f97ef..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'}) @@ -92,6 +93,7 @@ export const series = (...args) => { // 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) }