@yoltra/core 0.3.0 → 0.4.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 +1 -1
- package/README.md +112 -7
- package/dist/types/entity/entityAdapter.d.ts +118 -0
- package/dist/types/eventBus/EventBus.d.ts +18 -5
- package/dist/types/eventBus/LooseEventBus.d.ts +72 -49
- package/dist/types/index.d.ts +9 -1
- package/dist/types/persistence/adapters.d.ts +33 -0
- package/dist/types/persistence/persist.d.ts +126 -0
- package/dist/types/serialize/codec.d.ts +119 -0
- package/dist/types/store/Store.d.ts +73 -15
- package/dist/types/types.d.ts +129 -29
- package/dist/types/utils/immutability.d.ts +24 -1
- package/dist/yoltra.cjs.js +2 -2
- package/dist/yoltra.esm.js +1090 -460
- package/dist/yoltra.umd.js +2 -2
- package/package.json +23 -3
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lossless encoding of store state for the wire.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* The wire is JSON, and `JSON.stringify` is not a safe way to put arbitrary state on it. It does
|
|
6
|
+
* not fail on the values it cannot represent — it quietly destroys them. A `Map` becomes `{}`, a
|
|
7
|
+
* `Set` becomes `{}`, a `Date` becomes a string, `undefined` disappears from objects entirely,
|
|
8
|
+
* and a `BigInt` or a cycle throws from inside a handler nobody awaits.
|
|
9
|
+
*
|
|
10
|
+
* Silent destruction is the dangerous half. The panel showed `{}` where a `Map` lived, which is
|
|
11
|
+
* merely wrong; but time-travel then sent that `{}` back and applied it to the running store,
|
|
12
|
+
* replacing a live `Map` with an empty object in the user's own application. A debugging tool
|
|
13
|
+
* corrupting the program it is inspecting is the worst failure available to it.
|
|
14
|
+
*
|
|
15
|
+
* Values are therefore tagged rather than coerced. Anything JSON can carry travels unchanged;
|
|
16
|
+
* anything it cannot is wrapped in a marker object that {@link decodeState} reverses exactly.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
/** Options for {@link encodeState}. */
|
|
21
|
+
export interface EncodeOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Redacts a value before it leaves the process.
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* State frequently holds tokens, session material and personal data, and devtools traffic
|
|
27
|
+
* crosses a socket to another process. Return the replacement value, or the value itself to
|
|
28
|
+
* keep it. Applied before encoding, so a redacted value is encoded like any other.
|
|
29
|
+
*/
|
|
30
|
+
readonly sanitize?: (path: string, value: unknown) => unknown;
|
|
31
|
+
/**
|
|
32
|
+
* Maximum number of nodes to encode. Beyond it, subtrees are replaced by a truncation marker.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* A snapshot larger than the hub's frame cap is rejected outright, which reads to the user as
|
|
36
|
+
* a panel that hangs. Truncating visibly is a better failure: the panel renders, and says
|
|
37
|
+
* where it stopped. Defaults to 100000.
|
|
38
|
+
*/
|
|
39
|
+
readonly maxNodes?: number;
|
|
40
|
+
}
|
|
41
|
+
/** Reports what an encode had to compromise. Empty when nothing was lost. */
|
|
42
|
+
export interface EncodeReport {
|
|
43
|
+
/** Node budget was exhausted and some subtrees were replaced by markers. */
|
|
44
|
+
readonly truncated: boolean;
|
|
45
|
+
/** Values no JSON representation exists for, by path — functions, symbols, DOM nodes. */
|
|
46
|
+
readonly unsupported: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
/** Result of {@link encodeState}. */
|
|
49
|
+
export interface EncodeResult {
|
|
50
|
+
readonly value: unknown;
|
|
51
|
+
readonly report: EncodeReport;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Encodes a value into something `JSON.stringify` can carry losslessly.
|
|
55
|
+
*
|
|
56
|
+
* @param input - Any value, including one holding `Map`, `Set`, `Date`, `BigInt` or cycles.
|
|
57
|
+
* @param options - Redaction and size limits.
|
|
58
|
+
* @returns The encoded value plus a report of anything that could not be represented.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* const { value } = encodeState({ index: new Map([['a', 1]]) });
|
|
63
|
+
* JSON.stringify(value); // safe, and decodeState restores the Map
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
68
|
+
export declare function encodeState(input: unknown, options?: EncodeOptions): EncodeResult;
|
|
69
|
+
/**
|
|
70
|
+
* Reverses {@link encodeState}.
|
|
71
|
+
*
|
|
72
|
+
* @param input - A value produced by `encodeState` (typically after a JSON round trip).
|
|
73
|
+
* @returns The original structure, with `Map`, `Set`, `Date` and friends restored.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* Unsupported markers decode to `undefined`: a function cannot be reconstructed, and inventing a
|
|
77
|
+
* placeholder would be worse than an absent value. Cycles are restored by resolving references
|
|
78
|
+
* after the tree is built, so a decoded structure is cyclic exactly where the original was.
|
|
79
|
+
*
|
|
80
|
+
* @public
|
|
81
|
+
*/
|
|
82
|
+
export declare function decodeState(input: unknown): unknown;
|
|
83
|
+
/** Outcome of {@link encodeStateBounded}. */
|
|
84
|
+
export interface BoundedEncodeResult {
|
|
85
|
+
/** The encoded value, small enough to send. */
|
|
86
|
+
readonly value: unknown;
|
|
87
|
+
/** `true` when the state did not fit and parts were replaced by markers. */
|
|
88
|
+
readonly truncated: boolean;
|
|
89
|
+
/** Explains what was dropped, for display beside a partial tree. */
|
|
90
|
+
readonly note?: string;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Encodes a value, shrinking it until its serialized form fits within `maxBytes`.
|
|
94
|
+
*
|
|
95
|
+
* @param input - Any value.
|
|
96
|
+
* @param maxBytes - Byte budget for the serialized form.
|
|
97
|
+
* @param options - Passed through to {@link encodeState}.
|
|
98
|
+
*
|
|
99
|
+
* @returns The encoded value and whether anything had to be dropped.
|
|
100
|
+
*
|
|
101
|
+
* @remarks
|
|
102
|
+
* A frame larger than the hub's cap is not merely slow — it is rejected, and the connection with
|
|
103
|
+
* it, so the client reconnects, asks again, is refused again, and the panel sits waiting through
|
|
104
|
+
* a loop with nothing on screen to explain it. The size therefore has to be bounded before the
|
|
105
|
+
* frame is sent rather than discovered afterwards.
|
|
106
|
+
*
|
|
107
|
+
* Node count is a poor proxy for bytes: a hundred nodes holding base64 blobs outweigh a hundred
|
|
108
|
+
* thousand holding integers. So this measures the encoded output and, when it is too large,
|
|
109
|
+
* scales the node budget by how far over it went and measures again. Scaling by the overshoot
|
|
110
|
+
* rather than halving matters: from a default of a hundred thousand nodes, repeated halving
|
|
111
|
+
* needs a dozen rounds to reach the hundreds, so a state that could have been shown in part
|
|
112
|
+
* would have been abandoned instead.
|
|
113
|
+
*
|
|
114
|
+
* Truncation is reported rather than performed silently. A partial tree presented as the state is
|
|
115
|
+
* worse than no tree at all: a debugger that quietly lies about state is not a debugger.
|
|
116
|
+
*
|
|
117
|
+
* @public
|
|
118
|
+
*/
|
|
119
|
+
export declare function encodeStateBounded(input: unknown, maxBytes: number, options?: EncodeOptions): BoundedEncodeResult;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Event, EventMapBase, EventKey, EventUnion, Change, DeepReadonly, EffectSpec,
|
|
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';
|
|
2
2
|
export declare class Store<EM extends EventMapBase, R extends string, S extends Record<R, any>> implements StoreInstance<R, S, EM> {
|
|
3
3
|
/**
|
|
4
4
|
* Store name (used by DevTools & diagnostics).
|
|
@@ -47,7 +47,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
47
47
|
private readonly listeners;
|
|
48
48
|
/**
|
|
49
49
|
* Registered effect handlers keyed by `"channel::type"` for O(1) lookup.
|
|
50
|
-
* Used for effects with explicit `keys`
|
|
50
|
+
* Used for effects with explicit `keys` targeting.
|
|
51
51
|
*
|
|
52
52
|
* @internal
|
|
53
53
|
*/
|
|
@@ -102,12 +102,33 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
102
102
|
* @internal
|
|
103
103
|
*/
|
|
104
104
|
private readonly replayEnabled;
|
|
105
|
+
/**
|
|
106
|
+
* Produces the `id` for each emitted event. Defaults to `crypto.randomUUID()`; overridable
|
|
107
|
+
* via {@link StoreSpec.idFactory} for runtimes lacking it or for deterministic tests.
|
|
108
|
+
*
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
private readonly idFactory;
|
|
105
112
|
/**
|
|
106
113
|
* Optional hook invoked when an effect throws/rejects. See
|
|
107
114
|
* {@link StoreSpec.onEffectError}. `await emit()` never rejects on effect
|
|
108
115
|
* failure — this is how callers observe effect errors.
|
|
109
116
|
*/
|
|
110
117
|
private readonly onEffectError?;
|
|
118
|
+
/**
|
|
119
|
+
* Optional hook invoked when a reducer throws. See {@link StoreSpec.onReducerError}. The
|
|
120
|
+
* failing slice is isolated rather than the event being rolled back, so this is the only
|
|
121
|
+
* signal that a reducer misbehaved.
|
|
122
|
+
*/
|
|
123
|
+
private readonly onReducerError?;
|
|
124
|
+
/**
|
|
125
|
+
* `slice:channel:type` combinations already warned about for payload aliasing.
|
|
126
|
+
*
|
|
127
|
+
* @remarks
|
|
128
|
+
* Development-only diagnostics have to stay quiet enough to be read. One warning names the
|
|
129
|
+
* pattern; repeating it once per event would bury it.
|
|
130
|
+
*/
|
|
131
|
+
private readonly warnedPayloadAliases;
|
|
111
132
|
/**
|
|
112
133
|
* Pending events awaiting the **synchronous** reduce phase (middleware +
|
|
113
134
|
* reducers + subscribers + coarse listeners). Drained by {@link drainReduce}.
|
|
@@ -327,6 +348,30 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
327
348
|
*
|
|
328
349
|
* @internal
|
|
329
350
|
*/
|
|
351
|
+
/**
|
|
352
|
+
* Reduces one slice and contains any error it raises.
|
|
353
|
+
*
|
|
354
|
+
* @returns `true` when the slice changed.
|
|
355
|
+
*
|
|
356
|
+
* @remarks
|
|
357
|
+
* The single funnel both dispatch paths go through, which is the point. Keyed reducers run
|
|
358
|
+
* through `reducerBus`, whose handler loop caught and logged; pattern reducers were called
|
|
359
|
+
* straight from the drain, so their errors escaped to the caller instead. The same bug in the
|
|
360
|
+
* same reducer therefore produced two different outcomes depending on how the slice happened
|
|
361
|
+
* to be targeted — a keyed reducer's throw let the event commit and its effects run, while a
|
|
362
|
+
* pattern reducer's throw aborted the commit and notified nobody, not even the uncommitted
|
|
363
|
+
* subscribers a veto would have reached.
|
|
364
|
+
*
|
|
365
|
+
* The semantics are now the same either way: **the failing slice is isolated.** Its state is
|
|
366
|
+
* unchanged, every other slice still reduces, and the event still commits if anything else
|
|
367
|
+
* changed. Rolling the whole event back would be tidier in principle, but fine-grained
|
|
368
|
+
* subscribers are notified inside `forwardEvent` as each slice commits, so an event that
|
|
369
|
+
* reverted afterwards would have already told components about a value that no longer exists.
|
|
370
|
+
* Isolation keeps every notification truthful.
|
|
371
|
+
*
|
|
372
|
+
* @internal
|
|
373
|
+
*/
|
|
374
|
+
private forwardEventGuarded;
|
|
330
375
|
private forwardEvent;
|
|
331
376
|
/**
|
|
332
377
|
* Returns a structured introspection snapshot for DevTools UIs.
|
|
@@ -400,6 +445,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
400
445
|
type: string;
|
|
401
446
|
payload: any;
|
|
402
447
|
id: string;
|
|
448
|
+
meta?: EventMeta;
|
|
403
449
|
}>): void;
|
|
404
450
|
/**
|
|
405
451
|
* Emits a typed event `(channel, type, payload)`.
|
|
@@ -605,13 +651,21 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
605
651
|
/**
|
|
606
652
|
* Registers a middleware (runs **before** reducers).
|
|
607
653
|
*
|
|
608
|
-
* @param mw - Middleware `(state, event, emit) => boolean
|
|
609
|
-
*
|
|
654
|
+
* @param mw - Middleware `(state, event, emit) => boolean`. Return `false` to cancel event
|
|
655
|
+
* propagation.
|
|
610
656
|
* @returns Unsubscribe function that removes this middleware.
|
|
611
657
|
*
|
|
658
|
+
* @remarks
|
|
659
|
+
* **Synchronous, and that is the contract.** The reduce phase completes before `emit()`
|
|
660
|
+
* returns, so the commit decision has to be available in the same tick. An `async` middleware
|
|
661
|
+
* returns a Promise, every Promise is truthy, and the veto would therefore never fire — the
|
|
662
|
+
* event would commit while the middleware was still deciding. The type rejects it; this note
|
|
663
|
+
* exists because the examples here used to teach it. Do authorization and validation here, and
|
|
664
|
+
* anything that needs to await in an effect.
|
|
665
|
+
*
|
|
612
666
|
* @example Logging middleware
|
|
613
667
|
* ```ts
|
|
614
|
-
* const off = store.registerMiddleware(
|
|
668
|
+
* const off = store.registerMiddleware((state, event) => {
|
|
615
669
|
* console.log('Event:', event.channel, event.type, event.payload);
|
|
616
670
|
* return true; // allow
|
|
617
671
|
* });
|
|
@@ -628,12 +682,12 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
628
682
|
*
|
|
629
683
|
* @public
|
|
630
684
|
*/
|
|
631
|
-
registerMiddleware(mw:
|
|
685
|
+
registerMiddleware(mw: MiddlewareInput<DeepReadonly<S>, EM>): Unsubscribe;
|
|
632
686
|
/**
|
|
633
687
|
* Dynamically **adds** a named slice reducer at runtime.
|
|
634
688
|
*
|
|
635
689
|
* @param name - New slice name (must not already exist).
|
|
636
|
-
* @param spec - Reducer spec (state,
|
|
690
|
+
* @param spec - Reducer spec (state, when, reducer).
|
|
637
691
|
* @returns Disposer function that **removes** the slice (and its state).
|
|
638
692
|
*
|
|
639
693
|
* @example
|
|
@@ -657,7 +711,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
657
711
|
*
|
|
658
712
|
* Effects are **keyed** by `(channel, type)` for O(1) lookup (no scanning all effects).
|
|
659
713
|
*
|
|
660
|
-
* @param spec - Effect specification with `
|
|
714
|
+
* @param spec - Effect specification with `when` targeting and `effect` (handler).
|
|
661
715
|
* @returns Unsubscribe function.
|
|
662
716
|
*
|
|
663
717
|
* @example Logging effect
|
|
@@ -723,7 +777,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
723
777
|
*
|
|
724
778
|
* @public
|
|
725
779
|
*/
|
|
726
|
-
replaceMiddleware(next:
|
|
780
|
+
replaceMiddleware(next: MiddlewareInput<DeepReadonly<S>, EM>[]): void;
|
|
727
781
|
/**
|
|
728
782
|
* Replaces all registered **effects** (HMR-friendly).
|
|
729
783
|
*
|
|
@@ -780,7 +834,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
780
834
|
*/
|
|
781
835
|
hotReplace(partial: {
|
|
782
836
|
reducer?: Record<R, ReducerSpec<S[R], EM>>;
|
|
783
|
-
middleware?:
|
|
837
|
+
middleware?: MiddlewareInput<DeepReadonly<S>, EM>[];
|
|
784
838
|
effects?: Array<EffectSpec<DeepReadonly<S>, EM>>;
|
|
785
839
|
preserveState?: boolean;
|
|
786
840
|
}): void;
|
|
@@ -789,7 +843,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
789
843
|
* and wires `(channel, type)` listeners on the reducer bus.
|
|
790
844
|
*
|
|
791
845
|
* @param name - Slice name.
|
|
792
|
-
* @param rSpec - Reducer spec (state,
|
|
846
|
+
* @param rSpec - Reducer spec (state, when, reducer).
|
|
793
847
|
* @param opts - `{ preserveState: boolean }` whether to keep existing state.
|
|
794
848
|
*
|
|
795
849
|
* @internal
|
|
@@ -806,9 +860,9 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
|
|
|
806
860
|
*/
|
|
807
861
|
private unmountSlice;
|
|
808
862
|
/**
|
|
809
|
-
* Normalizes event targeting from `when`
|
|
863
|
+
* Normalizes event targeting from `when` to an array of EventKeys.
|
|
810
864
|
*
|
|
811
|
-
* @param spec - Object with optional `when`
|
|
865
|
+
* @param spec - Object with an optional `when` matcher.
|
|
812
866
|
* @returns Array of `[channel, type]` pairs.
|
|
813
867
|
*
|
|
814
868
|
* @internal
|
|
@@ -887,13 +941,15 @@ export declare function createStore<S extends Record<string, any>, EM extends Ev
|
|
|
887
941
|
reducer?: {
|
|
888
942
|
[K in keyof S]?: ReducerSpec<S[K], EM>;
|
|
889
943
|
};
|
|
890
|
-
middleware?:
|
|
944
|
+
middleware?: MiddlewareInput<DeepReadonly<S>, EM>[];
|
|
891
945
|
effects?: Array<EffectSpec<DeepReadonly<S>, EM>>;
|
|
892
946
|
dedupWindowMs?: number;
|
|
947
|
+
idFactory?: () => string;
|
|
893
948
|
devtools?: {
|
|
894
949
|
allowReplay?: boolean;
|
|
895
950
|
};
|
|
896
951
|
onEffectError?: (error: unknown, event: EventUnion<EM>) => void;
|
|
952
|
+
onReducerError?: (error: unknown, event: EventUnion<EM>, slice: string) => void;
|
|
897
953
|
}): StoreInstance<keyof S & string, S, EM>;
|
|
898
954
|
/**
|
|
899
955
|
* Creates a store with types inferred from the reducers map.
|
|
@@ -926,13 +982,15 @@ export declare function createStore<S extends Record<string, any>, EM extends Ev
|
|
|
926
982
|
export declare function createStore<RM extends ReducersMapAny>(cfg: {
|
|
927
983
|
name: string;
|
|
928
984
|
reducer: RM;
|
|
929
|
-
middleware?:
|
|
985
|
+
middleware?: MiddlewareInput<DeepReadonly<StateFromReducers<RM>>, EMFromReducersStrict<RM>>[];
|
|
930
986
|
effects?: Array<EffectSpec<DeepReadonly<StateFromReducers<RM>>, EMFromReducersStrict<RM>>>;
|
|
931
987
|
dedupWindowMs?: number;
|
|
988
|
+
idFactory?: () => string;
|
|
932
989
|
devtools?: {
|
|
933
990
|
allowReplay?: boolean;
|
|
934
991
|
};
|
|
935
992
|
onEffectError?: (error: unknown, event: EventUnion<EMFromReducersStrict<RM>>) => void;
|
|
993
|
+
onReducerError?: (error: unknown, event: EventUnion<EMFromReducersStrict<RM>>, slice: string) => void;
|
|
936
994
|
}): StoreInstance<keyof RM & string, StateFromReducers<RM>, EMFromReducersStrict<RM>>;
|
|
937
995
|
/**
|
|
938
996
|
* Utility to define **typed** `(channel, events[])` definitions for reducer specs.
|
package/dist/types/types.d.ts
CHANGED
|
@@ -48,7 +48,30 @@ export type EventKey<EM extends EventMapBase> = {
|
|
|
48
48
|
[C in keyof EM & string]: [C, keyof EM[C] & string];
|
|
49
49
|
}[keyof EM & string];
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
51
|
+
* Opaque, optional envelope metadata carried alongside an {@link Event}.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* The store never reads, validates or acts on this — it only carries it end to end, so
|
|
55
|
+
* reducers, middleware, effects, event subscribers and instrumentation all observe the same
|
|
56
|
+
* value. It is deliberately untyped at this level: consumers namespace their own keys (for
|
|
57
|
+
* example a tracing integration keeping provenance under `meta.trace`) rather than
|
|
58
|
+
* extending core with domain concepts.
|
|
59
|
+
*
|
|
60
|
+
* It is **not** part of the deduplication fingerprint, which is computed from
|
|
61
|
+
* `(channel, type, payload)` only. Two events differing solely in `meta` still dedupe.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* await store.emit('orders', 'created', payload, {
|
|
66
|
+
* meta: { trace: { origin: 'checkout-service', hop: 1 } },
|
|
67
|
+
* });
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @public
|
|
71
|
+
*/
|
|
72
|
+
export type EventMeta = Readonly<Record<string, unknown>>;
|
|
73
|
+
/**
|
|
74
|
+
* A single event object: `{ channel, type, payload, id }`, plus optional `meta`.
|
|
52
75
|
*
|
|
53
76
|
* @typeParam EM - Event map.
|
|
54
77
|
* @typeParam C - Channel key.
|
|
@@ -56,14 +79,16 @@ export type EventKey<EM extends EventMapBase> = {
|
|
|
56
79
|
* @typeParam P - Payload type (defaults to `EM[C][T]`).
|
|
57
80
|
*
|
|
58
81
|
* @remarks
|
|
59
|
-
* - The `id` field is automatically added by the store to enable deduplication
|
|
82
|
+
* - The `id` field is automatically added by the store to enable deduplication, unless the
|
|
83
|
+
* emitter supplies one via {@link EmitOptions.id}.
|
|
60
84
|
* - Used for preventing duplicate event processing (e.g., React Strict Mode).
|
|
85
|
+
* - `meta` is present only when {@link EmitOptions.meta} was supplied. See {@link EventMeta}.
|
|
61
86
|
*
|
|
62
87
|
* @example
|
|
63
88
|
* ```ts
|
|
64
89
|
* type EM = { ui: { toggle: boolean } };
|
|
65
90
|
* type Evt = Event<EM, 'ui', 'toggle'>;
|
|
66
|
-
* // { channel: 'ui'; type: 'toggle'; payload: boolean; id: string }
|
|
91
|
+
* // { channel: 'ui'; type: 'toggle'; payload: boolean; id: string; meta?: EventMeta }
|
|
67
92
|
* ```
|
|
68
93
|
*
|
|
69
94
|
* @public
|
|
@@ -74,6 +99,11 @@ export interface Event<EM extends EventMapBase = EventMapBase, C extends keyof E
|
|
|
74
99
|
payload: P;
|
|
75
100
|
/** Unique identifier for deduplication and devtools tracking (automatically added by store) */
|
|
76
101
|
id: string;
|
|
102
|
+
/**
|
|
103
|
+
* Optional caller-supplied metadata, carried through the pipeline untouched.
|
|
104
|
+
* Absent entirely unless {@link EmitOptions.meta} was supplied. See {@link EventMeta}.
|
|
105
|
+
*/
|
|
106
|
+
readonly meta?: EventMeta;
|
|
77
107
|
}
|
|
78
108
|
/**
|
|
79
109
|
* Generic "old → new" wrapper for fine-grained change notifications.
|
|
@@ -129,6 +159,41 @@ export interface EmitOptions {
|
|
|
129
159
|
* is 0, using a short default window.
|
|
130
160
|
*/
|
|
131
161
|
dedupKey?: string;
|
|
162
|
+
/**
|
|
163
|
+
* Use this exact id for the event instead of generating one.
|
|
164
|
+
*
|
|
165
|
+
* @remarks
|
|
166
|
+
* Intended for **idempotent re-emission**: a caller replaying an event from elsewhere (a
|
|
167
|
+
* peer store, a durable log) can preserve the original id so the same logical event keeps
|
|
168
|
+
* one identity everywhere, which makes it traceable across systems and in DevTools.
|
|
169
|
+
*
|
|
170
|
+
* The store does **not** enforce uniqueness — supplying a duplicate id does not dedupe the
|
|
171
|
+
* event. Deduplication is a separate, opt-in concern; see {@link EmitOptions.dedupKey}.
|
|
172
|
+
*/
|
|
173
|
+
id?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Metadata to attach to this event, carried through the pipeline untouched and visible to
|
|
176
|
+
* reducers, middleware, effects, subscribers and instrumentation. See {@link EventMeta}.
|
|
177
|
+
*
|
|
178
|
+
* @remarks
|
|
179
|
+
* Omitting this leaves `event.meta` genuinely absent rather than `undefined`, so event
|
|
180
|
+
* objects are byte-identical to those produced before this option existed.
|
|
181
|
+
*/
|
|
182
|
+
meta?: EventMeta;
|
|
183
|
+
/**
|
|
184
|
+
* Bypass deduplication for this emit entirely, even when the store was created with
|
|
185
|
+
* {@link StoreSpec.dedupWindowMs} greater than 0.
|
|
186
|
+
*
|
|
187
|
+
* @remarks
|
|
188
|
+
* Content-based dedup fingerprints `(channel, type, payload)`, so a store with a dedup
|
|
189
|
+
* window silently collapses genuinely distinct events that happen to share a payload —
|
|
190
|
+
* repeated ticks with an empty payload, or the same event legitimately arriving twice from
|
|
191
|
+
* two different sources. Set this when the caller already guarantees distinctness by other
|
|
192
|
+
* means and needs every emit to land.
|
|
193
|
+
*
|
|
194
|
+
* Takes precedence over both {@link EmitOptions.dedupKey} and the store-level window.
|
|
195
|
+
*/
|
|
196
|
+
skipDedup?: boolean;
|
|
132
197
|
}
|
|
133
198
|
export type Emit<EM extends EventMapBase> = <C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, payload: EM[C][T], opts?: EmitOptions) => Promise<void>;
|
|
134
199
|
/**
|
|
@@ -145,12 +210,16 @@ export type Unsubscribe = () => void;
|
|
|
145
210
|
* @public
|
|
146
211
|
*/
|
|
147
212
|
export interface InstrumentedEvent<EM extends EventMapBase = EventMapBase> {
|
|
148
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* The processed event, including its `id` and any {@link EventMeta} the emitter attached.
|
|
215
|
+
* `meta` is absent unless it was supplied.
|
|
216
|
+
*/
|
|
149
217
|
event: {
|
|
150
218
|
id: string;
|
|
151
219
|
channel: string;
|
|
152
220
|
type: string;
|
|
153
221
|
payload: unknown;
|
|
222
|
+
meta?: EventMeta;
|
|
154
223
|
};
|
|
155
224
|
/** `true` if the event passed middleware and ran reducers; `false` if vetoed. */
|
|
156
225
|
committed: boolean;
|
|
@@ -295,6 +364,26 @@ export type StoreSpec<R extends string, S extends Record<R, any>, EM extends Eve
|
|
|
295
364
|
* @default 0 (disabled)
|
|
296
365
|
*/
|
|
297
366
|
dedupWindowMs?: number;
|
|
367
|
+
/**
|
|
368
|
+
* Generates the `id` for each emitted event. Defaults to `crypto.randomUUID()`.
|
|
369
|
+
*
|
|
370
|
+
* @remarks
|
|
371
|
+
* Two reasons to override it. First, portability: `crypto.randomUUID` requires a **secure
|
|
372
|
+
* context** in browsers and is absent on some runtimes (React Native / Hermes), where the
|
|
373
|
+
* default would throw on every emit. Second, determinism: injecting a counter makes event
|
|
374
|
+
* ids stable across runs, which is what allows byte-exact assertions in tests.
|
|
375
|
+
*
|
|
376
|
+
* The factory must return a string. Uniqueness is the caller's responsibility.
|
|
377
|
+
*
|
|
378
|
+
* @default () => crypto.randomUUID()
|
|
379
|
+
*
|
|
380
|
+
* @example
|
|
381
|
+
* ```ts
|
|
382
|
+
* let n = 0;
|
|
383
|
+
* const store = createStore({ name: 'Test', reducer, idFactory: () => `evt-${++n}` });
|
|
384
|
+
* ```
|
|
385
|
+
*/
|
|
386
|
+
idFactory?: () => string;
|
|
298
387
|
/**
|
|
299
388
|
* DevTools configuration options.
|
|
300
389
|
*
|
|
@@ -324,6 +413,25 @@ export type StoreSpec<R extends string, S extends Record<R, any>, EM extends Eve
|
|
|
324
413
|
* @param event - The event whose effect failed.
|
|
325
414
|
*/
|
|
326
415
|
onEffectError?: (error: unknown, event: EventUnion<EM>) => void;
|
|
416
|
+
/**
|
|
417
|
+
* Invoked when a reducer throws.
|
|
418
|
+
*
|
|
419
|
+
* @remarks
|
|
420
|
+
* A reducer is meant to be pure and total, so a throw is a bug in application code — and it
|
|
421
|
+
* used to be almost invisible. Keyed reducers ran through a bus that logged and moved on,
|
|
422
|
+
* letting the event commit and its effects run; pattern reducers threw straight out of the
|
|
423
|
+
* drain, aborting the commit and notifying nobody. Both paths now isolate the failing slice
|
|
424
|
+
* and report here.
|
|
425
|
+
*
|
|
426
|
+
* The failing slice keeps its previous state; every other slice still reduces, and the event
|
|
427
|
+
* still commits if anything else changed. `emit()` never rejects because of a reducer error,
|
|
428
|
+
* so this hook is how a caller observes one.
|
|
429
|
+
*
|
|
430
|
+
* @param error - The thrown value.
|
|
431
|
+
* @param event - The event being reduced when it threw.
|
|
432
|
+
* @param slice - Name of the slice whose reducer threw.
|
|
433
|
+
*/
|
|
434
|
+
onReducerError?: (error: unknown, event: EventUnion<EM>, slice: string) => void;
|
|
327
435
|
};
|
|
328
436
|
/**
|
|
329
437
|
* Public Store surface.
|
|
@@ -384,9 +492,9 @@ export interface StoreInstance<R extends string = string, S extends Record<R, an
|
|
|
384
492
|
*/
|
|
385
493
|
registerEffect(spec: EffectSpec<DeepReadonly<S>, EM>): Unsubscribe;
|
|
386
494
|
/**
|
|
387
|
-
* Dynamically add middleware.
|
|
495
|
+
* Dynamically add middleware, in either the function or the spec form.
|
|
388
496
|
*/
|
|
389
|
-
registerMiddleware(mw:
|
|
497
|
+
registerMiddleware(mw: MiddlewareInput<DeepReadonly<S>, EM>): Unsubscribe;
|
|
390
498
|
/**
|
|
391
499
|
* Dynamically add/remove a namespaced reducer slice at runtime.
|
|
392
500
|
*/
|
|
@@ -466,7 +574,7 @@ export interface StoreInstance<R extends string = string, S extends Record<R, an
|
|
|
466
574
|
*/
|
|
467
575
|
hotReplace(partial: {
|
|
468
576
|
reducer?: Record<R, ReducerSpec<S[R], EM>>;
|
|
469
|
-
middleware?:
|
|
577
|
+
middleware?: MiddlewareInput<DeepReadonly<S>, EM>[];
|
|
470
578
|
effects?: Array<EffectSpec<DeepReadonly<S>, EM>>;
|
|
471
579
|
preserveState?: boolean;
|
|
472
580
|
}): void;
|
|
@@ -487,6 +595,7 @@ export interface StoreInstance<R extends string = string, S extends Record<R, an
|
|
|
487
595
|
type: string;
|
|
488
596
|
payload: any;
|
|
489
597
|
id: string;
|
|
598
|
+
meta?: EventMeta;
|
|
490
599
|
}>): void;
|
|
491
600
|
/**
|
|
492
601
|
* Returns a structured introspection snapshot for DevTools UIs.
|
|
@@ -569,15 +678,6 @@ export interface StoreInstance<R extends string = string, S extends Record<R, an
|
|
|
569
678
|
* };
|
|
570
679
|
* ```
|
|
571
680
|
*
|
|
572
|
-
* @example Using `events` (legacy)
|
|
573
|
-
* ```ts
|
|
574
|
-
* const counterSpec: ReducerSpec<{ value: number }, MyEM> = {
|
|
575
|
-
* state: { value: 0 },
|
|
576
|
-
* events: [['ui', 'increment'], ['ui', 'decrement']],
|
|
577
|
-
* reducer(s, evt) { ... },
|
|
578
|
-
* };
|
|
579
|
-
* ```
|
|
580
|
-
*
|
|
581
681
|
* @public
|
|
582
682
|
*/
|
|
583
683
|
export interface ReducerSpec<S = any, EM extends EventMapBase = EventMapBase> {
|
|
@@ -587,14 +687,8 @@ export interface ReducerSpec<S = any, EM extends EventMapBase = EventMapBase> {
|
|
|
587
687
|
state: S;
|
|
588
688
|
/**
|
|
589
689
|
* Event targeting using the unified `When` matcher.
|
|
590
|
-
* Preferred over `events` for new code.
|
|
591
690
|
*/
|
|
592
691
|
when?: When<EM>;
|
|
593
|
-
/**
|
|
594
|
-
* List of EventKeys `[channel, type]` that this reducer responds to.
|
|
595
|
-
* @deprecated Use `when: { keys: [...] }` instead for better type inference.
|
|
596
|
-
*/
|
|
597
|
-
events?: ReadonlyArray<EventKey<EM>>;
|
|
598
692
|
/**
|
|
599
693
|
* Pure reducer function: `(state, event) => nextState`.
|
|
600
694
|
*/
|
|
@@ -651,14 +745,8 @@ export type ReducerFunction<S = any, EM extends EventMapBase = EventMapBase> = (
|
|
|
651
745
|
export interface EffectSpec<S = any, EM extends EventMapBase = EventMapBase> {
|
|
652
746
|
/**
|
|
653
747
|
* Event targeting using the unified `When` matcher.
|
|
654
|
-
* Preferred over `events` for new code.
|
|
655
748
|
*/
|
|
656
749
|
when?: When<EM>;
|
|
657
|
-
/**
|
|
658
|
-
* List of EventKeys `[channel, type]` that this effect responds to.
|
|
659
|
-
* @deprecated Use `when: { keys: [...] }` instead for better type inference.
|
|
660
|
-
*/
|
|
661
|
-
events?: ReadonlyArray<EventKey<EM>>;
|
|
662
750
|
/**
|
|
663
751
|
* Async effect handler: `(event, getState, emit) => void | Promise<void>`.
|
|
664
752
|
*/
|
|
@@ -986,11 +1074,23 @@ export type Dotted<Slice> = (keyof Slice & string) | Path<Slice>;
|
|
|
986
1074
|
/**
|
|
987
1075
|
* Deep readonly type: recursively makes all properties readonly.
|
|
988
1076
|
*
|
|
1077
|
+
* @remarks
|
|
1078
|
+
* The built-in object types are handled before the general mapped-object case, because
|
|
1079
|
+
* mapping over one destroys it. `{ readonly [K in keyof Map<K, V>]: ... }` produces an object
|
|
1080
|
+
* carrying the *names* of a Map's methods with their signatures rewritten, so reading a Map
|
|
1081
|
+
* out of state and calling `.get()` on it was a type error even though the value at runtime
|
|
1082
|
+
* is an ordinary Map. The same applied to `Set`, `Date`, `RegExp` and any function stored in
|
|
1083
|
+
* state.
|
|
1084
|
+
*
|
|
1085
|
+
* Collections become their `Readonly*` counterparts, which is the same treatment arrays
|
|
1086
|
+
* already had. Functions are returned untouched: a function's properties are not state, and
|
|
1087
|
+
* mapping over them makes it uncallable.
|
|
1088
|
+
*
|
|
989
1089
|
* @typeParam T - Type to make readonly.
|
|
990
1090
|
*
|
|
991
1091
|
* @public
|
|
992
1092
|
*/
|
|
993
|
-
export type DeepReadonly<T> = T extends (infer A)[] ? ReadonlyArray<DeepReadonly<A>> : T extends object ? {
|
|
1093
|
+
export type DeepReadonly<T> = T extends (...args: never[]) => unknown ? T : T extends (infer A)[] ? ReadonlyArray<DeepReadonly<A>> : T extends ReadonlyMap<infer K, infer V> ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>> : T extends ReadonlySet<infer V> ? ReadonlySet<DeepReadonly<V>> : T extends Date | RegExp | Promise<unknown> | Error ? T : T extends object ? {
|
|
994
1094
|
readonly [K in keyof T]: DeepReadonly<T[K]>;
|
|
995
1095
|
} : T;
|
|
996
1096
|
/**
|
|
@@ -44,4 +44,27 @@ import { DeepReadonly } from '../types';
|
|
|
44
44
|
*
|
|
45
45
|
* @public
|
|
46
46
|
*/
|
|
47
|
-
export declare function freezeState<T>(obj: T, seen?: WeakSet<object
|
|
47
|
+
export declare function freezeState<T>(obj: T, seen?: WeakSet<object>, alias?: AliasWatch): DeepReadonly<T>;
|
|
48
|
+
/**
|
|
49
|
+
* Watches the freeze walk for one specific reference.
|
|
50
|
+
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* Exists to turn a dev-only heisenbug into a named warning. Because the freeze is deep and
|
|
53
|
+
* in place, anything a reducer stores **by reference** is frozen too — the event payload, a
|
|
54
|
+
* module-level default, a cached response. Mutating that object afterwards then throws, only in
|
|
55
|
+
* development, from a stack that has nothing to do with the store, and the same code works in
|
|
56
|
+
* production because the freeze is compiled out.
|
|
57
|
+
*
|
|
58
|
+
* Freezing it is not the mistake: an object reachable from state genuinely must not be mutated,
|
|
59
|
+
* or state changes behind the store's back. Keeping the reference is. The walk already visits
|
|
60
|
+
* every node, so recognising one of them costs an identity comparison and lets the store say so
|
|
61
|
+
* at the moment it happens.
|
|
62
|
+
*
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
export interface AliasWatch {
|
|
66
|
+
/** The reference to look for while freezing. */
|
|
67
|
+
readonly watch: object;
|
|
68
|
+
/** Called if `watch` is reachable from the value being frozen. */
|
|
69
|
+
readonly onFound: () => void;
|
|
70
|
+
}
|