feat(stdlib): new modules + eslint/tsconfig migration

- Add array/async/etc. modules and type tests; migrate to eslint flat config
  and composite tsconfig (vitest typecheck enabled).
- Fix PubSub.emit to snapshot listeners before iterating (stable EventEmitter
  semantics; avoids invoking listeners added during the same emit).
This commit is contained in:
2026-06-07 16:29:08 +07:00
parent 008d85a8fd
commit 96f4cba4a8
118 changed files with 3511 additions and 240 deletions
+15 -14
View File
@@ -10,20 +10,21 @@ pnpm install @robonen/stdlib
## Modules ## Modules
| Module | Utilities | | Module | Utilities |
| --------------- | --------------------------------------------------------------- | | --------------- | ---------------------------------------------------------------------------------- |
| **arrays** | `cluster`, `first`, `last`, `sum`, `unique` | | **arrays** | `cluster`, `first`, `groupBy`, `last`, `partition`, `range`, `sum`, `toArray`, `unique`, `zip` |
| **async** | `sleep`, `tryIt` | | **async** | `pool`, `retry`, `sleep`, `tryIt` |
| **bits** | `flags` | | **bits** | `flagsGenerator`, `and`, `or`, `not`, `has`, `is`, `unset`, `toggle`, `BitVector` |
| **collections** | `get` | | **collections** | `get` |
| **math** | `clamp`, `lerp`, `remap` + BigInt variants | | **functions** | `compose`, `debounce`, `memoize`, `once`, `pipe`, `throttle` |
| **objects** | `omit`, `pick` | | **math** | `clamp`, `lerp`, `remap` + BigInt variants |
| **patterns** | `pubsub` | | **objects** | `omit`, `pick` |
| **structs** | `stack` | | **patterns** | `Command`, `PubSub`, `StateMachine` |
| **sync** | `mutex` | | **structs** | `BinaryHeap`, `CircularBuffer`, `Deque`, `LinkedList`, `PriorityQueue`, `Queue`, `Stack` |
| **text** | `levenshteinDistance`, `trigramDistance` | | **sync** | `mutex` |
| **types** | JS & TS type utilities | | **text** | `levenshteinDistance`, `trigramDistance`, `templateObject` |
| **utils** | `timestamp`, `noop` | | **types** | JS & TS type utilities |
| **utils** | `timestamp`, `noop` |
## Usage ## Usage
+3
View File
@@ -0,0 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
-4
View File
@@ -1,4 +0,0 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic));
+6 -6
View File
@@ -39,18 +39,18 @@
} }
}, },
"scripts": { "scripts": {
"lint:check": "oxlint -c oxlint.config.ts", "lint:check": "eslint .",
"lint:fix": "oxlint -c oxlint.config.ts --fix", "lint:fix": "eslint . --fix",
"test": "vitest run", "test": "vitest run",
"dev": "vitest dev", "dev": "vitest dev",
"build": "tsdown" "build": "tsdown"
}, },
"devDependencies": { "devDependencies": {
"@robonen/oxlint": "workspace:*", "@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:", "eslint": "catalog:",
"oxlint": "catalog:", "tsdown": "catalog:",
"tsdown": "catalog:" "typescript": "^5.9.3"
} }
} }
+10 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { cluster } from '.'; import { cluster } from '.';
describe('cluster', () => { describe('cluster', () => {
@@ -37,4 +37,13 @@ describe('cluster', () => {
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
it('not mutate the input and produce copied sub-arrays', () => {
const input = [1, 2, 3, 4];
const result = cluster(input, 2);
result[0]!.push(99);
expect(input).toEqual([1, 2, 3, 4]);
});
}); });
+8 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { first } from '.'; import { first } from '.';
describe('first', () => { describe('first', () => {
@@ -20,4 +20,11 @@ describe('first', () => {
expect(first([1, 2, 3], 42)).toBe(1); expect(first([1, 2, 3], 42)).toBe(1);
expect(first(['a', 'b', 'c'], 'default')).toBe('a'); expect(first(['a', 'b', 'c'], 'default')).toBe('a');
}); });
it('preserve a present null/undefined/falsy first element (not the default)', () => {
expect(first([null as number | null], 42)).toBeNull();
expect(first([undefined, 2], 42)).toBeUndefined();
expect(first([0], 99)).toBe(0);
expect(first([''], 'x')).toBe('');
});
}); });
+2 -1
View File
@@ -16,5 +16,6 @@
* @since 0.0.3 * @since 0.0.3
*/ */
export function first<Value>(arr: Value[], defaultValue?: Value) { export function first<Value>(arr: Value[], defaultValue?: Value) {
return arr[0] ?? defaultValue; // Branch on length, not nullishness, so a present null/undefined first element is preserved.
return arr.length > 0 ? arr[0]! : defaultValue;
} }
@@ -0,0 +1,16 @@
import { describe, expectTypeOf, it } from 'vitest';
import { groupBy } from '.';
describe('groupBy', () => {
it('keys the record by the union returned by the key function', () => {
const result = groupBy([1, 2, 3], n => (n % 2 === 0 ? 'even' : 'odd'));
expectTypeOf(result).toEqualTypeOf<Record<'even' | 'odd', number[]>>();
});
it('preserves the element type in the grouped arrays', () => {
const result = groupBy([{ id: 1 }], item => item.id);
expectTypeOf(result).toEqualTypeOf<Record<number, Array<{ id: number }>>>();
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { groupBy } from '.';
describe('groupBy', () => {
it('group by a string key', () => {
const result = groupBy([1, 2, 3, 4], n => (n % 2 === 0 ? 'even' : 'odd'));
expect(result).toEqual({ odd: [1, 3], even: [2, 4] });
});
it('group objects by a property', () => {
const input = [
{ type: 'a', v: 1 },
{ type: 'b', v: 2 },
{ type: 'a', v: 3 },
];
expect(groupBy(input, item => item.type)).toEqual({
a: [{ type: 'a', v: 1 }, { type: 'a', v: 3 }],
b: [{ type: 'b', v: 2 }],
});
});
it('pass the index to the key function', () => {
const result = groupBy(['a', 'b', 'c', 'd'], (_, i) => i % 2);
expect(result).toEqual({ 0: ['a', 'c'], 1: ['b', 'd'] });
});
it('return an empty object for an empty array', () => {
expect(groupBy([], () => 'x')).toEqual({});
});
it('push elements by reference (no cloning)', () => {
const item = { id: 1 };
const result = groupBy([item], x => x.id);
expect(result[1]![0]).toBe(item);
});
it('handle __proto__ and other Object.prototype keys as ordinary groups', () => {
const proto = groupBy(['a', 'b'], (): string => '__proto__');
expect(proto.__proto__).toEqual(['a', 'b']);
const ctor = groupBy(['x'], (): string => 'constructor');
expect(ctor.constructor).toEqual(['x']);
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* @name groupBy
* @category Arrays
* @description Groups the elements of an array by the key returned by `getKey`
*
* @param {Value[]} array - The array to group
* @param {(item: Value, index: number) => Key} getKey - Maps an element to its group key
* @returns {Record<Key, Value[]>} An object of arrays, keyed by group
*
* @example
* groupBy([1, 2, 3, 4], n => (n % 2 === 0 ? 'even' : 'odd'))
* // => { odd: [1, 3], even: [2, 4] }
*
* @example
* groupBy([{ type: 'a', v: 1 }, { type: 'b', v: 2 }, { type: 'a', v: 3 }], item => item.type)
* // => { a: [{ type: 'a', v: 1 }, { type: 'a', v: 3 }], b: [{ type: 'b', v: 2 }] }
*
* @since 0.0.10
*/
export function groupBy<Value, Key extends PropertyKey>(
array: Value[],
getKey: (item: Value, index: number) => Key,
): Record<Key, Value[]> {
// Null-prototype object so keys like '__proto__'/'constructor' become ordinary own keys
// instead of colliding with Object.prototype (which would throw on .push or pollute).
const result = Object.create(null) as Record<Key, Value[]>;
for (let i = 0; i < array.length; i++) {
const item = array[i]!;
const key = getKey(item, i);
(result[key] ??= []).push(item);
}
return result;
}
+5
View File
@@ -1,5 +1,10 @@
export * from './cluster'; export * from './cluster';
export * from './first'; export * from './first';
export * from './groupBy';
export * from './last'; export * from './last';
export * from './partition';
export * from './range';
export * from './sum'; export * from './sum';
export * from './toArray';
export * from './unique'; export * from './unique';
export * from './zip';
+7 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { last } from '.'; import { last } from '.';
describe('last', () => { describe('last', () => {
@@ -20,4 +20,10 @@ describe('last', () => {
expect(last([1, 2, 3], 42)).toBe(3); expect(last([1, 2, 3], 42)).toBe(3);
expect(last(['a', 'b', 'c'], 'default')).toBe('c'); expect(last(['a', 'b', 'c'], 'default')).toBe('c');
}); });
it('preserve a present null/undefined/falsy last element (not the default)', () => {
expect(last([1, null as number | null], 42)).toBeNull();
expect(last([1, undefined], 42)).toBeUndefined();
expect(last([0], 99)).toBe(0);
});
}); });
+2 -1
View File
@@ -16,5 +16,6 @@
* @since 0.0.3 * @since 0.0.3
*/ */
export function last<Value>(arr: Value[], defaultValue?: Value) { export function last<Value>(arr: Value[], defaultValue?: Value) {
return arr[arr.length - 1] ?? defaultValue; // Branch on length, not nullishness, so a present null/undefined last element is preserved.
return arr.length > 0 ? arr[arr.length - 1]! : defaultValue;
} }
@@ -0,0 +1,17 @@
import { describe, expectTypeOf, it } from 'vitest';
import { partition } from '.';
describe('partition', () => {
it('returns a tuple of two arrays of the element type', () => {
const result = partition([1, 2, 3], n => n > 1);
expectTypeOf(result).toEqualTypeOf<[number[], number[]]>();
});
it('narrows both partitions with a type guard', () => {
const mixed: Array<string | number> = ['a', 1];
const result = partition(mixed, (v): v is string => typeof v === 'string');
expectTypeOf(result).toEqualTypeOf<[string[], number[]]>();
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { partition } from '.';
describe('partition', () => {
it('split by a predicate into [matching, rest]', () => {
expect(partition([1, 2, 3, 4], n => n % 2 === 0)).toEqual([[2, 4], [1, 3]]);
});
it('preserve order within each partition', () => {
expect(partition([5, 1, 4, 2, 3], n => n > 2)).toEqual([[5, 4, 3], [1, 2]]);
});
it('pass the index to the predicate', () => {
expect(partition(['a', 'b', 'c', 'd'], (_, i) => i < 2)).toEqual([['a', 'b'], ['c', 'd']]);
});
it('handle all-matching and none-matching', () => {
expect(partition([1, 2, 3], () => true)).toEqual([[1, 2, 3], []]);
expect(partition([1, 2, 3], () => false)).toEqual([[], [1, 2, 3]]);
});
it('work with a type guard', () => {
const mixed: Array<string | number> = ['a', 1, 'b', 2];
const [strings, numbers] = partition(mixed, (v): v is string => typeof v === 'string');
expect(strings).toEqual(['a', 'b']);
expect(numbers).toEqual([1, 2]);
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* @name partition
* @category Arrays
* @description Splits an array into two: elements that satisfy the predicate and those that do not
*
* @param {Value[]} array - The array to split
* @param {(item: Value, index: number) => boolean} predicate - Decides which partition an element belongs to
* @returns {[Value[], Value[]]} A tuple of `[matching, rest]`
*
* @example
* partition([1, 2, 3, 4], n => n % 2 === 0) // => [[2, 4], [1, 3]]
*
* @example
* const [strings, others] = partition(mixed, (v): v is string => typeof v === 'string');
*
* @since 0.0.10
*/
export function partition<Value, Matched extends Value>(
array: Value[],
predicate: (item: Value, index: number) => item is Matched,
): [Matched[], Array<Exclude<Value, Matched>>];
export function partition<Value>(
array: Value[],
predicate: (item: Value, index: number) => boolean,
): [Value[], Value[]];
export function partition<Value>(
array: Value[],
predicate: (item: Value, index: number) => boolean,
): [Value[], Value[]] {
const matched: Value[] = [];
const rest: Value[] = [];
for (let i = 0; i < array.length; i++) {
const item = array[i]!;
if (predicate(item, i))
matched.push(item);
else
rest.push(item);
}
return [matched, rest];
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { range } from '.';
describe('range', () => {
it('generate 0..stop with a single argument', () => {
expect(range(4)).toEqual([0, 1, 2, 3]);
});
it('generate start..stop', () => {
expect(range(1, 5)).toEqual([1, 2, 3, 4]);
});
it('respect a positive step', () => {
expect(range(0, 10, 2)).toEqual([0, 2, 4, 6, 8]);
});
it('support a negative step', () => {
expect(range(5, 0, -1)).toEqual([5, 4, 3, 2, 1]);
});
it('return an empty array for an empty range', () => {
expect(range(0)).toEqual([]);
expect(range(5, 5)).toEqual([]);
expect(range(0, 5, -1)).toEqual([]);
});
it('return an empty array for a zero step', () => {
expect(range(0, 5, 0)).toEqual([]);
});
it('handle non-integer steps', () => {
expect(range(0, 1, 0.25)).toEqual([0, 0.25, 0.5, 0.75]);
});
it('span zero with a negative start', () => {
expect(range(-2, 3)).toEqual([-2, -1, 0, 1, 2]);
expect(range(-3, 3, 2)).toEqual([-3, -1, 1]);
});
it('handle a non-integer step that is not exactly representable', () => {
const result = range(0, 1, 0.1);
expect(result).toHaveLength(10);
expect(result[0]).toBe(0);
expect(result.at(-1)).toBeCloseTo(0.9, 10);
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @name range
* @category Arrays
* @description Generates an array of numbers from `start` (inclusive) to `stop` (exclusive)
*
* @param {number} startOrStop - The start of the range, or the stop when called with one argument
* @param {number} [stop] - The end of the range (exclusive)
* @param {number} [step] - The increment between values; supports negative steps. Default `1`
* @returns {number[]} The generated range
*
* @example
* range(4) // => [0, 1, 2, 3]
*
* @example
* range(1, 5) // => [1, 2, 3, 4]
*
* @example
* range(0, 10, 2) // => [0, 2, 4, 6, 8]
*
* @example
* range(5, 0, -1) // => [5, 4, 3, 2, 1]
*
* @since 0.0.10
*/
export function range(stop: number): number[];
export function range(start: number, stop: number, step?: number): number[];
export function range(startOrStop: number, stop?: number, step = 1): number[] {
let start = startOrStop;
if (stop === undefined) {
start = 0;
stop = startOrStop;
}
if (step === 0)
return [];
const length = Math.max(Math.ceil((stop - start) / step), 0);
return Array.from({ length }, (_, i) => start + i * step);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { sum } from '.'; import { sum } from '.';
describe('sum', () => { describe('sum', () => {
@@ -0,0 +1,17 @@
import { describe, expectTypeOf, it } from 'vitest';
import { toArray } from '.';
describe('toArray', () => {
it('wraps a single value into an array of that type', () => {
expectTypeOf(toArray(1)).toEqualTypeOf<number[]>();
});
it('returns an array input as the same element type', () => {
expectTypeOf(toArray([1, 2, 3])).toEqualTypeOf<number[]>();
});
it('returns the element array type for a nullish input', () => {
expectTypeOf(toArray<string>(undefined)).toEqualTypeOf<string[]>();
expectTypeOf(toArray<number>(null)).toEqualTypeOf<number[]>();
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { toArray } from '.';
describe('toArray', () => {
it('wrap a single value into an array', () => {
expect(toArray(1)).toEqual([1]);
expect(toArray('a')).toEqual(['a']);
expect(toArray(false)).toEqual([false]);
expect(toArray(0)).toEqual([0]);
});
it('return arrays as-is (same reference, no copy)', () => {
const arr = [1, 2, 3];
expect(toArray(arr)).toBe(arr);
});
it('treat null and undefined as empty', () => {
expect(toArray(undefined)).toEqual([]);
expect(toArray(null)).toEqual([]);
});
it('preserve empty arrays', () => {
const empty: number[] = [];
expect(toArray(empty)).toBe(empty);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { Arrayable } from '../../types';
/**
* @name toArray
* @category Arrays
* @description Normalize an `Arrayable<T>` value into an array. `undefined` and `null` become an empty array; a single value is wrapped; arrays are returned as-is (no copy).
*
* @param {Arrayable<Value> | null | undefined} value The value to normalize
* @returns {Value[]} The value as an array
*
* @example
* toArray(1) // => [1]
*
* @example
* toArray([1, 2]) // => [1, 2]
*
* @example
* toArray(undefined) // => []
*
* @since 0.0.10
*/
export function toArray<Value>(value: Arrayable<Value> | null | undefined): Value[] {
if (value === null || value === undefined) return [];
return Array.isArray(value) ? value : [value];
}
+25 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { unique } from '.'; import { unique } from '.';
describe('unique', () => { describe('unique', () => {
@@ -42,4 +42,28 @@ describe('unique', () => {
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
it('keep the last value per extracted key (last-write-wins, first-seen order)', () => {
const result = unique(
[{ id: 1, v: 'a' }, { id: 2, v: 'b' }, { id: 1, v: 'c' }],
item => item.id,
);
expect(result).toEqual([{ id: 1, v: 'c' }, { id: 2, v: 'b' }]);
});
it('return a new array and not mutate the input', () => {
const input = [1, 2, 2, 3];
const result = unique(input);
expect(result).not.toBe(input);
expect(input).toEqual([1, 2, 2, 3]);
});
it('preserve element identity for object values', () => {
const a = { id: 1 };
const result = unique([a], item => item.id);
expect(result[0]).toBe(a);
});
}); });
+10 -7
View File
@@ -14,20 +14,23 @@ export type Extractor<Value, Key extends UniqueKey> = (value: Value) => Key;
* unique([1, 2, 3, 3, 4, 5, 5, 6]) //=> [1, 2, 3, 4, 5, 6] * unique([1, 2, 3, 3, 4, 5, 5, 6]) //=> [1, 2, 3, 4, 5, 6]
* *
* @example * @example
* unique([{ id: 1 }, { id: 2 }, { id: 1 }], (a, b) => a.id === b.id) //=> [{ id: 1 }, { id: 2 }] * unique([{ id: 1 }, { id: 2 }, { id: 1 }], value => value.id) //=> [{ id: 1 }, { id: 2 }]
* *
* @since 0.0.3 * @since 0.0.3
*/ */
export function unique<Value, Key extends UniqueKey>( export function unique<Value, Key extends UniqueKey>(
array: Value[], array: Value[],
extractor?: Extractor<Value, Key>, extractor?: Extractor<Value, Key>,
) { ): Value[] {
// Fast path: a plain Set is leaner than a Map storing each value as both key and value.
if (!extractor)
return [...new Set(array)];
// Last-write-wins per extracted key, preserving first-seen insertion order.
const values = new Map<Key, Value>(); const values = new Map<Key, Value>();
for (const value of array) { for (const value of array)
const key = extractor ? extractor(value) : value as any; values.set(extractor(value), value);
values.set(key, value);
}
return Array.from(values.values()); return [...values.values()];
} }
@@ -0,0 +1,27 @@
import { describe, expectTypeOf, it } from 'vitest';
import { zip } from '.';
describe('zip', () => {
it('produces tuples of the element types (two arrays)', () => {
const result = zip([1, 2], ['a', 'b']);
expectTypeOf(result).toEqualTypeOf<Array<[number, string]>>();
});
it('produces tuples of the element types (three arrays)', () => {
const result = zip([1], ['a'], [true]);
expectTypeOf(result).toEqualTypeOf<Array<[number, string, boolean]>>();
});
it('zips a single array into singleton tuples', () => {
const result = zip([1, 2, 3]);
expectTypeOf(result).toEqualTypeOf<Array<[number]>>();
});
it('produces tuples for four and five arrays', () => {
expectTypeOf(zip([1], ['a'], [true], [9])).toEqualTypeOf<Array<[number, string, boolean, number]>>();
expectTypeOf(zip([1], ['a'], [true], [9], ['x'])).toEqualTypeOf<Array<[number, string, boolean, number, string]>>();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { zip } from '.';
describe('zip', () => {
it('zip two arrays of equal length', () => {
expect(zip([1, 2, 3], ['a', 'b', 'c'])).toEqual([[1, 'a'], [2, 'b'], [3, 'c']]);
});
it('zip three arrays', () => {
expect(zip([1, 2], ['a', 'b'], [true, false])).toEqual([[1, 'a', true], [2, 'b', false]]);
});
it('truncate to the shortest array', () => {
expect(zip([1, 2, 3], ['a'])).toEqual([[1, 'a']]);
expect(zip([1], ['a', 'b', 'c'])).toEqual([[1, 'a']]);
});
it('zip a single array into singletons', () => {
expect(zip([1, 2, 3])).toEqual([[1], [2], [3]]);
});
it('return an empty array when an input is empty', () => {
expect(zip([1, 2, 3], [])).toEqual([]);
});
it('return an empty array with no arguments', () => {
expect((zip as () => unknown[])()).toEqual([]);
});
it('zip four and five arrays', () => {
expect(zip([1], ['a'], [true], [9])).toEqual([[1, 'a', true, 9]]);
expect(zip([1, 2], ['a', 'b'], [true, false], [9, 8], ['x', 'y']))
.toEqual([[1, 'a', true, 9, 'x'], [2, 'b', false, 8, 'y']]);
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* @name zip
* @category Arrays
* @description Combines several arrays into an array of tuples, stopping at the shortest input
*
* @param {...Array} arrays - The arrays to zip together
* @returns {Array} An array of tuples; its length equals the shortest input array
*
* @example
* zip([1, 2, 3], ['a', 'b', 'c']) // => [[1, 'a'], [2, 'b'], [3, 'c']]
*
* @example
* zip([1, 2], ['a', 'b'], [true, false]) // => [[1, 'a', true], [2, 'b', false]]
*
* @example
* zip([1, 2, 3], ['a']) // => [[1, 'a']] (truncated to the shortest)
*
* @since 0.0.10
*/
export function zip<A>(a: A[]): Array<[A]>;
export function zip<A, B>(a: A[], b: B[]): Array<[A, B]>;
export function zip<A, B, C>(a: A[], b: B[], c: C[]): Array<[A, B, C]>;
export function zip<A, B, C, D>(a: A[], b: B[], c: C[], d: D[]): Array<[A, B, C, D]>;
export function zip<A, B, C, D, E>(a: A[], b: B[], c: C[], d: D[], e: E[]): Array<[A, B, C, D, E]>;
export function zip(...arrays: any[][]): any[][] {
if (arrays.length === 0)
return [];
let length = arrays[0]!.length;
for (let i = 1; i < arrays.length; i++)
length = Math.min(length, arrays[i]!.length);
return Array.from({ length }, (_, i) => arrays.map(array => array[i]));
}
+1
View File
@@ -1,3 +1,4 @@
export * from './pool';
export * from './retry'; export * from './retry';
export * from './sleep'; export * from './sleep';
export * from './tryIt'; export * from './tryIt';
+438
View File
@@ -0,0 +1,438 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AsyncPool } from '.';
describe('AsyncPool', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// ── Constructor / getters ────────────────────────────────────────────────
describe('constructor', () => {
it('default concurrency is Infinity', () => {
expect(new AsyncPool().concurrency).toBe(Infinity);
});
it('respects explicit concurrency', () => {
expect(new AsyncPool({ concurrency: 4 }).concurrency).toBe(4);
});
it('truncates float to integer', () => {
expect(new AsyncPool({ concurrency: 3.9 }).concurrency).toBe(3);
});
it('Infinity concurrency is preserved (not coerced to 0)', () => {
expect(new AsyncPool({ concurrency: Infinity }).concurrency).toBe(Infinity);
});
it('invalid values fall back to Infinity', () => {
expect(new AsyncPool({ concurrency: 0 }).concurrency).toBe(Infinity);
expect(new AsyncPool({ concurrency: -1 }).concurrency).toBe(Infinity);
});
it('initial size and active are 0', () => {
const pool = new AsyncPool({ concurrency: 2 });
expect(pool.size).toBe(0);
expect(pool.active).toBe(0);
});
});
// ── add() — basic execution ──────────────────────────────────────────────
describe('add()', () => {
it('starts task immediately when under limit', () => {
const pool = new AsyncPool({ concurrency: 2 });
const fn = vi.fn().mockResolvedValue('ok');
pool.add(fn);
expect(fn).toHaveBeenCalledTimes(1);
expect(pool.active).toBe(1);
});
it('passes the pool signal to the task', () => {
const pool = new AsyncPool({ concurrency: 1 });
let receivedSignal: AbortSignal | undefined;
pool.add((signal) => {
receivedSignal = signal;
return Promise.resolve();
});
expect(receivedSignal).toBeInstanceOf(AbortSignal);
});
it('returns the task result', async () => {
const pool = new AsyncPool({ concurrency: 1 });
const result = await pool.add(() => Promise.resolve(42));
expect(result).toBe(42);
});
it('propagates task rejection', async () => {
const pool = new AsyncPool({ concurrency: 1 });
const error = new Error('boom');
await expect(pool.add(() => Promise.reject(error))).rejects.toThrow('boom');
});
});
// ── Concurrency limiting ─────────────────────────────────────────────────
describe('concurrency limiting', () => {
it('does not exceed the concurrency limit', () => {
const pool = new AsyncPool({ concurrency: 2 });
const fn = vi.fn(() => new Promise<void>(() => {}));
pool.add(fn);
pool.add(fn);
pool.add(fn); // queued
expect(fn).toHaveBeenCalledTimes(2);
expect(pool.active).toBe(2);
expect(pool.size).toBe(1);
});
it('dequeues task when an active one completes', async () => {
const pool = new AsyncPool({ concurrency: 1 });
let resolve1!: () => void;
const task1 = vi.fn(() => new Promise<void>(r => (resolve1 = r)));
const task2 = vi.fn(() => Promise.resolve());
pool.add(task1);
pool.add(task2);
expect(task2).toHaveBeenCalledTimes(0);
resolve1();
await Promise.resolve();
await Promise.resolve(); // two microtask ticks for then handlers
expect(task2).toHaveBeenCalledTimes(1);
});
it('runs tasks in FIFO order', async () => {
const pool = new AsyncPool({ concurrency: 1 });
const order: number[] = [];
let unblock!: () => void;
pool.add(() => new Promise<void>(r => (unblock = r)));
pool.add(async () => {
order.push(1);
});
pool.add(async () => {
order.push(2);
});
unblock();
await pool.all();
expect(order).toEqual([1, 2]);
});
it('respects concurrency across many tasks', async () => {
const pool = new AsyncPool({ concurrency: 3 });
let concurrent = 0;
let maxConcurrent = 0;
const tasks = Array.from({ length: 9 }, () => async (_signal: AbortSignal) => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await Promise.resolve();
concurrent--;
});
await Promise.all(tasks.map(t => pool.add(t)));
expect(maxConcurrent).toBeLessThanOrEqual(3);
});
it('starts all tasks immediately with Infinity concurrency', () => {
const pool = new AsyncPool();
const fn = vi.fn(() => new Promise<void>(() => {}));
for (let i = 0; i < 10; i++) pool.add(fn);
expect(fn).toHaveBeenCalledTimes(10);
expect(pool.size).toBe(0);
});
});
// ── concurrency setter ───────────────────────────────────────────────────
describe('concurrency setter', () => {
it('increasing concurrency starts queued tasks immediately', async () => {
const pool = new AsyncPool({ concurrency: 1 });
const fn = vi.fn(() => new Promise<void>(() => {}));
pool.add(fn);
pool.add(fn);
pool.add(fn);
expect(fn).toHaveBeenCalledTimes(1);
pool.concurrency = 3;
expect(fn).toHaveBeenCalledTimes(3);
expect(pool.size).toBe(0);
});
it('decreasing concurrency does not stop running tasks', () => {
const pool = new AsyncPool({ concurrency: 3 });
const fn = vi.fn(() => new Promise<void>(() => {}));
pool.add(fn);
pool.add(fn);
pool.add(fn);
pool.concurrency = 1;
expect(pool.active).toBe(3); // already running tasks are not stopped
});
it('invalid value falls back to Infinity', () => {
const pool = new AsyncPool({ concurrency: 2 });
pool.concurrency = -5;
expect(pool.concurrency).toBe(Infinity);
});
});
// ── all() ────────────────────────────────────────────────────────────────
describe('all()', () => {
it('resolves immediately when pool is empty', async () => {
await expect(new AsyncPool().all()).resolves.toBeUndefined();
});
it('waits for all tasks to complete', async () => {
const pool = new AsyncPool({ concurrency: 2 });
const completed: number[] = [];
pool.add(async () => {
completed.push(1);
});
pool.add(async () => {
completed.push(2);
});
await pool.all();
expect(completed).toEqual([1, 2]);
});
it('throws AggregateError when any task fails', async () => {
const pool = new AsyncPool({ concurrency: 2 });
pool.add(() => Promise.reject(new Error('e1'))).catch(() => {});
pool.add(() => Promise.reject(new Error('e2'))).catch(() => {});
pool.add(() => Promise.resolve('ok'));
await expect(pool.all()).rejects.toBeInstanceOf(AggregateError);
});
it('AggregateError contains all failures', async () => {
const pool = new AsyncPool({ concurrency: 2 });
const e1 = new Error('e1');
const e2 = new Error('e2');
pool.add(() => Promise.reject(e1)).catch(() => {});
pool.add(() => Promise.reject(e2)).catch(() => {});
try {
await pool.all();
}
catch (err) {
expect(err).toBeInstanceOf(AggregateError);
expect((err as AggregateError).errors).toContain(e1);
expect((err as AggregateError).errors).toContain(e2);
}
});
it('multiple concurrent callers share the same underlying drain', () => {
// all() wraps _drain.promise in .then() each call (different outer promises),
// but allSettled() returns the raw _drain.promise — verify that is idempotent
const pool = new AsyncPool({ concurrency: 1 });
pool.add(() => new Promise<void>(() => {}));
expect(pool.allSettled()).toBe(pool.allSettled());
});
it('resets after each drain cycle', async () => {
const pool = new AsyncPool({ concurrency: 1 });
pool.add(() => Promise.resolve());
await pool.all();
// second batch
pool.add(() => Promise.resolve());
await pool.all(); // should not throw or hang
expect(pool.active).toBe(0);
});
});
// ── allSettled() ─────────────────────────────────────────────────────────
describe('allSettled()', () => {
it('resolves immediately with empty array when pool is empty', async () => {
await expect(new AsyncPool().allSettled()).resolves.toEqual([]);
});
it('returns fulfilled results', async () => {
const pool = new AsyncPool({ concurrency: 2 });
pool.add(() => Promise.resolve(1));
pool.add(() => Promise.resolve(2));
const results = await pool.allSettled();
expect(results).toHaveLength(2);
expect(results.every(r => r.status === 'fulfilled')).toBe(true);
});
it('returns mix of fulfilled and rejected', async () => {
const pool = new AsyncPool({ concurrency: 2 });
pool.add(() => Promise.resolve('ok'));
pool.add(() => Promise.reject(new Error('fail'))).catch(() => {});
const results = await pool.allSettled();
expect(results.some(r => r.status === 'fulfilled')).toBe(true);
expect(results.some(r => r.status === 'rejected')).toBe(true);
});
it('all() and allSettled() called simultaneously share the same promise', () => {
const pool = new AsyncPool({ concurrency: 1 });
pool.add(() => new Promise<void>(() => {}));
// Both chain off the same underlying _drain.promise
const a = pool.allSettled();
const b = pool.allSettled();
expect(a).toBe(b);
});
it('results are cleared after each cycle', async () => {
const pool = new AsyncPool({ concurrency: 1 });
pool.add(() => Promise.resolve('first'));
const r1 = await pool.allSettled();
expect(r1).toHaveLength(1);
pool.add(() => Promise.resolve('second'));
const r2 = await pool.allSettled();
expect(r2).toHaveLength(1); // only new-batch results
});
});
// ── size / active getters ────────────────────────────────────────────────
describe('size / active', () => {
it('active reflects currently running tasks', async () => {
const pool = new AsyncPool({ concurrency: 2 });
let resolve1!: () => void;
const p = pool.add(() => new Promise<void>(r => (resolve1 = r)));
expect(pool.active).toBe(1);
resolve1();
await p;
expect(pool.active).toBe(0);
});
it('size reflects queued count', () => {
const pool = new AsyncPool({ concurrency: 1 });
const neverResolve = () => new Promise<void>(() => {});
pool.add(neverResolve);
pool.add(neverResolve);
pool.add(neverResolve);
expect(pool.active).toBe(1);
expect(pool.size).toBe(2);
});
});
// ── AbortSignal / cancellation ───────────────────────────────────────────
describe('signal / abort', () => {
it('add() after abort rejects immediately', async () => {
const controller = new AbortController();
const pool = new AsyncPool({ concurrency: 2, signal: controller.signal });
controller.abort(new Error('cancelled'));
await expect(pool.add(() => Promise.resolve())).rejects.toThrow('cancelled');
});
it('queued tasks are rejected on abort', async () => {
const controller = new AbortController();
const pool = new AsyncPool({ concurrency: 1, signal: controller.signal });
pool.add(() => new Promise<void>(() => {})); // blocks the slot
const queued = pool.add(() => Promise.resolve('queued'));
controller.abort();
await expect(queued).rejects.toBeDefined();
});
it('running tasks receive the aborted signal', async () => {
const controller = new AbortController();
const pool = new AsyncPool({ concurrency: 1, signal: controller.signal });
let taskSignal!: AbortSignal;
const taskDone = pool.add((signal) => {
taskSignal = signal;
return new Promise<void>((resolve) => {
signal.addEventListener('abort', () => resolve());
});
});
controller.abort();
await taskDone;
expect(taskSignal.aborted).toBe(true);
});
it('allSettled() resolves after abort with rejected entries', async () => {
const controller = new AbortController();
const pool = new AsyncPool({ concurrency: 1, signal: controller.signal });
// Active task listens to signal and resolves on abort
pool.add(signal => new Promise<void>((resolve) => {
signal.addEventListener('abort', () => resolve(), { once: true });
}));
pool.add(() => Promise.resolve()).catch(() => {}); // queued — rejected on abort
controller.abort();
const results = await pool.allSettled();
expect(results.some(r => r.status === 'rejected')).toBe(true);
});
it('all() rejects with an AggregateError including the abort reason', async () => {
const controller = new AbortController();
const pool = new AsyncPool({ concurrency: 1, signal: controller.signal });
const reason = new Error('cancelled');
pool.add(signal => new Promise<void>((resolve) => {
signal.addEventListener('abort', () => resolve(), { once: true });
}));
pool.add(() => Promise.resolve()).catch(() => {}); // queued — rejected on abort
controller.abort(reason);
await expect(pool.all()).rejects.toBeInstanceOf(AggregateError);
});
it('dispose() detaches the abort listener', () => {
const controller = new AbortController();
const removeSpy = vi.spyOn(controller.signal, 'removeEventListener');
const pool = new AsyncPool({ signal: controller.signal });
pool.dispose();
expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));
});
});
// ── Error resilience ─────────────────────────────────────────────────────
describe('error resilience', () => {
it('rejected task does not block subsequent tasks', async () => {
const pool = new AsyncPool({ concurrency: 1 });
const completed: string[] = [];
pool.add(() => Promise.reject(new Error('fail'))).catch(() => {});
pool.add(async () => {
completed.push('second');
});
await pool.all().catch(() => {});
expect(completed).toContain('second');
});
it('rejected task does not corrupt active count', async () => {
const pool = new AsyncPool({ concurrency: 2 });
await pool.add(() => Promise.reject(new Error('fail'))).catch(() => {});
expect(pool.active).toBe(0);
});
it('a synchronously-throwing task rejects instead of wedging the pool', async () => {
const pool = new AsyncPool({ concurrency: 1 });
await expect(pool.add(() => {
throw new Error('sync');
})).rejects.toThrow('sync');
// The slot must be released, not leaked.
expect(pool.active).toBe(0);
// Subsequent tasks still run.
await expect(pool.add(() => Promise.resolve('next'))).resolves.toBe('next');
});
});
// ── Results survive a drained batch ──────────────────────────────────────
describe('drain before waiter', () => {
it('allSettled() still sees results when the batch drained first', async () => {
const pool = new AsyncPool({ concurrency: 2 });
await pool.add(() => Promise.resolve(1));
await pool.add(() => Promise.resolve(2));
const results = await pool.allSettled();
expect(results).toHaveLength(2);
});
it('all() still throws after a failing task drained first', async () => {
const pool = new AsyncPool({ concurrency: 1 });
await pool.add(() => Promise.reject(new Error('boom'))).catch(() => {});
await expect(pool.all()).rejects.toBeInstanceOf(AggregateError);
});
});
});
+241
View File
@@ -1,3 +1,244 @@
import { CircularBuffer } from '../../structs/CircularBuffer';
import { isNumber } from '../../types';
export interface AsyncPoolOptions { export interface AsyncPoolOptions {
concurrency?: number; concurrency?: number;
signal?: AbortSignal;
}
interface PoolEntry<T = any> {
task: (signal: AbortSignal) => Promise<T>;
resolve: (value: T) => void;
reject: (reason: unknown) => void;
}
// Shared sentinel — never aborts, avoids allocating AbortController per instance when no signal
const NEVER_ABORT_SIGNAL: AbortSignal = /* @__PURE__ */ new AbortController().signal;
// Normalizes concurrency option to a positive integer or Infinity.
// Note: (Infinity | 0) === 0 in JS — the `|| Infinity` trick recovers the correct value.
function normalizeConcurrency(value: unknown): number {
return (isNumber(value) && value > 0) ? (value | 0) || Infinity : Infinity;
}
/**
* @name AsyncPool
* @category Async
* @description A concurrency-limited async task pool with AbortSignal support.
* Tasks start immediately when under the concurrency limit and are queued otherwise.
* Each task receives the pool's AbortSignal for cooperative cancellation.
*
* Use `all()` to wait for all tasks (throws AggregateError on any failure)
* or `allSettled()` to inspect individual results — mirroring the Promise static API.
*
* @example
* const pool = new AsyncPool({ concurrency: 3, signal: controller.signal });
* for (const chunk of chunks) {
* pool.add((signal) => fetch(chunk.url, { signal }));
* }
* await pool.all();
*
* @since 0.0.9
*/
export class AsyncPool {
private limit: number;
private activeCount: number;
private readonly queue: CircularBuffer<PoolEntry>;
// withResolvers result — single field replaces separate drainPromise + drainResolve
private pending: ReturnType<typeof Promise.withResolvers<Array<PromiseSettledResult<unknown>>>> | null;
private readonly signal: AbortSignal;
private settled: Array<PromiseSettledResult<unknown>>;
// Kept so the listener can be detached via dispose(), avoiding retention through a long-lived signal
private readonly abortListener: (() => void) | null;
// No aborted field — use this.signal.aborted directly (always in sync with the platform)
constructor(options: AsyncPoolOptions = {}) {
this.limit = normalizeConcurrency(options.concurrency);
this.activeCount = 0;
this.queue = new CircularBuffer<PoolEntry>();
this.pending = null;
this.signal = options.signal ?? NEVER_ABORT_SIGNAL;
this.settled = [];
if (options.signal) {
this.abortListener = () => this.onAbort();
options.signal.addEventListener('abort', this.abortListener, { once: true });
}
else {
this.abortListener = null;
}
}
/**
* Detaches the pool's abort listener from the provided signal. Call this when the pool is no
* longer needed but the signal outlives it (e.g. one long-lived controller feeding many pools),
* otherwise the pool stays reachable through the signal's listener list and cannot be GC'd.
*/
dispose(): void {
if (this.abortListener)
this.signal.removeEventListener('abort', this.abortListener);
}
get size(): number {
return this.queue.length;
}
get active(): number {
return this.activeCount;
}
get concurrency(): number {
return this.limit;
}
/**
* Updates the concurrency limit at runtime. If increased, queued tasks are started immediately
* to fill the newly available slots.
*/
set concurrency(value: number) {
this.limit = normalizeConcurrency(value);
this.fill();
}
/**
* Adds a task to the pool. Starts immediately if under the concurrency limit; queues otherwise.
* The task receives the pool's AbortSignal for cooperative cancellation.
*
* @param {(signal: AbortSignal) => Promise<T>} task
* @returns {Promise<T>}
*/
add<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
// withResolvers — resolve/reject needed outside the promise for PoolEntry;
// no executor closure, no captured variables
const { promise, resolve, reject } = Promise.withResolvers<T>();
if (this.signal.aborted) {
reject(this.signal.reason);
return promise;
}
const entry = { task, resolve, reject } as PoolEntry<T>;
if (this.activeCount < this.limit) {
this.run(entry);
}
else {
this.queue.pushBack(entry);
}
return promise;
}
/**
* Like `Promise.all` — resolves when all tasks complete; throws `AggregateError` if any failed.
* Multiple concurrent callers share the same underlying promise.
*
* @returns {Promise<void>}
*/
async all(): Promise<void> {
const results = await this.waitForDrain();
const errors: unknown[] = [];
for (const r of results) {
if (r.status === 'rejected')
errors.push((r as PromiseRejectedResult).reason);
}
if (errors.length > 0)
throw new AggregateError(errors, 'AsyncPool: one or more tasks failed');
}
/**
* Like `Promise.allSettled` — always resolves with the settled result of every task.
* Multiple concurrent callers share the same underlying promise.
*
* @returns {Promise<Array<PromiseSettledResult<unknown>>>}
*/
allSettled(): Promise<Array<PromiseSettledResult<unknown>>> {
return this.waitForDrain();
}
private waitForDrain(): Promise<Array<PromiseSettledResult<unknown>>> {
if (this.activeCount === 0 && this.queue.isEmpty) {
// Fast path — swap settled out immediately; no copy
const results = this.settled;
this.settled = [];
return Promise.resolve(results);
}
if (this.pending !== null)
return this.pending.promise; // idempotent — N callers share one promise
this.pending = Promise.withResolvers();
return this.pending.promise;
}
private run(entry: PoolEntry): void {
this.activeCount++;
let task: Promise<unknown>;
// A task that throws synchronously must become a rejection, otherwise the
// exception escapes add(), the concurrency slot leaks and the pool wedges forever.
try {
task = Promise.resolve(entry.task(this.signal));
}
catch (reason) {
this.settled.push({ status: 'rejected', reason });
entry.reject(reason);
this.next();
return;
}
task.then(
(value) => {
this.settled.push({ status: 'fulfilled', value });
entry.resolve(value);
this.next();
},
(reason) => {
this.settled.push({ status: 'rejected', reason });
entry.reject(reason);
this.next();
},
);
}
private next(): void {
this.activeCount--;
this.fill();
}
// Central pump — fills available concurrency slots from the queue.
// Exit point: flushes drain when queue and active are both empty.
// Note: no abort check here — onAbort() drains the queue synchronously before
// any microtask could call fill(), so by the time fill() runs, queue is already empty.
private fill(): void {
const q = this.queue;
while (!q.isEmpty && this.activeCount < this.limit) {
this.run(q.popFront() as PoolEntry);
}
if (q.isEmpty && this.activeCount === 0)
this.flush();
}
private onAbort(): void {
const reason = this.signal.reason;
const q = this.queue;
while (!q.isEmpty) {
const entry = q.popFront() as PoolEntry;
this.settled.push({ status: 'rejected', reason });
entry.reject(reason);
}
if (this.activeCount === 0)
this.flush();
}
private flush(): void {
const drain = this.pending;
// No waiter yet — keep `settled` intact so a later all()/allSettled() can still
// observe the results of every task via the waitForDrain() fast path.
if (drain === null)
return;
this.pending = null;
// Swap — hand off current array, allocate fresh one; no element copy
const results = this.settled;
this.settled = [];
drain.resolve(results);
}
} }
+46 -9
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { retry } from '.'; import { retry } from '.';
describe('retry', () => { describe('retry', () => {
@@ -20,7 +20,7 @@ describe('retry', () => {
expect(successFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) }); expect(successFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) });
}); });
it('use default times value of 2', async () => { it('use default times value of 2', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('Test error')); const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
await expect(retry(failingFn)).rejects.toThrow('Test error'); await expect(retry(failingFn)).rejects.toThrow('Test error');
@@ -58,7 +58,7 @@ describe('retry', () => {
await expect(retry(failingFn, { await expect(retry(failingFn, {
times: 3, times: 3,
shouldRetry: (error) => error.name !== 'NetworkError' shouldRetry: error => error.name !== 'NetworkError',
})).rejects.toThrow('Network failed'); })).rejects.toThrow('Network failed');
expect(failingFn).toHaveBeenCalledTimes(1); expect(failingFn).toHaveBeenCalledTimes(1);
@@ -70,7 +70,7 @@ describe('retry', () => {
await expect(retry(failingFn, { await expect(retry(failingFn, {
times: 5, times: 5,
shouldRetry: (error, count) => count < 3 // Only retry first 2 attempts shouldRetry: (error, count) => count < 3, // Only retry first 2 attempts
})).rejects.toThrow('Test error'); })).rejects.toThrow('Test error');
expect(failingFn).toHaveBeenCalledTimes(3); // Initial + 2 retries expect(failingFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
@@ -89,7 +89,7 @@ describe('retry', () => {
await expect(retry(failingFn, { await expect(retry(failingFn, {
times: 5, times: 5,
shouldRetry: (error) => error.name === 'TemporaryError' shouldRetry: error => error.name === 'TemporaryError',
})).rejects.toThrow('Permanent failure'); })).rejects.toThrow('Permanent failure');
expect(failingFn).toHaveBeenCalledTimes(3); expect(failingFn).toHaveBeenCalledTimes(3);
@@ -212,7 +212,7 @@ describe('retry', () => {
const complexFn = vi.fn().mockResolvedValue({ const complexFn = vi.fn().mockResolvedValue({
data: [1, 2, 3], data: [1, 2, 3],
status: 'ok', status: 'ok',
metadata: { timestamp: 123456 } metadata: { timestamp: 123456 },
}); });
const result = await retry(complexFn); const result = await retry(complexFn);
@@ -220,13 +220,13 @@ describe('retry', () => {
expect(result).toEqual({ expect(result).toEqual({
data: [1, 2, 3], data: [1, 2, 3],
status: 'ok', status: 'ok',
metadata: { timestamp: 123456 } metadata: { timestamp: 123456 },
}); });
}); });
it('stop retrying when stop function is called', async () => { it('stop retrying when stop function is called', async () => {
const customError = new Error('Custom stop error'); const customError = new Error('Custom stop error');
const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error: any) => void }) => { const stopFn = vi.fn(async ({ count, stop }: { count: number; stop: (error: any) => void }) => {
if (count === 2) { if (count === 2) {
stop(customError); stop(customError);
} }
@@ -239,7 +239,7 @@ describe('retry', () => {
}); });
it('stop retrying with undefined error when stop is called without argument', async () => { it('stop retrying with undefined error when stop is called without argument', async () => {
const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error?: any) => void }) => { const stopFn = vi.fn(async ({ count, stop }: { count: number; stop: (error?: any) => void }) => {
if (count === 2) { if (count === 2) {
stop(); stop();
} }
@@ -250,4 +250,41 @@ describe('retry', () => {
expect(stopFn).toHaveBeenCalledTimes(2); expect(stopFn).toHaveBeenCalledTimes(2);
}); });
it('make exactly one attempt for times: 0 and reject with the real error (not null)', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('only attempt'));
await expect(retry(failingFn, { times: 0 })).rejects.toThrow('only attempt');
expect(failingFn).toHaveBeenCalledTimes(1);
});
it('make exactly one attempt for a negative times', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('only attempt'));
await expect(retry(failingFn, { times: -3 })).rejects.toThrow('only attempt');
expect(failingFn).toHaveBeenCalledTimes(1);
});
it('stop() on the first attempt aborts immediately with the given reason', async () => {
const reason = new Error('early');
const fn = vi.fn(async ({ stop }: { stop: (error: any) => void }) => {
stop(reason);
throw new Error('should not surface');
});
await expect(retry(fn, { times: 5 })).rejects.toBe(reason);
expect(fn).toHaveBeenCalledTimes(1);
});
it('stop() rejects with a non-Error value verbatim', async () => {
const fn = vi.fn(async ({ stop }: { stop: (error: any) => void }) => {
stop('plain string reason');
throw new Error('ignored');
});
await expect(retry(fn, { times: 3 })).rejects.toBe('plain string reason');
expect(fn).toHaveBeenCalledTimes(1);
});
}); });
+7 -3
View File
@@ -55,6 +55,9 @@ export async function retry<Return>(
shouldRetry, shouldRetry,
} = options; } = options;
// Always make at least one attempt — `times < 1` would otherwise skip the loop
// entirely and throw a bare `null`, which is impossible for callers to diagnose.
const maxAttempts = times < 1 ? 1 : times;
const wrappedFn = tryIt(fn); const wrappedFn = tryIt(fn);
const delayFn = isFunction(delay) ? delay : null; const delayFn = isFunction(delay) ? delay : null;
const delayMs = delayFn ? 0 : delay as number; const delayMs = delayFn ? 0 : delay as number;
@@ -66,7 +69,7 @@ export async function retry<Return>(
let lastError: Error | null = null; let lastError: Error | null = null;
let count = 1; let count = 1;
while (count <= times) { while (count <= maxAttempts) {
const { error, data } = await wrappedFn({ count, stop }); const { error, data } = await wrappedFn({ count, stop });
if (!error) if (!error)
@@ -82,7 +85,7 @@ export async function retry<Return>(
count++; count++;
// Don't delay after the last attempt // Don't delay after the last attempt
if (count <= times) { if (count <= maxAttempts) {
const ms = delayFn ? delayFn(count) : delayMs; const ms = delayFn ? delayFn(count) : delayMs;
if (ms > 0) if (ms > 0)
@@ -90,6 +93,7 @@ export async function retry<Return>(
} }
} }
// eslint-disable-next-line eslint/no-throw-literal // lastError is always set by the loop above (at least one attempt runs).
// eslint-disable-next-line no-throw-literal -- rethrowing the original caught error verbatim
throw lastError!; throw lastError!;
} }
+27 -8
View File
@@ -1,19 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { sleep } from '.'; import { sleep } from '.';
describe('sleep', () => { describe('sleep', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers();
}); });
it('delay execution by the specified amount of time', async () => { afterEach(() => {
const start = performance.now(); vi.useRealTimers();
const delay = 100; });
await sleep(delay); it('resolve only after the requested delay elapses', async () => {
let done = false;
sleep(100).then(() => (done = true));
const end = performance.now(); await vi.advanceTimersByTimeAsync(99);
expect(done).toBe(false);
expect(end - start).toBeGreaterThan(delay - 5); await vi.advanceTimersByTimeAsync(1);
expect(done).toBe(true);
});
it('resolve with undefined', async () => {
const promise = sleep(0);
await vi.advanceTimersByTimeAsync(0);
expect(await promise).toBeUndefined();
});
it('resolve on the next macrotask for a zero delay', async () => {
let done = false;
sleep(0).then(() => (done = true));
await vi.advanceTimersByTimeAsync(0);
expect(done).toBe(true);
}); });
}); });
@@ -0,0 +1,37 @@
import { describe, expectTypeOf, it } from 'vitest';
import { tryIt } from '.';
describe('tryIt', () => {
it('wraps async returns in a Promise of the discriminated union', () => {
const wrapped = tryIt(async (n: number) => n * 2);
expectTypeOf(wrapped(2)).toEqualTypeOf<
Promise<{ error: Error; data: undefined } | { error: undefined; data: number }>
>();
});
it('keeps sync returns synchronous', () => {
const wrapped = tryIt((n: number) => n * 2);
expectTypeOf(wrapped(2)).toEqualTypeOf<
{ error: Error; data: undefined } | { error: undefined; data: number }
>();
});
it('unwraps Awaited for a promise-returning function', () => {
const wrapped = tryIt((n: number) => Promise.resolve(`${n}`));
expectTypeOf(wrapped(1)).toEqualTypeOf<
Promise<{ error: Error; data: undefined } | { error: undefined; data: string }>
>();
});
it('narrows data when error is checked', () => {
const result = tryIt((n: number) => n)(1);
if (result.error === undefined)
expectTypeOf(result.data).toEqualTypeOf<number>();
else
expectTypeOf(result.data).toEqualTypeOf<undefined>();
});
});
+30 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { tryIt } from '.'; import { tryIt } from '.';
describe('tryIt', () => { describe('tryIt', () => {
@@ -68,4 +68,33 @@ describe('tryIt', () => {
expect(error?.message).toBe('Test error'); expect(error?.message).toBe('Test error');
expect(data).toBeUndefined(); expect(data).toBeUndefined();
}); });
it('capture a rejected thenable (non-native PromiseLike)', async () => {
const thenableFn = () => ({
// eslint-disable-next-line unicorn/no-thenable -- intentionally exercising a custom thenable
then(_resolve: (v: number) => void, reject: (e: unknown) => void) {
reject(new Error('thenable error'));
},
});
const { error, data } = await tryIt(thenableFn)();
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('thenable error');
expect(data).toBeUndefined();
});
it('resolve a fulfilled thenable to its data', async () => {
const thenableFn = () => ({
// eslint-disable-next-line unicorn/no-thenable -- intentionally exercising a custom thenable
then(resolve: (v: number) => void) {
resolve(42);
},
});
const { error, data } = await tryIt(thenableFn)();
expect(error).toBeUndefined();
expect(data).toBe(42);
});
}); });
+19 -8
View File
@@ -1,11 +1,19 @@
import { isPromise } from '../../types'; export type TryItReturn<Return> = Return extends PromiseLike<any>
export type TryItReturn<Return> = Return extends Promise<any>
? Promise<{ error: Error; data: undefined } | { error: undefined; data: Awaited<Return> }> ? Promise<{ error: Error; data: undefined } | { error: undefined; data: Awaited<Return> }>
: { error: Error; data: undefined } | { error: undefined; data: Return }; : { error: Error; data: undefined } | { error: undefined; data: Return };
function onResolve(data: any) { return { error: undefined, data }; } function isThenable(value: unknown): value is PromiseLike<unknown> {
function onReject(error: any) { return { error, data: undefined }; } return value !== null && (typeof value === 'object' || typeof value === 'function')
&& typeof (value as PromiseLike<unknown>).then === 'function';
}
function onResolve(data: any) {
return { error: undefined, data };
}
function onReject(error: any) {
return { error, data: undefined };
}
/** /**
* @name tryIt * @name tryIt
@@ -31,11 +39,14 @@ export function tryIt<Args extends any[], Return>(
try { try {
const result = fn(...args); const result = fn(...args);
if (isPromise(result)) // Handle any thenable (native Promise, async fn, or custom PromiseLike), so a
return result.then(onResolve, onReject) as TryItReturn<Return>; // rejected thenable is captured as { error } instead of escaping as raw data.
if (isThenable(result))
return Promise.resolve(result).then(onResolve, onReject) as TryItReturn<Return>;
return { error: undefined, data: result } as TryItReturn<Return>; return { error: undefined, data: result } as TryItReturn<Return>;
} catch (error) { }
catch (error) {
return { error, data: undefined } as TryItReturn<Return>; return { error, data: undefined } as TryItReturn<Return>;
} }
}; };
+14 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { flagsGenerator } from '.'; import { flagsGenerator } from '.';
describe('flagsGenerator', () => { describe('flagsGenerator', () => {
@@ -23,4 +23,17 @@ describe('flagsGenerator', () => {
expect(() => generateFlag()).toThrow(new RangeError('Cannot create more than 31 flags')); expect(() => generateFlag()).toThrow(new RangeError('Cannot create more than 31 flags'));
}); });
it('produce 31 distinct, orthogonal powers of two up to 2^30', () => {
const generateFlag = flagsGenerator();
const flags = Array.from({ length: 31 }, () => generateFlag());
expect(new Set(flags).size).toBe(31);
flags.forEach((flag, i) => {
expect(flag).toBe(2 ** i);
expect(flag & (flag - 1)).toBe(0); // exactly one bit set
});
expect(flags.at(-1)).toBe(2 ** 30);
});
}); });
+11 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { and, or, not, has, is, unset, toggle } from '.'; import { and, has, is, not, or, toggle, unset } from '.';
describe('flagsAnd', () => { describe('flagsAnd', () => {
it('no effect on zero flags', () => { it('no effect on zero flags', () => {
@@ -61,6 +61,15 @@ describe('flagsHas', () => {
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('require ALL queried bits, not just any (partial overlap is false)', () => {
// 0b1000 is set but 0b0100 is not — partial overlap must be false
expect(has(0b1010, 0b1100)).toBe(false);
// both bits present
expect(has(0b1110, 0b1100)).toBe(true);
// querying zero bits is vacuously true
expect(has(0b1010, 0b0000)).toBe(true);
});
}); });
describe('flagsIs', () => { describe('flagsIs', () => {
+1 -1
View File
@@ -29,7 +29,7 @@ export function or(...flags: number[]) {
/** /**
* @name not * @name not
* @category Bits * @category Bits
* @description Function to combine multiple flags using the XOR operator * @description Function to apply the bitwise NOT (complement) operator to a flag
* *
* @param {number} flag - The flag to apply the NOT operator to * @param {number} flag - The flag to apply the NOT operator to
* @returns {number} The result of the NOT operator * @returns {number} The result of the NOT operator
+2
View File
@@ -1 +1,3 @@
export * from './flags'; export * from './flags';
export * from './helpers';
export * from './vector';
+69 -3
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { BitVector } from '.'; import { BitVector } from '.';
describe('BitVector', () => { describe('BitVector', () => {
@@ -54,10 +54,76 @@ describe('BitVector', () => {
expect(bitVector.previousBit(0)).toBe(-1); expect(bitVector.previousBit(0)).toBe(-1);
}); });
it('throw RangeError when previousBit is called with an unreachable value', () => { it('clamp an out-of-range start index and return the previous set bit', () => {
const bitVector = new BitVector(16); const bitVector = new BitVector(16);
bitVector.setBit(5); bitVector.setBit(5);
expect(() => bitVector.previousBit(24)).toThrow(new RangeError('Unreachable value')); expect(bitVector.previousBit(24)).toBe(5);
});
it('return -1 from previousBit on an empty out-of-range query', () => {
const bitVector = new BitVector(16);
expect(bitVector.previousBit(24)).toBe(-1);
});
it('toggle bits correctly', () => {
const bitVector = new BitVector(16);
bitVector.toggleBit(7);
expect(bitVector.getBit(7)).toBe(true);
bitVector.toggleBit(7);
expect(bitVector.getBit(7)).toBe(false);
});
it('find the next bit correctly', () => {
const bitVector = new BitVector(100);
const indices = [0, 1, 14, 15, 63, 64, 65, 66, 88, 99];
const result = [];
indices.forEach(index => bitVector.setBit(index));
for (let i = bitVector.nextBit(-1); i !== -1; i = bitVector.nextBit(i)) {
result.push(i);
}
expect(result).toEqual(indices);
});
it('return -1 when no next bit is found', () => {
const bitVector = new BitVector(16);
expect(bitVector.nextBit(0)).toBe(-1);
expect(bitVector.nextBit(15)).toBe(-1);
});
it('count the number of set bits', () => {
const bitVector = new BitVector(100);
expect(bitVector.count()).toBe(0);
[0, 5, 63, 64, 99].forEach(index => bitVector.setBit(index));
expect(bitVector.count()).toBe(5);
bitVector.clearBit(5);
expect(bitVector.count()).toBe(4);
});
it('tolerate out-of-bounds writes without crashing or corrupting in-range bits', () => {
const bitVector = new BitVector(16);
bitVector.setBit(3);
expect(() => {
bitVector.setBit(1000);
bitVector.clearBit(1000);
bitVector.toggleBit(1000);
}).not.toThrow();
// out-of-range reads are false; in-range state is intact
expect(bitVector.getBit(1000)).toBe(false);
expect(bitVector.getBit(3)).toBe(true);
expect(bitVector.count()).toBe(1);
}); });
}); });
+71
View File
@@ -2,7 +2,10 @@ export interface BitVectorLike {
getBit(index: number): boolean; getBit(index: number): boolean;
setBit(index: number): void; setBit(index: number): void;
clearBit(index: number): void; clearBit(index: number): void;
toggleBit(index: number): void;
previousBit(index: number): number; previousBit(index: number): number;
nextBit(index: number): number;
count(): number;
} }
/** /**
@@ -30,7 +33,18 @@ export class BitVector extends Uint8Array implements BitVectorLike {
this[index >> 3]! &= ~(1 << (index & 7)); this[index >> 3]! &= ~(1 << (index & 7));
} }
toggleBit(index: number): void {
this[index >> 3]! ^= 1 << (index & 7);
}
previousBit(index: number): number { previousBit(index: number): number {
// Clamp an out-of-range start to the vector's bit length so a query past the end
// returns the last set bit (or -1) instead of falling through to the invariant throw.
const totalBits = this.length << 3;
if (index > totalBits)
index = totalBits;
while (index !== ((index >> 3) << 3)) { while (index !== ((index >> 3) << 3)) {
--index; --index;
@@ -58,4 +72,61 @@ export class BitVector extends Uint8Array implements BitVectorLike {
throw new RangeError('Unreachable value'); throw new RangeError('Unreachable value');
} }
nextBit(index: number): number {
const totalBits = this.length << 3;
let i = index + 1;
if (i < 0)
i = 0;
// Finish scanning the remainder of the starting byte.
while (i < totalBits && (i & 7) !== 0) {
if (this.getBit(i))
return i;
++i;
}
// Skip over fully-empty bytes.
let byteIndex = i >> 3;
while (byteIndex < this.length && this[byteIndex] === 0)
++byteIndex;
if (byteIndex >= this.length)
return -1;
i = byteIndex << 3;
const end = i + 8;
while (i < end) {
if (this.getBit(i))
return i;
++i;
}
throw new RangeError('Unreachable value');
}
count(): number {
let total = 0;
const len = this.length;
// Indexed loop — the typed-array iterator protocol (for...of) is ~3.5x slower here.
for (let i = 0; i < len; i++) {
// Brian Kernighan's algorithm: iterate once per set bit.
let byte = this[i]!;
while (byte !== 0) {
byte &= byte - 1;
++total;
}
}
return total;
}
} }
@@ -0,0 +1,53 @@
import { describe, expectTypeOf, it } from 'vitest';
import { get } from '.';
import type { Get } from '.';
describe('get', () => {
describe('runtime return type', () => {
it('infer a nested object value', () => {
expectTypeOf(get({ user: { name: 'John' } }, 'user.name')).toEqualTypeOf<string | undefined>();
});
it('infer a value behind an array index', () => {
expectTypeOf(get({ items: [{ id: 1 }] }, 'items.0.id')).toEqualTypeOf<number | undefined>();
});
it('infer an array element', () => {
expectTypeOf(get({ items: [{ id: 1 }] }, 'items.0')).toEqualTypeOf<{ id: number } | undefined>();
});
it('infer the element type of a root array', () => {
expectTypeOf(get(['a', 'b', 'c'], '0')).toEqualTypeOf<string | undefined>();
});
it('narrow to undefined when descending into a primitive', () => {
expectTypeOf(get({ a: 1 }, 'a.b.c')).toEqualTypeOf<undefined>();
});
it('narrow to undefined for a missing key', () => {
expectTypeOf(get({ user: { name: 'John' } }, 'user.age')).toEqualTypeOf<undefined>();
});
});
describe('Get', () => {
it('resolve a simple object path', () => {
expectTypeOf<Get<{ user: { name: string } }, 'user.name'>>().toEqualTypeOf<string>();
});
it('resolve a path through an array index (general arrays widen with undefined)', () => {
expectTypeOf<Get<{ list: number[] }, 'list.0'>>().toEqualTypeOf<number | undefined>();
});
it('resolve an exact element type from a tuple', () => {
expectTypeOf<Get<{ pair: [string, number] }, 'pair.1'>>().toEqualTypeOf<number>();
});
it('resolve a deeply nested path', () => {
expectTypeOf<Get<{ a: { b: { c: boolean } } }, 'a.b.c'>>().toEqualTypeOf<boolean>();
});
it('resolve to never for a missing key', () => {
expectTypeOf<Get<{ a: number }, 'b'>>().toEqualTypeOf<never>();
});
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { get } from '.';
describe('get', () => {
it('read a top-level property', () => {
expect(get({ name: 'John' }, 'name')).toBe('John');
});
it('read a nested object property', () => {
expect(get({ user: { name: 'John' } }, 'user.name')).toBe('John');
});
it('read a value through an array index', () => {
expect(get({ items: [{ id: 1 }, { id: 2 }] }, 'items.1.id')).toBe(2);
});
it('read deeply nested values', () => {
const source = { a: { b: { c: { d: 42 } } } };
expect(get(source, 'a.b.c.d')).toBe(42);
});
it('return undefined for a missing leaf', () => {
expect(get({ user: { name: 'John' } }, 'user.age')).toBeUndefined();
});
it('return undefined when traversing through a missing branch', () => {
expect(get({ a: 1 }, 'a.b.c')).toBeUndefined();
});
it('return undefined when traversing through null/undefined', () => {
expect(get({ user: null }, 'user.name')).toBeUndefined();
expect(get({ user: undefined }, 'user.name')).toBeUndefined();
});
it('preserve falsy values', () => {
expect(get({ count: 0 }, 'count')).toBe(0);
expect(get({ flag: false }, 'flag')).toBe(false);
expect(get({ value: '' }, 'value')).toBe('');
});
it('work on arrays as the root collection', () => {
expect(get(['a', 'b', 'c'], '2')).toBe('c');
});
it('return the object itself for an empty path', () => {
const obj = { a: 1 };
expect(get(obj, '')).toBeUndefined();
});
it('resolve own properties (inherited keys are reachable via the prototype chain)', () => {
const proto = { inherited: 'from-proto' };
const obj = Object.create(proto) as { inherited: string; own?: number };
obj.own = 1;
expect(get(obj, 'own')).toBe(1);
// documents current behavior: bracket access does walk the prototype chain
expect(get(obj, 'inherited')).toBe('from-proto');
});
});
+35 -3
View File
@@ -27,8 +27,40 @@ export type ExtractFromCollection<O, K>
: never : never
: never; : never;
type Get<O, K> = ExtractFromCollection<O, Path<K>>; export type Get<O, K> = ExtractFromCollection<O, Path<K>>;
export function get<O extends Collection, K extends string>(obj: O, path: K) { /**
return path.split('.').reduce((acc, key) => (acc as any)?.[key], obj) as Get<O, K> | undefined; * @name get
* @category Collections
* @description Safely read a deeply nested value from a collection by a dot-separated path
*
* @param {Collection} obj - The source object or array
* @param {string} path - Dot-separated path, e.g. `'user.addresses.0.street'`
* @returns {Get<O, K> | undefined} The resolved value, or `undefined` if any segment is missing
*
* @example
* get({ user: { name: 'John' } }, 'user.name'); // 'John'
* get({ items: [{ id: 1 }] }, 'items.0.id'); // 1
* get({ a: 1 }, 'a.b.c'); // undefined
*
* @since 0.0.4
*/
export function get<O extends Collection, K extends string>(obj: O, path: K): Get<O, K> | undefined {
let value: any = obj;
let start = 0;
// Walk the path without allocating an intermediate array of segments.
for (let i = 0, len = path.length; i <= len; i++) {
// Split on '.' (char code 46) or the end of the string.
if (i !== len && path.charCodeAt(i) !== 46)
continue;
if (value === null || value === undefined)
return undefined;
value = value[path.slice(start, i)];
start = i + 1;
}
return value;
} }
@@ -0,0 +1,18 @@
import { describe, expectTypeOf, it } from 'vitest';
import { compose } from '.';
describe('compose', () => {
it('infers the final return type through the chain', () => {
const fn = compose((s: string) => s.length > 0, (n: number) => `${n}`, (n: number) => n + 1);
expectTypeOf(fn).parameters.toEqualTypeOf<[number]>();
expectTypeOf(fn).returns.toEqualTypeOf<boolean>();
});
it('keeps the variadic parameters of the last function', () => {
const fn = compose((n: number) => `${n}`, (a: number, b: number) => a + b);
expectTypeOf(fn).parameters.toEqualTypeOf<[number, number]>();
expectTypeOf(fn).returns.toEqualTypeOf<string>();
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { compose } from '.';
describe('compose', () => {
it('apply functions right-to-left', () => {
const calc = compose((n: number) => `= ${n}`, (n: number) => n * 2, (n: number) => n + 1);
expect(calc(3)).toBe('= 8');
});
it('pass multiple arguments to the last function', () => {
const calc = compose((n: number) => n * 10, (a: number, b: number) => a + b);
expect(calc(2, 3)).toBe(50);
});
it('support a single function', () => {
const inc = compose((n: number) => n + 1);
expect(inc(1)).toBe(2);
});
it('mirror pipe with reversed arguments', () => {
const f = (n: number) => n + 1;
const g = (n: number) => n * 2;
expect(compose(g, f)(3)).toBe(8);
});
it('forward this to the right-most function', () => {
const calc = compose((n: number) => n * 2, function (this: { base: number }, n: number) {
return this.base + n;
});
expect(calc.call({ base: 10 }, 5)).toBe(30);
});
it('return the input unchanged with no functions', () => {
expect((compose as unknown as (...fns: never[]) => (x: number) => number)()(42)).toBe(42);
});
});
@@ -0,0 +1,38 @@
import type { AnyFunction } from '../../types';
/**
* @name compose
* @category Functions
* @description Composes functions right-to-left: `compose(f, g)(x)` is `f(g(x))`
*
* @param {...Function} fns - The functions to compose; the last may take any number of arguments
* @returns {Function} A function that runs the input through every function from last to first
*
* @example
* const calc = compose((n: number) => `= ${n}`, (n: number) => n * 2, (n: number) => n + 1);
* calc(3); // '= 8'
*
* @since 0.0.10
*/
export function compose<A extends any[], B>(ab: (...a: A) => B): (...a: A) => B;
export function compose<A extends any[], B, C>(bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => C;
export function compose<A extends any[], B, C, D>(cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => D;
export function compose<A extends any[], B, C, D, E>(de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => E;
export function compose<A extends any[], B, C, D, E, F>(ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => F;
export function compose<A extends any[], B, C, D, E, F, G>(fg: (f: F) => G, ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => G;
export function compose<A extends any[], B, C, D, E, F, G, H>(gh: (g: G) => H, fg: (f: F) => G, ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => H;
export function compose(...fns: AnyFunction[]): AnyFunction {
return function (this: unknown, ...args: any[]) {
const last = fns.length - 1;
if (last < 0)
return args[0];
let result = fns[last]!.apply(this, args);
for (let i = last - 1; i >= 0; i--)
result = fns[i]!.call(this, result);
return result;
};
}
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { debounce } from '.';
describe('debounce', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('delay invocation until wait elapses since the last call', () => {
const spy = vi.fn();
const debounced = debounce(spy, 100);
debounced();
debounced();
debounced();
expect(spy).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1);
});
it('reset the timer on every call', () => {
const spy = vi.fn();
const debounced = debounce(spy, 100);
debounced();
vi.advanceTimersByTime(60);
debounced();
vi.advanceTimersByTime(60);
expect(spy).not.toHaveBeenCalled();
vi.advanceTimersByTime(40);
expect(spy).toHaveBeenCalledTimes(1);
});
it('invoke on the leading edge', () => {
const spy = vi.fn();
const debounced = debounce(spy, 100, { leading: true, trailing: false });
debounced();
expect(spy).toHaveBeenCalledTimes(1);
debounced();
debounced();
expect(spy).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1);
});
it('forward the latest arguments and this', () => {
const spy = vi.fn(function (this: { base: number }, n: number) {
return this.base + n;
});
const debounced = debounce(spy, 100);
debounced.call({ base: 10 }, 1);
debounced.call({ base: 10 }, 2);
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith(2);
expect(spy.mock.results[0]!.value).toBe(12);
});
it('cancel a pending invocation', () => {
const spy = vi.fn();
const debounced = debounce(spy, 100);
debounced();
expect(debounced.pending()).toBe(true);
debounced.cancel();
expect(debounced.pending()).toBe(false);
vi.advanceTimersByTime(100);
expect(spy).not.toHaveBeenCalled();
});
it('flush a pending invocation immediately and return its result', () => {
const spy = vi.fn((n: number) => n * 2);
const debounced = debounce(spy, 100);
debounced(5);
expect(debounced.flush()).toBe(10);
expect(spy).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1);
});
it('resolves the wait from a getter on each call', () => {
const spy = vi.fn();
let wait = 100;
const debounced = debounce(spy, () => wait);
debounced();
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1);
wait = 200;
debounced();
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1); // not yet, window is now 200
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(2);
});
it('forces invocation after maxWait under sustained calls', () => {
const spy = vi.fn();
const debounced = debounce(spy, 100, { maxWait: 250 });
// Keep resetting the 100ms timer every 80ms; without maxWait it would never fire.
debounced();
vi.advanceTimersByTime(80);
debounced();
vi.advanceTimersByTime(80);
debounced();
vi.advanceTimersByTime(80);
expect(spy).not.toHaveBeenCalled();
// 240ms elapsed; at 250ms the maxWait fires.
vi.advanceTimersByTime(10);
expect(spy).toHaveBeenCalledTimes(1);
});
it('pending() is false after a maxWait-forced invocation', () => {
const debounced = debounce(vi.fn(), 100, { maxWait: 150 });
debounced();
vi.advanceTimersByTime(150);
expect(debounced.pending()).toBe(false);
});
it('leading + trailing fires on both edges for a burst but once for a lone call', () => {
const spy = vi.fn();
const debounced = debounce(spy, 100, { leading: true, trailing: true });
debounced();
expect(spy).toHaveBeenCalledTimes(1); // leading
debounced();
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(2); // trailing
spy.mockClear();
debounced(); // isolated call
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1); // leading only, no trailing double-fire
});
it('re-arm after a trailing fire', () => {
const spy = vi.fn();
const debounced = debounce(spy, 100);
debounced();
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1);
debounced();
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(2);
});
it('flush() and cancel() are no-ops when nothing is pending', () => {
const spy = vi.fn();
const debounced = debounce(spy, 100);
expect(debounced.flush()).toBeUndefined();
debounced.cancel();
expect(spy).not.toHaveBeenCalled();
expect(debounced.pending()).toBe(false);
});
it('pending() is false during the window when trailing is disabled', () => {
const debounced = debounce(vi.fn(), 100, { leading: true, trailing: false });
debounced();
expect(debounced.pending()).toBe(false);
});
});
+130
View File
@@ -0,0 +1,130 @@
import type { AnyFunction } from '../../types';
export interface DebounceOptions {
/** Invoke on the leading edge of the timeout. Default `false`. */
leading?: boolean;
/** Invoke on the trailing edge of the timeout. Default `true`. */
trailing?: boolean;
/**
* The maximum time `fn` is allowed to be delayed before it is forcibly
* invoked, even while calls keep arriving. Accepts a number or a getter
* resolved lazily. When omitted there is no upper bound.
*/
maxWait?: number | (() => number);
}
export interface DebouncedFunction<T extends AnyFunction> {
(this: ThisParameterType<T>, ...args: Parameters<T>): void;
/** Cancel a pending invocation without calling the function. */
cancel: () => void;
/** Immediately invoke a pending call (if any) and return its result. */
flush: () => ReturnType<T> | undefined;
/** Whether an invocation is currently scheduled. */
pending: () => boolean;
}
/**
* @name debounce
* @category Functions
* @description Delays invoking a function until `wait` ms have elapsed since the last call
*
* @param {Function} fn - The function to debounce
* @param {number | (() => number)} wait - Milliseconds to wait, or a getter resolved lazily on each call (useful for reactive delays)
* @param {DebounceOptions} [options] - Leading/trailing edge behavior and `maxWait`
* @returns {DebouncedFunction} The debounced function with `cancel()`, `flush()` and `pending()`
*
* @example
* const onResize = debounce(() => layout(), 200);
* window.addEventListener('resize', onResize);
* onResize.cancel();
*
* @example
* // Reactive delay + a guaranteed call at least every 1000ms under sustained input
* const save = debounce(persist, () => delayRef.value, { maxWait: 1000 });
*
* @since 0.0.10
*/
export function debounce<T extends AnyFunction>(
fn: T,
wait: number | (() => number),
options: DebounceOptions = {},
): DebouncedFunction<T> {
const { leading = false, trailing = true, maxWait } = options;
const resolveWait = typeof wait === 'function' ? wait : () => wait;
const resolveMaxWait = maxWait === undefined
? undefined
: (typeof maxWait === 'function' ? maxWait : () => maxWait);
let timer: ReturnType<typeof setTimeout> | undefined;
let maxTimer: ReturnType<typeof setTimeout> | undefined;
let pending: (() => ReturnType<T>) | undefined;
let result: ReturnType<T> | undefined;
function invoke() {
if (pending === undefined)
return;
result = pending();
pending = undefined;
}
function clearTimers() {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
if (maxTimer !== undefined) {
clearTimeout(maxTimer);
maxTimer = undefined;
}
}
const debounced = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
// The arrow captures the call-time `this` lexically, no aliasing needed.
pending = () => fn.apply(this, args) as ReturnType<T>;
const callLeading = leading && timer === undefined && maxTimer === undefined;
if (timer !== undefined)
clearTimeout(timer);
timer = setTimeout(() => {
clearTimers();
if (trailing)
invoke();
}, resolveWait());
// maxWait: guarantee an invocation within maxWait of the burst's first call.
if (resolveMaxWait !== undefined && maxTimer === undefined) {
maxTimer = setTimeout(() => {
clearTimers();
invoke();
}, resolveMaxWait());
}
if (callLeading)
invoke();
} as DebouncedFunction<T>;
debounced.cancel = () => {
clearTimers();
pending = undefined;
};
debounced.flush = () => {
if (timer !== undefined || maxTimer !== undefined) {
clearTimers();
invoke();
}
return result;
};
// True only when an invocation will actually occur: a maxWait flush is always honored,
// but the trailing-edge timer fires fn only when `trailing` is enabled.
debounced.pending = () => maxTimer !== undefined || (trailing && timer !== undefined);
return debounced;
}
+6
View File
@@ -0,0 +1,6 @@
export * from './compose';
export * from './debounce';
export * from './memoize';
export * from './once';
export * from './pipe';
export * from './throttle';
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import { memoize } from '.';
describe('memoize', () => {
it('cache results by the first argument', () => {
const spy = vi.fn((n: number) => n * 2);
const memoized = memoize(spy);
expect(memoized(2)).toBe(4);
expect(memoized(2)).toBe(4);
expect(memoized(3)).toBe(6);
expect(spy).toHaveBeenCalledTimes(2);
});
it('use a custom resolver for the cache key', () => {
const spy = vi.fn((a: number, b: number) => a + b);
const memoized = memoize(spy, (a, b) => `${a},${b}`);
expect(memoized(1, 2)).toBe(3);
expect(memoized(1, 2)).toBe(3);
expect(memoized(2, 1)).toBe(3);
expect(spy).toHaveBeenCalledTimes(2);
});
it('cache falsy results', () => {
const spy = vi.fn((_n: number) => 0);
const memoized = memoize(spy);
expect(memoized(1)).toBe(0);
expect(memoized(1)).toBe(0);
expect(spy).toHaveBeenCalledTimes(1);
});
it('expose the cache and a clear() method', () => {
const memoized = memoize((n: number) => n * 2);
memoized(2);
expect(memoized.cache.size).toBe(1);
expect(memoized.cache.get(2)).toBe(4);
memoized.clear();
expect(memoized.cache.size).toBe(0);
});
it('preserve this', () => {
const memoized = memoize(function (this: { base: number }, n: number) {
return this.base + n;
});
expect(memoized.call({ base: 10 }, 5)).toBe(15);
});
it('key only on the first argument by default (documented multi-arg footgun)', () => {
const spy = vi.fn((a: number, b: number) => a + b);
const memoized = memoize(spy);
expect(memoized(1, 2)).toBe(3);
expect(memoized(1, 9)).toBe(3); // stale — collides on first arg
expect(spy).toHaveBeenCalledTimes(1);
});
it('cache an undefined return value (does not recompute)', () => {
const spy = vi.fn((_n: number) => undefined);
const memoized = memoize(spy);
memoized(1);
memoized(1);
expect(spy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,50 @@
import type { AnyFunction } from '../../types';
export type MemoizeResolver<T extends AnyFunction> = (...args: Parameters<T>) => unknown;
export type MemoizedFunction<T extends AnyFunction> = T & {
/** The underlying cache, keyed by the resolver (first argument by default). */
cache: Map<unknown, ReturnType<T>>;
/** Drop all cached results. */
clear: () => void;
};
/**
* @name memoize
* @category Functions
* @description Caches the result of a function by its arguments
*
* @param {Function} fn - The function to memoize
* @param {Function} [resolver] - Maps the arguments to a cache key; defaults to the first argument
* @returns {MemoizedFunction} The memoized function, exposing its `cache` and a `clear()` method
*
* @example
* const slow = memoize((n: number) => expensive(n));
* slow(2); // computed
* slow(2); // cached
*
* @example
* const sum = memoize((a: number, b: number) => a + b, (a, b) => `${a},${b}`);
*
* @since 0.0.10
*/
export function memoize<T extends AnyFunction>(fn: T, resolver?: MemoizeResolver<T>): MemoizedFunction<T> {
const cache = new Map<unknown, ReturnType<T>>();
const memoized = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
const key = resolver ? resolver(...args) : args[0];
if (cache.has(key))
return cache.get(key)!;
const result = fn.apply(this, args) as ReturnType<T>;
cache.set(key, result);
return result;
} as MemoizedFunction<T>;
memoized.cache = cache;
memoized.clear = () => cache.clear();
return memoized;
}
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { once } from '.';
describe('once', () => {
it('invoke the original function only once', () => {
const spy = vi.fn(() => 42);
const onced = once(spy);
expect(onced()).toBe(42);
expect(onced()).toBe(42);
expect(onced()).toBe(42);
expect(spy).toHaveBeenCalledTimes(1);
});
it('forward arguments and this from the first call', () => {
const onced = once(function (this: { base: number }, a: number, b: number) {
return this.base + a + b;
});
expect(onced.call({ base: 10 }, 1, 2)).toBe(13);
});
it('cache the first result even when later args differ', () => {
const onced = once((n: number) => n * 2);
expect(onced(2)).toBe(4);
expect(onced(100)).toBe(4);
});
it('run again after clear()', () => {
let count = 0;
const onced = once(() => ++count);
expect(onced()).toBe(1);
expect(onced()).toBe(1);
onced.clear();
expect(onced()).toBe(2);
});
it('stay retryable when the first call throws (guard armed only on success)', () => {
let n = 0;
const onced = once(() => {
n++;
if (n === 1)
throw new Error('first');
return n;
});
expect(() => onced()).toThrow('first');
expect(onced()).toBe(2);
expect(onced()).toBe(2);
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { AnyFunction } from '../../types';
export type OnceFunction<T extends AnyFunction> = T & {
/** Reset the guard so the next call runs the original function again. */
clear: () => void;
};
/**
* @name once
* @category Functions
* @description Wraps a function so it runs at most once; subsequent calls return the first result
*
* @param {Function} fn - The function to guard
* @returns {OnceFunction} The guarded function with a `clear()` method to reset it
*
* @example
* const init = once(() => Math.random());
* init(); // 0.42
* init(); // 0.42 (cached)
* init.clear();
* init(); // 0.91 (runs again)
*
* @since 0.0.10
*/
export function once<T extends AnyFunction>(fn: T): OnceFunction<T> {
let called = false;
let result: ReturnType<T>;
const onced = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
if (!called) {
// Arm the guard only after a successful call, so a throwing first call stays
// retryable (matching memoize) instead of permanently latching `undefined`.
result = fn.apply(this, args);
called = true;
}
return result;
} as OnceFunction<T>;
onced.clear = () => {
called = false;
// Release the cached result so it (and anything it retains) can be GC'd.
result = undefined as ReturnType<T>;
};
return onced;
}
@@ -0,0 +1,24 @@
import { describe, expectTypeOf, it } from 'vitest';
import { pipe } from '.';
describe('pipe', () => {
it('infers the final return type through the chain', () => {
const fn = pipe((n: number) => n + 1, n => `${n}`, s => s.length > 0);
expectTypeOf(fn).parameters.toEqualTypeOf<[number]>();
expectTypeOf(fn).returns.toEqualTypeOf<boolean>();
});
it('keeps the variadic parameters of the first function', () => {
const fn = pipe((a: number, b: number) => a + b, n => `${n}`);
expectTypeOf(fn).parameters.toEqualTypeOf<[number, number]>();
expectTypeOf(fn).returns.toEqualTypeOf<string>();
});
it('supports a single function', () => {
const fn = pipe((n: number) => n > 0);
expectTypeOf(fn).returns.toEqualTypeOf<boolean>();
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { pipe } from '.';
describe('pipe', () => {
it('apply functions left-to-right', () => {
const calc = pipe((n: number) => n + 1, n => n * 2, n => `= ${n}`);
expect(calc(3)).toBe('= 8');
});
it('pass multiple arguments to the first function', () => {
const calc = pipe((a: number, b: number) => a + b, n => n * 10);
expect(calc(2, 3)).toBe(50);
});
it('support a single function', () => {
const inc = pipe((n: number) => n + 1);
expect(inc(1)).toBe(2);
});
it('preserve this for the first function', () => {
const calc = pipe(function (this: { base: number }, n: number) {
return this.base + n;
}, n => n * 2);
expect(calc.call({ base: 10 }, 5)).toBe(30);
});
it('return the input unchanged with no functions', () => {
expect((pipe as unknown as (...fns: never[]) => (x: number) => number)()(42)).toBe(42);
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { AnyFunction } from '../../types';
/**
* @name pipe
* @category Functions
* @description Composes functions left-to-right: `pipe(f, g)(x)` is `g(f(x))`
*
* @param {...Function} fns - The functions to pipe; the first may take any number of arguments
* @returns {Function} A function that runs the input through every function in order
*
* @example
* const calc = pipe((n: number) => n + 1, n => n * 2, n => `= ${n}`);
* calc(3); // '= 8'
*
* @since 0.0.10
*/
export function pipe<A extends any[], B>(ab: (...a: A) => B): (...a: A) => B;
export function pipe<A extends any[], B, C>(ab: (...a: A) => B, bc: (b: B) => C): (...a: A) => C;
export function pipe<A extends any[], B, C, D>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D): (...a: A) => D;
export function pipe<A extends any[], B, C, D, E>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E): (...a: A) => E;
export function pipe<A extends any[], B, C, D, E, F>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F): (...a: A) => F;
export function pipe<A extends any[], B, C, D, E, F, G>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F, fg: (f: F) => G): (...a: A) => G;
export function pipe<A extends any[], B, C, D, E, F, G, H>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F, fg: (f: F) => G, gh: (g: G) => H): (...a: A) => H;
export function pipe(...fns: AnyFunction[]): AnyFunction {
return function (this: unknown, ...args: any[]) {
if (fns.length === 0)
return args[0];
let result = fns[0]!.apply(this, args);
for (let i = 1; i < fns.length; i++)
result = fns[i]!.call(this, result);
return result;
};
}
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { throttle } from '.';
describe('throttle', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1_700_000_000_000);
});
afterEach(() => vi.useRealTimers());
it('invoke immediately on the leading edge', () => {
const spy = vi.fn();
const throttled = throttle(spy, 100);
throttled();
expect(spy).toHaveBeenCalledTimes(1);
});
it('throttle rapid calls to leading + trailing', () => {
const spy = vi.fn();
const throttled = throttle(spy, 100);
throttled();
throttled();
throttled();
expect(spy).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(2);
});
it('skip the leading call when leading is false', () => {
const spy = vi.fn();
const throttled = throttle(spy, 100, { leading: false });
throttled();
expect(spy).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1);
});
it('skip the trailing call when trailing is false', () => {
const spy = vi.fn();
const throttled = throttle(spy, 100, { trailing: false });
throttled();
throttled();
throttled();
expect(spy).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1);
});
it('allow another leading call after the window passes', () => {
const spy = vi.fn();
const throttled = throttle(spy, 100, { trailing: false });
throttled();
expect(spy).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(150);
throttled();
expect(spy).toHaveBeenCalledTimes(2);
});
it('cancel a pending trailing call', () => {
const spy = vi.fn();
const throttled = throttle(spy, 100);
throttled();
throttled();
expect(throttled.pending()).toBe(true);
throttled.cancel();
expect(throttled.pending()).toBe(false);
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1);
});
it('flush a pending trailing call immediately and return its result', () => {
const spy = vi.fn((n: number) => n * 2);
const throttled = throttle(spy, 100);
throttled(1);
throttled(5);
expect(throttled.flush()).toBe(10);
expect(spy).toHaveBeenCalledTimes(2);
expect(throttled.pending()).toBe(false);
});
it('resolves the wait from a getter on each call', () => {
const spy = vi.fn();
let wait = 100;
const throttled = throttle(spy, () => wait, { leading: false });
throttled();
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1);
wait = 200;
throttled();
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(1); // window widened to 200
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledTimes(2);
});
it('trailing invocation uses the latest arguments', () => {
const spy = vi.fn();
const throttled = throttle(spy, 100);
throttled('a');
throttled('b');
throttled('c');
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenNthCalledWith(1, 'a'); // leading
expect(spy).toHaveBeenNthCalledWith(2, 'c'); // trailing = most recent
});
it('rate-limit across multiple sustained windows', () => {
const spy = vi.fn();
const throttled = throttle(spy, 100);
for (let i = 0; i < 5; i++) {
throttled();
vi.advanceTimersByTime(50);
}
// 250ms of calls every 50ms with a 100ms window — far fewer than 5 invocations.
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(spy.mock.calls.length).toBeLessThan(5);
});
it('flush() with nothing scheduled returns the last result without double-calling', () => {
const spy = vi.fn((n: number) => n);
const throttled = throttle(spy, 100);
throttled(1); // leading fires immediately, nothing trailing pending
expect(throttled.flush()).toBe(1);
expect(spy).toHaveBeenCalledTimes(1);
});
it('preserve this on the leading call', () => {
const throttled = throttle(function (this: { base: number }, n: number) {
this.base += n;
}, 100);
const ctx = { base: 10 };
throttled.call(ctx, 5);
expect(ctx.base).toBe(15);
});
});
+106
View File
@@ -0,0 +1,106 @@
import type { AnyFunction } from '../../types';
export interface ThrottleOptions {
/** Invoke on the leading edge of the window. Default `true`. */
leading?: boolean;
/** Invoke on the trailing edge of the window. Default `true`. */
trailing?: boolean;
}
export interface ThrottledFunction<T extends AnyFunction> {
(this: ThisParameterType<T>, ...args: Parameters<T>): void;
/** Cancel a pending trailing invocation. */
cancel: () => void;
/** Immediately invoke a pending call (if any) and return its result. */
flush: () => ReturnType<T> | undefined;
/** Whether a trailing invocation is currently scheduled. */
pending: () => boolean;
}
/**
* @name throttle
* @category Functions
* @description Invokes a function at most once per `wait` ms
*
* @param {Function} fn - The function to throttle
* @param {number | (() => number)} wait - Milliseconds to throttle to, or a getter resolved lazily on each call (useful for reactive windows)
* @param {ThrottleOptions} [options] - Leading/trailing edge behavior
* @returns {ThrottledFunction} The throttled function with `cancel()`, `flush()` and `pending()`
*
* @example
* const onScroll = throttle(() => update(), 100);
* window.addEventListener('scroll', onScroll);
*
* @since 0.0.10
*/
export function throttle<T extends AnyFunction>(fn: T, wait: number | (() => number), options: ThrottleOptions = {}): ThrottledFunction<T> {
const { leading = true, trailing = true } = options;
const resolveWait = typeof wait === 'function' ? wait : () => wait;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending: (() => ReturnType<T>) | undefined;
let result: ReturnType<T> | undefined;
let lastInvokeTime = 0;
function invoke(time: number) {
if (pending === undefined)
return;
lastInvokeTime = time;
result = pending();
pending = undefined;
}
const throttled = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
const now = Date.now();
const wait = resolveWait();
// Skip the leading call by pretending we just invoked.
if (lastInvokeTime === 0 && !leading)
lastInvokeTime = now;
const remaining = wait - (now - lastInvokeTime);
// The arrow captures the call-time `this` lexically, no aliasing needed.
pending = () => fn.apply(this, args) as ReturnType<T>;
// Outside the window (or the clock jumped): invoke right away.
if (remaining <= 0 || remaining > wait) {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
invoke(now);
}
else if (timer === undefined && trailing) {
timer = setTimeout(() => {
timer = undefined;
invoke(leading ? Date.now() : 0);
}, remaining);
}
} as ThrottledFunction<T>;
throttled.cancel = () => {
if (timer !== undefined)
clearTimeout(timer);
timer = undefined;
pending = undefined;
lastInvokeTime = 0;
};
throttled.flush = () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
invoke(Date.now());
}
return result;
};
throttled.pending = () => timer !== undefined;
return throttled;
}
+2
View File
@@ -1,6 +1,8 @@
export * from './arrays'; export * from './arrays';
export * from './async'; export * from './async';
export * from './bits'; export * from './bits';
export * from './collections';
export * from './functions';
export * from './math'; export * from './math';
export * from './objects'; export * from './objects';
export * from './patterns'; export * from './patterns';
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { clamp } from '.'; import { clamp } from '.';
describe('clamp', () => { describe('clamp', () => {
+11 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { inverseLerp, lerp } from '.'; import { inverseLerp, lerp } from '.';
describe('lerp', () => { describe('lerp', () => {
@@ -26,6 +26,16 @@ describe('lerp', () => {
const result = lerp(0, 10, 1.5); const result = lerp(0, 10, 1.5);
expect(result).toBe(15); expect(result).toBe(15);
}); });
it('interpolates from a non-zero start', () => {
expect(lerp(10, 20, 0.5)).toBe(15);
expect(lerp(-10, 10, 0.25)).toBe(-5);
});
it('propagates NaN and Infinity', () => {
expect(lerp(0, 10, Number.NaN)).toBeNaN();
expect(lerp(0, Number.POSITIVE_INFINITY, 0.5)).toBe(Number.POSITIVE_INFINITY);
});
}); });
describe('inverseLerp', () => { describe('inverseLerp', () => {
@@ -43,4 +43,12 @@ describe('remap', () => {
// input range is zero (should return output min) // input range is zero (should return output min)
expect(remap(5, 0, 0, 0, 100)).toBe(0); expect(remap(5, 0, 0, 0, 100)).toBe(0);
}); });
it('handle a reversed (descending) input range', () => {
// 2 is 80% of the way from 10 down to 0
expect(remap(2, 10, 0, 0, 100)).toBe(80);
// clamps to the interval regardless of orientation
expect(remap(15, 10, 0, 0, 100)).toBe(0);
expect(remap(-5, 10, 0, 0, 100)).toBe(100);
});
}); });
+3 -1
View File
@@ -19,7 +19,9 @@ export function remap(value: number, in_min: number, in_max: number, out_min: nu
if (in_min === in_max) if (in_min === in_max)
return out_min; return out_min;
const clampedValue = clamp(value, in_min, in_max); // Clamp to the interval's actual bounds so a reversed (descending) input range still works;
// inverseLerp itself handles in_min > in_max correctly.
const clampedValue = clamp(value, Math.min(in_min, in_max), Math.max(in_min, in_max));
return lerp(out_min, out_max, inverseLerp(in_min, in_max, clampedValue)); return lerp(out_min, out_max, inverseLerp(in_min, in_max, clampedValue));
} }
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { clampBigInt } from '.'; import { clampBigInt } from '.';
describe('clampBigInt', () => { describe('clampBigInt', () => {
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { inverseLerpBigInt, lerpBigInt } from '.'; import { inverseLerpBigInt, lerpBigInt } from '.';
const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
@@ -28,6 +28,20 @@ describe('lerpBigInt', () => {
const result = lerpBigInt(0n, 10n, 1.5); const result = lerpBigInt(0n, 10n, 1.5);
expect(result).toBe(15n); expect(result).toBe(15n);
}); });
it('truncates the fractional part toward zero', () => {
expect(lerpBigInt(0n, 10n, 0.29)).toBe(2n); // 2.9 -> 2
expect(lerpBigInt(0n, -10n, 0.29)).toBe(-2n); // -2.9 -> -2 (toward zero, asymmetric)
});
it('stays exact for very large bigint ranges', () => {
expect(lerpBigInt(0n, 10n ** 30n, 0.5)).toBe(5n * 10n ** 29n);
});
it('interpolates a reversed (start > end) range', () => {
expect(lerpBigInt(10n, 0n, 0.5)).toBe(5n);
expect(lerpBigInt(10n, 0n, 0.25)).toBe(8n); // 10 + (-10 * 0.25) = 7.5 -> 8? truncation
});
}); });
describe('inverseLerpBigInt', () => { describe('inverseLerpBigInt', () => {
@@ -61,23 +75,20 @@ describe('inverseLerpBigInt', () => {
expect(result).toBe(0); expect(result).toBe(0);
}); });
it('handles the maximum safe integer correctly', () => { it('returns 1 at the maximum safe integer', () => {
const result = inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER); expect(inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER)).toBe(1);
expect(result).toBe(1);
}); });
it('handles values just above the maximum safe integer correctly', () => { it('returns 0 at the start of a max-safe-integer range', () => {
const result = inverseLerpBigInt(0n, MAX_SAFE_INTEGER, 0n); expect(inverseLerpBigInt(0n, MAX_SAFE_INTEGER, 0n)).toBe(0);
expect(result).toBe(0);
}); });
it('handles values just below the maximum safe integer correctly', () => { it('returns the midpoint of a max-safe-integer range', () => {
const result = inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER); // 6-decimal SCALE quantizes the result, so allow ~1e-6 tolerance.
expect(result).toBe(1); expect(inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER / 2n)).toBeCloseTo(0.5, 5);
}); });
it('handles values just above the maximum safe integer correctly', () => { it('handles values far beyond 2^53', () => {
const result = inverseLerpBigInt(0n, 2n ** 128n, 2n ** 127n); expect(inverseLerpBigInt(0n, 2n ** 128n, 2n ** 127n)).toBe(0.5);
expect(result).toBe(0.5);
}); });
}); });
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { maxBigInt } from '.'; import { maxBigInt } from '.';
describe('maxBigInt', () => { describe('maxBigInt', () => {
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { minBigInt } from '.'; import { minBigInt } from '.';
describe('minBigInt', () => { describe('minBigInt', () => {
@@ -29,4 +29,14 @@ describe('remapBigInt', () => {
// input range is zero (should return output min) // input range is zero (should return output min)
expect(remapBigInt(5n, 0n, 0n, 0n, 100n)).toBe(0n); expect(remapBigInt(5n, 0n, 0n, 0n, 100n)).toBe(0n);
}); });
it('stay exact for large ranges (no number round-trip)', () => {
// 1/3 of 10^30, computed entirely in BigInt — only truncation, no float precision loss
expect(remapBigInt(1n, 0n, 3n, 0n, 10n ** 30n)).toBe(333333333333333333333333333333n);
});
it('preserve precision well beyond 2^53', () => {
const huge = 10n ** 40n;
expect(remapBigInt(1n, 0n, 2n, 0n, huge)).toBe(huge / 2n);
});
}); });
@@ -1,5 +1,4 @@
import { clampBigInt } from '../clampBigInt'; import { clampBigInt } from '../clampBigInt';
import { inverseLerpBigInt, lerpBigInt } from '../lerpBigInt';
/** /**
* @name remapBigInt * @name remapBigInt
@@ -21,5 +20,7 @@ export function remapBigInt(value: bigint, in_min: bigint, in_max: bigint, out_m
const clampedValue = clampBigInt(value, in_min, in_max); const clampedValue = clampBigInt(value, in_min, in_max);
return lerpBigInt(out_min, out_max, inverseLerpBigInt(in_min, in_max, clampedValue)); // Stay entirely in BigInt — round-tripping through a JS number (as inverseLerpBigInt does)
// quantizes to ~6 decimals and overflows precision past 2^53, defeating the point of BigInt.
return out_min + ((clampedValue - in_min) * (out_max - out_min)) / (in_max - in_min);
} }
@@ -0,0 +1,14 @@
import { describe, expectTypeOf, it } from 'vitest';
import { omit } from '.';
interface Sample { a: number; b: string; c: boolean }
describe('omit', () => {
it('removes a single key from the type', () => {
expectTypeOf(omit({ a: 1, b: 'x', c: true } as Sample, 'a')).toEqualTypeOf<Omit<Sample, 'a'>>();
});
it('removes multiple keys from the type', () => {
expectTypeOf(omit({ a: 1, b: 'x', c: true } as Sample, ['a', 'b'])).toEqualTypeOf<Omit<Sample, 'a' | 'b'>>();
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { omit } from '.'; import { omit } from '.';
describe('omit', () => { describe('omit', () => {
+11 -9
View File
@@ -22,18 +22,20 @@ export function omit<Target extends object, Key extends keyof Target>(
target: Target, target: Target,
keys: Arrayable<Key>, keys: Arrayable<Key>,
): Omit<Target, Key> { ): Omit<Target, Key> {
const result = { ...target }; const result = {} as Omit<Target, Key>;
if (!target || !keys) if (!target)
return result; return result;
if (isArray(keys)) { // Build the kept-keys object directly instead of spread-then-delete: `delete` forces V8
for (const key of keys) { // to drop the object into slow dictionary mode, penalizing all later property access.
delete result[key]; const omitted = new Set<PropertyKey>(
} keys === null || keys === undefined ? [] : isArray(keys) ? keys : [keys],
} );
else {
delete result[keys]; for (const key in target) {
if (Object.hasOwn(target, key) && !omitted.has(key))
(result as Record<PropertyKey, unknown>)[key] = target[key];
} }
return result; return result;
@@ -0,0 +1,14 @@
import { describe, expectTypeOf, it } from 'vitest';
import { pick } from '.';
interface Sample { a: number; b: string; c: boolean }
describe('pick', () => {
it('narrows to a single picked key', () => {
expectTypeOf(pick({ a: 1, b: 'x', c: true } as Sample, 'a')).toEqualTypeOf<Pick<Sample, 'a'>>();
});
it('narrows to multiple picked keys', () => {
expectTypeOf(pick({ a: 1, b: 'x', c: true } as Sample, ['a', 'b'])).toEqualTypeOf<Pick<Sample, 'a' | 'b'>>();
});
});
+6 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { pick } from '.'; import { pick } from '.';
describe('pick', () => { describe('pick', () => {
@@ -23,7 +23,11 @@ describe('pick', () => {
it('handle non-existent keys by setting them to undefined', () => { it('handle non-existent keys by setting them to undefined', () => {
const result = pick({ a: 1, b: 2 }, ['a', 'c'] as any); const result = pick({ a: 1, b: 2 }, ['a', 'c'] as any);
expect(result).toEqual({ a: 1, c: undefined }); // toEqual ignores undefined values, so assert key presence explicitly.
expect(Object.keys(result).sort()).toEqual(['a', 'c']);
expect('c' in result).toBe(true);
expect((result as Record<string, unknown>).c).toBeUndefined();
expect(result.a).toBe(1);
}); });
it('return an empty object if target is null or undefined', () => { it('return an empty object if target is null or undefined', () => {
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { CommandHistory, AsyncCommandHistory } from '.'; import { AsyncCommandHistory, CommandHistory } from '.';
import type { Command, AsyncCommand } from '.'; import type { AsyncCommand, Command } from '.';
describe('commandHistory', () => { describe('commandHistory', () => {
let history: CommandHistory; let history: CommandHistory;
@@ -278,4 +278,43 @@ describe('asyncCommandHistory', () => {
expect(limited.size).toBe(2); expect(limited.size).toBe(2);
}); });
describe('error handling', () => {
it('does not record a sync command whose execute throws', () => {
const h = new CommandHistory();
const cmd: Command = {
execute: () => {
throw new Error('boom');
},
undo: () => {},
};
expect(() => h.execute(cmd)).toThrow('boom');
expect(h.size).toBe(0);
expect(h.undo()).toBe(false);
});
it('does not record an async command whose execute rejects', async () => {
const h = new AsyncCommandHistory();
const cmd: AsyncCommand = {
execute: () => Promise.reject(new Error('boom')),
undo: () => Promise.resolve(),
};
await expect(h.execute(cmd)).rejects.toThrow('boom');
expect(h.size).toBe(0);
expect(await h.undo()).toBe(false);
});
it('propagates a rejecting undo', async () => {
const h = new AsyncCommandHistory();
const cmd: AsyncCommand = {
execute: () => Promise.resolve(),
undo: () => Promise.reject(new Error('undo failed')),
};
await h.execute(cmd);
await expect(h.undo()).rejects.toThrow('undo failed');
});
});
}); });
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PubSub } from '.'; import { PubSub } from '.';
describe('pubsub', () => { describe('pubsub', () => {
@@ -115,4 +115,54 @@ describe('pubsub', () => {
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
}); });
describe('off edge cases', () => {
it('removes only the targeted listener of many', () => {
const a = vi.fn();
const b = vi.fn();
eventBus.on('event1', a);
eventBus.on('event1', b);
eventBus.off('event1', a);
eventBus.emit('event1', 'x');
expect(a).not.toHaveBeenCalled();
expect(b).toHaveBeenCalledTimes(1);
});
it('is a no-op when removing an unregistered listener or unknown event', () => {
const a = vi.fn();
eventBus.on('event1', a);
expect(() => eventBus.off('event1', vi.fn())).not.toThrow();
expect(() => eventBus.off('event2', vi.fn())).not.toThrow();
eventBus.emit('event1', 'x');
expect(a).toHaveBeenCalledTimes(1);
});
});
describe('emit stability', () => {
it('does not invoke listeners added during the same emit', () => {
const added = vi.fn();
const adder = vi.fn(() => eventBus.on('event1', added));
eventBus.on('event1', adder);
eventBus.emit('event1', 'x');
expect(adder).toHaveBeenCalledTimes(1);
expect(added).not.toHaveBeenCalled(); // only fires on the next emit
});
it('a once listener removes itself and fires exactly once', () => {
const listener = vi.fn();
eventBus.once('event1', listener);
eventBus.emit('event1', 'a');
eventBus.emit('event1', 'b');
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith('a');
});
});
}); });
@@ -92,7 +92,11 @@ export class PubSub<Events extends Record<string, (...args: any[]) => any>> {
if (!listeners) if (!listeners)
return false; return false;
listeners.forEach(listener => listener(...args)); // Snapshot first: iterating the live Set would invoke listeners added during this
// emit (and is fragile to self-removal). A copy gives stable EventEmitter semantics.
const snapshot = [...listeners];
for (const listener of snapshot)
listener(...args);
return true; return true;
} }
@@ -1,6 +1,6 @@
import { isString } from '../../../types'; import { isString } from '../../../types';
import { BaseStateMachine } from './base'; import { BaseStateMachine } from './base';
import type { AsyncStateNodeConfig, ExtractStates, ExtractEvents } from './types'; import type { AsyncStateNodeConfig, ExtractEvents, ExtractStates } from './types';
/** /**
* @name AsyncStateMachine * @name AsyncStateMachine
@@ -0,0 +1,24 @@
import { describe, expectTypeOf, it } from 'vitest';
import { createMachine } from '.';
describe('createMachine', () => {
const machine = createMachine({
initial: 'idle',
states: {
idle: { on: { START: 'running' } },
running: { on: { STOP: 'idle' } },
},
});
it('infers the state union from the states config', () => {
expectTypeOf(machine.current).toEqualTypeOf<'idle' | 'running'>();
});
it('infers the event union accepted by send', () => {
expectTypeOf(machine.send).parameter(0).toEqualTypeOf<'START' | 'STOP'>();
});
it('send returns the (typed) resulting state', () => {
expectTypeOf(machine.send('START')).toEqualTypeOf<'idle' | 'running'>();
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createMachine, createAsyncMachine, StateMachine, AsyncStateMachine } from '.'; import { AsyncStateMachine, StateMachine, createAsyncMachine, createMachine } from '.';
describe('stateMachine', () => { describe('stateMachine', () => {
describe('createMachine (without context)', () => { describe('createMachine (without context)', () => {
@@ -685,4 +685,23 @@ describe('asyncStateMachine', () => {
expect(entryHook).toHaveBeenCalledOnce(); expect(entryHook).toHaveBeenCalledOnce();
}); });
}); });
describe('transition into an unknown target state', () => {
it('silently moves to a target that has no state node, then dead-ends', () => {
const machine = createMachine({
initial: 'idle',
states: {
idle: { on: { GO: 'ghost' } }, // 'ghost' is not defined in states
},
});
// Documents current behavior: the transition is accepted, no throw.
expect(machine.send('GO')).toBe('ghost');
expect(machine.current).toBe('ghost');
// From the undefined state there are no transitions — further sends are no-ops.
expect(machine.send('GO')).toBe('ghost');
expect(machine.can('GO')).toBe(false);
});
});
}); });
@@ -1,6 +1,6 @@
import { isString } from '../../../types'; import { isString } from '../../../types';
import { BaseStateMachine } from './base'; import { BaseStateMachine } from './base';
import type { SyncStateNodeConfig, ExtractStates, ExtractEvents } from './types'; import type { ExtractEvents, ExtractStates, SyncStateNodeConfig } from './types';
/** /**
* @name StateMachine * @name StateMachine
@@ -32,6 +32,21 @@ describe('BinaryHeap', () => {
expect(heap.peek()).toBe(8); expect(heap.peek()).toBe(8);
}); });
it('should not mutate the input array', () => {
const input = [5, 3, 8, 1, 4];
new BinaryHeap(input);
expect(input).toEqual([5, 3, 8, 1, 4]);
});
it('should not overflow the stack for a very large initial array', () => {
const big = Array.from({ length: 200_000 }, (_, i) => 200_000 - i);
expect(() => new BinaryHeap(big)).not.toThrow();
expect(new BinaryHeap(big).peek()).toBe(1);
});
}); });
describe('push', () => { describe('push', () => {
@@ -226,4 +241,37 @@ describe('BinaryHeap', () => {
expect(heap.pop()).toBeUndefined(); expect(heap.pop()).toBeUndefined();
}); });
}); });
describe('nullable / falsy elements', () => {
it('peek/pop return a legitimately stored null root (not undefined)', () => {
// Comparator that ranks null before any number.
const heap = new BinaryHeap<number | null>([5, null, 3], {
comparator: (a, b) => (a === null ? -1 : b === null ? 1 : a - b),
});
expect(heap.length).toBe(3);
expect(heap.peek()).toBeNull();
expect(heap.pop()).toBeNull();
expect(heap.length).toBe(2);
});
it('peek returns a 0 root rather than collapsing to undefined', () => {
const heap = new BinaryHeap([0, 5, 3]);
expect(heap.peek()).toBe(0);
});
});
describe('async iteration', () => {
it('yields every element with the root (min) first', async () => {
const heap = new BinaryHeap([5, 3, 8, 1]);
const out: number[] = [];
for await (const value of heap)
out.push(value);
expect(out[0]).toBe(1); // heap array order — root first
expect([...out].sort((a, b) => a - b)).toEqual([1, 3, 5, 8]);
});
});
}); });
+8 -4
View File
@@ -1,4 +1,3 @@
import { first } from '../../arrays';
import { isArray } from '../../types'; import { isArray } from '../../types';
import type { BinaryHeapLike, Comparator } from './types'; import type { BinaryHeapLike, Comparator } from './types';
@@ -54,7 +53,11 @@ export class BinaryHeap<T> implements BinaryHeapLike<T> {
if (initialValues !== null && initialValues !== undefined) { if (initialValues !== null && initialValues !== undefined) {
const items = isArray(initialValues) ? initialValues : [initialValues]; const items = isArray(initialValues) ? initialValues : [initialValues];
this.heap.push(...items);
// Avoid push(...items): spreading a large array as arguments overflows the call stack.
for (const item of items)
this.heap.push(item);
this.heapify(); this.heapify();
} }
} }
@@ -91,7 +94,7 @@ export class BinaryHeap<T> implements BinaryHeapLike<T> {
public pop(): T | undefined { public pop(): T | undefined {
if (this.heap.length === 0) return undefined; if (this.heap.length === 0) return undefined;
const root = first(this.heap)!; const root = this.heap[0]!;
const last = this.heap.pop()!; const last = this.heap.pop()!;
if (this.heap.length > 0) { if (this.heap.length > 0) {
@@ -107,7 +110,8 @@ export class BinaryHeap<T> implements BinaryHeapLike<T> {
* @returns {T | undefined} The root element, or `undefined` if the heap is empty * @returns {T | undefined} The root element, or `undefined` if the heap is empty
*/ */
public peek(): T | undefined { public peek(): T | undefined {
return first(this.heap); // Direct index preserves a legitimately stored null/undefined root (length is the empty check).
return this.heap.length === 0 ? undefined : this.heap[0];
} }
/** /**
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CircularBuffer } from '.'; import { CircularBuffer } from '.';
describe('circularBuffer', () => { describe('circularBuffer', () => {
@@ -53,7 +53,8 @@ export class CircularBuffer<T> implements CircularBufferLike<T> {
const requested = Math.max(items.length, initialCapacity ?? 0); const requested = Math.max(items.length, initialCapacity ?? 0);
const cap = Math.max(MIN_CAPACITY, nextPowerOfTwo(requested)); const cap = Math.max(MIN_CAPACITY, nextPowerOfTwo(requested));
this.buffer = Array.from<T | undefined>({ length: cap }); // eslint-disable-next-line unicorn/no-new-array -- preallocate exact-size ring (Array.from({length}) is ~40x slower)
this.buffer = new Array<T | undefined>(cap);
for (const item of items) for (const item of items)
this.pushBack(item); this.pushBack(item);
@@ -190,7 +191,8 @@ export class CircularBuffer<T> implements CircularBufferLike<T> {
* @returns {this} * @returns {this}
*/ */
clear() { clear() {
this.buffer = Array.from<T | undefined>({ length: MIN_CAPACITY }); // eslint-disable-next-line unicorn/no-new-array -- preallocate exact-size ring (Array.from({length}) is ~40x slower)
this.buffer = new Array<T | undefined>(MIN_CAPACITY);
this.head = 0; this.head = 0;
this.count = 0; this.count = 0;
@@ -203,7 +205,8 @@ export class CircularBuffer<T> implements CircularBufferLike<T> {
* @returns {T[]} * @returns {T[]}
*/ */
toArray() { toArray() {
const result = Array.from<T>({ length: this.count }); // eslint-disable-next-line unicorn/no-new-array -- preallocate exact-size result (filled below; Array.from({length}) is ~40x slower)
const result = new Array<T>(this.count);
for (let i = 0; i < this.count; i++) for (let i = 0; i < this.count; i++)
result[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)] as T; result[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)] as T;
@@ -225,8 +228,12 @@ export class CircularBuffer<T> implements CircularBufferLike<T> {
* *
* @returns {IterableIterator<T>} * @returns {IterableIterator<T>}
*/ */
[Symbol.iterator]() { * [Symbol.iterator](): IterableIterator<T> {
return this.toArray()[Symbol.iterator](); // Lazy walk over the ring — no intermediate array snapshot allocation.
const mask = this.buffer.length - 1;
for (let i = 0; i < this.count; i++)
yield this.buffer[(this.head + i) & mask] as T;
} }
/** /**
@@ -246,7 +253,8 @@ export class CircularBuffer<T> implements CircularBufferLike<T> {
*/ */
private grow() { private grow() {
const newCapacity = this.buffer.length << 1; const newCapacity = this.buffer.length << 1;
const newBuffer = Array.from<T | undefined>({ length: newCapacity }); // eslint-disable-next-line unicorn/no-new-array -- preallocate exact-size ring on the amortized push hot path
const newBuffer = new Array<T | undefined>(newCapacity);
for (let i = 0; i < this.count; i++) for (let i = 0; i < this.count; i++)
newBuffer[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)]; newBuffer[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)];
+17 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Deque } from '.'; import { Deque } from '.';
describe('deque', () => { describe('deque', () => {
@@ -31,6 +31,22 @@ describe('deque', () => {
expect(deque.length).toBe(0); expect(deque.length).toBe(0);
expect(deque.isFull).toBe(false); expect(deque.isFull).toBe(false);
}); });
it('throw when initial values exceed maxSize', () => {
expect(() => new Deque([1, 2, 3, 4, 5], { maxSize: 3 })).toThrow(RangeError);
});
it('enforce maxSize against subsequent pushes', () => {
const deque = new Deque([1, 2], { maxSize: 2 });
expect(deque.isFull).toBe(true);
expect(() => deque.pushBack(3)).toThrow(RangeError);
expect(() => deque.pushFront(0)).toThrow(RangeError);
deque.popFront();
expect(deque.isFull).toBe(false);
expect(() => deque.pushBack(3)).not.toThrow();
});
}); });
describe('pushBack', () => { describe('pushBack', () => {
+4 -1
View File
@@ -42,6 +42,9 @@ export class Deque<T> implements DequeLike<T> {
constructor(initialValues?: T[] | T, options?: DequeOptions) { constructor(initialValues?: T[] | T, options?: DequeOptions) {
this.maxSize = options?.maxSize ?? Infinity; this.maxSize = options?.maxSize ?? Infinity;
this.buffer = new CircularBuffer(initialValues); this.buffer = new CircularBuffer(initialValues);
if (this.buffer.length > this.maxSize)
throw new RangeError('Deque: initial values exceed maxSize');
} }
/** /**
@@ -65,7 +68,7 @@ export class Deque<T> implements DequeLike<T> {
* @returns {boolean} `true` if the deque is full, `false` otherwise * @returns {boolean} `true` if the deque is full, `false` otherwise
*/ */
get isFull() { get isFull() {
return this.buffer.length === this.maxSize; return this.buffer.length >= this.maxSize;
} }
/** /**
@@ -98,6 +98,10 @@ describe('LinkedList', () => {
expect(list.popBack()).toBe(3); expect(list.popBack()).toBe(3);
expect(list.length).toBe(2); expect(list.length).toBe(2);
expect(list.peekBack()).toBe(2); expect(list.peekBack()).toBe(2);
// tail pointer must be rewired and detached
expect(list.tail!.value).toBe(2);
expect(list.tail!.next).toBeUndefined();
expect(list.peekFront()).toBe(1);
}); });
it('should handle single element', () => { it('should handle single element', () => {
@@ -123,6 +127,10 @@ describe('LinkedList', () => {
expect(list.popFront()).toBe(1); expect(list.popFront()).toBe(1);
expect(list.length).toBe(2); expect(list.length).toBe(2);
expect(list.peekFront()).toBe(2); expect(list.peekFront()).toBe(2);
// head pointer must be rewired and detached
expect(list.head!.value).toBe(2);
expect(list.head!.prev).toBeUndefined();
expect(list.peekBack()).toBe(3);
}); });
it('should handle single element', () => { it('should handle single element', () => {
@@ -170,11 +178,14 @@ describe('LinkedList', () => {
const list = new LinkedList<number>(); const list = new LinkedList<number>();
const node = list.pushBack(2); const node = list.pushBack(2);
list.insertBefore(node, 1); const inserted = list.insertBefore(node, 1);
expect(list.peekFront()).toBe(1); expect(list.peekFront()).toBe(1);
expect(list.peekBack()).toBe(2); expect(list.peekBack()).toBe(2);
expect(list.length).toBe(2); expect(list.length).toBe(2);
// new node becomes the head with no prev
expect(list.head).toBe(inserted);
expect(inserted.prev).toBeUndefined();
}); });
it('should insert before middle node', () => { it('should insert before middle node', () => {
@@ -202,10 +213,13 @@ describe('LinkedList', () => {
const list = new LinkedList<number>(); const list = new LinkedList<number>();
const node = list.pushBack(1); const node = list.pushBack(1);
list.insertAfter(node, 2); const inserted = list.insertAfter(node, 2);
expect(list.peekFront()).toBe(1); expect(list.peekFront()).toBe(1);
expect(list.peekBack()).toBe(2); expect(list.peekBack()).toBe(2);
// new node becomes the tail with no next
expect(list.tail).toBe(inserted);
expect(inserted.next).toBeUndefined();
expect(list.length).toBe(2); expect(list.length).toBe(2);
}); });
@@ -209,5 +209,17 @@ describe('PriorityQueue', () => {
expect(pq.dequeue()).toBe(10); expect(pq.dequeue()).toBe(10);
expect(pq.dequeue()).toBeUndefined(); expect(pq.dequeue()).toBeUndefined();
}); });
it('accept a new element after dequeue frees a slot in a full queue', () => {
const pq = new PriorityQueue([1, 2], { maxSize: 2 });
expect(pq.isFull).toBe(true);
expect(() => pq.enqueue(3)).toThrow(RangeError);
pq.dequeue();
expect(pq.isFull).toBe(false);
expect(() => pq.enqueue(3)).not.toThrow();
expect(pq.length).toBe(2);
});
}); });
}); });
+13 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Queue } from '.'; import { Queue } from '.';
describe('queue', () => { describe('queue', () => {
@@ -87,7 +87,7 @@ describe('queue', () => {
expect(queue.dequeue()).toBeUndefined(); expect(queue.dequeue()).toBeUndefined();
}); });
it('compact internal storage after many dequeues', () => { it('keep correct length and front element after many enqueue/dequeue cycles', () => {
const queue = new Queue<number>(); const queue = new Queue<number>();
for (let i = 0; i < 100; i++) for (let i = 0; i < 100; i++)
@@ -99,6 +99,17 @@ describe('queue', () => {
expect(queue.length).toBe(20); expect(queue.length).toBe(20);
expect(queue.peek()).toBe(80); expect(queue.peek()).toBe(80);
}); });
it('report isFull and free a slot after dequeue', () => {
const queue = new Queue<number>(undefined, { maxSize: 2 });
queue.enqueue(1).enqueue(2);
expect(queue.isFull).toBe(true);
queue.dequeue();
expect(queue.isFull).toBe(false);
expect(() => queue.enqueue(3)).not.toThrow();
});
}); });
describe('peek', () => { describe('peek', () => {
+47 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Stack } from '.'; import { Stack } from '.';
describe('stack', () => { describe('stack', () => {
@@ -33,6 +33,26 @@ describe('stack', () => {
expect(stack.length).toBe(0); expect(stack.length).toBe(0);
expect(stack.isFull).toBe(false); expect(stack.isFull).toBe(false);
}); });
it('keep a falsy single initial value', () => {
expect(new Stack(0).length).toBe(1);
expect(new Stack(0).peek()).toBe(0);
expect(new Stack('').peek()).toBe('');
});
it('copy the initial array (no aliasing of the caller array)', () => {
const initialValues = [1, 2, 3];
const stack = new Stack(initialValues);
initialValues.push(4);
expect(stack.length).toBe(3);
expect([...stack]).toEqual([3, 2, 1]);
});
it('throw when initial values exceed maxSize', () => {
expect(() => new Stack([1, 2, 3], { maxSize: 2 })).toThrow(RangeError);
});
}); });
describe('push', () => { describe('push', () => {
@@ -51,6 +71,16 @@ describe('stack', () => {
expect(() => stack.push(2)).toThrow(new RangeError('Stack is full')); expect(() => stack.push(2)).toThrow(new RangeError('Stack is full'));
}); });
it('report isFull at the boundary', () => {
const stack = new Stack<number>(undefined, { maxSize: 2 });
stack.push(1).push(2);
expect(stack.isFull).toBe(true);
stack.pop();
expect(stack.isFull).toBe(false);
});
}); });
describe('pop', () => { describe('pop', () => {
@@ -97,6 +127,22 @@ describe('stack', () => {
}); });
}); });
describe('toArray / toString', () => {
it('return elements in LIFO order as a distinct array', () => {
const stack = new Stack([1, 2, 3]);
const arr = stack.toArray();
expect(arr).toEqual([3, 2, 1]);
arr.push(99);
expect(stack.length).toBe(3);
});
it('stringify in LIFO order', () => {
expect(new Stack([1, 2, 3]).toString()).toBe('3,2,1');
});
});
describe('iteration', () => { describe('iteration', () => {
it('iterate over the stack in LIFO order', () => { it('iterate over the stack in LIFO order', () => {
const stack = new Stack<number>([1, 2, 3]); const stack = new Stack<number>([1, 2, 3]);
+21 -12
View File
@@ -1,4 +1,3 @@
import { last } from '../../arrays';
import { isArray } from '../../types'; import { isArray } from '../../types';
import type { StackLike } from './types'; import type { StackLike } from './types';
@@ -43,7 +42,17 @@ export class Stack<T> implements StackLike<T> {
*/ */
constructor(initialValues?: T[] | T, options?: StackOptions) { constructor(initialValues?: T[] | T, options?: StackOptions) {
this.maxSize = options?.maxSize ?? Infinity; this.maxSize = options?.maxSize ?? Infinity;
this.stack = isArray(initialValues) ? initialValues : initialValues ? [initialValues] : [];
// Copy the input so external mutation can't corrupt internal state, and use an
// explicit nullish check so falsy single values (0, '', false) are not dropped.
const values = isArray(initialValues)
? initialValues.slice()
: initialValues !== null && initialValues !== undefined ? [initialValues] : [];
if (values.length > this.maxSize)
throw new RangeError('Stack: initial values exceed maxSize');
this.stack = values;
} }
/** /**
@@ -67,7 +76,7 @@ export class Stack<T> implements StackLike<T> {
* @returns {boolean} `true` if the stack is full, `false` otherwise * @returns {boolean} `true` if the stack is full, `false` otherwise
*/ */
public get isFull() { public get isFull() {
return this.stack.length === this.maxSize; return this.stack.length >= this.maxSize;
} }
/** /**
@@ -98,10 +107,9 @@ export class Stack<T> implements StackLike<T> {
* @returns {T | undefined} The top element of the stack * @returns {T | undefined} The top element of the stack
*/ */
public peek() { public peek() {
if (this.isEmpty) // Direct index preserves a legitimately stored `undefined`/`null` top and
return undefined; // returns `undefined` for an empty stack (noUncheckedIndexedAccess).
return this.stack[this.stack.length - 1];
return last(this.stack);
} }
/** /**
@@ -138,8 +146,10 @@ export class Stack<T> implements StackLike<T> {
* *
* @returns {IterableIterator<T>} * @returns {IterableIterator<T>}
*/ */
public [Symbol.iterator]() { public* [Symbol.iterator]() {
return this.toArray()[Symbol.iterator](); // Lazy reverse walk — no eager reversed-copy allocation.
for (let i = this.stack.length - 1; i >= 0; i--)
yield this.stack[i]!;
} }
/** /**
@@ -148,8 +158,7 @@ export class Stack<T> implements StackLike<T> {
* @returns {AsyncIterableIterator<T>} * @returns {AsyncIterableIterator<T>}
*/ */
public async* [Symbol.asyncIterator]() { public async* [Symbol.asyncIterator]() {
for (const element of this.toArray()) { for (let i = this.stack.length - 1; i >= 0; i--)
yield element; yield this.stack[i]!;
}
} }
} }
+18 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SyncMutex } from '.'; import { SyncMutex } from '.';
describe('SyncMutex', () => { describe('SyncMutex', () => {
@@ -91,4 +91,21 @@ describe('SyncMutex', () => {
expect(mutex.isLocked).toBe(false); expect(mutex.isLocked).toBe(false);
}); });
it('releases the lock and rethrows when the callback throws synchronously', async () => {
await expect(mutex.execute(() => {
throw new Error('boom');
})).rejects.toThrow('boom');
expect(mutex.isLocked).toBe(false);
// The mutex must still be usable afterwards.
await expect(mutex.execute(() => Promise.resolve('ok'))).resolves.toBe('ok');
});
it('releases the lock and rejects when the callback returns a rejecting promise', async () => {
await expect(mutex.execute(() => Promise.reject(new Error('async boom')))).rejects.toThrow('async boom');
expect(mutex.isLocked).toBe(false);
});
}); });
+8 -3
View File
@@ -37,9 +37,14 @@ export class SyncMutex {
return; return;
this.lock(); this.lock();
const result = await callback();
this.unlock();
return result; // try/finally guarantees the lock is released even if the callback throws or
// rejects — otherwise a single failure would wedge the guarded section forever.
try {
return await callback();
}
finally {
this.unlock();
}
} }
} }
+1 -2
View File
@@ -1,4 +1,3 @@
export * from './levenshtein-distance'; export * from './levenshtein-distance';
export * from './template';
export * from './trigram-distance'; export * from './trigram-distance';
// TODO: Template is not implemented yet
// export * from './template';
@@ -29,4 +29,15 @@ describe('levenshteinDistance', () => {
expect(levenshteinDistance('abc', '')).toBe(3); expect(levenshteinDistance('abc', '')).toBe(3);
expect(levenshteinDistance('', 'abc')).toBe(3); expect(levenshteinDistance('', 'abc')).toBe(3);
}); });
it('is symmetric', () => {
expect(levenshteinDistance('kitten', 'sitting')).toBe(levenshteinDistance('sitting', 'kitten'));
expect(levenshteinDistance('football', 'foot')).toBe(levenshteinDistance('foot', 'football'));
});
it('counts UTF-16 code units (surrogate pairs count as two)', () => {
expect(levenshteinDistance('😀', '')).toBe(2); // surrogate pair = 2 code units
expect(levenshteinDistance('a😀b', 'a😀b')).toBe(0);
expect(levenshteinDistance('café', 'cafe')).toBe(1);
});
}); });
@@ -15,32 +15,34 @@ export function levenshteinDistance(left: string, right: string): number {
if (left.length === 0) return right.length; if (left.length === 0) return right.length;
if (right.length === 0) return left.length; if (right.length === 0) return left.length;
// Create empty edit distance matrix for all possible modifications of // Iterate with the shorter string as the inner dimension so the rolling rows are
// substrings of left to substrings of right // O(min(m, n)) memory instead of a full O(m * n) matrix.
const distanceMatrix = Array(right.length + 1).fill(null).map(() => Array(left.length + 1).fill(null)); const outer = left.length >= right.length ? left : right;
const inner = left.length >= right.length ? right : left;
const innerLength = inner.length;
// Fill the first row of the matrix // prev = previous row; current = row being computed. prev starts as the base row [0..innerLength].
// If this is the first row, we're transforming from an empty string to left let prev = Array.from({ length: innerLength + 1 }, (_, i) => i);
// In this case, the number of operations equals the length of left substring let current = Array.from<number>({ length: innerLength + 1 });
for (let i = 0; i <= left.length; i++)
distanceMatrix[0]![i]! = i;
// Fill the first column of the matrix for (let i = 1; i <= outer.length; i++) {
// If this is the first column, we're transforming empty string to right current[0] = i;
// In this case, the number of operations equals the length of right substring const outerChar = outer[i - 1];
for (let j = 0; j <= right.length; j++)
distanceMatrix[j]![0]! = j;
for (let j = 1; j <= right.length; j++) { for (let j = 1; j <= innerLength; j++) {
for (let i = 1; i <= left.length; i++) { const cost = outerChar === inner[j - 1] ? 0 : 1;
const indicator = left[i - 1] === right[j - 1] ? 0 : 1; current[j]! = Math.min(
distanceMatrix[j]![i]! = Math.min( prev[j]! + 1, // insertion
distanceMatrix[j]![i - 1]! + 1, // deletion current[j - 1]! + 1, // deletion
distanceMatrix[j - 1]![i]! + 1, // insertion prev[j - 1]! + cost, // substitution
distanceMatrix[j - 1]![i - 1]! + indicator, // substitution
); );
} }
// Swap the rolling rows; the freshly computed row becomes `prev` for the next iteration.
const next = prev;
prev = current;
current = next;
} }
return distanceMatrix[right.length]![left.length]!; return prev[innerLength]!;
} }
+35 -2
View File
@@ -1,7 +1,8 @@
import { describe, expectTypeOf, it } from 'vitest'; import { describe, expectTypeOf, it } from 'vitest';
import type { ClearPlaceholder, ExtractPlaceholders } from './index'; import type { ClearPlaceholder, ExtractPlaceholders, GenerateTypes } from './index';
import { templateObject } from './index';
describe.skip('template', () => { describe('template', () => {
describe('ClearPlaceholder', () => { describe('ClearPlaceholder', () => {
it('ignores strings without braces', () => { it('ignores strings without braces', () => {
type actual = ClearPlaceholder<'name'>; type actual = ClearPlaceholder<'name'>;
@@ -102,4 +103,36 @@ describe.skip('template', () => {
expectTypeOf<actual>().toEqualTypeOf<expected>(); expectTypeOf<actual>().toEqualTypeOf<expected>();
}); });
}); });
describe('GenerateTypes', () => {
type Shape = GenerateTypes<'user.name', string>;
it('accepts a fully-matching shape', () => {
expectTypeOf<{ user: { name: 'John' } }>().toExtend<Shape>();
});
it('accepts missing keys (every key is optional)', () => {
expectTypeOf<{ unrelated: number }>().toExtend<Shape>();
});
it('accepts extra keys (objects stay open)', () => {
expectTypeOf<{ user: { name: 'John' }; extra: number }>().toExtend<Shape>();
});
it('rejects a mistyped leaf value', () => {
expectTypeOf<{ user: { name: number } }>().not.toExtend<Shape>();
});
});
describe('templateObject', () => {
it('always returns a string', () => {
expectTypeOf(templateObject('Hello, {name}!', { name: 'John' })).toEqualTypeOf<string>();
expectTypeOf(templateObject('Hi {user.name}', { user: { name: 'John' } })).toEqualTypeOf<string>();
});
it('accepts a string or factory fallback', () => {
expectTypeOf(templateObject('Hello, {name}!', {}, 'Guest')).toEqualTypeOf<string>();
expectTypeOf(templateObject('Hello, {name}!', {}, key => `<${key}>`)).toEqualTypeOf<string>();
});
});
}); });

Some files were not shown because too many files have changed in this diff Show More