Skip to content

v3.0.0: add settle/map/mapSettled and AbortSignal support#3

Merged
Acro merged 8 commits into
masterfrom
claude/npm-package-review-0xjzp8
Jul 12, 2026
Merged

v3.0.0: add settle/map/mapSettled and AbortSignal support#3
Acro merged 8 commits into
masterfrom
claude/npm-package-review-0xjzp8

Conversation

@Acro

@Acro Acro commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Expands the API to 3.0.0 while keeping the v2 surface: the default export and the parallel(jobs, limit) call shape are unchanged; everything new is additive. Follows up on #2 (merged).

New named exports

Function Purpose
settle(jobs, limit?, options?) Concurrency-limited Promise.allSettled — resolves to per-job { status, value | reason } in input order; never rejects because of a failing job
map(items, mapper, limit?, options?) Map over data with (item, index) => value | Promise<value> instead of pre-built thunks; fail-fast, results in input order
mapSettled(items, mapper, limit?, options?) Settle-all variant of map

parallel remains the default export and is also re-exported by name. Both CJS and ESM are first-class — an exports map routes each module system to the right entry, so import parallel from 'await-parallel-limit' works in bare Node ESM.

New options argument (3rd positional, { signal })

AbortSignal support across all four variants:

  • An already-aborted signal rejects immediately, before any job starts.
  • Aborting mid-run — including synchronously from inside a job during worker startup — rejects with the signal's reason (or a default AbortError for minimal signals without reason support) and stops scheduling new jobs.
  • In-flight jobs run to completion (JS can't force-cancel them); their results are discarded and a late straggler rejection is absorbed rather than surfacing as an unhandled rejection.
  • The abort listener is removed on completion and on abort — verified by listener add/remove counting in tests.

Hardened by two review passes + verification + bulletproofing

Review pass 1 fixed fail-fast semantics (a rejection now stops new jobs from starting) and made map/mapSettled reject on non-array input instead of throwing synchronously.

Review pass 2 (8-angle deep review, every finding independently verified) surfaced 10 findings, all fixed — highlights: a critical sync-abort hole (a job aborting the signal synchronously made all variants fulfill with a holey array — fixed via post-attach re-check + one unified stopped flag); sparse-array holes now reach the mapper as undefined; workers invoke the mapper/thunk directly (100k-item map: ~185ms → ~13ms, no O(N) thunk array); abort machinery simplified; engines raised to >=16.14; stub-signal tests pin the fallback AbortError and listener add/remove symmetry.

End-to-end verification at the package boundary (packed tarball → fresh consumer): full behavioral battery plus the typings floor pinned at TS ≥ 3.4 (verified with TS 3.4.5 and latest). Recipe persisted in .claude/skills/verify/SKILL.md.

Bulletproofing pass:

  • Fixed an Infinity regression vs published 2.1.0 (v2 treated Infinity as unbounded; validation had silently degraded it to 5).
  • Input-length snapshot — mutating the input array mid-run can't change the result set.
  • Seeded differential fuzzer (test/fuzz.cjs): randomized variants/sizes/limits/failures/aborts vs a per-job reference model — 2,000 scenarios green under --unhandled-rejections=strict (500 per CI run).
  • Exact-type regression tests (test/types.test.ts) — tuple inference and settle narrowing are now build-gated.
  • CI: exact engines floor ('16.14') through 24.x; fuzz job; package-boundary job (tarball → consumer → smoke + TS 3.4.5/latest gates).
  • README rewritten around explicit Guarantees.

Agent-friendly packaging

  • ESM exports map + static wrapper (esm/index.mjs re-exports the CJS build — one implementation, no dual-package hazard). The previously documented bare-Node-ESM default-import trap is fixed, not just documented. Types route per condition (esm/index.d.mts / dist/index.d.ts); legacy TS resolvers use main/types, so the TS 3.4 floor holds (gated in CI, including a node16-resolution check).
  • llms.txt ships in the npm tarball — complete API, semantics, recipes, and gotchas (thunks-not-promises, settle rejection rules, abort scope) in one context-window-sized file at node_modules/await-parallel-limit/llms.txt.
  • AGENTS.md + CLAUDE.md — build/test/fuzz commands, architecture map, and frozen invariants for agents working on the repo.
  • @example JSDoc on all four functions — flows into dist/index.d.ts for hover docs and d.ts-reading agents.
  • Richer npm metadata — sharper description and expanded keywords (promise-pool, p-limit, allsettled, rate-limit, abortsignal, …).
  • ESM boundary smoke (test/boundary/smoke.mjs) runs in CI beside the CJS smoke: asserts the default import is the function and drives the full surface.
  • Provenance publish workflow (.github/workflows/publish.yml) — release-triggered npm publish --provenance via OIDC, guarded by a tag↔version check and the full gate (unit + type suite, 500 fuzz scenarios, CJS+ESM tarball smokes) before anything ships. Requires an NPM_TOKEN automation-token secret.

Internals

  • One worker-pool core for all variants: sustained concurrency (sliding pool, not batches), index-ordered result writes, direct invoke(items[i], i) dispatch.
  • AbortSignalLike and SettledResult are declared locally, so the package compiles without DOM/es2020 libs and stays zero-dependency.

Backward compatibility

  • parallel(tasks, 30) (the exact @paperbits/core call shape) verified against published 2.1.0 head-to-head: happy path identical; error path rejects with the same error at the same point; Infinity parity restored.
  • One deliberate divergence, disclosed in the README: after a fail-fast rejection, remaining not-yet-started jobs are abandoned instead of continuing in the background.
  • Tuple return-type inference for as const job arrays preserved and regression-locked by test/types.test.ts.
  • As a major bump, Paperbits' ^2.1.0 range will not auto-adopt 3.0.0 — they opt in by widening their range.

Testing

  • npm test32/32 unit tests + exact-type gate, under --unhandled-rejections=strict.
  • npm run fuzz → seeded differential fuzzing; 2,000 scenarios validated locally, 500 per CI run.
  • CI package-boundary job → CJS + ESM tarball smoke tests + TS 3.4.5/latest/node16 typings gates.
  • Release path re-runs the full gate before npm publish --provenance.
  • Benchmarks: 100k-item map ~13–17ms. Tarball: 8 files (dist, esm, llms.txt, README, LICENSE, package.json).

🤖 Generated with Claude Code

claude added 8 commits July 12, 2026 15:30
…tible)

Expands the API while keeping v2 fully working — the default export and the
`parallel(jobs, limit)` call shape are unchanged; everything new is additive.

New named exports:
- `settle(jobs, limit?, options?)` — concurrency-limited `Promise.allSettled`;
  returns per-job `{ status, value | reason }` in input order, never rejects on
  a failing job.
- `map(items, mapper, limit?, options?)` — map over data with a
  `(item, index) => value | Promise<value>` mapper instead of pre-built thunks;
  fail-fast, results in input order.
- `mapSettled(items, mapper, limit?, options?)` — settle-all variant of `map`.

New options (3rd positional arg, `{ signal }`):
- `AbortSignal` support across all variants. An already-aborted signal rejects
  immediately; aborting mid-run rejects with the signal's reason and stops
  scheduling new jobs. In-flight jobs run to completion (their results are
  discarded) and a late straggler rejection is swallowed rather than surfacing
  as an unhandled rejection.

Internals:
- All four variants share one worker-pool core (sustained concurrency, ordered
  writes). `AbortSignalLike` and `SettledResult` are declared locally so the
  package still needs no DOM/es2020 lib and stays zero-dependency.

Tests: suite grows to 22 cases covering settle/map/mapSettled, sync mappers,
abort (pre-aborted, mid-flight, default AbortError, settle-honours-abort, and
no-leak on a non-aborted signal). Legacy `parallel(jobs, 30)` verified
byte-for-byte identical to published 2.1.0 on the @paperbits/core call shape.

README documents the full v3 surface and the 2.x-compatibility note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
…bad input

Two fixes from the PR #3 deep review:

1. Fail-fast now stops starting new jobs. Previously only the worker that hit
   the rejection died; the surviving workers kept pulling fresh jobs until the
   array drained, contradicting the documented behaviour ("jobs already in
   flight run to completion"). A shared `stopped` flag is now set on the first
   rejection and checked in the worker loop alongside `signal.aborted`, so
   not-yet-started jobs are abandoned once the caller has the rejection.
   This is a deliberate divergence from 2.x background side effects; the
   observable API (same rejection, same error, ordered results) is unchanged.

2. map/mapSettled now reject on non-array input instead of throwing
   synchronously, matching parallel/settle so `.catch()` protects callers on
   all four variants.

Tests grow to 26: no-new-jobs-after-rejection, reject-not-throw on non-array
for map/mapSettled, map honours abort, settle on empty input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
…, coverage

Correctness:
- Sync-abort hole (critical): the abort listener was attached after workers
  had synchronously started, and real signals fire no 'abort' event for late
  listeners — a job aborting the signal synchronously made all four variants
  FULFILL with a holey results array instead of rejecting. Fixed by re-checking
  signal.aborted immediately after attaching the listener, and unifying
  cancellation into the single `stopped` flag (set by both the fail-fast catch
  and the abort listener).
- Sparse-array inputs: map/mapSettled built thunks via items.map(), which
  skips holes, so jobs[i]() threw an internal TypeError presented as the
  item's outcome. Workers now invoke mapper(items[i], i) directly; holes reach
  the mapper as undefined, matching Promise.all semantics.
- Published typings: AbortSignalLike's `reason` is now `any` instead of
  `unknown`, keeping the d.ts compilable on pre-3.0 TypeScript.

Performance / structure:
- run() takes (items, invoke) and calls the mapper/thunk directly in the
  worker loop instead of materialising a closure + extra promise per element:
  map over 100k items drops from ~185ms to ~13ms and no longer retains an
  O(N) throwaway thunk array. This also deletes the map/mapSettled
  duplication — all four variants are now one-line wrappers over run(), and
  the input-validation message has a single owner.
- Abort machinery simplified from four interacting pieces (sentinel promise,
  definite-assignment assertion, Promise.race, standalone swallow-catch) to
  one wrapped promise whose rejection handler doubles as the straggler
  absorber; the provably-dead all.catch() is gone.

Docs / packaging:
- README Compatibility section now discloses the deliberate fail-fast change
  (3.x abandons not-yet-started jobs after a rejection; 2.x kept executing
  them in the background) and points side-effect-dependent callers at settle.
- engines raised to >=16.14 — the test suite requires AbortController with
  abort(reason)/signal.reason; the previous ">=12" claim was untestable.

Tests grow to 30 with a stub AbortSignalLike that pins the previously
uncovered paths: library-fallback AbortError for reason-less signals,
listener add/remove symmetry after completion and after abort, sync-abort
rejection, and sparse-hole mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
…syntax)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
…+ CI gates

Correctness:
- limit=Infinity regression vs published 2.1.0: v2's `limit || 5` treated
  Infinity as unbounded (all jobs at once); normalizeLimit silently degraded
  it to 5. Infinity now means unbounded again (verified head-to-head: v2 peak
  12/12, v3 was 5, now 12). Documented in README.
- Input-length snapshot: items.length is captured at call time, so mutating
  the input array mid-run can no longer grow the result set or (worse) shrink
  it into a silently holey fulfilled array. Elements stay lazily read.

Enforcement:
- test/fuzz.cjs: seeded (xorshift32, reproducible-by-seed) differential
  fuzzer — randomized variants, sizes, limits (incl. Infinity and junk),
  delays, failures, sync throws, and aborts (pre/timed/sync-from-a-job) —
  checking concurrency ceiling, exact reference results/outcomes, legal
  rejection provenance, and nothing-starts-after-settle. 2,000 scenarios
  green under --unhandled-rejections=strict.
- test/types.test.ts: exact-type (not assignability) assertions for tuple
  inference, settle narrowing, map/mapSettled generics, and the literal
  DEFAULT_CONCURRENCY — compiled by npm test, so inference regressions fail
  the build.
- npm test now runs the unit suite under --unhandled-rejections=strict.
- CI: matrix gains the exact engines floor ('16.14') and 24.x; new fuzz job
  (500 scenarios); new package-boundary job that packs the tarball, installs
  it into a fresh consumer, runs test/boundary/smoke.cjs against the
  installed package, and compiles test/boundary/consumer.ts with both
  TypeScript 3.4.5 (the documented floor) and typescript@latest.
- README: Behaviour section rewritten as explicit Guarantees (ceiling,
  ordering, fail-fast, abort, no stray rejections, input snapshot).

Suite: 32 unit tests + type gate + 2,000-scenario fuzz + boundary smoke, all
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
…r metadata

Makes the package fully discoverable and correctly usable by AI coding agents
(and humans) in every module system:

- ESM interop fixed via an exports map + static wrapper (esm/index.mjs
  re-exports the CJS build — one implementation, no dual-package hazard).
  `import parallel from 'await-parallel-limit'` now yields the function in
  bare Node ESM instead of the exports object. Types route per-condition
  (esm/index.d.mts for import, dist/index.d.ts for require); legacy TS
  resolvers still use main/types, so the TS 3.4 floor is unchanged.
- llms.txt ships in the npm tarball: complete API, semantics, recipes, and
  gotchas (thunks-not-promises, settle rejection rules, abort scope) in one
  context-window-sized file at node_modules/await-parallel-limit/llms.txt.
- AGENTS.md + CLAUDE.md for agents working on the repo: build/test/fuzz
  commands, architecture map, and the frozen invariants.
- @example blocks on all four functions' JSDoc — flows into dist/index.d.ts,
  so hover docs and d.ts-reading agents get working usage samples.
- package.json: sharper description, expanded keywords (promise-pool, p-limit,
  allsettled, rate-limit, abortsignal, ...).
- test/boundary/smoke.mjs: ESM boundary smoke (default-import-is-function,
  full surface, abort) run in CI next to the CJS smoke; verify skill updated.

Gauntlet: 32/32 units + type gate, 300 fuzz scenarios, CJS+ESM boundary
smokes, TS 3.4 floor gate, TS node16-resolution gate (exports-map types) —
all green. Tarball: 8 files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
Publishes to npm with --provenance from GitHub Actions (OIDC id-token), so the
registry links the tarball to the exact workflow run and commit. Triggered by
publishing a GitHub Release (or manually); guards that the release tag matches
package.json's version, and re-runs the full gate first: unit suite + type
gate, 500 fuzz scenarios, and the CJS+ESM package-boundary smokes against the
freshly packed tarball. Requires an NPM_TOKEN automation-token secret.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
@Acro
Acro merged commit 7610fa5 into master Jul 12, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants