@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.
package/README.md CHANGED
@@ -34,20 +34,20 @@ 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
- ├─ 2. Reducers ─── Synchronous state updates, fine-grained path change detection
39
+ ├─ 2. Reducers ─── Every matching slice staged, then all committed under one root
40
40
  ├─ 3. Event subscribers ─── Committed/uncommitted event notifications
41
41
  ├─ 4. Coarse subscribers ─── External store listeners (useSyncExternalStore, etc.), if state changed
42
42
  │
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,17 @@ store.onEvent(
316
316
  "uncommitted",
317
317
  );
318
318
 
319
- // All events — both committed and uncommitted
319
+ // Written events: state actually changed. Fires after the commit, so getState() is current.
320
+ store.onEvent(
321
+ "plan",
322
+ "patch",
323
+ (event, getState) => {
324
+ console.log("applied:", getState().plan);
325
+ },
326
+ "written",
327
+ );
328
+
329
+ // All events: both committed and uncommitted (not written; see below)
320
330
  store.onEvent(
321
331
  "ui",
322
332
  "action",
@@ -327,11 +337,190 @@ store.onEvent(
327
337
  );
328
338
  ```
329
339
 
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
342
+ all. `written` is the stricter fact, added rather than substituted, so toasts and analytics keep
343
+ working unchanged. `all` stays `committed | uncommitted`; folding `written` in would hand existing
344
+ subscribers a second notification per event.
345
+
346
+ ---
347
+
348
+ ## Commits are atomic across slices
349
+
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
352
+ same event already applied.
353
+
354
+ That matters most where a change is used as a signal to re-read, which is what the React hooks do.
355
+
356
+ ---
357
+
358
+ ## Refusing a write
359
+
360
+ A reducer returns `Rejected(reason)` instead of state to decline. **The whole event is rejected**:
361
+ no slice writes, no change notification fires, and the caller is told why.
362
+
363
+ ```typescript
364
+ import { createStore, Rejected } from "@yoltra/core";
365
+
366
+ const store = createStore({
367
+ name: "plan",
368
+ reducer: {
369
+ plan: {
370
+ state: { steps: [], version: 1 },
371
+ when: { keys: [["plan", "patch"]] },
372
+ reducer: (state, event) =>
373
+ event.payload.expectedVersion === state.version
374
+ ? { ...state, steps: event.payload.steps, version: state.version + 1 }
375
+ : Rejected(`stale write: expected v${event.payload.expectedVersion}, have v${state.version}`),
376
+ },
377
+ },
378
+ onRejected: (rejection, event, slice) => metrics.increment("write.refused", { slice }),
379
+ });
380
+
381
+ const result = await store.emit("plan", "patch", { steps, expectedVersion: 1 });
382
+
383
+ result.committed; // true: middleware allowed it
384
+ result.written; // false: nothing was written
385
+ result.rejected?.reason; // "stale write: expected v1, have v3"
386
+ ```
387
+
388
+ Refusing is **not** the same as returning the state unchanged, which is indistinguishable from
389
+ "this event did not concern me". It is also not the same as throwing: a reducer that throws has a
390
+ bug, so its slice is isolated and every other slice still commits, while a reducer that refuses
391
+ has made a decision and the whole event yields to it.
392
+
393
+ `emit` resolves to an `EmitResult` once effects have run:
394
+
395
+ | | |
396
+ |---|---|
397
+ | `committed` | middleware did not veto |
398
+ | `written` | a reducer actually changed state |
399
+ | `rejected` | present when a reducer refused, carrying `reason` |
400
+
401
+ ---
402
+
403
+ ## Request and reply: `store.call()`
404
+
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
407
+ subscription outlives the call, and a responder that forgets to echo the id produces a timeout
408
+ with nothing to point at.
409
+
410
+ ```typescript
411
+ const res = await store.call("rpc", "ask", { q: "who?" }, { reply: ["rpc", "answer"] });
412
+ res.payload.text;
413
+ ```
414
+
415
+ The responder does nothing special. It replies through the `emit` it was handed, and the store's
416
+ causal stamp correlates the two, and **there is no id to mint, echo, or forget**:
417
+
418
+ ```typescript
419
+ store.registerEffect({
420
+ when: { keys: [["rpc", "ask"]] },
421
+ effect: async (event, _get, emit) => {
422
+ await emit("rpc", "answer", await lookup(event.payload.q));
423
+ },
424
+ });
425
+ ```
426
+
427
+ ### A call resolves to the event, not the payload
428
+
429
+ Because a caller often cannot know *which* reply it will get. `reply` names the **terminal**
430
+ types, and the event carries the discriminant:
431
+
432
+ ```typescript
433
+ const res = await store.call("rpc", "ask", { q }, { reply: ["rpc", ["answer", "error"]] });
434
+
435
+ switch (res.type) {
436
+ case "answer": return res.payload.text;
437
+ case "error": throw new Error(res.payload.reason);
438
+ }
439
+ ```
440
+
441
+ ### Progress streams, and the producer waits
442
+
443
+ Any correlated event that is **not** terminal is progress. Iterate the call to consume it:
444
+
445
+ ```typescript
446
+ const call = store.call("job", "start", { id }, {
447
+ reply: ["job", "done"],
448
+ highWaterMark: 4,
449
+ });
450
+
451
+ for await (const step of call) await render(step.payload);
452
+ const { payload } = await call;
453
+ ```
454
+
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,
457
+ so a responder writing `await emit("job", "tick", chunk)` is **paced by the reader**:
458
+
459
+ ```typescript
460
+ effect: async (_event, _get, emit) => {
461
+ for (const chunk of chunks) {
462
+ await emit("job", "tick", chunk); // waits here while the consumer is behind
463
+ }
464
+ await emit("job", "done", { ok: true });
465
+ }
466
+ ```
467
+
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
470
+ terminal event from ever being sent. Un-iterated progress therefore buffers to `highWaterMark`
471
+ and is then counted on `call.dropped` rather than blocking.
472
+
473
+ ### Giving up
474
+
475
+ | | |
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. |
478
+ | `signal` | An `AbortSignal`, for a real deadline or a cancelled action. |
479
+ | `call.cancel(reason)` | Stops listening and settles. Safe to call twice. |
480
+
481
+ However a call ends, whether resolved, timed out or aborted, the subscription is removed and any producer
482
+ parked on backpressure is released. A wedged responder is worse than the unbounded buffer this
483
+ replaced.
484
+
485
+ ## Reading a value as you subscribe
486
+
487
+ `connect` starts at "from now on", so a subscriber's first read had to repeat the path elsewhere:
488
+ the same path in two places, free to drift:
489
+
490
+ ```typescript
491
+ store.connect({ reducer: "todos", property: "items.0.title" }, render, { immediate: true });
492
+ ```
493
+
494
+ The synthetic first change has `oldValue: undefined` and **no provenance**, because no event
495
+ caused it. For a wildcard pattern, which has no single current value, the slice root is delivered
496
+ with `path: ""`.
497
+
498
+ React does not need this: `useSyncExternalStore` already reads a snapshot on mount.
499
+
500
+ ---
501
+
502
+ ## Where a change came from
503
+
504
+ A `Change` names the event that caused it, so a subscriber no longer has to mirror the cause into
505
+ state and keep it in two places:
506
+
507
+ ```typescript
508
+ store.connect({ reducer: "orders", property: "status" }, (change) => {
509
+ audit.record(change.path, change.newValue, {
510
+ causedBy: change.eventId,
511
+ via: `${change.channel}/${change.type}`,
512
+ });
513
+ });
514
+ ```
515
+
516
+ Provenance is **absent** when no event caused the change: a DevTools time-travel jump, or the
517
+ `immediate` delivery above. Absence is the signal, rather than a fabricated id.
518
+
330
519
  ---
331
520
 
332
521
  ## Event Deduplication (opt-in)
333
522
 
334
- 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
335
524
  events (double-clicks, repeated `+1`). Opt in only when you actually want coalescing:
336
525
 
337
526
  ```typescript
@@ -344,12 +533,63 @@ const store = createStore({
344
533
  dedupWindowMs: 100, // default: 0 (disabled)
345
534
  });
346
535
 
347
- // 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.
348
537
  await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page}` });
349
538
  ```
350
539
 
351
540
  ---
352
541
 
542
+ ## Cascade protection (on by default)
543
+
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
+ drains **synchronously**, so that is not a slow program: it is a frozen tab, or a pinned core,
547
+ with no error and no stack to point at.
548
+
549
+ Every event therefore carries its causal position, and the store refuses to extend a chain past a
550
+ ceiling:
551
+
552
+ ```typescript
553
+ const store = createStore({
554
+ name: "app",
555
+ reducer: { ... },
556
+
557
+ // Defaults to 64. Bounded whether or not you configure it. A failure mode this bad
558
+ // should not require configuration to avoid. Set Infinity to opt out and own it.
559
+ maxReduceDepth: 64,
560
+
561
+ onCascade: ({ event, depth, chain }) => {
562
+ report(`cascade at ${event.channel}/${event.type}, depth ${depth}`, chain);
563
+ },
564
+ });
565
+ ```
566
+
567
+ An event emitted while another is being handled is one deeper than its cause, and carries
568
+ `parentId` and `depth` so the cycle is legible after the fact:
569
+
570
+ ```typescript
571
+ store.onEvent("plan", "patch", (event) => {
572
+ event.depth; // 0 for an event emitted by application code
573
+ event.parentId; // undefined at depth 0; the causing event's id below it
574
+ });
575
+ ```
576
+
577
+ Both fields are **absent** on a root event rather than present as `0`/`undefined`, so events your
578
+ application emits stay byte-identical to before this existed.
579
+
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
582
+ effect happened to be emitting, which is the same unattributable failure the ceiling exists to
583
+ prevent.
584
+
585
+ **A wide burst is not a cascade.** One event whose subscriber fans out to five hundred siblings
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, because each call drains to completion before the next, so every one is
588
+ a root. `maxTransitionsPerDrain` bounds burst *width* and is off by default for that reason; the
589
+ event that starts a drain is never refused by it.
590
+
591
+ ---
592
+
353
593
  ## Dynamic Reducers
354
594
 
355
595
  Add or remove reducer slices at runtime:
@@ -399,13 +639,13 @@ if (import.meta.hot) {
399
639
 
400
640
  ### State is synchronous; `await` only for effects
401
641
 
402
- 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
403
643
  `await` needed to read it back. Await `emit()` when you also want _this event's_ effects to have
404
644
  finished:
405
645
 
406
646
  ```typescript
407
647
  emit("todo", "add", todo);
408
- store.getState(); // Already reflects the new todo — no await required
648
+ store.getState(); // Already reflects the new todo. No await required
409
649
 
410
650
  await emit("todo", "save", todo); // resolves once save's effects complete
411
651
  ```
@@ -509,7 +749,7 @@ that never happened.
509
749
 
510
750
  **Nothing throws on boot.** A missing, unparseable or unmigratable payload falls back to your
511
751
  declared defaults and reports through `onError`. A store that will not start because storage
512
- 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
513
753
  page, so write failures are reported the same way rather than raised.
514
754
 
515
755
  **Version mismatches are refused, not trusted.** Reducers change, and a snapshot written
@@ -527,7 +767,7 @@ For a server render, `dehydrate(store, { version })` produces the payload and
527
767
  ## Lists that reorder
528
768
 
529
769
  Path notification is positional for arrays. `items.0.title` names a *slot*, not a thing, so
530
- `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
531
771
  diff correctly reports that nearly every leaf changed. Inserting one row at the front of a
532
772
  thousand wakes a thousand subscribers.
533
773
 
@@ -550,7 +790,7 @@ todos.idsPath; // "ids"
550
790
  `entities.abc.title` survives insert, remove and reorder. A list container subscribes to `ids`
551
791
  and reorders its children; rows subscribe to their own entity and stay asleep through a sort.
552
792
 
553
- `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
554
794
  confined, not removed. What you get is cost proportional to what actually changed.
555
795
 
556
796
  For a small list that only ever grows at the end, `items.0.title` is fine and simpler. The
@@ -563,8 +803,8 @@ normalised, and the array reports roughly a thousand changed paths against two.
563
803
  case the adapter is for.
564
804
 
565
805
  A single-field update runs the other way: 20 µs for the array against 470 µs normalised.
566
- `detectChangedProps` indexes an array but enumerates an object's keys — building two key
567
- 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
568
808
  almost nothing in it moved. The numbers are in `benchmarks/`, and closing that gap is tracked
569
809
  work rather than a property of normalising as such.
570
810
 
@@ -573,55 +813,69 @@ individual fields edited is better off as an array today.
573
813
 
574
814
  ## Performance
575
815
 
576
- | Metric | Value |
577
- | ------------------ | ----------------------------------------- |
578
- | **Bundle size** | 6.7 KB for the store (minified + gzipped) |
579
- | **Tree-shakeable** | Yes (ES modules) |
580
- | **Dependencies** | Zero |
581
- | **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 |
582
822
 
583
823
  Bundle size is checked, not asserted: `rush size` bundles the package the way a consumer
584
- would — tree-shaken, minified, gzipped — and fails when it exceeds the budget declared in
585
- `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.
586
827
 
587
828
  The number that matters is what you import, not what the package exports:
588
829
 
589
- | Import | Size |
590
- | ----------------------------------- | ------ |
591
- | `{ createStore }` | 6.7 KB |
592
- | `{ createStore, hydrate, persist }` | 8.2 KB |
593
- | everything | 9.5 KB |
594
-
595
- Persistence and the entity adapter cost nothing to anyone who does not import them — the
596
- first row has not moved as either was added, which is the tree-shaking claim being checked
597
- rather than repeated. The last row is a growth tripwire; `import * as all` is not something
598
- anybody writes.
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.
843
+
844
+ The **gap between rows** is the tree-shaking claim, and it is what to watch: persistence adds
845
+ 1.5 KB to the people who import it and nothing to anyone else, and the whole barrel is 2.9 KB
846
+ past the store. The last row is a growth tripwire; `import * as all` is not something anybody
847
+ writes.
848
+
849
+ The first row moves only when the store itself grows, and it has: bounding cascades, staging
850
+ commits so they apply atomically, and `store.call()` are all store machinery rather than
851
+ opt-in modules, so they are paid by everyone. That is the honest trade for a default that
852
+ stops a runaway from hanging the tab.
599
853
 
600
854
  ---
601
855
 
602
856
  ## Documentation
603
857
 
604
- - **[yoltra Root README](../../README.md)** — Overview and
858
+ - **[yoltra Root README](../../README.md)**: Overview and
605
859
  quick start
606
- - **[@yoltra/react](../react/README.md)** —
860
+ - **[@yoltra/react](../react/README.md)**:
607
861
  React hooks and Suspense
608
- - **[Quick Start Guide](https://github.com/yoltra/yoltra/blob/main/docs/en/QUICK_START_GUIDE.md)**
609
- — Five steps to a working app
610
- - **[Event Queue Architecture](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)**
611
- — Technical deep-dive
612
- - **[Library Comparison](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)**
613
- — 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
614
868
 
615
869
  ---
616
870
 
617
871
  ## Examples
618
872
 
619
- - **[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
620
874
  CRUD with performance profiling · [▶ Open the live demo](https://yoltra.dev/en/demos/in-react)
621
- - **[Kinetic Logo](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-kinetic-logo)**
622
- — 3000 circles with physics simulation · [▶ Open the live demo](https://yoltra.dev/en/demos/kinetic-logo)
623
- - **[Next.js Integration](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)**
624
- — 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)
625
879
 
626
880
  ---
627
881
 
@@ -634,10 +888,10 @@ anybody writes.
634
888
 
635
889
  ## Status
636
890
 
637
- **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.
638
892
 
639
893
  ---
640
894
 
641
895
  ## License
642
896
 
643
- **MIT** — Free to use in commercial and open-source projects.
897
+ **MIT**. Free to use in commercial and open-source projects.
@@ -9,10 +9,15 @@ export { EventBus } from './eventBus/EventBus.js';
9
9
  export { LooseEventBus } from './eventBus/LooseEventBus.js';
10
10
  export { Reducer } from './reducer/Reducer.js';
11
11
  export { Store, createStore, typedEvents } from './store/Store.js';
12
+ export { Rejected, isRejected } from './store/rejection.js';
13
+ export { CallAbortedError, CallTimeoutError } from './store/call.js';
14
+ export type { CallHandle, CallOptions, ReplySpec } from './store/call.js';
15
+ export type { Rejection } from './store/rejection.js';
12
16
  export { detectChangedProps } from './utils/detectChangedProps.js';
13
17
  export { freezeState } from './utils/immutability.js';
18
+ export type { AliasWatch } from './utils/immutability.js';
14
19
  export { eventKeys } from './types.js';
15
- export type { EventMapBase, EventKey, Event, EventUnion, Change, Emit, EmitOptions, EventMeta, InstrumentedEvent, InstrumentationObserver, Unsubscribe, StoreSpec, StoreInstance, ReducerSpec, ReducerFunction, ReducersMapAny, StateFromReducers, EMFromReducersStrict, EffectSpec, EffectFunction, MiddlewareFunction, MiddlewareSpec, MiddlewareInput, DeepReadonly, DeepRO, Primitive, RootValue, Path, PathValue, WithGlob, Dotted, EventPhase, EventSubscriptionHandler, NarrowedEventHandler, When, EventFromWhen, EventConsumerType, EventConsumerMeta, } 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';
16
21
  export { createEntityAdapter } from './entity/entityAdapter.js';
17
22
  export type { EntityAdapter, EntityAdapterOptions, EntityId, EntityState, EntityUpdate, } from './entity/entityAdapter.js';
18
23
  export { decodeState, encodeState, encodeStateBounded } from './serialize/codec.js';
@@ -84,10 +84,6 @@ export interface Hydration {
84
84
  export declare function hydrate(options: PersistOptions & {
85
85
  readonly source?: string;
86
86
  }): Promise<Hydration>;
87
- /** A reducer spec, as far as hydration cares: something carrying an initial `state`. */
88
- interface HasState {
89
- state: unknown;
90
- }
91
87
  /**
92
88
  * Replaces each reducer's initial state with what was restored for it.
93
89
  *
@@ -97,7 +93,9 @@ interface HasState {
97
93
  *
98
94
  * @public
99
95
  */
100
- export declare function withHydration<R extends Record<string, HasState>>(reducers: R, hydration: Hydration): R;
96
+ export declare function withHydration<R extends Record<string, {
97
+ state: unknown;
98
+ }>>(reducers: R, hydration: Hydration): R;
101
99
  /** The store surface persistence needs, which is two methods wide. */
102
100
  export interface PersistableStore {
103
101
  getState(): unknown;
@@ -123,4 +121,3 @@ export declare function persist(store: PersistableStore, options: PersistOptions
123
121
  * @public
124
122
  */
125
123
  export declare function dehydrate(store: Pick<PersistableStore, "getState">, options: Pick<PersistOptions, "version" | "slices">): string;
126
- export {};
@@ -1,4 +1,5 @@
1
1
  import { EventMapBase, EventUnion, ReducerFunction } from '../types.js';
2
+ import { Rejection } from '../store/rejection.js';
2
3
  /**
3
4
  * Thin wrapper around a pure reducer function (stateful event consumer):
4
5
  * given a state `S` and an event (from {@link EventUnion | `EventUnion<EM>`}),
@@ -68,7 +69,7 @@ export declare class Reducer<S, EM extends EventMapBase = EventMapBase> {
68
69
  *
69
70
  * @param state - Current state.
70
71
  * @param event - An event drawn from {@link EventUnion | `EventUnion<EM>`}.
71
- * @returns The next state produced by the underlying reducer function.
72
+ * @returns The next state, or a {@link Rejection} if the reducer refused the write.
72
73
  *
73
74
  * @example
74
75
  * ```ts
@@ -77,5 +78,5 @@ export declare class Reducer<S, EM extends EventMapBase = EventMapBase> {
77
78
  *
78
79
  * @public
79
80
  */
80
- reduce(state: S, event: EventUnion<EM>): S;
81
+ reduce(state: S, event: EventUnion<EM>): S | Rejection;
81
82
  }