From ec87c8f30ae337b09b456c7f872e27df2988da09 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Sat, 17 Oct 2026 11:06:53 +0000 Subject: [PATCH] feat(deep): add sequential traversal and stable path sorting --- .changeset/deep-traversal-sort.md | 5 ++ libs/deep/README.md | 42 +++++++++ libs/deep/src/deepForEachAsync.test.ts | 120 +++++++++++++++++++++++++ libs/deep/src/deepForEachAsync.ts | 64 +++++++++++++ libs/deep/src/deepSort.test.ts | 39 ++++++++ libs/deep/src/deepSort.ts | 42 +++++++++ libs/deep/src/index.ts | 5 ++ 7 files changed, 317 insertions(+) create mode 100644 .changeset/deep-traversal-sort.md create mode 100644 libs/deep/src/deepForEachAsync.test.ts create mode 100644 libs/deep/src/deepForEachAsync.ts create mode 100644 libs/deep/src/deepSort.test.ts create mode 100644 libs/deep/src/deepSort.ts diff --git a/.changeset/deep-traversal-sort.md b/.changeset/deep-traversal-sort.md new file mode 100644 index 00000000..64991ab9 --- /dev/null +++ b/.changeset/deep-traversal-sort.md @@ -0,0 +1,5 @@ +--- +"@cleverbrush/deep": minor +--- + +Add sequential deepForEachAsync traversal and stable in-place deepSort with typed options, explicit validation and failure propagation. diff --git a/libs/deep/README.md b/libs/deep/README.md index f8c20a5e..2a9e676b 100644 --- a/libs/deep/README.md +++ b/libs/deep/README.md @@ -208,3 +208,45 @@ const hash = HashObject({ name: 'John', age: 30 }); ## License BSD-3-Clause + +### `deepForEachAsync(input, visitor, context?, options?)` + +Visits plain records sequentially in parent-before-child order, awaiting each +visitor before continuing. Arrays, including nested arrays, are containers; +primitives, dates and custom instances are ignored. By default traversal follows +all own array-valued fields on a record. `options.optimizer` selects one such +field instead. Ordinary object-valued fields are not child containers. + +```ts +import { deepForEachAsync } from '@cleverbrush/deep'; + +type Node = { name: string; children?: Node[] }; +await deepForEachAsync(tree, async node => { + await saveNode(node.name); +}, undefined, { optimizer: 'children' }); +``` + +`options.validation(node)` returning false skips that record and its subtree, +while later siblings are still visited. `context` is the visitor's `this` value. +Visitor/validation errors reject the returned promise immediately. Repeated +references are visited at each occurrence; a cycle on the active traversal path +rejects with `TypeError`. Traversal itself does not copy or mutate input; visitors +may mutate the records, and child arrays are read after their parent visitor. + +### `deepSort(items, index, field)` + +Stably sorts and returns the input array in place, using the numeric element at +`item[field][index]`. Equal keys preserve their original order and object identity. + +```ts +import { deepSort } from '@cleverbrush/deep'; + +const nodes = [{ position: [0, 3] }, { position: [0, 1] }]; +deepSort(nodes, 1, 'position'); +// nodes is now [{ position: [0, 1] }, { position: [0, 3] }] +``` + +`index` must be a non-negative integer and exist in every selected field, or a +`RangeError` is thrown. Fields must be arrays containing finite numeric sort keys, +or a `TypeError` is thrown. All keys are checked before the array is changed. +Empty input is valid; path depth is independent of the number of records. diff --git a/libs/deep/src/deepForEachAsync.test.ts b/libs/deep/src/deepForEachAsync.test.ts new file mode 100644 index 00000000..14bb29f6 --- /dev/null +++ b/libs/deep/src/deepForEachAsync.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { deepForEachAsync } from './deepForEachAsync.js'; + +type Node = { id: string; children?: Node[]; other?: Node[] }; +describe('deepForEachAsync', () => { + it('awaits each parent and child before starting the next sibling', async () => { + const events: string[] = []; + const input = [{ id: 'a', children: [{ id: 'b' }] }, [{ id: 'c' }]]; + await deepForEachAsync(input, async node => { + events.push('start:' + node.id); + await Promise.resolve(); + events.push('end:' + node.id); + }); + expect(events).toEqual([ + 'start:a', + 'end:a', + 'start:b', + 'end:b', + 'start:c', + 'end:c' + ]); + expect(input[0]).toEqual({ id: 'a', children: [{ id: 'b' }] }); + }); + it('uses only the selected own child array and binds context', async () => { + const context = { ids: [] as string[] }; + await deepForEachAsync( + { id: 'a', children: [{ id: 'b' }], other: [{ id: 'c' }] }, + function (this: typeof context, node: Node) { + this.ids.push(node.id); + }, + context, + { optimizer: 'children' } + ); + expect(context.ids).toEqual(['a', 'b']); + }); + it('skips filtered subtrees but keeps visiting later siblings', async () => { + const ids: string[] = []; + await deepForEachAsync( + [{ id: 'skip', children: [{ id: 'hidden' }] }, { id: 'keep' }], + node => ids.push(node.id), + undefined, + { validation: node => node.id !== 'skip' } + ); + expect(ids).toEqual(['keep']); + }); + it('ignores primitives, null, dates and custom objects', async () => { + const seen: object[] = []; + class Opaque {} + await deepForEachAsync( + [ + 1, + null, + undefined, + 'text', + new Date(), + new Opaque(), + { id: 'ok' } + ], + node => seen.push(node) + ); + expect(seen).toEqual([{ id: 'ok' }]); + }); + it('ignores ordinary object-valued properties and inherited arrays', async () => { + const input = { id: 'root', nested: { id: 'not-a-child' } }; + const ids: string[] = []; + await deepForEachAsync( + input, + node => ids.push(node.id), + undefined, + { optimizer: 'children' } + ); + expect(ids).toEqual(['root']); + const inherited = Object.create({ children: [{ id: 'inherited' }] }); + await deepForEachAsync(inherited, () => { + throw new Error('opaque prototype'); + }); + }); + it('visits null-prototype records and repeated references', async () => { + const shared = Object.assign(Object.create(null), { id: 'shared' }); + const ids: string[] = []; + await deepForEachAsync([shared, shared], node => + ids.push(node.id) + ); + expect(ids).toEqual(['shared', 'shared']); + }); + it('rejects record and array cycles', async () => { + const record: Node = { id: 'cycle' }; + record.children = [record]; + const array: unknown[] = []; + array.push(array); + for (const input of [record, array]) { + await expect(deepForEachAsync(input, () => {})).rejects.toThrow( + TypeError + ); + } + }); + it('propagates synchronous and asynchronous visitor failures', async () => { + const failure = new Error('visitor failed'); + for (const visitor of [ + () => { + throw failure; + }, + async () => { + throw failure; + } + ]) { + await expect(deepForEachAsync([{}], visitor)).rejects.toBe(failure); + } + }); + it('propagates validation failures', async () => { + const failure = new Error('validation failed'); + await expect( + deepForEachAsync({}, () => {}, undefined, { + validation: () => { + throw failure; + } + }) + ).rejects.toBe(failure); + }); +}); diff --git a/libs/deep/src/deepForEachAsync.ts b/libs/deep/src/deepForEachAsync.ts new file mode 100644 index 00000000..479d6039 --- /dev/null +++ b/libs/deep/src/deepForEachAsync.ts @@ -0,0 +1,64 @@ +import { isPlainObject } from './data.js'; + +/** Child selection and subtree filtering for sequential record traversal. */ +export interface DeepForEachAsyncOptions { + /** Traverse only this array-valued field; otherwise visit all own array fields. */ + optimizer?: keyof T & string; + /** False skips a record and its descendants, without skipping later siblings. */ + validation?: (node: T) => boolean; +} + +/** + * Visit plain records sequentially, parent before children, awaiting each visitor. + * + * Arrays, including nested arrays, are containers; primitives and opaque objects + * are ignored. Record children are selected from own array-valued properties. + * A shared record is visited at each occurrence; cycles on the active traversal + * path reject with TypeError. This helper does not copy or mutate input, though + * the visitor may mutate its records. Visitor failures reject immediately. + * + * @param input A record or an arbitrarily nested array of records. + * @param visitor Synchronous or asynchronous work for each accepted record. + * @param context Optional visitor `this` value. + * @param options Child-array selection and subtree filtering. + */ +export async function deepForEachAsync< + T extends object = Record, + TContext = undefined +>( + input: unknown, + visitor: (this: TContext, node: T) => unknown, + context?: TContext, + options: DeepForEachAsyncOptions = {} +): Promise { + const active = new Set(); + async function visit(value: unknown): Promise { + const array = Array.isArray(value); + if (!array && !isPlainObject(value)) return; + const node = value as T; + if (!array && options.validation && !options.validation(node)) return; + if (active.has(value as object)) { + throw new TypeError('Cannot traverse circular data.'); + } + active.add(value as object); + try { + if (array) { + for (const child of value as unknown[]) await visit(child); + return; + } + await visitor.call(context as TContext, node); + const keys = + options.optimizer === undefined + ? Object.keys(node) + : [options.optimizer]; + for (const key of keys) { + if (!Object.hasOwn(node, key)) continue; + const child = (node as Record)[key]; + if (Array.isArray(child)) await visit(child); + } + } finally { + active.delete(value as object); + } + } + await visit(input); +} diff --git a/libs/deep/src/deepSort.test.ts b/libs/deep/src/deepSort.test.ts new file mode 100644 index 00000000..1528e06b --- /dev/null +++ b/libs/deep/src/deepSort.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { deepSort } from './deepSort.js'; + +describe('deepSort', () => { + it('sorts in place and preserves ties and object identity', () => { + const a = { path: [3, 2], id: 'a' }; + const b = { path: [1, 1], id: 'b' }; + const c = { path: [0, 2], id: 'c' }; + const input = [a, b, c]; + expect(deepSort(input, 1, 'path')).toBe(input); + expect(input).toEqual([b, a, c]); + expect(input[1]).toBe(a); + }); + it('validates depth against each path rather than record count', () => { + const input = [{ path: [8, 4, 1] }]; + expect(deepSort(input, 2, 'path')).toEqual(input); + expect(deepSort([], 5, 'path')).toEqual([]); + }); + it.each([-1, 0.5, NaN, Infinity])('rejects invalid index %s', index => { + expect(() => deepSort([{ path: [1] }], index, 'path')).toThrow( + RangeError + ); + }); + it('rejects an out-of-bounds path before mutating', () => { + const input = [{ path: [2, 0] }, { path: [1] }]; + const before = [...input]; + expect(() => deepSort(input, 1, 'path')).toThrow(RangeError); + expect(input).toEqual(before); + }); + it.each([undefined, null, 1, {}, 'value', [NaN], [Infinity], ['1']])( + 'rejects invalid sort field %s', + path => { + expect(() => deepSort([{ path }], 0, 'path')).toThrow(TypeError); + } + ); + it('rejects non-array input', () => { + expect(() => deepSort(null as never, 0, 'path')).toThrow(TypeError); + }); +}); diff --git a/libs/deep/src/deepSort.ts b/libs/deep/src/deepSort.ts new file mode 100644 index 00000000..3e48481e --- /dev/null +++ b/libs/deep/src/deepSort.ts @@ -0,0 +1,42 @@ +/** + * Stably sort records in place by one numeric position in an array-valued field. + * + * All keys are validated before changing the input. An invalid index rejects + * with RangeError; missing/non-array fields and non-finite keys reject with + * TypeError. Empty input is valid. Equal keys retain their original order. + * + * @example + * ```ts + * deepSort([{ path: [2, 0] }, { path: [1, 4] }], 0, 'path'); + * // => [{ path: [1, 4] }, { path: [2, 0] }] + * ``` + */ +export function deepSort( + items: T[], + index: number, + field: keyof T +): T[] { + if (!Number.isSafeInteger(index) || index < 0) { + throw new RangeError('index must be a non-negative integer.'); + } + if (!Array.isArray(items)) throw new TypeError('items must be an array.'); + const sorted = items.map(item => { + const values = item?.[field]; + if (!Array.isArray(values)) { + throw new TypeError('The selected field must be an array.'); + } + if (index >= values.length) { + throw new RangeError('index exceeds a selected field length.'); + } + const key = values[index]; + if (typeof key !== 'number' || !Number.isFinite(key)) { + throw new TypeError('Sort keys must be finite numbers.'); + } + return { item, key }; + }); + sorted.sort((a, b) => a.key - b.key); + sorted.forEach(({ item }, position) => { + items[position] = item; + }); + return items; +} diff --git a/libs/deep/src/index.ts b/libs/deep/src/index.ts index 6d58eb19..e6bde5f9 100644 --- a/libs/deep/src/index.ts +++ b/libs/deep/src/index.ts @@ -3,6 +3,11 @@ import { deepEqual } from './deepEqual.js'; import { deepExtend, type Merge, type MergeTwo } from './deepExtend.js'; import { deepFlatten } from './deepFlatten.js'; +export { + type DeepForEachAsyncOptions, + deepForEachAsync +} from './deepForEachAsync.js'; +export { deepSort } from './deepSort.js'; export { deepClone, deepEqual,