From 407863054ce0661029c8912ab17b4a6f05bc6345 Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 10:13:13 +0200 Subject: [PATCH 01/12] Copy over promise-utils files --- package.json | 1 + pnpm-lock.yaml | 32 ++ src/promises/index.test.ts | 799 +++++++++++++++++++++++++++++++++++++ src/promises/index.ts | 211 ++++++++++ 4 files changed, 1043 insertions(+) create mode 100644 src/promises/index.test.ts create mode 100644 src/promises/index.ts diff --git a/package.json b/package.json index 8c8679e..8c80213 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "prettier": "^3.8.1", "ts-jest": "^29.4.9", "ts-node": "^10.9.2", + "type-plus": "^7.6.2", "typescript": "^5.9.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b2c520..429bb56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@24.13.2)(typescript@5.9.3) + type-plus: + specifier: ^7.6.2 + version: 7.6.2 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -2013,6 +2016,10 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + is-builtin-module@3.2.1: resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} engines: {node: '>=6'} @@ -2999,6 +3006,9 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + tersify@3.12.1: + resolution: {integrity: sha512-VwzXGHZSOB4T27s4uvh9v8FYrNXyfVz0nBQi28TDwrZoQwT8ZJUp1W2Ff73ekN07stJSb0D+pr6iXeNeFqTI6Q==} + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -3116,6 +3126,9 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-plus@7.6.2: + resolution: {integrity: sha512-qUlXv9Y0/W56pg38m275IMD3WA03QbVoqNY16S3kmwtuA4gOT2iheyUdOp8NWrmXWpf7om98hzr7AZD/eW2jLA==} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -3152,6 +3165,10 @@ packages: universal-user-agent@6.0.1: resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + unpartial@1.0.5: + resolution: {integrity: sha512-yAqaXcachjgZUnM2yIkf+4KJhmyuoj7stBvlnlZpB15OYVbKnLhgJfmLW7qkpzLHCdsm1bEFvhyN9hCmlZ3uuw==} + engines: {node: '>=6'} + unrs-resolver@1.7.12: resolution: {integrity: sha512-pfcdDxrVoUc5ZB3VCVJNSWbs63lgQVYLVw4k/rCr8Smi/V2Sxi1odEckVq6Zf803OtbYia1+YpiGCZoODfWLsQ==} @@ -5912,6 +5929,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-buffer@2.0.5: {} + is-builtin-module@3.2.1: dependencies: builtin-modules: 3.3.0 @@ -7134,6 +7153,12 @@ snapshots: tapable@2.3.2: {} + tersify@3.12.1: + dependencies: + acorn: 8.15.0 + is-buffer: 2.0.5 + unpartial: 1.0.5 + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.3 @@ -7233,6 +7258,11 @@ snapshots: type-fest@4.41.0: {} + type-plus@7.6.2: + dependencies: + tersify: 3.12.1 + unpartial: 1.0.5 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -7282,6 +7312,8 @@ snapshots: universal-user-agent@6.0.1: {} + unpartial@1.0.5: {} + unrs-resolver@1.7.12: dependencies: napi-postinstall: 0.2.4 diff --git a/src/promises/index.test.ts b/src/promises/index.test.ts new file mode 100644 index 0000000..d4c6a0a --- /dev/null +++ b/src/promises/index.test.ts @@ -0,0 +1,799 @@ +import { go, goSync, success, fail, assertGoSuccess, assertGoError, GoWrappedError } from './index'; +import { assertType, Equal } from 'type-plus'; + +const expectToBeAround = (actual: number, expected: number, range = 10) => { + expect(actual).toBeGreaterThanOrEqual(expected - range); + expect(actual).toBeLessThanOrEqual(expected + range); +}; + +const resolveAfter = (ms: number, value?: T): Promise => + new Promise((resolve) => setTimeout(() => resolve(value as T), ms)); +const rejectAfter = (ms: number, value?: T): Promise => + new Promise((_, reject) => setTimeout(() => reject(value), ms)); + +describe('basic goSync usage', () => { + it('resolves successful synchronous functions', () => { + const res = goSync(() => 2 + 2); + expect(res).toEqual(success(4)); + expect(res).toEqual({ success: true, data: 4 }); + }); + + it('resolves unsuccessful synchronous functions', () => { + const err = new Error('Computer says no'); + const res = goSync(() => { + throw err; + }); + expect(res).toEqual(fail(err)); + expect(res).toEqual({ success: false, error: err }); + }); +}); + +describe('basic go usage', () => { + it('resolves successful asynchronous functions', async () => { + const successFn = new Promise((res) => res(2)); + const res = await go(() => successFn); + expect(res).toEqual(success(2)); + }); + + it('resolves unsuccessful asynchronous functions', async () => { + const err = new Error('Computer says no'); + const errorFn = new Promise((_res, rej) => rej(err)); + const res = await go(() => errorFn); + expect(res).toEqual(fail(err)); + }); + + it('resolves asynchronous functions which throws', async () => { + const err = new Error('Computer says no'); + const errorFn = new Promise(() => { + throw err; + }); + const res = await go(() => errorFn); + expect(res).toEqual(fail(err)); + }); + + it('resolves on sync errors as well', async () => { + const obj = {} as any; + const res = await go(() => obj.nonExistingFunction()); + expect(res).toEqual(fail(new TypeError('obj.nonExistingFunction is not a function'))); + }); + + // NOTE: This is not an issue of promise utils library since the error is thrown before the value is passed as an + // argument to the go function + it('throws on sync usage without callback', async () => { + const obj = {} as any; + expect(() => go(obj.nonExistingFunction())).toThrow(new TypeError('obj.nonExistingFunction is not a function')); + }); + + it('accepts a sync function if the return type is never', async () => { + const err = new Error('asd'); + const res = await go(() => { + throw err; + }); + expect(res).toEqual(fail(err)); + }); +}); + +describe('basic retry usage', () => { + const operations = { + successFn: () => new Promise((res) => res(2)), + errorFn: () => new Promise((_res, rej) => rej(new Error('Computer says no'))), + }; + + it('retries the specified number of times', async () => { + jest + .spyOn(operations, 'successFn') + .mockRejectedValueOnce(new Error('Error 1')) + .mockRejectedValueOnce(new Error('Error 2')); + + const res = await go(operations.successFn, { retries: 2 }); + expect(operations.successFn).toHaveBeenCalledTimes(3); + expect(res).toEqual(success(2)); + }); + + it('retries and resolves unsuccessful asynchronous functions with the error from last retry', async () => { + const attempts = 3; + jest + .spyOn(operations, 'errorFn') + .mockRejectedValueOnce(new Error('Error 1')) + .mockRejectedValueOnce(new Error('Error 2')); + + const res = await go(operations.errorFn, { retries: 2 }); + expect(operations.errorFn).toHaveBeenCalledTimes(attempts); + expect(res).toEqual(fail(new Error('Computer says no'))); + }); + + it('resolves unsuccessful asynchronous functions with no retries', async () => { + jest.spyOn(operations, 'errorFn').mockRejectedValueOnce(new Error('Computer says no')); + + const res = await go(operations.errorFn, { retries: 0 }); + expect(operations.errorFn).toHaveBeenCalledTimes(1); + expect(res).toEqual(fail(new Error('Computer says no'))); + }); +}); + +describe('basic timeout usage', () => { + const operations = { + successFn: () => resolveAfter(10, 2), + errorFn: () => rejectAfter(10, new Error('Computer says no')), + }; + + it('resolves successful asynchronous functions within the timout limit', async () => { + const res = await go(operations.successFn, { attemptTimeoutMs: 20 }); + expect(res).toEqual(success(2)); + }); + + it('resolves unsuccessful asynchronous functions within the timout limit', async () => { + const res = await go(operations.errorFn, { attemptTimeoutMs: 20 }); + expect(res).toEqual(fail(new Error('Computer says no'))); + }); + + it('resolves timed out asynchronous functions', async () => { + const res = await go(operations.successFn, { attemptTimeoutMs: 5 }); + expect(res).toEqual(fail(new Error('Operation timed out'))); + }); + + it('shows difference between promise callback and promise value', async () => { + // Promise value tries to resolve THE SAME promise every attempt + const sleepPromise = resolveAfter(50); + const goVal = await go(() => sleepPromise, { attemptTimeoutMs: 30, retries: 1 }); + expect(goVal).toEqual(success(undefined)); + + // Promise callback tries to resolve NEW promise every attempt + const goFn = await go(() => resolveAfter(50), { attemptTimeoutMs: 30, retries: 1 }); + expect(goFn).toEqual(fail(new Error('Operation timed out'))); + }); + + it('shows that timeout 0 means 0 ms (not infinity)', async () => { + const res = await go(operations.successFn, { attemptTimeoutMs: 0 }); + expect(res).toEqual(fail(new Error('Operation timed out'))); + }); +}); + +describe('basic retry and timeout usage', () => { + const operations = { + successFn: () => resolveAfter(100, 2), + errorFn: () => rejectAfter(100, new Error('Computer says no')), + }; + + it('resolves successful asynchronous functions', async () => { + const res = await go(operations.successFn, { attemptTimeoutMs: 120, retries: 3 }); + expect(res).toEqual(success(2)); + }); + + it('resolves unsuccessful asynchronous functions', async () => { + const res = await go(operations.errorFn, { attemptTimeoutMs: 120, retries: 3 }); + expect(res).toEqual(fail(new Error('Computer says no'))); + }); + + it('retries and resolves successful asynchronous functions', async () => { + jest + .spyOn(operations, 'successFn') + .mockRejectedValueOnce(new Error('Error 1')) + .mockRejectedValueOnce(new Error('Error 2')); + + const res = await go(operations.successFn, { attemptTimeoutMs: 120, retries: 3 }); + expect(operations.successFn).toHaveBeenCalledTimes(3); + expect(res).toEqual(success(2)); + }); + + it('retries and resolves successful asynchronous functions with varying timeouts', async () => { + jest.spyOn(operations, 'successFn'); + const start = performance.now(); + const res = await go(operations.successFn, { attemptTimeoutMs: [50, 70, 90, 120], retries: 3 }); + const end = performance.now(); + expectToBeAround(end - start, 50 + 70 + 90 + 100); + expect(operations.successFn).toHaveBeenCalledTimes(4); + expect(res).toEqual(success(2)); + }); + + it('retries and resolves unsuccessful asynchronous functions', async () => { + jest + .spyOn(operations, 'errorFn') + .mockRejectedValueOnce(new Error('Error 1')) + .mockRejectedValueOnce(new Error('Error 2')); + + const res = await go(operations.errorFn, { attemptTimeoutMs: 120, retries: 2 }); + expect(operations.errorFn).toHaveBeenCalledTimes(3); + expect(res).toEqual(fail(new Error('Computer says no'))); + }); + + it('retries and resolves unsuccessful timed out functions', async () => { + const attempts = 3; + jest.spyOn(operations, 'successFn'); + + const res = await go(operations.successFn, { attemptTimeoutMs: 50, retries: 2 }); + expect(operations.successFn).toHaveBeenCalledTimes(attempts); + expect(res).toEqual(fail(new Error('Operation timed out'))); + }); + + it('retries with multiple timeout durations and resolves unsuccessful timed out functions', async () => { + const attempts = 3; + jest.spyOn(operations, 'successFn'); + + const start = performance.now(); + const res = await go(operations.successFn, { attemptTimeoutMs: [50, 70, 90], retries: 2 }); + const end = performance.now(); + expectToBeAround(end - start, 50 + 70 + 90); + expect(operations.successFn).toHaveBeenCalledTimes(attempts); + expect(res).toEqual(fail(new Error('Operation timed out'))); + }); + + it('retries and timeouts within the timeout limit of each attempt', async () => { + jest.spyOn(operations, 'successFn'); + const start = performance.now(); + const res = await go(operations.successFn, { attemptTimeoutMs: [50, 60, 70, 80, 90, 95], retries: 5 }); + const end = performance.now(); + expectToBeAround(end - start, 50 + 60 + 70 + 80 + 90 + 95); + expect(operations.successFn).toHaveBeenCalledTimes(6); + expect(res).toEqual(fail(new Error('Operation timed out'))); + }); + + it('retries with multiple timeout durations and uses the last value if array length is smaller than total attempts', async () => { + const attempts = 6; + jest.spyOn(operations, 'successFn'); + const start = performance.now(); + const res = await go(operations.successFn, { attemptTimeoutMs: [50, 70], retries: 5 }); + const end = performance.now(); + expectToBeAround(end - start, 50 + 70 + 70 + 70 + 70 + 70); + expect(operations.successFn).toHaveBeenCalledTimes(attempts); + expect(res).toEqual(fail(new Error('Operation timed out'))); + }); +}); + +describe('custom error type', () => { + class CustomError extends Error { + custom: string; + + constructor(message: string) { + super(message); + this.custom = '123'; + } + } + + describe('goSync', () => { + it('error handling', () => { + const goRes = goSync(() => { + throw new CustomError('custom'); + }); + assertGoError(goRes); + const err = goRes.error; + + assertType(err); + // Check that "err" is not assignable to CustomError + assertType.isFalse(false as Equal); + expect(err instanceof CustomError).toBe(true); + }); + + it('can specify custom error type', () => { + const goRes = goSync(() => { + throw new CustomError('custom'); + }); + assertGoError(goRes); + const err = goRes.error; + + assertType(err); + expect(err instanceof CustomError).toBe(true); + }); + + it('will wraps non error throw in Error class', () => { + const goRes = goSync(() => { + throw 'string-error'; + }); + assertGoError(goRes); + const err = goRes.error; + + assertType(err); + expect(err instanceof Error).toBe(true); + }); + }); + + describe('go', () => { + it('error handling', async () => { + const goRes = await go(() => { + throw new CustomError('custom'); + }); + assertGoError(goRes); + const err = goRes.error; + + assertType(err); + // Check that "err" is not assignable to CustomError + assertType.isFalse(false as Equal); + expect(err instanceof CustomError).toBe(true); + }); + + it('can specify custom error type', async () => { + const goRes = await go(() => { + throw new CustomError('custom'); + }); + assertGoError(goRes); + const err = goRes.error; + + assertType(err); + expect(err instanceof CustomError).toBe(true); + }); + + it('will wraps non error throw in Error class', async () => { + const goRes = await go(() => { + throw 'string-error'; + }); + assertGoError(goRes); + const err = goRes.error; + + assertType(err); + expect(err instanceof Error).toBe(true); + }); + }); +}); + +describe('the "this" limitation', () => { + class Test { + constructor() {} + sync() { + return this._sync(); + } + _sync() { + return '123'; + } + + async() { + return this._async(); + } + _async() { + return Promise.resolve('123'); + } + } + + // The error message for when reading a property of undefined has changed between major node versions + const expectReadPropertyOfUndefined = (res: unknown, prop: string) => { + // process.version returns the version as the string: 'v[major].[minor].[patch]' + const majorVersion = process.version.split('.')[0]!.substring(1); + if (Number(majorVersion) >= 16) { + expect(res).toEqual(fail(new TypeError(`Cannot read properties of undefined (reading '${prop}')`))); + } else { + expect(res).toEqual(fail(new TypeError(`Cannot read property '${prop}' of undefined`))); + } + }; + + it('fails for sync version', () => { + const test = new Test(); + + const res = goSync(test.sync); + + expectReadPropertyOfUndefined(res, '_sync'); + }); + + it('fails for async version', async () => { + const test = new Test(); + + const res = await go(test.async); + + expectReadPropertyOfUndefined(res, '_async'); + }); +}); + +describe('assertGoSuccess', () => { + it('works for success', () => { + const res = goSync(() => 123); + + assertGoSuccess(res); + + // The "data" property should now be inferred since the success was asserted + const data = res.data; + expect(data).toBe(data); + }); + + it('works for failure (rethrows the go error)', () => { + const res = goSync(() => { + throw new Error('my bad'); + }); + + expect(() => assertGoSuccess(res)).toThrow('my bad'); + }); +}); + +describe('assertGoError', () => { + it('works for success', () => { + const res = goSync(() => 123); + + expect(() => assertGoError(res)).toThrow('Assertion failed. Expected error, but no error was thrown'); + }); + + it('works for failure', () => { + const res = goSync(() => { + throw new Error('error'); + }); + + assertGoError(res); + + // The "error" property should now be inferred since the success was asserted + const err = res.error; + expect(err).toBe(err); + }); +}); + +it('has access to native error', async () => { + const throwingFn = async () => { + throw { message: 'an error', data: 'some data' }; + }; + + const goRes = await go, GoWrappedError>(throwingFn); + + assertGoError(goRes); + // The error message is the not very useful stringified data + expect(goRes.error).toEqual(new Error('[object Object]')); + expect(goRes.error instanceof GoWrappedError).toBeTruthy(); + expect(goRes.error.reason).toEqual({ message: 'an error', data: 'some data' }); +}); + +// NOTE: Keep in sync with README +describe('documentation snippets are valid', () => { + const fetchData = (_path: string) => { + if (_path.startsWith('throw')) return Promise.reject('unexpected error'); + return Promise.resolve('some data'); + }; + + it('success usage', async () => { + const goFetchData = await go(() => fetchData('users')); + if (goFetchData.success) { + const data = goFetchData.data; + + assertType(data); + expect(data).toBe('some data'); + } + }); + + it('error usage', async () => { + const goFetchData = await go(() => fetchData('throw')); + if (!goFetchData.success) { + const error = goFetchData.error; + + expect(error).toEqual(new Error('unexpected error')); + } + }); + + it('sync usage', () => { + const someData = { key: 123 }; + const parseData = (rawData: typeof someData) => ({ ...rawData, parsed: true }); + const goParseData = goSync(() => parseData(someData)); + if (goParseData.success) { + const data = goParseData.data; + + expect(data.parsed).toBe(true); + } + }); + + it('shows limitation', () => { + class MyClass { + constructor() {} + get() { + return this._get(); + } + _get() { + return '123'; + } + } + + const myClass = new MyClass(); + const resWorks = goSync(() => myClass.get()); // This works + assertGoSuccess(resWorks); + const resFails = goSync(myClass.get); // This doesn't work + assertGoError(resFails); + }); + + it('verbosity of try catch', async () => { + class MyError extends Error { + reason: string; + constructor(m: string) { + super(m); + this.reason = m; + } + } + const someAsyncCall = () => Promise.reject(new MyError('custom error')); + const logError = (mess: string) => expect(mess).toEqual(expect.any(String)); + + // Verbose try catch + try { + const data = await someAsyncCall(); + assertType(data); // The function above should throw + } catch (e) { + return logError((e as MyError).reason); + } + + // Compare it to simpler version using go + type MyData = Promise; + const goRes = await go(someAsyncCall); + if (!goRes.success) return logError(goRes.error.reason); + // At this point TypeScript infers that the error was handled and goRes must be a success response + const data = goRes.data; + assertType(data); + }); +}); + +describe('delay', () => { + it('only delays on retries', async () => { + const goRes = await go(async () => 123, { delay: { type: 'static', delayMs: 2000 } }); + expect(goRes).toEqual(success(123)); + }, 20); // Make the test timeout smaller then the delay + + describe('random', () => { + it('waits for a random period of time before retry', async () => { + const now = Date.now(); + const ticks: number[] = []; + + jest.spyOn(global.Math, 'random').mockReturnValueOnce(0.5); + jest.spyOn(global.Math, 'random').mockReturnValueOnce(1); + + await go( + async () => { + ticks.push(Date.now() - now); + throw new Error(); + }, + { delay: { type: 'random', minDelayMs: 0, maxDelayMs: 100 }, retries: 2 } + ); + + expect(ticks.length).toBe(3); + expectToBeAround(ticks[0]!, 0); + expectToBeAround(ticks[1]!, 50); + expectToBeAround(ticks[2]!, 150); + }); + }); + + describe('static', () => { + it('waits for a fixed period of time before retry', async () => { + const now = Date.now(); + const ticks: number[] = []; + + await go( + async () => { + ticks.push(Date.now() - now); + throw new Error(); + }, + { delay: { type: 'static', delayMs: 50 }, retries: 2 } + ); + + expect(ticks.length).toBe(3); + expectToBeAround(ticks[0]!, 0); + expectToBeAround(ticks[1]!, 50); + expectToBeAround(ticks[2]!, 100); + }); + }); +}); + +describe('totalTimeoutMs', () => { + it('stops retying after the full timeout is exceeded', async () => { + const now = Date.now(); + const ticks: number[] = []; + + await go( + async () => { + ticks.push(Date.now() - now); + throw new Error(); + }, + { delay: { type: 'static', delayMs: 50 }, retries: 150, totalTimeoutMs: 150 } + ); + + expect(ticks.length).toBe(3); + expectToBeAround(ticks[0]!, 0); + expectToBeAround(ticks[1]!, 50); + expectToBeAround(ticks[2]!, 100); + }); + + it('runs the go callback at least once independently of full timeout', async () => { + const now = Date.now(); + const ticks: number[] = []; + + await go( + async () => { + ticks.push(Date.now() - now); + throw new Error(); + }, + { delay: { type: 'static', delayMs: 50 }, retries: 10, totalTimeoutMs: 0 } + ); + + expect(ticks.length).toBe(1); + expectToBeAround(ticks[0]!, 0); + }); + + it('resolves the value immediately after the timeout has exceeded', async () => { + const now = Date.now(); + + const goRes = await go( + async () => { + await resolveAfter(50); + }, + { delay: { type: 'static', delayMs: 50 }, retries: 1, totalTimeoutMs: 20 } + ); + + const delta = Date.now() - now; + expectToBeAround(delta, 20); + expect(goRes).toEqual(fail(new Error('Full timeout exceeded'))); + }); +}); + +describe('onAttemptError', () => { + it('calls the function after every unsuccessfull attempt except last', async () => { + const onAttemptError = jest.fn(); + + let counter = 0; + const goRes = await go( + async () => { + counter++; + throw new Error('fail' + counter); + }, + { retries: 3, onAttemptError } + ); + + expect(onAttemptError).toBeCalledTimes(3); + expect(onAttemptError).toHaveBeenNthCalledWith(1, fail(new Error('fail1'))); + expect(onAttemptError).toHaveBeenNthCalledWith(2, fail(new Error('fail2'))); + expect(onAttemptError).toHaveBeenNthCalledWith(3, fail(new Error('fail3'))); + expect(goRes).toEqual(fail(new Error('fail4'))); + }); + + it('does not trigger the callback after total timeout has been exceeded', async () => { + const onAttemptError = jest.fn(); + + const goRes = await go( + async () => { + await resolveAfter(50); + }, + { retries: 3, totalTimeoutMs: 20, onAttemptError } + ); + + expect(onAttemptError).toHaveBeenCalledTimes(0); + expect(goRes).toEqual(fail(new Error('Full timeout exceeded'))); + }); + + it('does not call the callback after successful attempt', async () => { + const onAttemptError = jest.fn(); + + await go(async () => Promise.resolve(123), { onAttemptError }); + + expect(onAttemptError).toHaveBeenCalledTimes(0); + }); + + describe('does not call for last unsuccessfull attempt', () => { + it('and attempt timeout', async () => { + const onAttemptError = jest.fn(); + + const goRes = await go( + async () => { + await resolveAfter(20); + }, + { attemptTimeoutMs: 10, onAttemptError } + ); + // Make sure the attempt inside the go function above is completed + await resolveAfter(30); + + expect(onAttemptError).toHaveBeenCalledTimes(0); + expect(goRes).toEqual(fail(new Error('Operation timed out'))); + }); + + it('and total timeout', async () => { + const onAttemptError = jest.fn(); + + const goRes = await go( + async () => { + await resolveAfter(20); + }, + { totalTimeoutMs: 10, onAttemptError } + ); + // Make sure the attempt inside the go function above is completed + await resolveAfter(30); + + expect(onAttemptError).toHaveBeenCalledTimes(0); + expect(goRes).toEqual(fail(new Error('Full timeout exceeded'))); + }); + + it('both attemp timeout and total timeout', async () => { + const onAttemptError = jest.fn(); + + const goRes = await go( + async () => { + await resolveAfter(20); + }, + { retries: 2, attemptTimeoutMs: 10, totalTimeoutMs: 25, onAttemptError } + ); + // Make sure the attempt inside the go function above is completed + await resolveAfter(50); + + expect(onAttemptError).toHaveBeenCalledTimes(2); + expect(onAttemptError).toHaveBeenNthCalledWith(1, fail(new Error('Operation timed out'))); + expect(onAttemptError).toHaveBeenNthCalledWith(2, fail(new Error('Operation timed out'))); + expect(goRes).toEqual(fail(new Error('Full timeout exceeded'))); + }); + }); + + it('is automatically typed', async () => { + class CustomError extends Error { + custom: string; + + constructor(message: string) { + super(message); + this.custom = '123'; + } + } + + await go, CustomError>( + async () => { + throw new CustomError('fail'); + }, + { + retries: 3, + onAttemptError: (goRes) => { + expect(goRes).toEqual(success(123)); + + assertGoError(goRes); + assertType(goRes.error); + }, + } + ); + }); + + it('accepts, but does not wait for async callback finish', async () => { + const log: string[] = []; + let counter = 0; + + const goRes = await go( + async () => { + counter++; + const m = 'fail' + counter; + log.push(`go callback: ${m}`); + throw new Error(m); + }, + { + retries: 1, + onAttemptError: async (goRes) => { + log.push(`onAttemptError: ${JSON.stringify(goRes)}`); + + await resolveAfter(20); + + log.push(`onAttemptError (after sleep): ${JSON.stringify(goRes)}`); + }, + } + ); + + expect(goRes).toEqual(fail(new Error('fail2'))); + expect(log).toEqual(['go callback: fail1', 'onAttemptError: {"success":false,"error":{}}', 'go callback: fail2']); + await resolveAfter(50); // We need to wait for unfinished onAttemptError callbacks + expect(log).toEqual([ + 'go callback: fail1', + 'onAttemptError: {"success":false,"error":{}}', + 'go callback: fail2', + 'onAttemptError (after sleep): {"success":false,"error":{}}', + ]); + }); + + it('handles nested promises correctly', async () => { + const x = Promise.resolve('123') as any as Promise>; + + const goRes = await go(() => x); + assertGoSuccess(goRes); + + assertType(goRes.data); + }); + + it('allows you to access both error and success properties', async () => { + const { success, error, data } = goSync(() => 123); + // @ts-expect-error should not work + const x: number = data; + assertType(data); + assertType(error); + + if (success) { + assertType(data); + assertType(error); + } else { + assertType(data); + assertType(error); + } + }); + + it('does not delay after last attempt', async () => { + const start = performance.now(); + + await go(() => Promise.reject('error'), { delay: { type: 'static', delayMs: 100 }, retries: 2 }); + + expect(performance.now() - start).toBeLessThan(2 * 100 + 50); + }); +}); diff --git a/src/promises/index.ts b/src/promises/index.ts new file mode 100644 index 0000000..6ddb47b --- /dev/null +++ b/src/promises/index.ts @@ -0,0 +1,211 @@ +// NOTE: We use discriminated unions over "success" property +export type GoResultSuccess = { data: T; success: true; error: undefined }; +export type GoResultError = { data: undefined; error: E; success: false }; +export type GoResult = GoResultSuccess | GoResultError; + +export interface StaticDelayOptions { + type: 'static'; + delayMs: number; +} + +export interface RandomDelayOptions { + type: 'random'; + minDelayMs: number; + maxDelayMs: number; +} + +export interface GoAsyncOptions { + retries?: number; // Number of retries to attempt if the go callback is unsuccessful. + attemptTimeoutMs?: number | number[]; // The timeout for each attempt. Can provide an array for different timeouts for each attempt. If the array is shorter than the number of retries, the last value is used for all remaining attempts, if the length of the array is longer than the number of retries, the extra values are ignored. + totalTimeoutMs?: number; // The maximum timeout for all attempts and delays. No more retries are performed after this timeout. + delay?: StaticDelayOptions | RandomDelayOptions; // Type of the delay before each attempt. There is no delay before the first request. + onAttemptError?: (goRes: GoResultError) => void; // Callback invoked after each failed attempt is completed. This callback does not fire for the last attempt or when a "totalTimeoutMs" is exceeded (these should be handled explicitly with the result of "go" call). +} + +export class GoWrappedError extends Error { + constructor(public reason: unknown) { + super('' + reason); + } +} + +// NOTE: This needs to be written using 'function' syntax (cannot be arrow function) +// See: https://github.com/microsoft/TypeScript/issues/34523#issuecomment-542978853 +export function assertGoSuccess(result: GoResult): asserts result is GoResultSuccess { + if (!result.success) { + throw result.error; + } +} + +// NOTE: This needs to be written using 'function' syntax (cannot be arrow function) +// See: https://github.com/microsoft/TypeScript/issues/34523#issuecomment-542978853 +export function assertGoError(result: GoResult): asserts result is GoResultError { + if (result.success) { + throw new Error('Assertion failed. Expected error, but no error was thrown'); + } +} + +export const success = (value: T): GoResultSuccess => { + return { success: true, data: value, error: undefined }; +}; + +// We allow the consumer to type which error is returned. The "err" parameter has weaker type ("Error") to accommodate +// for a generic error thrown by the go functions. +export const fail = (err: Error): GoResultError => { + return { success: false, data: undefined, error: err as E }; +}; + +const createGoError = (err: unknown): GoResultError => { + if (err instanceof Error) return fail(err); + return fail(new GoWrappedError(err)); +}; + +export const goSync = (fn: () => T): GoResult => { + try { + return success(fn()); + } catch (err) { + return createGoError(err) as GoResultError; + } +}; + +const getRandomInRange = (min: number, max: number) => { + return Math.random() * (max - min) + min; +}; + +interface CancellableTimeout { + cancel: () => void; + promise: Promise; +} +const cancellableSleep = (ms: number) => { + let resolveFn: any; + let timeoutId: any; + const promise = new Promise((resolve) => { + resolveFn = resolve; + timeoutId = setTimeout(resolve, ms); + }); + + const cancel = () => { + clearTimeout(timeoutId); + resolveFn(); + }; + + return { + promise, + cancel, + }; +}; +const cancellableTimeout = (ms: number): CancellableTimeout => { + let rejectFn: any; + let timeoutId: any; + const promise = new Promise((_, reject) => { + rejectFn = reject; + timeoutId = setTimeout(() => reject('Operation timed out'), ms); + }); + + const cancel = () => { + clearTimeout(timeoutId); + rejectFn(); + }; + + return { + promise, + cancel, + }; +}; + +const attempt = async ( + fn: () => T, + attemptTimeoutMs?: number +): Promise, E>> => { + let timeout: CancellableTimeout | null = null; + + // We need try/catch because `fn` might throw sync errors as well + try { + if (attemptTimeoutMs === undefined) return success(await fn()); + else { + timeout = cancellableTimeout(attemptTimeoutMs); + const result = await Promise.race([fn(), timeout.promise]); + timeout.cancel(); + return success(result); + } + } catch (err) { + if (timeout?.cancel) { + timeout.cancel(); + } + return createGoError(err) as GoResultError; + } +}; + +export const go = async ( + fn: () => T, + options?: GoAsyncOptions +): Promise, E>> => { + if (!options) return attempt(fn); + + const { retries, attemptTimeoutMs, delay, totalTimeoutMs, onAttemptError } = options; + + let fullTimeoutExceeded = false; + let totalTimeoutCancellable: CancellableTimeout | null = null; + let fullTimeoutPromise = new Promise((_resolve) => {}); // Never resolves + if (totalTimeoutMs !== undefined) { + // Start a "full" timeout that will stop all retries after it is exceeded + totalTimeoutCancellable = cancellableSleep(totalTimeoutMs); + fullTimeoutPromise = totalTimeoutCancellable.promise.then(() => { + fullTimeoutExceeded = true; + return fail(new Error('Full timeout exceeded')); + }); + } + + // Typing as "any" because TS has troubles understanding that the value can be non-null + let delayCancellable: any; + const makeAttempts = async () => { + const attempts = retries ? retries + 1 : 1; + let lastFailedAttemptResult: GoResultError | null = null; + for (let i = 0; i < attempts; i++) { + // if array of timeouts is provided, use the timeout at the current index, + // or the last one if the index is out of bounds + // if a single timeout is provided, use it for all attempts + let currentAttemptTimeoutMs: number | undefined; + if (Array.isArray(attemptTimeoutMs)) { + currentAttemptTimeoutMs = attemptTimeoutMs[i] || attemptTimeoutMs.at(-1); + } else { + currentAttemptTimeoutMs = attemptTimeoutMs; + } + // Return early in case the global timeout has been exceeded during after attempt wait time. + // + // This is guaranteed to be false for the first attempt. + if (fullTimeoutExceeded) break; + const goRes = await attempt(fn, currentAttemptTimeoutMs); + // Return early if the timeout is exceeded not to cause any side effects (such as calling "onAttemptError" function) + if (fullTimeoutExceeded) break; + + if (i !== attempts - 1 && !goRes.success && onAttemptError) goSync(() => onAttemptError(goRes)); + if (goRes.success) return goRes; + + lastFailedAttemptResult = goRes; + if (delay && i !== attempts - 1) { + switch (delay.type) { + case 'random': { + const { minDelayMs, maxDelayMs } = delay; + delayCancellable = cancellableSleep(getRandomInRange(minDelayMs, maxDelayMs)); + await delayCancellable.promise; + break; + } + case 'static': { + const { delayMs } = delay; + delayCancellable = cancellableSleep(delayMs); + await delayCancellable.promise; + break; + } + } + } + } + + return lastFailedAttemptResult!; + }; + + const result = await Promise.race([makeAttempts(), fullTimeoutPromise]); + if (totalTimeoutCancellable?.cancel) totalTimeoutCancellable.cancel(); + if (delayCancellable?.cancel) delayCancellable.cancel(); + + return result as Promise, E>>; +}; From ff72560c74cf579d7ab0987e7b25adb4828f03fd Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 10:31:48 +0200 Subject: [PATCH 02/12] Add to exports and replace internal imports --- package.json | 1 - pnpm-lock.yaml | 14 +++----------- src/blockchain-utilities/schema.ts | 3 ++- src/config-parsing/index.test.ts | 3 ++- src/http/index.ts | 3 ++- src/processing/processing.test.ts | 2 +- src/processing/processing.ts | 3 ++- src/processing/unsafe-evaluate.ts | 2 +- src/release-scripts/tag-and-release.ts | 3 ++- src/run-in-loop/index.ts | 3 +-- src/universal-index.ts | 1 + 11 files changed, 17 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 8c80213..0950448 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ }, "dependencies": { "@api3/ois": "^3.0.0", - "@api3/promise-utils": "^0.4.0", "@octokit/rest": "^20.1.1", "axios": "^1.18.1", "dotenv": "^17.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 429bb56..ac603cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@api3/ois': specifier: ^3.0.0 version: 3.0.0 - '@api3/promise-utils': - specifier: ^0.4.0 - version: 0.4.0 '@octokit/rest': specifier: ^20.1.1 version: 20.1.1 @@ -91,9 +88,6 @@ packages: '@api3/ois@3.0.0': resolution: {integrity: sha512-scqcvBOBsptgW3rsTLxQSlTA51W1Fr0YpvyKg2JDS2zTDeGNg1gWx9A3tW6fTM4cj57VnowPgj/ZyxQ7w1Ch0Q==} - '@api3/promise-utils@0.4.0': - resolution: {integrity: sha512-+8fcNjjQeQAuuSXFwu8PMZcYzjwjDiGYcMUfAQ0lpREb1zHonwWZ2N0B9h/g1cvWzg9YhElbeb/SyhCrNm+b/A==} - '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -3339,8 +3333,6 @@ snapshots: lodash: 4.18.1 zod: 4.4.3 - '@api3/promise-utils@0.4.0': {} - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -5197,7 +5189,7 @@ snapshots: debug: 4.4.3 enhanced-resolve: 5.20.1 eslint: 8.57.1 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.13.7 @@ -5219,7 +5211,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -5317,7 +5309,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/src/blockchain-utilities/schema.ts b/src/blockchain-utilities/schema.ts index 08e3d46..d266e67 100644 --- a/src/blockchain-utilities/schema.ts +++ b/src/blockchain-utilities/schema.ts @@ -1,7 +1,8 @@ -import { goSync } from '@api3/promise-utils'; import { ethers } from 'ethers'; import { z } from 'zod'; +import { goSync } from '../promises'; + export const hexSchema = z .string() .regex(/^0x[\dA-Fa-f]+$/, 'Invalid hex string format') diff --git a/src/config-parsing/index.test.ts b/src/config-parsing/index.test.ts index 0065d02..b7f3b95 100644 --- a/src/config-parsing/index.test.ts +++ b/src/config-parsing/index.test.ts @@ -1,6 +1,7 @@ -import { goSync } from '@api3/promise-utils'; import { ZodError } from 'zod'; +import { goSync } from '../promises'; + import { interpolateSecretsIntoConfig } from './index'; const rawConfig = { diff --git a/src/http/index.ts b/src/http/index.ts index b085cc9..ef42956 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,6 +1,7 @@ -import { go, type GoAsyncOptions } from '@api3/promise-utils'; import axios, { type Method, type AxiosError, type AxiosResponse } from 'axios'; +import { go, type GoAsyncOptions } from '../promises'; + const DEFAULT_TIMEOUT_MS = 10_000; export interface Request { diff --git a/src/processing/processing.test.ts b/src/processing/processing.test.ts index d989a8e..3ed710d 100644 --- a/src/processing/processing.test.ts +++ b/src/processing/processing.test.ts @@ -1,8 +1,8 @@ /* eslint-disable jest/prefer-strict-equal */ // Because the errors are thrown from the "vm" module (different context), they are not strictly equal. -import { go } from '@api3/promise-utils'; import { ZodError } from 'zod'; import { createEndpoint } from '../../test/fixtures/processing'; +import { go } from '../promises'; import { addReservedParameters, diff --git a/src/processing/processing.ts b/src/processing/processing.ts index 70d8538..2f71b12 100644 --- a/src/processing/processing.ts +++ b/src/processing/processing.ts @@ -1,5 +1,6 @@ import { type Endpoint, RESERVED_PARAMETERS } from '@api3/ois'; -import { type GoAsyncOptions, go } from '@api3/promise-utils'; + +import { type GoAsyncOptions, go } from '../promises'; import { type EndpointParameters, diff --git a/src/processing/unsafe-evaluate.ts b/src/processing/unsafe-evaluate.ts index d0ec52b..1a7d8ed 100644 --- a/src/processing/unsafe-evaluate.ts +++ b/src/processing/unsafe-evaluate.ts @@ -35,7 +35,7 @@ import vm from 'node:vm'; import worker_threads from 'node:worker_threads'; import zlib from 'node:zlib'; -import { type GoWrappedError, go } from '@api3/promise-utils'; +import { type GoWrappedError, go } from '../promises'; import { createTimers } from './vm-timers'; diff --git a/src/release-scripts/tag-and-release.ts b/src/release-scripts/tag-and-release.ts index 9f14c74..e23e8cb 100644 --- a/src/release-scripts/tag-and-release.ts +++ b/src/release-scripts/tag-and-release.ts @@ -17,9 +17,10 @@ import { execSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; -import { go } from '@api3/promise-utils'; import { Octokit } from '@octokit/rest'; +import { go } from '../promises'; + const execSyncWithErrorHandling = (command: string) => { // eslint-disable-next-line functional/no-try-statements try { diff --git a/src/run-in-loop/index.ts b/src/run-in-loop/index.ts index 4197901..85c046e 100644 --- a/src/run-in-loop/index.ts +++ b/src/run-in-loop/index.ts @@ -1,6 +1,5 @@ -import { go } from '@api3/promise-utils'; - import { type Logger } from '../logger'; +import { go } from '../promises'; import { generateRandomBytes32, sleep } from '../utils'; export type RunInLoopExecutionIdOptions = diff --git a/src/universal-index.ts b/src/universal-index.ts index cf05bb6..207afc0 100644 --- a/src/universal-index.ts +++ b/src/universal-index.ts @@ -1,5 +1,6 @@ // NOTE: Only export modules which work in both Node.js and browser environments. export * from './blockchain-utilities'; export * from './http'; +export * from './promises'; export * from './run-in-loop'; export * from './utils'; From b9d46245af115f240872ef1b7c446b8eeeb7ccbe Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 10:15:41 +0200 Subject: [PATCH 03/12] Use dedicated matchers in test --- src/promises/index.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/promises/index.test.ts b/src/promises/index.test.ts index d4c6a0a..6d5543a 100644 --- a/src/promises/index.test.ts +++ b/src/promises/index.test.ts @@ -261,7 +261,7 @@ describe('custom error type', () => { assertType(err); // Check that "err" is not assignable to CustomError assertType.isFalse(false as Equal); - expect(err instanceof CustomError).toBe(true); + expect(err).toBeInstanceOf(CustomError); }); it('can specify custom error type', () => { @@ -272,7 +272,7 @@ describe('custom error type', () => { const err = goRes.error; assertType(err); - expect(err instanceof CustomError).toBe(true); + expect(err).toBeInstanceOf(CustomError); }); it('will wraps non error throw in Error class', () => { @@ -283,7 +283,7 @@ describe('custom error type', () => { const err = goRes.error; assertType(err); - expect(err instanceof Error).toBe(true); + expect(err).toBeInstanceOf(Error); }); }); @@ -298,7 +298,7 @@ describe('custom error type', () => { assertType(err); // Check that "err" is not assignable to CustomError assertType.isFalse(false as Equal); - expect(err instanceof CustomError).toBe(true); + expect(err).toBeInstanceOf(CustomError); }); it('can specify custom error type', async () => { @@ -309,7 +309,7 @@ describe('custom error type', () => { const err = goRes.error; assertType(err); - expect(err instanceof CustomError).toBe(true); + expect(err).toBeInstanceOf(CustomError); }); it('will wraps non error throw in Error class', async () => { @@ -320,7 +320,7 @@ describe('custom error type', () => { const err = goRes.error; assertType(err); - expect(err instanceof Error).toBe(true); + expect(err).toBeInstanceOf(Error); }); }); }); @@ -531,7 +531,7 @@ describe('delay', () => { { delay: { type: 'random', minDelayMs: 0, maxDelayMs: 100 }, retries: 2 } ); - expect(ticks.length).toBe(3); + expect(ticks).toHaveLength(3); expectToBeAround(ticks[0]!, 0); expectToBeAround(ticks[1]!, 50); expectToBeAround(ticks[2]!, 150); @@ -551,7 +551,7 @@ describe('delay', () => { { delay: { type: 'static', delayMs: 50 }, retries: 2 } ); - expect(ticks.length).toBe(3); + expect(ticks).toHaveLength(3); expectToBeAround(ticks[0]!, 0); expectToBeAround(ticks[1]!, 50); expectToBeAround(ticks[2]!, 100); @@ -572,7 +572,7 @@ describe('totalTimeoutMs', () => { { delay: { type: 'static', delayMs: 50 }, retries: 150, totalTimeoutMs: 150 } ); - expect(ticks.length).toBe(3); + expect(ticks).toHaveLength(3); expectToBeAround(ticks[0]!, 0); expectToBeAround(ticks[1]!, 50); expectToBeAround(ticks[2]!, 100); @@ -590,7 +590,7 @@ describe('totalTimeoutMs', () => { { delay: { type: 'static', delayMs: 50 }, retries: 10, totalTimeoutMs: 0 } ); - expect(ticks.length).toBe(1); + expect(ticks).toHaveLength(1); expectToBeAround(ticks[0]!, 0); }); @@ -623,7 +623,7 @@ describe('onAttemptError', () => { { retries: 3, onAttemptError } ); - expect(onAttemptError).toBeCalledTimes(3); + expect(onAttemptError).toHaveBeenCalledTimes(3); expect(onAttemptError).toHaveBeenNthCalledWith(1, fail(new Error('fail1'))); expect(onAttemptError).toHaveBeenNthCalledWith(2, fail(new Error('fail2'))); expect(onAttemptError).toHaveBeenNthCalledWith(3, fail(new Error('fail3'))); From e585be5eaf6c29012009dc2b0370fe22dc0f79af Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 10:38:02 +0200 Subject: [PATCH 04/12] Replace deprecated Equal type --- src/promises/index.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/promises/index.test.ts b/src/promises/index.test.ts index 6d5543a..be45365 100644 --- a/src/promises/index.test.ts +++ b/src/promises/index.test.ts @@ -1,5 +1,6 @@ +import { assertType, type IsEqual } from 'type-plus'; + import { go, goSync, success, fail, assertGoSuccess, assertGoError, GoWrappedError } from './index'; -import { assertType, Equal } from 'type-plus'; const expectToBeAround = (actual: number, expected: number, range = 10) => { expect(actual).toBeGreaterThanOrEqual(expected - range); @@ -260,7 +261,7 @@ describe('custom error type', () => { assertType(err); // Check that "err" is not assignable to CustomError - assertType.isFalse(false as Equal); + assertType.isFalse(false as IsEqual); expect(err).toBeInstanceOf(CustomError); }); @@ -297,7 +298,7 @@ describe('custom error type', () => { assertType(err); // Check that "err" is not assignable to CustomError - assertType.isFalse(false as Equal); + assertType.isFalse(false as IsEqual); expect(err).toBeInstanceOf(CustomError); }); From 01938386305be1d1eb5ce23729dbe75f11dcabea Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 11:02:52 +0200 Subject: [PATCH 05/12] Run eslint:fix --- src/promises/index.test.ts | 30 +++++++++++++++++------------- src/promises/index.ts | 23 +++++++++++------------ 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/promises/index.test.ts b/src/promises/index.test.ts index be45365..22808a7 100644 --- a/src/promises/index.test.ts +++ b/src/promises/index.test.ts @@ -347,7 +347,7 @@ describe('the "this" limitation', () => { // The error message for when reading a property of undefined has changed between major node versions const expectReadPropertyOfUndefined = (res: unknown, prop: string) => { // process.version returns the version as the string: 'v[major].[minor].[patch]' - const majorVersion = process.version.split('.')[0]!.substring(1); + const majorVersion = process.version.split('.')[0]!.slice(1); if (Number(majorVersion) >= 16) { expect(res).toEqual(fail(new TypeError(`Cannot read properties of undefined (reading '${prop}')`))); } else { @@ -379,7 +379,7 @@ describe('assertGoSuccess', () => { assertGoSuccess(res); // The "data" property should now be inferred since the success was asserted - const data = res.data; + const { data } = res; expect(data).toBe(data); }); @@ -412,7 +412,7 @@ describe('assertGoError', () => { }); }); -it('has access to native error', async () => { +test('has access to native error', async () => { const throwingFn = async () => { throw { message: 'an error', data: 'some data' }; }; @@ -436,7 +436,7 @@ describe('documentation snippets are valid', () => { it('success usage', async () => { const goFetchData = await go(() => fetchData('users')); if (goFetchData.success) { - const data = goFetchData.data; + const { data } = goFetchData; assertType(data); expect(data).toBe('some data'); @@ -446,7 +446,7 @@ describe('documentation snippets are valid', () => { it('error usage', async () => { const goFetchData = await go(() => fetchData('throw')); if (!goFetchData.success) { - const error = goFetchData.error; + const { error } = goFetchData; expect(error).toEqual(new Error('unexpected error')); } @@ -457,7 +457,7 @@ describe('documentation snippets are valid', () => { const parseData = (rawData: typeof someData) => ({ ...rawData, parsed: true }); const goParseData = goSync(() => parseData(someData)); if (goParseData.success) { - const data = goParseData.data; + const { data } = goParseData; expect(data.parsed).toBe(true); } @@ -496,16 +496,20 @@ describe('documentation snippets are valid', () => { try { const data = await someAsyncCall(); assertType(data); // The function above should throw - } catch (e) { - return logError((e as MyError).reason); + } catch (error) { + logError((error as MyError).reason); + return; } // Compare it to simpler version using go type MyData = Promise; const goRes = await go(someAsyncCall); - if (!goRes.success) return logError(goRes.error.reason); + if (!goRes.success) { + logError(goRes.error.reason); + return; + } // At this point TypeScript infers that the error was handled and goRes must be a success response - const data = goRes.data; + const { data } = goRes; assertType(data); }); }); @@ -619,7 +623,7 @@ describe('onAttemptError', () => { const goRes = await go( async () => { counter++; - throw new Error('fail' + counter); + throw new Error(`fail${counter}`); }, { retries: 3, onAttemptError } ); @@ -648,7 +652,7 @@ describe('onAttemptError', () => { it('does not call the callback after successful attempt', async () => { const onAttemptError = jest.fn(); - await go(async () => Promise.resolve(123), { onAttemptError }); + await go(async () => 123, { onAttemptError }); expect(onAttemptError).toHaveBeenCalledTimes(0); }); @@ -738,7 +742,7 @@ describe('onAttemptError', () => { const goRes = await go( async () => { counter++; - const m = 'fail' + counter; + const m = `fail${counter}`; log.push(`go callback: ${m}`); throw new Error(m); }, diff --git a/src/promises/index.ts b/src/promises/index.ts index 6ddb47b..306d66c 100644 --- a/src/promises/index.ts +++ b/src/promises/index.ts @@ -24,7 +24,7 @@ export interface GoAsyncOptions { export class GoWrappedError extends Error { constructor(public reason: unknown) { - super('' + reason); + super(`${reason}`); } } @@ -62,8 +62,8 @@ const createGoError = (err: unknown): GoResultError => { export const goSync = (fn: () => T): GoResult => { try { return success(fn()); - } catch (err) { - return createGoError(err) as GoResultError; + } catch (error) { + return createGoError(error) as GoResultError; } }; @@ -120,18 +120,19 @@ const attempt = async ( // We need try/catch because `fn` might throw sync errors as well try { - if (attemptTimeoutMs === undefined) return success(await fn()); - else { + if (attemptTimeoutMs === undefined) { + return success(await fn()); + } else { timeout = cancellableTimeout(attemptTimeoutMs); const result = await Promise.race([fn(), timeout.promise]); timeout.cancel(); return success(result); } - } catch (err) { + } catch (error) { if (timeout?.cancel) { timeout.cancel(); } - return createGoError(err) as GoResultError; + return createGoError(error) as GoResultError; } }; @@ -165,11 +166,9 @@ export const go = async ( // or the last one if the index is out of bounds // if a single timeout is provided, use it for all attempts let currentAttemptTimeoutMs: number | undefined; - if (Array.isArray(attemptTimeoutMs)) { - currentAttemptTimeoutMs = attemptTimeoutMs[i] || attemptTimeoutMs.at(-1); - } else { - currentAttemptTimeoutMs = attemptTimeoutMs; - } + currentAttemptTimeoutMs = Array.isArray(attemptTimeoutMs) + ? attemptTimeoutMs[i] || attemptTimeoutMs.at(-1) + : attemptTimeoutMs; // Return early in case the global timeout has been exceeded during after attempt wait time. // // This is guaranteed to be false for the first attempt. From 099702f32552dcd8638df1a9da2390c82ed0578f Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 11:37:32 +0200 Subject: [PATCH 06/12] Fix remaining eslint errors in implementation --- src/processing/processing.test.ts | 8 ++++---- src/promises/index.ts | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/processing/processing.test.ts b/src/processing/processing.test.ts index 3ed710d..8da09fa 100644 --- a/src/processing/processing.test.ts +++ b/src/processing/processing.test.ts @@ -59,7 +59,7 @@ describe(preProcessEndpointParametersV1.name, () => { const throwingFunc = async () => preProcessEndpointParametersV1(preProcessingSpecifications, parameters); - await expect(throwingFunc).rejects.toThrow('SyntaxError: Unexpected identifier'); + await expect(throwingFunc).rejects.toThrow('Unexpected identifier'); }); it('demonstrates access to endpointParameters, but reserved parameters are inaccessible', async () => { @@ -193,7 +193,7 @@ describe(postProcessResponseV1.name, () => { const throwingFunc = async () => postProcessResponseV1({ price: 1000 }, postProcessingSpecifications, parameters); - await expect(throwingFunc).rejects.toThrow('SyntaxError: Unexpected identifier'); + await expect(throwingFunc).rejects.toThrow('Unexpected identifier'); }); }); @@ -299,7 +299,7 @@ describe(preProcessEndpointParametersV2.name, () => { const throwingFunc = async () => preProcessEndpointParametersV2(preProcessingSpecificationV2, parameters); - await expect(throwingFunc).rejects.toThrow('SyntaxError: Unexpected identifier'); + await expect(throwingFunc).rejects.toThrow('Unexpected identifier'); }); it('reserved parameters are inaccessible', async () => { @@ -452,7 +452,7 @@ describe(postProcessResponseV2.name, () => { const throwingFunc = async () => postProcessResponseV2({ price: 1000 }, postProcessingSpecificationV2, parameters); - await expect(throwingFunc).rejects.toThrow('SyntaxError: Unexpected identifier'); + await expect(throwingFunc).rejects.toThrow('Unexpected identifier'); }); }); diff --git a/src/promises/index.ts b/src/promises/index.ts index 306d66c..5a867b9 100644 --- a/src/promises/index.ts +++ b/src/promises/index.ts @@ -1,3 +1,8 @@ +/* eslint-disable functional/no-try-statements */ + +import isError from 'lodash/isError'; +import noop from 'lodash/noop'; + // NOTE: We use discriminated unions over "success" property export type GoResultSuccess = { data: T; success: true; error: undefined }; export type GoResultError = { data: undefined; error: E; success: false }; @@ -22,6 +27,7 @@ export interface GoAsyncOptions { onAttemptError?: (goRes: GoResultError) => void; // Callback invoked after each failed attempt is completed. This callback does not fire for the last attempt or when a "totalTimeoutMs" is exceeded (these should be handled explicitly with the result of "go" call). } +// eslint-disable-next-line functional/no-classes export class GoWrappedError extends Error { constructor(public reason: unknown) { super(`${reason}`); @@ -55,7 +61,7 @@ export const fail = (err: Error): GoResultError => { }; const createGoError = (err: unknown): GoResultError => { - if (err instanceof Error) return fail(err); + if (isError(err)) return fail(err); return fail(new GoWrappedError(err)); }; @@ -96,7 +102,7 @@ const cancellableSleep = (ms: number) => { const cancellableTimeout = (ms: number): CancellableTimeout => { let rejectFn: any; let timeoutId: any; - const promise = new Promise((_, reject) => { + const promise = new Promise((_resolve, reject) => { rejectFn = reject; timeoutId = setTimeout(() => reject('Operation timed out'), ms); }); @@ -146,7 +152,7 @@ export const go = async ( let fullTimeoutExceeded = false; let totalTimeoutCancellable: CancellableTimeout | null = null; - let fullTimeoutPromise = new Promise((_resolve) => {}); // Never resolves + let fullTimeoutPromise = new Promise(noop); // Never resolves if (totalTimeoutMs !== undefined) { // Start a "full" timeout that will stop all retries after it is exceeded totalTimeoutCancellable = cancellableSleep(totalTimeoutMs); @@ -165,10 +171,11 @@ export const go = async ( // if array of timeouts is provided, use the timeout at the current index, // or the last one if the index is out of bounds // if a single timeout is provided, use it for all attempts - let currentAttemptTimeoutMs: number | undefined; - currentAttemptTimeoutMs = Array.isArray(attemptTimeoutMs) - ? attemptTimeoutMs[i] || attemptTimeoutMs.at(-1) + const currentAttemptTimeoutMs = Array.isArray(attemptTimeoutMs) + ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + attemptTimeoutMs[i] || attemptTimeoutMs.at(-1) : attemptTimeoutMs; + // Return early in case the global timeout has been exceeded during after attempt wait time. // // This is guaranteed to be false for the first attempt. From aba6c91d970be90ac18cdaaf8abb95523ca52a8e Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 11:20:40 +0200 Subject: [PATCH 07/12] Fix jest/prefer-strict-equal eslint errors --- src/promises/index.test.ts | 96 ++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 46 deletions(-) diff --git a/src/promises/index.test.ts b/src/promises/index.test.ts index 22808a7..151bf76 100644 --- a/src/promises/index.test.ts +++ b/src/promises/index.test.ts @@ -15,8 +15,8 @@ const rejectAfter = (ms: number, value?: T): Promise => describe('basic goSync usage', () => { it('resolves successful synchronous functions', () => { const res = goSync(() => 2 + 2); - expect(res).toEqual(success(4)); - expect(res).toEqual({ success: true, data: 4 }); + expect(res).toStrictEqual(success(4)); + expect(res).toStrictEqual({ success: true, data: 4, error: undefined }); }); it('resolves unsuccessful synchronous functions', () => { @@ -24,8 +24,8 @@ describe('basic goSync usage', () => { const res = goSync(() => { throw err; }); - expect(res).toEqual(fail(err)); - expect(res).toEqual({ success: false, error: err }); + expect(res).toStrictEqual(fail(err)); + expect(res).toStrictEqual({ success: false, data: undefined, error: err }); }); }); @@ -33,14 +33,14 @@ describe('basic go usage', () => { it('resolves successful asynchronous functions', async () => { const successFn = new Promise((res) => res(2)); const res = await go(() => successFn); - expect(res).toEqual(success(2)); + expect(res).toStrictEqual(success(2)); }); it('resolves unsuccessful asynchronous functions', async () => { const err = new Error('Computer says no'); const errorFn = new Promise((_res, rej) => rej(err)); const res = await go(() => errorFn); - expect(res).toEqual(fail(err)); + expect(res).toStrictEqual(fail(err)); }); it('resolves asynchronous functions which throws', async () => { @@ -49,13 +49,13 @@ describe('basic go usage', () => { throw err; }); const res = await go(() => errorFn); - expect(res).toEqual(fail(err)); + expect(res).toStrictEqual(fail(err)); }); it('resolves on sync errors as well', async () => { const obj = {} as any; const res = await go(() => obj.nonExistingFunction()); - expect(res).toEqual(fail(new TypeError('obj.nonExistingFunction is not a function'))); + expect(res).toStrictEqual(fail(new TypeError('obj.nonExistingFunction is not a function'))); }); // NOTE: This is not an issue of promise utils library since the error is thrown before the value is passed as an @@ -70,7 +70,7 @@ describe('basic go usage', () => { const res = await go(() => { throw err; }); - expect(res).toEqual(fail(err)); + expect(res).toStrictEqual(fail(err)); }); }); @@ -88,7 +88,7 @@ describe('basic retry usage', () => { const res = await go(operations.successFn, { retries: 2 }); expect(operations.successFn).toHaveBeenCalledTimes(3); - expect(res).toEqual(success(2)); + expect(res).toStrictEqual(success(2)); }); it('retries and resolves unsuccessful asynchronous functions with the error from last retry', async () => { @@ -100,7 +100,7 @@ describe('basic retry usage', () => { const res = await go(operations.errorFn, { retries: 2 }); expect(operations.errorFn).toHaveBeenCalledTimes(attempts); - expect(res).toEqual(fail(new Error('Computer says no'))); + expect(res).toStrictEqual(fail(new Error('Computer says no'))); }); it('resolves unsuccessful asynchronous functions with no retries', async () => { @@ -108,7 +108,7 @@ describe('basic retry usage', () => { const res = await go(operations.errorFn, { retries: 0 }); expect(operations.errorFn).toHaveBeenCalledTimes(1); - expect(res).toEqual(fail(new Error('Computer says no'))); + expect(res).toStrictEqual(fail(new Error('Computer says no'))); }); }); @@ -120,33 +120,33 @@ describe('basic timeout usage', () => { it('resolves successful asynchronous functions within the timout limit', async () => { const res = await go(operations.successFn, { attemptTimeoutMs: 20 }); - expect(res).toEqual(success(2)); + expect(res).toStrictEqual(success(2)); }); it('resolves unsuccessful asynchronous functions within the timout limit', async () => { const res = await go(operations.errorFn, { attemptTimeoutMs: 20 }); - expect(res).toEqual(fail(new Error('Computer says no'))); + expect(res).toStrictEqual(fail(new Error('Computer says no'))); }); it('resolves timed out asynchronous functions', async () => { const res = await go(operations.successFn, { attemptTimeoutMs: 5 }); - expect(res).toEqual(fail(new Error('Operation timed out'))); + expect(res).toStrictEqual(fail(new GoWrappedError('Operation timed out'))); }); it('shows difference between promise callback and promise value', async () => { // Promise value tries to resolve THE SAME promise every attempt const sleepPromise = resolveAfter(50); const goVal = await go(() => sleepPromise, { attemptTimeoutMs: 30, retries: 1 }); - expect(goVal).toEqual(success(undefined)); + expect(goVal).toStrictEqual(success(undefined)); // Promise callback tries to resolve NEW promise every attempt const goFn = await go(() => resolveAfter(50), { attemptTimeoutMs: 30, retries: 1 }); - expect(goFn).toEqual(fail(new Error('Operation timed out'))); + expect(goFn).toStrictEqual(fail(new GoWrappedError('Operation timed out'))); }); it('shows that timeout 0 means 0 ms (not infinity)', async () => { const res = await go(operations.successFn, { attemptTimeoutMs: 0 }); - expect(res).toEqual(fail(new Error('Operation timed out'))); + expect(res).toStrictEqual(fail(new GoWrappedError('Operation timed out'))); }); }); @@ -158,12 +158,12 @@ describe('basic retry and timeout usage', () => { it('resolves successful asynchronous functions', async () => { const res = await go(operations.successFn, { attemptTimeoutMs: 120, retries: 3 }); - expect(res).toEqual(success(2)); + expect(res).toStrictEqual(success(2)); }); it('resolves unsuccessful asynchronous functions', async () => { const res = await go(operations.errorFn, { attemptTimeoutMs: 120, retries: 3 }); - expect(res).toEqual(fail(new Error('Computer says no'))); + expect(res).toStrictEqual(fail(new Error('Computer says no'))); }); it('retries and resolves successful asynchronous functions', async () => { @@ -174,7 +174,7 @@ describe('basic retry and timeout usage', () => { const res = await go(operations.successFn, { attemptTimeoutMs: 120, retries: 3 }); expect(operations.successFn).toHaveBeenCalledTimes(3); - expect(res).toEqual(success(2)); + expect(res).toStrictEqual(success(2)); }); it('retries and resolves successful asynchronous functions with varying timeouts', async () => { @@ -184,7 +184,7 @@ describe('basic retry and timeout usage', () => { const end = performance.now(); expectToBeAround(end - start, 50 + 70 + 90 + 100); expect(operations.successFn).toHaveBeenCalledTimes(4); - expect(res).toEqual(success(2)); + expect(res).toStrictEqual(success(2)); }); it('retries and resolves unsuccessful asynchronous functions', async () => { @@ -195,7 +195,7 @@ describe('basic retry and timeout usage', () => { const res = await go(operations.errorFn, { attemptTimeoutMs: 120, retries: 2 }); expect(operations.errorFn).toHaveBeenCalledTimes(3); - expect(res).toEqual(fail(new Error('Computer says no'))); + expect(res).toStrictEqual(fail(new Error('Computer says no'))); }); it('retries and resolves unsuccessful timed out functions', async () => { @@ -204,7 +204,7 @@ describe('basic retry and timeout usage', () => { const res = await go(operations.successFn, { attemptTimeoutMs: 50, retries: 2 }); expect(operations.successFn).toHaveBeenCalledTimes(attempts); - expect(res).toEqual(fail(new Error('Operation timed out'))); + expect(res).toStrictEqual(fail(new GoWrappedError('Operation timed out'))); }); it('retries with multiple timeout durations and resolves unsuccessful timed out functions', async () => { @@ -216,7 +216,7 @@ describe('basic retry and timeout usage', () => { const end = performance.now(); expectToBeAround(end - start, 50 + 70 + 90); expect(operations.successFn).toHaveBeenCalledTimes(attempts); - expect(res).toEqual(fail(new Error('Operation timed out'))); + expect(res).toStrictEqual(fail(new GoWrappedError('Operation timed out'))); }); it('retries and timeouts within the timeout limit of each attempt', async () => { @@ -226,7 +226,7 @@ describe('basic retry and timeout usage', () => { const end = performance.now(); expectToBeAround(end - start, 50 + 60 + 70 + 80 + 90 + 95); expect(operations.successFn).toHaveBeenCalledTimes(6); - expect(res).toEqual(fail(new Error('Operation timed out'))); + expect(res).toStrictEqual(fail(new GoWrappedError('Operation timed out'))); }); it('retries with multiple timeout durations and uses the last value if array length is smaller than total attempts', async () => { @@ -237,7 +237,7 @@ describe('basic retry and timeout usage', () => { const end = performance.now(); expectToBeAround(end - start, 50 + 70 + 70 + 70 + 70 + 70); expect(operations.successFn).toHaveBeenCalledTimes(attempts); - expect(res).toEqual(fail(new Error('Operation timed out'))); + expect(res).toStrictEqual(fail(new GoWrappedError('Operation timed out'))); }); }); @@ -349,9 +349,9 @@ describe('the "this" limitation', () => { // process.version returns the version as the string: 'v[major].[minor].[patch]' const majorVersion = process.version.split('.')[0]!.slice(1); if (Number(majorVersion) >= 16) { - expect(res).toEqual(fail(new TypeError(`Cannot read properties of undefined (reading '${prop}')`))); + expect(res).toStrictEqual(fail(new TypeError(`Cannot read properties of undefined (reading '${prop}')`))); } else { - expect(res).toEqual(fail(new TypeError(`Cannot read property '${prop}' of undefined`))); + expect(res).toStrictEqual(fail(new TypeError(`Cannot read property '${prop}' of undefined`))); } }; @@ -421,9 +421,9 @@ test('has access to native error', async () => { assertGoError(goRes); // The error message is the not very useful stringified data - expect(goRes.error).toEqual(new Error('[object Object]')); + expect(goRes.error).toStrictEqual(new GoWrappedError({ message: 'an error', data: 'some data' })); expect(goRes.error instanceof GoWrappedError).toBeTruthy(); - expect(goRes.error.reason).toEqual({ message: 'an error', data: 'some data' }); + expect(goRes.error.reason).toStrictEqual({ message: 'an error', data: 'some data' }); }); // NOTE: Keep in sync with README @@ -448,7 +448,7 @@ describe('documentation snippets are valid', () => { if (!goFetchData.success) { const { error } = goFetchData; - expect(error).toEqual(new Error('unexpected error')); + expect(error).toStrictEqual(new GoWrappedError('unexpected error')); } }); @@ -490,7 +490,7 @@ describe('documentation snippets are valid', () => { } } const someAsyncCall = () => Promise.reject(new MyError('custom error')); - const logError = (mess: string) => expect(mess).toEqual(expect.any(String)); + const logError = (mess: string) => expect(mess).toStrictEqual(expect.any(String)); // Verbose try catch try { @@ -517,7 +517,7 @@ describe('documentation snippets are valid', () => { describe('delay', () => { it('only delays on retries', async () => { const goRes = await go(async () => 123, { delay: { type: 'static', delayMs: 2000 } }); - expect(goRes).toEqual(success(123)); + expect(goRes).toStrictEqual(success(123)); }, 20); // Make the test timeout smaller then the delay describe('random', () => { @@ -611,7 +611,7 @@ describe('totalTimeoutMs', () => { const delta = Date.now() - now; expectToBeAround(delta, 20); - expect(goRes).toEqual(fail(new Error('Full timeout exceeded'))); + expect(goRes).toStrictEqual(fail(new Error('Full timeout exceeded'))); }); }); @@ -632,7 +632,7 @@ describe('onAttemptError', () => { expect(onAttemptError).toHaveBeenNthCalledWith(1, fail(new Error('fail1'))); expect(onAttemptError).toHaveBeenNthCalledWith(2, fail(new Error('fail2'))); expect(onAttemptError).toHaveBeenNthCalledWith(3, fail(new Error('fail3'))); - expect(goRes).toEqual(fail(new Error('fail4'))); + expect(goRes).toStrictEqual(fail(new Error('fail4'))); }); it('does not trigger the callback after total timeout has been exceeded', async () => { @@ -646,7 +646,7 @@ describe('onAttemptError', () => { ); expect(onAttemptError).toHaveBeenCalledTimes(0); - expect(goRes).toEqual(fail(new Error('Full timeout exceeded'))); + expect(goRes).toStrictEqual(fail(new Error('Full timeout exceeded'))); }); it('does not call the callback after successful attempt', async () => { @@ -671,7 +671,7 @@ describe('onAttemptError', () => { await resolveAfter(30); expect(onAttemptError).toHaveBeenCalledTimes(0); - expect(goRes).toEqual(fail(new Error('Operation timed out'))); + expect(goRes).toStrictEqual(fail(new GoWrappedError('Operation timed out'))); }); it('and total timeout', async () => { @@ -687,7 +687,7 @@ describe('onAttemptError', () => { await resolveAfter(30); expect(onAttemptError).toHaveBeenCalledTimes(0); - expect(goRes).toEqual(fail(new Error('Full timeout exceeded'))); + expect(goRes).toStrictEqual(fail(new Error('Full timeout exceeded'))); }); it('both attemp timeout and total timeout', async () => { @@ -703,9 +703,9 @@ describe('onAttemptError', () => { await resolveAfter(50); expect(onAttemptError).toHaveBeenCalledTimes(2); - expect(onAttemptError).toHaveBeenNthCalledWith(1, fail(new Error('Operation timed out'))); - expect(onAttemptError).toHaveBeenNthCalledWith(2, fail(new Error('Operation timed out'))); - expect(goRes).toEqual(fail(new Error('Full timeout exceeded'))); + expect(onAttemptError).toHaveBeenNthCalledWith(1, fail(new GoWrappedError('Operation timed out'))); + expect(onAttemptError).toHaveBeenNthCalledWith(2, fail(new GoWrappedError('Operation timed out'))); + expect(goRes).toStrictEqual(fail(new Error('Full timeout exceeded'))); }); }); @@ -726,7 +726,7 @@ describe('onAttemptError', () => { { retries: 3, onAttemptError: (goRes) => { - expect(goRes).toEqual(success(123)); + expect(goRes).toStrictEqual(success(123)); assertGoError(goRes); assertType(goRes.error); @@ -758,10 +758,14 @@ describe('onAttemptError', () => { } ); - expect(goRes).toEqual(fail(new Error('fail2'))); - expect(log).toEqual(['go callback: fail1', 'onAttemptError: {"success":false,"error":{}}', 'go callback: fail2']); + expect(goRes).toStrictEqual(fail(new Error('fail2'))); + expect(log).toStrictEqual([ + 'go callback: fail1', + 'onAttemptError: {"success":false,"error":{}}', + 'go callback: fail2', + ]); await resolveAfter(50); // We need to wait for unfinished onAttemptError callbacks - expect(log).toEqual([ + expect(log).toStrictEqual([ 'go callback: fail1', 'onAttemptError: {"success":false,"error":{}}', 'go callback: fail2', From f09fce26a40f7792a9789c329981ed23de6ba7b3 Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 11:50:19 +0200 Subject: [PATCH 08/12] Cosmetic eslint fixes --- src/promises/index.test.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/promises/index.test.ts b/src/promises/index.test.ts index 151bf76..c54c968 100644 --- a/src/promises/index.test.ts +++ b/src/promises/index.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable lodash/prefer-constant */ +/* eslint-disable unicorn/error-message */ +/* eslint-disable functional/no-classes */ import { assertType, type IsEqual } from 'type-plus'; import { go, goSync, success, fail, assertGoSuccess, assertGoError, GoWrappedError } from './index'; @@ -10,7 +13,7 @@ const expectToBeAround = (actual: number, expected: number, range = 10) => { const resolveAfter = (ms: number, value?: T): Promise => new Promise((resolve) => setTimeout(() => resolve(value as T), ms)); const rejectAfter = (ms: number, value?: T): Promise => - new Promise((_, reject) => setTimeout(() => reject(value), ms)); + new Promise((_resolve, reject) => setTimeout(() => reject(value), ms)); describe('basic goSync usage', () => { it('resolves successful synchronous functions', () => { @@ -31,14 +34,14 @@ describe('basic goSync usage', () => { describe('basic go usage', () => { it('resolves successful asynchronous functions', async () => { - const successFn = new Promise((res) => res(2)); + const successFn = new Promise((resolve) => resolve(2)); const res = await go(() => successFn); expect(res).toStrictEqual(success(2)); }); it('resolves unsuccessful asynchronous functions', async () => { const err = new Error('Computer says no'); - const errorFn = new Promise((_res, rej) => rej(err)); + const errorFn = new Promise((_resolve, reject) => reject(err)); const res = await go(() => errorFn); expect(res).toStrictEqual(fail(err)); }); @@ -76,8 +79,8 @@ describe('basic go usage', () => { describe('basic retry usage', () => { const operations = { - successFn: () => new Promise((res) => res(2)), - errorFn: () => new Promise((_res, rej) => rej(new Error('Computer says no'))), + successFn: () => new Promise((resolve) => resolve(2)), + errorFn: () => new Promise((_resolve, reject) => reject(new Error('Computer says no'))), }; it('retries the specified number of times', async () => { @@ -261,6 +264,7 @@ describe('custom error type', () => { assertType(err); // Check that "err" is not assignable to CustomError + // eslint-disable-next-line deprecation/deprecation assertType.isFalse(false as IsEqual); expect(err).toBeInstanceOf(CustomError); }); @@ -298,6 +302,7 @@ describe('custom error type', () => { assertType(err); // Check that "err" is not assignable to CustomError + // eslint-disable-next-line deprecation/deprecation assertType.isFalse(false as IsEqual); expect(err).toBeInstanceOf(CustomError); }); @@ -358,6 +363,7 @@ describe('the "this" limitation', () => { it('fails for sync version', () => { const test = new Test(); + // eslint-disable-next-line jest/unbound-method -- intentionally passing an unbound method to demonstrate the `this` limitation const res = goSync(test.sync); expectReadPropertyOfUndefined(res, '_sync'); @@ -366,6 +372,7 @@ describe('the "this" limitation', () => { it('fails for async version', async () => { const test = new Test(); + // eslint-disable-next-line jest/unbound-method -- intentionally passing an unbound method to demonstrate the `this` limitation const res = await go(test.async); expectReadPropertyOfUndefined(res, '_async'); @@ -477,6 +484,7 @@ describe('documentation snippets are valid', () => { const myClass = new MyClass(); const resWorks = goSync(() => myClass.get()); // This works assertGoSuccess(resWorks); + // eslint-disable-next-line jest/unbound-method const resFails = goSync(myClass.get); // This doesn't work assertGoError(resFails); }); @@ -493,6 +501,7 @@ describe('documentation snippets are valid', () => { const logError = (mess: string) => expect(mess).toStrictEqual(expect.any(String)); // Verbose try catch + // eslint-disable-next-line functional/no-try-statements try { const data = await someAsyncCall(); assertType(data); // The function above should throw @@ -748,6 +757,7 @@ describe('onAttemptError', () => { }, { retries: 1, + // eslint-disable-next-line @typescript-eslint/no-misused-promises onAttemptError: async (goRes) => { log.push(`onAttemptError: ${JSON.stringify(goRes)}`); @@ -785,7 +795,7 @@ describe('onAttemptError', () => { it('allows you to access both error and success properties', async () => { const { success, error, data } = goSync(() => 123); // @ts-expect-error should not work - const x: number = data; + const _x: number = data; assertType(data); assertType(error); From 58b181d20a9c992837ecee21650e5b85d9858884 Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 12:14:37 +0200 Subject: [PATCH 09/12] Register custom assertion names --- .eslintrc.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.eslintrc.json b/.eslintrc.json index 5eb0703..377289d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,6 +3,25 @@ "rules": { "lodash/import-scope": ["error", "method"] }, + "overrides": [ + { + "files": ["**/*.test.ts"], + "rules": { + "jest/prefer-ending-with-an-expect": [ + "error", + { + "assertFunctionNames": ["expect*", "assertGoSuccess", "assertGoError", "assertType"] + } + ], + "jest/expect-expect": [ + "warn", + { + "assertFunctionNames": ["expect*", "assertGoSuccess", "assertGoError", "assertType"] + } + ] + } + } + ], "parserOptions": { "project": ["./tsconfig.json"] } From 086088e63f0ec2deae4f645b84a1325821a26dab Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 12:27:02 +0200 Subject: [PATCH 10/12] Fix test conditionals eslint rules --- src/promises/index.test.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/promises/index.test.ts b/src/promises/index.test.ts index c54c968..834dd00 100644 --- a/src/promises/index.test.ts +++ b/src/promises/index.test.ts @@ -442,32 +442,29 @@ describe('documentation snippets are valid', () => { it('success usage', async () => { const goFetchData = await go(() => fetchData('users')); - if (goFetchData.success) { - const { data } = goFetchData; + assertGoSuccess(goFetchData); + const { data } = goFetchData; - assertType(data); - expect(data).toBe('some data'); - } + assertType(data); + expect(data).toBe('some data'); }); it('error usage', async () => { const goFetchData = await go(() => fetchData('throw')); - if (!goFetchData.success) { - const { error } = goFetchData; + assertGoError(goFetchData); + const { error } = goFetchData; - expect(error).toStrictEqual(new GoWrappedError('unexpected error')); - } + expect(error).toStrictEqual(new GoWrappedError('unexpected error')); }); it('sync usage', () => { const someData = { key: 123 }; const parseData = (rawData: typeof someData) => ({ ...rawData, parsed: true }); const goParseData = goSync(() => parseData(someData)); - if (goParseData.success) { - const { data } = goParseData; + assertGoSuccess(goParseData); + const { data } = goParseData; - expect(data.parsed).toBe(true); - } + expect(data.parsed).toBe(true); }); it('shows limitation', () => { @@ -513,6 +510,7 @@ describe('documentation snippets are valid', () => { // Compare it to simpler version using go type MyData = Promise; const goRes = await go(someAsyncCall); + // eslint-disable-next-line jest/no-conditional-in-test if (!goRes.success) { logError(goRes.error.reason); return; @@ -718,6 +716,7 @@ describe('onAttemptError', () => { }); }); + // eslint-disable-next-line jest/prefer-ending-with-an-expect -- assertions run inside the onAttemptError callback it('is automatically typed', async () => { class CustomError extends Error { custom: string; @@ -792,6 +791,7 @@ describe('onAttemptError', () => { assertType(goRes.data); }); + // eslint-disable-next-line jest/prefer-ending-with-an-expect it('allows you to access both error and success properties', async () => { const { success, error, data } = goSync(() => 123); // @ts-expect-error should not work @@ -799,6 +799,7 @@ describe('onAttemptError', () => { assertType(data); assertType(error); + // eslint-disable-next-line jest/no-conditional-in-test -- verifies discriminated-union narrowing in both directions if (success) { assertType(data); assertType(error); From e79a12a4fa35fce1cfb8898e238077944783e495 Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 24 Jul 2026 13:09:40 +0200 Subject: [PATCH 11/12] Migrate README --- README.md | 2 +- src/promises/README.md | 184 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/promises/README.md diff --git a/README.md b/README.md index ad375e8..65bdf75 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Read the documentation and sources of each module how to use it in the project. - [http](./src/http/README.md) - [logger](./src/logger/README.md) - [processing](./src/processing/README.md) +- [promises](./src/promises/README.md) - [release-scripts](./src/release-scripts/README.md) - [run-in-loop](./src/run-in-loop/README.md) - [utils](./src/utils/README.md) @@ -52,7 +53,6 @@ manually, by adding the following to your `tsconfig.json`: - [contracts](https://github.com/api3dao/contracts) - [eslint-plugin-commons](https://github.com/api3dao/eslint-plugin-commons) - [ois](https://github.com/api3dao/ois) -- [promise-utils](https://github.com/api3dao/promise-utils) ## For developers diff --git a/src/promises/README.md b/src/promises/README.md new file mode 100644 index 0000000..17b97bf --- /dev/null +++ b/src/promises/README.md @@ -0,0 +1,184 @@ +# Promises + +> A simple module for a functional and typesafe error handling + +## Usage + +The API is small and well focused on providing [more concise error handling](#motivation). The main functions of this +module are `go` and `goSync` functions. They accept a function to execute, and additionally `go` accepts an optional +`GoAsyncOptions` object as the second parameter. If the function executes without an error, a success response with the +data is returned, otherwise an error response is returned. + + + +```ts +// Imagine an async function for fetching API data +const goFetchData = await go(() => fetchData('users')); +// The "goFetchData" value is either: {success: true, data: ...} or {success: false, error: ...} +if (goFetchData.success) { + const data = goFetchData.data + ... +} +``` + +or: + +```ts +// Imagine an async function for fetching API data +// If the fetch data is a non class function returning a promise, you can drop the arrow function +const goFetchData = await go(() => fetchData('users')); +// The "goFetchData" value is either: {success: true, data: ...} or {success: false, error: ...} +if (!goFetchData.success) { + const error = goFetchData.error + ... +} +``` + +and with `GoAsyncOptions`: + +```ts +// The `fetchData` function will be retried a maximum of 2 times on error, with each attempt having +// a timeout of 5 seconds and a total timeout 10 seconds (shared among all attempts and delays). +const goFetchData = await go(() => fetchData('users'), { retries: 2, attemptTimeoutMs: 5_000, totalTimeoutMs: 10_000 }); +... +``` + +and for synchronous functions: + +```ts +const someData = ... +// Imagine a synchronous function for parsing data +const goParseData = goSync(() => parseData(someData)); +// The goParseData value is either: {success: true, data: ...} or {success: false, error: ...} +if (goParseData.success) { + const data = goParseData.data + ... +} +``` + +The return value from the promise utils functions works very well with TypeScript inference. When you check the the +`success` property, TypeScript will infer the correct response type. + +## API + +The full `promises` API consists of the following functions: + +- `go(asyncFn, options)` - Executes the `asyncFn` and returns a response of type `GoResult` +- `goSync(fn)` - Executes the `fn` and returns a response of type `GoResult` +- `assertGoSuccess(goRes)` - Verifies that the `goRes` is a success response (`GoResultSuccess` type) and throws + otherwise. +- `assertGoError(goRes)` - Verifies that the `goRes` is an error response (`GoResultError` type) and throws otherwise. +- `success(value)` - Creates a successful result value, specifically `{success: true, data: value}` +- `fail(error)` - Creates an error result, specifically `{success: false, error: error}` + +and the following Typescript types: + +- ```ts + type GoResultSuccess = { data: T; success: true; error: undefined }; + ``` +- ```ts + type GoResultError = { data: undefined; error: E; success: false }; + ``` +- ```ts + type GoResult = GoResultSuccess | GoResultError; + ``` +- ```ts + interface GoAsyncOptions { + retries?: number; // Number of retries to attempt if the go callback is unsuccessful. + attemptTimeoutMs?: number | number[]; // The timeout for each attempt. Can provide an array for different timeouts for each attempt. If the array is shorter than the number of retries, the last value is used for all remaining attempts, if the length of the array is longer than the number of retries, the extra values are ignored. + totalTimeoutMs?: number; // The maximum timeout for all attempts and delays. No more retries are performed after this timeout. + delay?: StaticDelayOptions | RandomDelayOptions; // Type of the delay before each attempt. There is no delay before the first request. + onAttemptError?: (goRes: GoResultError) => void; // Callback invoked after each failed attempt is completed. This callback does not fire for the last attempt or when a "totalTimeoutMs" is exceeded (these should be handled explicitly with the result of "go" call). + } + ``` +- ```ts + interface StaticDelayOptions { + type: 'static'; + delayMs: number; + } + ``` +- ```ts + interface RandomDelayOptions { + type: 'random'; + minDelayMs: number; + maxDelayMs: number; + } + ``` + +Careful, the `attemptTimeoutMs` value of `0` means timeout of 0 ms. If you want to have infinite timeout omit the key or +set it to `undefined`. + +The last exported value is a `GoWrappedError` class which wraps an error which happens in go callback. The difference +between `GoWrappedError` and regular `Error` class is that you can access `GoWrappedError.reason` to get the original +value which was thrown by the function. + +Take a look at the [implementation](./index.ts) and [tests](./index.test.ts) for detailed examples and usage. + +## Motivation + +### Verbosity and interoperability of try-catch pattern + +```ts +// Verbose try catch +try { + const data = await someAsyncCall(); + ... +} catch (e) { + // The "e" is "unknown" because any value can be thrown in Javascript so casting is needed + return logError((e as MyError).reason); +} + +// Compare it to simpler version using go +const goRes = await go(someAsyncCall); +if (!goRes.success) return logError(goRes.error.reason); +// At this point TypeScript infers that the error was handled and "goRes" must be a success response +const data = goRes.data; +... +``` + +Also, think about what happens when you want to handle multiple "can fail" operations in a single function call. You can +either: + +1. Have them in a same try catch block - but then it's difficult to differentiate between what error has been thrown. + Also this usually leads to a lot of code inside a try block and the catch clause acts more like "catch anything". +2. Use nested try catch blocks - but this hurts readability and forces you into the callback hell pattern. + +### Consistent throwing of an `Error` instance + +JavaScript supports throwing any expression, not just `Error` instances. This is also a reason why TypeScript infers the +error as `unknown` or `any` (see: +[useUnknownInCatchVariables](https://www.typescriptlang.org/tsconfig#useUnknownInCatchVariables)). + +The error response from `go` and `goSync` always return an instance of the `Error` class. Of course, throwing custom +errors (derived from `Error`) is supported. + +### Intentionally limited feature set + +The go utils by design offer only very basic timeout and retry capabilities as these are often application specific and +could quickly result in bloated configuration. If you are looking for more complex features, consider using one of the +alternatives, e.g. https://github.com/lifeomic/attempt + +## Limitations + +There is a limitation when using class functions due to how javascript +[this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this) works. + +```ts +class MyClass { + constructor() {} + get() { + return this._get(); + } + _get() { + return '123'; + } +} + +const myClass = new MyClass(); +const resWorks = goSync(() => myClass.get()); // This works +// However, seeing the line above it may be tempting to rewrite it to +const resFails = goSync(myClass.get); // This doesn't work +``` + +The problem is that the `this` keyword is determined by how a function is called and in the second example, the `this` +inside the `get` function is `undefined` which makes the `this._get()` throw an error. From 7220f8bf433ee0e533f33d6b94bdcd21b47eea2c Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Mon, 27 Jul 2026 17:01:51 +0200 Subject: [PATCH 12/12] Rename to promise-utils --- README.md | 2 +- src/blockchain-utilities/schema.ts | 2 +- src/config-parsing/index.test.ts | 2 +- src/http/index.ts | 2 +- src/processing/processing.test.ts | 2 +- src/processing/processing.ts | 2 +- src/processing/unsafe-evaluate.ts | 2 +- src/{promises => promise-utils}/README.md | 4 ++-- src/{promises => promise-utils}/index.test.ts | 0 src/{promises => promise-utils}/index.ts | 0 src/release-scripts/tag-and-release.ts | 2 +- src/run-in-loop/index.ts | 2 +- src/universal-index.ts | 2 +- 13 files changed, 12 insertions(+), 12 deletions(-) rename src/{promises => promise-utils}/README.md (98%) rename src/{promises => promise-utils}/index.test.ts (100%) rename src/{promises => promise-utils}/index.ts (100%) diff --git a/README.md b/README.md index 65bdf75..51719ed 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Read the documentation and sources of each module how to use it in the project. - [http](./src/http/README.md) - [logger](./src/logger/README.md) - [processing](./src/processing/README.md) -- [promises](./src/promises/README.md) +- [promise-utils](./src/promise-utils/README.md) - [release-scripts](./src/release-scripts/README.md) - [run-in-loop](./src/run-in-loop/README.md) - [utils](./src/utils/README.md) diff --git a/src/blockchain-utilities/schema.ts b/src/blockchain-utilities/schema.ts index d266e67..a75e58f 100644 --- a/src/blockchain-utilities/schema.ts +++ b/src/blockchain-utilities/schema.ts @@ -1,7 +1,7 @@ import { ethers } from 'ethers'; import { z } from 'zod'; -import { goSync } from '../promises'; +import { goSync } from '../promise-utils'; export const hexSchema = z .string() diff --git a/src/config-parsing/index.test.ts b/src/config-parsing/index.test.ts index b7f3b95..4776f7c 100644 --- a/src/config-parsing/index.test.ts +++ b/src/config-parsing/index.test.ts @@ -1,6 +1,6 @@ import { ZodError } from 'zod'; -import { goSync } from '../promises'; +import { goSync } from '../promise-utils'; import { interpolateSecretsIntoConfig } from './index'; diff --git a/src/http/index.ts b/src/http/index.ts index ef42956..71e7739 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,6 +1,6 @@ import axios, { type Method, type AxiosError, type AxiosResponse } from 'axios'; -import { go, type GoAsyncOptions } from '../promises'; +import { go, type GoAsyncOptions } from '../promise-utils'; const DEFAULT_TIMEOUT_MS = 10_000; diff --git a/src/processing/processing.test.ts b/src/processing/processing.test.ts index 8da09fa..edfa1b9 100644 --- a/src/processing/processing.test.ts +++ b/src/processing/processing.test.ts @@ -2,7 +2,7 @@ import { ZodError } from 'zod'; import { createEndpoint } from '../../test/fixtures/processing'; -import { go } from '../promises'; +import { go } from '../promise-utils'; import { addReservedParameters, diff --git a/src/processing/processing.ts b/src/processing/processing.ts index 2f71b12..759358b 100644 --- a/src/processing/processing.ts +++ b/src/processing/processing.ts @@ -1,6 +1,6 @@ import { type Endpoint, RESERVED_PARAMETERS } from '@api3/ois'; -import { type GoAsyncOptions, go } from '../promises'; +import { type GoAsyncOptions, go } from '../promise-utils'; import { type EndpointParameters, diff --git a/src/processing/unsafe-evaluate.ts b/src/processing/unsafe-evaluate.ts index 1a7d8ed..f28dbb2 100644 --- a/src/processing/unsafe-evaluate.ts +++ b/src/processing/unsafe-evaluate.ts @@ -35,7 +35,7 @@ import vm from 'node:vm'; import worker_threads from 'node:worker_threads'; import zlib from 'node:zlib'; -import { type GoWrappedError, go } from '../promises'; +import { type GoWrappedError, go } from '../promise-utils'; import { createTimers } from './vm-timers'; diff --git a/src/promises/README.md b/src/promise-utils/README.md similarity index 98% rename from src/promises/README.md rename to src/promise-utils/README.md index 17b97bf..ff4193f 100644 --- a/src/promises/README.md +++ b/src/promise-utils/README.md @@ -1,4 +1,4 @@ -# Promises +# Promise utils > A simple module for a functional and typesafe error handling @@ -61,7 +61,7 @@ The return value from the promise utils functions works very well with TypeScrip ## API -The full `promises` API consists of the following functions: +The full `promise-utils` API consists of the following functions: - `go(asyncFn, options)` - Executes the `asyncFn` and returns a response of type `GoResult` - `goSync(fn)` - Executes the `fn` and returns a response of type `GoResult` diff --git a/src/promises/index.test.ts b/src/promise-utils/index.test.ts similarity index 100% rename from src/promises/index.test.ts rename to src/promise-utils/index.test.ts diff --git a/src/promises/index.ts b/src/promise-utils/index.ts similarity index 100% rename from src/promises/index.ts rename to src/promise-utils/index.ts diff --git a/src/release-scripts/tag-and-release.ts b/src/release-scripts/tag-and-release.ts index e23e8cb..b3dbbfb 100644 --- a/src/release-scripts/tag-and-release.ts +++ b/src/release-scripts/tag-and-release.ts @@ -19,7 +19,7 @@ import { readFileSync } from 'node:fs'; import { Octokit } from '@octokit/rest'; -import { go } from '../promises'; +import { go } from '../promise-utils'; const execSyncWithErrorHandling = (command: string) => { // eslint-disable-next-line functional/no-try-statements diff --git a/src/run-in-loop/index.ts b/src/run-in-loop/index.ts index 85c046e..909f424 100644 --- a/src/run-in-loop/index.ts +++ b/src/run-in-loop/index.ts @@ -1,5 +1,5 @@ import { type Logger } from '../logger'; -import { go } from '../promises'; +import { go } from '../promise-utils'; import { generateRandomBytes32, sleep } from '../utils'; export type RunInLoopExecutionIdOptions = diff --git a/src/universal-index.ts b/src/universal-index.ts index 207afc0..8a6d560 100644 --- a/src/universal-index.ts +++ b/src/universal-index.ts @@ -1,6 +1,6 @@ // NOTE: Only export modules which work in both Node.js and browser environments. export * from './blockchain-utilities'; export * from './http'; -export * from './promises'; +export * from './promise-utils'; export * from './run-in-loop'; export * from './utils';