diff --git a/src/functional.js b/src/functional.js index d9d2d77..776c8e4 100644 --- a/src/functional.js +++ b/src/functional.js @@ -164,9 +164,29 @@ export const filter = (...args) => { return immediate ? run(items) : run } +/** + * Accumulate values while scanning an iterable. + * + * By default `scan` behaves like the original implementation and returns a + * `{ results, errors, failure }` object containing every intermediate result. + * + * When the caller only cares about the final accumulated value (e.g. using + * `scan` as a pure reduce), set `storePartialResults: false`. In that mode the + * function returns `{ value, errors, failure }`, where `value` is the last + * successful accumulator. + * + * @param {AsyncIterable|Array} iterable - Source of values. + * @param {(accumulator, item) => Promise} scanner + * @param {*} initialValue. + * @param {{ + * strategy?: StrategyFn, onError?, onFailure?, storePartialResults?: boolean + * }} opts + */ // eslint-disable-next-line complexity export const scan = async (iterable, scanner, initialValue, opts = {}) => { - const {strategy = failFast, onError, onFailure} = opts + const { + strategy = failFast, onError, onFailure, storePartialResults = true, + } = opts const results = [] let acc = initialValue const errors = [] @@ -174,7 +194,8 @@ export const scan = async (iterable, scanner, initialValue, opts = {}) => { for await (const item of iterable) { try { acc = await scanner(acc, item) - results.push(acc) + if (storePartialResults) + results.push(acc) } catch (error) { const strategyName = strategy.name ?? strategy @@ -186,12 +207,9 @@ export const scan = async (iterable, scanner, initialValue, opts = {}) => { if (onFailure) { onFailure({item, error}) } - errors.push({item, error}) - return { - results, - errors, - failure: {item, error}, - } + return storePartialResults + ? {results, errors, failure: {item, error}} + : {result: acc, errors, failure: {item, error}} } if (strategyName === 'skip') { @@ -209,7 +227,9 @@ export const scan = async (iterable, scanner, initialValue, opts = {}) => { onFailure(true) } - return {results, errors, failure} + return storePartialResults + ? {results, errors, failure} + : {result: acc, errors, failure} } export const pipe = (...fns) => input => diff --git a/tests/scan-reduce.test.js b/tests/scan-reduce.test.js new file mode 100644 index 0000000..57c8d6b --- /dev/null +++ b/tests/scan-reduce.test.js @@ -0,0 +1,95 @@ +import {test, expect} from 'vitest' +import {scan, failFast} from '$src/functional' + +test('returns final value when storePartialResults is false', async () => { + const tracks = [ + {duration: 5}, + {duration: 7}, + {duration: 10}, + ] + + const {result: totalDuration} = await scan( + tracks, + (accumulator, {duration}) => accumulator + duration, + 0, + {storePartialResults: false}, + ) + + expect(totalDuration).toBe(22) +}) + +test('failFast stops on error with storePartialResults false', async () => { + const tracks = [ + {duration: 5}, + {duration: 7}, + {duration: 10}, + ] + + const {result, failure, errors} = await scan( + tracks, + (accumulator, {duration}) => { + if (duration > 7) + throw new Error('Duration too long') + return accumulator + duration + }, + 0, + {strategy: failFast, storePartialResults: false}, + ) + + expect(result).toBe(12) // 5 + 7 before error + expect(failure).toEqual({ + item: {duration: 10}, + error: new Error('Duration too long'), + }) + // failFast doesn't collect errors in the errors array + expect(errors).toHaveLength(0) +}) + +test('failFast throws when accessing missing property', async () => { + const tracks = [ + {duration: 5}, + {}, // Missing duration property - will cause error when trying to access it + {duration: 10}, + ] + + const {result, failure} = await scan( + tracks, + (accumulator, item) => { + // Explicitly throw on missing property to test failFast behavior + if (item.duration === undefined) + throw new Error('Missing duration') + return accumulator + item.duration + }, + 0, + {strategy: failFast, storePartialResults: false}, + ) + + expect(result).toBe(5) // Only first item processed + expect(failure.item).toEqual({}) + expect(failure.error.message).toBe('Missing duration') +}) + +test( + 'empty iterable returns initial value with storePartialResults false', + async () => { + const {result: total} = await scan( + [], + (accumulator, item) => accumulator + item, + 42, + {storePartialResults: false}, + ) + + expect(total).toBe(42) + } +) + +test('default behavior unchanged with storePartialResults true', async () => { + const {results} = await scan( + [1, 2, 3], + (acc, item) => acc + item, + 0, + {storePartialResults: true}, + ) + + expect(results).toEqual([1, 3, 6]) +})