@yoltra/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import { EventKey, EventMapBase, EventUnion, MiddlewareFunction, MiddlewareInput, When } from '../types.js';
2
+ /**
3
+ * Checks if an event matches a `When` matcher.
4
+ *
5
+ * @param when - The When matcher (or undefined for "all events").
6
+ * @param event - The event to check.
7
+ * @returns `true` if the event matches, `false` otherwise.
8
+ *
9
+ * @remarks
10
+ * - `undefined` or missing `when` matches ALL events.
11
+ * - `{ any: true }` matches ALL events.
12
+ * - `{ keys: [...] }` matches if event's `[channel, type]` is in the array.
13
+ * - `{ channel: 'x' }` matches if event's channel equals 'x'.
14
+ * - `{ channels: ['x', 'y'] }` matches if event's channel is in the array.
15
+ *
16
+ * @internal
17
+ */
18
+ export declare function matchesWhen<EM extends EventMapBase>(when: When<EM> | undefined, event: EventUnion<EM>): boolean;
19
+ /**
20
+ * Extracts the middleware function from a MiddlewareInput.
21
+ * Handles both raw functions (legacy) and MiddlewareSpec objects.
22
+ *
23
+ * @param input - MiddlewareInput (function or spec).
24
+ * @returns The middleware function.
25
+ *
26
+ * @internal
27
+ */
28
+ export declare function getMiddlewareFunction<St, EM extends EventMapBase>(input: MiddlewareInput<St, EM>): MiddlewareFunction<St, EM>;
29
+ /**
30
+ * Gets the `when` matcher from a MiddlewareInput.
31
+ *
32
+ * @param input - MiddlewareInput (function or spec).
33
+ * @returns The `when` matcher, or `undefined` for raw functions (match all).
34
+ *
35
+ * @internal
36
+ */
37
+ export declare function getMiddlewareWhen<St, EM extends EventMapBase>(input: MiddlewareInput<St, EM>): When<EM> | undefined;
38
+ /**
39
+ * Normalizes event targeting from `when` to an array of EventKeys.
40
+ *
41
+ * @param spec - Object with an optional `when` matcher.
42
+ * @returns Array of `[channel, type]` pairs.
43
+ *
44
+ * @internal
45
+ */
46
+ export declare function normalizeEventKeys<EM extends EventMapBase>(spec: {
47
+ when?: When<EM>;
48
+ events?: ReadonlyArray<EventKey<EM>>;
49
+ }): ReadonlyArray<EventKey<EM>>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Reading and expanding dotted state paths.
3
+ *
4
+ * @remarks
5
+ * Moved out of `Store.ts` unchanged. Neither function touched an instance field.
6
+ *
7
+ * `Store` still exposes both as members, and deliberately so. `Store.buildAncestorPaths` is
8
+ * public API that appears in the committed reference, and `getAtPath` is replaced on the
9
+ * instance by a test that counts the walks a change description costs, so the internal callers
10
+ * have to keep reaching it through `this`.
11
+ *
12
+ * @module
13
+ */
14
+ /**
15
+ * Reads a dotted path from an object (supports numeric array indices via string keys).
16
+ *
17
+ * @param obj - Root object (slice or value).
18
+ * @param path - Dotted path; leading dot is ignored.
19
+ * @returns The value at the path, or `undefined`.
20
+ *
21
+ * @internal
22
+ */
23
+ export declare function getAtPath(obj: any, path: string): any;
24
+ /**
25
+ * Builds ancestor paths for a dotted path.
26
+ *
27
+ * For `"a.b.c"`, returns `["a", "a.b", "a.b.c"]`. Leading dots are trimmed.
28
+ *
29
+ * @param path - Dotted path string.
30
+ * @returns Array of ancestor paths.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * buildAncestorPaths('x.y.z'); // ['x','x.y','x.y.z']
35
+ * ```
36
+ *
37
+ * @public
38
+ */
39
+ export declare function buildAncestorPaths(path: string): string[];
@@ -0,0 +1,15 @@
1
+ import { DeepReadonly, EffectSpec, EmitOptions, EmitResult, EventMapBase, EventUnion } from '../types.js';
2
+ import { CallHandle, CallOptions } from './call.js';
3
+ /**
4
+ * What `performCall` needs from the store.
5
+ *
6
+ * @remarks
7
+ * Three members, named rather than structural over the whole class, because three is few enough
8
+ * that naming them documents the coupling instead of hiding it.
9
+ */
10
+ export interface CallDeps<St, EM extends EventMapBase> {
11
+ readonly idFactory: () => string;
12
+ readonly registerEffect: (spec: EffectSpec<DeepReadonly<St>, EM>) => () => void;
13
+ readonly emit: <C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, payload: EM[C][T], opts?: EmitOptions) => Promise<EmitResult>;
14
+ }
15
+ export declare function performCall<St, EM extends EventMapBase, C extends keyof EM & string, T extends keyof EM[C] & string>(deps: CallDeps<St, EM>, channel: C, type: T, payload: EM[C][T], opts: CallOptions<EM>): CallHandle<EventUnion<EM>, EventUnion<EM>>;
@@ -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 bundle that inlined a second copy, a consumer that
10
+ * pinned an older 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 {};
@@ -1,6 +1,5 @@
1
- /**
2
- * @module @yoltra/core
3
- */
1
+ import { Rejection } from './store/rejection.js';
2
+ import { CallHandle, CallOptions } from './store/call.js';
4
3
  /**
5
4
  * A minimal "record of record" constraint for EventMaps.
6
5
  *
@@ -104,6 +103,29 @@ export interface Event<EM extends EventMapBase = EventMapBase, C extends keyof E
104
103
  * Absent entirely unless {@link EmitOptions.meta} was supplied. See {@link EventMeta}.
105
104
  */
106
105
  readonly meta?: EventMeta;
106
+ /**
107
+ * The `id` of the event whose handling caused this one, when there was one.
108
+ *
109
+ * @remarks
110
+ * Absent on a **root** event — one emitted by application code rather than by a middleware,
111
+ * subscriber or effect reacting to another event. Together with {@link Event.depth} this makes
112
+ * a cascade legible after the fact: without it, a runaway chain is a pile of unrelated events
113
+ * with no way to tell which caused which.
114
+ */
115
+ readonly parentId?: string;
116
+ /**
117
+ * How many events deep in a causal chain this one is. A root event is depth `0`; an event
118
+ * emitted while handling it is `1`, and so on.
119
+ *
120
+ * @remarks
121
+ * Absent on a root event rather than present as `0`, so an event emitted by application code
122
+ * stays byte-identical to one built before causality tracking existed — the same treatment
123
+ * {@link Event.meta} gets, and for the same reason: `Object.keys` and `toStrictEqual` are load
124
+ * bearing in consumer tests.
125
+ *
126
+ * This is the value {@link StoreSpec.maxReduceDepth} bounds.
127
+ */
128
+ readonly depth?: number;
107
129
  }
108
130
  /**
109
131
  * Generic "old → new" wrapper for fine-grained change notifications.
@@ -127,6 +149,20 @@ export interface Change<V = any> {
127
149
  newValue: V;
128
150
  /** Dotted path for fine-grained listeners; e.g., "data.items.0.title" */
129
151
  path?: string;
152
+ /**
153
+ * The `id` of the event that caused this change.
154
+ *
155
+ * @remarks
156
+ * A change used to be anonymous, so a subscriber that needed to know *why* a value moved had
157
+ * to mirror the cause into state and store it twice. Absent when the change did not come from
158
+ * an event — a DevTools time-travel snapshot, for instance — which is itself the signal that
159
+ * no event caused it.
160
+ */
161
+ eventId?: string;
162
+ /** Channel of the causing event. Absent for the same reason as {@link Change.eventId}. */
163
+ channel?: string;
164
+ /** Type of the causing event. Absent for the same reason as {@link Change.eventId}. */
165
+ type?: string;
130
166
  }
131
167
  /**
132
168
  * Emit function narrowed to the developer's EventMap.
@@ -143,6 +179,58 @@ export interface Change<V = any> {
143
179
  *
144
180
  * @public
145
181
  */
182
+ /**
183
+ * What an `emit` resolves to once its effects have run.
184
+ *
185
+ * @remarks
186
+ * `emit` used to resolve to `void`, so a caller could not tell "the reducer applied my write"
187
+ * from "the reducer looked at my write and returned the state unchanged". On a single-writer
188
+ * store that distinction is academic; on a contended one it is a lost update the API could not
189
+ * report.
190
+ *
191
+ * Deliberately does **not** carry the changed paths. Building that list costs a string
192
+ * concatenation per changed path on every emit, and almost no caller reads it — the same reason
193
+ * change notifications are built lazily. Instrumentation already provides them to the observers
194
+ * that do want them.
195
+ *
196
+ * @public
197
+ */
198
+ export interface EmitResult {
199
+ /**
200
+ * The event was not vetoed by middleware.
201
+ *
202
+ * @remarks
203
+ * Unchanged in meaning, and deliberately not narrowed to "state changed" — an event-only store
204
+ * commits every event and writes nothing, by construction.
205
+ */
206
+ readonly committed: boolean;
207
+ /** A reducer actually changed state. */
208
+ readonly written: boolean;
209
+ /** Present when a reducer refused the write. See {@link Rejection}. */
210
+ readonly rejected?: Rejection;
211
+ }
212
+ /**
213
+ * Options for {@link StoreInstance.connect}.
214
+ *
215
+ * @public
216
+ */
217
+ export interface ConnectOptions {
218
+ /**
219
+ * Deliver the current value once, immediately, before any change arrives.
220
+ *
221
+ * @remarks
222
+ * A subscription otherwise starts at "from now on", so a subscriber's first render has to read
223
+ * the path separately — the same path, spelled twice, which is one place for them to drift.
224
+ *
225
+ * The synthetic change has `oldValue: undefined` and no `eventId`, `channel` or `type`: no
226
+ * event caused it, and claiming one would be a lie a subscriber could act on.
227
+ *
228
+ * For a wildcard pattern the "current value" of a match set is not a thing, so the slice root
229
+ * is delivered with `path: ""`. React's hooks do not need this at all — `useSyncExternalStore`
230
+ * already reads a snapshot on mount — so it is aimed at imperative subscribers.
231
+ */
232
+ readonly immediate?: boolean;
233
+ }
146
234
  /**
147
235
  * Per-emit options.
148
236
  *
@@ -163,8 +251,8 @@ export interface EmitOptions {
163
251
  * Use this exact id for the event instead of generating one.
164
252
  *
165
253
  * @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
254
+ * Intended for **idempotent re-emission**: a caller replaying an event from elsewhere (another
255
+ * store, a durable log) can preserve the original id so the same logical event keeps
168
256
  * one identity everywhere, which makes it traceable across systems and in DevTools.
169
257
  *
170
258
  * The store does **not** enforce uniqueness — supplying a duplicate id does not dedupe the
@@ -195,7 +283,7 @@ export interface EmitOptions {
195
283
  */
196
284
  skipDedup?: boolean;
197
285
  }
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>;
286
+ 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<EmitResult>;
199
287
  /**
200
288
  * Basic unsubscribe handle.
201
289
  *
@@ -235,6 +323,15 @@ export interface InstrumentedEvent<EM extends EventMapBase = EventMapBase> {
235
323
  nextValues: Record<string, unknown>;
236
324
  /** Wall-clock milliseconds spent in the synchronous reduce phase for this event. */
237
325
  reduceTimeMs: number;
326
+ /**
327
+ * Present when a reducer refused the write, carrying its reason.
328
+ *
329
+ * @remarks
330
+ * Distinct from `committed: false`, which means middleware vetoed the event before any reducer
331
+ * saw it. This is a reducer having considered the write and declined it — the two look
332
+ * identical in state and are entirely different in cause.
333
+ */
334
+ rejected?: Rejection;
238
335
  }
239
336
  /**
240
337
  * Observer for {@link StoreInstance.instrument}. Called once per emitted event
@@ -432,7 +529,104 @@ export type StoreSpec<R extends string, S extends Record<R, any>, EM extends Eve
432
529
  * @param slice - Name of the slice whose reducer threw.
433
530
  */
434
531
  onReducerError?: (error: unknown, event: EventUnion<EM>, slice: string) => void;
532
+ /**
533
+ * Maximum causal depth of an event chain before the store refuses to extend it.
534
+ *
535
+ * @remarks
536
+ * An event emitted while handling another is one deeper than its cause. Two reducers wired to
537
+ * each other, or an effect that emits the event its own reducer answers, climb this without
538
+ * bound — and the reduce queue drains synchronously, so in a browser that is a frozen tab with
539
+ * no error and no stack, and on a server a pinned core.
540
+ *
541
+ * **On by default**, because the whole point is that the failure mode does not require
542
+ * configuration to avoid. The default is far past any legitimate chain: an event caused by an
543
+ * event caused by an event is normal, sixty-four deep is a bug. Raise it if an application
544
+ * genuinely nests deeper, or set `Infinity` to opt out entirely and own the consequences.
545
+ *
546
+ * Breaching does not throw — see {@link StoreSpec.onCascade}.
547
+ *
548
+ * @default 64
549
+ */
550
+ maxReduceDepth?: number;
551
+ /**
552
+ * Maximum number of events one synchronous drain will process before refusing more.
553
+ *
554
+ * @remarks
555
+ * A drain processes one root event plus every event emitted *while it runs* — so this counts a
556
+ * single causal burst, not application traffic. A plain loop is unaffected: `emit` drains to
557
+ * completion before it returns, so `for (const row of rows) store.emit(…)` is a thousand drains
558
+ * of one event each, never one drain of a thousand.
559
+ *
560
+ * **Off by default** because a wide burst is not by itself a bug. One `sync` event whose
561
+ * subscriber fans out to five hundred `upsert`s is a legitimate shape, and a default low enough
562
+ * to catch a runaway would refuse it. Depth is what separates a cascade from a fan-out — a
563
+ * fan-out is wide and shallow, a cascade is narrow and deep — which is why
564
+ * {@link StoreSpec.maxReduceDepth} carries the default and this does not.
565
+ *
566
+ * Set it when a store's bursts are known to be bounded and an unexpectedly wide one is itself
567
+ * the symptom worth catching.
568
+ *
569
+ * @default undefined (no limit)
570
+ */
571
+ maxTransitionsPerDrain?: number;
572
+ /**
573
+ * Called when a ceiling is breached, instead of throwing.
574
+ *
575
+ * @remarks
576
+ * The offending emit is refused and the chain stops there; everything already committed
577
+ * stands. It does not throw, because the throw would surface in whichever frame happened to be
578
+ * emitting — a subscriber, an effect, a middleware — which is the same species of
579
+ * hard-to-attribute failure the ceiling exists to prevent. A cascade is a wiring bug, and this
580
+ * is where the wiring gets named.
581
+ *
582
+ * @param info - Which ceiling, the event that would have extended the chain, and its causal
583
+ * chain of ids, newest last.
584
+ */
585
+ onCascade?: (info: CascadeInfo<EM>) => void;
586
+ /**
587
+ * Called when a reducer refuses a write by returning {@link Rejected}.
588
+ *
589
+ * @remarks
590
+ * The caller learns of its own refusal from the `emit` result; this is for everyone else —
591
+ * logging, metrics, alerting on a rate of rejected writes. Shaped as a callback rather than a
592
+ * subscription for the same reason {@link StoreSpec.onReducerError} is: it is a rare global
593
+ * signal, not something several independent parties register and unregister for.
594
+ *
595
+ * A refusal is a normal outcome, not an error. It means a reducer considered the write and
596
+ * declined it — a stale compare-and-swap, an unmet precondition — and the event is rejected
597
+ * whole, so no slice writes.
598
+ *
599
+ * @param rejection - The refusal and its reason.
600
+ * @param event - The event that was refused.
601
+ * @param slice - Name of the slice whose reducer refused.
602
+ */
603
+ onRejected?: (rejection: Rejection, event: EventUnion<EM>, slice: string) => void;
435
604
  };
605
+ /**
606
+ * What {@link StoreSpec.onCascade} receives when a ceiling is breached.
607
+ *
608
+ * @typeParam EM - Event map.
609
+ *
610
+ * @public
611
+ */
612
+ export interface CascadeInfo<EM extends EventMapBase = EventMapBase> {
613
+ /** Which ceiling was hit. */
614
+ readonly limit: "maxReduceDepth" | "maxTransitionsPerDrain";
615
+ /** The configured value that was exceeded. */
616
+ readonly limitValue: number;
617
+ /** The event that was refused — the one that would have extended the chain. */
618
+ readonly event: EventUnion<EM>;
619
+ /** Causal depth the refused event would have had. */
620
+ readonly depth: number;
621
+ /**
622
+ * Ids from the root of the chain to the refused event's parent, newest last.
623
+ *
624
+ * @remarks
625
+ * Bounded to the most recent entries: a cascade is long by definition, and the useful part is
626
+ * the cycle at the end rather than the thousand identical hops before it.
627
+ */
628
+ readonly chain: readonly string[];
629
+ }
436
630
  /**
437
631
  * Public Store surface.
438
632
  *
@@ -474,7 +668,16 @@ export interface StoreInstance<R extends string = string, S extends Record<R, an
474
668
  connect(spec: {
475
669
  reducer: R;
476
670
  property: string;
477
- }, handler: (change: Change) => void): Unsubscribe;
671
+ }, handler: (change: Change) => void, options?: ConnectOptions): Unsubscribe;
672
+ /**
673
+ * Sends a request and waits for the reply, correlating the two automatically.
674
+ *
675
+ * @remarks
676
+ * Awaitable for the terminal reply, async-iterable for progress. See the implementation on
677
+ * {@link Store.call} for the full contract: correlation, backpressure, timeouts, and why it
678
+ * is a local primitive.
679
+ */
680
+ 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>>;
478
681
  /**
479
682
  * Convenience helper to register an **effect** filtered by a single `(channel, type)` pair.
480
683
  *
@@ -664,7 +867,8 @@ export interface StoreInstance<R extends string = string, S extends Record<R, an
664
867
  * Use `when` for event targeting (preferred). The `events` property is
665
868
  * kept for backward compatibility but `when` is recommended for new code.
666
869
  *
667
- * @example Using `when` (recommended)
870
+ * @example
871
+ * Using `when` (recommended)
668
872
  * ```ts
669
873
  * const counterSpec: ReducerSpec<{ value: number }, MyEM> = {
670
874
  * state: { value: 0 },
@@ -706,7 +910,7 @@ export interface ReducerSpec<S = any, EM extends EventMapBase = EventMapBase> {
706
910
  *
707
911
  * @public
708
912
  */
709
- export type ReducerFunction<S = any, EM extends EventMapBase = EventMapBase> = (state: S, event: EventUnion<EM>) => S;
913
+ export type ReducerFunction<S = any, EM extends EventMapBase = EventMapBase> = (state: S, event: EventUnion<EM>) => S | Rejection;
710
914
  /**
711
915
  * Effect specification (stateless async event consumer).
712
916
  *
@@ -719,7 +923,8 @@ export type ReducerFunction<S = any, EM extends EventMapBase = EventMapBase> = (
719
923
  * - Effects are keyed by event for O(1) lookup (no scanning).
720
924
  * - Use `when` for event targeting (preferred over `events`).
721
925
  *
722
- * @example Using `when` (recommended)
926
+ * @example
927
+ * Using `when` (recommended)
723
928
  * ```ts
724
929
  * const logEffect: EffectSpec<AppState, MyEM> = {
725
930
  * when: { keys: eventKeys<MyEM>()([['ui', 'increment']]) },
@@ -1130,11 +1335,39 @@ export type DeepReadonly<T> = T extends (...args: never[]) => unknown ? T : T ex
1130
1335
  *
1131
1336
  * - `'committed'`: Events that passed middleware and reached reducers (default)
1132
1337
  * - `'uncommitted'`: Events rejected by middleware
1338
+ * - `'written'`: Events that actually changed state
1133
1339
  * - `'all'`: Both committed and uncommitted events
1134
1340
  *
1341
+ * @remarks
1342
+ * `'committed'` means **not vetoed**, and always has. It fires for an event that passed
1343
+ * middleware whether or not any reducer wrote anything — including every event in a store with
1344
+ * no reducers at all, which is the shape a notification or analytics bus takes. Toasts,
1345
+ * animations and tracking depend on that, so it is not narrowed.
1346
+ *
1347
+ * `'written'` is the stricter fact, added rather than substituted: state changed. It fires
1348
+ * **after** the commit, so a subscriber reading `getState()` from it sees the new value — which
1349
+ * is what people tend to assume `'committed'` does.
1350
+ *
1351
+ * `'all'` deliberately stays `committed | uncommitted`. Folding `'written'` into it would hand
1352
+ * every existing `'all'` subscriber a second notification per written event and quietly double
1353
+ * their counts.
1354
+ *
1355
+ * @public
1356
+ */
1357
+ export type EventPhase = "committed" | "uncommitted" | "written" | "all";
1358
+ /**
1359
+ * The phases a handler is actually *told about*.
1360
+ *
1361
+ * @remarks
1362
+ * `'all'` is a subscription selector, not an outcome — nothing is ever delivered "in the all
1363
+ * phase". Naming the difference keeps the two from being conflated in a handler signature, which
1364
+ * is where they were previously spelled out by hand and drifted: adding `'written'` to
1365
+ * {@link EventPhase} left three copies in `@yoltra/react` still claiming a handler could only
1366
+ * ever see two phases, and the build failed on the mismatch.
1367
+ *
1135
1368
  * @public
1136
1369
  */
1137
- export type EventPhase = "committed" | "uncommitted" | "all";
1370
+ export type NotifiedPhase = Exclude<EventPhase, "all">;
1138
1371
  /**
1139
1372
  * Handler function for event subscriptions (receives full event union).
1140
1373
  *
@@ -1163,7 +1396,7 @@ export type EventPhase = "committed" | "uncommitted" | "all";
1163
1396
  *
1164
1397
  * @public
1165
1398
  */
1166
- export type EventSubscriptionHandler<S = any, EM extends EventMapBase = EventMapBase> = (event: EventUnion<EM>, getState: () => S, emit: Emit<EM>, phase: "committed" | "uncommitted") => void | Promise<void>;
1399
+ export type EventSubscriptionHandler<S = any, EM extends EventMapBase = EventMapBase> = (event: EventUnion<EM>, getState: () => S, emit: Emit<EM>, phase: NotifiedPhase) => void | Promise<void>;
1167
1400
  /**
1168
1401
  * Narrowed event subscription handler for specific `(channel, type)` pairs.
1169
1402
  * Provides better type inference when subscribing to a single event type.
@@ -1188,4 +1421,4 @@ export type EventSubscriptionHandler<S = any, EM extends EventMapBase = EventMap
1188
1421
  *
1189
1422
  * @public
1190
1423
  */
1191
- export type NarrowedEventHandler<S, EM extends EventMapBase, C extends keyof EM & string, T extends keyof EM[C] & string> = (event: Event<EM, C, T>, getState: () => S, emit: Emit<EM>, phase: "committed" | "uncommitted") => void | Promise<void>;
1424
+ export type NarrowedEventHandler<S, EM extends EventMapBase, C extends keyof EM & string, T extends keyof EM[C] & string> = (event: Event<EM, C, T>, getState: () => S, emit: Emit<EM>, phase: NotifiedPhase) => void | Promise<void>;