Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/deep-traversal-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cleverbrush/deep": minor
---

Add sequential deepForEachAsync traversal and stable in-place deepSort with typed options, explicit validation and failure propagation.
42 changes: 42 additions & 0 deletions libs/deep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node>(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.
120 changes: 120 additions & 0 deletions libs/deep/src/deepForEachAsync.test.ts
Original file line number Diff line number Diff line change
@@ -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<Node>(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<Node>(
[{ 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<Node>(
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<Node>([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);
});
});
64 changes: 64 additions & 0 deletions libs/deep/src/deepForEachAsync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { isPlainObject } from './data.js';

/** Child selection and subtree filtering for sequential record traversal. */
export interface DeepForEachAsyncOptions<T extends object> {
/** 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<string, unknown>,
TContext = undefined
>(
input: unknown,
visitor: (this: TContext, node: T) => unknown,
context?: TContext,
options: DeepForEachAsyncOptions<T> = {}
): Promise<void> {
const active = new Set<object>();
async function visit(value: unknown): Promise<void> {
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<string, unknown>)[key];
if (Array.isArray(child)) await visit(child);
}
} finally {
active.delete(value as object);
}
}
await visit(input);
}
39 changes: 39 additions & 0 deletions libs/deep/src/deepSort.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 42 additions & 0 deletions libs/deep/src/deepSort.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(
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;
}
5 changes: 5 additions & 0 deletions libs/deep/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading