From 87efa335372337f523c675005f7c3cc7f06b4206 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Tue, 5 May 2026 11:02:35 +0200 Subject: [PATCH 1/3] Add parallelWithAccumulation method to Future --- src/domain/entities/generic/Future.ts | 71 +++- .../entities/generic/__tests/Future.spec.ts | 390 ++++++++++++++++++ 2 files changed, 453 insertions(+), 8 deletions(-) create mode 100644 src/domain/entities/generic/__tests/Future.spec.ts diff --git a/src/domain/entities/generic/Future.ts b/src/domain/entities/generic/Future.ts index 2855a0c..a9dec2a 100644 --- a/src/domain/entities/generic/Future.ts +++ b/src/domain/entities/generic/Future.ts @@ -169,7 +169,7 @@ export class Future { static sequentialWithAccumulation( futures: Array>, - options: { stopOnError?: boolean } = {} + options: SequentialWithAccumulationOptions = {} ): Future> { const { stopOnError = false } = options; const processSequentially = ( @@ -203,11 +203,58 @@ export class Future { return processSequentially(futures); } - static fromPromise(promise: Promise): FutureData { - return Future.fromComputation((resolve, reject) => { - promise.then(resolve).catch(err => reject(err ? err.message : "Unknown error")); - return () => {}; - }); + static parallelWithAccumulation( + futures: Array>, + options: ParallelWithAccumulationOptions = {} + ): Future> { + const { concurrency = 10, stopOnError = true } = options; + + const toParallelResult = (future: Future): Future> => { + return future + .map>(data => ({ type: "success", data })) + .mapError>(error => ({ type: "error", error })) + .flatMapError(errorResult => + Future.success>(errorResult) + ); + }; + + const processInParallel = ( + pendingFutures: Array>, + accumulatedData: D[] = [] + ): Future> => { + if (pendingFutures.length === 0) { + return Future.success({ type: "success", data: accumulatedData }); + } + + const currentBatch = pendingFutures.slice(0, concurrency); + const remainingFutures = pendingFutures.slice(concurrency); + + return Future.parallel(currentBatch.map(toParallelResult), { + concurrency: concurrency, + }).flatMap(batchResults => { + const successfulData = batchResults.flatMap(result => + result.type === "success" ? [result.data] : [] + ); + + const batchErrors = batchResults.flatMap(result => + result.type === "error" ? [result.error] : [] + ); + + const nextAccumulatedData = [...accumulatedData, ...successfulData]; + + if (batchErrors.length > 0 && stopOnError) { + return Future.success({ + type: "error", + errors: batchErrors, + data: nextAccumulatedData, + }); + } + + return processInParallel(remainingFutures, nextAccumulatedData); + }); + }; + + return processInParallel(futures); } } @@ -215,6 +262,12 @@ export type SequentialAccumulatedData = | { type: "success"; data: D[] } | { type: "error"; error: E; data: D[] }; +export type ParallelAccumulatedData = + | { type: "success"; data: D[] } + | { type: "error"; errors: E[]; data: D[] }; + +type ParallelResult = { type: "success"; data: D } | { type: "error"; error: E }; + export type Cancel = (() => void) | undefined; interface CaptureAsync { @@ -224,6 +277,10 @@ interface CaptureAsync { type ParallelOptions = { concurrency: number }; +type SequentialWithAccumulationOptions = { stopOnError?: boolean }; + +type ParallelWithAccumulationOptions = { concurrency?: number; stopOnError?: boolean }; + /* Example of how use Future.fromComputation */ export function getJSON(url: string): Future { const abortController = new AbortController(); @@ -250,5 +307,3 @@ export function getJSON(url: string): Future { function isNamedError(error: unknown): error is { name: string } { return Boolean(error && typeof error === "object" && "name" in error); } - -export type FutureData = Future; diff --git a/src/domain/entities/generic/__tests/Future.spec.ts b/src/domain/entities/generic/__tests/Future.spec.ts new file mode 100644 index 0000000..f8726ab --- /dev/null +++ b/src/domain/entities/generic/__tests/Future.spec.ts @@ -0,0 +1,390 @@ +import { describe, expect, test, it, vi, expectTypeOf } from "vitest"; +import { Future, ParallelAccumulatedData, SequentialAccumulatedData } from "../Future"; + +describe("Basic builders", () => { + test("Future.success", async () => { + const value$ = Future.success(10); + + expectTypeOf(value$).toEqualTypeOf>(); + await expectAsync(value$, { toEqual: 10 }); + }); + + test("Future.error", async () => { + const error = new CodedError("message: Error 1", { code: "E001" }); + const value$ = Future.error(error); + + expectTypeOf(value$).toEqualTypeOf>(); + await expectAsync(value$, { toThrow: error }); + }); +}); + +describe("run", () => { + it("calls the sucess branch with the value", async () => { + const success = vi.fn(); + const reject = vi.fn(); + + Future.success(1).run(success, reject); + await nextTick(); + + expect(success).toHaveBeenCalledTimes(1); + expect(success.mock.calls[0]).toEqual([1]); + expect(reject).not.toHaveBeenCalled(); + }); + + it("calls the error branch with the error", async () => { + const success = vi.fn(); + const reject = vi.fn(); + + const async = Future.error({ errorCode: "E12" }); + async.run(success, reject); + await nextTick(); + + expect(success).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledTimes(1); + const error = reject.mock.calls[0]?.[0]; + expect(error).toEqual({ errorCode: "E12" }); + }); +}); + +describe("toPromise", () => { + it("converts a successful Async to promise", async () => { + await expect(Future.success(1).toPromise()).resolves.toEqual(1); + }); + + it("converts an error Async to promise", async () => { + await expect(Future.error(new Error("message")).toPromise()).rejects.toThrow( + new Error("message") + ); + }); +}); + +describe("helpers", () => { + test("Future.sleep", async () => { + await expectAsync(Future.sleep(1), { toEqual: 1 }); + }); + + test("Future.void", async () => { + await expectAsync(Future.void(), { toEqual: undefined }); + }); +}); + +describe("Transformations", () => { + test("map", async () => { + const value1$ = Future.success(1); + const value2$ = value1$.map(x => x.toString()); + + await expectAsync(value2$, { toEqual: "1" }); + }); + + test("mapError", async () => { + const value1$ = Future.error(1); + const value2$ = value1$.mapError(x => x.toString()); + expectTypeOf(value2$).toEqualTypeOf>(); + + await expectAsync(value2$, { toThrow: "1" }); + }); + + describe("flatMapError", () => { + it("maps an error to a successful Future", async () => { + const value1$ = Future.error(1); + const value2$ = value1$.flatMapError(x => Future.success(x.toString())); + expectTypeOf(value2$).toEqualTypeOf>(); + + await expectAsync(value2$, { toEqual: "1" }); + }); + + it("maps an error to another error Future", async () => { + const value3$ = Future.error(1); + const value4$ = value3$.flatMapError(x => Future.error(x.toString())); + expectTypeOf(value4$).toEqualTypeOf>(); + + await expectAsync(value4$, { toThrow: "1" }); + }); + }); + + describe("flatMap/chain", () => { + it("builds an async value mapping to another async", async () => { + const value$ = Future.success(1) + .chain(value => Future.success(value + 2)) + .flatMap(value => Future.success(value + 3)) + .flatMap(value => Future.success(value + 4)); + + await expectAsync(value$, { toEqual: 10 }); + }); + }); +}); + +describe("Future.block", () => { + describe("when all awaited values in the block are successful", () => { + it("returns the returned value as an async", async () => { + const result$ = Future.block(async $ => { + const value1 = await $(Future.success(1)); + const value2 = await $(Future.success("2")); + const value3 = await $(Future.success(3)); + return value1 + parseInt(value2) + value3; + }); + + await expectAsync(result$, { toEqual: 6 }); + }); + }); + + describe("when any the awaited values in the block is an error", () => { + it("returns that error as the async result", async () => { + const result$ = Future.block(async $ => { + const value1 = await $(Future.success(1)); + const value2 = await $(Future.error("message") as Future); + const value3 = await $(Future.success(3)); + return value1 + value2 + value3; + }); + + await expectAsync(result$, { toThrow: "message" }); + }); + }); + + describe("when any the awaited values in the block is an error", () => { + it("returns that error as the async result", async () => { + const result$ = Future.block_()(async $ => { + const value1 = await $(Future.success(1)); + const value2 = await $(Future.error("message") as Future); + const value3 = await $(Future.success(3)); + return value1 + value2 + value3; + }); + + await expectAsync(result$, { toThrow: "message" }); + }); + }); + + describe("when the helper $.error is called", () => { + it("returns that async error as the async result", async () => { + const value1 = 1; + const double = vi.fn((x: number) => x); + + const result$ = Future.block_()(async $ => { + if (value1 > 0) $.throw(new Error("message")); + const value = await $(Future.success(double(1))); + return value; + }); + + await expectAsync(result$, { toThrow: new Error("message") }); + expect(double).not.toHaveBeenCalled(); + }); + }); +}); + +describe("fromComputation", () => { + describe("for a successful computation", () => { + it("return a success async", async () => { + const value$ = Future.fromComputation((resolve, _reject) => { + resolve(1); + return () => {}; + }); + + await expectAsync(value$, { toEqual: 1 }); + }); + }); + + describe("for an unsuccessful computation", () => { + it("return an error async", async () => { + const value$ = Future.fromComputation((_resolve, reject) => { + reject("message"); + return () => {}; + }); + + await expectAsync(value$, { toThrow: "message" }); + }); + }); +}); + +describe("cancel", () => { + it("cancels the async and the error branch is not called", async () => { + const success = vi.fn(); + const reject = vi.fn(); + + const cancel = Future.sleep(1).run(success, reject); + cancel?.(); + await nextTick(); + + expect(success).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledTimes(0); + }); +}); + +describe("join2", () => { + it("returns a single async with the pair of values", async () => { + const join$ = Future.join2(Future.success(123), Future.success("hello")); + + expectTypeOf(join$).toEqualTypeOf>(); + await expectAsync(join$, { toEqual: [123, "hello"] }); + }); + + it("returns an error if some of the inputs is an error", async () => { + const join$ = Future.join2(Future.success(123), Future.error("Some error")); + + expectTypeOf(join$).toEqualTypeOf>(); + await expectAsync(join$, { toThrow: "Some error" }); + }); +}); + +describe("joinObj", () => { + it("returns an async with the object of values", async () => { + const join$ = Future.joinObj({ + n: Future.success(123), + s: Future.success("hello"), + }); + + await expectAsync(join$, { + toEqual: { n: 123, s: "hello" }, + }); + }); + + it("returns an error if some of the inputs is an error", async () => { + const join$ = Future.joinObj({ + n: Future.success(123) as Future, + s: Future.error("Some error") as Future, + }); + expectTypeOf(join$).toEqualTypeOf>(); + + await expectAsync(join$, { toThrow: "Some error" }); + }); +}); + +describe("sequential", () => { + it("returns an async containing all the values as an array", async () => { + const values$ = Future.sequential([ + Future.success(1), + Future.success(2), + Future.success(3), + ]); + await expectAsync(values$, { toEqual: [1, 2, 3] }); + }); +}); + +describe("parallel", async () => { + test("concurrency smaller than length", async () => { + const asyncs = [Future.sleep(3), Future.sleep(1), Future.sleep(2)]; + const values$ = Future.parallel(asyncs, { concurrency: 2 }); + await expectAsync(values$, { toEqual: [3, 1, 2] }); + }); + + test("concurrency larger than length", async () => { + const asyncs = [Future.sleep(3), Future.sleep(1), Future.sleep(2)]; + const values$ = Future.parallel(asyncs, { concurrency: 4 }); + await expectAsync(values$, { toEqual: [3, 1, 2] }); + }); +}); + +describe("sequentialWithAccumulation", () => { + it("if there is no error, it returns an async containing all the accumulated values as an array", async () => { + const $futuresArray = [Future.success(1), Future.success(2), Future.success(3)]; + + const values$ = Future.sequentialWithAccumulation($futuresArray); + const expected: SequentialAccumulatedData = { + type: "success", + data: [1, 2, 3], + }; + + await expectAsync(values$, { toEqual: expected }); + }); + + it("if there is an error in any Future, it continues and returns an async containing all the other accumulated values as an array", async () => { + const $futuresArray = [Future.success(1), Future.error("error"), Future.success(3)]; + + const values$ = Future.sequentialWithAccumulation($futuresArray); + const expected: SequentialAccumulatedData = { + type: "success", + data: [1, 3], + }; + + await expectAsync(values$, { toEqual: expected }); + }); + + it("if there is an error in some Future and the option stopOnError is enabled, it returns the error and an async containing all accumulated values as an array until the error ocurrs", async () => { + const $futuresArray = [ + Future.success(1), + Future.success(2), + Future.error("error"), + Future.success(4), + ]; + + const values$ = Future.sequentialWithAccumulation($futuresArray, { stopOnError: true }); + const expected: SequentialAccumulatedData = { + type: "error", + data: [1, 2], + error: "error", + }; + + await expectAsync(values$, { toEqual: expected }); + }); +}); + +describe("parallelWithAccumulation", () => { + it("if there is no error, it returns an async containing all the accumulated values as an array", async () => { + const $futuresArray = [Future.success(1), Future.success(2), Future.success(3)]; + + const values$ = Future.parallelWithAccumulation($futuresArray); + const expected: ParallelAccumulatedData = { + type: "success", + data: [1, 2, 3], + }; + + await expectAsync(values$, { toEqual: expected }); + }); + + it("if there is an error in any Future, it continues and returns an async containing all the other accumulated values as an array", async () => { + const $futuresArray = [Future.success(1), Future.error("error"), Future.success(3)]; + + const values$ = Future.parallelWithAccumulation($futuresArray); + const expected: ParallelAccumulatedData = { + type: "error", + data: [1, 3], + errors: ["error"], + }; + + await expectAsync(values$, { toEqual: expected }); + }); + + it("if an error occurs, it completes the current batch, accumulates all successful results from it, and terminates before starting the next batch", async () => { + const $futuresArray = [ + Future.error("error"), + Future.success(2), + Future.success(3), + Future.success(4), + ]; + + const values$ = Future.parallelWithAccumulation($futuresArray, { + stopOnError: true, + concurrency: 2, + }); + const expected: ParallelAccumulatedData = { + type: "error", + data: [2], + errors: ["error"], + }; + + await expectAsync(values$, { toEqual: expected }); + }); +}); + +function nextTick() { + return new Promise(process.nextTick); +} + +export async function expectAsync( + value$: Future, + options: { toEqual: D; toThrow?: undefined } | { toEqual?: undefined; toThrow: E } +): Promise { + if ("toEqual" in options) { + await expect(value$.toPromise()).resolves.toEqual(options.toEqual); + } else { + await expect(value$.toPromise()).rejects.toMatchObject(options.toThrow as any); + } +} + +class CodedError extends Error { + code: string; + + constructor(message: string, data: { code: string }) { + super(message); + this.code = data.code; + } +} From 9d6c8a031a5eb99e735a9507fcbc11dbb3c6502b Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Tue, 5 May 2026 11:15:25 +0200 Subject: [PATCH 2/3] Add again removed fromPromise method and FutureData type --- src/domain/entities/generic/Future.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/domain/entities/generic/Future.ts b/src/domain/entities/generic/Future.ts index a9dec2a..923d196 100644 --- a/src/domain/entities/generic/Future.ts +++ b/src/domain/entities/generic/Future.ts @@ -256,6 +256,13 @@ export class Future { return processInParallel(futures); } + + static fromPromise(promise: Promise): FutureData { + return Future.fromComputation((resolve, reject) => { + promise.then(resolve).catch(err => reject(err ? err.message : "Unknown error")); + return () => {}; + }); + } } export type SequentialAccumulatedData = @@ -307,3 +314,5 @@ export function getJSON(url: string): Future { function isNamedError(error: unknown): error is { name: string } { return Boolean(error && typeof error === "object" && "name" in error); } + +export type FutureData = Future; From 748f835335d20c7584f6514ec4416e3b30b8295d Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Mon, 22 Jun 2026 14:55:42 +0200 Subject: [PATCH 3/3] refactor: extract FutureWithAccumulation into its own file --- src/domain/entities/generic/Future.ts | 101 ++-------------- .../generic/FutureWithAccumulation.ts | 110 ++++++++++++++++++ .../entities/generic/__tests/Future.spec.ts | 3 +- 3 files changed, 121 insertions(+), 93 deletions(-) create mode 100644 src/domain/entities/generic/FutureWithAccumulation.ts diff --git a/src/domain/entities/generic/Future.ts b/src/domain/entities/generic/Future.ts index 923d196..201727c 100644 --- a/src/domain/entities/generic/Future.ts +++ b/src/domain/entities/generic/Future.ts @@ -1,3 +1,10 @@ +import { + FutureWithAccumulation, + ParallelAccumulatedData, + ParallelWithAccumulationOptions, + SequentialAccumulatedData, + SequentialWithAccumulationOptions, +} from "./FutureWithAccumulation"; import * as rcpromise from "real-cancellable-promise"; /** @@ -171,90 +178,14 @@ export class Future { futures: Array>, options: SequentialWithAccumulationOptions = {} ): Future> { - const { stopOnError = false } = options; - const processSequentially = ( - futures: Array>, - accumulatedData: D[] = [] - ): Future> => { - const [firstFuture, ...remainingFutures] = futures; - - if (!firstFuture) { - return Future.success({ type: "success", data: accumulatedData }); - } - - return firstFuture - .flatMap(resultData => { - return processSequentially(remainingFutures, [...accumulatedData, resultData]); - }) - .flatMapError((error: E) => { - if (stopOnError) { - const accumulatedDataWithError: SequentialAccumulatedData = { - type: "error", - error: error, - data: accumulatedData, - }; - return Future.success(accumulatedDataWithError); - } else { - return processSequentially(remainingFutures, accumulatedData); - } - }); - }; - - return processSequentially(futures); + return FutureWithAccumulation.sequential(futures, options); } static parallelWithAccumulation( futures: Array>, options: ParallelWithAccumulationOptions = {} ): Future> { - const { concurrency = 10, stopOnError = true } = options; - - const toParallelResult = (future: Future): Future> => { - return future - .map>(data => ({ type: "success", data })) - .mapError>(error => ({ type: "error", error })) - .flatMapError(errorResult => - Future.success>(errorResult) - ); - }; - - const processInParallel = ( - pendingFutures: Array>, - accumulatedData: D[] = [] - ): Future> => { - if (pendingFutures.length === 0) { - return Future.success({ type: "success", data: accumulatedData }); - } - - const currentBatch = pendingFutures.slice(0, concurrency); - const remainingFutures = pendingFutures.slice(concurrency); - - return Future.parallel(currentBatch.map(toParallelResult), { - concurrency: concurrency, - }).flatMap(batchResults => { - const successfulData = batchResults.flatMap(result => - result.type === "success" ? [result.data] : [] - ); - - const batchErrors = batchResults.flatMap(result => - result.type === "error" ? [result.error] : [] - ); - - const nextAccumulatedData = [...accumulatedData, ...successfulData]; - - if (batchErrors.length > 0 && stopOnError) { - return Future.success({ - type: "error", - errors: batchErrors, - data: nextAccumulatedData, - }); - } - - return processInParallel(remainingFutures, nextAccumulatedData); - }); - }; - - return processInParallel(futures); + return FutureWithAccumulation.parallel(futures, options); } static fromPromise(promise: Promise): FutureData { @@ -265,16 +196,6 @@ export class Future { } } -export type SequentialAccumulatedData = - | { type: "success"; data: D[] } - | { type: "error"; error: E; data: D[] }; - -export type ParallelAccumulatedData = - | { type: "success"; data: D[] } - | { type: "error"; errors: E[]; data: D[] }; - -type ParallelResult = { type: "success"; data: D } | { type: "error"; error: E }; - export type Cancel = (() => void) | undefined; interface CaptureAsync { @@ -284,10 +205,6 @@ interface CaptureAsync { type ParallelOptions = { concurrency: number }; -type SequentialWithAccumulationOptions = { stopOnError?: boolean }; - -type ParallelWithAccumulationOptions = { concurrency?: number; stopOnError?: boolean }; - /* Example of how use Future.fromComputation */ export function getJSON(url: string): Future { const abortController = new AbortController(); diff --git a/src/domain/entities/generic/FutureWithAccumulation.ts b/src/domain/entities/generic/FutureWithAccumulation.ts new file mode 100644 index 0000000..01179ce --- /dev/null +++ b/src/domain/entities/generic/FutureWithAccumulation.ts @@ -0,0 +1,110 @@ +import { Future } from "./Future"; + +export class FutureWithAccumulation { + static sequential( + futures: Array>, + options: SequentialWithAccumulationOptions = {} + ): Future> { + const { stopOnError = false } = options; + const processSequentially = ( + futures: Array>, + accumulatedData: D[] = [] + ): Future> => { + const [firstFuture, ...remainingFutures] = futures; + + if (!firstFuture) { + return Future.success({ type: "success", data: accumulatedData }); + } + + return firstFuture + .flatMap(resultData => { + return processSequentially(remainingFutures, [...accumulatedData, resultData]); + }) + .flatMapError((error: E) => { + if (stopOnError) { + const accumulatedDataWithError: SequentialAccumulatedData = { + type: "error", + error: error, + data: accumulatedData, + }; + return Future.success(accumulatedDataWithError); + } else { + return processSequentially(remainingFutures, accumulatedData); + } + }); + }; + + return processSequentially(futures); + } + + static parallel( + futures: Array>, + options: ParallelWithAccumulationOptions = {} + ): Future> { + const { concurrency = 10, stopOnError = true } = options; + + const toParallelResult = (future: Future): Future> => { + return future + .map>(data => ({ type: "success", data })) + .mapError>(error => ({ type: "error", error })) + .flatMapError(errorResult => + Future.success>(errorResult) + ); + }; + + const processInParallel = ( + pendingFutures: Array>, + accumulatedData: D[] = [] + ): Future> => { + if (pendingFutures.length === 0) { + return Future.success({ type: "success", data: accumulatedData }); + } + + const currentBatch = pendingFutures.slice(0, concurrency); + const remainingFutures = pendingFutures.slice(concurrency); + + return Future.parallel(currentBatch.map(toParallelResult), { + concurrency: concurrency, + }).flatMap(batchResults => { + const successfulData = batchResults.flatMap(result => + result.type === "success" ? [result.data] : [] + ); + + const batchErrors = batchResults.flatMap(result => + result.type === "error" ? [result.error] : [] + ); + + const nextAccumulatedData = [...accumulatedData, ...successfulData]; + + if (batchErrors.length > 0 && stopOnError) { + return Future.success({ + type: "error", + errors: batchErrors, + data: nextAccumulatedData, + }); + } + + return processInParallel(remainingFutures, nextAccumulatedData); + }); + }; + + return processInParallel(futures); + } +} + +export type ParallelWithAccumulationOptions = { + concurrency?: number; + stopOnError?: boolean; +}; + +export type ParallelAccumulatedData = + | { type: "success"; data: D[] } + | { type: "error"; errors: E[]; data: D[] }; + +type ParallelResult = { type: "success"; data: D } | { type: "error"; error: E }; + +export type SequentialWithAccumulationOptions = { stopOnError?: boolean }; + +export type SequentialAccumulatedData = + | { type: "success"; data: D[] } + | { type: "error"; error: E; data: D[] }; diff --git a/src/domain/entities/generic/__tests/Future.spec.ts b/src/domain/entities/generic/__tests/Future.spec.ts index f8726ab..1f33975 100644 --- a/src/domain/entities/generic/__tests/Future.spec.ts +++ b/src/domain/entities/generic/__tests/Future.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test, it, vi, expectTypeOf } from "vitest"; -import { Future, ParallelAccumulatedData, SequentialAccumulatedData } from "../Future"; +import { Future } from "../Future"; +import { ParallelAccumulatedData, SequentialAccumulatedData } from "../FutureWithAccumulation"; describe("Basic builders", () => { test("Future.success", async () => {