|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | + |
| 5 | +## Build, test, lint |
| 6 | + |
| 7 | +The project is pure ESM JavaScript with **types written in JSDoc** (no TypeScript source files). `.d.ts` declarations are emitted from JSDoc by `tsc -p declaration.tsconfig.json` at publish time only — do not commit them. |
| 8 | + |
| 9 | +- `npm test` — full check chain (lint, tsc, knip, type-coverage, installed-check) followed by mocha + c8 coverage. This is what the pre-push husky hook runs; if it fails, fix the cause rather than `--no-verify`. |
| 10 | +- `npm run check` — only the static checks (lint + tsc + knip + type-coverage + installed-check), no tests. |
| 11 | +- `npx mocha test/<name>.spec.js` — run a single spec file. |
| 12 | +- `npx mocha test/<name>.spec.js -g "<pattern>"` — filter to specific `it()` blocks within a file. |
| 13 | +- `npm run build` — clean and emit `.d.ts` declarations. |
| 14 | + |
| 15 | +Type-coverage is enforced at **≥95% strict** (excluding `test/*.spec.js`). Lint is `@voxpelli/eslint-config` (neostandard). Knip's "unused devDependency" findings are treated as errors by `npm test`. |
| 16 | + |
| 17 | +Commits must follow Conventional Commits (validated by the `commit-msg` husky hook via `validate-conventional-commit`); `release-please` cuts releases automatically from `main`, so `feat:` bumps minor and `fix:` bumps patch. |
| 18 | + |
| 19 | +Engines: Node ≥22.0.0 (the well-known `Symbol.asyncDispose` is required natively). The CI matrix in `.github/workflows/nodejs.yml` should match. |
| 20 | + |
| 21 | +## Architecture |
| 22 | + |
| 23 | +The library is one core function (`bufferedAsyncMap`) plus a thin wrapper (`mergeIterables`). Everything lives in `index.js`; `lib/` contains three small helpers worth knowing about. |
| 24 | + |
| 25 | +### `bufferedAsyncMap(input, callback, options)` — the state machine |
| 26 | + |
| 27 | +The function returns a stateful `AsyncIterableIterator` with these closure variables forming the state machine: |
| 28 | + |
| 29 | +- **`bufferedPromises[]`** — in-flight promises (size capped at `bufferSize`). Each is the `callback(item, {signal})` result wrapped to never reject (errors are caught into `{err}` envelopes). |
| 30 | +- **`subIterators[]`** — stack of nested iterators spawned when `callback` returns an `AsyncIterable<R>` (async-generator callbacks). |
| 31 | +- **`promisesToSourceIteratorMap`** — WeakMap tracking which iterator produced each buffer slot; consulted by `findLeastTargeted` (`lib/find-least-targeted.js`) for load-balancing. |
| 32 | +- **`internalAC`** — an `AbortController` minted per call. Its signal is **always** the second arg to `callback`, regardless of whether the consumer passed `options.signal`. It fires from `markAsEnded()` on iterator close, from `options.signal` aborting (linked via `addEventListener('abort', …)`), and from the first error in `errors: 'fail-fast'` mode. This is what lets in-flight callbacks fast-path on shutdown. |
| 33 | +- **`abortReason: { reason, delivered: boolean } | undefined`** — drives the "reject the next `.next()` once with `signal.reason`, then `done:true` forever" contract. Set by external abort, pre-aborted signal, or first fail-fast error. |
| 34 | +- **`capturedErrors[]`** — accumulates errors in `'fail-eventually'` mode; on drain, throws the single error directly (identity-preserved) or wraps in `AggregateError` for ≥2. |
| 35 | +- **`isDone`** — set once by `markAsEnded()` to make all close paths idempotent. |
| 36 | + |
| 37 | +### Two pull/dispatch loops |
| 38 | + |
| 39 | +`fillQueue()` is the **producer**: pulls from source up to `bufferSize`, dispatches via `callback(item, {signal})`, pushes the wrapped promise into `bufferedPromises`. In `ordered: true` mode it always feeds from `subIterators[0]`; in `ordered: false` it picks the least-targeted iterator via `findLeastTargeted` to prevent starvation. |
| 40 | + |
| 41 | +`nextValue()` is the **consumer**: races `bufferedPromises[0]` (ordered) or `Promise.race(bufferedPromises)` (unordered) against an abort sentinel from `internalAC.signal`. Abort always wins over a buffered value resolving in the same tick — the post-race code re-checks `abortReason` regardless of which promise won the race. |
| 42 | + |
| 43 | +`markAsEnded()` is the **single cleanup path**: sets `isDone`, fires `internalAC.abort()`, calls `Promise.allSettled(...iterators.map(it => it.return()))`, clears buffers. Called from `return()`, `throw()`, `Symbol.asyncDispose`, source-exhaustion, and abort delivery. Idempotent via the `isDone` guard. |
| 44 | + |
| 45 | +### Iterator chaining via `currentStep` |
| 46 | + |
| 47 | +`next()` chains each call's promise via `currentStep.then(nextValue, nextValue)` (both fulfilled and rejected handlers are `nextValue`) so that one rejection doesn't poison every subsequent call — the next call still re-enters `nextValue`, which then observes the post-rejection state machine (most often returning `{done:true}`). |
| 48 | + |
| 49 | +### Lib helpers (reuse these, don't reimplement) |
| 50 | + |
| 51 | +- `lib/find-least-targeted.js` — load-balancing: given a list of iterators and the current buffer, picks the iterator with fewest in-flight slots. |
| 52 | +- `lib/misc.js` — `makeIterableAsync(input)` (sync iterable → async iterable) and `arrayDeleteInPlace(list, value)` (in-place splice by value). |
| 53 | +- `lib/type-checks.js` — `isAsyncIterable`, `isIterable`, `isPartOfArray` guards. |
| 54 | + |
| 55 | +### Public-API contracts worth preserving |
| 56 | + |
| 57 | +- Callback receives `(item, { signal })` where `signal` is **always present** (the internal one) even when no `options.signal` is provided. |
| 58 | +- Aborts cancel **consumption**, not in-flight callback work. Promises cannot be cancelled — the library propagates the signal so user code can voluntarily exit; it does not race-and-discard. The README documents this explicitly. |
| 59 | +- `errors: 'fail-eventually'` (default) keeps the historical "drain then throw" semantics; `'fail-fast'` mirrors `Promise.all`. External abort always wins over queued/captured errors. |
| 60 | +- Existing one-arg callbacks (`async (item) => …`) keep working — JS ignores extra args, so the second-arg widening is non-breaking. |
| 61 | + |
| 62 | +## Test conventions |
| 63 | + |
| 64 | +Mocha + chai + sinon. Tests use `sinon.useFakeTimers()` plus `clock.runAllAsync()` / `clock.tickAsync(ms)` for deterministic timing. The standard pattern for an async flow that needs the clock to advance is: |
| 65 | + |
| 66 | +```js |
| 67 | +const flow = (async () => { for await (...) { ... } })(); |
| 68 | +await clock.runAllAsync(); |
| 69 | +await flow; |
| 70 | +``` |
| 71 | + |
| 72 | +Inline `for await` blocks **without** the IIFE wrapper will deadlock under fake timers when the source uses real `setTimeout`. Test helpers in `test/utils.js` (`yieldValuesOverTime`, `nestedYieldValuesOverTime`, `promisableTimeout`) are the source of truth — reuse them. |
| 73 | + |
| 74 | +For testing rejections, prefer the `.catch(err => ({ rejectedWith: err }))` envelope pattern (used across `test/abort.spec.js` and `test/errors-fail-fast.spec.js`) over chai-as-promised's `should.be.rejectedWith` when asserting identity-equality on non-Error reasons. |
0 commit comments