@yoltra/core 0.5.0 → 0.7.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.
@@ -1,4 +1,6 @@
1
- import { Event, EventMapBase, EventKey, EventUnion, Change, DeepReadonly, EffectSpec, EventMeta, MiddlewareInput, ReducersMapAny, ReducerSpec, StateFromReducers, StoreInstance, StoreSpec, Unsubscribe, EMFromReducersStrict, Emit, EmitOptions, InstrumentationObserver, EventPhase, NarrowedEventHandler, When } from '../types.js';
1
+ import { Event, EventMapBase, EventKey, EventUnion, Change, DeepReadonly, EffectSpec, EventMeta, MiddlewareInput, ReducersMapAny, ReducerSpec, StateFromReducers, StoreInstance, StoreSpec, Unsubscribe, EMFromReducersStrict, Emit, EmitOptions, EmitResult, ConnectOptions, InstrumentationObserver, CascadeInfo, EventPhase, NarrowedEventHandler, When } from '../types.js';
2
+ import { CallHandle, CallOptions } from './call.js';
3
+ import { Rejection } from './rejection.js';
2
4
  export declare class Store<EM extends EventMapBase, R extends string, S extends Record<R, any>> implements StoreInstance<R, S, EM> {
3
5
  /**
4
6
  * Store name (used by DevTools & diagnostics).
@@ -80,6 +82,18 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
80
82
  *
81
83
  * @internal
82
84
  */
85
+ /**
86
+ * Subscribers to events that actually changed state, notified after the commit.
87
+ *
88
+ * @remarks
89
+ * Separate from `committedEventSubscribers` rather than a filter over it, because the two
90
+ * answer different questions and one of them is load bearing: `committed` means "not vetoed"
91
+ * and fires for every event a store accepts, including every event in a store with no
92
+ * reducers. Narrowing it would have silently stopped toasts and analytics firing.
93
+ *
94
+ * @internal
95
+ */
96
+ private readonly writtenEventSubscribers;
83
97
  private readonly allEventSubscribers;
84
98
  /**
85
99
  * Track reducerBus unsubs per slice for HMR/register/unregister.
@@ -142,6 +156,36 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
142
156
  * @internal
143
157
  */
144
158
  private isReducing;
159
+ /**
160
+ * The event currently being reduced, or `null` outside the drain.
161
+ *
162
+ * @remarks
163
+ * This is what makes causality exact rather than best-effort. The drain is synchronous — no
164
+ * `await` can interleave — so any `emit` that arrives while it is set is, without ambiguity, a
165
+ * consequence of this event. That catches the case a scoped `emit` closure cannot: a
166
+ * middleware or subscriber that captured the store and calls `store.emit` directly instead of
167
+ * using the injected one. Attribution should not depend on which reference a consumer reached
168
+ * for.
169
+ *
170
+ * @internal
171
+ */
172
+ private currentEvent;
173
+ /**
174
+ * Events processed by the drain currently in progress. Compared against
175
+ * `maxTransitionsPerDrain`, which is off unless configured.
176
+ *
177
+ * @internal
178
+ */
179
+ private transitionsThisDrain;
180
+ /**
181
+ * Ceilings that stop a cascade from becoming a hung process. See {@link StoreSpec.maxReduceDepth}.
182
+ *
183
+ * @internal
184
+ */
185
+ private readonly maxReduceDepth;
186
+ private readonly maxTransitionsPerDrain;
187
+ private readonly onCascade?;
188
+ private readonly onRejected?;
145
189
  /**
146
190
  * Registered instrumentation observers (DevTools seam). See {@link instrument}.
147
191
  *
@@ -151,11 +195,24 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
151
195
  /**
152
196
  * Scratch array collecting slice-prefixed changed leaf paths during an
153
197
  * instrumented reduce. Set by {@link drainReduce} while observers are active;
154
- * appended to by {@link forwardEvent}. `null` when not instrumenting.
198
+ * appended to by {@link commitStaged}. `null` when not instrumenting.
155
199
  *
156
200
  * @internal
157
201
  */
158
202
  private changedPathSink;
203
+ /**
204
+ * Where keyed reducers put their pending writes during a reduce, and the refusal one of them
205
+ * returned.
206
+ *
207
+ * @remarks
208
+ * Keyed reducers are invoked through `reducerBus`, which delivers to handlers and has no way
209
+ * to hand a value back — the same reason `changedPathSink` exists. `null` outside a reduce.
210
+ *
211
+ * @internal
212
+ */
213
+ private stagingSink;
214
+ private stagedRejection;
215
+ private stagedRejectedBy;
159
216
  /**
160
217
  * Count of effect tasks currently in flight; surfaced as queue depth by
161
218
  * {@link __devtoolsIntrospect}.
@@ -269,41 +326,17 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
269
326
  */
270
327
  private pruneProcessedEvents;
271
328
  /**
272
- * Checks if an event matches a `When` matcher.
273
- *
274
- * @param when - The When matcher (or undefined for "all events").
275
- * @param event - The event to check.
276
- * @returns `true` if the event matches, `false` otherwise.
329
+ * Reports a breached ceiling and refuses the emit.
277
330
  *
278
331
  * @remarks
279
- * - `undefined` or missing `when` matches ALL events.
280
- * - `{ any: true }` matches ALL events.
281
- * - `{ keys: [...] }` matches if event's `[channel, type]` is in the array.
282
- * - `{ channel: 'x' }` matches if event's channel equals 'x'.
283
- * - `{ channels: ['x', 'y'] }` matches if event's channel is in the array.
332
+ * Console *and* hook, matching how reducer and effect errors are reported: a cascade is a
333
+ * wiring bug, and the console line is what a developer who has not registered a hook will
334
+ * actually see. Without one, refusing the emit would look exactly like the event never having
335
+ * been emitted at all — which is the invisibility this whole guard exists to end.
284
336
  *
285
337
  * @internal
286
338
  */
287
- private matchesWhen;
288
- /**
289
- * Extracts the middleware function from a MiddlewareInput.
290
- * Handles both raw functions (legacy) and MiddlewareSpec objects.
291
- *
292
- * @param input - MiddlewareInput (function or spec).
293
- * @returns The middleware function.
294
- *
295
- * @internal
296
- */
297
- private getMiddlewareFunction;
298
- /**
299
- * Gets the `when` matcher from a MiddlewareInput.
300
- *
301
- * @param input - MiddlewareInput (function or spec).
302
- * @returns The `when` matcher, or `undefined` for raw functions (match all).
303
- *
304
- * @internal
305
- */
306
- private getMiddlewareWhen;
339
+ private reportCascade;
307
340
  /**
308
341
  * Invokes all registered **effects** for a given event.
309
342
  * Handles both key-based effects (O(1) lookup) and pattern-based effects (runtime matching).
@@ -313,6 +346,17 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
313
346
  * @internal
314
347
  */
315
348
  private notifyEffects;
349
+ /**
350
+ * An `emit` that attributes whatever it sends to `cause`.
351
+ *
352
+ * @remarks
353
+ * Built per event rather than per effect: every effect reacting to one event shares a cause,
354
+ * and one closure is cheaper than one per handler on a path that runs for every committed
355
+ * event.
356
+ *
357
+ * @internal
358
+ */
359
+ private scopedEmit;
316
360
  /**
317
361
  * Notifies event subscribers for a specific phase.
318
362
  *
@@ -367,17 +411,60 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
367
411
  * pattern reducer's throw aborted the commit and notified nobody, not even the uncommitted
368
412
  * subscribers a veto would have reached.
369
413
  *
370
- * The semantics are now the same either way: **the failing slice is isolated.** Its state is
414
+ * The semantics are the same either way: **the failing slice is isolated.** Its state is
371
415
  * unchanged, every other slice still reduces, and the event still commits if anything else
372
- * changed. Rolling the whole event back would be tidier in principle, but fine-grained
373
- * subscribers are notified inside `forwardEvent` as each slice commits, so an event that
374
- * reverted afterwards would have already told components about a value that no longer exists.
375
- * Isolation keeps every notification truthful.
416
+ * changed.
417
+ *
418
+ * That is deliberately *not* what a {@link Rejected} refusal does, which discards the whole
419
+ * event. A crash and a refusal are different acts: a reducer that throws has a bug and should
420
+ * not be able to veto its neighbours' work, while a reducer that refuses has made a decision
421
+ * and must be able to.
422
+ *
423
+ * This once argued that rolling back was untenable, because subscribers were notified as each
424
+ * slice committed and a later revert would have told them about a value that no longer
425
+ * existed. Staging removed that obstacle — nothing is notified until every slice is written —
426
+ * which is what made refusal possible at all.
427
+ *
428
+ * @internal
429
+ */
430
+ private stageSliceGuarded;
431
+ /**
432
+ * Runs one slice's reducer and records what it *would* write. Writes nothing.
433
+ *
434
+ * @returns The reducer's {@link Rejection} if it refused, otherwise `null`.
435
+ *
436
+ * @remarks
437
+ * The staging half of the write path. Nothing here touches `this.state` or notifies anybody,
438
+ * which is what lets the event be refused after every reducer has had its say — a decision
439
+ * that has to see the whole diff cannot be made one slice at a time.
440
+ *
441
+ * Freezing happens here rather than at commit because it is where the new value is built, and
442
+ * the freeze is a no-op on anything already frozen; a staged slice that never commits is
443
+ * discarded frozen, which costs nothing and keeps the committed path free of a second walk.
376
444
  *
377
445
  * @internal
378
446
  */
379
- private forwardEventGuarded;
380
- private forwardEvent;
447
+ private stageSlice;
448
+ /**
449
+ * Writes every staged slice, then tells the world — in that order.
450
+ *
451
+ * @remarks
452
+ * The commit half. Assigning all slices under a single new root before any notification goes
453
+ * out is what closes the window this used to leave open: notifications fired per slice as each
454
+ * committed, so a subscriber to slice A that read `getState()` could observe slice B of the
455
+ * *same event* not yet applied. In React that window is real, because the atomic hooks use a
456
+ * change as a bare signal and then re-read the whole store.
457
+ *
458
+ * It is also what makes refusal possible at all. The previous code documented rollback as
459
+ * untenable precisely because "an event that reverted afterwards would have already told
460
+ * components about a value that no longer exists" — true when notification and commit were the
461
+ * same step, and no longer true now that they are not.
462
+ *
463
+ * @returns `true` if anything was written.
464
+ *
465
+ * @internal
466
+ */
467
+ private commitStaged;
381
468
  /**
382
469
  * Returns a structured introspection snapshot for DevTools UIs.
383
470
  *
@@ -422,7 +509,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
422
509
  * fine-grained path changes for each slice.
423
510
  *
424
511
  * **State Immutability**: If any slices change, a new state object is created via
425
- * shallow spread. This ensures consistent immutability with {@link forwardEvent}.
512
+ * shallow spread. This ensures consistent immutability with {@link commitStaged}.
426
513
  *
427
514
  * **Missing slices**: the snapshot should contain every slice. A slice absent
428
515
  * from `nextPlain` is **retained at its current value** (not blanked to
@@ -461,13 +548,13 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
461
548
  * phase* (step 5) runs afterwards, asynchronously.
462
549
  * 1. **Deduplication** (opt-in) - Skip when content-dedup is enabled (`dedupWindowMs > 0`) or a matching `dedupKey` recurs; off by default
463
550
  * 2. **Middleware** (sync) - Pre-reducer hooks; may cancel by returning `false`
464
- * 3. **Reducers** (sync) - state updates + fine-grained path notifications
465
- * 4. **Subscribers + coarse** (sync) - event subscribers (fire-and-forget) then coarse listeners (only if state changed)
551
+ * 3. **Reducers** (sync) - every matching slice is *staged*; nothing is written yet, so a refusal from the last reducer still stops the first one's write
552
+ * 4. **Commit + subscribers** (sync) - all staged slices are assigned under one new root, then event subscribers (`committed`, then `written` when state actually changed), then coarse listeners
466
553
  * 5. **Effects** (async) - side-effects keyed by `(channel, type)`; the returned promise resolves once they complete
467
554
  *
468
555
  * **Change Detection**: Uses reference equality (`===`) on `this.state` to determine
469
- * if any slice changed. Works because {@link forwardEvent} creates a new state reference
470
- * via shallow spread when any slice changes.
556
+ * if any slice changed. Works because the commit builds a new state reference via
557
+ * shallow spread when any slice changes.
471
558
  *
472
559
  * @typeParam C - Channel key in `EM`.
473
560
  * @typeParam T - Type key within channel `C`.
@@ -495,7 +582,20 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
495
582
  *
496
583
  * @public
497
584
  */
498
- emit<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, payload: EM[C][T], opts?: EmitOptions): Promise<void>;
585
+ emit<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, payload: EM[C][T], opts?: EmitOptions): Promise<EmitResult>;
586
+ /**
587
+ * The real emit, with an explicitly supplied cause.
588
+ *
589
+ * @remarks
590
+ * Exists so the parent can be passed without a pseudo-private field on the public
591
+ * {@link EmitOptions}. Two callers supply one: the public {@link emit} passes `null` and lets
592
+ * `currentEvent` speak for the synchronous case, and the scoped `emit` handed to effects passes
593
+ * the event that triggered them — effects resume after the drain has ended, so nothing else
594
+ * could still know what caused them.
595
+ *
596
+ * @internal
597
+ */
598
+ private emitCaused;
499
599
  /**
500
600
  * Drains the reduce queue **synchronously**. For each event it runs middleware,
501
601
  * reducers, event subscribers, and coarse listeners in the same tick, so
@@ -536,7 +636,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
536
636
  /**
537
637
  * Builds an {@link InstrumentedEvent} from the reduce result and notifies
538
638
  * observers. `changedPaths` are the exact slice-prefixed leaf paths recorded
539
- * by {@link forwardEvent} during this reduce, so DevTools patches need no
639
+ * by {@link commitStaged} during this reduce, so DevTools patches need no
540
640
  * re-diff.
541
641
  *
542
642
  * @internal
@@ -573,7 +673,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
573
673
  connect(spec: {
574
674
  reducer: R;
575
675
  property: string;
576
- }, h: (chg: Change) => void): () => void;
676
+ }, h: (chg: Change) => void, options?: ConnectOptions): () => void;
577
677
  /**
578
678
  * Subscribe to events by channel and type.
579
679
  *
@@ -743,6 +843,86 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
743
843
  *
744
844
  * @public
745
845
  */
846
+ /**
847
+ * Sends a request and waits for the reply, correlating the two automatically.
848
+ *
849
+ * @typeParam C - Request channel.
850
+ * @typeParam T - Request type within `C`.
851
+ * @param channel - Channel to send on.
852
+ * @param type - Event type to send.
853
+ * @param payload - The **request** payload. This is what you are sending; what comes back is
854
+ * described by {@link CallOptions.reply}, not by this.
855
+ * @param opts - Which replies end the call, and how long to wait. See {@link CallOptions}.
856
+ * @returns A {@link CallHandle}: `await` it for the terminal reply, or `for await` it for
857
+ * progress events as they arrive.
858
+ *
859
+ * @remarks
860
+ * Every consumer of an event bus eventually writes request/reply by hand — mint an id,
861
+ * subscribe, match, time out, unsubscribe — and every one of them writes the same eighty lines
862
+ * with the same two bugs: the subscription outlives the call, and a responder that forgets to
863
+ * echo the id produces a timeout with nothing to point at. This is that, once.
864
+ *
865
+ * **Correlation is causal.** The store stamps `parentId` on anything emitted while an event is
866
+ * being handled, so a responder that replies through the `emit` it was handed is already
867
+ * correlated. There is no id to mint, echo, or forget:
868
+ *
869
+ * ```ts
870
+ * store.registerEffect({
871
+ * when: { keys: [["rpc", "ask"]] },
872
+ * effect: async (event, _get, emit) => {
873
+ * await emit("rpc", "answer", await lookup(event.payload.q));
874
+ * },
875
+ * });
876
+ * ```
877
+ *
878
+ * **The reply carries its own discriminant.** A call resolves to the *event*, not the payload,
879
+ * because a caller often cannot know which kind of reply it will get:
880
+ *
881
+ * ```ts
882
+ * const res = await store.call("rpc", "ask", { q }, { reply: ["rpc", ["answer", "error"]] });
883
+ * switch (res.type) {
884
+ * case "answer": return res.payload;
885
+ * case "error": throw new Error(res.payload.reason);
886
+ * }
887
+ * ```
888
+ *
889
+ * **Progress streams, with backpressure.** Any correlated event that is not terminal is
890
+ * progress, and iterating the call consumes it. The producer genuinely waits: `emit` resolves
891
+ * only once its effects have run, and the collector is an effect that does not return until the
892
+ * consumer has taken the item. A responder writing `await emit("rpc", "progress", chunk)` is
893
+ * therefore paced by the reader, with nothing buffering without bound.
894
+ *
895
+ * ```ts
896
+ * const call = store.call("job", "start", { id }, {
897
+ * reply: ["job", "done"],
898
+ * highWaterMark: 4,
899
+ * });
900
+ * for await (const step of call) await render(step.payload); // producer waits on this
901
+ * const { payload } = await call;
902
+ * ```
903
+ *
904
+ * Backpressure engages **once you begin iterating**. A call that is only awaited never pulls,
905
+ * so blocking its producer would deadlock the call itself — progress nobody reads would stop
906
+ * the terminal event from ever being sent. Un-iterated progress therefore buffers to
907
+ * `highWaterMark` and is then counted on {@link CallHandle.dropped} rather than blocking.
908
+ *
909
+ * **This is a local primitive.**
910
+ *
911
+ * @example Timeout is idle, not total
912
+ * ```ts
913
+ * // Survives a job that streams for minutes; fails a responder that goes quiet for 5s.
914
+ * await store.call("job", "start", { id }, { reply: ["job", "done"], timeoutMs: 5_000 });
915
+ * ```
916
+ *
917
+ * @example Cancelling
918
+ * ```ts
919
+ * const call = store.call("rpc", "ask", { q }, { reply: ["rpc", "answer"] });
920
+ * useEffect(() => () => call.cancel("unmounted"), [call]);
921
+ * ```
922
+ *
923
+ * @public
924
+ */
925
+ call<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, payload: EM[C][T], opts: CallOptions<EM>): CallHandle<EventUnion<EM>, EventUnion<EM>>;
746
926
  registerEffect(spec: EffectSpec<DeepReadonly<S>, EM>): () => void;
747
927
  /**
748
928
  * Convenience helper to register an **effect** filtered by a single `(channel, type)` pair.
@@ -864,15 +1044,6 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
864
1044
  * @internal
865
1045
  */
866
1046
  private unmountSlice;
867
- /**
868
- * Normalizes event targeting from `when` to an array of EventKeys.
869
- *
870
- * @param spec - Object with an optional `when` matcher.
871
- * @returns Array of `[channel, type]` pairs.
872
- *
873
- * @internal
874
- */
875
- private normalizeEventKeys;
876
1047
  /**
877
1048
  * Reads a dotted path from an object (supports numeric array indices via string keys).
878
1049
  *
@@ -880,6 +1051,10 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
880
1051
  * @param path - Dotted path; leading dot is ignored.
881
1052
  * @returns The value at the path, or `undefined`.
882
1053
  *
1054
+ * @remarks
1055
+ * A member rather than a bare import: a test replaces this on the instance to count how many
1056
+ * walks describing a change costs, which only works while the callers go through `this`.
1057
+ *
883
1058
  * @internal
884
1059
  */
885
1060
  private getAtPath;
@@ -955,6 +1130,10 @@ export declare function createStore<S extends Record<string, any>, EM extends Ev
955
1130
  };
956
1131
  onEffectError?: (error: unknown, event: EventUnion<EM>) => void;
957
1132
  onReducerError?: (error: unknown, event: EventUnion<EM>, slice: string) => void;
1133
+ maxReduceDepth?: number;
1134
+ maxTransitionsPerDrain?: number;
1135
+ onCascade?: (info: CascadeInfo<EM>) => void;
1136
+ onRejected?: (rejection: Rejection, event: EventUnion<EM>, slice: string) => void;
958
1137
  }): StoreInstance<keyof S & string, S, EM>;
959
1138
  /**
960
1139
  * Creates a store with types inferred from the reducers map.
@@ -996,6 +1175,10 @@ export declare function createStore<RM extends ReducersMapAny>(cfg: {
996
1175
  };
997
1176
  onEffectError?: (error: unknown, event: EventUnion<EMFromReducersStrict<RM>>) => void;
998
1177
  onReducerError?: (error: unknown, event: EventUnion<EMFromReducersStrict<RM>>, slice: string) => void;
1178
+ maxReduceDepth?: number;
1179
+ maxTransitionsPerDrain?: number;
1180
+ onCascade?: (info: CascadeInfo<EMFromReducersStrict<RM>>) => void;
1181
+ onRejected?: (rejection: Rejection, event: EventUnion<EMFromReducersStrict<RM>>, slice: string) => void;
999
1182
  }): StoreInstance<keyof RM & string, StateFromReducers<RM>, EMFromReducersStrict<RM>>;
1000
1183
  /**
1001
1184
  * Utility to define **typed** `(channel, events[])` definitions for reducer specs.
@@ -0,0 +1,149 @@
1
+ import { EventMapBase, EventUnion } from '../types.js';
2
+ /**
3
+ * Which reply events end a {@link StoreInstance.call | call}, and therefore what it resolves to.
4
+ *
5
+ * @remarks
6
+ * Given as `[channel]` or `[channel, type]` or `[channel, [type, type]]`. The named types are
7
+ * **terminal**: the first one to arrive settles the call. Every other correlated event on that
8
+ * channel is progress.
9
+ *
10
+ * Naming a channel alone makes every event on it terminal, which suits a responder with a single
11
+ * kind of answer. Naming types is what lets a responder stream: `["rpc", ["answer", "error"]]`
12
+ * ends on either, and anything else — `progress`, `partial`, `log` — flows to the consumer.
13
+ *
14
+ * @public
15
+ */
16
+ export type ReplySpec<EM extends EventMapBase> = readonly [channel: keyof EM & string] | readonly [channel: keyof EM & string, type: string] | readonly [channel: keyof EM & string, types: readonly string[]];
17
+ /**
18
+ * Options for {@link StoreInstance.call}.
19
+ *
20
+ * @public
21
+ */
22
+ export interface CallOptions<EM extends EventMapBase> {
23
+ /** Which reply events end the call. See {@link ReplySpec}. */
24
+ readonly reply: ReplySpec<EM>;
25
+ /**
26
+ * How long the call may sit **idle** before it gives up, in milliseconds.
27
+ *
28
+ * @remarks
29
+ * Idle, not total: every correlated event resets it, progress included. A job that streams for
30
+ * two minutes must not fail a thirty-second call, and a total deadline would make the timeout a
31
+ * function of how much work the responder had to do rather than whether it is still alive.
32
+ *
33
+ * For a genuine deadline — "this must be finished by then, however lively" — use
34
+ * {@link CallOptions.signal} with an `AbortSignal.timeout()`.
35
+ *
36
+ * @default 30000
37
+ */
38
+ readonly timeoutMs?: number;
39
+ /**
40
+ * Aborts the call. The returned promise rejects and the iterator ends.
41
+ *
42
+ * @remarks
43
+ * Unlike `timeoutMs` this is absolute, so it is the right tool for a request deadline, a
44
+ * user-cancelled action, or a component unmounting.
45
+ */
46
+ readonly signal?: AbortSignal;
47
+ /**
48
+ * How many progress events may buffer before the producer is made to wait.
49
+ *
50
+ * @remarks
51
+ * Only meaningful once the caller is iterating. See {@link StoreInstance.call} for what
52
+ * backpressure means here and when it engages.
53
+ *
54
+ * @default 16
55
+ */
56
+ readonly highWaterMark?: number;
57
+ /**
58
+ * Correlate on this id instead of on causality.
59
+ *
60
+ * @remarks
61
+ * Causal matching — a reply is correlated because the store stamped it as *caused by* the
62
+ * request — is free and cannot be forged, but only holds in one process. A reply arriving from
63
+ * another node, a worker, or any transport carries no causal link, so for those the responder
64
+ * echoes an id and both sides agree on it here.
65
+ *
66
+ * When set, the id is sent as `meta.correlationId` and a reply matches if it echoes the same
67
+ * value **or** is causally descended. Causality still wins where it applies, so a local
68
+ * responder needs no changes to be compatible with a remote one.
69
+ */
70
+ readonly correlationId?: string;
71
+ }
72
+ /**
73
+ * The result of {@link StoreInstance.call}: awaitable for the terminal reply, async-iterable for
74
+ * progress.
75
+ *
76
+ * @typeParam TReply - The terminal reply event.
77
+ * @typeParam TProgress - Non-terminal correlated events.
78
+ *
79
+ * @remarks
80
+ * One object serving both shapes, rather than two functions, because the caller's intent is not
81
+ * known at the call site — the same request may be awaited in one place and streamed in another,
82
+ * and the responder should not have to care which.
83
+ *
84
+ * ```ts
85
+ * // Await the answer, ignore the running commentary.
86
+ * const done = await store.call("rpc", "ask", { q }, { reply: ["rpc", "answer"] });
87
+ *
88
+ * // Or consume the commentary, then take the answer.
89
+ * const call = store.call("rpc", "ask", { q }, { reply: ["rpc", "answer"] });
90
+ * for await (const step of call) render(step.payload);
91
+ * const answer = await call;
92
+ * ```
93
+ *
94
+ * Awaiting the same call twice is safe and yields the same reply; the terminal event is retained.
95
+ *
96
+ * @public
97
+ */
98
+ export interface CallHandle<TReply, TProgress> extends Promise<TReply>, AsyncIterable<TProgress> {
99
+ /**
100
+ * Progress events discarded because nothing was iterating.
101
+ *
102
+ * @remarks
103
+ * Zero unless the call was awaited without being iterated *and* the responder streamed more
104
+ * than `highWaterMark` events. Non-zero is not an error — it is the honest count of what a
105
+ * caller chose not to read, and is worth logging rather than guessing at.
106
+ */
107
+ readonly dropped: number;
108
+ /** Stops listening and settles the call. Safe to call more than once. */
109
+ cancel(reason?: string): void;
110
+ }
111
+ /**
112
+ * Raised when a call goes {@link CallOptions.timeoutMs} without a correlated event.
113
+ *
114
+ * @public
115
+ */
116
+ export declare class CallTimeoutError extends Error {
117
+ readonly channel: string;
118
+ readonly type: string;
119
+ readonly idleMs: number;
120
+ constructor(channel: string, type: string, idleMs: number);
121
+ }
122
+ /**
123
+ * Raised when a call is cancelled, or its {@link CallOptions.signal} aborts.
124
+ *
125
+ * @public
126
+ */
127
+ export declare class CallAbortedError extends Error {
128
+ constructor(reason: string);
129
+ }
130
+ /**
131
+ * Normalises a {@link ReplySpec} into a channel and a terminal-type test.
132
+ *
133
+ * @internal
134
+ */
135
+ export declare function parseReply<EM extends EventMapBase>(reply: ReplySpec<EM>): {
136
+ channel: string;
137
+ isTerminal: (type: string) => boolean;
138
+ };
139
+ /**
140
+ * Whether `event` is a reply to the request identified by `requestId` / `correlationId`.
141
+ *
142
+ * @remarks
143
+ * Causality first: the store stamps `parentId` on anything emitted while handling an event, so a
144
+ * responder that answers through the `emit` it was given is correlated without doing anything.
145
+ * The explicit id is the fallback for replies that crossed a boundary causality cannot.
146
+ *
147
+ * @internal
148
+ */
149
+ export declare function isReplyTo<EM extends EventMapBase>(event: EventUnion<EM>, requestId: string, correlationId: string | undefined): boolean;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @module @yoltra/core
3
+ */
4
+ /**
5
+ * A bounded hand-off queue between one producer and one consumer, where **the producer waits**.
6
+ *
7
+ * @remarks
8
+ * This is what makes {@link StoreInstance.call}'s backpressure real rather than decorative. A
9
+ * plain buffer accepts everything and grows; this one hands the producer a promise that does not
10
+ * resolve until the consumer has taken an item. Because the store awaits effects, and `emit`
11
+ * resolves only once its effects have finished, a producer writing
12
+ *
13
+ * ```ts
14
+ * await emit("rpc", "progress", chunk);
15
+ * ```
16
+ *
17
+ * genuinely blocks until the consumer catches up — end to end, through machinery that already
18
+ * existed, with nothing polling and nothing dropped.
19
+ *
20
+ * **Backpressure only engages once the consumer has begun iterating.** Before that, items buffer
21
+ * up to `highWaterMark` and further ones are counted and discarded. That asymmetry is deliberate:
22
+ * a caller that only awaits the terminal reply never pulls, so blocking the producer would
23
+ * deadlock the very call it is feeding — the producer would be waiting to deliver progress
24
+ * nobody will read, and would therefore never emit the terminal event that ends the wait.
25
+ *
26
+ * @internal
27
+ */
28
+ export declare class CallQueue<T> {
29
+ private readonly highWaterMark;
30
+ private readonly buffer;
31
+ /** Consumers parked in `take`, oldest first. */
32
+ private readonly takers;
33
+ /** Producers parked in `put`, each with the item they are waiting to hand over. */
34
+ private readonly putters;
35
+ private consuming;
36
+ /** No more items will be accepted, but what is already here is still owed to the consumer. */
37
+ private ended;
38
+ /** Abandoned: nothing further is owed to anybody. */
39
+ private closed;
40
+ /** Items discarded because nobody was iterating and the buffer was full. */
41
+ private dropped;
42
+ constructor(highWaterMark: number);
43
+ /** How many items were discarded for want of a consumer. */
44
+ get droppedCount(): number;
45
+ /**
46
+ * Marks that a consumer has started pulling. From here on, a full buffer parks the producer
47
+ * rather than dropping.
48
+ */
49
+ beginConsuming(): void;
50
+ /**
51
+ * Offers an item. The returned promise settles when the item has been taken — or immediately,
52
+ * if it fit in the buffer or was dropped.
53
+ */
54
+ put(item: T): Promise<void>;
55
+ /** Takes the next item, waiting if none is available. Resolves `done` once closed and drained. */
56
+ take(): Promise<IteratorResult<T>>;
57
+ /**
58
+ * Stops accepting items, but keeps owing the consumer everything already queued.
59
+ *
60
+ * @remarks
61
+ * What the terminal reply does. Closing outright at that moment would throw away progress the
62
+ * responder had already handed over and the consumer had not yet read — which is exactly what
63
+ * happened before this existed: a six-step job delivered five steps, because the sixth was in
64
+ * the buffer when `done` arrived and the buffer was cleared. The terminal event says "no more
65
+ * is coming", not "forget what you were given".
66
+ */
67
+ end(): void;
68
+ /**
69
+ * Closes the queue: waiting consumers are told `done`, and **every parked producer is
70
+ * released**.
71
+ *
72
+ * @remarks
73
+ * Releasing producers is not tidying up. A producer parked on `put` is a pending `await emit`
74
+ * somewhere; leaving it parked when the call has already settled would hang the responder for
75
+ * good — turning a timed-out call into a wedged process, which is worse than the problem
76
+ * backpressure was added to solve.
77
+ */
78
+ close(): void;
79
+ }