@yoltra/core 0.5.0 → 0.6.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}.
@@ -268,6 +325,18 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
268
325
  * @internal
269
326
  */
270
327
  private pruneProcessedEvents;
328
+ /**
329
+ * Reports a breached ceiling and refuses the emit.
330
+ *
331
+ * @remarks
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.
336
+ *
337
+ * @internal
338
+ */
339
+ private reportCascade;
271
340
  /**
272
341
  * Checks if an event matches a `When` matcher.
273
342
  *
@@ -313,6 +382,17 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
313
382
  * @internal
314
383
  */
315
384
  private notifyEffects;
385
+ /**
386
+ * An `emit` that attributes whatever it sends to `cause`.
387
+ *
388
+ * @remarks
389
+ * Built per event rather than per effect: every effect reacting to one event shares a cause,
390
+ * and one closure is cheaper than one per handler on a path that runs for every committed
391
+ * event.
392
+ *
393
+ * @internal
394
+ */
395
+ private scopedEmit;
316
396
  /**
317
397
  * Notifies event subscribers for a specific phase.
318
398
  *
@@ -367,17 +447,60 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
367
447
  * pattern reducer's throw aborted the commit and notified nobody, not even the uncommitted
368
448
  * subscribers a veto would have reached.
369
449
  *
370
- * The semantics are now the same either way: **the failing slice is isolated.** Its state is
450
+ * The semantics are the same either way: **the failing slice is isolated.** Its state is
371
451
  * 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.
452
+ * changed.
453
+ *
454
+ * That is deliberately *not* what a {@link Rejected} refusal does, which discards the whole
455
+ * event. A crash and a refusal are different acts: a reducer that throws has a bug and should
456
+ * not be able to veto its neighbours' work, while a reducer that refuses has made a decision
457
+ * and must be able to.
458
+ *
459
+ * This once argued that rolling back was untenable, because subscribers were notified as each
460
+ * slice committed and a later revert would have told them about a value that no longer
461
+ * existed. Staging removed that obstacle — nothing is notified until every slice is written —
462
+ * which is what made refusal possible at all.
376
463
  *
377
464
  * @internal
378
465
  */
379
- private forwardEventGuarded;
380
- private forwardEvent;
466
+ private stageSliceGuarded;
467
+ /**
468
+ * Runs one slice's reducer and records what it *would* write. Writes nothing.
469
+ *
470
+ * @returns The reducer's {@link Rejection} if it refused, otherwise `null`.
471
+ *
472
+ * @remarks
473
+ * The staging half of the write path. Nothing here touches `this.state` or notifies anybody,
474
+ * which is what lets the event be refused after every reducer has had its say — a decision
475
+ * that has to see the whole diff cannot be made one slice at a time.
476
+ *
477
+ * Freezing happens here rather than at commit because it is where the new value is built, and
478
+ * the freeze is a no-op on anything already frozen; a staged slice that never commits is
479
+ * discarded frozen, which costs nothing and keeps the committed path free of a second walk.
480
+ *
481
+ * @internal
482
+ */
483
+ private stageSlice;
484
+ /**
485
+ * Writes every staged slice, then tells the world — in that order.
486
+ *
487
+ * @remarks
488
+ * The commit half. Assigning all slices under a single new root before any notification goes
489
+ * out is what closes the window this used to leave open: notifications fired per slice as each
490
+ * committed, so a subscriber to slice A that read `getState()` could observe slice B of the
491
+ * *same event* not yet applied. In React that window is real, because the atomic hooks use a
492
+ * change as a bare signal and then re-read the whole store.
493
+ *
494
+ * It is also what makes refusal possible at all. The previous code documented rollback as
495
+ * untenable precisely because "an event that reverted afterwards would have already told
496
+ * components about a value that no longer exists" — true when notification and commit were the
497
+ * same step, and no longer true now that they are not.
498
+ *
499
+ * @returns `true` if anything was written.
500
+ *
501
+ * @internal
502
+ */
503
+ private commitStaged;
381
504
  /**
382
505
  * Returns a structured introspection snapshot for DevTools UIs.
383
506
  *
@@ -422,7 +545,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
422
545
  * fine-grained path changes for each slice.
423
546
  *
424
547
  * **State Immutability**: If any slices change, a new state object is created via
425
- * shallow spread. This ensures consistent immutability with {@link forwardEvent}.
548
+ * shallow spread. This ensures consistent immutability with {@link commitStaged}.
426
549
  *
427
550
  * **Missing slices**: the snapshot should contain every slice. A slice absent
428
551
  * from `nextPlain` is **retained at its current value** (not blanked to
@@ -461,13 +584,13 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
461
584
  * phase* (step 5) runs afterwards, asynchronously.
462
585
  * 1. **Deduplication** (opt-in) - Skip when content-dedup is enabled (`dedupWindowMs > 0`) or a matching `dedupKey` recurs; off by default
463
586
  * 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)
587
+ * 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
588
+ * 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
589
  * 5. **Effects** (async) - side-effects keyed by `(channel, type)`; the returned promise resolves once they complete
467
590
  *
468
591
  * **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.
592
+ * if any slice changed. Works because the commit builds a new state reference via
593
+ * shallow spread when any slice changes.
471
594
  *
472
595
  * @typeParam C - Channel key in `EM`.
473
596
  * @typeParam T - Type key within channel `C`.
@@ -495,7 +618,20 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
495
618
  *
496
619
  * @public
497
620
  */
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>;
621
+ emit<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, payload: EM[C][T], opts?: EmitOptions): Promise<EmitResult>;
622
+ /**
623
+ * The real emit, with an explicitly supplied cause.
624
+ *
625
+ * @remarks
626
+ * Exists so the parent can be passed without a pseudo-private field on the public
627
+ * {@link EmitOptions}. Two callers supply one: the public {@link emit} passes `null` and lets
628
+ * `currentEvent` speak for the synchronous case, and the scoped `emit` handed to effects passes
629
+ * the event that triggered them — effects resume after the drain has ended, so nothing else
630
+ * could still know what caused them.
631
+ *
632
+ * @internal
633
+ */
634
+ private emitCaused;
499
635
  /**
500
636
  * Drains the reduce queue **synchronously**. For each event it runs middleware,
501
637
  * reducers, event subscribers, and coarse listeners in the same tick, so
@@ -536,7 +672,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
536
672
  /**
537
673
  * Builds an {@link InstrumentedEvent} from the reduce result and notifies
538
674
  * observers. `changedPaths` are the exact slice-prefixed leaf paths recorded
539
- * by {@link forwardEvent} during this reduce, so DevTools patches need no
675
+ * by {@link commitStaged} during this reduce, so DevTools patches need no
540
676
  * re-diff.
541
677
  *
542
678
  * @internal
@@ -573,7 +709,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
573
709
  connect(spec: {
574
710
  reducer: R;
575
711
  property: string;
576
- }, h: (chg: Change) => void): () => void;
712
+ }, h: (chg: Change) => void, options?: ConnectOptions): () => void;
577
713
  /**
578
714
  * Subscribe to events by channel and type.
579
715
  *
@@ -743,6 +879,92 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
743
879
  *
744
880
  * @public
745
881
  */
882
+ /**
883
+ * Sends a request and waits for the reply, correlating the two automatically.
884
+ *
885
+ * @typeParam C - Request channel.
886
+ * @typeParam T - Request type within `C`.
887
+ * @param channel - Channel to send on.
888
+ * @param type - Event type to send.
889
+ * @param payload - The **request** payload. This is what you are sending; what comes back is
890
+ * described by {@link CallOptions.reply}, not by this.
891
+ * @param opts - Which replies end the call, and how long to wait. See {@link CallOptions}.
892
+ * @returns A {@link CallHandle}: `await` it for the terminal reply, or `for await` it for
893
+ * progress events as they arrive.
894
+ *
895
+ * @remarks
896
+ * Every consumer of an event bus eventually writes request/reply by hand — mint an id,
897
+ * subscribe, match, time out, unsubscribe — and every one of them writes the same eighty lines
898
+ * with the same two bugs: the subscription outlives the call, and a responder that forgets to
899
+ * echo the id produces a timeout with nothing to point at. This is that, once.
900
+ *
901
+ * **Correlation is causal.** The store stamps `parentId` on anything emitted while an event is
902
+ * being handled, so a responder that replies through the `emit` it was handed is already
903
+ * correlated. There is no id to mint, echo, or forget:
904
+ *
905
+ * ```ts
906
+ * store.registerEffect({
907
+ * when: { keys: [["rpc", "ask"]] },
908
+ * effect: async (event, _get, emit) => {
909
+ * await emit("rpc", "answer", await lookup(event.payload.q));
910
+ * },
911
+ * });
912
+ * ```
913
+ *
914
+ * **The reply carries its own discriminant.** A call resolves to the *event*, not the payload,
915
+ * because a caller often cannot know which kind of reply it will get:
916
+ *
917
+ * ```ts
918
+ * const res = await store.call("rpc", "ask", { q }, { reply: ["rpc", ["answer", "error"]] });
919
+ * switch (res.type) {
920
+ * case "answer": return res.payload;
921
+ * case "error": throw new Error(res.payload.reason);
922
+ * }
923
+ * ```
924
+ *
925
+ * **Progress streams, with backpressure.** Any correlated event that is not terminal is
926
+ * progress, and iterating the call consumes it. The producer genuinely waits: `emit` resolves
927
+ * only once its effects have run, and the collector is an effect that does not return until the
928
+ * consumer has taken the item. A responder writing `await emit("rpc", "progress", chunk)` is
929
+ * therefore paced by the reader, with nothing buffering without bound.
930
+ *
931
+ * ```ts
932
+ * const call = store.call("job", "start", { id }, {
933
+ * reply: ["job", "done"],
934
+ * highWaterMark: 4,
935
+ * });
936
+ * for await (const step of call) await render(step.payload); // producer waits on this
937
+ * const { payload } = await call;
938
+ * ```
939
+ *
940
+ * Backpressure engages **once you begin iterating**. A call that is only awaited never pulls,
941
+ * so blocking its producer would deadlock the call itself — progress nobody reads would stop
942
+ * the terminal event from ever being sent. Un-iterated progress therefore buffers to
943
+ * `highWaterMark` and is then counted on {@link CallHandle.dropped} rather than blocking.
944
+ *
945
+ * **This is a local primitive.** A reply cannot reach it from a federated peer: the federation
946
+ * envelope carries neither `meta` nor `parentId`, and ingress namespaces the channel, so
947
+ * neither correlation nor the reply route survives the hop. That is not an oversight to route
948
+ * around — federation answers cross-node request/reply with typed peer *queries*, which are
949
+ * gated by a responder policy that may concede or deny. A call that federated silently would
950
+ * turn that access decision into an accident of which channel someone named. Ask a peer with a
951
+ * query; use `call` within a process.
952
+ *
953
+ * @example Timeout is idle, not total
954
+ * ```ts
955
+ * // Survives a job that streams for minutes; fails a responder that goes quiet for 5s.
956
+ * await store.call("job", "start", { id }, { reply: ["job", "done"], timeoutMs: 5_000 });
957
+ * ```
958
+ *
959
+ * @example Cancelling
960
+ * ```ts
961
+ * const call = store.call("rpc", "ask", { q }, { reply: ["rpc", "answer"] });
962
+ * useEffect(() => () => call.cancel("unmounted"), [call]);
963
+ * ```
964
+ *
965
+ * @public
966
+ */
967
+ 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
968
  registerEffect(spec: EffectSpec<DeepReadonly<S>, EM>): () => void;
747
969
  /**
748
970
  * Convenience helper to register an **effect** filtered by a single `(channel, type)` pair.
@@ -955,6 +1177,10 @@ export declare function createStore<S extends Record<string, any>, EM extends Ev
955
1177
  };
956
1178
  onEffectError?: (error: unknown, event: EventUnion<EM>) => void;
957
1179
  onReducerError?: (error: unknown, event: EventUnion<EM>, slice: string) => void;
1180
+ maxReduceDepth?: number;
1181
+ maxTransitionsPerDrain?: number;
1182
+ onCascade?: (info: CascadeInfo<EM>) => void;
1183
+ onRejected?: (rejection: Rejection, event: EventUnion<EM>, slice: string) => void;
958
1184
  }): StoreInstance<keyof S & string, S, EM>;
959
1185
  /**
960
1186
  * Creates a store with types inferred from the reducers map.
@@ -996,6 +1222,10 @@ export declare function createStore<RM extends ReducersMapAny>(cfg: {
996
1222
  };
997
1223
  onEffectError?: (error: unknown, event: EventUnion<EMFromReducersStrict<RM>>) => void;
998
1224
  onReducerError?: (error: unknown, event: EventUnion<EMFromReducersStrict<RM>>, slice: string) => void;
1225
+ maxReduceDepth?: number;
1226
+ maxTransitionsPerDrain?: number;
1227
+ onCascade?: (info: CascadeInfo<EMFromReducersStrict<RM>>) => void;
1228
+ onRejected?: (rejection: Rejection, event: EventUnion<EMFromReducersStrict<RM>>, slice: string) => void;
999
1229
  }): StoreInstance<keyof RM & string, StateFromReducers<RM>, EMFromReducersStrict<RM>>;
1000
1230
  /**
1001
1231
  * 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
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @module @yoltra/core
3
+ */
4
+ /**
5
+ * Brand identifying a {@link Rejection}.
6
+ *
7
+ * @remarks
8
+ * `Symbol.for` rather than `Symbol()`, so the brand survives two copies of this package meeting
9
+ * at runtime — a duplicated dependency, a federated bundle, a consumer that pinned an older
10
+ * minor. With a unique symbol the check would silently answer `false` across that boundary and a
11
+ * refusal would read as ordinary state, which is the failure this whole feature exists to end.
12
+ *
13
+ * @internal
14
+ */
15
+ declare const REJECTED: unique symbol;
16
+ /**
17
+ * A reducer's refusal to apply a write, carrying the reason.
18
+ *
19
+ * @remarks
20
+ * Distinct from a reducer returning its state unchanged, which is indistinguishable from "the
21
+ * event did not concern me". A `Rejection` says *this write was considered and declined*, and it
22
+ * says why — which is what a contended store needs and what a lost update otherwise costs.
23
+ *
24
+ * @public
25
+ */
26
+ export interface Rejection {
27
+ readonly [REJECTED]: true;
28
+ /** Why the write was refused. Surfaced to the caller and to `onRejected`. */
29
+ readonly reason: string;
30
+ }
31
+ /**
32
+ * Builds a {@link Rejection} for a reducer to return instead of state.
33
+ *
34
+ * @param reason - Why the write is refused; surfaced verbatim to the caller.
35
+ *
36
+ * @remarks
37
+ * Rejecting is a whole-event act: no slice commits, no change notifications fire, and the
38
+ * caller's `emit` resolves reporting the refusal. A reducer that merely has nothing to do should
39
+ * return its state, not this.
40
+ *
41
+ * @example Compare-and-swap on a contended slice
42
+ * ```ts
43
+ * reducer: (state, event) =>
44
+ * event.payload.expectedVersion === state.version
45
+ * ? { ...state, ...event.payload.patch, version: state.version + 1 }
46
+ * : Rejected(`stale write: expected v${event.payload.expectedVersion}, have v${state.version}`)
47
+ * ```
48
+ *
49
+ * @public
50
+ */
51
+ export declare function Rejected(reason: string): Rejection;
52
+ /**
53
+ * Whether a reducer returned a {@link Rejection} rather than state.
54
+ *
55
+ * @public
56
+ */
57
+ export declare function isRejected(value: unknown): value is Rejection;
58
+ export {};