@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.es.md +462 -76
- package/README.md +317 -63
- package/dist/types/index.d.ts +6 -1
- package/dist/types/persistence/persist.d.ts +3 -6
- package/dist/types/reducer/Reducer.d.ts +3 -2
- package/dist/types/store/Store.d.ts +239 -56
- package/dist/types/store/call.d.ts +149 -0
- package/dist/types/store/callQueue.d.ts +79 -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 +58 -0
- package/dist/types/types.d.ts +246 -13
- package/dist/yoltra.cjs +3 -8
- package/dist/yoltra.cjs.map +1 -1
- package/dist/yoltra.mjs +1509 -1038
- 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 +12 -11
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
|
|
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 ───
|
|
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
|
-
|
|
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
|
|
50
|
-
phase
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 `"**"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
567
|
-
arrays and a `Set` per comparison
|
|
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** |
|
|
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
|
|
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
|
-
|
|
590
|
-
|
|
|
591
|
-
|
|
|
592
|
-
| `{ createStore
|
|
593
|
-
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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)
|
|
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
|
-
|
|
610
|
-
- **[Event Queue Architecture](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)
|
|
611
|
-
|
|
612
|
-
- **[Library Comparison](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)
|
|
613
|
-
|
|
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)
|
|
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
|
-
|
|
623
|
-
- **[Next.js Integration](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)
|
|
624
|
-
|
|
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
|
|
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
|
|
897
|
+
**MIT**. Free to use in commercial and open-source projects.
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
}
|