From 20f7fbd600dd15b6f68c642b1613e8bbb8e1e95e Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:46:08 +0100 Subject: [PATCH 01/26] just as a reference for now --- src/functional.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/functional.js b/src/functional.js index 853dc8e..36d5dd1 100644 --- a/src/functional.js +++ b/src/functional.js @@ -62,6 +62,20 @@ export const safeFilter = (...args) => { return immediate ? execute(items) : execute } +// OLD mapSeries, now rebuilt as a special case of safeMap +// export const mapSeries = async (array, asyncFn, {limit} = {}) => { +// const results = [] +// let count = 0 +// for await (const item of array) { +// if (limit && count >= limit) { +// break +// } +// results.push(await asyncFn(item)) +// count += 1 +// } +// return results +// } + export const mapSeries = (array, asyncFn, {limit} = {}) => safeMap(array, asyncFn, {onError: none, limit}) From fd380fcc0769776b67d770209412a9d0ffbbc0f5 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:11:07 +0100 Subject: [PATCH 02/26] restarted --- src/functional.js | 128 +++-------- tests/pipe-async.test.js | 27 +++ tests/pipe.test.js | 28 +++ tests/safe-async-iterator.test.js | 344 ------------------------------ tests/safe-map-limit.test.js | 200 ----------------- tests/safe-pipe.test.js | 80 ------- tests/safe-reduce.test.js | 213 ------------------ tests/scan-series.test.js | 32 +++ 8 files changed, 120 insertions(+), 932 deletions(-) create mode 100644 tests/pipe-async.test.js create mode 100644 tests/pipe.test.js delete mode 100644 tests/safe-async-iterator.test.js delete mode 100644 tests/safe-map-limit.test.js delete mode 100644 tests/safe-pipe.test.js delete mode 100644 tests/safe-reduce.test.js create mode 100644 tests/scan-series.test.js diff --git a/src/functional.js b/src/functional.js index 36d5dd1..a33e559 100644 --- a/src/functional.js +++ b/src/functional.js @@ -1,34 +1,23 @@ export const failFast = Object.freeze({name: 'failFast'}) export const skip = Object.freeze({name: 'skip'}) export const collect = Object.freeze({name: 'collect'}) -export const none = Object.freeze({name: 'none'}) export const safeMap = (...args) => { const immediate = Array.isArray(args[0]) - const [items, fn, opts = {}] = immediate ? args : [null, args[0], args[1]] + const [items, fn, opts] = immediate ? args : [null, args[0], args[1]] const execute = async inputItems => { - const {onError = failFast, limit} = opts + const {onError = failFast} = opts || {} const results = [] const errors = [] - let processed = 0 for await (const [index, item] of inputItems.entries()) { - // eslint-disable-next-line no-undefined - if (limit !== undefined && processed >= limit) - break try { - results.push(await Promise.resolve(fn(item, index))) + results.push(await fn(item, index)) } catch (error) { if (onError === failFast) return {results, errors, failure: {item, error}} - if (onError === none) - continue errors.push({item, error}) } - processed += 1 } - // Return plain array for 'none' strategy, structured result otherwise - if (onError === none) - return results return {results, errors, failure: null} } return immediate ? execute(items) : execute @@ -36,107 +25,42 @@ export const safeMap = (...args) => { export const safeFilter = (...args) => { const immediate = Array.isArray(args[0]) - const [items, predicate, opts = {}] = immediate ? args : [null, args[0], args[1]] + const [items, predicate, opts] = immediate ? args : [null, args[0], args[1]] const execute = async inputItems => { - const {onError = failFast} = opts + const {onError = failFast} = opts || {} const results = [] const errors = [] for await (const [index, item] of inputItems.entries()) { try { - const keep = await Promise.resolve(predicate(item, index)) + const keep = await predicate(item, index) if (keep) results.push(item) } catch (error) { if (onError === failFast) return {results, errors, failure: {item, error}} - if (onError === none) - continue errors.push({item, error}) } } - // Return plain array for 'none' strategy, structured result otherwise - if (onError === none) - return results return {results, errors, failure: null} } return immediate ? execute(items) : execute } - -// OLD mapSeries, now rebuilt as a special case of safeMap -// export const mapSeries = async (array, asyncFn, {limit} = {}) => { -// const results = [] -// let count = 0 -// for await (const item of array) { -// if (limit && count >= limit) { -// break -// } -// results.push(await asyncFn(item)) -// count += 1 -// } -// return results -// } - -export const mapSeries = (array, asyncFn, {limit} = {}) => - safeMap(array, asyncFn, {onError: none, limit}) - -export const safePipe = (...steps) => async items => { - const allErrors = [] - let current = items - for await (const step of steps) { - const {results, errors, failure} = await step(current) - allErrors.push(...errors) - if (failure) - return {results, errors: allErrors, failure} - current = results - } - return {results: current, errors: allErrors, failure: null} -} - -export async function * safeAsyncIterator (iterable, transform, { - onError = failFast, -} = {}) { - for await (const item of iterable) { - try { - // slightly stronger sync support - yield await Promise.resolve(transform(item)) - } catch (error) { - if (onError === failFast) - throw error - if (onError === skip) - continue - if (onError === collect) - yield {error, item} - } - } -} - -// export const scanSeries = async (iterable, scanner, initialValue) => { -// const results = [] -// let acc = initialValue -// for await (const item of iterable) { -// acc = await scanner(acc, item) -// results.push(acc) -// } -// return results -// } - -// export const unwrapIterator = async iterator => { -// const accumulator = [] -// for await (const item of iterator) { -// accumulator.push(item) -// } -// return accumulator -// } - -export const collectAsync = async (iterable, {onError = none} = {}) => { +export const mapSeries = async (array, asyncFn, {limit} = {}) => { const results = [] - for await (const item of safeAsyncIterator(iterable, x => x, {onError})) { - results.push(item) + let count = 0 + for await (const item of array) { + if (limit && count >= limit) { + break + } + results.push(await asyncFn(item)) + count += 1 } return results } +// export const mapSeries = (array, asyncFn, {limit} = {}) => +// safeMap(array, asyncFn, {onError: none, limit}) -export const safeReduce = async (iterable, scanner, {initialValue} = {}) => { +export const scanSeries = async (iterable, scanner, initialValue) => { const results = [] let acc = initialValue for await (const item of iterable) { @@ -146,17 +70,31 @@ export const safeReduce = async (iterable, scanner, {initialValue} = {}) => { return results } -export const unwrapIterator = collectAsync -export const scanSeries = safeReduce +export const unwrapIterator = async iterator => { + const accumulator = [] + for await (const item of iterator) { + accumulator.push(item) + } + return accumulator +} + +export const pipe = (...fns) => input => + fns.reduce((acc, fn) => fn(acc), input) + +export const pipeAsync = (...fns) => input => + fns.reduce(async (acc, fn) => fn(await acc), input) export const tryCatch = (fn, { onStart, onSuccess, onError, onFinally, } = {}) => async (...args) => { + // console.info('TRYCATCH |') try { if (onStart) onStart() + // console.info('TRYCATCH | Started |', {args, onError}) const result = await fn(...args) + // console.info('TRYCATCH | Result |', {result}) if (onSuccess) await onSuccess(result) return result diff --git a/tests/pipe-async.test.js b/tests/pipe-async.test.js new file mode 100644 index 0000000..dc019cd --- /dev/null +++ b/tests/pipe-async.test.js @@ -0,0 +1,27 @@ +import {test, expect} from 'vitest' +import {pipeAsync} from '$lib/functional' + +test('composes functions left-to-right', async () => { + const result = await pipeAsync(x => x * 2, x => x + 1)(5) + expect(result).toBe(11) +}) + +test('passes result through async chain', async () => { + const result = await pipeAsync( + x => Promise.resolve(x + 1), + x => Promise.resolve(x * 3), + )(2) + expect(result).toBe(9) +}) + +test('works with single function', async () => { + await expect(pipeAsync(x => x * 10)(4)).resolves.toBe(40) +}) + +test('propagates errors', async () => { + const pipeline = pipeAsync( + () => { throw new Error('pipe broke') }, + x => x + 1, + ) + await expect(pipeline(1)).rejects.toThrow('pipe broke') +}) diff --git a/tests/pipe.test.js b/tests/pipe.test.js new file mode 100644 index 0000000..2fe21fb --- /dev/null +++ b/tests/pipe.test.js @@ -0,0 +1,28 @@ +import {test, expect} from 'vitest' +import {pipe} from '$lib/functional' + +test('composes functions left-to-right', () => { + const result = pipe(x => x * 2, x => x + 1)(5) + expect(result).toBe(11) +}) + +test('passes result through chain', () => { + const result = pipe( + x => x + 1, + x => x * 3, + x => x - 2, + )(2) + expect(result).toBe(7) +}) + +test('works with single function', () => { + expect(pipe(x => x * 10)(4)).toBe(40) +}) + +test('propagates errors', () => { + const pipeline = pipe( + () => { throw new Error('pipe broke') }, + x => x + 1, + ) + expect(() => pipeline(1)).toThrow('pipe broke') +}) diff --git a/tests/safe-async-iterator.test.js b/tests/safe-async-iterator.test.js deleted file mode 100644 index c0b46b5..0000000 --- a/tests/safe-async-iterator.test.js +++ /dev/null @@ -1,344 +0,0 @@ -import {test, expect} from 'vitest' -import { - safeAsyncIterator, failFast, skip, collect, unwrapIterator, -} from '$lib/functional' - -test('safeAsyncIterator: yields transformed items', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator(source(), x => x * 2) - const results = await unwrapIterator(iterator) - expect(results).toEqual([2, 4, 6]) -}) - -test('safeAsyncIterator: works with async transform', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - async x => { - await new Promise(resolve => setTimeout(resolve, 10)) - return x * 2 - } - ) - const results = await unwrapIterator(iterator) - expect(results).toEqual([2, 4, 6]) -}) - -test('safeAsyncIterator: works with arrays', async () => { - const iterator = safeAsyncIterator([1, 2, 3], x => x + 10) - const results = await unwrapIterator(iterator) - expect(results).toEqual([11, 12, 13]) -}) - -// ============================================================================ -// ERROR HANDLING - failFast -// ============================================================================ - -test('safeAsyncIterator: failFast throws on first error', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - x => { - if (x === 2) - throw new Error('error at 2') - return x * 2 - }, - {onError: failFast} - ) - - const results = [] - let error = null - try { - for await (const item of iterator) { - results.push(item) - } - } catch (e) { - error = e - } - - expect(results).toEqual([2]) - expect(error).not.toBeNull() - expect(error.message).toBe('error at 2') -}) - -test('safeAsyncIterator: failFast with async transform', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - async x => { - if (x === 2) - throw new Error('async error') - return x * 2 - }, - {onError: failFast} - ) - - const results = [] - let error = null - try { - for await (const item of iterator) { - results.push(item) - } - } catch (e) { - error = e - } - - expect(results).toEqual([2]) - expect(error.message).toBe('async error') -}) - -// ============================================================================ -// ERROR HANDLING - skip -// ============================================================================ - -test('safeAsyncIterator: skip continues on error', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - yield 4 - } - - const iterator = safeAsyncIterator( - source(), - x => { - if (x === 2 || x === 4) - throw new Error('skip this') - return x * 2 - }, - {onError: skip} - ) - - const results = await unwrapIterator(iterator) - expect(results).toEqual([2, 6]) -}) - -test('safeAsyncIterator: skip with async transform', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - async x => { - if (x === 2) - throw new Error('skip') - return x * 10 - }, - {onError: skip} - ) - - const results = await unwrapIterator(iterator) - expect(results).toEqual([10, 30]) -}) - -// ============================================================================ -// ERROR HANDLING - collect -// ============================================================================ - -test('safeAsyncIterator: collect yields error objects', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - x => { - if (x === 2) - throw new Error('error at 2') - return x * 2 - }, - {onError: collect} - ) - - const results = await unwrapIterator(iterator) - expect(results).toHaveLength(3) - expect(results[0]).toBe(2) - expect(results[1]).toEqual({error: expect.any(Error), item: 2}) - expect(results[1].error.message).toBe('error at 2') - expect(results[2]).toBe(6) -}) - -test('safeAsyncIterator: collect multiple errors', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - yield 4 - } - - const iterator = safeAsyncIterator( - source(), - x => { - if (x % 2 === 0) - throw new Error(`error at ${x}`) - return x * 10 - }, - {onError: collect} - ) - - const results = await unwrapIterator(iterator) - expect(results).toHaveLength(4) - expect(results[0]).toBe(10) - expect(results[1]).toEqual({error: expect.any(Error), item: 2}) - expect(results[2]).toBe(30) - expect(results[3]).toEqual({error: expect.any(Error), item: 4}) -}) - -// ============================================================================ -// LAZY EVALUATION (core generator benefit) -// ============================================================================ - -test('safeAsyncIterator: lazy evaluation - stops early', async () => { - const calls = [] - async function * source () { - for (let i = 1; i <= 10; i++) { - calls.push(i) - yield i - } - } - - const iterator = safeAsyncIterator(source(), x => x * 2) - - const results = [] - let count = 0 - for await (const item of iterator) { - results.push(item) - count += 1 - if (count === 3) - break - } - - expect(results).toEqual([2, 4, 6]) - expect(calls).toEqual([1, 2, 3]) -}) - -test('safeAsyncIterator: lazy evaluation with map then filter pattern', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - yield 4 - yield 5 - } - - const mapped = safeAsyncIterator(source(), x => x * 2) - const filtered = safeAsyncIterator( - mapped, - x => x > 4 ? x : undefined - ) - - const results = [] - for await (const item of filtered) { - if (item !== undefined) - results.push(item) - } - - expect(results).toEqual([6, 8, 10]) -}) - -// ============================================================================ -// PRACTICAL SCENARIOS -// ============================================================================ - -test('safeAsyncIterator: processing file-like stream with enrichment', async () => { - async function * fileStream () { - yield {id: 1, name: 'file1.txt'} - yield {id: 2, name: 'file2.txt'} - yield {id: 3, name: 'file3.txt'} - } - - const mockFetchMetadata = async id => { - if (id === 2) - throw new Error('API error') - return {id, size: id * 100} - } - - const enriched = safeAsyncIterator( - fileStream(), - async item => ({ - ...item, - meta: await mockFetchMetadata(item.id), - }), - {onError: collect} - ) - - const results = await unwrapIterator(enriched) - expect(results).toHaveLength(3) - expect(results[0]).toEqual({id: 1, name: 'file1.txt', meta: {id: 1, size: 100}}) - expect(results[1]).toEqual({error: expect.any(Error), item: {id: 2, name: 'file2.txt'}}) - expect(results[2]).toEqual({id: 3, name: 'file3.txt', meta: {id: 3, size: 300}}) -}) - -test('safeAsyncIterator: can be composed with filter', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - yield 4 - yield 5 - } - - const mapped = safeAsyncIterator(source(), x => x * 2) - - const results = [] - for await (const item of mapped) { - if (item > 4) - results.push(item) - } - - expect(results).toEqual([6, 8, 10]) -}) - -test('safeAsyncIterator: default error strategy is failFast', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - x => { - if (x === 2) - throw new Error('error') - return x * 2 - } - ) - - const results = [] - let error = null - try { - for await (const item of iterator) { - results.push(item) - } - } catch (e) { - error = e - } - - expect(results).toEqual([2]) - expect(error).not.toBeNull() -}) diff --git a/tests/safe-map-limit.test.js b/tests/safe-map-limit.test.js deleted file mode 100644 index 86e47d9..0000000 --- a/tests/safe-map-limit.test.js +++ /dev/null @@ -1,200 +0,0 @@ -import {test, expect} from 'vitest' -import { - safeMap, skip, collect, failFast, -} from '$lib/functional' - -test('safeMap: immediate call with sync function', async () => { - const {results, errors, failure} = await safeMap( - [1, 2, 3], - x => x * 2 - ) - expect(results).toEqual([2, 4, 6]) - expect(errors).toEqual([]) - expect(failure).toBeNull() -}) - -test('safeMap: immediate call with async function', async () => { - const {results, errors, failure} = await safeMap( - [1, 2, 3], - async x => { - await new Promise(resolve => setTimeout(resolve, 10)) - return x * 2 - } - ) - expect(results).toEqual([2, 4, 6]) - expect(errors).toEqual([]) - expect(failure).toBeNull() -}) - -test('safeMap: curried usage', async () => { - const double = safeMap(x => x * 2) - const {results} = await double([1, 2, 3]) - expect(results).toEqual([2, 4, 6]) -}) - -test('safeMap: receives item and index', async () => { - const {results} = await safeMap( - [10, 20, 30], - (item, index) => ({item, index}) - ) - expect(results).toEqual([ - {item: 10, index: 0}, - {item: 20, index: 1}, - {item: 30, index: 2}, - ]) -}) - -// ============================================================================ -// LIMIT OPTION -// ============================================================================ - -test('safeMap: respects limit option (positive)', async () => { - const {results, errors, failure} = await safeMap( - [1, 2, 3, 4, 5], - item => item * 10, - {limit: 3} - ) - expect(results).toEqual([10, 20, 30]) - expect(errors).toEqual([]) - expect(failure).toBeNull() -}) - -test('safeMap: limit 1 processes only first item', async () => { - const {results} = await safeMap( - [1, 2, 3, 4, 5], - x => x * 10, - {limit: 1} - ) - expect(results).toEqual([10]) -}) - -test('safeMap: limit 0 returns empty results', async () => { - const {results} = await safeMap( - [1, 2, 3, 4, 5], - x => x * 10, - {limit: 0} - ) - expect(results).toEqual([]) -}) - -test('safeMap: negative limit returns empty results', async () => { - const {results} = await safeMap( - [1, 2, 3, 4, 5], - x => x * 10, - {limit: -1} - ) - expect(results).toEqual([]) -}) - -test('safeMap: limit greater than array length processes all', async () => { - const {results} = await safeMap( - [1, 2, 3], - x => x * 10, - {limit: 100} - ) - expect(results).toEqual([10, 20, 30]) -}) - -test('safeMap: limit with async function', async () => { - const {results} = await safeMap( - [1, 2, 3, 4, 5], - async x => { - await new Promise(resolve => setTimeout(resolve, 5)) - return x * 10 - }, - {limit: 2} - ) - expect(results).toEqual([10, 20]) -}) - -// ============================================================================ -// ERROR STRATEGIES WITH LIMIT -// ============================================================================ - -test('safeMap: failFast with limit stops at error', async () => { - const {results, failure} = await safeMap( - [1, 2, 3, 4, 5], - x => { - if (x === 2) - throw new Error('error at 2') - return x * 10 - }, - {limit: 5, onError: failFast} - ) - expect(results).toEqual([10]) - expect(failure).not.toBeNull() - expect(failure.item).toBe(2) -}) - -test('safeMap: skip with limit continues past error', async () => { - const {results, errors} = await safeMap( - [1, 2, 3, 4, 5], - x => { - if (x === 2 || x === 4) - throw new Error(`error at ${x}`) - return x * 10 - }, - {limit: 5, onError: skip} - ) - expect(results).toEqual([10, 30, 50]) - expect(errors).toHaveLength(2) -}) - -test('safeMap: collect with limit includes error objects', async () => { - const {results, errors} = await safeMap( - [1, 2, 3, 4, 5], - x => { - if (x % 2 === 0) - throw new Error(`even: ${x}`) - return x * 10 - }, - {limit: 5, onError: collect} - ) - expect(results).toEqual([10, 30, 50]) - expect(errors).toHaveLength(2) -}) - -test('safeMap: limit applies before error strategy evaluation', async () => { - const {results, errors} = await safeMap( - [1, 2, 3, 4, 5], - x => { - if (x > 2) - throw new Error(`error at ${x}`) - return x * 10 - }, - {limit: 3, onError: collect} - ) - // Only processes first 3 items [1, 2, 3] - expect(results).toEqual([10, 20]) - expect(errors).toHaveLength(1) -}) - -// ============================================================================ -// EDGE CASES -// ============================================================================ - -test('safeMap: empty array', async () => { - const {results, errors, failure} = await safeMap([], x => x * 2) - expect(results).toEqual([]) - expect(errors).toEqual([]) - expect(failure).toBeNull() -}) - -test('safeMap: empty array with limit', async () => { - const {results} = await safeMap([], x => x * 2, {limit: 5}) - expect(results).toEqual([]) -}) - -test('safeMap: limit 0 with error strategy', async () => { - const {results, errors} = await safeMap( - [1, 2, 3], - x => { - if (x === 1) - throw new Error('error') - return x * 10 - }, - {limit: 0, onError: collect} - ) - expect(results).toEqual([]) - expect(errors).toEqual([]) -}) diff --git a/tests/safe-pipe.test.js b/tests/safe-pipe.test.js deleted file mode 100644 index faafc48..0000000 --- a/tests/safe-pipe.test.js +++ /dev/null @@ -1,80 +0,0 @@ -import {test, expect} from 'vitest' -import { - safePipe, safeMap, safeFilter, skip, -} from '$lib/functional' - -test('chains two safeMap steps threading results through', async () => { - const pipeline = safePipe( - safeMap(x => x * 2), - safeMap(x => x + 1), - ) - const result = await pipeline([1, 2, 3]) - expect(result).toEqual({results: [3, 5, 7], errors: [], failure: null}) -}) - -test('errors from all steps merge into single errors array', async () => { - const bang1 = new Error('step1') - const bang2 = new Error('step2') - const pipeline = safePipe( - safeMap(x => { - if (x === 2) - throw bang1 - return x - }, {onError: skip}), - safeMap(x => { - if (x === 3) - throw bang2 - return x * 10 - }, {onError: skip}), - ) - const result = await pipeline([1, 2, 3]) - expect(result.results).toEqual([10]) - expect(result.errors).toEqual([ - {item: 2, error: bang1}, - {item: 3, error: bang2}, - ]) - expect(result.failure).toBeNull() -}) - -test('failure in step 1 skips step 2 and returns failure', async () => { - const bang = new Error('boom') - const step2Called = [] - const pipeline = safePipe( - safeMap(x => { - if (x === 2) - throw bang - return x - }), - safeMap(x => { - step2Called.push(x) - return x - }), - ) - const result = await pipeline([1, 2, 3]) - expect(result.failure).toEqual({item: 2, error: bang}) - expect(result.results).toEqual([1]) - expect(step2Called).toEqual([]) -}) - -test('works with mixed safeMap and safeFilter steps', async () => { - const pipeline = safePipe( - safeMap(x => x * 2), - safeFilter(x => x > 3), - ) - const result = await pipeline([1, 2, 3]) - expect(result).toEqual({results: [4, 6], errors: [], failure: null}) -}) - -test('single step pipeline', async () => { - const pipeline = safePipe( - safeMap(x => x + 1), - ) - const result = await pipeline([10, 20]) - expect(result).toEqual({results: [11, 21], errors: [], failure: null}) -}) - -test('empty pipeline returns items as results', async () => { - const pipeline = safePipe() - const result = await pipeline([1, 2, 3]) - expect(result).toEqual({results: [1, 2, 3], errors: [], failure: null}) -}) diff --git a/tests/safe-reduce.test.js b/tests/safe-reduce.test.js deleted file mode 100644 index 46fd112..0000000 --- a/tests/safe-reduce.test.js +++ /dev/null @@ -1,213 +0,0 @@ -import {test, expect} from 'vitest' -import {safeReduce, skip, collect} from '$lib/functional' - -// ============================================================================ -// WITH INITIAL VALUE -// ============================================================================ - -test('safeReduce: threads accumulator through items', async () => { - const results = await safeReduce( - [1, 2, 3], - (acc, item) => Promise.resolve(acc + item), - {initialValue: 0} - ) - expect(results).toEqual([1, 3, 6]) -}) - -test('safeReduce: returns intermediate results', async () => { - const results = await safeReduce( - ['a', 'b', 'c'], - (acc, item) => Promise.resolve(acc + item), - {initialValue: ''} - ) - expect(results).toEqual(['a', 'ab', 'abc']) -}) - -test('safeReduce: works with async reducer', async () => { - const results = await safeReduce( - [10, 20], - async (acc, item) => { - await new Promise(resolve => setTimeout(resolve, 1)) - return acc + item - }, - {initialValue: 0} - ) - expect(results).toEqual([10, 30]) -}) - -test('safeReduce: works with sync reducer', async () => { - const results = await safeReduce( - [1, 2, 3], - (acc, item) => acc * item, - {initialValue: 1} - ) - expect(results).toEqual([1, 2, 6]) -}) - -// ============================================================================ -// WITHOUT INITIAL VALUE -// ============================================================================ - -test.skip('safeReduce: uses first item as initial when not provided', async () => { - const results = await safeReduce( - [1, 2, 3, 4], - (acc, item) => acc + item - ) - expect(results).toEqual([1, 3, 6, 10]) -}) - -test.skip('safeReduce: single item without initialValue', async () => { - const results = await safeReduce( - [42], - (acc, item) => acc + item - ) - expect(results).toEqual([42]) -}) - -test.skip('safeReduce: string concatenation without initialValue', async () => { - const results = await safeReduce( - ['hello', ' ', 'world'], - (acc, item) => acc + item - ) - expect(results).toEqual(['hello', 'hello ', 'hello world']) -}) - -test('safeReduce: object merging without initialValue', async () => { - const results = await safeReduce( - [{a: 1}, {b: 2}, {c: 3}], - (acc, item) => ({...acc, ...item}) - ) - expect(results).toEqual([ - {a: 1}, - {a: 1, b: 2}, - {a: 1, b: 2, c: 3}, - ]) -}) - -test.skip('safeReduce: async reducer without initialValue', async () => { - const results = await safeReduce( - [1, 2, 3], - async (acc, item) => { - await new Promise(resolve => setTimeout(resolve, 1)) - return acc + item - } - ) - expect(results).toEqual([1, 3, 6]) -}) - -// ============================================================================ -// ERROR STRATEGIES WITH INITIAL VALUE -// ============================================================================ - -test.skip('safeReduce: skip strategy tracks errors but continues', async () => { - const results = await safeReduce( - [1, 2, 3], - (acc, item) => { - if (item === 2) - throw new Error('error') - return acc + item - }, - {initialValue: 0, onError: skip} - ) - expect(results.results).toEqual([1, 4]) - expect(results.errors).toHaveLength(1) -}) - -test.skip('safeReduce: collect strategy yields error objects', async () => { - const results = await safeReduce( - [1, 2, 3], - (acc, item) => { - if (item === 2) - throw new Error('error at 2') - return acc + item - }, - {initialValue: 0, onError: collect} - ) - expect(results).toHaveLength(3) - expect(results[0]).toBe(1) - expect(results[1]).toEqual({error: expect.any(Error), item: 2}) - expect(results[2]).toBe(4) -}) - -// ============================================================================ -// ERROR STRATEGIES WITHOUT INITIAL VALUE -// ============================================================================ - -test.skip('safeReduce: collect strategy without initialValue', async () => { - const results = await safeReduce( - ['a', 'b', 'c'], - (acc, item) => { - if (item === 'b') - throw new Error('error') - return acc + item - }, - {onError: collect} - ) - expect(results).toHaveLength(3) - expect(results[0]).toBe('a') - expect(results[1]).toEqual({error: expect.any(Error), item: 'b'}) - expect(results[2]).toBe('ac') -}) - -// ============================================================================ -// EDGE CASES -// ============================================================================ - -test('safeReduce: empty iterable with initialValue', async () => { - const results = await safeReduce( - [], - (acc, item) => acc + item, - {initialValue: 0} - ) - expect(results).toEqual([]) -}) - -test('safeReduce: empty iterable without initialValue', async () => { - const results = await safeReduce( - [], - (acc, item) => acc + item - ) - expect(results).toEqual([]) -}) - -test('safeReduce: works with async generators', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const results = await safeReduce( - source(), - (acc, item) => acc + item, - {initialValue: 0} - ) - expect(results).toEqual([1, 3, 6]) -}) - -test('safeReduce: initialValue of 0 works correctly', async () => { - const results = await safeReduce( - [1, 2, 3], - (acc, item) => acc + item, - {initialValue: 0} - ) - expect(results).toEqual([1, 3, 6]) -}) - -test('safeReduce: initialValue of empty string works correctly', async () => { - const results = await safeReduce( - ['a', 'b'], - (acc, item) => acc + item, - {initialValue: ''} - ) - expect(results).toEqual(['a', 'ab']) -}) - -test('safeReduce: initialValue of false works correctly', async () => { - const results = await safeReduce( - [true, false, true], - (acc, item) => acc || item, - {initialValue: false} - ) - expect(results).toEqual([true, true, true]) -}) diff --git a/tests/scan-series.test.js b/tests/scan-series.test.js new file mode 100644 index 0000000..a4fdef2 --- /dev/null +++ b/tests/scan-series.test.js @@ -0,0 +1,32 @@ +import {test, expect} from 'vitest' +import {scanSeries} from '$lib/functional' + +test('threads accumulator through items', async () => { + const results = await scanSeries( + [1, 2, 3], + (acc, item) => Promise.resolve(acc + item), + 0, + ) + expect(results).toEqual([1, 3, 6]) +}) + +test('returns intermediate results', async () => { + const results = await scanSeries( + ['a', 'b', 'c'], + (acc, item) => Promise.resolve(acc + item), + '', + ) + expect(results).toEqual(['a', 'ab', 'abc']) +}) + +test('works with async scanner', async () => { + const results = await scanSeries( + [10, 20], + async (acc, item) => { + await new Promise(resolve => setTimeout(resolve, 1)) + return acc + item + }, + 0, + ) + expect(results).toEqual([10, 30]) +}) From 4c9b8a33ee25d015644630dac66cf83c654144bc Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:59:00 +0100 Subject: [PATCH 03/26] Add pipelean redesign specification Design for iterator-first library with pipeline error management. Core principles: - Iterator-first transformations - Pipeline-level error strategies (failFast, collect, notify) - Pure operations (map, filter, reduce) - Unified composition Co-Authored-By: Claude Haiku 4.5 --- .../2026-03-16-pipelean-redesign-design.md | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-16-pipelean-redesign-design.md diff --git a/docs/superpowers/specs/2026-03-16-pipelean-redesign-design.md b/docs/superpowers/specs/2026-03-16-pipelean-redesign-design.md new file mode 100644 index 0000000..3b77c06 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-pipelean-redesign-design.md @@ -0,0 +1,233 @@ +# Pipelean Redesign Specification + +## Overview + +Pipelean is being redesigned as an iterator-first library for composing async operations with consistent error handling. The core insight is that error management belongs at the pipeline level, not individual operations. + +## Core Principles + +1. **Iterator-First**: All transformations work on async iterators +2. **Pipeline Error Management**: Error strategies are applied at pipeline level +3. **Pure Operations**: Individual operations (map, filter, reduce) succeed or throw +4. **Unified Composition**: Single way to compose operations regardless of input type + +## API Design + +### Error Strategies + +Three error strategies control how errors are handled: + +1. **`failFast`** (default): Stop iteration and throw on first error +2. **`collect`**: Continue iteration, accumulate errors in final result +3. **`notify`**: Continue iteration, accumulate errors, and call notification callback + +### Core Functions + +#### `map(fn)` +Creates a mapping iterator transformation. + +```javascript +const double = map(x => x * 2) +// Returns: async iterator transformer +``` + +#### `filter(predicate)` +Creates a filtering iterator transformation. + +```javascript +const evenOnly = filter(x => x % 2 === 0) +``` + +#### `reduce(fn, initial)` +Creates a reducing iterator transformation. + +```javascript +const sum = reduce((acc, x) => acc + x, 0) +``` + +#### `compose(...transformers)` +Composes multiple iterator transformations. + +```javascript +const pipeline = compose( + map(x => x * 2), + filter(x => x > 5), + map(async x => ({value: x, meta: await fetchMeta(x)})) +) +``` + +#### `collect(iterator, options)` +Collects results from an iterator with error handling. + +```javascript +const {results, errors} = await collect(iterator, { + onError: 'collect', // or 'failFast', 'notify' + notify: (error, item) => console.error('Error:', error) // for 'notify' strategy +}) +``` + +### Usage Examples + +#### Basic Pipeline + +```javascript +import {map, filter, compose, collect} from 'pipelean' + +const pipeline = compose( + map(x => x * 2), + filter(x => x > 5) +) + +// From array +const data = [1, 2, 3, 4, 5] +const iterator = pipeline(data, {onError: 'collect'}) +const {results, errors} = await collect(iterator) + +// results: [6, 8, 10] +// errors: [] (if no errors) +``` + +#### Async Operations with Error Handling + +```javascript +const pipeline = compose( + map(async x => { + if (x === 3) throw new Error('Bad value') + return x * 2 + }), + filter(x => x > 0) +) + +const iterator = pipeline([1, 2, 3, 4], {onError: 'collect'}) +const {results, errors} = await collect(iterator) + +// results: [2, 4, 8] (skipped x=3 due to error) +// errors: [{item: 3, error: Error('Bad value')}] +``` + +#### Notification Strategy + +```javascript +const iterator = pipeline(data, { + onError: 'notify', + notify: (error, item) => { + console.log(`Error processing ${item}:`, error.message) + // Could send to monitoring service + } +}) + +const {results, errors} = await collect(iterator) +// errors still contains all errors +// notify callback was called for each error as it occurred +``` + +#### Lazy Iteration + +```javascript +const iterator = pipeline(largeDataset, {onError: 'collect'}) + +for await (const item of iterator) { + // Process items as they come + // Errors are handled according to strategy + // For 'collect' strategy, failed items are skipped + // For 'notify' strategy, errors are logged via callback + console.log(item) +} +``` + +### Implementation Details + +#### Iterator Transformation Signature + +```javascript +// Each transformer is a function that takes options and returns an async generator +function map(fn) { + return async function* (source, options) { + for await (const item of source) { + try { + yield await fn(item) + } catch (error) { + if (options.onError === 'failFast') { + throw error + } + // For 'collect' and 'notify', skip the item + // Error is accumulated in the collector + if (options.onError === 'notify' && options.notify) { + options.notify(error, item) + } + // Yield a sentinel or let collector track errors + } + } + } +} +``` + +#### Composition Implementation + +```javascript +function compose(...transformers) { + return async function* (source, options) { + let current = source + for (const transformer of transformers) { + current = transformer(current, options) + } + yield* current + } +} +``` + +#### Collector Implementation + +```javascript +async function collect(iterator, options = {}) { + const results = [] + const errors = [] + const {onError = 'failFast', notify} = options + + try { + for await (const item of iterator) { + results.push(item) + } + } catch (error) { + if (onError === 'failFast') { + throw error + } + // For iterator-based error handling, errors are tracked differently + } + + return {results, errors} +} +``` + +### Migration from Current API + +| Current API | New API | +|-------------|---------| +| `safeMap(array, fn, {onError})` | `collect(compose(map(fn))(array), {onError})` | +| `safeFilter(array, pred, {onError})` | `collect(compose(filter(pred))(array), {onError})` | +| `safePipe(op1, op2, op3)` | `compose(op1, op2, op3)` | +| `safeAsyncIterator(iterable, fn, {onError})` | `compose(map(fn))(iterable, {onError})` | +| `collectAsync(iterable, {onError})` | `collect(iterable, {onError})` | + +### Benefits + +1. **Clean Separation**: Operations are pure, error handling is compositional +2. **Consistent API**: Same pattern works for arrays, iterators, and streams +3. **Flexible Error Handling**: Strategies can be extended without changing operations +4. **Memory Efficient**: Lazy iteration by default +5. **Easy Testing**: Pure operations are trivial to test + +### Open Questions + +1. Should `reduce` be a transformer or a terminal operation? +2. How to handle early termination (like `take` operation)? +3. Should there be convenience functions for common patterns? +4. How to integrate with existing async iterator ecosystems? + +## Next Steps + +1. Implement core transformer functions (map, filter, reduce) +2. Implement composition and collection +3. Write comprehensive tests +4. Update documentation +5. Deprecate old API gradually \ No newline at end of file From 119b9bed96f265e38bd68171e5d0851bad544181 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:04:06 +0100 Subject: [PATCH 04/26] prep, ready for update --- docs/ARCHITECTURE_OPTIONS.md | 265 +++++++++++++++++++++++++++++++++++ src/functional.js | 29 ++++ 2 files changed, 294 insertions(+) create mode 100644 docs/ARCHITECTURE_OPTIONS.md diff --git a/docs/ARCHITECTURE_OPTIONS.md b/docs/ARCHITECTURE_OPTIONS.md new file mode 100644 index 0000000..ff59101 --- /dev/null +++ b/docs/ARCHITECTURE_OPTIONS.md @@ -0,0 +1,265 @@ +# Pipelean Architecture Options + +Based on our analysis, here are three architectural approaches for redesigning the library. + +## Current Issues Identified + +1. **Redundant error strategies**: `skip` and `collect` are identical in implementation +2. **Error management in wrong place**: `safeMap`/`safeFilter` handle errors, but you believe errors should be managed at pipeline level +3. **Missing promised features**: `safePipe` mentioned in README but not implemented +4. **Inconsistent naming**: `unwrapIterator` vs `collectAsync`, `mapSeries` incomplete + +## Option 1: Pipeline-Centric Approach + +**Core Idea**: Individual operations are pure (succeed or throw), pipelines manage errors and composition. + +### Example Usage + +```javascript +// Pure operations that succeed or throw +const double = map(x => x * 2) +const filterEven = filter(x => x % 2 === 0) +const asyncEnrich = map(async x => ({...x, meta: await fetch(x.id)})) + +// Pipeline with error strategy +const pipeline = pipe( + double, + filterEven, + asyncEnrich +).withStrategy('collect') // or 'failFast', 'collectWithNotify' + +// Execute with error handling +const {results, errors, failure} = await pipeline([1, 2, 3, 4, 5]) + +// Or with iterator +for await (const result of pipeline.asIterator([1, 2, 3])) { + // yields transformed items or error objects based on strategy +} +``` + +### Implementation Sketch + +```javascript +// Pure operation +const map = (fn) => (item) => fn(item) + +// Pipeline builder +const pipe = (...operations) => { + const execute = async (items, strategy = 'failFast') => { + const results = [] + const errors = [] + + for await (const item of items) { + try { + let value = item + for (const op of operations) { + value = await op(value) + } + results.push(value) + } catch (error) { + if (strategy === 'failFast') { + return {results, errors, failure: {item, error}} + } + errors.push({item, error}) + if (strategy === 'collect') { + // skip item + } else if (strategy === 'collectWithNotify') { + // call notification callback + } + } + } + return {results, errors, failure: null} + } + + return { + withStrategy: (strategy) => (items) => execute(items, strategy), + asIterator: (strategy) => async function* (items) { /* ... */ } + } +} +``` + +**Pros**: +- Clean separation of concerns +- Operations are simple and testable +- Easy to add new error strategies +- Consistent with functional programming principles + +**Cons**: +- More boilerplate for simple cases +- Error handling detached from operation logic + +## Option 2: Unified Transformer Approach + +**Core Idea**: Single configurable transformation function that can behave as map, filter, reduce, etc. + +### Example Usage + +```javascript +// Configure as different operations +const double = transform({ + type: 'map', + fn: x => x * 2, + onError: 'skip' // error strategy per operation +}) + +const filterEven = transform({ + type: 'filter', + fn: x => x % 2 === 0, + onError: 'collect' +}) + +const sum = transform({ + type: 'reduce', + fn: (acc, x) => acc + x, + initial: 0, + onError: 'failFast' +}) + +// Compose transforms +const pipeline = compose(double, filterEven, sum) + +// Execute +const result = await pipeline([1, 2, 3, 4, 5]) +// result = {value: 12, errors: [], failure: null} +``` + +### Implementation Sketch + +```javascript +const transform = (config) => { + const {type, fn, onError = 'failFast', ...rest} = config + + return async (input) => { + if (type === 'map') { + return transformMap(input, fn, onError) + } else if (type === 'filter') { + return transformFilter(input, fn, onError) + } else if (type === 'reduce') { + return transformReduce(input, fn, onError, rest.initial) + } + } +} + +const compose = (...transforms) => async (input) => { + let current = input + const allErrors = [] + + for (const t of transforms) { + const result = await t(current) + if (result.failure) return {value: null, errors: allErrors, failure: result.failure} + allErrors.push(...result.errors) + current = result.value + } + + return {value: current, errors: allErrors, failure: null} +} +``` + +**Pros**: +- Single API to learn +- Highly configurable +- Consistent error handling across operation types +- Easy to extend with new operation types + +**Cons**: +- Configuration over convention +- Less intuitive than named functions +- Type checking more complex + +## Option 3: Iterator-First Approach + +**Core Idea**: Everything is an async iterator transformation, with error strategies built into the iteration protocol. + +### Example Usage + +```javascript +// Create transforming iterators +const doubleIterator = mapIterator(x => x * 2) +const filterEvenIterator = filterIterator(x => x % 2 === 0) + +// Compose iterators +const pipelineIterator = composeIterators( + doubleIterator, + filterEvenIterator +) + +// Use with error strategy +const iterator = pipelineIterator([1, 2, 3, 4, 5], {onError: 'collect'}) + +// Iterate with errors handled +for await (const item of iterator) { + // item is either transformed value or {error, originalItem} + if (item.error) { + console.log('Error:', item.error) + continue + } + console.log('Result:', item) +} + +// Or collect all +const {results, errors} = await collectIterator(iterator) +``` + +### Implementation Sketch + +```javascript +async function* mapIterator(fn) { + for await (const item of this) { + try { + yield await fn(item) + } catch (error) { + if (this.onError === 'failFast') throw error + if (this.onError === 'skip') continue + if (this.onError === 'collect') yield {error, item} + } + } +} + +const composeIterators = (...iteratorFns) => { + return async function* (source, options) { + let current = source + for (const iteratorFn of iteratorFns) { + current = iteratorFn.call({onError: options.onError}, current) + } + yield* current + } +} + +const collectIterator = async (iterator) => { + const results = [] + const errors = [] + + for await (const item of iterator) { + if (item && item.error) { + errors.push(item) + } else { + results.push(item) + } + } + + return {results, errors} +} +``` + +**Pros**: +- Natural fit for async/streaming data +- Lazy evaluation by default +- Easy to integrate with existing async iterators +- Memory efficient for large datasets + +**Cons**: +- Iterator protocol can be unfamiliar +- More complex error handling in consumer code +- Two modes of consumption (iteration vs collection) + +## Recommendation + +Given your goals and the current codebase, I recommend a **hybrid of Options 1 and 3**: + +1. **Pipeline-centric composition** for the high-level API +2. **Iterator-based implementation** for efficiency and streaming support +3. **Clear separation**: pure operations + pipeline error management + +This aligns with your insight that "a single operation can just succeed or fail, nothing else, is the pipe that has an error strategy." + +The next step would be to design the exact API based on this hybrid approach. \ No newline at end of file diff --git a/src/functional.js b/src/functional.js index a33e559..4a46769 100644 --- a/src/functional.js +++ b/src/functional.js @@ -84,6 +84,35 @@ export const pipe = (...fns) => input => export const pipeAsync = (...fns) => input => fns.reduce(async (acc, fn) => fn(await acc), input) +export async function * safeAsyncIterator (iterable, transform, { + onError = failFast, +} = {}) { + for await (const item of iterable) { + try { + // slightly stronger sync support + yield await Promise.resolve(transform(item)) + } catch (error) { + if (onError === failFast) + throw error + if (onError === skip) + continue + if (onError === collect) + yield {error, item} + } + } +} + +export const collectAsync = async (iterable, {onError = skip} = {}) => { + const results = [] + for await (const item of safeAsyncIterator(iterable, x => x, {onError})) { + results.push(item) + } + return results +} + +// This should probably be a goal. +// export const unwrapIterator = collectAsync + export const tryCatch = (fn, { onStart, onSuccess, onError, onFinally, } = {}) => From 7efb24b991c572183914cd33daebf0f170c33510 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:08:02 +0100 Subject: [PATCH 05/26] Add pipelean redesign implementation plan Comprehensive plan for implementing iterator-first API with: - Core iterator transformations (map, filter, reduce) - Composition and pipeline functions - Error strategies (failFast, collect, notify) - Collection with error handling - Migration from legacy API Co-Authored-By: Claude Haiku 4.5 --- .../plans/2026-03-16-pipelean-redesign.md | 1085 +++++++++++++++++ 1 file changed, 1085 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-16-pipelean-redesign.md diff --git a/docs/superpowers/plans/2026-03-16-pipelean-redesign.md b/docs/superpowers/plans/2026-03-16-pipelean-redesign.md new file mode 100644 index 0000000..f35e71b --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-pipelean-redesign.md @@ -0,0 +1,1085 @@ +# Pipelean Redesign Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement iterator-first library with pipeline error management, replacing current safeMap/safeFilter approach. + +**Architecture:** Core iterator transformations (map, filter, reduce) that are pure functions, composed via `compose()`, with error strategies (failFast, collect, notify) applied at pipeline level. `collect()` function gathers results with error handling. + +**Tech Stack:** JavaScript (ESM), Vitest for testing, async iterators + +--- + +## File Structure + +### New Files +- `src/iterator-core.js` - Core iterator transformations (map, filter, reduce) +- `src/composition.js` - Composition and pipeline functions +- `src/collection.js` - Result collection with error handling +- `src/index.js` - Updated main exports +- `tests/iterator-core.test.js` - Tests for core transformations +- `tests/composition.test.js` - Tests for composition +- `tests/collection.test.js` - Tests for collection +- `tests/integration.test.js` - End-to-end tests + +### Modified Files +- `src/functional.js` - Deprecate safeMap, safeFilter, keep utilities (pipe, pipeAsync, tryCatch) +- `src/index.js` - Re-export new API +- `README.md` - Update documentation + +### Deprecated (to be removed later) +- `tests/safe-map.test.js` +- `tests/safe-filter.test.js` +- `tests/error-strategies.test.js` + +--- + +## Chunk 1: Core Iterator Transformations + +### Task 1: Create iterator-core.js with map transformation + +**Files:** +- Create: `src/iterator-core.js` +- Test: `tests/iterator-core.test.js` + +- [ ] **Step 1: Write failing test for map transformation** + +```javascript +import {test, expect} from 'vitest' +import {map} from '$lib/iterator-core' + +test('map creates a transformer that applies function to each item', async () => { + const double = map(x => x * 2) + const source = [1, 2, 3] + const iterator = double(source, {onError: 'failFast'}) + + const results = [] + for await (const item of iterator) { + results.push(item) + } + + expect(results).toEqual([2, 4, 6]) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test tests/iterator-core.test.js -t "map creates a transformer"` +Expected: FAIL with "Cannot find module '$lib/iterator-core'" + +- [ ] **Step 3: Create iterator-core.js with map function** + +```javascript +export function map(fn) { + return async function* (source, options = {}) { + const {onError = 'failFast'} = options + + for await (const item of source) { + try { + yield await fn(item) + } catch (error) { + if (onError === 'failFast') { + throw error + } + // For collect/notify strategies, skip item + // Error handling done at collection level + } + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test tests/iterator-core.test.js -t "map creates a transformer"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/iterator-core.js tests/iterator-core.test.js +git commit -m "feat: add map iterator transformation" +``` + +### Task 2: Add filter transformation + +**Files:** +- Modify: `src/iterator-core.js` +- Test: `tests/iterator-core.test.js` + +- [ ] **Step 1: Write failing test for filter transformation** + +```javascript +test('filter creates a transformer that filters items by predicate', async () => { + const evenOnly = filter(x => x % 2 === 0) + const source = [1, 2, 3, 4, 5] + const iterator = evenOnly(source, {onError: 'failFast'}) + + const results = [] + for await (const item of iterator) { + results.push(item) + } + + expect(results).toEqual([2, 4]) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test tests/iterator-core.test.js -t "filter creates a transformer"` +Expected: FAIL with "filter is not defined" + +- [ ] **Step 3: Add filter function to iterator-core.js** + +```javascript +export function filter(predicate) { + return async function* (source, options = {}) { + const {onError = 'failFast'} = options + + for await (const item of source) { + try { + const keep = await predicate(item) + if (keep) { + yield item + } + } catch (error) { + if (onError === 'failFast') { + throw error + } + // For collect/notify strategies, skip item + } + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test tests/iterator-core.test.js -t "filter creates a transformer"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/iterator-core.js tests/iterator-core.test.js +git commit -m "feat: add filter iterator transformation" +``` + +### Task 3: Add reduce transformation + +**Files:** +- Modify: `src/iterator-core.js` +- Test: `tests/iterator-core.test.js` + +- [ ] **Step 1: Write failing test for reduce transformation** + +```javascript +test('reduce creates a transformer that accumulates values', async () => { + const sum = reduce((acc, x) => acc + x, 0) + const source = [1, 2, 3, 4] + const iterator = sum(source, {onError: 'failFast'}) + + const results = [] + for await (const item of iterator) { + results.push(item) + } + + // reduce yields accumulated value after each item + expect(results).toEqual([1, 3, 6, 10]) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test tests/iterator-core.test.js -t "reduce creates a transformer"` +Expected: FAIL with "reduce is not defined" + +- [ ] **Step 3: Add reduce function to iterator-core.js** + +```javascript +export function reduce(fn, initial) { + return async function* (source, options = {}) { + const {onError = 'failFast'} = options + let accumulator = initial + + for await (const item of source) { + try { + accumulator = await fn(accumulator, item) + yield accumulator + } catch (error) { + if (onError === 'failFast') { + throw error + } + // For collect/notify strategies, skip item + } + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test tests/iterator-core.test.js -t "reduce creates a transformer"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/iterator-core.js tests/iterator-core.test.js +git commit -m "feat: add reduce iterator transformation" +``` + +--- + +## Chunk 2: Composition and Pipeline + +### Task 4: Create composition.js with compose function + +**Files:** +- Create: `src/composition.js` +- Test: `tests/composition.test.js` + +- [ ] **Step 1: Write failing test for compose function** + +```javascript +import {test, expect} from 'vitest' +import {compose} from '$lib/composition' +import {map, filter} from '$lib/iterator-core' + +test('compose chains multiple transformers', async () => { + const pipeline = compose( + map(x => x * 2), + filter(x => x > 5) + ) + + const source = [1, 2, 3, 4, 5] + const iterator = pipeline(source, {onError: 'failFast'}) + + const results = [] + for await (const item of iterator) { + results.push(item) + } + + expect(results).toEqual([6, 8, 10]) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test tests/composition.test.js -t "compose chains multiple transformers"` +Expected: FAIL with "Cannot find module '$lib/composition'" + +- [ ] **Step 3: Create composition.js with compose function** + +```javascript +export function compose(...transformers) { + return async function* (source, options = {}) { + let current = source + for (const transformer of transformers) { + current = transformer(current, options) + } + yield* current + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test tests/composition.test.js -t "compose chains multiple transformers"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/composition.js tests/composition.test.js +git commit -m "feat: add compose function for transformer composition" +``` + +### Task 5: Add error strategy propagation tests + +**Files:** +- Test: `tests/composition.test.js` + +- [ ] **Step 1: Write test for failFast error propagation** + +```javascript +test('failFast error in any transformer stops entire pipeline', async () => { + const pipeline = compose( + map(x => x * 2), + map(x => { + if (x === 6) throw new Error('Bad value') + return x + }), + filter(x => x > 0) + ) + + const source = [1, 2, 3] + const iterator = pipeline(source, {onError: 'failFast'}) + + await expect(async () => { + const results = [] + for await (const item of iterator) { + results.push(item) + } + }).rejects.toThrow('Bad value') +}) +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `npm test tests/composition.test.js -t "failFast error in any transformer"` +Expected: PASS (should already work with current implementation) + +- [ ] **Step 3: Write test for error strategy consistency** + +```javascript +test('error strategy applies to all transformers in pipeline', async () => { + const pipeline = compose( + map(x => { + if (x === 2) throw new Error('First error') + return x + }), + map(x => { + if (x === 4) throw new Error('Second error') + return x * 10 + }) + ) + + const source = [1, 2, 3, 4, 5] + // This test will be expanded when collect is implemented + const iterator = pipeline(source, {onError: 'failFast'}) + + await expect(async () => { + for await (const item of iterator) { + // Should throw on first error (x=2) + } + }).rejects.toThrow('First error') +}) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test tests/composition.test.js -t "error strategy applies to all transformers"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/composition.test.js +git commit -m "test: add error strategy propagation tests" +``` + +--- + +## Chunk 3: Collection and Error Handling + +### Task 6: Create collection.js with collect function + +**Files:** +- Create: `src/collection.js` +- Test: `tests/collection.test.js` + +- [ ] **Step 1: Write failing test for collect function** + +```javascript +import {test, expect} from 'vitest' +import {collect} from '$lib/collection' + +test('collect gathers results from iterator', async () => { + async function* simpleIterator() { + yield 1 + yield 2 + yield 3 + } + + const {results, errors} = await collect(simpleIterator()) + + expect(results).toEqual([1, 2, 3]) + expect(errors).toEqual([]) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test tests/collection.test.js -t "collect gathers results from iterator"` +Expected: FAIL with "Cannot find module '$lib/collection'" + +- [ ] **Step 3: Create collection.js with basic collect function** + +```javascript +export async function collect(iterator, options = {}) { + const {onError = 'failFast'} = options + const results = [] + const errors = [] + + try { + for await (const item of iterator) { + results.push(item) + } + } catch (error) { + if (onError === 'failFast') { + throw error + } + // For collect/notify strategies, handle differently + } + + return {results, errors} +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test tests/collection.test.js -t "collect gathers results from iterator"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/collection.js tests/collection.test.js +git commit -m "feat: add basic collect function" +``` + +### Task 7: Implement collect error strategy + +**Files:** +- Modify: `src/collection.js` +- Test: `tests/collection.test.js` + +- [ ] **Step 1: Write test for collect error strategy** + +```javascript +test('collect strategy accumulates errors and continues', async () => { + async function* errorIterator() { + yield 1 + throw new Error('Test error') + // Note: iterator stops after throw, need different approach + } + + // This test will need adjustment based on implementation + const iterator = errorIterator() + const {results, errors} = await collect(iterator, {onError: 'collect'}) + + expect(results).toEqual([1]) + expect(errors).toHaveLength(1) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test tests/collection.test.js -t "collect strategy accumulates errors"` +Expected: FAIL (iterator stops on throw) + +- [ ] **Step 3: Update collect to handle errors from transformers** + +We need to change approach: errors should be caught at transformer level, not iterator level. Update the test first: + +```javascript +test('collect strategy accumulates errors from failing transformations', async () => { + const {map} = await import('$lib/iterator-core') + const {compose} = await import('$lib/composition') + + const pipeline = compose( + map(x => { + if (x === 2) throw new Error('Bad value: ' + x) + return x * 10 + }) + ) + + const source = [1, 2, 3, 4] + const iterator = pipeline(source, {onError: 'collect'}) + const {results, errors} = await collect(iterator) + + expect(results).toEqual([10, 30, 40]) // 2 is skipped + expect(errors).toEqual([ + {item: 2, error: new Error('Bad value: 2')} + ]) +}) +``` + +- [ ] **Step 4: Update iterator transformations to yield errors for collect strategy** + +Modify `src/iterator-core.js` map function: + +```javascript +export function map(fn) { + return async function* (source, options = {}) { + const {onError = 'failFast'} = options + + for await (const item of source) { + try { + yield await fn(item) + } catch (error) { + if (onError === 'failFast') { + throw error + } + // For collect strategy, yield error object + if (onError === 'collect' || onError === 'notify') { + yield {__error: true, error, item} + } + // For skip (old behavior), just continue + } + } + } +} +``` + +- [ ] **Step 5: Update collect to handle error objects** + +Modify `src/collection.js`: + +```javascript +export async function collect(iterator, options = {}) { + const {onError = 'failFast', notify} = options + const results = [] + const errors = [] + + try { + for await (const item of iterator) { + if (item && item.__error) { + errors.push({item: item.item, error: item.error}) + if (onError === 'notify' && notify) { + notify(item.error, item.item) + } + } else { + results.push(item) + } + } + } catch (error) { + if (onError === 'failFast') { + throw error + } + // Should not reach here for collect/notify strategies + } + + return {results, errors} +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `npm test tests/collection.test.js -t "collect strategy accumulates errors"` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/iterator-core.js src/collection.js tests/collection.test.js +git commit -m "feat: implement collect error strategy with error objects" +``` + +### Task 8: Implement notify error strategy + +**Files:** +- Modify: `src/collection.js` +- Test: `tests/collection.test.js` + +- [ ] **Step 1: Write test for notify error strategy** + +```javascript +test('notify strategy calls callback for each error', async () => { + const {map} = await import('$lib/iterator-core') + const {compose} = await import('$lib/composition') + + const pipeline = compose( + map(x => { + if (x === 2 || x === 4) throw new Error('Bad: ' + x) + return x * 10 + }) + ) + + const notifications = [] + const source = [1, 2, 3, 4, 5] + const iterator = pipeline(source, {onError: 'notify'}) + const {results, errors} = await collect(iterator, { + onError: 'notify', + notify: (error, item) => { + notifications.push({item, message: error.message}) + } + }) + + expect(results).toEqual([10, 30, 50]) + expect(errors).toHaveLength(2) + expect(notifications).toEqual([ + {item: 2, message: 'Bad: 2'}, + {item: 4, message: 'Bad: 4'} + ]) +}) +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `npm test tests/collection.test.js -t "notify strategy calls callback"` +Expected: PASS (should work with current implementation) + +- [ ] **Step 3: Update filter and reduce to use same error pattern** + +Modify `src/iterator-core.js` filter and reduce functions to yield error objects: + +```javascript +export function filter(predicate) { + return async function* (source, options = {}) { + const {onError = 'failFast'} = options + + for await (const item of source) { + try { + const keep = await predicate(item) + if (keep) { + yield item + } + } catch (error) { + if (onError === 'failFast') { + throw error + } + if (onError === 'collect' || onError === 'notify') { + yield {__error: true, error, item} + } + } + } + } +} + +export function reduce(fn, initial) { + return async function* (source, options = {}) { + const {onError = 'failFast'} = options + let accumulator = initial + + for await (const item of source) { + try { + accumulator = await fn(accumulator, item) + yield accumulator + } catch (error) { + if (onError === 'failFast') { + throw error + } + if (onError === 'collect' || onError === 'notify') { + yield {__error: true, error, item} + } + } + } + } +} +``` + +- [ ] **Step 4: Run all collection tests** + +Run: `npm test tests/collection.test.js` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/iterator-core.js tests/collection.test.js +git commit -m "feat: implement notify strategy and update all transformers" +``` + +--- + +## Chunk 4: Integration and API Updates + +### Task 9: Update main exports + +**Files:** +- Modify: `src/index.js` +- Create: `src/iterator-index.js` (optional, for new API) + +- [ ] **Step 1: Create new index for iterator API** + +```javascript +// src/iterator-index.js +export {map, filter, reduce} from './iterator-core.js' +export {compose} from './composition.js' +export {collect} from './collection.js' + +// Re-export utilities from functional.js +export {pipe, pipeAsync, tryCatch} from './functional.js' +export {unwrapIterator} from './functional.js' // Keep for compatibility +``` + +- [ ] **Step 2: Update main index.js** + +```javascript +// src/index.js +// New iterator API +export {map, filter, reduce} from './iterator-core.js' +export {compose} from './composition.js' +export {collect} from './collection.js' + +// Legacy API (mark as deprecated) +export {safeMap, safeFilter, mapSeries, scanSeries} from './functional.js' +export {failFast, skip, collect as collectStrategy} from './functional.js' +export {pipe, pipeAsync, tryCatch, unwrapIterator} from './functional.js' +export {safeAsyncIterator, collectAsync} from './functional.js' +``` + +- [ ] **Step 3: Add deprecation warnings to functional.js** + +Add at top of `src/functional.js`: + +```javascript +// DEPRECATION NOTICE +// safeMap and safeFilter are deprecated in favor of iterator API +// Use: collect(compose(map(fn))(array), {onError: 'collect'}) +// instead of: safeMap(array, fn, {onError: collect}) + +console.warn('pipelean: safeMap/safeFilter are deprecated. Use iterator API (map, filter, compose, collect).') +``` + +- [ ] **Step 4: Run existing tests to ensure compatibility** + +Run: `npm test` +Expected: Most tests pass, some may fail due to API changes + +- [ ] **Step 5: Commit** + +```bash +git add src/index.js src/iterator-index.js src/functional.js +git commit -m "feat: update exports and add deprecation warnings" +``` + +### Task 10: Create integration tests + +**Files:** +- Create: `tests/integration.test.js` + +- [ ] **Step 1: Write end-to-end integration test** + +```javascript +import {test, expect} from 'vitest' +import {map, filter, compose, collect} from '$lib' + +test('complete pipeline with error handling', async () => { + const pipeline = compose( + map(x => { + if (x === 0) throw new Error('Zero not allowed') + return x * 2 + }), + filter(x => x > 5), + map(async x => { + // Simulate async operation + return Promise.resolve({value: x, squared: x * x}) + }) + ) + + const data = [0, 1, 2, 3, 4, 5] + const iterator = pipeline(data, {onError: 'collect'}) + const {results, errors} = await collect(iterator) + + expect(errors).toEqual([ + {item: 0, error: new Error('Zero not allowed')} + ]) + + // 1*2=2 (filtered out), 2*2=4 (filtered out), 3*2=6, 4*2=8, 5*2=10 + expect(results).toEqual([ + {value: 6, squared: 36}, + {value: 8, squared: 64}, + {value: 10, squared: 100} + ]) +}) +``` + +- [ ] **Step 2: Write test for lazy iteration** + +```javascript +test('lazy iteration with large dataset', async () => { + async function* generateNumbers(limit) { + for (let i = 0; i < limit; i++) { + yield i + } + } + + const pipeline = compose( + map(x => x * 2), + filter(x => x % 3 === 0) + ) + + const iterator = pipeline(generateNumbers(1000), {onError: 'collect'}) + + let count = 0 + for await (const item of iterator) { + count++ + expect(item % 3).toBe(0) + expect(item % 2).toBe(0) + } + + // Should process all items without collecting all at once + expect(count).toBeGreaterThan(0) +}) +``` + +- [ ] **Step 3: Write test for migration compatibility** + +```javascript +test('migration example from safeMap to new API', async () => { + // Old way (deprecated) + // const {results, errors} = await safeMap([1, 2, 3], x => x * 2, {onError: collect}) + + // New way + const {map, compose, collect} = await import('$lib') + const double = map(x => x * 2) + const iterator = compose(double)([1, 2, 3], {onError: 'collect'}) + const {results, errors} = await collect(iterator) + + expect(results).toEqual([2, 4, 6]) + expect(errors).toEqual([]) +}) +``` + +- [ ] **Step 4: Run integration tests** + +Run: `npm test tests/integration.test.js` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/integration.test.js +git commit -m "test: add integration tests for new API" +``` + +--- + +## Chunk 5: Documentation and Cleanup + +### Task 11: Update README.md + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update core concepts section** + +Replace the "Core Concepts" section with: + +```markdown +## Core Concepts + +### Iterator-First Design +Pipelean transforms async iterators. Everything is a transformation pipeline that can process arrays, streams, or any async iterable. + +### Error Strategies +Three strategies control error handling at the pipeline level: +- **`failFast`** (default): Stop on first error +- **`collect`**: Continue, accumulate errors in result +- **`notify`**: Continue, accumulate errors, call notification callback + +### Pure Operations +Operations (`map`, `filter`, `reduce`) are pure functions that succeed or throw. Error handling is separate at the pipeline level. + +### Composition +Transformations are composed with `compose()` to build pipelines. Results are collected with `collect()`. +``` + +- [ ] **Step 2: Update operations section** + +Replace with new API examples: + +```markdown +## Operations + +### map(fn) +Creates a mapping iterator transformation. + +```javascript +const double = map(x => x * 2) +const iterator = double([1, 2, 3], {onError: 'collect'}) +``` + +### filter(predicate) +Creates a filtering iterator transformation. + +```javascript +const evenOnly = filter(x => x % 2 === 0) +``` + +### reduce(fn, initial) +Creates a reducing iterator transformation. + +```javascript +const sum = reduce((acc, x) => acc + x, 0) +``` + +### compose(...transformers) +Composes multiple transformations into a pipeline. + +```javascript +const pipeline = compose( + map(x => x * 2), + filter(x => x > 5), + map(async x => ({value: x})) +) +``` + +### collect(iterator, options) +Collects results from an iterator with error handling. + +```javascript +const {results, errors} = await collect(iterator, { + onError: 'collect', + notify: (error, item) => console.error('Error:', error) +}) +``` +``` + +- [ ] **Step 3: Update examples section** + +Replace with new examples: + +```markdown +## Example: Complete Pipeline + +```javascript +import {map, filter, compose, collect} from 'pipelean' + +const pipeline = compose( + map(x => { + if (x === 0) throw new Error('Zero not allowed') + return x * 2 + }), + filter(x => x > 5), + map(async x => ({value: x, meta: await fetchMeta(x)})) +) + +const data = [1, 2, 3, 4, 5] +const iterator = pipeline(data, {onError: 'collect'}) +const {results, errors} = await collect(iterator) + +// results: transformed successful items +// errors: accumulated error information +``` + +## Migration from Legacy API + +| Legacy API | New API | +|------------|---------| +| `safeMap(array, fn, {onError})` | `collect(compose(map(fn))(array), {onError})` | +| `safeFilter(array, pred, {onError})` | `collect(compose(filter(pred))(array), {onError})` | +| `safePipe(op1, op2, op3)` | `compose(op1, op2, op3)` | +``` + +- [ ] **Step 4: Run documentation verification** + +Check that all code examples are valid JavaScript. + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs: update README for new iterator API" +``` + +### Task 12: Clean up deprecated tests + +**Files:** +- Delete: `tests/safe-map.test.js` +- Delete: `tests/safe-filter.test.js` +- Modify: `tests/error-strategies.test.js` (update or delete) + +- [ ] **Step 1: Delete deprecated test files** + +```bash +rm tests/safe-map.test.js +rm tests/safe-filter.test.js +``` + +- [ ] **Step 2: Update error-strategies test** + +Either delete or update to test new error strategy objects: + +```javascript +// tests/error-strategies.test.js (updated) +import {test, expect} from 'vitest' + +test('error strategies are string constants', () => { + // In new API, strategies are strings, not objects + const strategies = ['failFast', 'collect', 'notify'] + expect(strategies).toHaveLength(3) + expect(strategies).toContain('failFast') + expect(strategies).toContain('collect') + expect(strategies).toContain('notify') +}) +``` + +- [ ] **Step 3: Run all tests to ensure everything works** + +Run: `npm test` +Expected: All tests pass + +- [ ] **Step 4: Commit** + +```bash +git add tests/ +git commit -m "chore: clean up deprecated tests" +``` + +--- + +## Final Verification + +### Task 13: Final integration test + +**Files:** +- Test: Run complete test suite + +- [ ] **Step 1: Run full test suite** + +```bash +npm test +``` + +Expected: All tests pass + +- [ ] **Step 2: Check bundle size and compatibility** + +```bash +# Check if any Node.js specific APIs are used +grep -r "require\|process\.\|__dirname\|__filename" src/ || echo "No Node.js specific APIs found" + +# Check ESM exports +node -e "import('./src/index.js').then(m => console.log('Exports:', Object.keys(m))).catch(e => console.error(e))" +``` + +- [ ] **Step 3: Create simple usage example** + +Create `examples/basic-usage.js`: + +```javascript +import {map, filter, compose, collect} from './src/index.js' + +async function main() { + const pipeline = compose( + map(x => x * 2), + filter(x => x > 5) + ) + + const data = [1, 2, 3, 4, 5] + const iterator = pipeline(data, {onError: 'collect'}) + const {results, errors} = await collect(iterator) + + console.log('Results:', results) // [6, 8, 10] + console.log('Errors:', errors) // [] +} + +main().catch(console.error) +``` + +- [ ] **Step 4: Run example** + +```bash +node examples/basic-usage.js +``` + +Expected: Output shows results [6, 8, 10] and empty errors + +- [ ] **Step 5: Final commit** + +```bash +git add examples/ +git commit -m "chore: add usage example and final verification" +``` + +--- + +## Plan Complete + +The implementation plan is now complete and saved to `docs/superpowers/plans/2026-03-16-pipelean-redesign.md`. + +**Ready to execute using superpowers:subagent-driven-development?** \ No newline at end of file From 711883616e73b75cef89450d43b83d0093313d10 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:41:57 +0100 Subject: [PATCH 06/26] finally took a proper diorection: removed safePipe, safeMap is what we need and is the execute/series operation. we keep pipeAsync, drop mapSeries. --- FEATURES.md | 30 ++++++++++ .../superpowers/specs/2026-03-17-reframing.md | 7 +++ src/functional.js | 55 ++++++++++--------- tests/error-strategies.test.js | 9 +-- tests/map-series.test.js | 27 --------- tests/safe-filter.test.js | 6 +- 6 files changed, 71 insertions(+), 63 deletions(-) create mode 100644 FEATURES.md create mode 100644 docs/superpowers/specs/2026-03-17-reframing.md delete mode 100644 tests/map-series.test.js diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..edf83fa --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,30 @@ +# FEATURES.md + +## safeMap + +### `safeMap` Feature Set + +* **Error Strategies** + * **Fail Fast:** Stops execution immediately upon the first error. + * **Collect:** Gathers all errors and continues processing until the end. + +* **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. + +* **Universal Input** + * Works on Arrays, Streams, Generators, and any Async Iterable. + +* **Universal Mapper** + * Handles both Synchronous and Asynchronous mapper functions automatically. + +* **Functional Flexibility** + * **Immediate Mode:** `safeMap(data, fn)` runs instantly. + * **Curried Mode:** `safeMap(fn)(data)` creates a reusable executable, ideal for pipelines. + +* **Order Guarantee** + * Because execution is sequential, output order strictly matches input order (no race conditions). + +* **Structured Results** + * Always returns a predictable object: `{ results, errors, failure }`. + * Errors are treated as data, removing the need for consumer-side `try/catch` blocks. diff --git a/docs/superpowers/specs/2026-03-17-reframing.md b/docs/superpowers/specs/2026-03-17-reframing.md new file mode 100644 index 0000000..a6c4331 --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-reframing.md @@ -0,0 +1,7 @@ +# Architecture + + * `safeMap` (Iteration): It works *horizontally*. It takes a list of items and applies one transformation to each item in parallel or sequence. + + * `pipeAsync` (Composition): It works *vertically*. + - It takes one item and passes it through a chain of functions, one after the other. + - should *not* handle iterables. I \ No newline at end of file diff --git a/src/functional.js b/src/functional.js index 4a46769..95b439d 100644 --- a/src/functional.js +++ b/src/functional.js @@ -1,28 +1,50 @@ export const failFast = Object.freeze({name: 'failFast'}) -export const skip = Object.freeze({name: 'skip'}) +// export const skip = Object.freeze({name: 'skip'}) export const collect = Object.freeze({name: 'collect'}) export const safeMap = (...args) => { - const immediate = Array.isArray(args[0]) + // FIX: Detect "immediate" by checking if the first arg is a function. + // This allows immediate usage like: safeMap(myStream, fn) + // (Because streams/generators are objects, not functions) + const immediate = typeof args[0] !== 'function' + const [items, fn, opts] = immediate ? args : [null, args[0], args[1]] + const execute = async inputItems => { - const {onError = failFast} = opts || {} + const {onError = 'failFast', take} = opts || {} const results = [] const errors = [] - for await (const [index, item] of inputItems.entries()) { + + // FIX: Manual index tracking allows us to support ANY iterable (Streams/Generators) + // "for await...of" handles both Arrays and Async Iterables automatically. + let index = 0 + for await (const item of inputItems) { + // eslint-disable-next-line no-undefined + if (take !== undefined && index >= take) { + break + } + try { results.push(await fn(item, index)) } catch (error) { - if (onError === failFast) + if (onError === 'failFast') { return {results, errors, failure: {item, error}} + } + // Default behavior: 'collect' errors.push({item, error}) } + + index++ } return {results, errors, failure: null} } + return immediate ? execute(items) : execute } +export const execute = safeMap +export const series = execute + export const safeFilter = (...args) => { const immediate = Array.isArray(args[0]) const [items, predicate, opts] = immediate ? args : [null, args[0], args[1]] @@ -45,20 +67,6 @@ export const safeFilter = (...args) => { } return immediate ? execute(items) : execute } -export const mapSeries = async (array, asyncFn, {limit} = {}) => { - const results = [] - let count = 0 - for await (const item of array) { - if (limit && count >= limit) { - break - } - results.push(await asyncFn(item)) - count += 1 - } - return results -} -// export const mapSeries = (array, asyncFn, {limit} = {}) => -// safeMap(array, asyncFn, {onError: none, limit}) export const scanSeries = async (iterable, scanner, initialValue) => { const results = [] @@ -78,12 +86,11 @@ export const unwrapIterator = async iterator => { return accumulator } -export const pipe = (...fns) => input => - fns.reduce((acc, fn) => fn(acc), input) - export const pipeAsync = (...fns) => input => fns.reduce(async (acc, fn) => fn(await acc), input) +export const pipe = pipeAsync + export async function * safeAsyncIterator (iterable, transform, { onError = failFast, } = {}) { @@ -94,15 +101,13 @@ export async function * safeAsyncIterator (iterable, transform, { } catch (error) { if (onError === failFast) throw error - if (onError === skip) - continue if (onError === collect) yield {error, item} } } } -export const collectAsync = async (iterable, {onError = skip} = {}) => { +export const collectAsync = async (iterable, {onError = collect} = {}) => { const results = [] for await (const item of safeAsyncIterator(iterable, x => x, {onError})) { results.push(item) diff --git a/tests/error-strategies.test.js b/tests/error-strategies.test.js index 2f2ee35..f9c63ad 100644 --- a/tests/error-strategies.test.js +++ b/tests/error-strategies.test.js @@ -1,23 +1,16 @@ import {test, expect} from 'vitest' -import {failFast, skip, collect} from '$lib/functional' +import {failFast, collect} from '..' test('failFast is a frozen object with name "failFast"', () => { expect(failFast).toEqual({name: 'failFast'}) expect(Object.isFrozen(failFast)).toBe(true) }) -test('skip is a frozen object with name "skip"', () => { - expect(skip).toEqual({name: 'skip'}) - expect(Object.isFrozen(skip)).toBe(true) -}) - test('collect is a frozen object with name "collect"', () => { expect(collect).toEqual({name: 'collect'}) expect(Object.isFrozen(collect)).toBe(true) }) test('strategies are distinct references', () => { - expect(failFast).not.toBe(skip) expect(failFast).not.toBe(collect) - expect(skip).not.toBe(collect) }) diff --git a/tests/map-series.test.js b/tests/map-series.test.js deleted file mode 100644 index 3a9da41..0000000 --- a/tests/map-series.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import {test, expect} from 'vitest' -import {mapSeries} from '$lib/functional' - -test('processes items sequentially and returns results', async () => { - const order = [] - const results = await mapSeries([1, 2, 3], item => { - order.push(item) - return Promise.resolve(item * 2) - }) - expect(results).toEqual([2, 4, 6]) - expect(order).toEqual([1, 2, 3]) -}) - -test('respects limit option', async () => { - const results = await mapSeries( - [1, 2, 3, 4, 5], - item => Promise.resolve(item * 10), - {limit: 3}, - ) - expect(results).toEqual([10, 20, 30]) -}) - -test('returns empty array for empty input', async () => { - await expect( - mapSeries([], item => Promise.resolve(item)), - ).resolves.toEqual([]) -}) diff --git a/tests/safe-filter.test.js b/tests/safe-filter.test.js index 81de586..3351a7c 100644 --- a/tests/safe-filter.test.js +++ b/tests/safe-filter.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {safeFilter, skip} from '$lib/functional' +import {safeFilter, collect} from '$lib/functional' test('predicate truthy keeps item in results', async () => { const result = await safeFilter([1, 2, 3, 4], x => x > 2) @@ -23,13 +23,13 @@ test('predicate throws with failFast stops and populates failure', async () => { expect(result.errors).toEqual([]) }) -test('predicate throws with skip continues and collects error', async () => { +test('predicate throws with collect continues and collects error', async () => { const bang = new Error('bang') const result = await safeFilter([1, 2, 3], x => { if (x === 2) throw bang return true - }, {onError: skip}) + }, {onError: collect}) expect(result.results).toEqual([1, 3]) expect(result.errors).toEqual([{item: 2, error: bang}]) expect(result.failure).toBeNull() From 2e2c905bb0e204f0d7dcab39792439bdf25d172a Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:43:29 +0100 Subject: [PATCH 07/26] sorted test not being aligned --- tests/pipe.test.js | 18 ++++++++++-------- tests/safe-map.test.js | 6 ++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/pipe.test.js b/tests/pipe.test.js index 2fe21fb..4196720 100644 --- a/tests/pipe.test.js +++ b/tests/pipe.test.js @@ -1,13 +1,14 @@ import {test, expect} from 'vitest' import {pipe} from '$lib/functional' -test('composes functions left-to-right', () => { - const result = pipe(x => x * 2, x => x + 1)(5) +test('composes functions left-to-right', async () => { + // Must await because pipe is now async-safe + const result = await pipe(x => x * 2, x => x + 1)(5) expect(result).toBe(11) }) -test('passes result through chain', () => { - const result = pipe( +test('passes result through chain', async () => { + const result = await pipe( x => x + 1, x => x * 3, x => x - 2, @@ -15,14 +16,15 @@ test('passes result through chain', () => { expect(result).toBe(7) }) -test('works with single function', () => { - expect(pipe(x => x * 10)(4)).toBe(40) +test('works with single function', async () => { + await expect(pipe(x => x * 10)(4)).resolves.toBe(40) }) -test('propagates errors', () => { +test('propagates errors', async () => { const pipeline = pipe( () => { throw new Error('pipe broke') }, x => x + 1, ) - expect(() => pipeline(1)).toThrow('pipe broke') + // Promise rejection must be caught with rejects + await expect(pipeline(1)).rejects.toThrow('pipe broke') }) diff --git a/tests/safe-map.test.js b/tests/safe-map.test.js index 72da128..3d5d11a 100644 --- a/tests/safe-map.test.js +++ b/tests/safe-map.test.js @@ -1,7 +1,5 @@ import {test, expect} from 'vitest' -import { - safeMap, skip, collect, -} from '$lib/functional' +import {safeMap, collect} from '$lib/functional' test('all items succeed returns results with no errors', async () => { const result = await safeMap([1, 2, 3], x => x * 2) @@ -26,7 +24,7 @@ test('skip continues past errors and collects them', async () => { if (x === 2) throw bang return x * 10 - }, {onError: skip}) + }, {onError: collect}) expect(result.results).toEqual([10, 30]) expect(result.errors).toEqual([{item: 2, error: bang}]) expect(result.failure).toBeNull() From c6fcd30314bebc293f0e379aaa1768fc29fd85cd Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:58:58 +0100 Subject: [PATCH 08/26] we keep the scan... we will argue about error handling later --- .../superpowers/specs/2026-03-17-reframing.md | 9 ++-- src/functional.js | 43 +++++++++++++++++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-03-17-reframing.md b/docs/superpowers/specs/2026-03-17-reframing.md index a6c4331..213d77d 100644 --- a/docs/superpowers/specs/2026-03-17-reframing.md +++ b/docs/superpowers/specs/2026-03-17-reframing.md @@ -1,7 +1,10 @@ # Architecture - * `safeMap` (Iteration): It works *horizontally*. It takes a list of items and applies one transformation to each item in parallel or sequence. + * `execute (series)` (Iteration): It works *horizontally*. It takes a list of items and applies one transformation to each item in parallel or sequence. - * `pipeAsync` (Composition): It works *vertically*. + * `pipe / pipeAsync` (Composition): It works *vertically*. - It takes one item and passes it through a chain of functions, one after the other. - - should *not* handle iterables. I \ No newline at end of file + - should *not* handle iterables. I + execute: Process independent items. + + * `scan` Process dependent items (stateful). \ No newline at end of file diff --git a/src/functional.js b/src/functional.js index 95b439d..beb9a47 100644 --- a/src/functional.js +++ b/src/functional.js @@ -68,15 +68,52 @@ export const safeFilter = (...args) => { return immediate ? execute(items) : execute } -export const scanSeries = async (iterable, scanner, initialValue) => { +/* + We use the same "immediate" vs "curried" pattern as execute + But here args are: (iterable, fn, init) or (fn, init) + Actually, usually scan takes (iterable, fn, init). + Let's stick to your execute pattern style if you like, + but typically scan is eager because you need the init value immediately. + + Let's keep it simple: Scan is almost always eager because of the 'initialValue'. +*/ +export const safeScan = async (iterable, scanner, initialValue) => { const results = [] let acc = initialValue + + // We reuse the iteration logic for await (const item of iterable) { - acc = await scanner(acc, item) - results.push(acc) + 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}, + } + } } + + return {results, errors: [], failure: null} +} + +export const scan = safeScan + +export const scanSeries = async (iterable, scanner, initialValue) => { + const {results} = await scan(iterable, scanner, initialValue) return results } +// export const scanSeries = async (iterable, scanner, initialValue) => { +// const results = [] +// let acc = initialValue +// for await (const item of iterable) { +// acc = await scanner(acc, item) +// results.push(acc) +// } +// return results +// } export const unwrapIterator = async iterator => { const accumulator = [] From 74cc67b0a38abeacf313db8254985f428d69fc07 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:03:13 +0100 Subject: [PATCH 09/26] cleanup --- src/functional.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/functional.js b/src/functional.js index beb9a47..fc42464 100644 --- a/src/functional.js +++ b/src/functional.js @@ -1,5 +1,4 @@ export const failFast = Object.freeze({name: 'failFast'}) -// export const skip = Object.freeze({name: 'skip'}) export const collect = Object.freeze({name: 'collect'}) export const safeMap = (...args) => { @@ -7,7 +6,6 @@ export const safeMap = (...args) => { // This allows immediate usage like: safeMap(myStream, fn) // (Because streams/generators are objects, not functions) const immediate = typeof args[0] !== 'function' - const [items, fn, opts] = immediate ? args : [null, args[0], args[1]] const execute = async inputItems => { @@ -42,9 +40,6 @@ export const safeMap = (...args) => { return immediate ? execute(items) : execute } -export const execute = safeMap -export const series = execute - export const safeFilter = (...args) => { const immediate = Array.isArray(args[0]) const [items, predicate, opts] = immediate ? args : [null, args[0], args[1]] @@ -99,10 +94,8 @@ export const safeScan = async (iterable, scanner, initialValue) => { return {results, errors: [], failure: null} } -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 scanSeries = async (iterable, scanner, initialValue) => { @@ -126,8 +119,6 @@ export const unwrapIterator = async iterator => { export const pipeAsync = (...fns) => input => fns.reduce(async (acc, fn) => fn(await acc), input) -export const pipe = pipeAsync - export async function * safeAsyncIterator (iterable, transform, { onError = failFast, } = {}) { @@ -176,3 +167,8 @@ export const tryCatch = (fn, { onFinally() } } + +export const execute = safeMap +export const series = execute +export const scan = safeScan +export const pipe = pipeAsync From 36b2a052ab2c4af247e45f581e4915332406ca2d Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:05:17 +0100 Subject: [PATCH 10/26] renamed and fixed safeFilter --- src/functional.js | 30 ++++++++++++++++++++++-------- tests/safe-filter.test.js | 18 +++++++++--------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/functional.js b/src/functional.js index fc42464..772b8bc 100644 --- a/src/functional.js +++ b/src/functional.js @@ -40,27 +40,41 @@ export const safeMap = (...args) => { return immediate ? execute(items) : execute } -export const safeFilter = (...args) => { - const immediate = Array.isArray(args[0]) +export const filter = (...args) => { + const immediate = typeof args[0] !== 'function' const [items, predicate, opts] = immediate ? args : [null, args[0], args[1]] - const execute = async inputItems => { - const {onError = failFast} = opts || {} + + // eslint-disable-next-line complexity, max-statements + const run = async inputItems => { + const {onError = 'failFast', take} = opts || {} const results = [] const errors = [] - for await (const [index, item] of inputItems.entries()) { + + let index = 0 + for await (const item of inputItems) { + // eslint-disable-next-line no-undefined + if (take !== undefined && results.length >= take) { + break + } + try { const keep = await predicate(item, index) - if (keep) + if (keep) { results.push(item) + } } catch (error) { - if (onError === failFast) + if (onError === 'failFast') { return {results, errors, failure: {item, error}} + } errors.push({item, error}) } + + index++ } return {results, errors, failure: null} } - return immediate ? execute(items) : execute + + return immediate ? run(items) : run } /* diff --git a/tests/safe-filter.test.js b/tests/safe-filter.test.js index 3351a7c..8e7950a 100644 --- a/tests/safe-filter.test.js +++ b/tests/safe-filter.test.js @@ -1,19 +1,19 @@ import {test, expect} from 'vitest' -import {safeFilter, collect} from '$lib/functional' +import {filter, collect} from '$lib/functional' test('predicate truthy keeps item in results', async () => { - const result = await safeFilter([1, 2, 3, 4], x => x > 2) + const result = await filter([1, 2, 3, 4], x => x > 2) expect(result).toEqual({results: [3, 4], errors: [], failure: null}) }) test('predicate falsy excludes item without error', async () => { - const result = await safeFilter([1, 2, 3], () => false) + const result = await filter([1, 2, 3], () => false) expect(result).toEqual({results: [], errors: [], failure: null}) }) test('predicate throws with failFast stops and populates failure', async () => { const bang = new Error('bang') - const result = await safeFilter([1, 2, 3], x => { + const result = await filter([1, 2, 3], x => { if (x === 2) throw bang return true @@ -25,7 +25,7 @@ test('predicate throws with failFast stops and populates failure', async () => { test('predicate throws with collect continues and collects error', async () => { const bang = new Error('bang') - const result = await safeFilter([1, 2, 3], x => { + const result = await filter([1, 2, 3], x => { if (x === 2) throw bang return true @@ -36,22 +36,22 @@ test('predicate throws with collect continues and collects error', async () => { }) test('async predicates work', async () => { - const result = await safeFilter([1, 2, 3], x => Promise.resolve(x % 2 === 1)) + const result = await filter([1, 2, 3], x => Promise.resolve(x % 2 === 1)) expect(result.results).toEqual([1, 3]) }) test('curried form returns a function', () => { - const fn = safeFilter(x => x > 2) + const fn = filter(x => x > 2) expect(typeof fn).toBe('function') }) test('curried form executes when called with items', async () => { - const evens = safeFilter(x => x % 2 === 0) + const evens = filter(x => x % 2 === 0) const result = await evens([1, 2, 3, 4]) expect(result).toEqual({results: [2, 4], errors: [], failure: null}) }) test('empty array returns empty result shape', async () => { - const result = await safeFilter([], () => true) + const result = await filter([], () => true) expect(result).toEqual({results: [], errors: [], failure: null}) }) From 3e073cd5c077570747155946822acad19665e1d9 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:15:18 +0100 Subject: [PATCH 11/26] unwrap and collect now a special case of series --- src/functional.js | 35 +++++++++++++++++++++-------------- tests/unwrap-iterator.test.js | 6 ++++-- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/functional.js b/src/functional.js index 772b8bc..2eb11bf 100644 --- a/src/functional.js +++ b/src/functional.js @@ -1,6 +1,10 @@ export const failFast = Object.freeze({name: 'failFast'}) export const collect = Object.freeze({name: 'collect'}) +/* + Series + Eager" iterator (it processes all items and returns an array). +*/ export const safeMap = (...args) => { // FIX: Detect "immediate" by checking if the first arg is a function. // This allows immediate usage like: safeMap(myStream, fn) @@ -122,13 +126,13 @@ export const scanSeries = async (iterable, scanner, initialValue) => { // return results // } -export const unwrapIterator = async iterator => { - const accumulator = [] - for await (const item of iterator) { - accumulator.push(item) - } - return accumulator -} +// export const unwrapIterator = async iterator => { +// const accumulator = [] +// for await (const item of iterator) { +// accumulator.push(item) +// } +// return accumulator +// } export const pipeAsync = (...fns) => input => fns.reduce(async (acc, fn) => fn(await acc), input) @@ -149,13 +153,13 @@ export async function * safeAsyncIterator (iterable, transform, { } } -export const collectAsync = async (iterable, {onError = collect} = {}) => { - const results = [] - for await (const item of safeAsyncIterator(iterable, x => x, {onError})) { - results.push(item) - } - return results -} +// export const collectAsync = async (iterable, {onError = collect} = {}) => { +// const results = [] +// for await (const item of safeAsyncIterator(iterable, x => x, {onError})) { +// results.push(item) +// } +// return results +// } // This should probably be a goal. // export const unwrapIterator = collectAsync @@ -186,3 +190,6 @@ export const execute = safeMap export const series = execute export const scan = safeScan export const pipe = pipeAsync + +export const unwrapIterator = iterator => series(iterator, x => x) +export const collectAsync = iterator => series(iterator, x => x) diff --git a/tests/unwrap-iterator.test.js b/tests/unwrap-iterator.test.js index 67dd56d..e487424 100644 --- a/tests/unwrap-iterator.test.js +++ b/tests/unwrap-iterator.test.js @@ -7,11 +7,13 @@ test('collects async iterator into array', async () => { yield 2 yield 3 } - await expect(unwrapIterator(gen())).resolves.toEqual([1, 2, 3]) + const {results} = await unwrapIterator(gen()) + expect(results).toEqual([1, 2, 3]) }) test('handles empty iterator', async () => { // eslint-disable-next-line no-empty-function const gen = async function * () {} - await expect(unwrapIterator(gen())).resolves.toEqual([]) + const {results} = await unwrapIterator(gen()) + expect(results).toEqual([]) }) From 82702ccc9359af4de0cba889d0dd0a02c260c07a Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:17:00 +0100 Subject: [PATCH 12/26] cleanup --- src/functional.js | 34 +++++----------------------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/src/functional.js b/src/functional.js index 2eb11bf..de1fd85 100644 --- a/src/functional.js +++ b/src/functional.js @@ -116,27 +116,14 @@ export const scanSeries = async (iterable, scanner, initialValue) => { const {results} = await safeScan(iterable, scanner, initialValue) return results } -// export const scanSeries = async (iterable, scanner, initialValue) => { -// const results = [] -// let acc = initialValue -// for await (const item of iterable) { -// acc = await scanner(acc, item) -// results.push(acc) -// } -// return results -// } - -// export const unwrapIterator = async iterator => { -// const accumulator = [] -// for await (const item of iterator) { -// accumulator.push(item) -// } -// return accumulator -// } - export const pipeAsync = (...fns) => input => fns.reduce(async (acc, fn) => fn(await acc), input) +/* + "Lazy" iterator (it yields items one by one). + This forces the consumer to check every single item to + see if it's an error or a valid result +*/ export async function * safeAsyncIterator (iterable, transform, { onError = failFast, } = {}) { @@ -153,17 +140,6 @@ export async function * safeAsyncIterator (iterable, transform, { } } -// export const collectAsync = async (iterable, {onError = collect} = {}) => { -// const results = [] -// for await (const item of safeAsyncIterator(iterable, x => x, {onError})) { -// results.push(item) -// } -// return results -// } - -// This should probably be a goal. -// export const unwrapIterator = collectAsync - export const tryCatch = (fn, { onStart, onSuccess, onError, onFinally, } = {}) => From f675d66f88d89202c3e7ca9ebdaca65637d2e691 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:38:06 +0100 Subject: [PATCH 13/26] reframing docs update + added retry and improved trycatch --- .../superpowers/specs/2026-03-17-reframing.md | 46 +++++++++++++++++-- src/functional.js | 22 ++++++++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-03-17-reframing.md b/docs/superpowers/specs/2026-03-17-reframing.md index 213d77d..f1961f8 100644 --- a/docs/superpowers/specs/2026-03-17-reframing.md +++ b/docs/superpowers/specs/2026-03-17-reframing.md @@ -1,10 +1,46 @@ # Architecture - * `execute (series)` (Iteration): It works *horizontally*. It takes a list of items and applies one transformation to each item in parallel or sequence. + * `execute (series)` (Iteration): + - It works *horizontally*. It takes a list of items and applies one transformation to each item in parallel or sequence. + - Orchestrate the list and report progress." + - Process independent items. + - stateless + - default to collect error strategy + + * `scan` (Iteration) + - Process dependent items + - stateful. + - locked on failFast error strategy + + * `filter` + - Selects items horizontally. + - Do not care if items are dependent or not + - stateless + - default to collect error strategy - * `pipe / pipeAsync` (Composition): It works *vertically*. + * `pipe / pipeAsync` (Composition): + - It works *vertically*. - It takes one item and passes it through a chain of functions, one after the other. - - should *not* handle iterables. I - execute: Process independent items. + - should *not* handle iterables. + - is just a "Function Builder." It creates a single, composite function f. + - has no error strategy. + + * `tryCatch` is a function wrapper + - "Protect the work" of the function + - is effectively a pipeline of length 1. + - Wraps one function with "Middleware/Lifecycle" (Start, Success, Error, Finally). + - Can handle progress / error notification even without being run in a series + - Deep Telemetry: Wrap fn in pipeline to Log specifically when step 2 of a 5-step pipe fails. + - Can be extended with specific progress/error notifications use-cases + + * `retry`: a specialized trycatch + - Perfect to combine in pipelines - * `scan` Process dependent items (stateful). \ No newline at end of file +```js +const pipeline = pipe( + processTrack, // Standard + retry(updateDb, 3), // This step gets 3 retries automatically + notifyUI // Standard +) +await series(tracks, pipeline) +``` diff --git a/src/functional.js b/src/functional.js index de1fd85..3bb8ea9 100644 --- a/src/functional.js +++ b/src/functional.js @@ -142,6 +142,7 @@ export async function * safeAsyncIterator (iterable, transform, { export const tryCatch = (fn, { onStart, onSuccess, onError, onFinally, + rethrow = false, } = {}) => async (...args) => { // console.info('TRYCATCH |') @@ -155,13 +156,32 @@ export const tryCatch = (fn, { await onSuccess(result) return result } catch (error) { - return onError ? onError(error) : null + if (onError) + await onError(error) + if (rethrow) + throw error + return null } finally { if (onFinally) onFinally() } } +export const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) + +export const retry = (fn, attempts) => async (...args) => { + let lastError + for (let i = 0; i < attempts; i++) { + try { + return await fn(...args) + } catch (error) { + lastError = error + // Optional: wait a bit? + } + } + throw lastError +} + export const execute = safeMap export const series = execute export const scan = safeScan From beba6e1deb2f9f32363a0e3f23ee0e0db9a451d7 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:39:40 +0100 Subject: [PATCH 14/26] this is the proper new behavior --- tests/try-catch.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/try-catch.test.js b/tests/try-catch.test.js index 3dcfb43..750a176 100644 --- a/tests/try-catch.test.js +++ b/tests/try-catch.test.js @@ -14,9 +14,8 @@ test('returns null on error when no onError provided', async () => { test('returns onError result on error', async () => { const wrapped = tryCatch( () => Promise.reject(new Error('boom')), - {onError: () => 'fallback'}, ) - await expect(wrapped()).resolves.toBe('fallback') + await expect(wrapped()).resolves.toBeNull() }) test('passes error to onError', async () => { From 5a4155eb14738d184182e6368d2a96cdcba01234 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:59:21 +0100 Subject: [PATCH 15/26] new series with onProgress etc... --- src/functional.js | 93 +++++++++++++++---- .../{safe-map.test.js => series-map.test.js} | 34 +++---- 2 files changed, 85 insertions(+), 42 deletions(-) rename tests/{safe-map.test.js => series-map.test.js} (63%) diff --git a/src/functional.js b/src/functional.js index 3bb8ea9..342bfe1 100644 --- a/src/functional.js +++ b/src/functional.js @@ -1,24 +1,22 @@ export const failFast = Object.freeze({name: 'failFast'}) export const collect = Object.freeze({name: 'collect'}) -/* - Series - Eager" iterator (it processes all items and returns an array). -*/ -export const safeMap = (...args) => { - // FIX: Detect "immediate" by checking if the first arg is a function. - // This allows immediate usage like: safeMap(myStream, fn) - // (Because streams/generators are objects, not functions) +export const series = (...args) => { const immediate = typeof args[0] !== 'function' const [items, fn, opts] = immediate ? args : [null, args[0], args[1]] - const execute = async inputItems => { - const {onError = 'failFast', take} = opts || {} + // eslint-disable-next-line complexity, max-statements + const run = async inputItems => { + const { + strategy = 'collect', + take, + onProgress, + onError, + } = opts || {} + const results = [] const errors = [] - // FIX: Manual index tracking allows us to support ANY iterable (Streams/Generators) - // "for await...of" handles both Arrays and Async Iterables automatically. let index = 0 for await (const item of inputItems) { // eslint-disable-next-line no-undefined @@ -27,12 +25,26 @@ export const safeMap = (...args) => { } try { - results.push(await fn(item, index)) + const result = await fn(item, index) + results.push(result) + + // Notify success + if (onProgress) { + await onProgress(result, item, index) + } } catch (error) { - if (onError === 'failFast') { - return {results, errors, failure: {item, error}} + if (onError) { + await onError(error, item, index) + } + + if (strategy === 'failFast') { + return { + results, + errors, + failure: {item, error}, + } } - // Default behavior: 'collect' + errors.push({item, error}) } @@ -41,9 +53,52 @@ export const safeMap = (...args) => { return {results, errors, failure: null} } - return immediate ? execute(items) : execute + return immediate ? run(items) : run } +/* + Series + Eager" iterator (it processes all items and returns an array). +*/ +// export const safeMap = (...args) => { +// // FIX: Detect "immediate" by checking if the first arg is a function. +// // This allows immediate usage like: safeMap(myStream, fn) +// // (Because streams/generators are objects, not functions) +// const immediate = typeof args[0] !== 'function' +// const [items, fn, opts] = immediate ? args : [null, args[0], args[1]] + +// const execute = async inputItems => { +// const {onError = 'failFast', take} = opts || {} +// const results = [] +// const errors = [] + +// // FIX: Manual index tracking allows us to support ANY iterable (Streams/Generators) +// // "for await...of" handles both Arrays and Async Iterables automatically. +// let index = 0 +// for await (const item of inputItems) { +// // eslint-disable-next-line no-undefined +// if (take !== undefined && index >= take) { +// break +// } + +// try { +// results.push(await fn(item, index)) +// } catch (error) { +// if (onError === 'failFast') { +// return {results, errors, failure: {item, error}} +// } +// // Default behavior: 'collect' +// errors.push({item, error}) +// } + +// index++ +// } +// return {results, errors, failure: null} +// } + +// return immediate ? execute(items) : execute +// } + export const filter = (...args) => { const immediate = typeof args[0] !== 'function' const [items, predicate, opts] = immediate ? args : [null, args[0], args[1]] @@ -182,8 +237,8 @@ export const retry = (fn, attempts) => async (...args) => { throw lastError } -export const execute = safeMap -export const series = execute +// export const execute = safeMap +// export const series = execute export const scan = safeScan export const pipe = pipeAsync diff --git a/tests/safe-map.test.js b/tests/series-map.test.js similarity index 63% rename from tests/safe-map.test.js rename to tests/series-map.test.js index 3d5d11a..2568428 100644 --- a/tests/safe-map.test.js +++ b/tests/series-map.test.js @@ -1,55 +1,43 @@ import {test, expect} from 'vitest' -import {safeMap, collect} from '$lib/functional' +import {series, collect} from '$lib/functional' test('all items succeed returns results with no errors', async () => { - const result = await safeMap([1, 2, 3], x => x * 2) + const result = await series([1, 2, 3], x => x * 2) expect(result).toEqual({results: [2, 4, 6], errors: [], failure: null}) }) test('failFast stops on first error with partial results', async () => { const bang = new Error('bang') - const result = await safeMap([1, 2, 3], x => { + const result = await series([1, 2, 3], x => { if (x === 2) throw bang return x * 10 - }) + }, {strategy: 'failFast'}) expect(result.results).toEqual([10]) expect(result.failure).toEqual({item: 2, error: bang}) expect(result.errors).toEqual([]) }) -test('skip continues past errors and collects them', async () => { - const bang = new Error('bang') - const result = await safeMap([1, 2, 3], x => { - if (x === 2) - throw bang - return x * 10 - }, {onError: collect}) - expect(result.results).toEqual([10, 30]) - expect(result.errors).toEqual([{item: 2, error: bang}]) - expect(result.failure).toBeNull() -}) - test('collect continues past errors same as skip', async () => { const bang = new Error('bang') - const result = await safeMap([1, 2, 3], x => { + const result = await series([1, 2, 3], x => { if (x === 2) throw bang return x * 10 - }, {onError: collect}) + }, {strategy: collect}) expect(result.results).toEqual([10, 30]) expect(result.errors).toEqual([{item: 2, error: bang}]) expect(result.failure).toBeNull() }) test('async mapping functions work', async () => { - const result = await safeMap([1, 2], x => Promise.resolve(x + 100)) + const result = await series([1, 2], x => Promise.resolve(x + 100)) expect(result.results).toEqual([101, 102]) }) test('passes index as second arg to fn', async () => { const indices = [] - await safeMap([10, 20, 30], (_item, index) => { + await series([10, 20, 30], (_item, index) => { indices.push(index) return index }) @@ -57,17 +45,17 @@ test('passes index as second arg to fn', async () => { }) test('empty array returns empty result shape', async () => { - const result = await safeMap([], x => x) + const result = await series([], x => x) expect(result).toEqual({results: [], errors: [], failure: null}) }) test('curried form returns a function', () => { - const fn = safeMap(x => x * 2) + const fn = series(x => x * 2) expect(typeof fn).toBe('function') }) test('curried form executes when called with items', async () => { - const double = safeMap(x => x * 2) + const double = series(x => x * 2) const result = await double([1, 2, 3]) expect(result).toEqual({results: [2, 4, 6], errors: [], failure: null}) }) From c1ea533c368ddc18527c35e23e5e7fb5e3802ae0 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:03:11 +0100 Subject: [PATCH 16/26] cleanup --- src/functional.js | 138 ++++++++++++++-------------------------------- 1 file changed, 42 insertions(+), 96 deletions(-) diff --git a/src/functional.js b/src/functional.js index 342bfe1..d938d3c 100644 --- a/src/functional.js +++ b/src/functional.js @@ -1,6 +1,48 @@ export const failFast = Object.freeze({name: 'failFast'}) export const collect = Object.freeze({name: 'collect'}) +export const tryCatch = (fn, { + onStart, onSuccess, onError, onFinally, + rethrow = false, +} = {}) => + async (...args) => { + // console.info('TRYCATCH |') + try { + if (onStart) + onStart() + // console.info('TRYCATCH | Started |', {args, onError}) + const result = await fn(...args) + // console.info('TRYCATCH | Result |', {result}) + if (onSuccess) + await onSuccess(result) + return result + } catch (error) { + if (onError) + await onError(error) + if (rethrow) + throw error + return null + } finally { + if (onFinally) + onFinally() + } + } + +export const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) + +export const retry = (fn, attempts) => async (...args) => { + let lastError + for (let i = 0; i < attempts; i++) { + try { + return await fn(...args) + } catch (error) { + lastError = error + // Optional: wait a bit? + } + } + throw lastError +} + export const series = (...args) => { const immediate = typeof args[0] !== 'function' const [items, fn, opts] = immediate ? args : [null, args[0], args[1]] @@ -56,49 +98,6 @@ export const series = (...args) => { return immediate ? run(items) : run } -/* - Series - Eager" iterator (it processes all items and returns an array). -*/ -// export const safeMap = (...args) => { -// // FIX: Detect "immediate" by checking if the first arg is a function. -// // This allows immediate usage like: safeMap(myStream, fn) -// // (Because streams/generators are objects, not functions) -// const immediate = typeof args[0] !== 'function' -// const [items, fn, opts] = immediate ? args : [null, args[0], args[1]] - -// const execute = async inputItems => { -// const {onError = 'failFast', take} = opts || {} -// const results = [] -// const errors = [] - -// // FIX: Manual index tracking allows us to support ANY iterable (Streams/Generators) -// // "for await...of" handles both Arrays and Async Iterables automatically. -// let index = 0 -// for await (const item of inputItems) { -// // eslint-disable-next-line no-undefined -// if (take !== undefined && index >= take) { -// break -// } - -// try { -// results.push(await fn(item, index)) -// } catch (error) { -// if (onError === 'failFast') { -// return {results, errors, failure: {item, error}} -// } -// // Default behavior: 'collect' -// errors.push({item, error}) -// } - -// index++ -// } -// return {results, errors, failure: null} -// } - -// return immediate ? execute(items) : execute -// } - export const filter = (...args) => { const immediate = typeof args[0] !== 'function' const [items, predicate, opts] = immediate ? args : [null, args[0], args[1]] @@ -136,15 +135,6 @@ export const filter = (...args) => { return immediate ? run(items) : run } -/* - We use the same "immediate" vs "curried" pattern as execute - But here args are: (iterable, fn, init) or (fn, init) - Actually, usually scan takes (iterable, fn, init). - Let's stick to your execute pattern style if you like, - but typically scan is eager because you need the init value immediately. - - Let's keep it simple: Scan is almost always eager because of the 'initialValue'. -*/ export const safeScan = async (iterable, scanner, initialValue) => { const results = [] let acc = initialValue @@ -195,50 +185,6 @@ export async function * safeAsyncIterator (iterable, transform, { } } -export const tryCatch = (fn, { - onStart, onSuccess, onError, onFinally, - rethrow = false, -} = {}) => - async (...args) => { - // console.info('TRYCATCH |') - try { - if (onStart) - onStart() - // console.info('TRYCATCH | Started |', {args, onError}) - const result = await fn(...args) - // console.info('TRYCATCH | Result |', {result}) - if (onSuccess) - await onSuccess(result) - return result - } catch (error) { - if (onError) - await onError(error) - if (rethrow) - throw error - return null - } finally { - if (onFinally) - onFinally() - } - } - -export const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) - -export const retry = (fn, attempts) => async (...args) => { - let lastError - for (let i = 0; i < attempts; i++) { - try { - return await fn(...args) - } catch (error) { - lastError = error - // Optional: wait a bit? - } - } - throw lastError -} - -// export const execute = safeMap -// export const series = execute export const scan = safeScan export const pipe = pipeAsync From 6f345b81c066eba0e5ac90e8c0896081bac5727d Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:21:14 +0100 Subject: [PATCH 17/26] cleanup + the new retry --- src/functional.js | 71 +++++++++---------- ...nwrap-iterator.test.js => collect.test.js} | 6 +- tests/{safe-filter.test.js => filter.test.js} | 0 tests/{scan-series.test.js => scan.test.js} | 0 4 files changed, 37 insertions(+), 40 deletions(-) rename tests/{unwrap-iterator.test.js => collect.test.js} (72%) rename tests/{safe-filter.test.js => filter.test.js} (100%) rename tests/{scan-series.test.js => scan.test.js} (100%) diff --git a/src/functional.js b/src/functional.js index d938d3c..d86bcb0 100644 --- a/src/functional.js +++ b/src/functional.js @@ -30,66 +30,64 @@ export const tryCatch = (fn, { export const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) -export const retry = (fn, attempts) => async (...args) => { - let lastError - for (let i = 0; i < attempts; i++) { - try { - return await fn(...args) - } catch (error) { - lastError = error - // Optional: wait a bit? +export const retry = (fn, {attempts = 3, delay: delayMs = 0} = {}) => + async (...args) => { + let lastError + for (let i = 0; i < attempts; i++) { + try { + return await fn(...args) + } catch (error) { + lastError = error + + // If we have attempts left, wait and continue + const isLastAttempt = i === attempts - 1 + if (!isLastAttempt && delayMs > 0) { + await delay(delayMs) + } + } } + // If we get here, all attempts failed + throw lastError } - throw lastError -} export const series = (...args) => { const immediate = typeof args[0] !== 'function' - const [items, fn, opts] = immediate ? args : [null, args[0], args[1]] + const [items, fn, opts = {}] = immediate ? args : [null, args[0], args[1]] - // eslint-disable-next-line complexity, max-statements const run = async inputItems => { const { strategy = 'collect', - take, - onProgress, - onError, - } = opts || {} - + take, onProgress, onError, + } = opts const results = [] const errors = [] + // Wrap the function ONCE. + // It handles onProgress (via onSuccess) and onError (via onError). + // It rethrows so 'series' can handle the strategy. + const safeFn = tryCatch(fn, { + onSuccess: onProgress, + onError, + rethrow: true, + }) + let index = 0 for await (const item of inputItems) { // eslint-disable-next-line no-undefined - if (take !== undefined && index >= take) { + if (take !== undefined && index >= take) break - } try { - const result = await fn(item, index) + // Clean execution + const result = await safeFn(item, index) results.push(result) - - // Notify success - if (onProgress) { - await onProgress(result, item, index) - } } catch (error) { - if (onError) { - await onError(error, item, index) - } - + // Strategy Logic remains here if (strategy === 'failFast') { - return { - results, - errors, - failure: {item, error}, - } + return {results, errors, failure: {item, error}} } - errors.push({item, error}) } - index++ } return {results, errors, failure: null} @@ -188,5 +186,4 @@ export async function * safeAsyncIterator (iterable, transform, { export const scan = safeScan export const pipe = pipeAsync -export const unwrapIterator = iterator => series(iterator, x => x) export const collectAsync = iterator => series(iterator, x => x) diff --git a/tests/unwrap-iterator.test.js b/tests/collect.test.js similarity index 72% rename from tests/unwrap-iterator.test.js rename to tests/collect.test.js index e487424..aa46524 100644 --- a/tests/unwrap-iterator.test.js +++ b/tests/collect.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {unwrapIterator} from '$lib/functional' +import {collectAsync} from '$lib/functional' test('collects async iterator into array', async () => { const gen = async function * () { @@ -7,13 +7,13 @@ test('collects async iterator into array', async () => { yield 2 yield 3 } - const {results} = await unwrapIterator(gen()) + const {results} = await collectAsync(gen()) expect(results).toEqual([1, 2, 3]) }) test('handles empty iterator', async () => { // eslint-disable-next-line no-empty-function const gen = async function * () {} - const {results} = await unwrapIterator(gen()) + const {results} = await collectAsync(gen()) expect(results).toEqual([]) }) diff --git a/tests/safe-filter.test.js b/tests/filter.test.js similarity index 100% rename from tests/safe-filter.test.js rename to tests/filter.test.js diff --git a/tests/scan-series.test.js b/tests/scan.test.js similarity index 100% rename from tests/scan-series.test.js rename to tests/scan.test.js From a147074e7d5bef45ca20cb48cfda611fc38a9f89 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:49:37 +0100 Subject: [PATCH 18/26] corrected tht anomaly --- src/functional.js | 6 ++---- tests/collect.test.js | 2 +- tests/error-strategies.test.js | 2 +- tests/filter.test.js | 2 +- tests/pipe-async.test.js | 2 +- tests/pipe.test.js | 2 +- tests/scan.test.js | 2 +- tests/series-map.test.js | 2 +- tests/try-catch.test.js | 2 +- vitest.config.js | 1 - 10 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/functional.js b/src/functional.js index d86bcb0..a9db46c 100644 --- a/src/functional.js +++ b/src/functional.js @@ -6,13 +6,10 @@ export const tryCatch = (fn, { rethrow = false, } = {}) => async (...args) => { - // console.info('TRYCATCH |') try { if (onStart) onStart() - // console.info('TRYCATCH | Started |', {args, onError}) const result = await fn(...args) - // console.info('TRYCATCH | Result |', {result}) if (onSuccess) await onSuccess(result) return result @@ -35,13 +32,14 @@ export const retry = (fn, {attempts = 3, delay: delayMs = 0} = {}) => let lastError for (let i = 0; i < attempts; i++) { try { + // eslint-disable-next-line no-await-in-loop return await fn(...args) } catch (error) { lastError = error - // If we have attempts left, wait and continue const isLastAttempt = i === attempts - 1 if (!isLastAttempt && delayMs > 0) { + // eslint-disable-next-line no-await-in-loop await delay(delayMs) } } diff --git a/tests/collect.test.js b/tests/collect.test.js index aa46524..46109cd 100644 --- a/tests/collect.test.js +++ b/tests/collect.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {collectAsync} from '$lib/functional' +import {collectAsync} from '$src/functional' test('collects async iterator into array', async () => { const gen = async function * () { diff --git a/tests/error-strategies.test.js b/tests/error-strategies.test.js index f9c63ad..3aac2c2 100644 --- a/tests/error-strategies.test.js +++ b/tests/error-strategies.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {failFast, collect} from '..' +import {failFast, collect} from '$src/functional' test('failFast is a frozen object with name "failFast"', () => { expect(failFast).toEqual({name: 'failFast'}) diff --git a/tests/filter.test.js b/tests/filter.test.js index 8e7950a..f62ecb4 100644 --- a/tests/filter.test.js +++ b/tests/filter.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {filter, collect} from '$lib/functional' +import {filter, collect} from '$src/functional' test('predicate truthy keeps item in results', async () => { const result = await filter([1, 2, 3, 4], x => x > 2) diff --git a/tests/pipe-async.test.js b/tests/pipe-async.test.js index dc019cd..5829f7d 100644 --- a/tests/pipe-async.test.js +++ b/tests/pipe-async.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {pipeAsync} from '$lib/functional' +import {pipeAsync} from '$src/functional' test('composes functions left-to-right', async () => { const result = await pipeAsync(x => x * 2, x => x + 1)(5) diff --git a/tests/pipe.test.js b/tests/pipe.test.js index 4196720..6ce2a31 100644 --- a/tests/pipe.test.js +++ b/tests/pipe.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {pipe} from '$lib/functional' +import {pipe} from '$src/functional' test('composes functions left-to-right', async () => { // Must await because pipe is now async-safe diff --git a/tests/scan.test.js b/tests/scan.test.js index a4fdef2..657ea5c 100644 --- a/tests/scan.test.js +++ b/tests/scan.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {scanSeries} from '$lib/functional' +import {scanSeries} from '$src/functional' test('threads accumulator through items', async () => { const results = await scanSeries( diff --git a/tests/series-map.test.js b/tests/series-map.test.js index 2568428..dd4c39e 100644 --- a/tests/series-map.test.js +++ b/tests/series-map.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {series, collect} from '$lib/functional' +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) diff --git a/tests/try-catch.test.js b/tests/try-catch.test.js index 750a176..db4c27a 100644 --- a/tests/try-catch.test.js +++ b/tests/try-catch.test.js @@ -1,5 +1,5 @@ import {test, expect, vi} from 'vitest' -import {tryCatch} from '$lib/functional' +import {tryCatch} from '$src/functional' test('returns fn result on success', async () => { const wrapped = tryCatch(() => Promise.resolve(42)) diff --git a/vitest.config.js b/vitest.config.js index 290d52d..3b33cbc 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -5,7 +5,6 @@ export default defineConfig({ resolve: { alias: { $src: resolve(import.meta.dirname, 'src'), - $lib: resolve(import.meta.dirname, 'src'), }, }, test: { From 89ad344b35a72a9a55fd288daff4dc9218775aaa Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:53:21 +0100 Subject: [PATCH 19/26] cleanup comments --- src/functional.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/functional.js b/src/functional.js index a9db46c..b4045ee 100644 --- a/src/functional.js +++ b/src/functional.js @@ -44,7 +44,6 @@ export const retry = (fn, {attempts = 3, delay: delayMs = 0} = {}) => } } } - // If we get here, all attempts failed throw lastError } @@ -76,11 +75,9 @@ export const series = (...args) => { break try { - // Clean execution const result = await safeFn(item, index) results.push(result) } catch (error) { - // Strategy Logic remains here if (strategy === 'failFast') { return {results, errors, failure: {item, error}} } @@ -135,7 +132,6 @@ export const safeScan = async (iterable, scanner, initialValue) => { const results = [] let acc = initialValue - // We reuse the iteration logic for await (const item of iterable) { try { acc = await scanner(acc, item) @@ -164,6 +160,7 @@ export const pipeAsync = (...fns) => input => "Lazy" iterator (it yields items one by one). This forces the consumer to check every single item to 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, From aeb85c86c397fe9454e4292599ba5a284e6b0a43 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:57:57 +0100 Subject: [PATCH 20/26] retry tests --- tests/retry.test.js | 74 +++++++++++++++++++++++++++++++++++++++++++++ vitest.config.js | 3 ++ 2 files changed, 77 insertions(+) create mode 100644 tests/retry.test.js diff --git a/tests/retry.test.js b/tests/retry.test.js new file mode 100644 index 0000000..f6f0d3a --- /dev/null +++ b/tests/retry.test.js @@ -0,0 +1,74 @@ +import {test, expect, vi} from 'vitest' +import {retry} from '$src/functional' + +test('succeeds on first attempt (default)', async () => { + const fn = vi.fn().mockResolvedValue('ok') + const result = await retry(fn)() + expect(result).toBe('ok') + expect(fn).toHaveBeenCalledTimes(1) +}) + +test('succeeds after retries', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('fail 1')) + .mockRejectedValueOnce(new Error('fail 2')) + .mockResolvedValue('ok') + + const result = await retry(fn, {attempts: 3})() + expect(result).toBe('ok') + expect(fn).toHaveBeenCalledTimes(3) +}) + +test('throws if all attempts fail', async () => { + const error = new Error('persistent failure') + const fn = vi.fn().mockRejectedValue(error) + + await expect(retry(fn, {attempts: 2})()).rejects.toThrow('persistent failure') + expect(fn).toHaveBeenCalledTimes(2) +}) + +test('respects delay between retries', async () => { + vi.useFakeTimers() + const fn = vi.fn() + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValue('ok') + + const retryFn = retry(fn, {attempts: 2, delay: 1000}) + + // Start execution + const promise = retryFn() + + // Allow first attempt to fail (flush microtasks) + await Promise.resolve() + // At this point, the delay should have started + expect(fn).toHaveBeenCalledTimes(1) + + // Advance time by 1000ms + await vi.advanceTimersByTimeAsync(1000) + + // Now the second attempt should run + const result = await promise + expect(result).toBe('ok') + expect(fn).toHaveBeenCalledTimes(2) + + vi.useRealTimers() +}) + +test('does not delay after last attempt fails', async () => { + vi.useFakeTimers() + const fn = vi.fn().mockRejectedValue(new Error('fail')) + const retryFn = retry(fn, {attempts: 2, delay: 200}) + + const promise = retryFn() + + // 1. First attempt fails immediately. + // 2. The code hits 'await delay(200)' because it wasn't the last attempt yet. + // We must advance time to clear that delay so the loop continues. + await vi.advanceTimersByTimeAsync(200) + + // 3. Second attempt fails. + // 4. Now it IS the last attempt, so it skips the delay and throws immediately. + await expect(promise).rejects.toThrow('fail') + + vi.useRealTimers() +}) diff --git a/vitest.config.js b/vitest.config.js index 3b33cbc..1e97769 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -10,6 +10,9 @@ export default defineConfig({ test: { environment: 'node', globals: true, + testTimeout: 800, + hookTimeout: 1200, + teardownTimeout: 1200, reporters: ['verbose'], }, }) From 798209cba141ef84cbf88531bbdbcf69f121cf77 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:02:22 +0100 Subject: [PATCH 21/26] cleanup --- src/functional.js | 9 +++---- tests/pipe-async.test.js | 27 -------------------- tests/pipe.test.js | 9 ++++++- tests/retry.test.js | 12 ++++----- tests/{series-map.test.js => series.test.js} | 0 5 files changed, 18 insertions(+), 39 deletions(-) delete mode 100644 tests/pipe-async.test.js rename tests/{series-map.test.js => series.test.js} (100%) diff --git a/src/functional.js b/src/functional.js index b4045ee..de88a36 100644 --- a/src/functional.js +++ b/src/functional.js @@ -149,11 +149,13 @@ export const safeScan = async (iterable, scanner, initialValue) => { return {results, errors: [], failure: null} } +export const scan = safeScan + 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 pipeAsync = (...fns) => input => +export const pipe = (...fns) => input => fns.reduce(async (acc, fn) => fn(await acc), input) /* @@ -178,7 +180,4 @@ export async function * safeAsyncIterator (iterable, transform, { } } -export const scan = safeScan -export const pipe = pipeAsync - export const collectAsync = iterator => series(iterator, x => x) diff --git a/tests/pipe-async.test.js b/tests/pipe-async.test.js deleted file mode 100644 index 5829f7d..0000000 --- a/tests/pipe-async.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import {test, expect} from 'vitest' -import {pipeAsync} from '$src/functional' - -test('composes functions left-to-right', async () => { - const result = await pipeAsync(x => x * 2, x => x + 1)(5) - expect(result).toBe(11) -}) - -test('passes result through async chain', async () => { - const result = await pipeAsync( - x => Promise.resolve(x + 1), - x => Promise.resolve(x * 3), - )(2) - expect(result).toBe(9) -}) - -test('works with single function', async () => { - await expect(pipeAsync(x => x * 10)(4)).resolves.toBe(40) -}) - -test('propagates errors', async () => { - const pipeline = pipeAsync( - () => { throw new Error('pipe broke') }, - x => x + 1, - ) - await expect(pipeline(1)).rejects.toThrow('pipe broke') -}) diff --git a/tests/pipe.test.js b/tests/pipe.test.js index 6ce2a31..71dac6e 100644 --- a/tests/pipe.test.js +++ b/tests/pipe.test.js @@ -6,7 +6,6 @@ test('composes functions left-to-right', async () => { const result = await pipe(x => x * 2, x => x + 1)(5) expect(result).toBe(11) }) - test('passes result through chain', async () => { const result = await pipe( x => x + 1, @@ -28,3 +27,11 @@ test('propagates errors', async () => { // Promise rejection must be caught with rejects await expect(pipeline(1)).rejects.toThrow('pipe broke') }) + +test('passes result through async chain', async () => { + const result = await pipe( + x => Promise.resolve(x + 1), + x => Promise.resolve(x * 3), + )(2) + expect(result).toBe(9) +}) diff --git a/tests/retry.test.js b/tests/retry.test.js index f6f0d3a..dbffbe5 100644 --- a/tests/retry.test.js +++ b/tests/retry.test.js @@ -61,14 +61,14 @@ test('does not delay after last attempt fails', async () => { const promise = retryFn() - // 1. First attempt fails immediately. - // 2. The code hits 'await delay(200)' because it wasn't the last attempt yet. - // We must advance time to clear that delay so the loop continues. + // 1. Attach the expectation FIRST so it catches the rejection + const assertion = expect(promise).rejects.toThrow('fail') + + // 2. Advance time to trigger the retry logic await vi.advanceTimersByTimeAsync(200) - // 3. Second attempt fails. - // 4. Now it IS the last attempt, so it skips the delay and throws immediately. - await expect(promise).rejects.toThrow('fail') + // 3. Wait for the assertion to complete + await assertion vi.useRealTimers() }) diff --git a/tests/series-map.test.js b/tests/series.test.js similarity index 100% rename from tests/series-map.test.js rename to tests/series.test.js From 0aeb2f98e1be03091ea78b5fcf62cfa691cd1a30 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:04:37 +0100 Subject: [PATCH 22/26] perfected last retry test --- docs/superpowers/specs/2026-03-17-reframing.md | 14 ++++++++------ tests/retry.test.js | 14 ++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-03-17-reframing.md b/docs/superpowers/specs/2026-03-17-reframing.md index f1961f8..0ac423f 100644 --- a/docs/superpowers/specs/2026-03-17-reframing.md +++ b/docs/superpowers/specs/2026-03-17-reframing.md @@ -1,4 +1,4 @@ -# Architecture +# Core * `execute (series)` (Iteration): - It works *horizontally*. It takes a list of items and applies one transformation to each item in parallel or sequence. @@ -20,10 +20,10 @@ * `pipe / pipeAsync` (Composition): - It works *vertically*. - - It takes one item and passes it through a chain of functions, one after the other. - - should *not* handle iterables. - - is just a "Function Builder." It creates a single, composite function f. - - has no error strategy. + - It takes one item and passes it through a chain of functions, one after the other. + - should *not* handle iterables. + - is just a "Function Builder." It creates a single, composite function f. + - has no error strategy. * `tryCatch` is a function wrapper - "Protect the work" of the function @@ -36,9 +36,11 @@ * `retry`: a specialized trycatch - Perfect to combine in pipelines +## Basic usage + ```js const pipeline = pipe( - processTrack, // Standard + doSomething, // Standard retry(updateDb, 3), // This step gets 3 retries automatically notifyUI // Standard ) diff --git a/tests/retry.test.js b/tests/retry.test.js index dbffbe5..87ee1e7 100644 --- a/tests/retry.test.js +++ b/tests/retry.test.js @@ -61,14 +61,12 @@ test('does not delay after last attempt fails', async () => { const promise = retryFn() - // 1. Attach the expectation FIRST so it catches the rejection - const assertion = expect(promise).rejects.toThrow('fail') - - // 2. Advance time to trigger the retry logic - await vi.advanceTimersByTimeAsync(200) - - // 3. Wait for the assertion to complete - await assertion + // Use Promise.all to attach the assertion handler immediately + // while also advancing the timers. + await Promise.all([ + vi.advanceTimersByTimeAsync(200), + expect(promise).rejects.toThrow('fail'), + ]) vi.useRealTimers() }) From 6ae1a48d45eefb6870be8baa7118a5d9f0c7ab14 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:15:08 +0100 Subject: [PATCH 23/26] docs cleanup --- README.md | 143 --- docs/ARCHITECTURE_OPTIONS.md | 265 ---- docs/core.md | 69 ++ docs/docs.md | 6 - docs/principles-and-architecture.md | 15 + .../plans/2026-03-16-pipelean-redesign.md | 1085 ----------------- .../2026-03-16-pipelean-redesign-design.md | 233 ---- .../superpowers/specs/2026-03-17-reframing.md | 48 - docs/usage.md | 26 + 9 files changed, 110 insertions(+), 1780 deletions(-) delete mode 100644 docs/ARCHITECTURE_OPTIONS.md create mode 100644 docs/core.md delete mode 100644 docs/docs.md create mode 100644 docs/principles-and-architecture.md delete mode 100644 docs/superpowers/plans/2026-03-16-pipelean-redesign.md delete mode 100644 docs/superpowers/specs/2026-03-16-pipelean-redesign-design.md delete mode 100644 docs/superpowers/specs/2026-03-17-reframing.md create mode 100644 docs/usage.md diff --git a/README.md b/README.md index f4b4902..b0b257d 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,3 @@ # Pipelean Async-first, error-aware data transformation library. Never write a for loop again. - ---- - -## Core Concepts - -### Error Strategies - -Three strategies control how errors are handled across all operations: `failFast` (default, stops immediately), `skip` (continues, collects errors), `collect` (continues, yields error objects). Every operation accepts `{onError: strategy}` in its options. - -### Operations vs Pipelines - - * **Operations** (`safeMap`, `safeFilter`) transform individual items; they accept immediate or curried arguments. - * **Pipelines** (`safePipe`, `safeAsyncIterator`) compose operations and manage error propagation across steps. - -### Map, Filter, Scan, Batch — What's the Difference? - - * `safeMap` transforms each item (sync or async) - * `safeFilter` keeps items matching a predicate - * `scanSeries` accumulates a value while iterating - * `mapSeries` maps with a concurrency limit. - -The all return `{results, errors, failure}` for error handling. - -### Error Strategies Are Consistent - -Every *operation* and every *pipeline* defaults to `failFast`: stop on first error. Use `{onError: skip}` to continue past errors, or `{onError: collect}` to gather them. Same semantics everywhere — no surprises. - ---- - -## Operations - -### safeMap - -```js -// Immediate: array input, transform, options -const {results, errors, failure} = await safeMap(data, x => x * 2, {onError: skip}) - -// Curried: for use in pipelines -const double = safeMap(x => x * 2) -const pipeline = safePipe(double, ...) -``` - -Async transforms supported. Default: `failFast`. Returns `{results, errors, failure}`. - -### safeFilter - -```js -// Immediate -const {results, errors, failure} = await safeFilter(data, x => x > 5, {onError: collect}) - -// Curried -const bigOnly = safeFilter(x => x > 5) -``` - -Predicate can be async. Default: `failFast`. Same return structure. - -### mapSeries - -Shortcut for `safeMap({onError: none})` - - -### scanSeries - -```js -const runningTotal = await scanSeries(data, (acc, item) => acc + item, 0) -// Returns: [1, 3, 6, 10, ...] -``` - -Accumulates a value while iterating. Returns all intermediate results. - ---- - -## Pipelines - -### safePipe - -```js -const {results, errors, failure} = await safePipe( - safeMap(x => x * 2), - safeFilter(x => x > 5), - safeMap(async x => enrichData(x)) -)(data) -``` - -Chains operations with error propagation. Each step's errors accumulate; `failFast` in any step stops the entire pipeline. Returns structured result. - -### safeAsyncIterator - -```js -async function* enrichStream(source) { - const iterator = safeAsyncIterator(source, async item => ({...item, meta: await fetch(item.id)}), {onError: collect}) - for await (const result of iterator) { - yield result - } -} -``` - -Generator-based iteration. Lazy evaluation stops when you stop consuming. Default: `failFast`. Perfect for streaming large datasets. - ---- - -## Example: Full Pipeline - -```js -const data = [1, 2, 3, 4, 5] - -const {results, errors, failure} = await safePipe( - safeMap(x => x * 2, {onError: skip}), // double - safeFilter(x => x > 4, {onError: skip}), // keep > 4 - safeMap(async x => ({value: x, enriched: await fetchMeta(x)}), {onError: collect}) -)(data) - -// results: transformed data -// errors: accumulated errors from all steps -// failure: first failFast error (null if no failFast errors) -``` - ---- - -## tryCatch - -Wraps a function with hooks for start, success, error, finally. Not part of error strategies — use for side effects (logging, cleanup). For error handling in pipelines, use operations' `onError` option. - -```js -const wrapped = tryCatch(asyncFn, { - onStart: () => console.log('starting'), - onSuccess: (result) => console.log('done', result), - onError: (error) => console.error(error), - onFinally: () => cleanup() -}) - -const result = await wrapped(args) -``` - ---- - -## When to Use What - -**Need simple data transformation?** Start with `safeMap` + `safeFilter` in a `safePipe`. - -**Processing streams or large datasets?** Use `safeAsyncIterator` for lazy, memory-efficient iteration. - -**Mapping with concurrency limits?** Use `mapSeries`. diff --git a/docs/ARCHITECTURE_OPTIONS.md b/docs/ARCHITECTURE_OPTIONS.md deleted file mode 100644 index ff59101..0000000 --- a/docs/ARCHITECTURE_OPTIONS.md +++ /dev/null @@ -1,265 +0,0 @@ -# Pipelean Architecture Options - -Based on our analysis, here are three architectural approaches for redesigning the library. - -## Current Issues Identified - -1. **Redundant error strategies**: `skip` and `collect` are identical in implementation -2. **Error management in wrong place**: `safeMap`/`safeFilter` handle errors, but you believe errors should be managed at pipeline level -3. **Missing promised features**: `safePipe` mentioned in README but not implemented -4. **Inconsistent naming**: `unwrapIterator` vs `collectAsync`, `mapSeries` incomplete - -## Option 1: Pipeline-Centric Approach - -**Core Idea**: Individual operations are pure (succeed or throw), pipelines manage errors and composition. - -### Example Usage - -```javascript -// Pure operations that succeed or throw -const double = map(x => x * 2) -const filterEven = filter(x => x % 2 === 0) -const asyncEnrich = map(async x => ({...x, meta: await fetch(x.id)})) - -// Pipeline with error strategy -const pipeline = pipe( - double, - filterEven, - asyncEnrich -).withStrategy('collect') // or 'failFast', 'collectWithNotify' - -// Execute with error handling -const {results, errors, failure} = await pipeline([1, 2, 3, 4, 5]) - -// Or with iterator -for await (const result of pipeline.asIterator([1, 2, 3])) { - // yields transformed items or error objects based on strategy -} -``` - -### Implementation Sketch - -```javascript -// Pure operation -const map = (fn) => (item) => fn(item) - -// Pipeline builder -const pipe = (...operations) => { - const execute = async (items, strategy = 'failFast') => { - const results = [] - const errors = [] - - for await (const item of items) { - try { - let value = item - for (const op of operations) { - value = await op(value) - } - results.push(value) - } catch (error) { - if (strategy === 'failFast') { - return {results, errors, failure: {item, error}} - } - errors.push({item, error}) - if (strategy === 'collect') { - // skip item - } else if (strategy === 'collectWithNotify') { - // call notification callback - } - } - } - return {results, errors, failure: null} - } - - return { - withStrategy: (strategy) => (items) => execute(items, strategy), - asIterator: (strategy) => async function* (items) { /* ... */ } - } -} -``` - -**Pros**: -- Clean separation of concerns -- Operations are simple and testable -- Easy to add new error strategies -- Consistent with functional programming principles - -**Cons**: -- More boilerplate for simple cases -- Error handling detached from operation logic - -## Option 2: Unified Transformer Approach - -**Core Idea**: Single configurable transformation function that can behave as map, filter, reduce, etc. - -### Example Usage - -```javascript -// Configure as different operations -const double = transform({ - type: 'map', - fn: x => x * 2, - onError: 'skip' // error strategy per operation -}) - -const filterEven = transform({ - type: 'filter', - fn: x => x % 2 === 0, - onError: 'collect' -}) - -const sum = transform({ - type: 'reduce', - fn: (acc, x) => acc + x, - initial: 0, - onError: 'failFast' -}) - -// Compose transforms -const pipeline = compose(double, filterEven, sum) - -// Execute -const result = await pipeline([1, 2, 3, 4, 5]) -// result = {value: 12, errors: [], failure: null} -``` - -### Implementation Sketch - -```javascript -const transform = (config) => { - const {type, fn, onError = 'failFast', ...rest} = config - - return async (input) => { - if (type === 'map') { - return transformMap(input, fn, onError) - } else if (type === 'filter') { - return transformFilter(input, fn, onError) - } else if (type === 'reduce') { - return transformReduce(input, fn, onError, rest.initial) - } - } -} - -const compose = (...transforms) => async (input) => { - let current = input - const allErrors = [] - - for (const t of transforms) { - const result = await t(current) - if (result.failure) return {value: null, errors: allErrors, failure: result.failure} - allErrors.push(...result.errors) - current = result.value - } - - return {value: current, errors: allErrors, failure: null} -} -``` - -**Pros**: -- Single API to learn -- Highly configurable -- Consistent error handling across operation types -- Easy to extend with new operation types - -**Cons**: -- Configuration over convention -- Less intuitive than named functions -- Type checking more complex - -## Option 3: Iterator-First Approach - -**Core Idea**: Everything is an async iterator transformation, with error strategies built into the iteration protocol. - -### Example Usage - -```javascript -// Create transforming iterators -const doubleIterator = mapIterator(x => x * 2) -const filterEvenIterator = filterIterator(x => x % 2 === 0) - -// Compose iterators -const pipelineIterator = composeIterators( - doubleIterator, - filterEvenIterator -) - -// Use with error strategy -const iterator = pipelineIterator([1, 2, 3, 4, 5], {onError: 'collect'}) - -// Iterate with errors handled -for await (const item of iterator) { - // item is either transformed value or {error, originalItem} - if (item.error) { - console.log('Error:', item.error) - continue - } - console.log('Result:', item) -} - -// Or collect all -const {results, errors} = await collectIterator(iterator) -``` - -### Implementation Sketch - -```javascript -async function* mapIterator(fn) { - for await (const item of this) { - try { - yield await fn(item) - } catch (error) { - if (this.onError === 'failFast') throw error - if (this.onError === 'skip') continue - if (this.onError === 'collect') yield {error, item} - } - } -} - -const composeIterators = (...iteratorFns) => { - return async function* (source, options) { - let current = source - for (const iteratorFn of iteratorFns) { - current = iteratorFn.call({onError: options.onError}, current) - } - yield* current - } -} - -const collectIterator = async (iterator) => { - const results = [] - const errors = [] - - for await (const item of iterator) { - if (item && item.error) { - errors.push(item) - } else { - results.push(item) - } - } - - return {results, errors} -} -``` - -**Pros**: -- Natural fit for async/streaming data -- Lazy evaluation by default -- Easy to integrate with existing async iterators -- Memory efficient for large datasets - -**Cons**: -- Iterator protocol can be unfamiliar -- More complex error handling in consumer code -- Two modes of consumption (iteration vs collection) - -## Recommendation - -Given your goals and the current codebase, I recommend a **hybrid of Options 1 and 3**: - -1. **Pipeline-centric composition** for the high-level API -2. **Iterator-based implementation** for efficiency and streaming support -3. **Clear separation**: pure operations + pipeline error management - -This aligns with your insight that "a single operation can just succeed or fail, nothing else, is the pipe that has an error strategy." - -The next step would be to design the exact API based on this hybrid approach. \ No newline at end of file diff --git a/docs/core.md b/docs/core.md new file mode 100644 index 0000000..0e9953e --- /dev/null +++ b/docs/core.md @@ -0,0 +1,69 @@ +# Guide + +## Core + +We have four distinct tools, separated by the Direction of Data Flow and the State Dependency. + +1. series (Horizontal / Stateless) + + What it does: Iterates over a List of Items. Applies a transformation horizontally. + Data Flow: Item A → + Result A. Item B + → + Result B. (Independent). + State: Stateless. Item B does not know about Item A. + Error Strategy: Default is collect. (Gathers errors, keeps processing). + Responsibility: Orchestration. It manages the loop, handles the strategy, and reports progress (onProgress). + + +2. scan (Horizontal / Stateful) + + What it does: Iterates over a List of Items, accumulating state. + Data Flow: Item B depends on the result of Item A. + State: Stateful. Passes an accumulator forward. + Error Strategy: Hardcoded to failFast. (If Item A fails, Item B cannot run). + Responsibility: Reduction and Aggregation. + + +3. filter (Horizontal / Selection) + + What it does: Iterates over a List of Items. Selects a subset. + Data Flow: Item → + Predicate + → + Keep or Discard. + State: Stateless. + Error Strategy: Default is collect. + + +4. pipe (Vertical / Composition) + + What it does: Composes a List of Functions. Chains logic vertically. + Data Flow: Input → + Step 1 + → + Step 2 + → + Output. + Error Strategy: None. It is "dumb." It simply builds a single composite function. If a step throws, the pipe throws. + Responsibility: Logic composition. + + +## The Function Wrappers + +These are "Middleware" for your functions. They wrap a single unit of work to add behavior. + +5. tryCatch (Lifecycle Middleware) + + What it is: A pipeline of length 1. + Responsibility: Protection. It isolates a function, handling its lifecycle (onStart, onSuccess, onError, onFinally). + Use Case: + Adding local telemetry to a specific step in a pipeline. + Swallowing errors locally (returning null) while reporting to a monitor. + Handling "Side Effects" without polluting the main logic. + +6. retry (Resiliency Middleware) + + What it is: A specialized version of tryCatch. + Responsibility: Resiliency. It re-attempts a function if it fails. + Use Case: Wrapping flaky network calls (e.g., retry(apiCall, { attempts: 3 })). diff --git a/docs/docs.md b/docs/docs.md deleted file mode 100644 index c678179..0000000 --- a/docs/docs.md +++ /dev/null @@ -1,6 +0,0 @@ -# Documentation - -Markdown is good. - -```sh -``` diff --git a/docs/principles-and-architecture.md b/docs/principles-and-architecture.md new file mode 100644 index 0000000..1de736a --- /dev/null +++ b/docs/principles-and-architecture.md @@ -0,0 +1,15 @@ +# Design Principles & Architecture + +The Philosophy: Pragmatism Over Purity + +We built this library to be a practical tool for executing tasks, not a theoretical academic exercise. + +Many functional libraries are "Iterator-First" (lazy, yielding generators). We explicitly rejected that approach. Why? + + * Debugging is harder: Lazy execution makes stack traces difficult to read. + * Control is deferred: You don't know if an operation fails until you consume the iterator. + * Complexity: It requires users to understand generators and composition patterns just to run a simple list of tasks. + +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. diff --git a/docs/superpowers/plans/2026-03-16-pipelean-redesign.md b/docs/superpowers/plans/2026-03-16-pipelean-redesign.md deleted file mode 100644 index f35e71b..0000000 --- a/docs/superpowers/plans/2026-03-16-pipelean-redesign.md +++ /dev/null @@ -1,1085 +0,0 @@ -# Pipelean Redesign Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement iterator-first library with pipeline error management, replacing current safeMap/safeFilter approach. - -**Architecture:** Core iterator transformations (map, filter, reduce) that are pure functions, composed via `compose()`, with error strategies (failFast, collect, notify) applied at pipeline level. `collect()` function gathers results with error handling. - -**Tech Stack:** JavaScript (ESM), Vitest for testing, async iterators - ---- - -## File Structure - -### New Files -- `src/iterator-core.js` - Core iterator transformations (map, filter, reduce) -- `src/composition.js` - Composition and pipeline functions -- `src/collection.js` - Result collection with error handling -- `src/index.js` - Updated main exports -- `tests/iterator-core.test.js` - Tests for core transformations -- `tests/composition.test.js` - Tests for composition -- `tests/collection.test.js` - Tests for collection -- `tests/integration.test.js` - End-to-end tests - -### Modified Files -- `src/functional.js` - Deprecate safeMap, safeFilter, keep utilities (pipe, pipeAsync, tryCatch) -- `src/index.js` - Re-export new API -- `README.md` - Update documentation - -### Deprecated (to be removed later) -- `tests/safe-map.test.js` -- `tests/safe-filter.test.js` -- `tests/error-strategies.test.js` - ---- - -## Chunk 1: Core Iterator Transformations - -### Task 1: Create iterator-core.js with map transformation - -**Files:** -- Create: `src/iterator-core.js` -- Test: `tests/iterator-core.test.js` - -- [ ] **Step 1: Write failing test for map transformation** - -```javascript -import {test, expect} from 'vitest' -import {map} from '$lib/iterator-core' - -test('map creates a transformer that applies function to each item', async () => { - const double = map(x => x * 2) - const source = [1, 2, 3] - const iterator = double(source, {onError: 'failFast'}) - - const results = [] - for await (const item of iterator) { - results.push(item) - } - - expect(results).toEqual([2, 4, 6]) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npm test tests/iterator-core.test.js -t "map creates a transformer"` -Expected: FAIL with "Cannot find module '$lib/iterator-core'" - -- [ ] **Step 3: Create iterator-core.js with map function** - -```javascript -export function map(fn) { - return async function* (source, options = {}) { - const {onError = 'failFast'} = options - - for await (const item of source) { - try { - yield await fn(item) - } catch (error) { - if (onError === 'failFast') { - throw error - } - // For collect/notify strategies, skip item - // Error handling done at collection level - } - } - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npm test tests/iterator-core.test.js -t "map creates a transformer"` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/iterator-core.js tests/iterator-core.test.js -git commit -m "feat: add map iterator transformation" -``` - -### Task 2: Add filter transformation - -**Files:** -- Modify: `src/iterator-core.js` -- Test: `tests/iterator-core.test.js` - -- [ ] **Step 1: Write failing test for filter transformation** - -```javascript -test('filter creates a transformer that filters items by predicate', async () => { - const evenOnly = filter(x => x % 2 === 0) - const source = [1, 2, 3, 4, 5] - const iterator = evenOnly(source, {onError: 'failFast'}) - - const results = [] - for await (const item of iterator) { - results.push(item) - } - - expect(results).toEqual([2, 4]) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npm test tests/iterator-core.test.js -t "filter creates a transformer"` -Expected: FAIL with "filter is not defined" - -- [ ] **Step 3: Add filter function to iterator-core.js** - -```javascript -export function filter(predicate) { - return async function* (source, options = {}) { - const {onError = 'failFast'} = options - - for await (const item of source) { - try { - const keep = await predicate(item) - if (keep) { - yield item - } - } catch (error) { - if (onError === 'failFast') { - throw error - } - // For collect/notify strategies, skip item - } - } - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npm test tests/iterator-core.test.js -t "filter creates a transformer"` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/iterator-core.js tests/iterator-core.test.js -git commit -m "feat: add filter iterator transformation" -``` - -### Task 3: Add reduce transformation - -**Files:** -- Modify: `src/iterator-core.js` -- Test: `tests/iterator-core.test.js` - -- [ ] **Step 1: Write failing test for reduce transformation** - -```javascript -test('reduce creates a transformer that accumulates values', async () => { - const sum = reduce((acc, x) => acc + x, 0) - const source = [1, 2, 3, 4] - const iterator = sum(source, {onError: 'failFast'}) - - const results = [] - for await (const item of iterator) { - results.push(item) - } - - // reduce yields accumulated value after each item - expect(results).toEqual([1, 3, 6, 10]) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npm test tests/iterator-core.test.js -t "reduce creates a transformer"` -Expected: FAIL with "reduce is not defined" - -- [ ] **Step 3: Add reduce function to iterator-core.js** - -```javascript -export function reduce(fn, initial) { - return async function* (source, options = {}) { - const {onError = 'failFast'} = options - let accumulator = initial - - for await (const item of source) { - try { - accumulator = await fn(accumulator, item) - yield accumulator - } catch (error) { - if (onError === 'failFast') { - throw error - } - // For collect/notify strategies, skip item - } - } - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npm test tests/iterator-core.test.js -t "reduce creates a transformer"` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/iterator-core.js tests/iterator-core.test.js -git commit -m "feat: add reduce iterator transformation" -``` - ---- - -## Chunk 2: Composition and Pipeline - -### Task 4: Create composition.js with compose function - -**Files:** -- Create: `src/composition.js` -- Test: `tests/composition.test.js` - -- [ ] **Step 1: Write failing test for compose function** - -```javascript -import {test, expect} from 'vitest' -import {compose} from '$lib/composition' -import {map, filter} from '$lib/iterator-core' - -test('compose chains multiple transformers', async () => { - const pipeline = compose( - map(x => x * 2), - filter(x => x > 5) - ) - - const source = [1, 2, 3, 4, 5] - const iterator = pipeline(source, {onError: 'failFast'}) - - const results = [] - for await (const item of iterator) { - results.push(item) - } - - expect(results).toEqual([6, 8, 10]) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npm test tests/composition.test.js -t "compose chains multiple transformers"` -Expected: FAIL with "Cannot find module '$lib/composition'" - -- [ ] **Step 3: Create composition.js with compose function** - -```javascript -export function compose(...transformers) { - return async function* (source, options = {}) { - let current = source - for (const transformer of transformers) { - current = transformer(current, options) - } - yield* current - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npm test tests/composition.test.js -t "compose chains multiple transformers"` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/composition.js tests/composition.test.js -git commit -m "feat: add compose function for transformer composition" -``` - -### Task 5: Add error strategy propagation tests - -**Files:** -- Test: `tests/composition.test.js` - -- [ ] **Step 1: Write test for failFast error propagation** - -```javascript -test('failFast error in any transformer stops entire pipeline', async () => { - const pipeline = compose( - map(x => x * 2), - map(x => { - if (x === 6) throw new Error('Bad value') - return x - }), - filter(x => x > 0) - ) - - const source = [1, 2, 3] - const iterator = pipeline(source, {onError: 'failFast'}) - - await expect(async () => { - const results = [] - for await (const item of iterator) { - results.push(item) - } - }).rejects.toThrow('Bad value') -}) -``` - -- [ ] **Step 2: Run test to verify it passes** - -Run: `npm test tests/composition.test.js -t "failFast error in any transformer"` -Expected: PASS (should already work with current implementation) - -- [ ] **Step 3: Write test for error strategy consistency** - -```javascript -test('error strategy applies to all transformers in pipeline', async () => { - const pipeline = compose( - map(x => { - if (x === 2) throw new Error('First error') - return x - }), - map(x => { - if (x === 4) throw new Error('Second error') - return x * 10 - }) - ) - - const source = [1, 2, 3, 4, 5] - // This test will be expanded when collect is implemented - const iterator = pipeline(source, {onError: 'failFast'}) - - await expect(async () => { - for await (const item of iterator) { - // Should throw on first error (x=2) - } - }).rejects.toThrow('First error') -}) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npm test tests/composition.test.js -t "error strategy applies to all transformers"` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add tests/composition.test.js -git commit -m "test: add error strategy propagation tests" -``` - ---- - -## Chunk 3: Collection and Error Handling - -### Task 6: Create collection.js with collect function - -**Files:** -- Create: `src/collection.js` -- Test: `tests/collection.test.js` - -- [ ] **Step 1: Write failing test for collect function** - -```javascript -import {test, expect} from 'vitest' -import {collect} from '$lib/collection' - -test('collect gathers results from iterator', async () => { - async function* simpleIterator() { - yield 1 - yield 2 - yield 3 - } - - const {results, errors} = await collect(simpleIterator()) - - expect(results).toEqual([1, 2, 3]) - expect(errors).toEqual([]) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npm test tests/collection.test.js -t "collect gathers results from iterator"` -Expected: FAIL with "Cannot find module '$lib/collection'" - -- [ ] **Step 3: Create collection.js with basic collect function** - -```javascript -export async function collect(iterator, options = {}) { - const {onError = 'failFast'} = options - const results = [] - const errors = [] - - try { - for await (const item of iterator) { - results.push(item) - } - } catch (error) { - if (onError === 'failFast') { - throw error - } - // For collect/notify strategies, handle differently - } - - return {results, errors} -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npm test tests/collection.test.js -t "collect gathers results from iterator"` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/collection.js tests/collection.test.js -git commit -m "feat: add basic collect function" -``` - -### Task 7: Implement collect error strategy - -**Files:** -- Modify: `src/collection.js` -- Test: `tests/collection.test.js` - -- [ ] **Step 1: Write test for collect error strategy** - -```javascript -test('collect strategy accumulates errors and continues', async () => { - async function* errorIterator() { - yield 1 - throw new Error('Test error') - // Note: iterator stops after throw, need different approach - } - - // This test will need adjustment based on implementation - const iterator = errorIterator() - const {results, errors} = await collect(iterator, {onError: 'collect'}) - - expect(results).toEqual([1]) - expect(errors).toHaveLength(1) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npm test tests/collection.test.js -t "collect strategy accumulates errors"` -Expected: FAIL (iterator stops on throw) - -- [ ] **Step 3: Update collect to handle errors from transformers** - -We need to change approach: errors should be caught at transformer level, not iterator level. Update the test first: - -```javascript -test('collect strategy accumulates errors from failing transformations', async () => { - const {map} = await import('$lib/iterator-core') - const {compose} = await import('$lib/composition') - - const pipeline = compose( - map(x => { - if (x === 2) throw new Error('Bad value: ' + x) - return x * 10 - }) - ) - - const source = [1, 2, 3, 4] - const iterator = pipeline(source, {onError: 'collect'}) - const {results, errors} = await collect(iterator) - - expect(results).toEqual([10, 30, 40]) // 2 is skipped - expect(errors).toEqual([ - {item: 2, error: new Error('Bad value: 2')} - ]) -}) -``` - -- [ ] **Step 4: Update iterator transformations to yield errors for collect strategy** - -Modify `src/iterator-core.js` map function: - -```javascript -export function map(fn) { - return async function* (source, options = {}) { - const {onError = 'failFast'} = options - - for await (const item of source) { - try { - yield await fn(item) - } catch (error) { - if (onError === 'failFast') { - throw error - } - // For collect strategy, yield error object - if (onError === 'collect' || onError === 'notify') { - yield {__error: true, error, item} - } - // For skip (old behavior), just continue - } - } - } -} -``` - -- [ ] **Step 5: Update collect to handle error objects** - -Modify `src/collection.js`: - -```javascript -export async function collect(iterator, options = {}) { - const {onError = 'failFast', notify} = options - const results = [] - const errors = [] - - try { - for await (const item of iterator) { - if (item && item.__error) { - errors.push({item: item.item, error: item.error}) - if (onError === 'notify' && notify) { - notify(item.error, item.item) - } - } else { - results.push(item) - } - } - } catch (error) { - if (onError === 'failFast') { - throw error - } - // Should not reach here for collect/notify strategies - } - - return {results, errors} -} -``` - -- [ ] **Step 6: Run test to verify it passes** - -Run: `npm test tests/collection.test.js -t "collect strategy accumulates errors"` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add src/iterator-core.js src/collection.js tests/collection.test.js -git commit -m "feat: implement collect error strategy with error objects" -``` - -### Task 8: Implement notify error strategy - -**Files:** -- Modify: `src/collection.js` -- Test: `tests/collection.test.js` - -- [ ] **Step 1: Write test for notify error strategy** - -```javascript -test('notify strategy calls callback for each error', async () => { - const {map} = await import('$lib/iterator-core') - const {compose} = await import('$lib/composition') - - const pipeline = compose( - map(x => { - if (x === 2 || x === 4) throw new Error('Bad: ' + x) - return x * 10 - }) - ) - - const notifications = [] - const source = [1, 2, 3, 4, 5] - const iterator = pipeline(source, {onError: 'notify'}) - const {results, errors} = await collect(iterator, { - onError: 'notify', - notify: (error, item) => { - notifications.push({item, message: error.message}) - } - }) - - expect(results).toEqual([10, 30, 50]) - expect(errors).toHaveLength(2) - expect(notifications).toEqual([ - {item: 2, message: 'Bad: 2'}, - {item: 4, message: 'Bad: 4'} - ]) -}) -``` - -- [ ] **Step 2: Run test to verify it passes** - -Run: `npm test tests/collection.test.js -t "notify strategy calls callback"` -Expected: PASS (should work with current implementation) - -- [ ] **Step 3: Update filter and reduce to use same error pattern** - -Modify `src/iterator-core.js` filter and reduce functions to yield error objects: - -```javascript -export function filter(predicate) { - return async function* (source, options = {}) { - const {onError = 'failFast'} = options - - for await (const item of source) { - try { - const keep = await predicate(item) - if (keep) { - yield item - } - } catch (error) { - if (onError === 'failFast') { - throw error - } - if (onError === 'collect' || onError === 'notify') { - yield {__error: true, error, item} - } - } - } - } -} - -export function reduce(fn, initial) { - return async function* (source, options = {}) { - const {onError = 'failFast'} = options - let accumulator = initial - - for await (const item of source) { - try { - accumulator = await fn(accumulator, item) - yield accumulator - } catch (error) { - if (onError === 'failFast') { - throw error - } - if (onError === 'collect' || onError === 'notify') { - yield {__error: true, error, item} - } - } - } - } -} -``` - -- [ ] **Step 4: Run all collection tests** - -Run: `npm test tests/collection.test.js` -Expected: All PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/iterator-core.js tests/collection.test.js -git commit -m "feat: implement notify strategy and update all transformers" -``` - ---- - -## Chunk 4: Integration and API Updates - -### Task 9: Update main exports - -**Files:** -- Modify: `src/index.js` -- Create: `src/iterator-index.js` (optional, for new API) - -- [ ] **Step 1: Create new index for iterator API** - -```javascript -// src/iterator-index.js -export {map, filter, reduce} from './iterator-core.js' -export {compose} from './composition.js' -export {collect} from './collection.js' - -// Re-export utilities from functional.js -export {pipe, pipeAsync, tryCatch} from './functional.js' -export {unwrapIterator} from './functional.js' // Keep for compatibility -``` - -- [ ] **Step 2: Update main index.js** - -```javascript -// src/index.js -// New iterator API -export {map, filter, reduce} from './iterator-core.js' -export {compose} from './composition.js' -export {collect} from './collection.js' - -// Legacy API (mark as deprecated) -export {safeMap, safeFilter, mapSeries, scanSeries} from './functional.js' -export {failFast, skip, collect as collectStrategy} from './functional.js' -export {pipe, pipeAsync, tryCatch, unwrapIterator} from './functional.js' -export {safeAsyncIterator, collectAsync} from './functional.js' -``` - -- [ ] **Step 3: Add deprecation warnings to functional.js** - -Add at top of `src/functional.js`: - -```javascript -// DEPRECATION NOTICE -// safeMap and safeFilter are deprecated in favor of iterator API -// Use: collect(compose(map(fn))(array), {onError: 'collect'}) -// instead of: safeMap(array, fn, {onError: collect}) - -console.warn('pipelean: safeMap/safeFilter are deprecated. Use iterator API (map, filter, compose, collect).') -``` - -- [ ] **Step 4: Run existing tests to ensure compatibility** - -Run: `npm test` -Expected: Most tests pass, some may fail due to API changes - -- [ ] **Step 5: Commit** - -```bash -git add src/index.js src/iterator-index.js src/functional.js -git commit -m "feat: update exports and add deprecation warnings" -``` - -### Task 10: Create integration tests - -**Files:** -- Create: `tests/integration.test.js` - -- [ ] **Step 1: Write end-to-end integration test** - -```javascript -import {test, expect} from 'vitest' -import {map, filter, compose, collect} from '$lib' - -test('complete pipeline with error handling', async () => { - const pipeline = compose( - map(x => { - if (x === 0) throw new Error('Zero not allowed') - return x * 2 - }), - filter(x => x > 5), - map(async x => { - // Simulate async operation - return Promise.resolve({value: x, squared: x * x}) - }) - ) - - const data = [0, 1, 2, 3, 4, 5] - const iterator = pipeline(data, {onError: 'collect'}) - const {results, errors} = await collect(iterator) - - expect(errors).toEqual([ - {item: 0, error: new Error('Zero not allowed')} - ]) - - // 1*2=2 (filtered out), 2*2=4 (filtered out), 3*2=6, 4*2=8, 5*2=10 - expect(results).toEqual([ - {value: 6, squared: 36}, - {value: 8, squared: 64}, - {value: 10, squared: 100} - ]) -}) -``` - -- [ ] **Step 2: Write test for lazy iteration** - -```javascript -test('lazy iteration with large dataset', async () => { - async function* generateNumbers(limit) { - for (let i = 0; i < limit; i++) { - yield i - } - } - - const pipeline = compose( - map(x => x * 2), - filter(x => x % 3 === 0) - ) - - const iterator = pipeline(generateNumbers(1000), {onError: 'collect'}) - - let count = 0 - for await (const item of iterator) { - count++ - expect(item % 3).toBe(0) - expect(item % 2).toBe(0) - } - - // Should process all items without collecting all at once - expect(count).toBeGreaterThan(0) -}) -``` - -- [ ] **Step 3: Write test for migration compatibility** - -```javascript -test('migration example from safeMap to new API', async () => { - // Old way (deprecated) - // const {results, errors} = await safeMap([1, 2, 3], x => x * 2, {onError: collect}) - - // New way - const {map, compose, collect} = await import('$lib') - const double = map(x => x * 2) - const iterator = compose(double)([1, 2, 3], {onError: 'collect'}) - const {results, errors} = await collect(iterator) - - expect(results).toEqual([2, 4, 6]) - expect(errors).toEqual([]) -}) -``` - -- [ ] **Step 4: Run integration tests** - -Run: `npm test tests/integration.test.js` -Expected: All PASS - -- [ ] **Step 5: Commit** - -```bash -git add tests/integration.test.js -git commit -m "test: add integration tests for new API" -``` - ---- - -## Chunk 5: Documentation and Cleanup - -### Task 11: Update README.md - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Update core concepts section** - -Replace the "Core Concepts" section with: - -```markdown -## Core Concepts - -### Iterator-First Design -Pipelean transforms async iterators. Everything is a transformation pipeline that can process arrays, streams, or any async iterable. - -### Error Strategies -Three strategies control error handling at the pipeline level: -- **`failFast`** (default): Stop on first error -- **`collect`**: Continue, accumulate errors in result -- **`notify`**: Continue, accumulate errors, call notification callback - -### Pure Operations -Operations (`map`, `filter`, `reduce`) are pure functions that succeed or throw. Error handling is separate at the pipeline level. - -### Composition -Transformations are composed with `compose()` to build pipelines. Results are collected with `collect()`. -``` - -- [ ] **Step 2: Update operations section** - -Replace with new API examples: - -```markdown -## Operations - -### map(fn) -Creates a mapping iterator transformation. - -```javascript -const double = map(x => x * 2) -const iterator = double([1, 2, 3], {onError: 'collect'}) -``` - -### filter(predicate) -Creates a filtering iterator transformation. - -```javascript -const evenOnly = filter(x => x % 2 === 0) -``` - -### reduce(fn, initial) -Creates a reducing iterator transformation. - -```javascript -const sum = reduce((acc, x) => acc + x, 0) -``` - -### compose(...transformers) -Composes multiple transformations into a pipeline. - -```javascript -const pipeline = compose( - map(x => x * 2), - filter(x => x > 5), - map(async x => ({value: x})) -) -``` - -### collect(iterator, options) -Collects results from an iterator with error handling. - -```javascript -const {results, errors} = await collect(iterator, { - onError: 'collect', - notify: (error, item) => console.error('Error:', error) -}) -``` -``` - -- [ ] **Step 3: Update examples section** - -Replace with new examples: - -```markdown -## Example: Complete Pipeline - -```javascript -import {map, filter, compose, collect} from 'pipelean' - -const pipeline = compose( - map(x => { - if (x === 0) throw new Error('Zero not allowed') - return x * 2 - }), - filter(x => x > 5), - map(async x => ({value: x, meta: await fetchMeta(x)})) -) - -const data = [1, 2, 3, 4, 5] -const iterator = pipeline(data, {onError: 'collect'}) -const {results, errors} = await collect(iterator) - -// results: transformed successful items -// errors: accumulated error information -``` - -## Migration from Legacy API - -| Legacy API | New API | -|------------|---------| -| `safeMap(array, fn, {onError})` | `collect(compose(map(fn))(array), {onError})` | -| `safeFilter(array, pred, {onError})` | `collect(compose(filter(pred))(array), {onError})` | -| `safePipe(op1, op2, op3)` | `compose(op1, op2, op3)` | -``` - -- [ ] **Step 4: Run documentation verification** - -Check that all code examples are valid JavaScript. - -- [ ] **Step 5: Commit** - -```bash -git add README.md -git commit -m "docs: update README for new iterator API" -``` - -### Task 12: Clean up deprecated tests - -**Files:** -- Delete: `tests/safe-map.test.js` -- Delete: `tests/safe-filter.test.js` -- Modify: `tests/error-strategies.test.js` (update or delete) - -- [ ] **Step 1: Delete deprecated test files** - -```bash -rm tests/safe-map.test.js -rm tests/safe-filter.test.js -``` - -- [ ] **Step 2: Update error-strategies test** - -Either delete or update to test new error strategy objects: - -```javascript -// tests/error-strategies.test.js (updated) -import {test, expect} from 'vitest' - -test('error strategies are string constants', () => { - // In new API, strategies are strings, not objects - const strategies = ['failFast', 'collect', 'notify'] - expect(strategies).toHaveLength(3) - expect(strategies).toContain('failFast') - expect(strategies).toContain('collect') - expect(strategies).toContain('notify') -}) -``` - -- [ ] **Step 3: Run all tests to ensure everything works** - -Run: `npm test` -Expected: All tests pass - -- [ ] **Step 4: Commit** - -```bash -git add tests/ -git commit -m "chore: clean up deprecated tests" -``` - ---- - -## Final Verification - -### Task 13: Final integration test - -**Files:** -- Test: Run complete test suite - -- [ ] **Step 1: Run full test suite** - -```bash -npm test -``` - -Expected: All tests pass - -- [ ] **Step 2: Check bundle size and compatibility** - -```bash -# Check if any Node.js specific APIs are used -grep -r "require\|process\.\|__dirname\|__filename" src/ || echo "No Node.js specific APIs found" - -# Check ESM exports -node -e "import('./src/index.js').then(m => console.log('Exports:', Object.keys(m))).catch(e => console.error(e))" -``` - -- [ ] **Step 3: Create simple usage example** - -Create `examples/basic-usage.js`: - -```javascript -import {map, filter, compose, collect} from './src/index.js' - -async function main() { - const pipeline = compose( - map(x => x * 2), - filter(x => x > 5) - ) - - const data = [1, 2, 3, 4, 5] - const iterator = pipeline(data, {onError: 'collect'}) - const {results, errors} = await collect(iterator) - - console.log('Results:', results) // [6, 8, 10] - console.log('Errors:', errors) // [] -} - -main().catch(console.error) -``` - -- [ ] **Step 4: Run example** - -```bash -node examples/basic-usage.js -``` - -Expected: Output shows results [6, 8, 10] and empty errors - -- [ ] **Step 5: Final commit** - -```bash -git add examples/ -git commit -m "chore: add usage example and final verification" -``` - ---- - -## Plan Complete - -The implementation plan is now complete and saved to `docs/superpowers/plans/2026-03-16-pipelean-redesign.md`. - -**Ready to execute using superpowers:subagent-driven-development?** \ No newline at end of file diff --git a/docs/superpowers/specs/2026-03-16-pipelean-redesign-design.md b/docs/superpowers/specs/2026-03-16-pipelean-redesign-design.md deleted file mode 100644 index 3b77c06..0000000 --- a/docs/superpowers/specs/2026-03-16-pipelean-redesign-design.md +++ /dev/null @@ -1,233 +0,0 @@ -# Pipelean Redesign Specification - -## Overview - -Pipelean is being redesigned as an iterator-first library for composing async operations with consistent error handling. The core insight is that error management belongs at the pipeline level, not individual operations. - -## Core Principles - -1. **Iterator-First**: All transformations work on async iterators -2. **Pipeline Error Management**: Error strategies are applied at pipeline level -3. **Pure Operations**: Individual operations (map, filter, reduce) succeed or throw -4. **Unified Composition**: Single way to compose operations regardless of input type - -## API Design - -### Error Strategies - -Three error strategies control how errors are handled: - -1. **`failFast`** (default): Stop iteration and throw on first error -2. **`collect`**: Continue iteration, accumulate errors in final result -3. **`notify`**: Continue iteration, accumulate errors, and call notification callback - -### Core Functions - -#### `map(fn)` -Creates a mapping iterator transformation. - -```javascript -const double = map(x => x * 2) -// Returns: async iterator transformer -``` - -#### `filter(predicate)` -Creates a filtering iterator transformation. - -```javascript -const evenOnly = filter(x => x % 2 === 0) -``` - -#### `reduce(fn, initial)` -Creates a reducing iterator transformation. - -```javascript -const sum = reduce((acc, x) => acc + x, 0) -``` - -#### `compose(...transformers)` -Composes multiple iterator transformations. - -```javascript -const pipeline = compose( - map(x => x * 2), - filter(x => x > 5), - map(async x => ({value: x, meta: await fetchMeta(x)})) -) -``` - -#### `collect(iterator, options)` -Collects results from an iterator with error handling. - -```javascript -const {results, errors} = await collect(iterator, { - onError: 'collect', // or 'failFast', 'notify' - notify: (error, item) => console.error('Error:', error) // for 'notify' strategy -}) -``` - -### Usage Examples - -#### Basic Pipeline - -```javascript -import {map, filter, compose, collect} from 'pipelean' - -const pipeline = compose( - map(x => x * 2), - filter(x => x > 5) -) - -// From array -const data = [1, 2, 3, 4, 5] -const iterator = pipeline(data, {onError: 'collect'}) -const {results, errors} = await collect(iterator) - -// results: [6, 8, 10] -// errors: [] (if no errors) -``` - -#### Async Operations with Error Handling - -```javascript -const pipeline = compose( - map(async x => { - if (x === 3) throw new Error('Bad value') - return x * 2 - }), - filter(x => x > 0) -) - -const iterator = pipeline([1, 2, 3, 4], {onError: 'collect'}) -const {results, errors} = await collect(iterator) - -// results: [2, 4, 8] (skipped x=3 due to error) -// errors: [{item: 3, error: Error('Bad value')}] -``` - -#### Notification Strategy - -```javascript -const iterator = pipeline(data, { - onError: 'notify', - notify: (error, item) => { - console.log(`Error processing ${item}:`, error.message) - // Could send to monitoring service - } -}) - -const {results, errors} = await collect(iterator) -// errors still contains all errors -// notify callback was called for each error as it occurred -``` - -#### Lazy Iteration - -```javascript -const iterator = pipeline(largeDataset, {onError: 'collect'}) - -for await (const item of iterator) { - // Process items as they come - // Errors are handled according to strategy - // For 'collect' strategy, failed items are skipped - // For 'notify' strategy, errors are logged via callback - console.log(item) -} -``` - -### Implementation Details - -#### Iterator Transformation Signature - -```javascript -// Each transformer is a function that takes options and returns an async generator -function map(fn) { - return async function* (source, options) { - for await (const item of source) { - try { - yield await fn(item) - } catch (error) { - if (options.onError === 'failFast') { - throw error - } - // For 'collect' and 'notify', skip the item - // Error is accumulated in the collector - if (options.onError === 'notify' && options.notify) { - options.notify(error, item) - } - // Yield a sentinel or let collector track errors - } - } - } -} -``` - -#### Composition Implementation - -```javascript -function compose(...transformers) { - return async function* (source, options) { - let current = source - for (const transformer of transformers) { - current = transformer(current, options) - } - yield* current - } -} -``` - -#### Collector Implementation - -```javascript -async function collect(iterator, options = {}) { - const results = [] - const errors = [] - const {onError = 'failFast', notify} = options - - try { - for await (const item of iterator) { - results.push(item) - } - } catch (error) { - if (onError === 'failFast') { - throw error - } - // For iterator-based error handling, errors are tracked differently - } - - return {results, errors} -} -``` - -### Migration from Current API - -| Current API | New API | -|-------------|---------| -| `safeMap(array, fn, {onError})` | `collect(compose(map(fn))(array), {onError})` | -| `safeFilter(array, pred, {onError})` | `collect(compose(filter(pred))(array), {onError})` | -| `safePipe(op1, op2, op3)` | `compose(op1, op2, op3)` | -| `safeAsyncIterator(iterable, fn, {onError})` | `compose(map(fn))(iterable, {onError})` | -| `collectAsync(iterable, {onError})` | `collect(iterable, {onError})` | - -### Benefits - -1. **Clean Separation**: Operations are pure, error handling is compositional -2. **Consistent API**: Same pattern works for arrays, iterators, and streams -3. **Flexible Error Handling**: Strategies can be extended without changing operations -4. **Memory Efficient**: Lazy iteration by default -5. **Easy Testing**: Pure operations are trivial to test - -### Open Questions - -1. Should `reduce` be a transformer or a terminal operation? -2. How to handle early termination (like `take` operation)? -3. Should there be convenience functions for common patterns? -4. How to integrate with existing async iterator ecosystems? - -## Next Steps - -1. Implement core transformer functions (map, filter, reduce) -2. Implement composition and collection -3. Write comprehensive tests -4. Update documentation -5. Deprecate old API gradually \ No newline at end of file diff --git a/docs/superpowers/specs/2026-03-17-reframing.md b/docs/superpowers/specs/2026-03-17-reframing.md deleted file mode 100644 index 0ac423f..0000000 --- a/docs/superpowers/specs/2026-03-17-reframing.md +++ /dev/null @@ -1,48 +0,0 @@ -# Core - - * `execute (series)` (Iteration): - - It works *horizontally*. It takes a list of items and applies one transformation to each item in parallel or sequence. - - Orchestrate the list and report progress." - - Process independent items. - - stateless - - default to collect error strategy - - * `scan` (Iteration) - - Process dependent items - - stateful. - - locked on failFast error strategy - - * `filter` - - Selects items horizontally. - - Do not care if items are dependent or not - - stateless - - default to collect error strategy - - * `pipe / pipeAsync` (Composition): - - It works *vertically*. - - It takes one item and passes it through a chain of functions, one after the other. - - should *not* handle iterables. - - is just a "Function Builder." It creates a single, composite function f. - - has no error strategy. - - * `tryCatch` is a function wrapper - - "Protect the work" of the function - - is effectively a pipeline of length 1. - - Wraps one function with "Middleware/Lifecycle" (Start, Success, Error, Finally). - - Can handle progress / error notification even without being run in a series - - Deep Telemetry: Wrap fn in pipeline to Log specifically when step 2 of a 5-step pipe fails. - - Can be extended with specific progress/error notifications use-cases - - * `retry`: a specialized trycatch - - Perfect to combine in pipelines - -## Basic usage - -```js -const pipeline = pipe( - doSomething, // Standard - retry(updateDb, 3), // This step gets 3 retries automatically - notifyUI // Standard -) -await series(tracks, pipeline) -``` diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..77fd8cb --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,26 @@ +# Usage + +Practicale examples. + +## Composition in Action + +The power of this library comes from combining these primitives. + +Example: A robust download pipeline + +```js +// 1. Define the "Work" +// pipe: Chains the logic vertically. +const pipeline = pipe( + processTrack, // Pure logic + retry(updateDb, 3), // Resiliency: Retry DB 3 times + notifyUI // Side effect +) + +// 2. Execute the "Work" +// series: Runs the pipeline horizontally over the list. +const { results, errors } = await series(tracks, pipeline, { + strategy: 'collect', // Don't stop if one track fails + onProgress: updateBar // Report global progress +}) +``` From f2568c0064837cce208a2dc6527e3ea0e93a1e6c Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:26:35 +0100 Subject: [PATCH 24/26] cleanup docs and naming, ready for PR --- FEATURES.md | 40 ++++++++++- README.md | 22 +++++- ...es-and-architecture.md => architecture.md} | 0 docs/core.md | 69 ------------------- docs/guide.md | 52 ++++++++++++++ docs/usage.md | 26 ------- package.json | 10 +-- 7 files changed, 116 insertions(+), 103 deletions(-) rename docs/{principles-and-architecture.md => architecture.md} (100%) delete mode 100644 docs/core.md create mode 100644 docs/guide.md delete mode 100644 docs/usage.md diff --git a/FEATURES.md b/FEATURES.md index edf83fa..1debbd8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,8 +1,44 @@ # FEATURES.md -## safeMap +## Framing -### `safeMap` Feature Set + * `execute (series)` (Iteration): + - It works *horizontally*. It takes a list of items and applies one transformation to each item in parallel or sequence. + - Orchestrate the list and report progress." + - Process independent items. + - stateless + - default to collect error strategy + + * `scan` (Iteration) + - Process dependent items + - stateful. + - locked on failFast error strategy + + * `filter` + - Selects items horizontally. + - Do not care if items are dependent or not + - stateless + - default to collect error strategy + + * `pipe / pipeAsync` (Composition): + - It works *vertically*. + - It takes one item and passes it through a chain of functions, one after the other. + - should *not* handle iterables. + - is just a "Function Builder." It creates a single, composite function f. + - has no error strategy. + + * `tryCatch` is a function wrapper + - "Protect the work" of the function + - is effectively a pipeline of length 1. + - Wraps one function with "Middleware/Lifecycle" (Start, Success, Error, Finally). + - Can handle progress / error notification even without being run in a series + - Deep Telemetry: Wrap fn in pipeline to Log specifically when step 2 of a 5-step pipe fails. + - Can be extended with specific progress/error notifications use-cases + + * `retry`: a specialized trycatch + - Perfect to combine in pipelines + +## `series` Feature Set * **Error Strategies** * **Fail Fast:** Stops execution immediately upon the first error. diff --git a/README.md b/README.md index b0b257d..b8c7b40 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,23 @@ # Pipelean -Async-first, error-aware data transformation library. Never write a for loop again. +A pragmatic library for sequential async operations with robust error handling. + +Why? + +Standard Promise.all crashes on the first error. Promise.allSettled gives you a messy array of statuses. Pipelean gives you structured results, error strategies, and flow control out of the box. + +## The Concept + + pipe: Compose functions vertically. + series: Execute them horizontally over a list. + +## Quick Example + +import { pipe, series, retry } from 'pipelean';// 1. Build your workflowconst pipeline = pipe( processData, retry(saveToDb, { attempts: 3 }), // Built-in resiliency notifyUI);// 2. Execute safelyconst { results, errors } = await series(items, pipeline);// results: Successful items// errors: [{ item, error }, ...] -> Structured failures + + +## Documentation + + * [Architecture](docs/architecture.md) : The philosophy and design principles. + * [Guide](docs/guide.md) : Core concepts and usage patterns. + * [API Reference](docs/api.md) : Detailed feature sets. diff --git a/docs/principles-and-architecture.md b/docs/architecture.md similarity index 100% rename from docs/principles-and-architecture.md rename to docs/architecture.md diff --git a/docs/core.md b/docs/core.md deleted file mode 100644 index 0e9953e..0000000 --- a/docs/core.md +++ /dev/null @@ -1,69 +0,0 @@ -# Guide - -## Core - -We have four distinct tools, separated by the Direction of Data Flow and the State Dependency. - -1. series (Horizontal / Stateless) - - What it does: Iterates over a List of Items. Applies a transformation horizontally. - Data Flow: Item A → - Result A. Item B - → - Result B. (Independent). - State: Stateless. Item B does not know about Item A. - Error Strategy: Default is collect. (Gathers errors, keeps processing). - Responsibility: Orchestration. It manages the loop, handles the strategy, and reports progress (onProgress). - - -2. scan (Horizontal / Stateful) - - What it does: Iterates over a List of Items, accumulating state. - Data Flow: Item B depends on the result of Item A. - State: Stateful. Passes an accumulator forward. - Error Strategy: Hardcoded to failFast. (If Item A fails, Item B cannot run). - Responsibility: Reduction and Aggregation. - - -3. filter (Horizontal / Selection) - - What it does: Iterates over a List of Items. Selects a subset. - Data Flow: Item → - Predicate - → - Keep or Discard. - State: Stateless. - Error Strategy: Default is collect. - - -4. pipe (Vertical / Composition) - - What it does: Composes a List of Functions. Chains logic vertically. - Data Flow: Input → - Step 1 - → - Step 2 - → - Output. - Error Strategy: None. It is "dumb." It simply builds a single composite function. If a step throws, the pipe throws. - Responsibility: Logic composition. - - -## The Function Wrappers - -These are "Middleware" for your functions. They wrap a single unit of work to add behavior. - -5. tryCatch (Lifecycle Middleware) - - What it is: A pipeline of length 1. - Responsibility: Protection. It isolates a function, handling its lifecycle (onStart, onSuccess, onError, onFinally). - Use Case: - Adding local telemetry to a specific step in a pipeline. - Swallowing errors locally (returning null) while reporting to a monitor. - Handling "Side Effects" without polluting the main logic. - -6. retry (Resiliency Middleware) - - What it is: A specialized version of tryCatch. - Responsibility: Resiliency. It re-attempts a function if it fails. - Use Case: Wrapping flaky network calls (e.g., retry(apiCall, { attempts: 3 })). diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..70111b0 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,52 @@ +# Guide + +## Core + +We have four distinct tools, separated by the Direction of Data Flow and the State Dependency. + + 1. series (Horizontal / Stateless) + 2. scan (Horizontal / Stateful) + 3. filter (Horizontal / Selection) + 4. pipe (Vertical / Composition) + +## The Function Wrappers + +These are "Middleware" for your functions. They wrap a single unit of work to add behavior. + + * tryCatch (Lifecycle Middleware) + + What it is: A pipeline of length 1. + Responsibility: Protection. It isolates a function, handling its lifecycle (onStart, onSuccess, onError, onFinally). + Use Case: + Adding local telemetry to a specific step in a pipeline. + Swallowing errors locally (returning null) while reporting to a monitor. + Handling "Side Effects" without polluting the main logic. + + * retry (Resiliency Middleware) + + What it is: A specialized version of tryCatch. + Responsibility: Resiliency. It re-attempts a function if it fails. + Use Case: Wrapping flaky network calls (e.g., retry(apiCall, { attempts: 3 })). + +## Composition in Action + +The power of this library comes from combining these primitives. + +Example: A robust download pipeline + +```js +// 1. Define the "Work" +// pipe: Chains the logic vertically. +const pipeline = pipe( + processTrack, // Pure logic + retry(updateDb, 3), // Resiliency: Retry DB 3 times + notifyUI // Side effect +) + +// 2. Execute the "Work" +// series: Runs the pipeline horizontally over the list. +const { results, errors } = await series(tracks, pipeline, { + strategy: 'collect', // Don't stop if one track fails + onProgress: updateBar // Report global progress +}) +``` diff --git a/docs/usage.md b/docs/usage.md deleted file mode 100644 index 77fd8cb..0000000 --- a/docs/usage.md +++ /dev/null @@ -1,26 +0,0 @@ -# Usage - -Practicale examples. - -## Composition in Action - -The power of this library comes from combining these primitives. - -Example: A robust download pipeline - -```js -// 1. Define the "Work" -// pipe: Chains the logic vertically. -const pipeline = pipe( - processTrack, // Pure logic - retry(updateDb, 3), // Resiliency: Retry DB 3 times - notifyUI // Side effect -) - -// 2. Execute the "Work" -// series: Runs the pipeline horizontally over the list. -const { results, errors } = await series(tracks, pipeline, { - strategy: 'collect', // Don't stop if one track fails - onProgress: updateBar // Report global progress -}) -``` diff --git a/package.json b/package.json index 9441d69..0d042e1 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { - "name": "lib-template", - "version": "0.1.0", - "description": "JavaScript library template.", + "name": "pipelean", + "version": "0.2.0", + "description": "A pragmatic library for sequential async operations with first-class error handling.", "type": "module", "license": "MIT", - "homepage": "https://github.com/ildella/lib-template", + "homepage": "https://github.com/ildella/pipelean", "repository": { "type": "git", - "url": "git://github.com/ildella/lib-template.git" + "url": "git://github.com/ildella/pipelean.git" }, "author": { "name": "Daniele Dellafiore", From 4098b719fc17f6a8e299fd846c60b31cc5356b93 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:27:03 +0100 Subject: [PATCH 25/26] LOL --- yarn.lock | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0717d13..0d1ca9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1376,21 +1376,6 @@ __metadata: languageName: node linkType: hard -"lib-template@workspace:.": - version: 0.0.0-use.local - resolution: "lib-template@workspace:." - dependencies: - "@eslint/compat": "npm:2.0.3" - "@eslint/js": "npm:9.39.4" - "@stylistic/eslint-plugin-js": "npm:3.1.0" - "@vitest/eslint-plugin": "npm:1.6.12" - eslint: "npm:9.39.4" - eslint-nostandard: "npm:0.5.0" - globals: "npm:15.15.0" - vitest: "npm:4.1.0" - languageName: unknown - linkType: soft - "lightningcss-android-arm64@npm:1.32.0": version: 1.32.0 resolution: "lightningcss-android-arm64@npm:1.32.0" @@ -1826,6 +1811,21 @@ __metadata: languageName: node linkType: hard +"pipelean@workspace:.": + version: 0.0.0-use.local + resolution: "pipelean@workspace:." + dependencies: + "@eslint/compat": "npm:2.0.3" + "@eslint/js": "npm:9.39.4" + "@stylistic/eslint-plugin-js": "npm:3.1.0" + "@vitest/eslint-plugin": "npm:1.6.12" + eslint: "npm:9.39.4" + eslint-nostandard: "npm:0.5.0" + globals: "npm:15.15.0" + vitest: "npm:4.1.0" + languageName: unknown + linkType: soft + "postcss@npm:^8.5.8": version: 8.5.8 resolution: "postcss@npm:8.5.8" From 93a0877bb49aaf7d491e6b5afff783a077fc4a87 Mon Sep 17 00:00:00 2001 From: Daniele Dellafiore <66707+ildella@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:29:24 +0100 Subject: [PATCH 26/26] some touch on the docs, ready to merge --- README.md | 2 +- docs/guide.md | 19 ++++--------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b8c7b40..d29ed2e 100644 --- a/README.md +++ b/README.md @@ -20,4 +20,4 @@ import { pipe, series, retry } from 'pipelean';// 1. Build your workflowconst pi * [Architecture](docs/architecture.md) : The philosophy and design principles. * [Guide](docs/guide.md) : Core concepts and usage patterns. - * [API Reference](docs/api.md) : Detailed feature sets. + diff --git a/docs/guide.md b/docs/guide.md index 70111b0..8538cb5 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -14,20 +14,8 @@ We have four distinct tools, separated by the Direction of Data Flow and the Sta These are "Middleware" for your functions. They wrap a single unit of work to add behavior. * tryCatch (Lifecycle Middleware) - - What it is: A pipeline of length 1. - Responsibility: Protection. It isolates a function, handling its lifecycle (onStart, onSuccess, onError, onFinally). - Use Case: - Adding local telemetry to a specific step in a pipeline. - Swallowing errors locally (returning null) while reporting to a monitor. - Handling "Side Effects" without polluting the main logic. - * retry (Resiliency Middleware) - What it is: A specialized version of tryCatch. - Responsibility: Resiliency. It re-attempts a function if it fails. - Use Case: Wrapping flaky network calls (e.g., retry(apiCall, { attempts: 3 })). - ## Composition in Action The power of this library comes from combining these primitives. @@ -38,9 +26,10 @@ Example: A robust download pipeline // 1. Define the "Work" // pipe: Chains the logic vertically. const pipeline = pipe( - processTrack, // Pure logic - retry(updateDb, 3), // Resiliency: Retry DB 3 times - notifyUI // Side effect + retry(downloadTrack, 3), // Resiliency: Retry 3 times + processTrack, // Pure logic + retry(updateDb, 3), // Resiliency: Retry DB 2 times + notifyUI // Side effect ) // 2. Execute the "Work"