@yoltra/core 0.6.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.
package/README.md CHANGED
@@ -34,7 +34,7 @@ emit(channel, type, payload)
34
34
  │
35
35
  ├─ 0. Dedup (opt-in) ─── Skip a duplicate only when dedupWindowMs > 0 or a dedupKey is given
36
36
  │
37
- │ ══ SYNCHRONOUS reduce phase — runs before emit() returns ══
37
+ │ ══ SYNCHRONOUS reduce phase: runs before emit() returns ══
38
38
  ├─ 1. Middleware ─── Synchronous pre-reducer hooks (return false to reject → "uncommitted" event)
39
39
  ├─ 2. Reducers ─── Every matching slice staged, then all committed under one root
40
40
  ├─ 3. Event subscribers ─── Committed/uncommitted event notifications
@@ -43,11 +43,11 @@ emit(channel, type, payload)
43
43
  └─ 5. Effects ─── ASYNC side-effects, one independent task per event (keyed for O(1) lookup)
44
44
  ```
45
45
 
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
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
48
  `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
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
51
  [Event Pipeline Architecture](../../docs/en/design/event-queue-architecture.md) for the full model.
52
52
 
53
53
  ---
@@ -71,17 +71,17 @@ Subscribe to exact state paths using dotted notation. Supports `*` (one segment)
71
71
  or more segments) wildcards:
72
72
 
73
73
  ```typescript
74
- // Exact path — fires when items[0].title changes
74
+ // Exact path: fires when items[0].title changes
75
75
  store.connect({ reducer: "todos", property: "items.0.title" }, (change) =>
76
76
  console.log("title:", change.oldValue, "→", change.newValue),
77
77
  );
78
78
 
79
- // Single-segment wildcard — fires when ANY item's title changes
79
+ // Single-segment wildcard: fires when ANY item's title changes
80
80
  store.connect({ reducer: "todos", property: "items.*.title" }, (change) =>
81
81
  console.log("some title changed at", change.path),
82
82
  );
83
83
 
84
- // Deep wildcard — fires when anything under items changes
84
+ // Deep wildcard: fires when anything under items changes
85
85
  store.connect({ reducer: "todos", property: "items.**" }, (change) =>
86
86
  console.log("items tree changed at", change.path),
87
87
  );
@@ -108,7 +108,7 @@ await store.emit("auth", "login", { token: "abc123" });
108
108
  store.getState().token; // "abc123"
109
109
  ```
110
110
 
111
- Such a slice has no property beneath it, so its changes are reported at the **slice root** —
111
+ Such a slice has no property beneath it, so its changes are reported at the **slice root**,
112
112
  the empty path. Subscribe to it with `property: ""`:
113
113
 
114
114
  ```typescript
@@ -117,20 +117,20 @@ store.connect({ reducer: "token", property: "" }, (change) =>
117
117
  );
118
118
  ```
119
119
 
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:
120
+ The types know the difference. `property` on a root-value slice accepts `""` and nothing else,
121
+ because there is no key to address, and the value comes back correctly typed:
122
122
 
123
123
  ```typescript
124
124
  const token = useAtomicProp({ reducer: "token", property: "" }); // string | null
125
125
  ```
126
126
 
127
- ### `""` versus `"**"` — watching a whole slice
127
+ ### `""` versus `"**"`: watching a whole slice
128
128
 
129
129
  Two subscriptions sound alike and are not:
130
130
 
131
131
  | Pattern | Fires when |
132
132
  |---|---|
133
- | `""` | the slice's **whole value** is replaced — a primitive changes, a `Map` is rebuilt, an object slice becomes `null` |
133
+ | `""` | the slice's **whole value** is replaced: a primitive changes, a `Map` is rebuilt, an object slice becomes `null` |
134
134
  | `"**"` | **anything** in the slice changes, at any depth. Matches the root too, since `**` matches zero segments |
135
135
  | `"*"` | one level down, exactly. Never matches the root |
136
136
 
@@ -140,7 +140,7 @@ because such a slice reports its changes at their leaves.
140
140
 
141
141
  `Map` and `Set` are compared by reference, not by entry: a reducer returning a new `Map` is a
142
142
  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
143
+ being a special case. Build a new collection instead of mutating the stored one. It is also why
144
144
  they have no paths beneath them: `"byId"` is subscribable, `"byId.get"` is not, and the types
145
145
  say so.
146
146
 
@@ -169,7 +169,7 @@ type AppEM = {
169
169
  system: { init: void; shutdown: void };
170
170
  };
171
171
 
172
- // Match specific event keys (recommended — preserves type correlation)
172
+ // Match specific event keys (recommended: preserves type correlation)
173
173
  const counterReducer = {
174
174
  state: { value: 0 },
175
175
  when: {
@@ -218,7 +218,7 @@ both raw functions (legacy) and `MiddlewareSpec` objects with targeting:
218
218
  ```typescript
219
219
  import type { MiddlewareSpec } from "@yoltra/core";
220
220
 
221
- // Targeted middleware — only runs for admin channel events
221
+ // Targeted middleware: only runs for admin channel events
222
222
  const adminGuard: MiddlewareSpec<AppState, AppEM> = {
223
223
  when: { channel: "admin" },
224
224
  middleware: (state, event) => {
@@ -228,7 +228,7 @@ const adminGuard: MiddlewareSpec<AppState, AppEM> = {
228
228
  meta: { type: "middleware", name: "adminGuard" },
229
229
  };
230
230
 
231
- // Global middleware — runs for all events (synchronous: return a boolean, never a Promise)
231
+ // Global middleware: runs for all events (synchronous: return a boolean, never a Promise)
232
232
  const logger = (state, event) => {
233
233
  console.log("Event:", event.channel, event.type);
234
234
  return true;
@@ -301,12 +301,12 @@ Subscribe to events (not state) from the view layer. Useful for notifications, a
301
301
  responding to rejected events:
302
302
 
303
303
  ```typescript
304
- // Committed events (default) — events that passed middleware
304
+ // Committed events (default): events that passed middleware
305
305
  const off = store.onEvent("ui", "save", (event, getState, emit, phase) => {
306
306
  console.log("Save committed:", event.payload);
307
307
  });
308
308
 
309
- // Uncommitted events — events rejected by middleware
309
+ // Uncommitted events: events rejected by middleware
310
310
  store.onEvent(
311
311
  "ui",
312
312
  "delete",
@@ -316,7 +316,7 @@ store.onEvent(
316
316
  "uncommitted",
317
317
  );
318
318
 
319
- // Written events — state actually changed. Fires after the commit, so getState() is current.
319
+ // Written events: state actually changed. Fires after the commit, so getState() is current.
320
320
  store.onEvent(
321
321
  "plan",
322
322
  "patch",
@@ -326,7 +326,7 @@ store.onEvent(
326
326
  "written",
327
327
  );
328
328
 
329
- // All events — both committed and uncommitted (not written; see below)
329
+ // All events: both committed and uncommitted (not written; see below)
330
330
  store.onEvent(
331
331
  "ui",
332
332
  "action",
@@ -338,7 +338,7 @@ store.onEvent(
338
338
  ```
339
339
 
340
340
  `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
341
+ whether or not a reducer wrote anything, including every event in a store with no reducers at
342
342
  all. `written` is the stricter fact, added rather than substituted, so toasts and analytics keep
343
343
  working unchanged. `all` stays `committed | uncommitted`; folding `written` in would hand existing
344
344
  subscribers a second notification per event.
@@ -348,7 +348,7 @@ subscribers a second notification per event.
348
348
  ## Commits are atomic across slices
349
349
 
350
350
  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
351
+ half-applied event. A subscriber to one slice reading `getState()` sees every other slice of the
352
352
  same event already applied.
353
353
 
354
354
  That matters most where a change is used as a signal to re-read, which is what the React hooks do.
@@ -380,8 +380,8 @@ const store = createStore({
380
380
 
381
381
  const result = await store.emit("plan", "patch", { steps, expectedVersion: 1 });
382
382
 
383
- result.committed; // true — middleware allowed it
384
- result.written; // false — but nothing was written
383
+ result.committed; // true: middleware allowed it
384
+ result.written; // false: nothing was written
385
385
  result.rejected?.reason; // "stale write: expected v1, have v3"
386
386
  ```
387
387
 
@@ -400,10 +400,10 @@ has made a decision and the whole event yields to it.
400
400
 
401
401
  ---
402
402
 
403
- ## Request and reply — `store.call()`
403
+ ## Request and reply: `store.call()`
404
404
 
405
405
  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
406
+ time out, unsubscribe. It is about eighty lines and it has the same two bugs every time: the
407
407
  subscription outlives the call, and a responder that forgets to echo the id produces a timeout
408
408
  with nothing to point at.
409
409
 
@@ -413,7 +413,7 @@ res.payload.text;
413
413
  ```
414
414
 
415
415
  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**:
416
+ causal stamp correlates the two, and **there is no id to mint, echo, or forget**:
417
417
 
418
418
  ```typescript
419
419
  store.registerEffect({
@@ -453,7 +453,7 @@ const { payload } = await call;
453
453
  ```
454
454
 
455
455
  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 —
456
+ run, and the collector is an effect that does not return until the consumer has taken the item,
457
457
  so a responder writing `await emit("job", "tick", chunk)` is **paced by the reader**:
458
458
 
459
459
  ```typescript
@@ -466,7 +466,7 @@ effect: async (_event, _get, emit) => {
466
466
  ```
467
467
 
468
468
  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
469
+ blocking its producer would deadlock the call itself: progress nobody reads would stop the
470
470
  terminal event from ever being sent. Un-iterated progress therefore buffers to `highWaterMark`
471
471
  and is then counted on `call.dropped` rather than blocking.
472
472
 
@@ -474,17 +474,17 @@ and is then counted on `call.dropped` rather than blocking.
474
474
 
475
475
  | | |
476
476
  |---|---|
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. |
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. |
478
478
  | `signal` | An `AbortSignal`, for a real deadline or a cancelled action. |
479
479
  | `call.cancel(reason)` | Stops listening and settles. Safe to call twice. |
480
480
 
481
- However a call ends — resolved, timed out, aborted — the subscription is removed and any producer
481
+ However a call ends, whether resolved, timed out or aborted, the subscription is removed and any producer
482
482
  parked on backpressure is released. A wedged responder is worse than the unbounded buffer this
483
483
  replaced.
484
484
 
485
485
  ## Reading a value as you subscribe
486
486
 
487
- `connect` starts at "from now on", so a subscriber's first read had to repeat the path elsewhere —
487
+ `connect` starts at "from now on", so a subscriber's first read had to repeat the path elsewhere:
488
488
  the same path in two places, free to drift:
489
489
 
490
490
  ```typescript
@@ -513,14 +513,14 @@ store.connect({ reducer: "orders", property: "status" }, (change) => {
513
513
  });
514
514
  ```
515
515
 
516
- Provenance is **absent** when no event caused the change — a DevTools time-travel jump, or the
516
+ Provenance is **absent** when no event caused the change: a DevTools time-travel jump, or the
517
517
  `immediate` delivery above. Absence is the signal, rather than a fabricated id.
518
518
 
519
519
  ---
520
520
 
521
521
  ## Event Deduplication (opt-in)
522
522
 
523
- Deduplication is **off by default** — Yoltra never silently drops legitimate rapid-fire identical
523
+ Deduplication is **off by default**. Yoltra never silently drops legitimate rapid-fire identical
524
524
  events (double-clicks, repeated `+1`). Opt in only when you actually want coalescing:
525
525
 
526
526
  ```typescript
@@ -533,7 +533,7 @@ const store = createStore({
533
533
  dedupWindowMs: 100, // default: 0 (disabled)
534
534
  });
535
535
 
536
- // Identity-based: dedupe by an explicit key — e.g. a React Strict Mode double-invoke in an effect.
536
+ // Identity-based: dedupe by an explicit key, e.g. a React Strict Mode double-invoke in an effect.
537
537
  await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page}` });
538
538
  ```
539
539
 
@@ -541,8 +541,8 @@ await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page
541
541
 
542
542
  ## Cascade protection (on by default)
543
543
 
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
544
+ Two consumers wired into each other, whether 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
546
546
  drains **synchronously**, so that is not a slow program: it is a frozen tab, or a pinned core,
547
547
  with no error and no stack to point at.
548
548
 
@@ -554,7 +554,7 @@ const store = createStore({
554
554
  name: "app",
555
555
  reducer: { ... },
556
556
 
557
- // Defaults to 64. Bounded whether or not you configure it — a failure mode this bad
557
+ // Defaults to 64. Bounded whether or not you configure it. A failure mode this bad
558
558
  // should not require configuration to avoid. Set Infinity to opt out and own it.
559
559
  maxReduceDepth: 64,
560
560
 
@@ -578,13 +578,13 @@ Both fields are **absent** on a root event rather than present as `0`/`undefined
578
578
  application emits stay byte-identical to before this existed.
579
579
 
580
580
  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
581
+ `onCascade` (plus a console error) names it. A throw would surface in whichever subscriber or
582
582
  effect happened to be emitting, which is the same unattributable failure the ceiling exists to
583
583
  prevent.
584
584
 
585
585
  **A wide burst is not a cascade.** One event whose subscriber fans out to five hundred siblings
586
586
  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
587
+ never accumulates depth at all, because each call drains to completion before the next, so every one is
588
588
  a root. `maxTransitionsPerDrain` bounds burst *width* and is off by default for that reason; the
589
589
  event that starts a drain is never refused by it.
590
590
 
@@ -639,13 +639,13 @@ if (import.meta.hot) {
639
639
 
640
640
  ### State is synchronous; `await` only for effects
641
641
 
642
- The reduce phase is synchronous, so state reflects your event the moment `emit()` returns — no
642
+ The reduce phase is synchronous, so state reflects your event the moment `emit()` returns, with no
643
643
  `await` needed to read it back. Await `emit()` when you also want _this event's_ effects to have
644
644
  finished:
645
645
 
646
646
  ```typescript
647
647
  emit("todo", "add", todo);
648
- store.getState(); // Already reflects the new todo — no await required
648
+ store.getState(); // Already reflects the new todo. No await required
649
649
 
650
650
  await emit("todo", "save", todo); // resolves once save's effects complete
651
651
  ```
@@ -749,7 +749,7 @@ that never happened.
749
749
 
750
750
  **Nothing throws on boot.** A missing, unparseable or unmigratable payload falls back to your
751
751
  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
752
+ holds stale JSON is worse than one that starts fresh, and a full disk should not take down a
753
753
  page, so write failures are reported the same way rather than raised.
754
754
 
755
755
  **Version mismatches are refused, not trusted.** Reducers change, and a snapshot written
@@ -767,7 +767,7 @@ For a server render, `dehydrate(store, { version })` produces the payload and
767
767
  ## Lists that reorder
768
768
 
769
769
  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
770
+ `unshift`, `splice(0, 1)` and `sort` move nearly every element into a different slot, and the
771
771
  diff correctly reports that nearly every leaf changed. Inserting one row at the front of a
772
772
  thousand wakes a thousand subscribers.
773
773
 
@@ -790,7 +790,7 @@ todos.idsPath; // "ids"
790
790
  `entities.abc.title` survives insert, remove and reorder. A list container subscribes to `ids`
791
791
  and reorders its children; rows subscribe to their own entity and stay asleep through a sort.
792
792
 
793
- `ids` is still an array, so a reorder still reports `ids.0`, `ids.1` and so on — that cost is
793
+ `ids` is still an array, so a reorder still reports `ids.0`, `ids.1` and so on. That cost is
794
794
  confined, not removed. What you get is cost proportional to what actually changed.
795
795
 
796
796
  For a small list that only ever grows at the end, `items.0.title` is fine and simpler. The
@@ -803,8 +803,8 @@ normalised, and the array reports roughly a thousand changed paths against two.
803
803
  case the adapter is for.
804
804
 
805
805
  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
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
808
808
  almost nothing in it moved. The numbers are in `benchmarks/`, and closing that gap is tracked
809
809
  work rather than a property of normalising as such.
810
810
 
@@ -813,24 +813,33 @@ individual fields edited is better off as an array today.
813
813
 
814
814
  ## Performance
815
815
 
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 |
816
+ | Metric | Value |
817
+ | ------------------ | -------------------------------------- |
818
+ | **Bundle size** | Measured every build, see table below |
819
+ | **Tree-shakeable** | Yes (ES modules) |
820
+ | **Dependencies** | Zero |
821
+ | **TypeScript** | Full type definitions included |
822
822
 
823
823
  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`.
824
+ would (tree-shaken, minified, gzipped) and fails when it exceeds the budget declared in
825
+ `package.json`. The table below is written by that same check, so it cannot drift from what
826
+ was measured; editing it by hand fails CI.
826
827
 
827
828
  The number that matters is what you import, not what the package exports:
828
829
 
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 |
830
+ <!-- size-table:start -->
831
+ | Import | Size | Budget |
832
+ | --- | --- | --- |
833
+ | `{ createStore }` | 8.3 KB | 14 KB |
834
+ | `{ createStore, hydrate, persist }` | 9.7 KB | 16 KB |
835
+ | everything | 11.2 KB | 18 KB |
836
+ <!-- size-table:end -->
837
+
838
+ These are **production** figures: what you ship once your bundler defines
839
+ `NODE_ENV=production` and the development-only guards drop out. The budget column is the
840
+ ceiling `rush size` enforces, and it is checked against a development build instead, which is
841
+ the larger of the two: dev-only code cannot grow unnoticed just because it never reaches a
842
+ user. So the headroom implied here is deliberately conservative.
834
843
 
835
844
  The **gap between rows** is the tree-shaking claim, and it is what to watch: persistence adds
836
845
  1.5 KB to the people who import it and nothing to anyone else, and the whole barrel is 2.9 KB
@@ -846,27 +855,27 @@ stops a runaway from hanging the tab.
846
855
 
847
856
  ## Documentation
848
857
 
849
- - **[yoltra Root README](../../README.md)** — Overview and
858
+ - **[yoltra Root README](../../README.md)**: Overview and
850
859
  quick start
851
- - **[@yoltra/react](../react/README.md)** —
860
+ - **[@yoltra/react](../react/README.md)**:
852
861
  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
862
+ - **[Quick Start Guide](https://github.com/yoltra/yoltra/blob/main/docs/en/QUICK_START_GUIDE.md)**:
863
+ Five steps to a working app
864
+ - **[Event Queue Architecture](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)**:
865
+ Technical deep-dive
866
+ - **[Library Comparison](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)**:
867
+ Architectural comparison
859
868
 
860
869
  ---
861
870
 
862
871
  ## Examples
863
872
 
864
- - **[Todo App](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)** — Full
873
+ - **[Todo App](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)**: Full
865
874
  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)
875
+ - **[Kinetic Logo](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-kinetic-logo)**:
876
+ 3000 circles with physics simulation · [▶ Open the live demo](https://yoltra.dev/en/demos/kinetic-logo)
877
+ - **[Next.js Integration](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)**:
878
+ Pages Router, client-side state + theme switcher · [▶ Open the live demo](https://yoltra.dev/en/demos/in-nextjs)
870
879
 
871
880
  ---
872
881
 
@@ -879,10 +888,10 @@ stops a runaway from hanging the tab.
879
888
 
880
889
  ## Status
881
890
 
882
- **Release Candidate** — APIs are stable, used in production, minor changes possible before v1.0.
891
+ **Release Candidate**. APIs are stable, used in production, minor changes possible before v1.0.
883
892
 
884
893
  ---
885
894
 
886
895
  ## License
887
896
 
888
- **MIT** — Free to use in commercial and open-source projects.
897
+ **MIT**. Free to use in commercial and open-source projects.
@@ -337,42 +337,6 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
337
337
  * @internal
338
338
  */
339
339
  private reportCascade;
340
- /**
341
- * Checks if an event matches a `When` matcher.
342
- *
343
- * @param when - The When matcher (or undefined for "all events").
344
- * @param event - The event to check.
345
- * @returns `true` if the event matches, `false` otherwise.
346
- *
347
- * @remarks
348
- * - `undefined` or missing `when` matches ALL events.
349
- * - `{ any: true }` matches ALL events.
350
- * - `{ keys: [...] }` matches if event's `[channel, type]` is in the array.
351
- * - `{ channel: 'x' }` matches if event's channel equals 'x'.
352
- * - `{ channels: ['x', 'y'] }` matches if event's channel is in the array.
353
- *
354
- * @internal
355
- */
356
- private matchesWhen;
357
- /**
358
- * Extracts the middleware function from a MiddlewareInput.
359
- * Handles both raw functions (legacy) and MiddlewareSpec objects.
360
- *
361
- * @param input - MiddlewareInput (function or spec).
362
- * @returns The middleware function.
363
- *
364
- * @internal
365
- */
366
- private getMiddlewareFunction;
367
- /**
368
- * Gets the `when` matcher from a MiddlewareInput.
369
- *
370
- * @param input - MiddlewareInput (function or spec).
371
- * @returns The `when` matcher, or `undefined` for raw functions (match all).
372
- *
373
- * @internal
374
- */
375
- private getMiddlewareWhen;
376
340
  /**
377
341
  * Invokes all registered **effects** for a given event.
378
342
  * Handles both key-based effects (O(1) lookup) and pattern-based effects (runtime matching).
@@ -942,13 +906,7 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
942
906
  * the terminal event from ever being sent. Un-iterated progress therefore buffers to
943
907
  * `highWaterMark` and is then counted on {@link CallHandle.dropped} rather than blocking.
944
908
  *
945
- * **This is a local primitive.** A reply cannot reach it from a federated peer: the federation
946
- * envelope carries neither `meta` nor `parentId`, and ingress namespaces the channel, so
947
- * neither correlation nor the reply route survives the hop. That is not an oversight to route
948
- * around — federation answers cross-node request/reply with typed peer *queries*, which are
949
- * gated by a responder policy that may concede or deny. A call that federated silently would
950
- * turn that access decision into an accident of which channel someone named. Ask a peer with a
951
- * query; use `call` within a process.
909
+ * **This is a local primitive.**
952
910
  *
953
911
  * @example Timeout is idle, not total
954
912
  * ```ts
@@ -1086,15 +1044,6 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
1086
1044
  * @internal
1087
1045
  */
1088
1046
  private unmountSlice;
1089
- /**
1090
- * Normalizes event targeting from `when` to an array of EventKeys.
1091
- *
1092
- * @param spec - Object with an optional `when` matcher.
1093
- * @returns Array of `[channel, type]` pairs.
1094
- *
1095
- * @internal
1096
- */
1097
- private normalizeEventKeys;
1098
1047
  /**
1099
1048
  * Reads a dotted path from an object (supports numeric array indices via string keys).
1100
1049
  *
@@ -1102,6 +1051,10 @@ export declare class Store<EM extends EventMapBase, R extends string, S extends
1102
1051
  * @param path - Dotted path; leading dot is ignored.
1103
1052
  * @returns The value at the path, or `undefined`.
1104
1053
  *
1054
+ * @remarks
1055
+ * A member rather than a bare import: a test replaces this on the instance to count how many
1056
+ * walks describing a change costs, which only works while the callers go through `this`.
1057
+ *
1105
1058
  * @internal
1106
1059
  */
1107
1060
  private getAtPath;
@@ -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>>;
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * @remarks
8
8
  * `Symbol.for` rather than `Symbol()`, so the brand survives two copies of this package meeting
9
- * at runtime — a duplicated dependency, a federated bundle, a consumer that pinned an older
10
- * minor. With a unique symbol the check would silently answer `false` across that boundary and a
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
11
  * refusal would read as ordinary state, which is the failure this whole feature exists to end.
12
12
  *
13
13
  * @internal