@yoltra/core 0.4.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.
- package/README.es.md +214 -7
- package/README.md +315 -13
- package/dist/types/eventBus/EventBus.d.ts +1 -1
- package/dist/types/eventBus/index.d.ts +2 -2
- package/dist/types/index.d.ts +21 -16
- package/dist/types/persistence/adapters.d.ts +1 -1
- package/dist/types/persistence/persist.d.ts +3 -6
- package/dist/types/reducer/Reducer.d.ts +4 -3
- package/dist/types/store/Store.d.ts +252 -17
- package/dist/types/store/call.d.ts +149 -0
- package/dist/types/store/callQueue.d.ts +79 -0
- package/dist/types/store/rejection.d.ts +58 -0
- package/dist/types/types.d.ts +279 -14
- package/dist/types/utils/detectChangedProps.d.ts +6 -0
- package/dist/types/utils/immutability.d.ts +1 -1
- package/dist/types/utils/index.d.ts +2 -2
- package/dist/yoltra.cjs +11 -0
- package/dist/yoltra.cjs.map +1 -0
- package/dist/yoltra.mjs +2902 -0
- package/dist/yoltra.mjs.map +1 -0
- package/dist/yoltra.umd.js +2 -2
- package/dist/yoltra.umd.js.map +1 -0
- package/package.json +21 -21
- package/dist/yoltra.cjs.js +0 -11
- package/dist/yoltra.esm.js +0 -2374
|
@@ -84,10 +84,6 @@ export interface Hydration {
|
|
|
84
84
|
export declare function hydrate(options: PersistOptions & {
|
|
85
85
|
readonly source?: string;
|
|
86
86
|
}): Promise<Hydration>;
|
|
87
|
-
/** A reducer spec, as far as hydration cares: something carrying an initial `state`. */
|
|
88
|
-
interface HasState {
|
|
89
|
-
state: unknown;
|
|
90
|
-
}
|
|
91
87
|
/**
|
|
92
88
|
* Replaces each reducer's initial state with what was restored for it.
|
|
93
89
|
*
|
|
@@ -97,7 +93,9 @@ interface HasState {
|
|
|
97
93
|
*
|
|
98
94
|
* @public
|
|
99
95
|
*/
|
|
100
|
-
export declare function withHydration<R extends Record<string,
|
|
96
|
+
export declare function withHydration<R extends Record<string, {
|
|
97
|
+
state: unknown;
|
|
98
|
+
}>>(reducers: R, hydration: Hydration): R;
|
|
101
99
|
/** The store surface persistence needs, which is two methods wide. */
|
|
102
100
|
export interface PersistableStore {
|
|
103
101
|
getState(): unknown;
|
|
@@ -123,4 +121,3 @@ export declare function persist(store: PersistableStore, options: PersistOptions
|
|
|
123
121
|
* @public
|
|
124
122
|
*/
|
|
125
123
|
export declare function dehydrate(store: Pick<PersistableStore, "getState">, options: Pick<PersistOptions, "version" | "slices">): string;
|
|
126
|
-
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { EventMapBase, EventUnion, ReducerFunction } from '../types';
|
|
1
|
+
import { EventMapBase, EventUnion, ReducerFunction } from '../types.js';
|
|
2
|
+
import { Rejection } from '../store/rejection.js';
|
|
2
3
|
/**
|
|
3
4
|
* Thin wrapper around a pure reducer function (stateful event consumer):
|
|
4
5
|
* given a state `S` and an event (from {@link EventUnion | `EventUnion<EM>`}),
|
|
@@ -68,7 +69,7 @@ export declare class Reducer<S, EM extends EventMapBase = EventMapBase> {
|
|
|
68
69
|
*
|
|
69
70
|
* @param state - Current state.
|
|
70
71
|
* @param event - An event drawn from {@link EventUnion | `EventUnion<EM>`}.
|
|
71
|
-
* @returns The next state
|
|
72
|
+
* @returns The next state, or a {@link Rejection} if the reducer refused the write.
|
|
72
73
|
*
|
|
73
74
|
* @example
|
|
74
75
|
* ```ts
|
|
@@ -77,5 +78,5 @@ export declare class Reducer<S, EM extends EventMapBase = EventMapBase> {
|
|
|
77
78
|
*
|
|
78
79
|
* @public
|
|
79
80
|
*/
|
|
80
|
-
reduce(state: S, event: EventUnion<EM>): S;
|
|
81
|
+
reduce(state: S, event: EventUnion<EM>): S | Rejection;
|
|
81
82
|
}
|
|
@@ -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';
|
|
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
|
|
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
|
*
|
|
@@ -338,6 +418,11 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
338
418
|
* For each changed **leaf path** (via {@link detectChangedProps}), emits that leaf and
|
|
339
419
|
* all of its **ancestors** once (e.g., `"data"`, `"data.123"`, `"data.123.title"`).
|
|
340
420
|
*
|
|
421
|
+
* A slice whose state **is** a single value — a primitive, a `Map`/`Set`, a `Date` — has no
|
|
422
|
+
* leaf below its root, and `detectChangedProps` reports its change as the empty path `""`.
|
|
423
|
+
* That path is emitted as-is, so `connect({ reducer, property: "" })` (and any `**` pattern)
|
|
424
|
+
* hears it. It has no ancestors to walk.
|
|
425
|
+
*
|
|
341
426
|
* **State Immutability**: When a slice changes, a new state object is created via
|
|
342
427
|
* shallow spread: `{ ...this.state, [sliceName]: newSlice }`. This ensures that
|
|
343
428
|
* `this.state` reference changes, enabling efficient change detection via `===`.
|
|
@@ -362,17 +447,60 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
362
447
|
* pattern reducer's throw aborted the commit and notified nobody, not even the uncommitted
|
|
363
448
|
* subscribers a veto would have reached.
|
|
364
449
|
*
|
|
365
|
-
* The semantics are
|
|
450
|
+
* The semantics are the same either way: **the failing slice is isolated.** Its state is
|
|
366
451
|
* unchanged, every other slice still reduces, and the event still commits if anything else
|
|
367
|
-
* changed.
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
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.
|
|
463
|
+
*
|
|
464
|
+
* @internal
|
|
465
|
+
*/
|
|
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.
|
|
371
500
|
*
|
|
372
501
|
* @internal
|
|
373
502
|
*/
|
|
374
|
-
private
|
|
375
|
-
private forwardEvent;
|
|
503
|
+
private commitStaged;
|
|
376
504
|
/**
|
|
377
505
|
* Returns a structured introspection snapshot for DevTools UIs.
|
|
378
506
|
*
|
|
@@ -417,7 +545,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
417
545
|
* fine-grained path changes for each slice.
|
|
418
546
|
*
|
|
419
547
|
* **State Immutability**: If any slices change, a new state object is created via
|
|
420
|
-
* shallow spread. This ensures consistent immutability with {@link
|
|
548
|
+
* shallow spread. This ensures consistent immutability with {@link commitStaged}.
|
|
421
549
|
*
|
|
422
550
|
* **Missing slices**: the snapshot should contain every slice. A slice absent
|
|
423
551
|
* from `nextPlain` is **retained at its current value** (not blanked to
|
|
@@ -456,13 +584,13 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
456
584
|
* phase* (step 5) runs afterwards, asynchronously.
|
|
457
585
|
* 1. **Deduplication** (opt-in) - Skip when content-dedup is enabled (`dedupWindowMs > 0`) or a matching `dedupKey` recurs; off by default
|
|
458
586
|
* 2. **Middleware** (sync) - Pre-reducer hooks; may cancel by returning `false`
|
|
459
|
-
* 3. **Reducers** (sync) -
|
|
460
|
-
* 4. **
|
|
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
|
|
461
589
|
* 5. **Effects** (async) - side-effects keyed by `(channel, type)`; the returned promise resolves once they complete
|
|
462
590
|
*
|
|
463
591
|
* **Change Detection**: Uses reference equality (`===`) on `this.state` to determine
|
|
464
|
-
* if any slice changed. Works because
|
|
465
|
-
*
|
|
592
|
+
* if any slice changed. Works because the commit builds a new state reference via
|
|
593
|
+
* shallow spread when any slice changes.
|
|
466
594
|
*
|
|
467
595
|
* @typeParam C - Channel key in `EM`.
|
|
468
596
|
* @typeParam T - Type key within channel `C`.
|
|
@@ -490,7 +618,20 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
490
618
|
*
|
|
491
619
|
* @public
|
|
492
620
|
*/
|
|
493
|
-
emit<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, payload: EM[C][T], opts?: EmitOptions): Promise<
|
|
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;
|
|
494
635
|
/**
|
|
495
636
|
* Drains the reduce queue **synchronously**. For each event it runs middleware,
|
|
496
637
|
* reducers, event subscribers, and coarse listeners in the same tick, so
|
|
@@ -531,7 +672,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
531
672
|
/**
|
|
532
673
|
* Builds an {@link InstrumentedEvent} from the reduce result and notifies
|
|
533
674
|
* observers. `changedPaths` are the exact slice-prefixed leaf paths recorded
|
|
534
|
-
* by {@link
|
|
675
|
+
* by {@link commitStaged} during this reduce, so DevTools patches need no
|
|
535
676
|
* re-diff.
|
|
536
677
|
*
|
|
537
678
|
* @internal
|
|
@@ -568,7 +709,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
568
709
|
connect(spec: {
|
|
569
710
|
reducer: R;
|
|
570
711
|
property: string;
|
|
571
|
-
}, h: (chg: Change) => void): () => void;
|
|
712
|
+
}, h: (chg: Change) => void, options?: ConnectOptions): () => void;
|
|
572
713
|
/**
|
|
573
714
|
* Subscribe to events by channel and type.
|
|
574
715
|
*
|
|
@@ -738,6 +879,92 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
738
879
|
*
|
|
739
880
|
* @public
|
|
740
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>>;
|
|
741
968
|
registerEffect(spec: EffectSpec<DeepReadonly<S>, EM>): () => void;
|
|
742
969
|
/**
|
|
743
970
|
* Convenience helper to register an **effect** filtered by a single `(channel, type)` pair.
|
|
@@ -950,6 +1177,10 @@ export declare function createStore<S extends Record<string, any>, EM extends Ev
|
|
|
950
1177
|
};
|
|
951
1178
|
onEffectError?: (error: unknown, event: EventUnion<EM>) => void;
|
|
952
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;
|
|
953
1184
|
}): StoreInstance<keyof S & string, S, EM>;
|
|
954
1185
|
/**
|
|
955
1186
|
* Creates a store with types inferred from the reducers map.
|
|
@@ -991,6 +1222,10 @@ export declare function createStore<RM extends ReducersMapAny>(cfg: {
|
|
|
991
1222
|
};
|
|
992
1223
|
onEffectError?: (error: unknown, event: EventUnion<EMFromReducersStrict<RM>>) => void;
|
|
993
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;
|
|
994
1229
|
}): StoreInstance<keyof RM & string, StateFromReducers<RM>, EMFromReducersStrict<RM>>;
|
|
995
1230
|
/**
|
|
996
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
|
+
}
|