@nlozgachev/pipelined 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,11 +15,13 @@ npm add @nlozgachev/pipelined
15
15
  ## Possibly maybe
16
16
 
17
17
  In mainstream TypeScript, code is often burdened by implicit control flow: unchecked exceptions,
18
- manual null propagation, and unhandled asynchronous failures. `pipelined` turns these complex
19
- runtime states into simple, transparent data structures that compose. By representing optionality as
20
- `Maybe`, failures as `Result`, lazy asynchronous pipelines as `Task.Result`, and repeated stateful
21
- interactions as `Op` and `Stream`, the library helps disentangle business logic from control
22
- mechanics.
18
+ manual null propagation, unhandled asynchronous failures, UI state race conditions, and deeply
19
+ nested spread operators. `pipelined` turns these complex runtime states into transparent, composable
20
+ data structures.
21
+
22
+ By representing optionality as `Maybe`, failures as `Result`, multi-field errors as `Validation`,
23
+ lazy asynchronous workflows as `Task.Result`, request concurrency as `Op`, and event pipelines as
24
+ `Stream`, the library disentangles business logic from control mechanics.
23
25
 
24
26
  To support these patterns without introducing bloat, the library is designed to be lightweight,
25
27
  zero-dependency, and fully tree-shakeable. The core module (`/core`) is under 16 KB gzipped, and the
@@ -30,7 +32,11 @@ environments.
30
32
 
31
33
  Full guides and API reference at **[pipelined.lozgachev.dev](https://pipelined.lozgachev.dev)**.
32
34
 
33
- ## Example: composing optional values
35
+ ---
36
+
37
+ ## Examples
38
+
39
+ ### Composing optional values
34
40
 
35
41
  `null` checks accumulate fast. Each one is a conditional branch that the type system can't help you
36
42
  forget. `Maybe<A>` turns absence into a value that composes — the same operations apply whether or
@@ -45,7 +51,7 @@ const parseDiscount = (raw: string): string =>
45
51
  pipe(
46
52
  raw,
47
53
  Str.trim,
48
- Num.parse, // "10" → Some(10), "abc" → None
54
+ Num.parse, // "15" → Some(15), "abc" → None
49
55
  Maybe.filter((n) => n >= 0 && n <= 100), // out of range → None
50
56
  Maybe.map((n) => `${n}% off`),
51
57
  Maybe.getOrElse(() => "No discount"),
@@ -58,7 +64,7 @@ parseDiscount("abc"); // "No discount"
58
64
 
59
65
  Every step that sees `None` is skipped. The fallback runs once, at the end.
60
66
 
61
- ## Example: typed async errors
67
+ ### Typed async errors and cancellation
62
68
 
63
69
  In JavaScript, asynchronous exceptions bypass the static type system, leaving unhandled rejections
64
70
  as invisible runtime risks. `Task.Result<E, A>` represents fallible asynchronous computations as
@@ -69,6 +75,8 @@ function signature, ensuring that failures are handled before compile time:
69
75
  import { pipe } from "@nlozgachev/pipelined/composition";
70
76
  import { Result, Task } from "@nlozgachev/pipelined/core";
71
77
 
78
+ type User = { id: string; name: string };
79
+ type Post = { id: string; title: string };
72
80
  type ApiError = { status: number; message: string };
73
81
 
74
82
  const fetchUser = (id: string): Task.Result<ApiError, User> =>
@@ -78,14 +86,14 @@ const fetchUser = (id: string): Task.Result<ApiError, User> =>
78
86
  if (!r.ok) throw { status: r.status, message: r.statusText };
79
87
  return r.json() as Promise<User>;
80
88
  }),
81
- (e) => e as ApiError,
89
+ { onError: (e) => e as ApiError },
82
90
  );
83
91
 
84
92
  const fetchPosts = (userId: string): Task.Result<ApiError, Post[]> =>
85
93
  Task.Result.tryCatch(
86
94
  (signal) =>
87
95
  fetch(`/users/${userId}/posts`, { signal }).then((r) => r.json()),
88
- (e) => e as ApiError,
96
+ { onError: (e) => e as ApiError },
89
97
  );
90
98
 
91
99
  // Chain two requests — the AbortSignal propagates to both automatically
@@ -116,7 +124,7 @@ if (Result.is.ok(result)) {
116
124
  }
117
125
  ```
118
126
 
119
- ## Example: transforming data
127
+ ### Transforming data collections
120
128
 
121
129
  Standard JavaScript arrays and records routinely return `undefined` on out-of-bounds access or
122
130
  missing keys. The utility modules in `pipelined` wrap these operations with data-last, curried
@@ -156,7 +164,83 @@ replaces a `map` followed by a `filter`. `Arr.head` returns `Maybe<Item>` rather
156
164
  `Item | undefined`, so the absence is explicit in the type and the rest of the pipeline handles it
157
165
  the same way.
158
166
 
159
- ## Example: retry, timeout, and cancellation
167
+ ### Multi-field form validation
168
+
169
+ In web forms and batch data ingestion, failing fast on the first error produces poor user
170
+ experiences. `Validation<E, A>` accumulates all errors across independent fields simultaneously:
171
+
172
+ ```ts
173
+ import { pipe } from "@nlozgachev/pipelined/composition";
174
+ import { Validation } from "@nlozgachev/pipelined/core";
175
+ import { Str } from "@nlozgachev/pipelined/data";
176
+
177
+ type SignupForm = { username: string; email: string };
178
+
179
+ const validateUsername = (name: string): Validation<string, string> =>
180
+ pipe(
181
+ name,
182
+ Str.trim,
183
+ Validation.from.Predicate(
184
+ (s) => s.length >= 3,
185
+ () => "Username must be at least 3 characters",
186
+ ),
187
+ );
188
+
189
+ const validateEmail = (email: string): Validation<string, string> =>
190
+ pipe(
191
+ email,
192
+ Str.trim,
193
+ Validation.from.Predicate(
194
+ (s) => s.includes("@"),
195
+ () => "Email must contain an @ symbol",
196
+ ),
197
+ );
198
+
199
+ const validateSignup = (form: SignupForm) =>
200
+ Validation.struct({
201
+ username: validateUsername(form.username),
202
+ email: validateEmail(form.email),
203
+ });
204
+
205
+ const outcome = validateSignup({ username: "a", email: "invalid" });
206
+
207
+ if (Validation.is.failed(outcome)) {
208
+ console.log(outcome.errors);
209
+ // ["Username must be at least 3 characters", "Email must contain an @ symbol"]
210
+ }
211
+ ```
212
+
213
+ ### Eliminating impossible UI states
214
+
215
+ Managing asynchronous data in UI components with separate boolean flags (`isLoading`, `isError`,
216
+ `data`) leads to contradictory combinations (like showing a spinner alongside an error alert).
217
+ `RemoteData` models the complete 4-state lifecycle explicitly:
218
+
219
+ ```ts
220
+ import { pipe } from "@nlozgachev/pipelined/composition";
221
+ import { RemoteData } from "@nlozgachev/pipelined/core";
222
+
223
+ type UserProfile = { name: string };
224
+ type FetchError = { message: string };
225
+
226
+ const renderUI = (state: RemoteData<FetchError, UserProfile>): string =>
227
+ pipe(
228
+ state,
229
+ RemoteData.match({
230
+ notAsked: () => "Click to load profile",
231
+ loading: () => "Loading...",
232
+ failure: (err) => `Failed: ${err.message}`,
233
+ success: (user) => `Welcome, ${user.name}!`,
234
+ }),
235
+ );
236
+
237
+ renderUI(RemoteData.make.notAsked()); // "Click to load profile"
238
+ renderUI(RemoteData.make.loading()); // "Loading..."
239
+ renderUI(RemoteData.make.failure({ message: "Network down" })); // "Failed: Network down"
240
+ renderUI(RemoteData.make.success({ name: "Alice" })); // "Welcome, Alice!"
241
+ ```
242
+
243
+ ### Managing request lifecycles, retries, and timeouts
160
244
 
161
245
  Handling robust network interactions — including retry attempts, backoff timing, timeouts, and
162
246
  signal-driven cancellation — typically requires complex, stateful code that is highly prone to
@@ -236,13 +320,11 @@ if (Op.isOk(outcome)) {
236
320
  fetchUser.abort();
237
321
  ```
238
322
 
239
- ## Example: repeated UI interactions
323
+ ### Repeated UI interactions and concurrency strategies
240
324
 
241
325
  User interfaces frequently trigger repeated asynchronous events: a search input firing on every
242
- keystroke, a submit button clicked multiple times, or a polling loop that must terminate when a
243
- newer request starts. Managing these concurrency scenarios traditionally requires complex, ad-hoc
244
- state machines. `Op` simplifies this by allowing developers to declare the concurrency strategy as a
245
- simple configuration choice:
326
+ keystroke, a submit button clicked multiple times, or background draft auto-saving. `Op` lets you
327
+ declare the concurrency strategy as a clean configuration choice:
246
328
 
247
329
  **Search — cancel the previous call when the user types:**
248
330
 
@@ -259,7 +341,7 @@ const searchOp = Op.create(
259
341
  );
260
342
 
261
343
  const search = Op.interpret(searchOp, {
262
- strategy: "restartable", // new call cancels the previous one
344
+ strategy: "restartable", // new call cancels the previous in-flight request
263
345
  retry: { attempts: 2, backoff: Duration.milliseconds(300) },
264
346
  });
265
347
 
@@ -273,7 +355,7 @@ search.subscribe((state) => {
273
355
  input.addEventListener("input", (e) => search.run(e.currentTarget.value));
274
356
  ```
275
357
 
276
- **Form submit — drop concurrent submissions:**
358
+ **Form submit — drop concurrent duplicate submissions:**
277
359
 
278
360
  ```ts
279
361
  const submitOp = Op.create(
@@ -301,14 +383,13 @@ form.addEventListener("submit", (e) => {
301
383
  ```
302
384
 
303
385
  The system supports a variety of built-in strategies — `restartable`, `exclusive`, `debounced`,
304
- `throttled`, `queue`, `buffered`, `concurrent`, `keyed`, and `once` — making the integration of
305
- complex async scenarios highly predictable.
386
+ `throttled`, `queue`, `buffered`, `concurrent`, `keyed`, and `once`.
306
387
 
307
- ## Example: typed event streaming and sequence reduction
388
+ ### Event streaming, sequence funnels, and queue safety
308
389
 
309
390
  Decoupling event producers from stateful event consumers often leads to untyped event emitters or
310
- complex ad-hoc state machines. `Stream` models event pipelines with typed message schemas, sequence
311
- pattern matching, and state reduction:
391
+ recursive call stack crashes during event cascades. `Stream` models in-memory event pipelines with
392
+ typed message schemas, multi-step sequence pattern matching, and causal FIFO queue dispatching:
312
393
 
313
394
  ```ts
314
395
  import { Stream } from "@nlozgachev/pipelined/core";
@@ -317,15 +398,16 @@ type UserFlowMessages = {
317
398
  sessionStarted: { sessionId: string };
318
399
  stepCompleted: { stepName: string };
319
400
  flowFinished: { totalTimeMs: number };
401
+ flowCancelled: { reason: string };
320
402
  };
321
403
 
322
404
  const flowStream = Stream.make<UserFlowMessages>();
323
405
 
324
- // Match sequence: sessionStarted -> stepCompleted -> flowFinished
406
+ // Pattern-match the complete multi-step funnel
325
407
  const sub = Stream.listen(
326
408
  flowStream,
327
409
  ["sessionStarted", "stepCompleted", "flowFinished"],
328
- { ordered: true },
410
+ { ordered: true, reset: "flowCancelled" },
329
411
  ).reduce(
330
412
  (msg, state) => {
331
413
  if (msg.kind === "flowFinished") {
@@ -355,62 +437,135 @@ Stream.emit(flowStream, {
355
437
  sub.getState(); // { completedFlows: 1 }
356
438
  ```
357
439
 
358
- ## What is included
440
+ `Stream` executes cascading events iteratively using an internal queue with $O(1)$ stack overhead,
441
+ completely preventing stack overflow crashes and out-of-order re-entrant execution.
442
+
443
+ ### Deep immutable updates without spread boilerplate
359
444
 
360
- The library covers the full spectrum of state and control flow scenarios encountered in production
361
- applications.
445
+ Modifying deeply nested properties in state trees normally requires tedious spread syntax. `Lens`
446
+ and `Optional` turn nested paths into first-class values:
362
447
 
363
- ### Core context containers
448
+ ```ts
449
+ import { pipe } from "@nlozgachev/pipelined/composition";
450
+ import { Lens } from "@nlozgachev/pipelined/core";
451
+
452
+ type Address = { city: string; zip: string };
453
+ type User = { name: string; address: Address };
454
+
455
+ const user: User = {
456
+ name: "Alice",
457
+ address: { city: "Berlin", zip: "10115" },
458
+ };
459
+
460
+ const addressLens = Lens.from.property<User>()("address");
461
+ const cityLens = Lens.from.property<Address>()("city");
462
+ const userCityLens = pipe(addressLens, Lens.andThen(cityLens));
364
463
 
365
- `Maybe` represents explicit optionality without null checks. `Result` handles synchronous, typed
366
- success and failure, while `Validation` accumulates multiple errors. `RemoteData` tracks the four
367
- states of an asynchronous data fetch (`NotAsked`, `Loading`, `Failure`, `Success`), `These` handles
368
- inclusive-OR scenarios containing a first value, a second, or both simultaneously, and `Tuple`
369
- provides a strongly-typed, immutable two-element pair.
464
+ // Immutably modify deep property in one line
465
+ const updatedUser = pipe(
466
+ user,
467
+ Lens.modify(userCityLens)((c) => c.toUpperCase()),
468
+ );
370
469
 
371
- ### Asynchronous operations
470
+ updatedUser.address.city; // "BERLIN"
471
+ ```
372
472
 
373
- `Task` represents a lazy, infallible asynchronous computation. Fallible asynchronous workflows are
374
- handled by `Task.Result`, `Task.Maybe`, and `Task.Validation`. For managing stateful, recurring
375
- asynchronous operations with complex scheduling, `Op` implements named concurrency strategies such
376
- as `restartable`, `exclusive`, `debounced`, `throttled`, and `queue`, handling retries, timeouts,
377
- and signal propagation automatically. `Stream` provides typed event streaming, sequence pattern
378
- matching, state reduction, and structural forwarding across channels. `Deferred` represents a
379
- lightweight, infallible asynchronous value that is guaranteed to always resolve without rejection.
473
+ ### Nominal type safety and security boundaries
380
474
 
381
- ### Optics and environment state
475
+ TypeScript's structural typing allows accidental parameter swapping (such as passing an `OrderId`
476
+ into a `UserId` slot). `Brand` creates nominal types with smart constructor validation gates at zero
477
+ runtime cost:
382
478
 
383
- `Lens` and `Optional` provide a simple concrete interface for safe, nested immutable data updates.
384
- Environment-dependent calculations and explicit state threading are supported by the `Reader` and
385
- `State` abstractions, while `Logged` enables side-effect-free data logging, and `Lazy` implements
386
- synchronous memoized thunks.
479
+ ```ts
480
+ import { pipe } from "@nlozgachev/pipelined/composition";
481
+ import { Result } from "@nlozgachev/pipelined/core";
482
+ import { Brand } from "@nlozgachev/pipelined/types";
387
483
 
388
- ### Algebraic and logic abstractions
484
+ type UserId = Brand<"UserId", string>;
485
+ type OrderId = Brand<"OrderId", string>;
486
+ type Email = Brand<"Email", string>;
389
487
 
390
- For general type-safe comparisons and algebraic operations, the library includes `Equality` for
391
- composable object and primitive comparisons, `Ordering` for sorting comparators, and `Combinable`
392
- for structural monoids (allowing folding collections with a neutral starting point). Composable
393
- boolean checks and type guards are supported by `Predicate` and `Refinement` abstractions.
488
+ const parseEmail = (raw: string): Result<string, Email> =>
489
+ raw.includes("@")
490
+ ? Result.make.ok(raw as Email)
491
+ : Result.make.err("Invalid email address");
394
492
 
395
- ### Optimized utilities
493
+ const sendReceipt = (userId: UserId, orderId: OrderId, email: Email) =>
494
+ `Sent to ${email} for order ${orderId} by user ${userId}`;
396
495
 
397
- Custom, performance-optimized utility modules (`Arr`, `Rec`, `Dict`, `Uniq`, `Num`, `Str`) wrap
398
- standard JavaScript types to return explicit types like `Maybe` and support data-last currying.
399
- Functions are composed using `pipe` and `flow`, which are enriched with high-level composition
400
- helpers like `when`, `unless`, `either`, `safe`, and `async` to support robust, expressive
401
- pipelines.
496
+ const user = "usr_100" as UserId;
497
+ const order = "ord_500" as OrderId;
498
+ const email = parseEmail("alice@example.com");
402
499
 
403
- ### Nominal branding, durations, and non-empty collections
500
+ if (Result.is.ok(email)) {
501
+ sendReceipt(user, order, email.value);
502
+ // Type checker prevents: sendReceipt(order, user, email.value)
503
+ }
504
+ ```
404
505
 
405
- Compile-time nominal typing with zero runtime overhead is provided by `Brand`. `Duration` safely
406
- models and converts time durations (seconds, milliseconds, etc.). `Arr.NonEmpty` and `Rec.NonEmpty`
407
- guarantee that an array or record is never empty, eliminating defensive length/emptiness checks at
408
- runtime.
506
+ ---
507
+
508
+ ## Quick Reference
509
+
510
+ | Problem to Solve | Module | Import Path |
511
+ | ------------------------------------------------------------- | ----------------------------- | ----------------------------------- |
512
+ | Optional values without `null` / `undefined` checks | `Maybe` | `@nlozgachev/pipelined/core` |
513
+ | Synchronous typed errors without `try`/`catch` | `Result` | `@nlozgachev/pipelined/core` |
514
+ | Multi-field form and batch validation error accumulation | `Validation` | `@nlozgachev/pipelined/core` |
515
+ | Lazy async workflows with automatic `AbortSignal` | `Task.Result`, `Task` | `@nlozgachev/pipelined/core` |
516
+ | Infallible async return container for tasks | `Deferred` | `@nlozgachev/pipelined/core` |
517
+ | Eliminating impossible UI loading/error/data states | `RemoteData` | `@nlozgachev/pipelined/core` |
518
+ | Managing request race conditions, retries, and locks | `Op` | `@nlozgachev/pipelined/core` |
519
+ | In-memory event streaming, sequence funnels & queue safety | `Stream` | `@nlozgachev/pipelined/core` |
520
+ | Deep nested immutable updates without spread boilerplate | `Lens`, `Optional` | `@nlozgachev/pipelined/core` |
521
+ | Implicit dependency injection without prop drilling | `Reader` | `@nlozgachev/pipelined/core` |
522
+ | Pure state transitions, tokenizers, and parsers | `State` | `@nlozgachev/pipelined/core` |
523
+ | Deterministic bracket cleanup (DB pools, file locks) | `Resource` | `@nlozgachev/pipelined/core` |
524
+ | Deferred computation memoized on first access | `Lazy` | `@nlozgachev/pipelined/core` |
525
+ | Pure calculation audit trails and decision logging | `Logged` | `@nlozgachev/pipelined/core` |
526
+ | Inclusive-OR data modeling and two-way sync diffs | `These` | `@nlozgachev/pipelined/core` |
527
+ | Deep structural equality & React component memoization | `Equality` | `@nlozgachev/pipelined/core` |
528
+ | Multi-column table sorting with tiebreakers | `Ordering` | `@nlozgachev/pipelined/core` |
529
+ | Composable boolean filter pipelines & authorization policies | `Predicate` | `@nlozgachev/pipelined/core` |
530
+ | Runtime type narrowing & custom type guard composition | `Refinement` | `@nlozgachev/pipelined/core` |
531
+ | Merging configurations & metric structures (Monoids) | `Combinable` | `@nlozgachev/pipelined/core` |
532
+ | Strongly-typed immutable pair manipulation | `Tuple` | `@nlozgachev/pipelined/core` |
533
+ | Point-free, bounds-safe array transformations | `Arr`, `Arr.NonEmpty` | `@nlozgachev/pipelined/data` |
534
+ | Type-safe object manipulation & key migration | `Rec`, `Rec.NonEmpty` | `@nlozgachev/pipelined/data` |
535
+ | Insertion-ordered maps with non-string keys | `Dict`, `Dict.NonEmpty` | `@nlozgachev/pipelined/data` |
536
+ | Immutable sets & role/permission algebra | `Uniq` | `@nlozgachev/pipelined/data` |
537
+ | String sanitization, numeric conversion & slug parsing | `Str` | `@nlozgachev/pipelined/data` |
538
+ | Boundary clamping & division-by-zero protection | `Num` | `@nlozgachev/pipelined/data` |
539
+ | Financial ledger arithmetic without float precision drift | `BigNum` | `@nlozgachev/pipelined/data` |
540
+ | Safe JSON parsing & circular reference protection | `Json` | `@nlozgachev/pipelined/data` |
541
+ | Nominal typing & security boundary gates | `Brand` | `@nlozgachev/pipelined/types` |
542
+ | Explicit, unit-safe time spans & timeout policies | `Duration`, `RetryPolicy` | `@nlozgachev/pipelined/types` |
543
+ | Left-to-right value pipeline execution | `pipe` | `@nlozgachev/pipelined/composition` |
544
+ | Left-to-right and right-to-left function composition | `flow`, `compose` | `@nlozgachev/pipelined/composition` |
545
+ | Currying, uncurrying, and argument flipping | `curry`, `uncurry`, `flip` | `@nlozgachev/pipelined/composition` |
546
+ | Multi-branch argument routing and combining | `converge`, `juxt`, `on` | `@nlozgachev/pipelined/composition` |
547
+ | Pure function memoization, predicates & pipeline side-effects | `memoize`, `tap`, `not`, `fn` | `@nlozgachev/pipelined/composition` |
548
+
549
+ ---
550
+
551
+ ## Package Architecture and Performance
552
+
553
+ `pipelined` is structured into 4 isolated, tree-shakeable entry points:
554
+
555
+ - **`@nlozgachev/pipelined/core`**: Core context containers, async runtimes, optics, and logic
556
+ abstractions (<16 KB gzipped).
557
+ - **`@nlozgachev/pipelined/data`**: Curried, data-last utilities for collections, numbers, strings,
558
+ and JSON (<13 KB gzipped).
559
+ - **`@nlozgachev/pipelined/composition`**: Pure higher-order function combinators (`pipe`, `flow`,
560
+ `compose`, `curry`, `uncurry`, `converge`, `juxt`, `memoize`, `tap`, `on`, `not`, `flip`, `fn`)
561
+ (<4 KB gzipped).
562
+ - **`@nlozgachev/pipelined/types`**: Type-level utilities (`Brand`, `Duration`, `RetryPolicy`) (<2
563
+ KB gzipped).
409
564
 
410
565
  Every utility in the library is benchmarked against its native equivalent. The data-last currying
411
- adds a small function call overhead, which is the expected cost of composability. For operations
412
- where native overhead is significant, custom implementations are used that often run faster than
413
- their native counterparts.
566
+ adds a negligible function call overhead, which is the expected cost of composability. For
567
+ operations where native overhead is significant, custom implementations are used that often run
568
+ faster than their native counterparts.
414
569
 
415
570
  ## License
416
571
 
@@ -4,7 +4,7 @@ import {
4
4
  Result,
5
5
  Task,
6
6
  isNonEmptyArr
7
- } from "./chunk-IHLH2XYQ.mjs";
7
+ } from "./chunk-U64ASY7P.mjs";
8
8
 
9
9
  // src/Data/Arr.ts
10
10
  var ArrMaybe;
@@ -1737,22 +1737,35 @@ var Stream;
1737
1737
  ((Stream2) => {
1738
1738
  Stream2.make = (options) => ({
1739
1739
  options,
1740
- _listeners: /* @__PURE__ */ new Set()
1740
+ _listeners: /* @__PURE__ */ new Set(),
1741
+ _queue: [],
1742
+ _isEmitting: false
1741
1743
  });
1742
1744
  Stream2.emit = (target, message) => {
1743
1745
  const targets = Array.isArray(target) ? target : [target];
1744
1746
  const msg = message;
1745
1747
  for (const stream of targets) {
1746
- const listeners = Array.from(stream._listeners);
1747
- for (const listener of listeners) {
1748
+ stream._queue.push(msg);
1749
+ if (!stream._isEmitting) {
1750
+ stream._isEmitting = true;
1748
1751
  try {
1749
- listener(msg);
1750
- } catch (err2) {
1751
- if (stream.options?.onError) {
1752
- stream.options.onError(err2);
1753
- } else {
1754
- throw err2;
1752
+ while (stream._queue.length > 0) {
1753
+ const nextMsg = stream._queue.shift();
1754
+ const listeners = Array.from(stream._listeners);
1755
+ for (const listener of listeners) {
1756
+ try {
1757
+ listener(nextMsg);
1758
+ } catch (err2) {
1759
+ if (stream.options?.onError) {
1760
+ stream.options.onError(err2);
1761
+ } else {
1762
+ throw err2;
1763
+ }
1764
+ }
1765
+ }
1755
1766
  }
1767
+ } finally {
1768
+ stream._isEmitting = false;
1756
1769
  }
1757
1770
  }
1758
1771
  }
@@ -1779,6 +1792,7 @@ var Stream;
1779
1792
  const isStrict = options?.strict ?? false;
1780
1793
  const isOnce = options?.once ?? false;
1781
1794
  const resetKinds = options?.reset ? new Set(Array.isArray(options.reset) ? options.reset : [options.reset]) : null;
1795
+ const optionalKinds = options?.optional ? new Set(Array.isArray(options.optional) ? options.optional : [options.optional]) : null;
1782
1796
  const createMatcher = (onMatch) => {
1783
1797
  let sequenceIndex = 0;
1784
1798
  return (msg) => {
@@ -1792,7 +1806,17 @@ var Stream;
1792
1806
  }
1793
1807
  return;
1794
1808
  }
1795
- const expectedKind = eventList[sequenceIndex];
1809
+ let expectedKind = eventList[sequenceIndex];
1810
+ if (expectedKind !== msg.kind && optionalKinds !== null) {
1811
+ let lookaheadIndex = sequenceIndex;
1812
+ while (lookaheadIndex < eventList.length && optionalKinds.has(eventList[lookaheadIndex]) && eventList[lookaheadIndex] !== msg.kind) {
1813
+ lookaheadIndex++;
1814
+ }
1815
+ if (lookaheadIndex < eventList.length && eventList[lookaheadIndex] === msg.kind) {
1816
+ sequenceIndex = lookaheadIndex;
1817
+ expectedKind = eventList[sequenceIndex];
1818
+ }
1819
+ }
1796
1820
  if (msg.kind === expectedKind) {
1797
1821
  sequenceIndex++;
1798
1822
  if (sequenceIndex === eventList.length) {
package/dist/core.d.mts CHANGED
@@ -2490,6 +2490,10 @@ type Stream<S extends Record<string, unknown>> = {
2490
2490
  readonly options?: Stream.Options;
2491
2491
  /** @internal */
2492
2492
  readonly _listeners: Set<(msg: Stream.Message<S>) => void>;
2493
+ /** @internal */
2494
+ readonly _queue: Array<Stream.Message<S>>;
2495
+ /** @internal */
2496
+ _isEmitting: boolean;
2493
2497
  };
2494
2498
  declare namespace Stream {
2495
2499
  /**
@@ -2517,6 +2521,8 @@ declare namespace Stream {
2517
2521
  readonly once?: boolean;
2518
2522
  /** Event kind(s) that reset sequence tracking to index 0. */
2519
2523
  readonly reset?: (keyof S & string) | ReadonlyArray<keyof S & string>;
2524
+ /** Event kind(s) in the sequence that may be present or skipped. */
2525
+ readonly optional?: (keyof S & string) | ReadonlyArray<keyof S & string>;
2520
2526
  };
2521
2527
  /**
2522
2528
  * Handle for an active stateful subscription.
@@ -2558,6 +2564,8 @@ declare namespace Stream {
2558
2564
  /**
2559
2565
  * Emits a message payload to one or more target streams.
2560
2566
  *
2567
+ * Uses a synchronous breadth-first trampoline queue to handle re-entrant emissions deterministically.
2568
+ *
2561
2569
  * @example
2562
2570
  * ```ts
2563
2571
  * Stream.emit(streamA, {
package/dist/core.d.ts CHANGED
@@ -2490,6 +2490,10 @@ type Stream<S extends Record<string, unknown>> = {
2490
2490
  readonly options?: Stream.Options;
2491
2491
  /** @internal */
2492
2492
  readonly _listeners: Set<(msg: Stream.Message<S>) => void>;
2493
+ /** @internal */
2494
+ readonly _queue: Array<Stream.Message<S>>;
2495
+ /** @internal */
2496
+ _isEmitting: boolean;
2493
2497
  };
2494
2498
  declare namespace Stream {
2495
2499
  /**
@@ -2517,6 +2521,8 @@ declare namespace Stream {
2517
2521
  readonly once?: boolean;
2518
2522
  /** Event kind(s) that reset sequence tracking to index 0. */
2519
2523
  readonly reset?: (keyof S & string) | ReadonlyArray<keyof S & string>;
2524
+ /** Event kind(s) in the sequence that may be present or skipped. */
2525
+ readonly optional?: (keyof S & string) | ReadonlyArray<keyof S & string>;
2520
2526
  };
2521
2527
  /**
2522
2528
  * Handle for an active stateful subscription.
@@ -2558,6 +2564,8 @@ declare namespace Stream {
2558
2564
  /**
2559
2565
  * Emits a message payload to one or more target streams.
2560
2566
  *
2567
+ * Uses a synchronous breadth-first trampoline queue to handle re-entrant emissions deterministically.
2568
+ *
2561
2569
  * @example
2562
2570
  * ```ts
2563
2571
  * Stream.emit(streamA, {
package/dist/core.js CHANGED
@@ -1811,22 +1811,35 @@ var Stream;
1811
1811
  ((Stream2) => {
1812
1812
  Stream2.make = (options) => ({
1813
1813
  options,
1814
- _listeners: /* @__PURE__ */ new Set()
1814
+ _listeners: /* @__PURE__ */ new Set(),
1815
+ _queue: [],
1816
+ _isEmitting: false
1815
1817
  });
1816
1818
  Stream2.emit = (target, message) => {
1817
1819
  const targets = Array.isArray(target) ? target : [target];
1818
1820
  const msg = message;
1819
1821
  for (const stream of targets) {
1820
- const listeners = Array.from(stream._listeners);
1821
- for (const listener of listeners) {
1822
+ stream._queue.push(msg);
1823
+ if (!stream._isEmitting) {
1824
+ stream._isEmitting = true;
1822
1825
  try {
1823
- listener(msg);
1824
- } catch (err2) {
1825
- if (stream.options?.onError) {
1826
- stream.options.onError(err2);
1827
- } else {
1828
- throw err2;
1826
+ while (stream._queue.length > 0) {
1827
+ const nextMsg = stream._queue.shift();
1828
+ const listeners = Array.from(stream._listeners);
1829
+ for (const listener of listeners) {
1830
+ try {
1831
+ listener(nextMsg);
1832
+ } catch (err2) {
1833
+ if (stream.options?.onError) {
1834
+ stream.options.onError(err2);
1835
+ } else {
1836
+ throw err2;
1837
+ }
1838
+ }
1839
+ }
1829
1840
  }
1841
+ } finally {
1842
+ stream._isEmitting = false;
1830
1843
  }
1831
1844
  }
1832
1845
  }
@@ -1853,6 +1866,7 @@ var Stream;
1853
1866
  const isStrict = options?.strict ?? false;
1854
1867
  const isOnce = options?.once ?? false;
1855
1868
  const resetKinds = options?.reset ? new Set(Array.isArray(options.reset) ? options.reset : [options.reset]) : null;
1869
+ const optionalKinds = options?.optional ? new Set(Array.isArray(options.optional) ? options.optional : [options.optional]) : null;
1856
1870
  const createMatcher = (onMatch) => {
1857
1871
  let sequenceIndex = 0;
1858
1872
  return (msg) => {
@@ -1866,7 +1880,17 @@ var Stream;
1866
1880
  }
1867
1881
  return;
1868
1882
  }
1869
- const expectedKind = eventList[sequenceIndex];
1883
+ let expectedKind = eventList[sequenceIndex];
1884
+ if (expectedKind !== msg.kind && optionalKinds !== null) {
1885
+ let lookaheadIndex = sequenceIndex;
1886
+ while (lookaheadIndex < eventList.length && optionalKinds.has(eventList[lookaheadIndex]) && eventList[lookaheadIndex] !== msg.kind) {
1887
+ lookaheadIndex++;
1888
+ }
1889
+ if (lookaheadIndex < eventList.length && eventList[lookaheadIndex] === msg.kind) {
1890
+ sequenceIndex = lookaheadIndex;
1891
+ expectedKind = eventList[sequenceIndex];
1892
+ }
1893
+ }
1870
1894
  if (msg.kind === expectedKind) {
1871
1895
  sequenceIndex++;
1872
1896
  if (sequenceIndex === eventList.length) {
package/dist/core.mjs CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  These,
25
25
  Tuple,
26
26
  Validation
27
- } from "./chunk-IHLH2XYQ.mjs";
27
+ } from "./chunk-U64ASY7P.mjs";
28
28
  import "./chunk-DENXUTKL.mjs";
29
29
  export {
30
30
  Combinable,
package/dist/data.mjs CHANGED
@@ -7,8 +7,8 @@ import {
7
7
  Rec,
8
8
  Str,
9
9
  Uniq
10
- } from "./chunk-EPT3GYGN.mjs";
11
- import "./chunk-IHLH2XYQ.mjs";
10
+ } from "./chunk-6N27QZFK.mjs";
11
+ import "./chunk-U64ASY7P.mjs";
12
12
  import "./chunk-DENXUTKL.mjs";
13
13
  export {
14
14
  Arr,
package/dist/index.js CHANGED
@@ -2293,22 +2293,35 @@ var Stream;
2293
2293
  ((Stream2) => {
2294
2294
  Stream2.make = (options) => ({
2295
2295
  options,
2296
- _listeners: /* @__PURE__ */ new Set()
2296
+ _listeners: /* @__PURE__ */ new Set(),
2297
+ _queue: [],
2298
+ _isEmitting: false
2297
2299
  });
2298
2300
  Stream2.emit = (target, message) => {
2299
2301
  const targets = Array.isArray(target) ? target : [target];
2300
2302
  const msg = message;
2301
2303
  for (const stream of targets) {
2302
- const listeners = Array.from(stream._listeners);
2303
- for (const listener of listeners) {
2304
+ stream._queue.push(msg);
2305
+ if (!stream._isEmitting) {
2306
+ stream._isEmitting = true;
2304
2307
  try {
2305
- listener(msg);
2306
- } catch (err2) {
2307
- if (stream.options?.onError) {
2308
- stream.options.onError(err2);
2309
- } else {
2310
- throw err2;
2308
+ while (stream._queue.length > 0) {
2309
+ const nextMsg = stream._queue.shift();
2310
+ const listeners = Array.from(stream._listeners);
2311
+ for (const listener of listeners) {
2312
+ try {
2313
+ listener(nextMsg);
2314
+ } catch (err2) {
2315
+ if (stream.options?.onError) {
2316
+ stream.options.onError(err2);
2317
+ } else {
2318
+ throw err2;
2319
+ }
2320
+ }
2321
+ }
2311
2322
  }
2323
+ } finally {
2324
+ stream._isEmitting = false;
2312
2325
  }
2313
2326
  }
2314
2327
  }
@@ -2335,6 +2348,7 @@ var Stream;
2335
2348
  const isStrict = options?.strict ?? false;
2336
2349
  const isOnce = options?.once ?? false;
2337
2350
  const resetKinds = options?.reset ? new Set(Array.isArray(options.reset) ? options.reset : [options.reset]) : null;
2351
+ const optionalKinds = options?.optional ? new Set(Array.isArray(options.optional) ? options.optional : [options.optional]) : null;
2338
2352
  const createMatcher = (onMatch) => {
2339
2353
  let sequenceIndex = 0;
2340
2354
  return (msg) => {
@@ -2348,7 +2362,17 @@ var Stream;
2348
2362
  }
2349
2363
  return;
2350
2364
  }
2351
- const expectedKind = eventList[sequenceIndex];
2365
+ let expectedKind = eventList[sequenceIndex];
2366
+ if (expectedKind !== msg.kind && optionalKinds !== null) {
2367
+ let lookaheadIndex = sequenceIndex;
2368
+ while (lookaheadIndex < eventList.length && optionalKinds.has(eventList[lookaheadIndex]) && eventList[lookaheadIndex] !== msg.kind) {
2369
+ lookaheadIndex++;
2370
+ }
2371
+ if (lookaheadIndex < eventList.length && eventList[lookaheadIndex] === msg.kind) {
2372
+ sequenceIndex = lookaheadIndex;
2373
+ expectedKind = eventList[sequenceIndex];
2374
+ }
2375
+ }
2352
2376
  if (msg.kind === expectedKind) {
2353
2377
  sequenceIndex++;
2354
2378
  if (sequenceIndex === eventList.length) {
package/dist/index.mjs CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  Rec,
40
40
  Str,
41
41
  Uniq
42
- } from "./chunk-EPT3GYGN.mjs";
42
+ } from "./chunk-6N27QZFK.mjs";
43
43
  import {
44
44
  Combinable,
45
45
  Deferred,
@@ -66,7 +66,7 @@ import {
66
66
  These,
67
67
  Tuple,
68
68
  Validation
69
- } from "./chunk-IHLH2XYQ.mjs";
69
+ } from "./chunk-U64ASY7P.mjs";
70
70
  import {
71
71
  Brand,
72
72
  Duration,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nlozgachev/pipelined",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "Opinionated functional abstractions for TypeScript",
5
5
  "license": "BSD-3-Clause",
6
6
  "homepage": "https://pipelined.lozgachev.dev",
@@ -18,7 +18,7 @@
18
18
  "types": "./dist/index.d.ts",
19
19
  "sideEffects": false,
20
20
  "engines": {
21
- "node": ">=24"
21
+ "node": ">=24.19.0"
22
22
  },
23
23
  "files": [
24
24
  "dist"
@@ -55,6 +55,7 @@
55
55
  "scripts": {
56
56
  "build": "tsup",
57
57
  "test": "vitest run",
58
+ "test:coverage": "vitest run --coverage",
58
59
  "bench": "vitest bench",
59
60
  "size": "size-limit",
60
61
  "typecheck": "tsc --noEmit",
@@ -68,24 +69,44 @@
68
69
  "docs:build": "pnpm --filter pipelined-docs build"
69
70
  },
70
71
  "devDependencies": {
71
- "@size-limit/file": "12.1.0",
72
- "@types/node": "25.9.3",
73
- "@vitest/coverage-v8": "4.1.8",
74
- "bumpp": "11.1.0",
75
- "dprint": "0.54.0",
76
- "fast-check": "4.8.0",
77
- "oxlint": "1.69.0",
78
- "size-limit": "12.1.0",
72
+ "@size-limit/file": "13.0.3",
73
+ "@types/node": "26.2.0",
74
+ "@vitest/coverage-v8": "4.1.10",
75
+ "bumpp": "12.2.0",
76
+ "dprint": "0.55.2",
77
+ "fast-check": "4.9.0",
78
+ "oxlint": "1.78.0",
79
+ "size-limit": "13.0.3",
79
80
  "tsup": "8.5.1",
80
81
  "typescript": "6.0.3",
81
- "vitest": "4.1.8"
82
+ "vitest": "4.1.10"
82
83
  },
83
84
  "size-limit": [
84
- { "path": "dist/index.js", "limit": "25 KB", "gzip": true },
85
- { "path": "dist/core.js", "limit": "16 KB", "gzip": true },
86
- { "path": "dist/composition.js", "limit": "4 KB", "gzip": true },
87
- { "path": "dist/data.js", "limit": "13 KB", "gzip": true },
88
- { "path": "dist/types.js", "limit": "2 KB", "gzip": true }
85
+ {
86
+ "path": "dist/index.js",
87
+ "limit": "25 KB",
88
+ "gzip": true
89
+ },
90
+ {
91
+ "path": "dist/core.js",
92
+ "limit": "16 KB",
93
+ "gzip": true
94
+ },
95
+ {
96
+ "path": "dist/composition.js",
97
+ "limit": "4 KB",
98
+ "gzip": true
99
+ },
100
+ {
101
+ "path": "dist/data.js",
102
+ "limit": "13 KB",
103
+ "gzip": true
104
+ },
105
+ {
106
+ "path": "dist/types.js",
107
+ "limit": "2 KB",
108
+ "gzip": true
109
+ }
89
110
  ],
90
111
  "packageManager": "pnpm@11.6.0+sha512.9a36518224080c6fe5165afdcfe79bfa118c29be703f3f462b1e32efe1e98e47e8750b148e08286250aad4113cc7993ca413c4e2cd447752708c2ee5751bc95f"
91
112
  }