@yoltra/core 0.6.0 β†’ 0.8.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.md CHANGED
@@ -5,8 +5,10 @@
5
5
  > [ πŸ‡²πŸ‡½ VersiΓ³n en EspaΓ±ol](./README.es.md) 
6
6
  > |   πŸ‘‰ πŸ‡ΊπŸ‡Έ English Version
7
7
 
8
- ![npm downloads](https://badgen.net/npm/dm/@yoltra/core)
9
- ![License](https://badgen.net/npm/license/@yoltra/core)
8
+ [![npm version](https://img.shields.io/npm/v/@yoltra/core)](https://www.npmjs.com/package/@yoltra/core)
9
+ [![npm downloads](https://img.shields.io/npm/dm/@yoltra/core)](https://www.npmjs.com/package/@yoltra/core)
10
+ [![types](https://img.shields.io/npm/types/@yoltra/core)](https://www.npmjs.com/package/@yoltra/core)
11
+ [![License](https://img.shields.io/npm/l/@yoltra/core)](https://github.com/yoltra/yoltra/blob/main/LICENSE)
10
12
 
11
13
  **Framework-agnostic event-driven state container with fine-grained path subscriptions.**
12
14
 
@@ -34,7 +36,7 @@ emit(channel, type, payload)
34
36
  β”‚
35
37
  β”œβ”€ 0. Dedup (opt-in) ─── Skip a duplicate only when dedupWindowMs > 0 or a dedupKey is given
36
38
  β”‚
37
- β”‚ ══ SYNCHRONOUS reduce phase β€” runs before emit() returns ══
39
+ β”‚ ══ SYNCHRONOUS reduce phase: runs before emit() returns ══
38
40
  β”œβ”€ 1. Middleware ─── Synchronous pre-reducer hooks (return false to reject β†’ "uncommitted" event)
39
41
  β”œβ”€ 2. Reducers ─── Every matching slice staged, then all committed under one root
40
42
  β”œβ”€ 3. Event subscribers ─── Committed/uncommitted event notifications
@@ -43,11 +45,11 @@ emit(channel, type, payload)
43
45
  └─ 5. Effects ─── ASYNC side-effects, one independent task per event (keyed for O(1) lookup)
44
46
  ```
45
47
 
46
- The reduce phase (1–4) is **synchronous**, so `getState()` is correct the instant `emit()` returns
47
- β€” even with middleware. Effects (5) run afterward as an independent async task; the promise from
48
+ The reduce phase (1–4) is **synchronous**, so `getState()` is correct the instant `emit()` returns,
49
+ even with middleware. Effects (5) run afterward as an independent async task; the promise from
48
50
  `emit()` resolves when that event's effects finish. Every stage is hook-able, and
49
- `store.instrument()` exposes the whole flow β€” changed leaf paths, reduce timing, committed/rejected
50
- phase β€” to the DevTools with no `as any`. See the
51
+ `store.instrument()` exposes the whole flow (changed leaf paths, reduce timing, committed/rejected
52
+ phase) to the DevTools with no `as any`. See the
51
53
  [Event Pipeline Architecture](../../docs/en/design/event-queue-architecture.md) for the full model.
52
54
 
53
55
  ---
@@ -71,17 +73,17 @@ Subscribe to exact state paths using dotted notation. Supports `*` (one segment)
71
73
  or more segments) wildcards:
72
74
 
73
75
  ```typescript
74
- // Exact path β€” fires when items[0].title changes
76
+ // Exact path: fires when items[0].title changes
75
77
  store.connect({ reducer: "todos", property: "items.0.title" }, (change) =>
76
78
  console.log("title:", change.oldValue, "β†’", change.newValue),
77
79
  );
78
80
 
79
- // Single-segment wildcard β€” fires when ANY item's title changes
81
+ // Single-segment wildcard: fires when ANY item's title changes
80
82
  store.connect({ reducer: "todos", property: "items.*.title" }, (change) =>
81
83
  console.log("some title changed at", change.path),
82
84
  );
83
85
 
84
- // Deep wildcard β€” fires when anything under items changes
86
+ // Deep wildcard: fires when anything under items changes
85
87
  store.connect({ reducer: "todos", property: "items.**" }, (change) =>
86
88
  console.log("items tree changed at", change.path),
87
89
  );
@@ -108,7 +110,7 @@ await store.emit("auth", "login", { token: "abc123" });
108
110
  store.getState().token; // "abc123"
109
111
  ```
110
112
 
111
- Such a slice has no property beneath it, so its changes are reported at the **slice root** β€”
113
+ Such a slice has no property beneath it, so its changes are reported at the **slice root**,
112
114
  the empty path. Subscribe to it with `property: ""`:
113
115
 
114
116
  ```typescript
@@ -117,20 +119,20 @@ store.connect({ reducer: "token", property: "" }, (change) =>
117
119
  );
118
120
  ```
119
121
 
120
- The types know the difference. `property` on a root-value slice accepts `""` and nothing else β€”
121
- there is no key to address β€” and the value comes back correctly typed:
122
+ The types know the difference. `property` on a root-value slice accepts `""` and nothing else,
123
+ because there is no key to address, and the value comes back correctly typed:
122
124
 
123
125
  ```typescript
124
126
  const token = useAtomicProp({ reducer: "token", property: "" }); // string | null
125
127
  ```
126
128
 
127
- ### `""` versus `"**"` β€” watching a whole slice
129
+ ### `""` versus `"**"`: watching a whole slice
128
130
 
129
131
  Two subscriptions sound alike and are not:
130
132
 
131
133
  | Pattern | Fires when |
132
134
  |---|---|
133
- | `""` | the slice's **whole value** is replaced β€” a primitive changes, a `Map` is rebuilt, an object slice becomes `null` |
135
+ | `""` | the slice's **whole value** is replaced: a primitive changes, a `Map` is rebuilt, an object slice becomes `null` |
134
136
  | `"**"` | **anything** in the slice changes, at any depth. Matches the root too, since `**` matches zero segments |
135
137
  | `"*"` | one level down, exactly. Never matches the root |
136
138
 
@@ -140,7 +142,7 @@ because such a slice reports its changes at their leaves.
140
142
 
141
143
  `Map` and `Set` are compared by reference, not by entry: a reducer returning a new `Map` is a
142
144
  change, mutating one in place is not. That follows from the immutability contract rather than
143
- being a special case β€” build a new collection instead of mutating the stored one. It is also why
145
+ being a special case. Build a new collection instead of mutating the stored one. It is also why
144
146
  they have no paths beneath them: `"byId"` is subscribable, `"byId.get"` is not, and the types
145
147
  say so.
146
148
 
@@ -169,7 +171,7 @@ type AppEM = {
169
171
  system: { init: void; shutdown: void };
170
172
  };
171
173
 
172
- // Match specific event keys (recommended β€” preserves type correlation)
174
+ // Match specific event keys (recommended: preserves type correlation)
173
175
  const counterReducer = {
174
176
  state: { value: 0 },
175
177
  when: {
@@ -212,13 +214,16 @@ const globalLogger = {
212
214
  ## Middleware
213
215
 
214
216
  Middleware runs **synchronously, before** reducers and can cancel event propagation (return
215
- `false` to reject β†’ "uncommitted" event). Async work belongs in effects, not middleware. Supports
217
+ `false` to reject β†’ "uncommitted" event; returning nothing allows it). Async work belongs in
218
+ effects, not middleware. When an event does not commit, `emit` says why: `reason` is
219
+ `"vetoed"`, `"deduped"` or `"cascade"`, and a veto names the middleware in `vetoedBy`, so a
220
+ guard refusing an action is distinguishable from a double-click being collapsed. Supports
216
221
  both raw functions (legacy) and `MiddlewareSpec` objects with targeting:
217
222
 
218
223
  ```typescript
219
224
  import type { MiddlewareSpec } from "@yoltra/core";
220
225
 
221
- // Targeted middleware β€” only runs for admin channel events
226
+ // Targeted middleware: only runs for admin channel events
222
227
  const adminGuard: MiddlewareSpec<AppState, AppEM> = {
223
228
  when: { channel: "admin" },
224
229
  middleware: (state, event) => {
@@ -228,7 +233,8 @@ const adminGuard: MiddlewareSpec<AppState, AppEM> = {
228
233
  meta: { type: "middleware", name: "adminGuard" },
229
234
  };
230
235
 
231
- // Global middleware β€” runs for all events (synchronous: return a boolean, never a Promise)
236
+ // Global middleware: runs for all events. Synchronous, never a Promise: only an explicit
237
+ // `false` vetoes, so middleware that just observes can return nothing at all.
232
238
  const logger = (state, event) => {
233
239
  console.log("Event:", event.channel, event.type);
234
240
  return true;
@@ -301,12 +307,12 @@ Subscribe to events (not state) from the view layer. Useful for notifications, a
301
307
  responding to rejected events:
302
308
 
303
309
  ```typescript
304
- // Committed events (default) β€” events that passed middleware
310
+ // Committed events (default): events that passed middleware
305
311
  const off = store.onEvent("ui", "save", (event, getState, emit, phase) => {
306
312
  console.log("Save committed:", event.payload);
307
313
  });
308
314
 
309
- // Uncommitted events β€” events rejected by middleware
315
+ // Uncommitted events: events rejected by middleware
310
316
  store.onEvent(
311
317
  "ui",
312
318
  "delete",
@@ -316,7 +322,7 @@ store.onEvent(
316
322
  "uncommitted",
317
323
  );
318
324
 
319
- // Written events β€” state actually changed. Fires after the commit, so getState() is current.
325
+ // Written events: state actually changed. Fires after the commit, so getState() is current.
320
326
  store.onEvent(
321
327
  "plan",
322
328
  "patch",
@@ -326,7 +332,7 @@ store.onEvent(
326
332
  "written",
327
333
  );
328
334
 
329
- // All events β€” both committed and uncommitted (not written; see below)
335
+ // All events: both committed and uncommitted (not written; see below)
330
336
  store.onEvent(
331
337
  "ui",
332
338
  "action",
@@ -338,17 +344,35 @@ store.onEvent(
338
344
  ```
339
345
 
340
346
  `committed` means **not vetoed**, and always has: it fires for every event middleware let through,
341
- whether or not a reducer wrote anything β€” including every event in a store with no reducers at
347
+ whether or not a reducer wrote anything, including every event in a store with no reducers at
342
348
  all. `written` is the stricter fact, added rather than substituted, so toasts and analytics keep
343
349
  working unchanged. `all` stays `committed | uncommitted`; folding `written` in would hand existing
344
350
  subscribers a second notification per event.
345
351
 
352
+ ### Event subscribers and time-travel
353
+
354
+ **Replay does not call your handlers.** Scrubbing a DevTools timeline reduces the events again,
355
+ so state follows the scrub, but `onEvent` handlers stay silent. They used to run exactly as they
356
+ do for a live event, which meant dragging a timeline re-published to peers, re-wrote to sockets
357
+ and re-fired analytics for events that were not happening again, with nothing available inside a
358
+ handler to tell the difference.
359
+
360
+ A handler that derives view state purely from the event stream, and performs no I/O, can opt in:
361
+
362
+ ```ts
363
+ store.onEvent("ui", "save", handler, "committed", { duringReplay: true });
364
+ ```
365
+
366
+ `store.isReplaying` is there for anything that has to branch rather than simply skip. Coarse
367
+ `subscribe` listeners and `connect` subscriptions keep firing throughout, because the state
368
+ genuinely did change and the UI has to follow the scrub.
369
+
346
370
  ---
347
371
 
348
372
  ## Commits are atomic across slices
349
373
 
350
374
  An event that touches several slices writes all of them, then notifies. Nothing observes a
351
- half-applied event β€” a subscriber to one slice reading `getState()` sees every other slice of the
375
+ half-applied event. A subscriber to one slice reading `getState()` sees every other slice of the
352
376
  same event already applied.
353
377
 
354
378
  That matters most where a change is used as a signal to re-read, which is what the React hooks do.
@@ -380,8 +404,8 @@ const store = createStore({
380
404
 
381
405
  const result = await store.emit("plan", "patch", { steps, expectedVersion: 1 });
382
406
 
383
- result.committed; // true β€” middleware allowed it
384
- result.written; // false β€” but nothing was written
407
+ result.committed; // true: middleware allowed it
408
+ result.written; // false: nothing was written
385
409
  result.rejected?.reason; // "stale write: expected v1, have v3"
386
410
  ```
387
411
 
@@ -400,10 +424,10 @@ has made a decision and the whole event yields to it.
400
424
 
401
425
  ---
402
426
 
403
- ## Request and reply β€” `store.call()`
427
+ ## Request and reply: `store.call()`
404
428
 
405
429
  Every event-bus consumer eventually writes request/reply by hand: mint an id, subscribe, match,
406
- time out, unsubscribe. It is about eighty lines and it has the same two bugs every time β€” the
430
+ time out, unsubscribe. It is about eighty lines and it has the same two bugs every time: the
407
431
  subscription outlives the call, and a responder that forgets to echo the id produces a timeout
408
432
  with nothing to point at.
409
433
 
@@ -413,7 +437,7 @@ res.payload.text;
413
437
  ```
414
438
 
415
439
  The responder does nothing special. It replies through the `emit` it was handed, and the store's
416
- causal stamp correlates the two β€” **there is no id to mint, echo, or forget**:
440
+ causal stamp correlates the two, and **there is no id to mint, echo, or forget**:
417
441
 
418
442
  ```typescript
419
443
  store.registerEffect({
@@ -453,7 +477,7 @@ const { payload } = await call;
453
477
  ```
454
478
 
455
479
  The backpressure is real, not a buffer with a limit. `emit` resolves only once its effects have
456
- run, and the collector is an effect that does not return until the consumer has taken the item β€”
480
+ run, and the collector is an effect that does not return until the consumer has taken the item,
457
481
  so a responder writing `await emit("job", "tick", chunk)` is **paced by the reader**:
458
482
 
459
483
  ```typescript
@@ -466,7 +490,7 @@ effect: async (_event, _get, emit) => {
466
490
  ```
467
491
 
468
492
  Backpressure engages **once you begin iterating**. A call that is only awaited never pulls, so
469
- blocking its producer would deadlock the call itself β€” progress nobody reads would stop the
493
+ blocking its producer would deadlock the call itself: progress nobody reads would stop the
470
494
  terminal event from ever being sent. Un-iterated progress therefore buffers to `highWaterMark`
471
495
  and is then counted on `call.dropped` rather than blocking.
472
496
 
@@ -474,17 +498,17 @@ and is then counted on `call.dropped` rather than blocking.
474
498
 
475
499
  | | |
476
500
  |---|---|
477
- | `timeoutMs` | **Idle**, not total β€” every correlated event resets it, progress included. A job that streams for two minutes will not fail a thirty-second call. Default 30s. |
501
+ | `timeoutMs` | **Idle**, not total: every correlated event resets it, progress included. A job that streams for two minutes will not fail a thirty-second call. Default 30s. |
478
502
  | `signal` | An `AbortSignal`, for a real deadline or a cancelled action. |
479
503
  | `call.cancel(reason)` | Stops listening and settles. Safe to call twice. |
480
504
 
481
- However a call ends β€” resolved, timed out, aborted β€” the subscription is removed and any producer
505
+ However a call ends, whether resolved, timed out or aborted, the subscription is removed and any producer
482
506
  parked on backpressure is released. A wedged responder is worse than the unbounded buffer this
483
507
  replaced.
484
508
 
485
509
  ## Reading a value as you subscribe
486
510
 
487
- `connect` starts at "from now on", so a subscriber's first read had to repeat the path elsewhere β€”
511
+ `connect` starts at "from now on", so a subscriber's first read had to repeat the path elsewhere:
488
512
  the same path in two places, free to drift:
489
513
 
490
514
  ```typescript
@@ -513,14 +537,14 @@ store.connect({ reducer: "orders", property: "status" }, (change) => {
513
537
  });
514
538
  ```
515
539
 
516
- Provenance is **absent** when no event caused the change β€” a DevTools time-travel jump, or the
540
+ Provenance is **absent** when no event caused the change: a DevTools time-travel jump, or the
517
541
  `immediate` delivery above. Absence is the signal, rather than a fabricated id.
518
542
 
519
543
  ---
520
544
 
521
545
  ## Event Deduplication (opt-in)
522
546
 
523
- Deduplication is **off by default** β€” Yoltra never silently drops legitimate rapid-fire identical
547
+ Deduplication is **off by default**. Yoltra never silently drops legitimate rapid-fire identical
524
548
  events (double-clicks, repeated `+1`). Opt in only when you actually want coalescing:
525
549
 
526
550
  ```typescript
@@ -533,7 +557,7 @@ const store = createStore({
533
557
  dedupWindowMs: 100, // default: 0 (disabled)
534
558
  });
535
559
 
536
- // Identity-based: dedupe by an explicit key β€” e.g. a React Strict Mode double-invoke in an effect.
560
+ // Identity-based: dedupe by an explicit key, e.g. a React Strict Mode double-invoke in an effect.
537
561
  await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page}` });
538
562
  ```
539
563
 
@@ -541,8 +565,8 @@ await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page
541
565
 
542
566
  ## Cascade protection (on by default)
543
567
 
544
- Two consumers wired into each other β€” a subscriber that emits what its own reducer answers, or
545
- two slices that answer each other's events β€” produce an event chain with no end. The reduce queue
568
+ Two consumers wired into each other, whether a subscriber that emits what its own reducer answers or
569
+ two slices that answer each other's events, produce an event chain with no end. The reduce queue
546
570
  drains **synchronously**, so that is not a slow program: it is a frozen tab, or a pinned core,
547
571
  with no error and no stack to point at.
548
572
 
@@ -554,7 +578,7 @@ const store = createStore({
554
578
  name: "app",
555
579
  reducer: { ... },
556
580
 
557
- // Defaults to 64. Bounded whether or not you configure it β€” a failure mode this bad
581
+ // Defaults to 64. Bounded whether or not you configure it. A failure mode this bad
558
582
  // should not require configuration to avoid. Set Infinity to opt out and own it.
559
583
  maxReduceDepth: 64,
560
584
 
@@ -578,13 +602,13 @@ Both fields are **absent** on a root event rather than present as `0`/`undefined
578
602
  application emits stay byte-identical to before this existed.
579
603
 
580
604
  Breaching does not throw. The offending emit is refused, everything already committed stands, and
581
- `onCascade` (plus a console error) names it β€” a throw would surface in whichever subscriber or
605
+ `onCascade` (plus a console error) names it. A throw would surface in whichever subscriber or
582
606
  effect happened to be emitting, which is the same unattributable failure the ceiling exists to
583
607
  prevent.
584
608
 
585
609
  **A wide burst is not a cascade.** One event whose subscriber fans out to five hundred siblings
586
610
  is a legitimate shape; depth is what separates it from a cycle, and a plain loop of `store.emit`
587
- never accumulates depth at all β€” each call drains to completion before the next, so every one is
611
+ never accumulates depth at all, because each call drains to completion before the next, so every one is
588
612
  a root. `maxTransitionsPerDrain` bounds burst *width* and is off by default for that reason; the
589
613
  event that starts a drain is never refused by it.
590
614
 
@@ -605,6 +629,63 @@ const dispose = store.registerReducer("filters", {
605
629
  dispose();
606
630
  ```
607
631
 
632
+ ### Decorating a store, with its types
633
+
634
+ A slice added at runtime used to be invisible to the type system: `registerReducer` took a
635
+ plain `string` and returned a bare disposer, so nothing downstream knew the slice existed or
636
+ what shape it had. `withSlice` returns **the same store, re-typed**:
637
+
638
+ ```typescript
639
+ type TransferEM = { transfer: { granted: { id: string } } };
640
+
641
+ const transfers = defineSlice<TransferEM>()({
642
+ state: { granted: [] as string[] },
643
+ when: { keys: [["transfer", "granted"]] },
644
+ reducer: (s, e) => (e.type === "granted" ? { granted: [...s.granted, e.payload.id] } : s),
645
+ });
646
+
647
+ const app = store.withSlice("transfers", transfers, { owner: "@scope/transfers" });
648
+
649
+ app.getState().transfers.granted; // string[]
650
+ app.emit("transfer", "granted", { id: "a1" }); // the new channel is emittable
651
+ ```
652
+
653
+ `withMiddleware` and `withEffect` do the same for the event map. Calls chain, and a library
654
+ publishes a decorator by taking a store and returning one:
655
+
656
+ ```typescript
657
+ export function withTransfers<R extends string, S extends Record<R, any>, EM extends EventMapBase>(
658
+ store: StoreInstance<R, S, EM>,
659
+ config: TransfersConfig,
660
+ ) {
661
+ return store.withSlice("transfers", transfers, { owner: "@scope/transfers" });
662
+ }
663
+
664
+ // Decorators nest, in any order.
665
+ const decorated = withTransfers(withDevtools(store, dtConfig), config);
666
+ ```
667
+
668
+ **Why the builders.** A spec's `when` carries channel and type strings and no payload types,
669
+ so the event map a decoration contributes cannot be inferred from it, and TypeScript has no
670
+ partial type-argument inference. `defineSlice<EM>()` puts it in a value position, where
671
+ inference works, so no registration site needs a type argument or a cast. One consequence
672
+ worth knowing: **a bare middleware function can never widen the event map**, because
673
+ `MiddlewareFunction`'s event parameter is a mapped type nothing can be inferred back out of.
674
+ Only the spec form from `defineMiddleware` can.
675
+
676
+ **It is the same object.** Nothing re-subscribes, no state moves, and any in-flight
677
+ `store.call()` is unaffected. Only the type changes.
678
+
679
+ **Ordering.** Decorate at module scope, once, before the first render. Between `createStore`
680
+ and the decoration the slice genuinely does not exist, and a component reading it sees
681
+ `undefined` until it does.
682
+
683
+ **Disposal.** `withSlice` hands back no disposer on purpose: after one runs, the widened type
684
+ still promises a slice that is gone, and no type system can express "valid until that call".
685
+ Use `registerSlice` when you own the slice and need teardown, and keep that disposer private
686
+ to the library. Reading a disposed slice throws a named error in development rather than
687
+ returning `undefined` from a type that promised a value.
688
+
608
689
  ---
609
690
 
610
691
  ## Hot Module Replacement
@@ -633,19 +714,43 @@ if (import.meta.hot) {
633
714
  }
634
715
  ```
635
716
 
717
+ ### `replace*` replaces what you authored, not what a library added
718
+
719
+ A reducer, middleware or effect registered **after** construction, with `registerReducer`,
720
+ `registerMiddleware` or `registerEffect`, survives a `replace*` call. Those registrations were
721
+ never part of the set you are replacing: nobody writing `replaceReducers(myReducers)` means "and
722
+ also delete the slice devtools mounted, along with its state".
723
+
724
+ This used to go the other way, which made the HMR line above delete a library's slice and its
725
+ state on the first file save, with no error and no warning. It is also why an in-flight
726
+ `store.call()` no longer dies mid-reload: its reply listener belongs to the store itself.
727
+
728
+ Pass `{ scope: "all" }` for the old wholesale behaviour, which a test harness resetting a store
729
+ between cases may genuinely want:
730
+
731
+ ```typescript
732
+ store.replaceReducers(nextReducers, { scope: "all" });
733
+ store.hotReplace({ reducer: nextReducers, scope: "all" }); // forwards to all three
734
+ ```
735
+
736
+ An application that authors a slice a library already mounted gets an error naming the slice,
737
+ rather than a silent takeover that leaves the library holding a disposer for something no longer
738
+ its own. In development, `replace*` logs at debug level when it preserved anything, so "why is
739
+ that effect still firing after a reload" has an answer.
740
+
636
741
  ---
637
742
 
638
743
  ## Best Practices
639
744
 
640
745
  ### State is synchronous; `await` only for effects
641
746
 
642
- The reduce phase is synchronous, so state reflects your event the moment `emit()` returns β€” no
747
+ The reduce phase is synchronous, so state reflects your event the moment `emit()` returns, with no
643
748
  `await` needed to read it back. Await `emit()` when you also want _this event's_ effects to have
644
749
  finished:
645
750
 
646
751
  ```typescript
647
752
  emit("todo", "add", todo);
648
- store.getState(); // Already reflects the new todo β€” no await required
753
+ store.getState(); // Already reflects the new todo. No await required
649
754
 
650
755
  await emit("todo", "save", todo); // resolves once save's effects complete
651
756
  ```
@@ -694,7 +799,8 @@ store.registerEffect({
694
799
  | `store.getState()` | Get current readonly state snapshot |
695
800
  | `store.subscribe(listener)` | Coarse subscription (any state change) |
696
801
  | `store.connect(spec, handler)` | Fine-grained path subscription with wildcards |
697
- | `store.onEvent(channel, type, handler, phase?)` | Event subscription (committed/uncommitted/all) |
802
+ | `store.onEvent(channel, type, handler, phase?, options?)` | Event subscription (committed/uncommitted/written/all). Silent during replay unless `{ duringReplay: true }` |
803
+ | `store.onRegistrationChange(observer, opts?)` | Fires when the store gains or loses a reducer, middleware or effect |
698
804
  | `store.onEffect(channel, type, handler)` | Single-event effect shorthand |
699
805
  | `store.dispose()` | Cleanup timers and resources |
700
806
 
@@ -702,6 +808,10 @@ store.registerEffect({
702
808
 
703
809
  | API | Description |
704
810
  | ----------------------------------- | ------------------------- |
811
+ | `store.registerSlice(name, spec, opts?)` | Add a slice at runtime; returns the widened store plus a disposer |
812
+ | `store.withSlice(name, spec, opts?)` | Same, returning the widened store for chaining |
813
+ | `store.withMiddleware(mw)`, `store.withEffect(spec)` | Register and widen the event map |
814
+ | `defineSlice<EM>()`, `defineMiddleware<EM>()`, `defineEffect<EM>()` | Declare the event map a spec contributes |
705
815
  | `store.registerReducer(name, spec)` | Add a slice at runtime |
706
816
  | `store.registerMiddleware(fn)` | Add middleware at runtime |
707
817
  | `store.registerEffect(spec)` | Add an effect at runtime |
@@ -710,10 +820,10 @@ store.registerEffect({
710
820
 
711
821
  | API | Description |
712
822
  | --------------------------------------- | -------------------------- |
713
- | `store.replaceReducers(reducers, opts)` | Replace all reducers |
714
- | `store.replaceMiddleware(middleware)` | Replace all middleware |
715
- | `store.replaceEffects(effects)` | Replace all effects |
716
- | `store.hotReplace(partial)` | Replace any subset at once |
823
+ | `store.replaceReducers(reducers, opts)` | Replace spec reducers; runtime ones survive unless `{ scope: "all" }` |
824
+ | `store.replaceMiddleware(middleware, opts)` | Replace spec middleware; same rule |
825
+ | `store.replaceEffects(effects, opts)` | Replace spec effects; same rule |
826
+ | `store.hotReplace(partial)` | Replace any subset at once; forwards `scope` |
717
827
 
718
828
  ### Helpers
719
829
 
@@ -749,7 +859,7 @@ that never happened.
749
859
 
750
860
  **Nothing throws on boot.** A missing, unparseable or unmigratable payload falls back to your
751
861
  declared defaults and reports through `onError`. A store that will not start because storage
752
- holds stale JSON is worse than one that starts fresh β€” and a full disk should not take down a
862
+ holds stale JSON is worse than one that starts fresh, and a full disk should not take down a
753
863
  page, so write failures are reported the same way rather than raised.
754
864
 
755
865
  **Version mismatches are refused, not trusted.** Reducers change, and a snapshot written
@@ -767,7 +877,7 @@ For a server render, `dehydrate(store, { version })` produces the payload and
767
877
  ## Lists that reorder
768
878
 
769
879
  Path notification is positional for arrays. `items.0.title` names a *slot*, not a thing, so
770
- `unshift`, `splice(0, 1)` and `sort` move nearly every element into a different slot β€” and the
880
+ `unshift`, `splice(0, 1)` and `sort` move nearly every element into a different slot, and the
771
881
  diff correctly reports that nearly every leaf changed. Inserting one row at the front of a
772
882
  thousand wakes a thousand subscribers.
773
883
 
@@ -790,7 +900,7 @@ todos.idsPath; // "ids"
790
900
  `entities.abc.title` survives insert, remove and reorder. A list container subscribes to `ids`
791
901
  and reorders its children; rows subscribe to their own entity and stay asleep through a sort.
792
902
 
793
- `ids` is still an array, so a reorder still reports `ids.0`, `ids.1` and so on β€” that cost is
903
+ `ids` is still an array, so a reorder still reports `ids.0`, `ids.1` and so on. That cost is
794
904
  confined, not removed. What you get is cost proportional to what actually changed.
795
905
 
796
906
  For a small list that only ever grows at the end, `items.0.title` is fine and simpler. The
@@ -803,8 +913,8 @@ normalised, and the array reports roughly a thousand changed paths against two.
803
913
  case the adapter is for.
804
914
 
805
915
  A single-field update runs the other way: 20 Β΅s for the array against 470 Β΅s normalised.
806
- `detectChangedProps` indexes an array but enumerates an object's keys β€” building two key
807
- arrays and a `Set` per comparison β€” so a wide entity map is more expensive to walk even when
916
+ `detectChangedProps` indexes an array but enumerates an object's keys, building two key
917
+ arrays and a `Set` per comparison, so a wide entity map is more expensive to walk even when
808
918
  almost nothing in it moved. The numbers are in `benchmarks/`, and closing that gap is tracked
809
919
  work rather than a property of normalising as such.
810
920
 
@@ -813,24 +923,33 @@ individual fields edited is better off as an array today.
813
923
 
814
924
  ## Performance
815
925
 
816
- | Metric | Value |
817
- | ------------------ | ----------------------------------------- |
818
- | **Bundle size** | 9.2 KB for the store (minified + gzipped) |
819
- | **Tree-shakeable** | Yes (ES modules) |
820
- | **Dependencies** | Zero |
821
- | **TypeScript** | Full type definitions included |
926
+ | Metric | Value |
927
+ | ------------------ | -------------------------------------- |
928
+ | **Bundle size** | Measured every build, see table below |
929
+ | **Tree-shakeable** | Yes (ES modules) |
930
+ | **Dependencies** | Zero |
931
+ | **TypeScript** | Full type definitions included |
822
932
 
823
933
  Bundle size is checked, not asserted: `rush size` bundles the package the way a consumer
824
- would β€” tree-shaken, minified, gzipped β€” and fails when it exceeds the budget declared in
825
- `package.json`.
934
+ would (tree-shaken, minified, gzipped) and fails when it exceeds the budget declared in
935
+ `package.json`. The table below is written by that same check, so it cannot drift from what
936
+ was measured; editing it by hand fails CI.
826
937
 
827
938
  The number that matters is what you import, not what the package exports:
828
939
 
829
- | Import | Size | Budget |
830
- | ----------------------------------- | ------- | ------ |
831
- | `{ createStore }` | 9.2 KB | 14 KB |
832
- | `{ createStore, hydrate, persist }` | 10.7 KB | 16 KB |
833
- | everything | 12.1 KB | 18 KB |
940
+ <!-- size-table:start -->
941
+ | Import | Size | Budget |
942
+ | --- | --- | --- |
943
+ | `{ createStore }` | 11.5 KB | 14 KB |
944
+ | `{ createStore, hydrate, persist }` | 12.8 KB | 16 KB |
945
+ | everything | 14.2 KB | 18 KB |
946
+ <!-- size-table:end -->
947
+
948
+ These are **production** figures: what you ship once your bundler defines
949
+ `NODE_ENV=production` and the development-only guards drop out. The budget column is the
950
+ ceiling `rush size` enforces, and it is checked against a development build instead, which is
951
+ the larger of the two: dev-only code cannot grow unnoticed just because it never reaches a
952
+ user. So the headroom implied here is deliberately conservative.
834
953
 
835
954
  The **gap between rows** is the tree-shaking claim, and it is what to watch: persistence adds
836
955
  1.5 KB to the people who import it and nothing to anyone else, and the whole barrel is 2.9 KB
@@ -846,27 +965,31 @@ stops a runaway from hanging the tab.
846
965
 
847
966
  ## Documentation
848
967
 
849
- - **[yoltra Root README](../../README.md)** β€” Overview and
968
+ - **[yoltra Root README](../../README.md)**: Overview and
850
969
  quick start
851
- - **[@yoltra/react](../react/README.md)** β€”
970
+ - **[@yoltra/react](../react/README.md)**:
852
971
  React hooks and Suspense
853
- - **[Quick Start Guide](https://github.com/yoltra/yoltra/blob/main/docs/en/QUICK_START_GUIDE.md)**
854
- β€” Five steps to a working app
855
- - **[Event Queue Architecture](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)**
856
- β€” Technical deep-dive
857
- - **[Library Comparison](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)**
858
- β€” Architectural comparison
972
+ - **[Quick Start Guide](https://github.com/yoltra/yoltra/blob/main/docs/en/QUICK_START_GUIDE.md)**:
973
+ Five steps to a working app
974
+ - **[Upgrading to 0.8.0](https://github.com/yoltra/yoltra/blob/main/docs/en/UPGRADE_0.8.md)**:
975
+ Five behaviour changes, and one hazard if you roll back
976
+ - **[Decoration Guide](https://github.com/yoltra/yoltra/blob/main/docs/en/DECORATION_GUIDE.md)**:
977
+ Adding a slice, middleware or effect to somebody else's store, with the types
978
+ - **[Event Queue Architecture](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)**:
979
+ Technical deep-dive
980
+ - **[Library Comparison](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)**:
981
+ Architectural comparison
859
982
 
860
983
  ---
861
984
 
862
985
  ## Examples
863
986
 
864
- - **[Todo App](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)** β€” Full
987
+ - **[Todo App](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)**: Full
865
988
  CRUD with performance profiling Β· [β–Ά Open the live demo](https://yoltra.dev/en/demos/in-react)
866
- - **[Kinetic Logo](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-kinetic-logo)**
867
- β€” 3000 circles with physics simulation Β· [β–Ά Open the live demo](https://yoltra.dev/en/demos/kinetic-logo)
868
- - **[Next.js Integration](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)**
869
- β€” Pages Router, client-side state + theme switcher Β· [β–Ά Open the live demo](https://yoltra.dev/en/demos/in-nextjs)
989
+ - **[Kinetic Logo](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-kinetic-logo)**:
990
+ 3000 circles with physics simulation Β· [β–Ά Open the live demo](https://yoltra.dev/en/demos/kinetic-logo)
991
+ - **[Next.js Integration](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)**:
992
+ Pages Router, client-side state + theme switcher Β· [β–Ά Open the live demo](https://yoltra.dev/en/demos/in-nextjs)
870
993
 
871
994
  ---
872
995
 
@@ -879,10 +1002,10 @@ stops a runaway from hanging the tab.
879
1002
 
880
1003
  ## Status
881
1004
 
882
- **Release Candidate** β€” APIs are stable, used in production, minor changes possible before v1.0.
1005
+ **Release Candidate**. APIs are stable, used in production, minor changes possible before v1.0.
883
1006
 
884
1007
  ---
885
1008
 
886
1009
  ## License
887
1010
 
888
- **MIT** β€” Free to use in commercial and open-source projects.
1011
+ **MIT**. Free to use in commercial and open-source projects.
@@ -17,7 +17,8 @@ export { detectChangedProps } from './utils/detectChangedProps.js';
17
17
  export { freezeState } from './utils/immutability.js';
18
18
  export type { AliasWatch } from './utils/immutability.js';
19
19
  export { eventKeys } from './types.js';
20
- export type { EventMapBase, EventKey, Event, EventUnion, Change, Emit, EmitOptions, EmitResult, ConnectOptions, EventMeta, InstrumentedEvent, CascadeInfo, InstrumentationObserver, Unsubscribe, StoreSpec, StoreInstance, ReducerSpec, ReducerFunction, ReducersMapAny, StateFromReducers, EMFromReducersStrict, EffectSpec, EffectFunction, MiddlewareFunction, MiddlewareSpec, MiddlewareInput, DeepReadonly, DeepRO, Primitive, RootValue, Path, PathValue, WithGlob, Dotted, EventPhase, NotifiedPhase, EventSubscriptionHandler, NarrowedEventHandler, When, EventFromWhen, EventConsumerType, EventConsumerMeta, } from './types.js';
20
+ export { defineSlice, defineMiddleware, defineEffect } from './types.js';
21
+ export type { Prettify, Merge, WidenNames, WidenState, EventMapCarrier, EMAddOf, Decoration, Decorated, StoreDecorator, Origin, RegistrationChange, RegistrationObserver, ReplaceScope, EmptyEventMap, NotCommittedReason, StateOfSpec, SatisfiesSlices, DecoratableStore, WidenedSlice, StoreDecoration, EventMapBase, EventKey, Event, EventUnion, Change, Emit, EmitOptions, EmitResult, ConnectOptions, EventMeta, InstrumentedEvent, CascadeInfo, InstrumentationObserver, Unsubscribe, StoreSpec, StoreInstance, ReducerSpec, ReducerFunction, ReducersMapAny, StateFromReducers, EMFromReducersStrict, EffectSpec, EffectFunction, MiddlewareFunction, MiddlewareSpec, MiddlewareInput, DeepReadonly, DeepRO, Primitive, RootValue, Path, PathValue, WithGlob, Dotted, EventPhase, NotifiedPhase, EventSubscriptionHandler, NarrowedEventHandler, When, EventFromWhen, EventConsumerType, EventConsumerMeta, } from './types.js';
21
22
  export { createEntityAdapter } from './entity/entityAdapter.js';
22
23
  export type { EntityAdapter, EntityAdapterOptions, EntityId, EntityState, EntityUpdate, } from './entity/entityAdapter.js';
23
24
  export { decodeState, encodeState, encodeStateBounded } from './serialize/codec.js';
@@ -20,7 +20,7 @@ export interface PersistenceAdapter {
20
20
  remove(key: string): void | Promise<void>;
21
21
  }
22
22
  /** Where a failure happened, so a handler can tell a bad write from a bad payload. */
23
- export type PersistencePhase = "read" | "write" | "decode" | "migrate";
23
+ export type PersistencePhase = "read" | "write" | "decode" | "migrate" | "encode";
24
24
  /** Shared configuration. */
25
25
  export interface PersistOptions {
26
26
  /** Storage key. */
@@ -120,4 +120,4 @@ export declare function persist(store: PersistableStore, options: PersistOptions
120
120
  *
121
121
  * @public
122
122
  */
123
- export declare function dehydrate(store: Pick<PersistableStore, "getState">, options: Pick<PersistOptions, "version" | "slices">): string;
123
+ export declare function dehydrate(store: Pick<PersistableStore, "getState">, options: Pick<PersistOptions, "version" | "slices" | "onError">): string;