@proj-airi/stream-kit 0.0.0-stage → 0.15.2

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/dist/index.mjs ADDED
@@ -0,0 +1,778 @@
1
+ //#region src/batcher.ts
2
+ const scheduleTimeout = (callback, delayMs) => {
3
+ const timer = setTimeout(callback, delayMs);
4
+ return () => clearTimeout(timer);
5
+ };
6
+ /**
7
+ * Groups items into batches, like `groupedWithin`. A batch flushes when it has `maxItems` items, or `maxWaitMs`
8
+ * after its first item, whichever comes first.
9
+ *
10
+ * @example
11
+ * const batcher = createBatcher<TraceRecord>({ maxWaitMs: 50, maxItems: 500, flush: records => send({ records }) })
12
+ * batcher.push(record)
13
+ */
14
+ function createBatcher(options) {
15
+ const maxItems = options.maxItems ?? Number.POSITIVE_INFINITY;
16
+ if (!Number.isFinite(options.maxWaitMs) || options.maxWaitMs < 0) throw new Error("maxWaitMs must be finite and nonnegative");
17
+ if (maxItems < 1) throw new Error("maxItems must be at least 1");
18
+ const schedule = options.schedule ?? scheduleTimeout;
19
+ let items = [];
20
+ let deadline = 0;
21
+ let cancelTimer;
22
+ let closed = false;
23
+ function flush() {
24
+ cancelTimer?.();
25
+ cancelTimer = void 0;
26
+ if (items.length === 0) return;
27
+ const batch = items;
28
+ items = [];
29
+ options.flush(batch);
30
+ }
31
+ function push(item) {
32
+ if (closed) return;
33
+ if (items.length > 0 && options.now && options.now() >= deadline) flush();
34
+ items.push(item);
35
+ if (items.length >= maxItems) {
36
+ flush();
37
+ return;
38
+ }
39
+ if (items.length === 1) {
40
+ deadline = (options.now?.() ?? 0) + options.maxWaitMs;
41
+ cancelTimer = schedule(flush, options.maxWaitMs);
42
+ }
43
+ }
44
+ return {
45
+ push,
46
+ flush,
47
+ close() {
48
+ closed = true;
49
+ flush();
50
+ }
51
+ };
52
+ }
53
+ //#endregion
54
+ //#region src/catch-up-feed.ts
55
+ /**
56
+ * A live feed in which a new subscriber first receives the backlog, then `caughtUp`, then the live messages.
57
+ *
58
+ * Guarantee: no message is lost or repeated between the backlog and the live messages, if the owner updates the
59
+ * state behind `backlog` before each `publish`. `subscribe` copies the backlog first. A `publish` during the backlog
60
+ * delivery, for example from a listener, waits and goes to the new subscriber after `caughtUp`. A subscriber added
61
+ * during a `publish` does not receive that message live, because its backlog already has it.
62
+ *
63
+ * The backlog and `caughtUp` always arrive synchronously inside `subscribe`. The strategy applies to live messages.
64
+ *
65
+ * @example
66
+ * const feed = createCatchUpFeed<Message>({ backlog: () => buffer.values(), caughtUp: () => ({ kind: 'caughtUp' }) })
67
+ * buffer.push(record)
68
+ * feed.publish({ kind: 'record', record })
69
+ */
70
+ function createCatchUpFeed(options) {
71
+ const subscribers = /* @__PURE__ */ new Set();
72
+ function publish(message) {
73
+ for (const subscriber of [...subscribers]) {
74
+ if (!subscriber.active) continue;
75
+ if (subscriber.held) subscriber.held.push(message);
76
+ else subscriber.deliver(message);
77
+ }
78
+ }
79
+ function subscribe(listener, subscribeOptions = {}) {
80
+ const strategy = subscribeOptions.strategy ?? "unbounded";
81
+ const subscriber = {
82
+ listener,
83
+ active: true,
84
+ held: [],
85
+ deliver: listener
86
+ };
87
+ if (strategy !== "unbounded") subscriber.deliver = createBoundedDelivery(subscriber, strategy, subscribeOptions.capacity ?? 256);
88
+ const backlog = [...options.backlog()];
89
+ if (options.caughtUp) backlog.push(options.caughtUp());
90
+ subscribers.add(subscriber);
91
+ const unsubscribe = () => {
92
+ subscriber.active = false;
93
+ subscriber.held = void 0;
94
+ subscribers.delete(subscriber);
95
+ };
96
+ try {
97
+ for (const message of backlog) listener(message);
98
+ const held = subscriber.held;
99
+ for (let index = 0; index < held.length; index++) subscriber.deliver(held[index]);
100
+ subscriber.held = void 0;
101
+ } catch (error) {
102
+ unsubscribe();
103
+ throw error;
104
+ }
105
+ return { unsubscribe };
106
+ }
107
+ return {
108
+ publish,
109
+ subscribe,
110
+ get subscribers() {
111
+ return subscribers.size;
112
+ }
113
+ };
114
+ }
115
+ function createBoundedDelivery(subscriber, strategy, capacity) {
116
+ if (!Number.isInteger(capacity) || capacity < 1) throw new Error("capacity must be a positive integer");
117
+ const queue = [];
118
+ let scheduled = false;
119
+ function drain() {
120
+ scheduled = false;
121
+ while (subscriber.active && queue.length > 0) {
122
+ const message = queue.shift();
123
+ try {
124
+ subscriber.listener(message);
125
+ } catch (error) {
126
+ if (queue.length > 0) schedule();
127
+ throw error;
128
+ }
129
+ }
130
+ queue.length = 0;
131
+ }
132
+ function schedule() {
133
+ if (!scheduled) {
134
+ scheduled = true;
135
+ queueMicrotask(drain);
136
+ }
137
+ }
138
+ return (message) => {
139
+ if (queue.length >= capacity) {
140
+ if (strategy === "dropping") return;
141
+ queue.shift();
142
+ }
143
+ queue.push(message);
144
+ schedule();
145
+ };
146
+ }
147
+ //#endregion
148
+ //#region src/cooldown.ts
149
+ /**
150
+ * Limits an action to once per window for each key, for example one flight recorder dump per source.
151
+ *
152
+ * The cooldown keeps one time for each key that it has seen.
153
+ *
154
+ * @example
155
+ * const cooldown = createCooldown({ ms: 30_000 })
156
+ * if (cooldown.tryAcquire(source, now))
157
+ * dump()
158
+ */
159
+ function createCooldown(options) {
160
+ const acquiredAt = /* @__PURE__ */ new Map();
161
+ return { tryAcquire(key, now) {
162
+ const last = acquiredAt.get(key);
163
+ if (last !== void 0 && now - last < options.ms) return false;
164
+ acquiredAt.set(key, now);
165
+ return true;
166
+ } };
167
+ }
168
+ //#endregion
169
+ //#region src/demand-union.ts
170
+ const ALL = "*";
171
+ /**
172
+ * The union of what each member wants, for example the tracks of all viewers.
173
+ *
174
+ * A producer reads the union to skip work that no member wants. One `'*'` member makes the union `'*'`.
175
+ *
176
+ * @example
177
+ * const demand = new DemandUnion<string>({ onChange: union => source.setWanted(union) })
178
+ * demand.set('viewer-1', ['audio.level'])
179
+ * demand.set('viewer-2', '*') // onChange('*')
180
+ * demand.delete('viewer-2') // onChange(Set { 'audio.level' })
181
+ */
182
+ var DemandUnion = class {
183
+ options;
184
+ members = /* @__PURE__ */ new Map();
185
+ current = /* @__PURE__ */ new Set();
186
+ constructor(options = {}) {
187
+ this.options = options;
188
+ }
189
+ get union() {
190
+ return this.current;
191
+ }
192
+ set(id, demand) {
193
+ this.members.set(id, demand === ALL ? ALL : new Set(demand));
194
+ this.update();
195
+ }
196
+ delete(id) {
197
+ if (this.members.delete(id)) this.update();
198
+ }
199
+ update() {
200
+ const next = this.compute();
201
+ if (sameDemand(next, this.current)) return;
202
+ this.current = next;
203
+ this.options.onChange?.(next);
204
+ }
205
+ compute() {
206
+ const union = /* @__PURE__ */ new Set();
207
+ for (const demand of this.members.values()) {
208
+ if (demand === ALL) return ALL;
209
+ for (const key of demand) union.add(key);
210
+ }
211
+ return union;
212
+ }
213
+ };
214
+ function sameDemand(a, b) {
215
+ if (a === ALL || b === ALL) return a === b;
216
+ if (a.size !== b.size) return false;
217
+ for (const key of a) if (!b.has(key)) return false;
218
+ return true;
219
+ }
220
+ //#endregion
221
+ //#region src/latest.ts
222
+ /**
223
+ * Creates a runner that keeps only the newest input.
224
+ *
225
+ * @example
226
+ * const runner = createLatest<string>(async (text, signal) => cache.set(text, await rephrase(text, signal)), { debounceMs: 600 })
227
+ * runner.push('hello')
228
+ * runner.push('hello world') // aborts the run for 'hello'
229
+ */
230
+ function createLatest(run, options = {}) {
231
+ const debounceMs = options.debounceMs ?? 0;
232
+ if (!Number.isFinite(debounceMs) || debounceMs < 0) throw new Error("Debounce must be finite and nonnegative");
233
+ const closed = new AbortController();
234
+ const key = options.key ?? ((value) => value);
235
+ const none = Symbol("none");
236
+ let lastKey = none;
237
+ let timer;
238
+ let active;
239
+ let running;
240
+ let waiting = Promise.withResolvers();
241
+ let pending = false;
242
+ function settleIfIdle() {
243
+ if (!pending && !running) {
244
+ waiting.resolve();
245
+ waiting = Promise.withResolvers();
246
+ }
247
+ }
248
+ function start(value) {
249
+ pending = false;
250
+ const controller = new AbortController();
251
+ active = controller;
252
+ const signal = AbortSignal.any([controller.signal, closed.signal]);
253
+ running = (async () => {
254
+ try {
255
+ await run(value, signal);
256
+ } catch (error) {
257
+ if (!signal.aborted) options.onError?.(error);
258
+ } finally {
259
+ if (active === controller) {
260
+ active = void 0;
261
+ running = void 0;
262
+ }
263
+ settleIfIdle();
264
+ }
265
+ })();
266
+ }
267
+ function push(value) {
268
+ if (closed.signal.aborted) return;
269
+ const next = key(value);
270
+ if (Object.is(next, lastKey)) return;
271
+ lastKey = next;
272
+ clearTimeout(timer);
273
+ active?.abort("Superseded by a newer input");
274
+ active = void 0;
275
+ running = void 0;
276
+ pending = true;
277
+ if (debounceMs > 0) timer = setTimeout(start, debounceMs, value);
278
+ else start(value);
279
+ }
280
+ function cancelPending() {
281
+ if (!pending || running) return;
282
+ clearTimeout(timer);
283
+ pending = false;
284
+ lastKey = none;
285
+ settleIfIdle();
286
+ }
287
+ function close(reason = "Runner closed") {
288
+ if (closed.signal.aborted) return;
289
+ clearTimeout(timer);
290
+ pending = false;
291
+ closed.abort(reason);
292
+ active = void 0;
293
+ running = void 0;
294
+ settleIfIdle();
295
+ }
296
+ options.signal?.addEventListener("abort", () => close(typeof options.signal?.reason === "string" ? options.signal.reason : void 0), {
297
+ once: true,
298
+ signal: closed.signal
299
+ });
300
+ if (options.signal?.aborted) close();
301
+ return {
302
+ push,
303
+ cancelPending,
304
+ idle: () => pending || running ? waiting.promise : Promise.resolve(),
305
+ close
306
+ };
307
+ }
308
+ //#endregion
309
+ //#region src/lease-table.ts
310
+ /**
311
+ * Keys that stay alive while their owner renews them, for example a remote source with a heartbeat, or a tab that
312
+ * repeats its announcement. The caller decides when to call `expired`, usually from a periodic tick.
313
+ *
314
+ * @example
315
+ * const leases = createLeaseTable<string>({ ttlMs: 12_000 })
316
+ * leases.touch('tab-1', now)
317
+ * for (const tab of leases.expired(later))
318
+ * forgetTab(tab)
319
+ */
320
+ function createLeaseTable(options) {
321
+ const touchedAt = /* @__PURE__ */ new Map();
322
+ return {
323
+ touch(key, now) {
324
+ touchedAt.set(key, now);
325
+ },
326
+ expired(now) {
327
+ const keys = [];
328
+ for (const [key, at] of touchedAt) if (now - at > options.ttlMs) keys.push(key);
329
+ for (const key of keys) touchedAt.delete(key);
330
+ return keys;
331
+ },
332
+ delete(key) {
333
+ touchedAt.delete(key);
334
+ },
335
+ has: (key) => touchedAt.has(key),
336
+ keys: () => touchedAt.keys()
337
+ };
338
+ }
339
+ //#endregion
340
+ //#region src/queue.ts
341
+ function createQueue(options) {
342
+ const queue = [];
343
+ let draining = false;
344
+ const internalEventListeners = {
345
+ enqueue: [],
346
+ dequeue: [],
347
+ process: [],
348
+ error: [],
349
+ result: [],
350
+ drain: []
351
+ };
352
+ const internalHandlerEventListeners = {};
353
+ function on(eventName, listener) {
354
+ internalEventListeners[eventName].push(listener);
355
+ }
356
+ function excludeFirstArgument([, ...params]) {
357
+ return params;
358
+ }
359
+ function emit(...args) {
360
+ switch (args[0]) {
361
+ case "enqueue":
362
+ internalEventListeners.enqueue.forEach((listener) => listener(...excludeFirstArgument(args)));
363
+ break;
364
+ case "dequeue":
365
+ internalEventListeners.dequeue.forEach((listener) => listener(...excludeFirstArgument(args)));
366
+ break;
367
+ case "process":
368
+ internalEventListeners.process.forEach((listener) => listener(...excludeFirstArgument(args)));
369
+ break;
370
+ case "error":
371
+ internalEventListeners.error.forEach((listener) => listener(...excludeFirstArgument(args)));
372
+ break;
373
+ case "result":
374
+ internalEventListeners.result.forEach((listener) => listener(...excludeFirstArgument(args)));
375
+ break;
376
+ case "drain": internalEventListeners.drain.forEach((listener) => listener(...excludeFirstArgument(args)));
377
+ }
378
+ }
379
+ function onHandlerEvent(eventName, listener) {
380
+ internalHandlerEventListeners[eventName] = internalHandlerEventListeners[eventName] || [];
381
+ internalHandlerEventListeners[eventName].push(listener);
382
+ }
383
+ function emitHandlerEvent(eventName, ...params) {
384
+ (internalHandlerEventListeners[eventName] || []).forEach((listener) => listener(...params));
385
+ }
386
+ function enqueue(payload) {
387
+ queue.push(payload);
388
+ emit("enqueue", payload, queue.length);
389
+ if (!draining) {
390
+ draining = true;
391
+ drain();
392
+ }
393
+ }
394
+ function clear() {
395
+ queue.length = 0;
396
+ }
397
+ async function drain() {
398
+ while (queue.length > 0) {
399
+ const payload = queue.shift();
400
+ emit("dequeue", payload, queue.length);
401
+ for (const handler of options.handlers) {
402
+ emit("process", payload, handler);
403
+ try {
404
+ emit("result", payload, await handler({
405
+ data: payload,
406
+ emit: emitHandlerEvent
407
+ }), handler);
408
+ } catch (err) {
409
+ emit("error", payload, err, handler);
410
+ continue;
411
+ }
412
+ }
413
+ }
414
+ draining = false;
415
+ emit("drain");
416
+ }
417
+ function length() {
418
+ return queue.length;
419
+ }
420
+ return {
421
+ enqueue,
422
+ clear,
423
+ length,
424
+ on,
425
+ onHandlerEvent
426
+ };
427
+ }
428
+ //#endregion
429
+ //#region src/retention-buffer.ts
430
+ /** Compacts the storage when this many dropped slots wait at its head. */
431
+ const COMPACT_AFTER = 1024;
432
+ /**
433
+ * Keeps the newest items within a byte limit and an age limit, in insertion order.
434
+ *
435
+ * Only the head can drop, so the age limit assumes that `ageOf` grows with insertion order. An older item behind a
436
+ * newer one stays until the newer one drops. A `push` applies only the byte limit, because the age limit needs `now`.
437
+ * One item that is larger than `maxBytes` drops at once. Eviction costs time for the dropped items only.
438
+ *
439
+ * @example
440
+ * const buffer = new RetentionBuffer<Entry>({ maxBytes: 8_000_000, maxAgeMs: 600_000, sizeOf: e => e.bytes, ageOf: e => e.observedAt })
441
+ * buffer.push(entry)
442
+ * buffer.evict(now) // call it from a periodic tick
443
+ */
444
+ var RetentionBuffer = class {
445
+ options;
446
+ entries = [];
447
+ head = 0;
448
+ totalBytes = 0;
449
+ constructor(options) {
450
+ this.options = options;
451
+ }
452
+ get size() {
453
+ return this.entries.length - this.head;
454
+ }
455
+ get bytes() {
456
+ return this.totalBytes;
457
+ }
458
+ push(item) {
459
+ const bytes = this.options.sizeOf(item);
460
+ this.entries.push({
461
+ item,
462
+ bytes
463
+ });
464
+ this.totalBytes += bytes;
465
+ this.evictWhile(() => this.totalBytes > (this.options.maxBytes ?? Number.POSITIVE_INFINITY));
466
+ }
467
+ /** Drops the items older than the age limit, then the oldest items over the byte limit. Returns the dropped items. */
468
+ evict(now) {
469
+ const cutoff = now - (this.options.maxAgeMs ?? Number.POSITIVE_INFINITY);
470
+ const maxBytes = this.options.maxBytes ?? Number.POSITIVE_INFINITY;
471
+ return this.evictWhile((entry) => this.options.ageOf(entry.item) < cutoff || this.totalBytes > maxBytes);
472
+ }
473
+ /** The kept items, oldest first. Do not push or evict while you iterate. */
474
+ *values() {
475
+ for (let index = this.head; index < this.entries.length; index++) yield this.entries[index].item;
476
+ }
477
+ /** Drops every item without `onEvict`. */
478
+ clear() {
479
+ this.entries = [];
480
+ this.head = 0;
481
+ this.totalBytes = 0;
482
+ }
483
+ evictWhile(shouldDrop) {
484
+ const dropped = [];
485
+ while (this.head < this.entries.length) {
486
+ const entry = this.entries[this.head];
487
+ if (!shouldDrop(entry)) break;
488
+ this.totalBytes -= entry.bytes;
489
+ dropped.push(entry.item);
490
+ this.head++;
491
+ }
492
+ if (dropped.length === 0) return dropped;
493
+ if (this.head >= COMPACT_AFTER && this.head * 2 >= this.entries.length) {
494
+ this.entries = this.entries.slice(this.head);
495
+ this.head = 0;
496
+ }
497
+ this.options.onEvict?.(dropped);
498
+ return dropped;
499
+ }
500
+ };
501
+ //#endregion
502
+ //#region src/scope.ts
503
+ /**
504
+ * Creates a scope that closes when `parent` aborts.
505
+ *
506
+ * @example
507
+ * const scope = createScope(options.signal)
508
+ * scope.defer(() => track.stop())
509
+ * await scope.close('Recording finished')
510
+ */
511
+ function createScope(parent, onCleanupError = console.error) {
512
+ const controller = new AbortController();
513
+ const cleanups = [];
514
+ const settled = Promise.withResolvers();
515
+ async function runCleanups() {
516
+ for (const cleanup of cleanups.splice(0).reverse()) try {
517
+ await cleanup();
518
+ } catch (error) {
519
+ onCleanupError(error);
520
+ }
521
+ settled.resolve();
522
+ }
523
+ function close(reason) {
524
+ if (!controller.signal.aborted) {
525
+ controller.abort(reason);
526
+ runCleanups();
527
+ }
528
+ return settled.promise;
529
+ }
530
+ parent?.addEventListener("abort", () => void close(parent.reason), {
531
+ once: true,
532
+ signal: controller.signal
533
+ });
534
+ if (parent?.aborted) close(parent.reason);
535
+ return {
536
+ signal: controller.signal,
537
+ closed: settled.promise,
538
+ defer(cleanup) {
539
+ if (controller.signal.aborted) Promise.resolve().then(cleanup).catch(onCleanupError);
540
+ else cleanups.push(cleanup);
541
+ },
542
+ child() {
543
+ return createScope(controller.signal, onCleanupError);
544
+ },
545
+ close
546
+ };
547
+ }
548
+ //#endregion
549
+ //#region src/sequence-tracker.ts
550
+ /**
551
+ * Accepts each sequence number once, in any arrival order.
552
+ *
553
+ * Numbers below `nextSeq` and repeats are not new. A number above `nextSeq` waits in a set until the numbers before
554
+ * it arrive. `nextSeq - 1` is the end of the unbroken prefix, so a reader can resume from it.
555
+ *
556
+ * @example
557
+ * const tracker = new SequenceTracker()
558
+ * tracker.accept(1) // => true, waits for 0
559
+ * tracker.accept(0) // => true, nextSeq is now 2
560
+ * tracker.accept(1) // => false
561
+ */
562
+ var SequenceTracker = class {
563
+ next;
564
+ ahead = /* @__PURE__ */ new Set();
565
+ constructor(firstSeq = 0) {
566
+ this.next = firstSeq;
567
+ }
568
+ /** The lowest number that did not arrive yet. */
569
+ get nextSeq() {
570
+ return this.next;
571
+ }
572
+ /** Returns true when `seq` is new. */
573
+ accept(seq) {
574
+ if (seq < this.next || this.ahead.has(seq)) return false;
575
+ this.ahead.add(seq);
576
+ while (this.ahead.delete(this.next)) this.next++;
577
+ return true;
578
+ }
579
+ };
580
+ //#endregion
581
+ //#region src/stream.ts
582
+ /**
583
+ * The optional cancellation handler owns resources that feed this readable output.
584
+ * `strategy` measures unread values. Writers check `desiredSize` because a push source cannot wait.
585
+ */
586
+ function createPushStream(onCancel, strategy) {
587
+ let closed = false;
588
+ let controller = null;
589
+ return {
590
+ stream: new ReadableStream({
591
+ start(ctrl) {
592
+ controller = ctrl;
593
+ },
594
+ cancel(reason) {
595
+ closed = true;
596
+ onCancel?.(reason);
597
+ }
598
+ }, strategy),
599
+ write(value) {
600
+ if (!controller || closed) return;
601
+ controller.enqueue(value);
602
+ },
603
+ close() {
604
+ if (!controller || closed) return;
605
+ closed = true;
606
+ controller.close();
607
+ },
608
+ error(err) {
609
+ if (!controller || closed) return;
610
+ closed = true;
611
+ controller.error(err);
612
+ },
613
+ isClosed() {
614
+ return closed;
615
+ },
616
+ desiredSize() {
617
+ return controller?.desiredSize ?? null;
618
+ }
619
+ };
620
+ }
621
+ async function readStream(stream, handler) {
622
+ const reader = stream.getReader();
623
+ try {
624
+ while (true) {
625
+ const { value, done } = await reader.read();
626
+ if (done) break;
627
+ await handler(value);
628
+ }
629
+ } finally {
630
+ reader.releaseLock();
631
+ }
632
+ }
633
+ //#endregion
634
+ //#region src/task.ts
635
+ function reasonFrom(value, fallback) {
636
+ if (typeof value === "string") return value;
637
+ if (value instanceof Error) return value.message;
638
+ return fallback;
639
+ }
640
+ /**
641
+ * Creates a task that settles by an explicit call.
642
+ *
643
+ * @example
644
+ * const task = createTask<string>({ signal: parent.signal, timeoutMs: 5000 })
645
+ * task.defer(() => socket.close())
646
+ * task.finish('ok')
647
+ * await task.done // => { status: 'finished', value: 'ok' }
648
+ */
649
+ function createTask(options = {}) {
650
+ if (options.timeoutMs !== void 0 && (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0)) throw new Error("Task timeout must be finite and nonnegative");
651
+ const controller = new AbortController();
652
+ const completion = Promise.withResolvers();
653
+ const cleanups = [];
654
+ const cleaned = Promise.withResolvers();
655
+ const onCleanupError = options.onCleanupError ?? console.error;
656
+ const parent = options.signal;
657
+ let settled = false;
658
+ let timer;
659
+ async function runCleanups() {
660
+ for (const cleanup of cleanups.splice(0).reverse()) try {
661
+ await cleanup();
662
+ } catch (error) {
663
+ onCleanupError(error);
664
+ }
665
+ cleaned.resolve();
666
+ }
667
+ function settle(outcome) {
668
+ if (settled) return;
669
+ settled = true;
670
+ clearTimeout(timer);
671
+ parent?.removeEventListener("abort", onParentAbort);
672
+ if (outcome.status !== "finished") controller.abort(outcome.status === "failed" ? outcome.error : outcome.reason);
673
+ completion.resolve(outcome);
674
+ runCleanups();
675
+ }
676
+ function onParentAbort() {
677
+ settle({
678
+ status: "cancelled",
679
+ reason: reasonFrom(parent?.reason, "Parent task closed")
680
+ });
681
+ }
682
+ const task = {
683
+ signal: controller.signal,
684
+ done: completion.promise,
685
+ closed: cleaned.promise,
686
+ get settled() {
687
+ return settled;
688
+ },
689
+ defer(cleanup) {
690
+ if (settled) Promise.resolve().then(cleanup).catch(onCleanupError);
691
+ else cleanups.push(cleanup);
692
+ },
693
+ finish: (value) => settle({
694
+ status: "finished",
695
+ value
696
+ }),
697
+ cancel: (reason) => settle({
698
+ status: "cancelled",
699
+ reason
700
+ }),
701
+ fail: (error) => settle({
702
+ status: "failed",
703
+ error
704
+ })
705
+ };
706
+ if (parent?.aborted) {
707
+ onParentAbort();
708
+ return task;
709
+ }
710
+ parent?.addEventListener("abort", onParentAbort, { once: true });
711
+ if (options.timeoutMs !== void 0) timer = setTimeout(() => task.fail(/* @__PURE__ */ new Error("Task timed out")), options.timeoutMs);
712
+ return task;
713
+ }
714
+ /**
715
+ * Runs `work` as one task. The returned value finishes the task, and a thrown error fails it.
716
+ * A call to `cancel` or `fail` inside `work` wins over its later return.
717
+ *
718
+ * @example
719
+ * const task = runTask(async ({ signal }) => fetch(url, { signal }).then(response => response.text()))
720
+ * const outcome = await task.done
721
+ */
722
+ function runTask(work, options = {}) {
723
+ const task = createTask(options);
724
+ if (task.settled) return task;
725
+ (async () => {
726
+ try {
727
+ task.finish(await work(task));
728
+ } catch (cause) {
729
+ task.fail(cause instanceof Error ? cause : new Error("Task failed", { cause }));
730
+ }
731
+ })();
732
+ return task;
733
+ }
734
+ /**
735
+ * Merges outcomes. A failure wins over a cancellation, and a cancellation wins over success.
736
+ *
737
+ * @example
738
+ * worstOutcome([{ status: 'finished', value: undefined }, { status: 'cancelled', reason: 'Closed' }])
739
+ * // => { status: 'cancelled', reason: 'Closed' }
740
+ */
741
+ function worstOutcome(outcomes) {
742
+ const failed = outcomes.find((outcome) => outcome.status === "failed");
743
+ if (failed) return failed;
744
+ return outcomes.find((outcome) => outcome.status === "cancelled") ?? {
745
+ status: "finished",
746
+ value: void 0
747
+ };
748
+ }
749
+ //#endregion
750
+ //#region src/watermark.ts
751
+ /**
752
+ * Tracks the newest event time of a stream and marks events that arrive too late.
753
+ *
754
+ * A late event is still an event. The caller decides what to do with it, for example count it or flag it in a view.
755
+ *
756
+ * @example
757
+ * const watermark = new Watermark({ allowedLatenessMs: 2000 })
758
+ * watermark.observe(10_000) // => { late: false }
759
+ * watermark.observe(7_000) // => { late: true }
760
+ */
761
+ var Watermark = class {
762
+ options;
763
+ newestTime = Number.NEGATIVE_INFINITY;
764
+ constructor(options) {
765
+ this.options = options;
766
+ }
767
+ /** The newest time observed. `-Infinity` before the first event. */
768
+ get newest() {
769
+ return this.newestTime;
770
+ }
771
+ observe(time) {
772
+ const late = time < this.newestTime - this.options.allowedLatenessMs;
773
+ this.newestTime = Math.max(this.newestTime, time);
774
+ return { late };
775
+ }
776
+ };
777
+ //#endregion
778
+ export { DemandUnion, RetentionBuffer, SequenceTracker, Watermark, createBatcher, createCatchUpFeed, createCooldown, createLatest, createLeaseTable, createPushStream, createQueue, createScope, createTask, readStream, runTask, worstOutcome };