@okikio/observables 1.0.2 → 1.3.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.
@@ -18,6 +18,77 @@ import "../../_dnt.polyfills.js";
18
18
  import { createStatefulOperator } from "../operators.js";
19
19
  import { isObservableError, ObservableError } from "../../error.js";
20
20
  import { pull } from "../../observable.js";
21
+ import { createQueue, dequeue, enqueue, getSize, isFull, toArray, } from "../../queue.js";
22
+ /**
23
+ * Cancels an iterator if it exposes `return()`.
24
+ *
25
+ * Most iterators created through `pull()` do expose `return()`, and calling it
26
+ * is the Observable equivalent of saying "the consumer is done, release any
27
+ * held resources now".
28
+ */
29
+ async function cancelIterator(iterator) {
30
+ if (iterator?.return) {
31
+ await iterator.return();
32
+ }
33
+ }
34
+ /**
35
+ * Rebuilds the strongly typed tuple returned by the combination operators.
36
+ *
37
+ * The runtime data is stored in mutable `unknown[]` buffers because values are
38
+ * filled over time. This helper isolates the final cast to one place, and the
39
+ * cast is sound because every caller preserves the exact length and ordering of
40
+ * the original companion Observable tuple.
41
+ */
42
+ function toCombinationTuple(sourceValue, otherValues) {
43
+ return [
44
+ sourceValue,
45
+ ...otherValues,
46
+ ];
47
+ }
48
+ /**
49
+ * Removes one queued value from each companion queue in order.
50
+ *
51
+ * Callers only use this after checking that every queue has at least one value.
52
+ * That precondition is what makes the returned tuple complete instead of partly
53
+ * `undefined`.
54
+ */
55
+ function shiftQueuedValues(queues) {
56
+ return queues.map((queue) => dequeue(queue));
57
+ }
58
+ /**
59
+ * Starts zip queues small and lets them grow only when buffering actually
60
+ * happens.
61
+ *
62
+ * `zipWith()` can run for a long time, so this avoids paying for large backing
63
+ * arrays up front while still keeping steady-state dequeue work O(1).
64
+ */
65
+ const INITIAL_ZIP_QUEUE_CAPACITY = 16;
66
+ /**
67
+ * Returns whether a queue currently has at least one buffered value.
68
+ */
69
+ function hasQueuedValue(queue) {
70
+ return getSize(queue) > 0;
71
+ }
72
+ /**
73
+ * Appends a value to a queue, growing the underlying circular buffer when the
74
+ * current capacity is exhausted.
75
+ *
76
+ * The shared queue utilities are fixed-capacity by design. `zipWith()` needs
77
+ * unbounded logical buffering, so this helper preserves the queue API while
78
+ * replacing the backing queue with a larger one only when necessary.
79
+ */
80
+ function enqueueQueuedValue(queue, value) {
81
+ if (!isFull(queue)) {
82
+ enqueue(queue, value);
83
+ return queue;
84
+ }
85
+ const grownQueue = createQueue(Math.max(queue.capacity * 2, 1));
86
+ for (const item of toArray(queue)) {
87
+ enqueue(grownQueue, item);
88
+ }
89
+ enqueue(grownQueue, value);
90
+ return grownQueue;
91
+ }
21
92
  /**
22
93
  * Transforms each item into a new stream and merges their outputs, running
23
94
  * them in parallel.
@@ -64,87 +135,133 @@ import { pull } from "../../observable.js";
64
135
  * @returns An operator function that maps and flattens values
65
136
  */
66
137
  export function mergeMap(project, concurrent = Infinity) {
138
+ const maxConcurrent = concurrent > 0 ? concurrent : 1;
67
139
  return createStatefulOperator({
68
140
  name: "mergeMap",
69
- // Initialize state
141
+ errorMode: "pass-through",
70
142
  createState: () => ({
71
- activeSubscriptions: new Map(),
143
+ activeIterators: new Map(),
144
+ activeTasks: new Set(),
72
145
  buffer: [],
73
- sourceCompleted: false,
74
146
  index: 0,
75
147
  activeCount: 0,
148
+ idleDeferred: null,
149
+ isCancelled: false,
150
+ sourceCompleted: false,
76
151
  }),
77
- // Process each incoming chunk
78
- async transform(chunk, state, controller) {
79
- // If we're under the concurrency limit, process immediately
80
- if (state.activeCount < concurrent) {
81
- await subscribeToProjection(chunk, state.index++);
82
- }
83
- else {
84
- // Otherwise, buffer for later
85
- state.buffer.push([chunk, state.index++]);
86
- }
87
- // Helper function to subscribe to inner Observable
88
- async function subscribeToProjection(value, innerIndex) {
152
+ transform(chunk, state, controller) {
153
+ const value = chunk;
154
+ const sourceIndex = state.index++;
155
+ // `flush()` must wait for both active inners and buffered outers.
156
+ // Resolving this gate too early would let the operator finish while inner
157
+ // work is still producing values.
158
+ const resolveIfIdle = () => {
159
+ if (state.sourceCompleted &&
160
+ state.activeCount === 0 &&
161
+ state.buffer.length === 0 &&
162
+ state.idleDeferred) {
163
+ state.idleDeferred.resolve();
164
+ state.idleDeferred = null;
165
+ }
166
+ };
167
+ // Keep filling free concurrency slots until either the buffer is empty or
168
+ // the operator reaches its configured parallelism limit.
169
+ const drainBuffer = () => {
170
+ while (!state.isCancelled &&
171
+ state.activeCount < maxConcurrent &&
172
+ state.buffer.length > 0) {
173
+ const next = state.buffer.shift();
174
+ if (!next)
175
+ break;
176
+ launchProjection(next[0], next[1]);
177
+ }
178
+ resolveIfIdle();
179
+ };
180
+ // Start draining one projected inner Observable and keep enough metadata
181
+ // around to cancel or await it later.
182
+ const launchProjection = (innerValue, innerIndex) => {
89
183
  let innerObservable;
90
184
  try {
91
- // Apply projection function to get inner Observable
92
- innerObservable = project(value, innerIndex);
185
+ innerObservable = project(innerValue, innerIndex);
93
186
  }
94
187
  catch (err) {
95
- // Forward any errors from the projection function
96
- controller.enqueue(ObservableError.from(err, "operator:stateful:mergeMap:project", value));
188
+ try {
189
+ controller.enqueue(ObservableError.from(err, "operator:stateful:mergeMap:project", innerValue));
190
+ }
191
+ catch {
192
+ // Downstream cancellation already decided the stream outcome.
193
+ }
194
+ drainBuffer();
97
195
  return;
98
196
  }
99
197
  state.activeCount++;
100
- // Use pull to iterate asynchronously
101
- try {
102
- for await (const innerValue of pull(innerObservable, { throwError: false })) {
103
- controller.enqueue(innerValue);
198
+ const iterator = pull(innerObservable, { throwError: false })[Symbol.asyncIterator]();
199
+ state.activeIterators.set(innerIndex, iterator);
200
+ const taskRef = { current: null };
201
+ const task = (async () => {
202
+ try {
203
+ while (!state.isCancelled) {
204
+ const { value: emittedValue, done } = await iterator.next();
205
+ if (done || state.isCancelled) {
206
+ break;
207
+ }
208
+ try {
209
+ controller.enqueue(emittedValue);
210
+ }
211
+ catch {
212
+ break;
213
+ }
214
+ }
104
215
  }
105
- }
106
- catch (err) {
107
- controller.enqueue(ObservableError.from(err, "operator:stateful:mergeMap:innerObservable", value));
108
- }
109
- finally {
110
- // Clean up after inner Observable completes
111
- state.activeSubscriptions.delete(innerIndex);
112
- state.activeCount--;
113
- // Process the buffer if we have space
114
- processBuffer();
115
- }
116
- }
117
- // Helper function to process the buffer
118
- async function processBuffer() {
119
- // While we have room for more inner Observables and
120
- // there are items in the buffer, subscribe to inner Observables
121
- while (state.activeCount < concurrent && state.buffer.length > 0) {
122
- const [value, bufferIndex] = state.buffer.shift();
123
- await subscribeToProjection(value, bufferIndex);
124
- }
125
- // If the source is completed and we have no active inner
126
- // subscriptions, complete the output stream
127
- // Nothing more to do, transformation is complete
216
+ catch (err) {
217
+ if (!state.isCancelled) {
218
+ try {
219
+ controller.enqueue(ObservableError.from(err, "operator:stateful:mergeMap:innerObservable", innerValue));
220
+ }
221
+ catch {
222
+ // Downstream cancellation already decided the stream outcome.
223
+ }
224
+ }
225
+ }
226
+ finally {
227
+ state.activeIterators.delete(innerIndex);
228
+ if (taskRef.current) {
229
+ state.activeTasks.delete(taskRef.current);
230
+ }
231
+ state.activeCount--;
232
+ drainBuffer();
233
+ }
234
+ })();
235
+ taskRef.current = task;
236
+ state.activeTasks.add(task);
237
+ };
238
+ if (state.activeCount < maxConcurrent) {
239
+ launchProjection(value, sourceIndex);
240
+ return;
128
241
  }
242
+ state.buffer.push([value, sourceIndex]);
129
243
  },
130
- // Handle the end of the source stream
131
- flush(state) {
132
- // Mark the source as completed
244
+ async flush(state) {
133
245
  state.sourceCompleted = true;
134
- // If no active inner subscriptions, we're done
135
- // Stream will close naturally
136
- // Otherwise, let the active subscriptions complete
137
- },
138
- // Handle cancellation
139
- cancel(state) {
140
- // Clean up all inner subscriptions
141
- for (const subscription of state.activeSubscriptions.values()) {
142
- subscription.unsubscribe();
246
+ if (state.activeCount === 0 && state.buffer.length === 0) {
247
+ return;
143
248
  }
144
- // Clear the buffer and state
249
+ state.idleDeferred ??= Promise.withResolvers();
250
+ await state.idleDeferred.promise;
251
+ },
252
+ async cancel(state) {
253
+ state.isCancelled = true;
145
254
  state.buffer.length = 0;
146
- state.activeSubscriptions.clear();
255
+ const iterators = [...state.activeIterators.values()];
256
+ state.activeIterators.clear();
257
+ await Promise.allSettled(iterators.map((iterator) => iterator.return?.()));
258
+ await Promise.allSettled([...state.activeTasks]);
259
+ state.activeTasks.clear();
147
260
  state.activeCount = 0;
261
+ if (state.idleDeferred) {
262
+ state.idleDeferred.resolve();
263
+ state.idleDeferred = null;
264
+ }
148
265
  },
149
266
  });
150
267
  }
@@ -248,25 +365,36 @@ export function concatMap(project) {
248
365
  export function switchMap(project) {
249
366
  return createStatefulOperator({
250
367
  name: "switchMap",
251
- // Initialize state
368
+ errorMode: "pass-through",
252
369
  createState: () => ({
253
- currentController: null,
370
+ currentIterator: null,
254
371
  currentTask: null,
255
372
  currentTaskToken: null,
256
- sourceCompleted: false,
373
+ isCancelled: false,
257
374
  index: 0,
258
375
  }),
259
- // Process each incoming chunk
260
- transform(chunk, state, controller) {
261
- if (isObservableError(chunk)) {
262
- // If the chunk is an error, we can immediately enqueue it
263
- controller.enqueue(chunk);
264
- return;
265
- }
266
- // Cancel any existing inner subscription
267
- if (state.currentController) {
268
- state.currentController.abort();
269
- state.currentController = null;
376
+ async transform(chunk, state, controller) {
377
+ /**
378
+ * Stops the current inner Observable before a newer one replaces it.
379
+ *
380
+ * The order matters: `return()` asks the iterator to clean up, then we
381
+ * await the draining task so no stale emissions race with the next inner.
382
+ */
383
+ const stopCurrent = async () => {
384
+ const iterator = state.currentIterator;
385
+ const task = state.currentTask;
386
+ state.currentIterator = null;
387
+ state.currentTask = null;
388
+ state.currentTaskToken = null;
389
+ if (iterator?.return) {
390
+ await iterator.return();
391
+ }
392
+ if (task) {
393
+ await task;
394
+ }
395
+ };
396
+ if (state.currentTaskToken || state.currentIterator || state.currentTask) {
397
+ await stopCurrent();
270
398
  }
271
399
  let innerObservable;
272
400
  try {
@@ -278,73 +406,512 @@ export function switchMap(project) {
278
406
  controller.enqueue(ObservableError.from(err, "operator:stateful:switchMap:project", chunk));
279
407
  return;
280
408
  }
281
- // Create a new abort controller for this inner subscription
282
- const abortController = new AbortController();
283
- state.currentController = abortController;
284
- // Subscribe to the new inner Observable
285
409
  const currentTaskToken = {};
410
+ const iterator = pull(innerObservable, { throwError: false })[Symbol.asyncIterator]();
411
+ state.currentTaskToken = currentTaskToken;
412
+ state.currentIterator = iterator;
413
+ const currentTaskRef = {
414
+ current: null,
415
+ };
286
416
  const currentTask = (async () => {
287
- const enqueueIfActive = (value) => {
288
- if (abortController.signal.aborted ||
289
- state.currentController !== abortController) {
290
- return;
291
- }
292
- try {
293
- controller.enqueue(value);
294
- }
295
- catch {
296
- abortController.abort();
297
- }
417
+ const isActive = () => {
418
+ return !state.isCancelled && state.currentTaskToken === currentTaskToken;
298
419
  };
299
420
  try {
300
- const iterator = pull(innerObservable, { throwError: false })[Symbol.asyncIterator]();
301
- while (!abortController.signal.aborted) {
302
- const { value, done } = await iterator.next();
303
- // If the controller is aborted or we're done, exit the loop
304
- if (abortController.signal.aborted || done)
421
+ while (isActive()) {
422
+ const { value: emittedValue, done } = await iterator.next();
423
+ if (done || !isActive()) {
305
424
  break;
306
- // Forward the value
307
- enqueueIfActive(value);
425
+ }
426
+ try {
427
+ controller.enqueue(emittedValue);
428
+ }
429
+ catch {
430
+ break;
431
+ }
308
432
  }
309
433
  }
310
434
  catch (err) {
311
- if (!abortController.signal.aborted) {
312
- enqueueIfActive(ObservableError.from(err, "operator:stateful:switchMap:innerObservable", chunk));
435
+ if (isActive()) {
436
+ try {
437
+ controller.enqueue(ObservableError.from(err, "operator:stateful:switchMap:innerObservable", chunk));
438
+ }
439
+ catch {
440
+ // Downstream cancellation already decided the stream outcome.
441
+ }
313
442
  }
314
443
  }
315
444
  finally {
316
- if (state.currentTaskToken === currentTaskToken) {
445
+ if (state.currentIterator === iterator) {
446
+ state.currentIterator = null;
447
+ }
448
+ if (state.currentTask === currentTaskRef.current) {
317
449
  state.currentTask = null;
318
- state.currentTaskToken = null;
319
450
  }
320
- // Only handle completion if this is still the current controller
321
- if (state.currentController === abortController) {
322
- state.currentController = null;
323
- // If source is completed and we have no active inner, we're done
324
- // Stream will close naturally
451
+ if (state.currentTaskToken === currentTaskToken) {
452
+ state.currentTaskToken = null;
325
453
  }
326
454
  }
327
455
  })();
456
+ currentTaskRef.current = currentTask;
328
457
  state.currentTask = currentTask;
329
- state.currentTaskToken = currentTaskToken;
330
458
  },
331
- // Handle the end of the source stream
332
459
  async flush(state) {
333
- // Mark the source as completed
334
- state.sourceCompleted = true;
335
460
  if (state.currentTask) {
336
461
  await state.currentTask;
337
462
  }
338
463
  },
339
- // Handle cancellation
340
- cancel(state) {
341
- // Cancel the current inner subscription
342
- if (state.currentController) {
343
- state.currentController.abort();
344
- state.currentController = null;
464
+ async cancel(state) {
465
+ state.isCancelled = true;
466
+ if (state.currentIterator?.return) {
467
+ await state.currentIterator.return();
345
468
  }
469
+ if (state.currentTask) {
470
+ await state.currentTask;
471
+ }
472
+ state.currentIterator = null;
346
473
  state.currentTask = null;
347
474
  state.currentTaskToken = null;
348
475
  },
349
476
  });
350
477
  }
478
+ /**
479
+ * Emits each source value together with the latest value from every companion
480
+ * Observable.
481
+ *
482
+ * This operator waits until every companion Observable has produced at least
483
+ * one value. After that gate opens, each new source value emits a tuple that
484
+ * includes the source value followed by the most recent companion values.
485
+ *
486
+ * ```text
487
+ * companion A: ---a----b-------c--
488
+ * companion B: -----x-------y-----
489
+ * source: ------1--2------3--
490
+ * output: ------[1,a,x][2,b,x][3,c,y]
491
+ * ```
492
+ *
493
+ * The source still decides when outputs happen. Companion Observables only
494
+ * update the remembered latest values.
495
+ *
496
+ * @typeParam T - Value type from the primary source
497
+ * @typeParam TOthers - Value types from the companion Observables
498
+ * @param others - Companion Observables whose latest values should be attached
499
+ * @returns An operator that emits tuples of the source value and latest others
500
+ */
501
+ export function withLatestFrom(...others) {
502
+ return createStatefulOperator({
503
+ name: "withLatestFrom",
504
+ errorMode: "pass-through",
505
+ createState: () => ({
506
+ latestValues: new Array(others.length),
507
+ seenLatest: others.map(() => false),
508
+ iterators: [],
509
+ tasks: new Set(),
510
+ isCancelled: false,
511
+ }),
512
+ start(state, controller) {
513
+ // Companion Observables run in the background and only update cached
514
+ // latest values. They never decide when to emit tuples themselves.
515
+ others.forEach((observable, index) => {
516
+ const iterator = pull(observable, { throwError: false })[Symbol.asyncIterator]();
517
+ state.iterators[index] = iterator;
518
+ const taskRef = { current: null };
519
+ const task = (async () => {
520
+ try {
521
+ while (!state.isCancelled) {
522
+ const { value, done } = await iterator.next();
523
+ if (done || state.isCancelled) {
524
+ break;
525
+ }
526
+ if (isObservableError(value)) {
527
+ try {
528
+ controller.enqueue(value);
529
+ }
530
+ catch {
531
+ break;
532
+ }
533
+ continue;
534
+ }
535
+ state.latestValues[index] = value;
536
+ state.seenLatest[index] = true;
537
+ }
538
+ }
539
+ catch (err) {
540
+ if (!state.isCancelled) {
541
+ try {
542
+ controller.enqueue(ObservableError.from(err, "operator:stateful:withLatestFrom:companion", { companionIndex: index }));
543
+ }
544
+ catch {
545
+ // Downstream cancellation already decided the stream outcome.
546
+ }
547
+ }
548
+ }
549
+ finally {
550
+ if (taskRef.current) {
551
+ state.tasks.delete(taskRef.current);
552
+ }
553
+ }
554
+ })();
555
+ taskRef.current = task;
556
+ state.tasks.add(task);
557
+ });
558
+ },
559
+ transform(chunk, state, controller) {
560
+ if (!state.seenLatest.every(Boolean)) {
561
+ return;
562
+ }
563
+ controller.enqueue(toCombinationTuple(chunk, state.latestValues));
564
+ },
565
+ async flush(state) {
566
+ await Promise.allSettled([...state.tasks]);
567
+ },
568
+ async cancel(state) {
569
+ state.isCancelled = true;
570
+ await Promise.allSettled(state.iterators.map((iterator) => cancelIterator(iterator)));
571
+ await Promise.allSettled([...state.tasks]);
572
+ state.tasks.clear();
573
+ state.iterators.length = 0;
574
+ },
575
+ });
576
+ }
577
+ /**
578
+ * Combines the source with companion Observables using combine-latest timing.
579
+ *
580
+ * Once every source has emitted at least one value, any new value from the
581
+ * primary source or a companion Observable emits a new tuple containing the
582
+ * latest value from each source.
583
+ *
584
+ * @typeParam T - Value type from the primary source
585
+ * @typeParam TOthers - Value types from the companion Observables
586
+ * @param others - Companion Observables that participate in the latest-value set
587
+ * @returns An operator that emits tuples of the latest value from every source
588
+ */
589
+ export function combineLatestWith(...others) {
590
+ return createStatefulOperator({
591
+ name: "combineLatestWith",
592
+ errorMode: "pass-through",
593
+ createState: () => ({
594
+ sourceValue: null,
595
+ hasSourceValue: false,
596
+ latestValues: new Array(others.length),
597
+ seenLatest: others.map(() => false),
598
+ iterators: [],
599
+ tasks: new Set(),
600
+ sourceCompleted: false,
601
+ isCancelled: false,
602
+ }),
603
+ start(state, controller) {
604
+ /**
605
+ * Emits the full latest tuple only after the source and every companion
606
+ * have contributed at least one value.
607
+ */
608
+ const emitLatest = () => {
609
+ if (!state.hasSourceValue || !state.seenLatest.every(Boolean)) {
610
+ return;
611
+ }
612
+ controller.enqueue(toCombinationTuple(state.sourceValue, state.latestValues));
613
+ };
614
+ others.forEach((observable, index) => {
615
+ const iterator = pull(observable, { throwError: false })[Symbol.asyncIterator]();
616
+ state.iterators[index] = iterator;
617
+ const taskRef = { current: null };
618
+ const task = (async () => {
619
+ try {
620
+ while (!state.isCancelled) {
621
+ const { value, done } = await iterator.next();
622
+ if (done || state.isCancelled) {
623
+ break;
624
+ }
625
+ if (isObservableError(value)) {
626
+ try {
627
+ controller.enqueue(value);
628
+ }
629
+ catch {
630
+ break;
631
+ }
632
+ continue;
633
+ }
634
+ state.latestValues[index] = value;
635
+ state.seenLatest[index] = true;
636
+ try {
637
+ emitLatest();
638
+ }
639
+ catch {
640
+ break;
641
+ }
642
+ }
643
+ }
644
+ catch (err) {
645
+ if (!state.isCancelled) {
646
+ try {
647
+ controller.enqueue(ObservableError.from(err, "operator:stateful:combineLatestWith:companion", { companionIndex: index }));
648
+ }
649
+ catch {
650
+ // Downstream cancellation already decided the stream outcome.
651
+ }
652
+ }
653
+ }
654
+ finally {
655
+ if (taskRef.current) {
656
+ state.tasks.delete(taskRef.current);
657
+ }
658
+ }
659
+ })();
660
+ taskRef.current = task;
661
+ state.tasks.add(task);
662
+ });
663
+ },
664
+ transform(chunk, state, controller) {
665
+ state.sourceValue = chunk;
666
+ state.hasSourceValue = true;
667
+ if (!state.seenLatest.every(Boolean)) {
668
+ return;
669
+ }
670
+ controller.enqueue(toCombinationTuple(state.sourceValue, state.latestValues));
671
+ },
672
+ async flush(state) {
673
+ state.sourceCompleted = true;
674
+ await Promise.allSettled([...state.tasks]);
675
+ },
676
+ async cancel(state) {
677
+ state.isCancelled = true;
678
+ await Promise.allSettled(state.iterators.map((iterator) => cancelIterator(iterator)));
679
+ await Promise.allSettled([...state.tasks]);
680
+ state.tasks.clear();
681
+ state.iterators.length = 0;
682
+ },
683
+ });
684
+ }
685
+ /**
686
+ * Pairs source values with companion values in strict arrival order.
687
+ *
688
+ * `zipWith` waits until it has one value from the source and one value from
689
+ * each companion. It then emits a tuple and removes those values from the
690
+ * queues before waiting for the next full set.
691
+ *
692
+ * @typeParam T - Value type from the primary source
693
+ * @typeParam TOthers - Value types from the companion Observables
694
+ * @param others - Companion Observables to align by arrival order
695
+ * @returns An operator that emits tuples aligned by position instead of time
696
+ */
697
+ export function zipWith(...others) {
698
+ return createStatefulOperator({
699
+ name: "zipWith",
700
+ errorMode: "pass-through",
701
+ createState: () => ({
702
+ sourceQueue: createQueue(INITIAL_ZIP_QUEUE_CAPACITY),
703
+ otherQueues: others.map(() => createQueue(INITIAL_ZIP_QUEUE_CAPACITY)),
704
+ sourceCompleted: false,
705
+ otherCompleted: others.map(() => false),
706
+ iterators: [],
707
+ tasks: new Set(),
708
+ isCancelled: false,
709
+ isTerminated: false,
710
+ }),
711
+ start(state, controller) {
712
+ /**
713
+ * Emits as many full zip tuples as are currently available.
714
+ *
715
+ * The termination logic is important: once a completed companion has no
716
+ * queued value left, zip can never produce another full tuple, so the
717
+ * operator terminates immediately instead of waiting for the source to end.
718
+ */
719
+ const emitAvailable = () => {
720
+ while (!state.isTerminated &&
721
+ hasQueuedValue(state.sourceQueue) &&
722
+ state.otherQueues.every((queue) => hasQueuedValue(queue))) {
723
+ controller.enqueue(toCombinationTuple(dequeue(state.sourceQueue), shiftQueuedValues(state.otherQueues)));
724
+ }
725
+ const blockedByCompletedCompanion = state.otherCompleted.some((completed, index) => completed && !hasQueuedValue(state.otherQueues[index]));
726
+ if (!state.isTerminated &&
727
+ (blockedByCompletedCompanion ||
728
+ (state.sourceCompleted && !hasQueuedValue(state.sourceQueue)))) {
729
+ state.isTerminated = true;
730
+ controller.terminate();
731
+ }
732
+ };
733
+ others.forEach((observable, index) => {
734
+ const iterator = pull(observable, { throwError: false })[Symbol.asyncIterator]();
735
+ state.iterators[index] = iterator;
736
+ const taskRef = { current: null };
737
+ const task = (async () => {
738
+ try {
739
+ while (!state.isCancelled && !state.isTerminated) {
740
+ const { value, done } = await iterator.next();
741
+ if (done || state.isCancelled || state.isTerminated) {
742
+ break;
743
+ }
744
+ if (isObservableError(value)) {
745
+ try {
746
+ controller.enqueue(value);
747
+ }
748
+ catch {
749
+ break;
750
+ }
751
+ continue;
752
+ }
753
+ state.otherQueues[index] = enqueueQueuedValue(state.otherQueues[index], value);
754
+ emitAvailable();
755
+ }
756
+ }
757
+ catch (err) {
758
+ if (!state.isCancelled && !state.isTerminated) {
759
+ try {
760
+ controller.enqueue(ObservableError.from(err, "operator:stateful:zipWith:companion", { companionIndex: index }));
761
+ }
762
+ catch {
763
+ // Downstream cancellation already decided the stream outcome.
764
+ }
765
+ }
766
+ }
767
+ finally {
768
+ state.otherCompleted[index] = true;
769
+ if (taskRef.current) {
770
+ state.tasks.delete(taskRef.current);
771
+ }
772
+ if (!state.isCancelled && !state.isTerminated) {
773
+ emitAvailable();
774
+ }
775
+ }
776
+ })();
777
+ taskRef.current = task;
778
+ state.tasks.add(task);
779
+ });
780
+ },
781
+ transform(chunk, state, controller) {
782
+ state.sourceQueue = enqueueQueuedValue(state.sourceQueue, chunk);
783
+ while (!state.isTerminated &&
784
+ hasQueuedValue(state.sourceQueue) &&
785
+ state.otherQueues.every((queue) => hasQueuedValue(queue))) {
786
+ controller.enqueue(toCombinationTuple(dequeue(state.sourceQueue), shiftQueuedValues(state.otherQueues)));
787
+ }
788
+ const blockedByCompletedCompanion = state.otherCompleted.some((completed, index) => completed && !hasQueuedValue(state.otherQueues[index]));
789
+ if (!state.isTerminated && blockedByCompletedCompanion) {
790
+ state.isTerminated = true;
791
+ controller.terminate();
792
+ }
793
+ },
794
+ async flush(state, controller) {
795
+ state.sourceCompleted = true;
796
+ if (!state.isTerminated && !hasQueuedValue(state.sourceQueue)) {
797
+ state.isTerminated = true;
798
+ controller.terminate();
799
+ }
800
+ await Promise.allSettled([...state.tasks]);
801
+ },
802
+ async cancel(state) {
803
+ state.isCancelled = true;
804
+ await Promise.allSettled(state.iterators.map((iterator) => cancelIterator(iterator)));
805
+ await Promise.allSettled([...state.tasks]);
806
+ state.tasks.clear();
807
+ state.iterators.length = 0;
808
+ },
809
+ });
810
+ }
811
+ /**
812
+ * Mirrors whichever Observable emits first: the source or one of the
813
+ * companions.
814
+ *
815
+ * If the primary source wins, future source values keep flowing and companion
816
+ * Observables are cancelled. If a companion wins, the source is ignored from
817
+ * that point on and the winning companion continues to drive the output.
818
+ *
819
+ * @typeParam T - Value type from the primary source
820
+ * @typeParam TOthers - Value types from the companion Observables
821
+ * @param others - Companion Observables racing against the source
822
+ * @returns An operator that mirrors the winning Observable
823
+ */
824
+ export function raceWith(...others) {
825
+ return createStatefulOperator({
826
+ name: "raceWith",
827
+ errorMode: "pass-through",
828
+ createState: () => ({
829
+ winner: null,
830
+ iterators: [],
831
+ tasks: new Set(),
832
+ sourceCompleted: false,
833
+ isCancelled: false,
834
+ }),
835
+ start(state, controller) {
836
+ /**
837
+ * Cancels every companion except the winning one.
838
+ *
839
+ * `raceWith()` is intentionally ruthless: once the first value arrives,
840
+ * every loser is stopped so only one Observable continues to drive output.
841
+ */
842
+ const cancelLosers = async (winnerIndex) => {
843
+ const loserIterators = state.iterators.filter((_, index) => index !== winnerIndex);
844
+ await Promise.allSettled(loserIterators.map((iterator) => cancelIterator(iterator)));
845
+ };
846
+ others.forEach((observable, index) => {
847
+ const iterator = pull(observable, { throwError: false })[Symbol.asyncIterator]();
848
+ state.iterators[index] = iterator;
849
+ const taskRef = { current: null };
850
+ const task = (async () => {
851
+ try {
852
+ while (!state.isCancelled) {
853
+ const { value, done } = await iterator.next();
854
+ if (done || state.isCancelled) {
855
+ break;
856
+ }
857
+ if (state.winner === null) {
858
+ state.winner = index;
859
+ await cancelLosers(index);
860
+ }
861
+ if (state.winner !== index) {
862
+ break;
863
+ }
864
+ try {
865
+ controller.enqueue(value);
866
+ }
867
+ catch {
868
+ break;
869
+ }
870
+ }
871
+ }
872
+ catch (err) {
873
+ if (!state.isCancelled && state.winner === index) {
874
+ try {
875
+ controller.enqueue(ObservableError.from(err, "operator:stateful:raceWith:companion", { companionIndex: index }));
876
+ }
877
+ catch {
878
+ // Downstream cancellation already decided the stream outcome.
879
+ }
880
+ }
881
+ }
882
+ finally {
883
+ if (taskRef.current) {
884
+ state.tasks.delete(taskRef.current);
885
+ }
886
+ }
887
+ })();
888
+ taskRef.current = task;
889
+ state.tasks.add(task);
890
+ });
891
+ },
892
+ async transform(chunk, state, controller) {
893
+ if (state.winner === null) {
894
+ state.winner = "source";
895
+ await Promise.allSettled(state.iterators.map((iterator) => cancelIterator(iterator)));
896
+ }
897
+ if (state.winner !== "source") {
898
+ return;
899
+ }
900
+ controller.enqueue(chunk);
901
+ },
902
+ async flush(state) {
903
+ state.sourceCompleted = true;
904
+ if (state.winner === "source") {
905
+ return;
906
+ }
907
+ await Promise.allSettled([...state.tasks]);
908
+ },
909
+ async cancel(state) {
910
+ state.isCancelled = true;
911
+ await Promise.allSettled(state.iterators.map((iterator) => cancelIterator(iterator)));
912
+ await Promise.allSettled([...state.tasks]);
913
+ state.tasks.clear();
914
+ state.iterators.length = 0;
915
+ },
916
+ });
917
+ }