@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.es.md +443 -148
- package/README.md +207 -84
- package/dist/types/index.d.ts +2 -1
- package/dist/types/persistence/persist.d.ts +2 -2
- package/dist/types/store/Store.d.ts +373 -66
- package/dist/types/store/fingerprint.d.ts +62 -0
- package/dist/types/store/matching.d.ts +49 -0
- package/dist/types/store/paths.d.ts +39 -0
- package/dist/types/store/performCall.d.ts +15 -0
- package/dist/types/store/rejection.d.ts +2 -2
- package/dist/types/types.d.ts +528 -18
- package/dist/yoltra.cjs +3 -8
- package/dist/yoltra.cjs.map +1 -1
- package/dist/yoltra.mjs +2133 -1385
- package/dist/yoltra.mjs.map +1 -1
- package/dist/yoltra.umd.js +3 -8
- package/dist/yoltra.umd.js.map +1 -1
- package/package.json +6 -4
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
|
-
](https://www.npmjs.com/package/@yoltra/core)
|
|
9
|
+
[](https://www.npmjs.com/package/@yoltra/core)
|
|
10
|
+
[](https://www.npmjs.com/package/@yoltra/core)
|
|
11
|
+
[](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
|
|
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
|
-
|
|
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
|
|
50
|
-
phase
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 `"**"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
384
|
-
result.written; // false
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
545
|
-
two slices that answer each other's events
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)`
|
|
714
|
-
| `store.replaceMiddleware(middleware)`
|
|
715
|
-
| `store.replaceEffects(effects)`
|
|
716
|
-
| `store.hotReplace(partial)`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
807
|
-
arrays and a `Set` per comparison
|
|
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** |
|
|
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
|
|
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
|
-
|
|
830
|
-
|
|
|
831
|
-
|
|
|
832
|
-
| `{ createStore
|
|
833
|
-
|
|
|
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)
|
|
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
|
-
|
|
855
|
-
- **[
|
|
856
|
-
|
|
857
|
-
- **[
|
|
858
|
-
|
|
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)
|
|
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
|
-
|
|
868
|
-
- **[Next.js Integration](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)
|
|
869
|
-
|
|
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
|
|
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
|
|
1011
|
+
**MIT**. Free to use in commercial and open-source projects.
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
|
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;
|