Skip to content

feat: stopWhen / stopWhenSync source adapters (0.9.2) - #12

Merged
ildella merged 5 commits into
masterfrom
stop-when
Oct 5, 2026
Merged

ildella merged 5 commits into
masterfrom
stop-when

Conversation

@ildella

@ildella ildella commented Aug 23, 2026

Copy link
Copy Markdown
Owner

What

New source adapter, promoted from nucube-app where it already replaced hand-rolled stop logic in the MusicBrainz enrichment pipeline (second time the pattern was requested by a consumer):

```js
export const stopWhen = (items, predicate = () => false) => ({
async * [Symbol.asyncIterator] () {
for await (const item of items) {
if (predicate(item))
return
yield item
}
},
})
// + stopWhenSync with Symbol.iterator
```

Usage — cancellation lives in the source, everything else stays in its own place:

```js
await series(enrichOne, {
total: albums.length,
pause: ENRICH_DELAY_MS,
pauseOnErrors: true,
onProgress: onItem,
})(stopWhen(albums, shouldStop))

stopWhen(folders, () => count >= limit) // limits close over counters
```

Contract (pinned by tests)

  • Check before yield — the triggering item is pulled but never processed ("cancel before work")
  • Pull-lazy — once stopped, the underlying source is abandoned mid-stream; native cleanup (`finally`, `iterator.return()`) still runs (locked with `trackedSource`)
  • Clean completion, not an error — consumers see `failure: false`, empty `sourceErrors`; cancel is a shorter run
  • Predicate throws are source errors — `onSourceError` / `sourceErrors`, never `onError`
  • Predicate is sync, receives `(item, index)`, defaults to `() => false` (identity wrapper)
  • Length forwarding — array sources forward `.length` on the wrapper so `series` keeps progress totals without an explicit `total`; generators keep total omitted
  • Composes with `series` / `scan` / `reduce` / `filter` / `findSync` and raw `for await` with zero changes to those functions

Design notes for reviewers

  • Source wrapper, not a `series` option. An option would only cover `series`/`filter`; it would never reach `reduce(ingestEvents())` or raw `for await` loops. The wrapper helps every consumer at once. A native `while:` option was considered and rejected; if we ever want one, `stopWhen` still stands alone.
  • Not intra-item abort. Work already started on an item is not interrupted — that stays in the operation (AbortController). Documented in reference/patterns.
  • Bug fix included: `filter` / `filterSync` / `findSync` sniff their first argument and treated any non-array object as a `where()` pattern — so `filter(pages(), pred)` silently returned a curried function instead of running. `isPatternObject` (in `shared.js`) now excludes objects implementing an iteration protocol. Patterns (`{active: true}`) are unaffected; regression tests cover generators directly.

Out of scope (explicitly)

  • native `while`/`until` option on consumers · async predicates · currying · `takeWhile` alias · changes to `take`

Testing

34 new tests (`tests/stop-when.test.js`, `tests/stop-when-sync.test.js`): nucube's 7 ported verbatim in intent + composition (series/reduce/filter/take), length forwarding, index passing, closure predicates, predicate-throws-as-source-error, generator cleanup. Full suite: 400 passed. Lint clean.

Docs

reference.md (new entry + TOC), guide.md, patterns.md (Pattern 9), examples.md (MusicBrainz-shaped example), architecture.md (one sentence under Eager Execution), migration.md (0.9.2 section incl. the filter fix note), skills/core/SKILL.md, README. Version bumped to 0.9.2.

Follow-up (not here)

nucube-app can delete `src/lib/iterables/stop-when.js` and import from `pipelean@0.9.2` with no call-site change; it may then drop its explicit `total: albums.length`.

stopWhen(items, predicate) stops pulling from any iterable once the
predicate is truthy, checked before the item is offered downstream.
Promoted from nucube-app where it replaced hand-rolled stop logic in
the MusicBrainz enrichment pipeline.

- check before yield: the triggering item is never processed
- pull-lazy: abandoned sources are left mid-stream, cleanup still runs
- clean completion: failure: false, empty sourceErrors — cancel is a
  shorter run, not a source death; predicate throws are source errors
- predicate receives (item, index), sync only, defaults to () => false
- forwards numeric length from array sources so series keeps progress
  totals without an explicit total option
- composes with series/scan/reduce/filter/findSync and raw for await
  with zero changes to those functions

Also fixes filter/filterSync/findSync arg sniffing: iterable objects
(generators, custom adapters) were misread as where() patterns because
any non-array object matched. Patterns are now objects that do not
implement an iteration protocol.
The default `() => false` is a pass-through, not an identity wrapper.
- examples.md: onProgress referenced an undefined onItem, and
  enrichLibrary awaited series without returning its report, so the
  { results } destructure was always undefined
- reference.md: stopWhen example imported only series
- guide.md: sync-counterparts list omitted stopWhenSync

@ildella ildella left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVED
stopWhen / stopWhenSync source adapters and the isPatternObject fix are sound: functional, code-quality, and security passes found nothing, and the full suite (400 tests) plus lint pass locally on head 8c10512.

Fixed in this review (8f0c11e) — three docs examples that didn't run as written:

  • docs/examples.md: the stopWhen snippet passed an undefined onItem to onProgress and enrichLibrary didn't return the series report, so { results } was always undefined
  • docs/reference.md: the stopWhen example imported only series
  • docs/guide.md: the sync-counterparts list omitted stopWhenSync

CI note: the "Code Check" workflow did not run on the new head. Pushes to this PR (including an empty commit) produce only the GitHub Advanced Security scan; the pull_request-triggered workflow last ran on 147142b (2026-08-23, green). Verified locally instead: yarn test 400 passed, yarn lint clean, on 8c10512. Worth checking why pull_request events stopped firing for this PR before relying on the gate again.

Master landed join() (0.10.0) and moved the filter/findSync pattern check
into parseSearchArgs (src/search.js), whose isPattern lacked the iterable
exclusion. Kept master's structure and folded the exclusion into isPattern,
so filter(pages(), pred) still runs instead of returning a curried where().
Dropped the now-unused isPatternObject from shared.js.

Docs: both stopWhen and join stay in the guide list and the skill; version
is 0.10.1 since stopWhen ships after join.
@ildella
ildella merged commit 8bfc96e into master Oct 5, 2026
2 checks passed
@ildella
ildella deleted the stop-when branch October 5, 2026 10:23
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.

1 participant