Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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}.
32 changes: 22 additions & 10 deletions src/functional.js
Original file line number Diff line number Diff line change
@@ -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'})
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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

Expand Down
12 changes: 6 additions & 6 deletions tests/error-strategies.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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)
Expand All @@ -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)
})
14 changes: 7 additions & 7 deletions tests/onFailure.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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]

Expand All @@ -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)
})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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])
})
13 changes: 13 additions & 0 deletions tests/pipe.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
96 changes: 96 additions & 0 deletions tests/series-pipe.test.js
Original file line number Diff line number Diff line change
@@ -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,
})
})
8 changes: 4 additions & 4 deletions tests/series.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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', () => {
Expand All @@ -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})
})
Loading