@yoltra/core 0.3.0 → 0.5.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 +1 -1
- package/README.md +169 -7
- package/dist/types/entity/entityAdapter.d.ts +118 -0
- package/dist/types/eventBus/EventBus.d.ts +18 -5
- package/dist/types/eventBus/LooseEventBus.d.ts +72 -49
- package/dist/types/eventBus/index.d.ts +2 -2
- package/dist/types/index.d.ts +16 -8
- package/dist/types/persistence/adapters.d.ts +33 -0
- package/dist/types/persistence/persist.d.ts +126 -0
- package/dist/types/reducer/Reducer.d.ts +1 -1
- package/dist/types/serialize/codec.d.ts +119 -0
- package/dist/types/store/Store.d.ts +78 -15
- package/dist/types/types.d.ts +164 -32
- package/dist/types/utils/detectChangedProps.d.ts +6 -0
- package/dist/types/utils/immutability.d.ts +25 -2
- package/dist/types/utils/index.d.ts +2 -2
- package/dist/yoltra.cjs +11 -0
- package/dist/yoltra.cjs.map +1 -0
- package/dist/yoltra.mjs +2391 -0
- package/dist/yoltra.mjs.map +1 -0
- package/dist/yoltra.umd.js +2 -2
- package/dist/yoltra.umd.js.map +1 -0
- package/package.json +33 -13
- package/dist/yoltra.cjs.js +0 -11
- package/dist/yoltra.esm.js +0 -1744
package/README.es.md
CHANGED
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-

|
|
2
2
|
|
|
3
3
|
# @yoltra/core
|
|
4
4
|
|
|
@@ -87,6 +87,63 @@ store.connect({ reducer: "todos", property: "items.**" }, (change) =>
|
|
|
87
87
|
);
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
### Slices that hold a single value
|
|
91
|
+
|
|
92
|
+
A slice does not have to be an object. A primitive, a `Map`, a `Set` or a `Date` is a valid
|
|
93
|
+
slice state, and it commits like any other:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
const store = createStore({
|
|
97
|
+
name: "session",
|
|
98
|
+
reducer: {
|
|
99
|
+
token: {
|
|
100
|
+
state: null as string | null,
|
|
101
|
+
when: { keys: [["auth", "login"]] },
|
|
102
|
+
reducer: (_state, event) => event.payload.token,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await store.emit("auth", "login", { token: "abc123" });
|
|
108
|
+
store.getState().token; // "abc123"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Such a slice has no property beneath it, so its changes are reported at the **slice root** —
|
|
112
|
+
the empty path. Subscribe to it with `property: ""`:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
store.connect({ reducer: "token", property: "" }, (change) =>
|
|
116
|
+
console.log("token:", change.oldValue, " --> ", change.newValue),
|
|
117
|
+
);
|
|
118
|
+
```
|
|
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:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
const token = useAtomicProp({ reducer: "token", property: "" }); // string | null
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `""` versus `"**"` — watching a whole slice
|
|
128
|
+
|
|
129
|
+
Two subscriptions sound alike and are not:
|
|
130
|
+
|
|
131
|
+
| Pattern | Fires when |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `""` | the slice's **whole value** is replaced — a primitive changes, a `Map` is rebuilt, an object slice becomes `null` |
|
|
134
|
+
| `"**"` | **anything** in the slice changes, at any depth. Matches the root too, since `**` matches zero segments |
|
|
135
|
+
| `"*"` | one level down, exactly. Never matches the root |
|
|
136
|
+
|
|
137
|
+
**`"**"` is the whole-slice subscription, and it works for every slice regardless of shape.**
|
|
138
|
+
Reach for `""` only when you mean the root value itself; on an object slice it stays quiet,
|
|
139
|
+
because such a slice reports its changes at their leaves.
|
|
140
|
+
|
|
141
|
+
`Map` and `Set` are compared by reference, not by entry: a reducer returning a new `Map` is a
|
|
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
|
|
144
|
+
they have no paths beneath them: `"byId"` is subscribable, `"byId.get"` is not, and the types
|
|
145
|
+
say so.
|
|
146
|
+
|
|
90
147
|
### Immutability
|
|
91
148
|
|
|
92
149
|
State is deep-frozen before committing. Mutations throw in strict mode:
|
|
@@ -426,14 +483,119 @@ store.registerEffect({
|
|
|
426
483
|
|
|
427
484
|
---
|
|
428
485
|
|
|
486
|
+
## Saving and restoring state
|
|
487
|
+
|
|
488
|
+
Two functions, because the halves happen on opposite sides of the store's existence.
|
|
489
|
+
`hydrate` produces *initial slice state*, so the store is born with it:
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
import { createStore, createWebStorageAdapter, hydrate, persist, withHydration } from '@yoltra/core';
|
|
493
|
+
|
|
494
|
+
const adapter = createWebStorageAdapter(localStorage);
|
|
495
|
+
const hydration = await hydrate({ key: 'app', adapter, version: 3 });
|
|
496
|
+
|
|
497
|
+
const store = createStore({
|
|
498
|
+
name: 'App',
|
|
499
|
+
reducer: withHydration({ todos: todosSpec, ui: uiSpec }, hydration),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const stop = persist(store, { key: 'app', adapter, version: 3, slices: ['todos'] });
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Restoring *after* construction is the obvious alternative and the wrong one: applying a
|
|
506
|
+
snapshot to a live store emits a change across every path, which on boot is a flash, a burst
|
|
507
|
+
of instrumentation entries describing changes nobody made, and effects observing a transition
|
|
508
|
+
that never happened.
|
|
509
|
+
|
|
510
|
+
**Nothing throws on boot.** A missing, unparseable or unmigratable payload falls back to your
|
|
511
|
+
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
|
|
513
|
+
page, so write failures are reported the same way rather than raised.
|
|
514
|
+
|
|
515
|
+
**Version mismatches are refused, not trusted.** Reducers change, and a snapshot written
|
|
516
|
+
against an older shape may not be valid state for this build at all. Supply `migrate` to
|
|
517
|
+
upgrade it, or it is discarded.
|
|
518
|
+
|
|
519
|
+
Writes are driven by instrumentation, so a change confined to a slice you are not persisting
|
|
520
|
+
costs nothing, and a burst is coalesced into one write. `Map`, `Set`, `Date`, `BigInt`,
|
|
521
|
+
`undefined` and circular references all survive the round trip: `JSON.stringify` does not fail
|
|
522
|
+
on those, it silently destroys them.
|
|
523
|
+
|
|
524
|
+
For a server render, `dehydrate(store, { version })` produces the payload and
|
|
525
|
+
`hydrate({ source, version })` consumes it.
|
|
526
|
+
|
|
527
|
+
## Lists that reorder
|
|
528
|
+
|
|
529
|
+
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
|
|
531
|
+
diff correctly reports that nearly every leaf changed. Inserting one row at the front of a
|
|
532
|
+
thousand wakes a thousand subscribers.
|
|
533
|
+
|
|
534
|
+
That is honest rather than noisy: with positional paths the value at almost every index really
|
|
535
|
+
did change. The remedy is the shape of the state, not a diff that stays quiet.
|
|
536
|
+
|
|
537
|
+
```ts
|
|
538
|
+
import { createEntityAdapter } from '@yoltra/core';
|
|
539
|
+
|
|
540
|
+
const todos = createEntityAdapter<Todo>();
|
|
541
|
+
|
|
542
|
+
// state is { ids: [...], entities: { abc: {...} } }
|
|
543
|
+
todos.updateOne(state, { id: 'abc', changes: { done: true } });
|
|
544
|
+
|
|
545
|
+
// and the adapter hands out the paths, so they are never typed by hand
|
|
546
|
+
todos.pathTo('abc', 'title'); // "entities.abc.title"
|
|
547
|
+
todos.idsPath; // "ids"
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
`entities.abc.title` survives insert, remove and reorder. A list container subscribes to `ids`
|
|
551
|
+
and reorders its children; rows subscribe to their own entity and stay asleep through a sort.
|
|
552
|
+
|
|
553
|
+
`ids` is still an array, so a reorder still reports `ids.0`, `ids.1` and so on — that cost is
|
|
554
|
+
confined, not removed. What you get is cost proportional to what actually changed.
|
|
555
|
+
|
|
556
|
+
For a small list that only ever grows at the end, `items.0.title` is fine and simpler. The
|
|
557
|
+
adapter is for collections that reorder, or that are large enough for the difference to show.
|
|
558
|
+
|
|
559
|
+
### What it costs, measured
|
|
560
|
+
|
|
561
|
+
At 1000 rows, diffing after an insert at the front costs 1200 µs for an array and 371 µs
|
|
562
|
+
normalised, and the array reports roughly a thousand changed paths against two. That is the
|
|
563
|
+
case the adapter is for.
|
|
564
|
+
|
|
565
|
+
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
|
|
568
|
+
almost nothing in it moved. The numbers are in `benchmarks/`, and closing that gap is tracked
|
|
569
|
+
work rather than a property of normalising as such.
|
|
570
|
+
|
|
571
|
+
So: normalise collections that reorder or churn. A large collection that only ever has
|
|
572
|
+
individual fields edited is better off as an array today.
|
|
573
|
+
|
|
429
574
|
## Performance
|
|
430
575
|
|
|
431
|
-
| Metric | Value
|
|
432
|
-
| ------------------ |
|
|
433
|
-
| **Bundle size** |
|
|
434
|
-
| **Tree-shakeable** | Yes (ES modules)
|
|
435
|
-
| **Dependencies** | Zero
|
|
436
|
-
| **TypeScript** | Full type definitions included
|
|
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 |
|
|
582
|
+
|
|
583
|
+
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`.
|
|
586
|
+
|
|
587
|
+
The number that matters is what you import, not what the package exports:
|
|
588
|
+
|
|
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.
|
|
437
599
|
|
|
438
600
|
---
|
|
439
601
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalised collections, so a list stops paying O(N) for an O(1) change.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Path notification is positional for arrays. `detectChangedProps` walks indices and reports
|
|
6
|
+
* `items.0.title`, which names a *slot*, not a thing. So `unshift`, `splice(0, 1)` and `sort`
|
|
7
|
+
* move nearly every element into a different slot, and the diff correctly reports that nearly
|
|
8
|
+
* every leaf changed. Inserting one row at the front of a thousand wakes a thousand
|
|
9
|
+
* subscribers.
|
|
10
|
+
*
|
|
11
|
+
* The remedy is the state shape, not a quieter diff. A key-stable array diff would need an
|
|
12
|
+
* identity key the diff has no business knowing, and even then the *paths* would still be
|
|
13
|
+
* positional — `items.0.title` names position zero, and so does the RFC-6902 pointer the
|
|
14
|
+
* devtools agents build from it.
|
|
15
|
+
*
|
|
16
|
+
* Normalising to `{ ids, entities }` makes `entities.abc.title` stable across insert, remove
|
|
17
|
+
* and reorder.
|
|
18
|
+
*
|
|
19
|
+
* **What this does not do:** `ids` is still an array, so a reorder still reports `ids.0`,
|
|
20
|
+
* `ids.1` and so on. That cost is confined rather than removed. A list container subscribes to
|
|
21
|
+
* `ids` and reorders its children; rows subscribe to `entities.<id>.<field>` and stay asleep.
|
|
22
|
+
* The promise is cost proportional to what actually changed.
|
|
23
|
+
*
|
|
24
|
+
* @module @yoltra/core
|
|
25
|
+
*/
|
|
26
|
+
/** What an entity may be keyed by. */
|
|
27
|
+
export type EntityId = string | number;
|
|
28
|
+
/**
|
|
29
|
+
* A normalised collection.
|
|
30
|
+
*
|
|
31
|
+
* @typeParam T - The entity.
|
|
32
|
+
* @typeParam Id - Its key type.
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
export interface EntityState<T, Id extends EntityId = string> {
|
|
37
|
+
/** Order. Reordering touches this and nothing under `entities`. */
|
|
38
|
+
readonly ids: readonly Id[];
|
|
39
|
+
/** Identity-keyed, so a path to one entity survives every change to the others. */
|
|
40
|
+
readonly entities: Readonly<Record<Id, T>>;
|
|
41
|
+
}
|
|
42
|
+
/** A change to apply to one entity. */
|
|
43
|
+
export interface EntityUpdate<T, Id extends EntityId> {
|
|
44
|
+
readonly id: Id;
|
|
45
|
+
readonly changes: Partial<T>;
|
|
46
|
+
}
|
|
47
|
+
/** How an adapter identifies and orders its entities. */
|
|
48
|
+
export interface EntityAdapterOptions<T, Id extends EntityId> {
|
|
49
|
+
/** Defaults to reading `id`. */
|
|
50
|
+
readonly selectId?: (entity: T) => Id;
|
|
51
|
+
/**
|
|
52
|
+
* Keeps `ids` sorted.
|
|
53
|
+
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* Omit it and `ids` holds insertion order, which is cheaper: with a comparer, any change
|
|
56
|
+
* that could affect position re-sorts. The sorted array is only adopted when it actually
|
|
57
|
+
* differs, so a sort that changes nothing reports nothing.
|
|
58
|
+
*/
|
|
59
|
+
readonly sortComparer?: (a: T, b: T) => number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Reducer helpers, selectors, and the subscription paths that make the shape worth having.
|
|
63
|
+
*
|
|
64
|
+
* @public
|
|
65
|
+
*/
|
|
66
|
+
export interface EntityAdapter<T, Id extends EntityId = string> {
|
|
67
|
+
getInitialState(): EntityState<T, Id>;
|
|
68
|
+
getInitialState<Extra extends object>(extra: Extra): EntityState<T, Id> & Extra;
|
|
69
|
+
/** Adds an entity. Existing ids are left alone — this is not an upsert. */
|
|
70
|
+
addOne<S extends EntityState<T, Id>>(state: S, entity: T): S;
|
|
71
|
+
addMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[]): S;
|
|
72
|
+
/** Adds or replaces one entity wholesale. */
|
|
73
|
+
setOne<S extends EntityState<T, Id>>(state: S, entity: T): S;
|
|
74
|
+
setMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[]): S;
|
|
75
|
+
/** Replaces the whole collection. */
|
|
76
|
+
setAll<S extends EntityState<T, Id>>(state: S, entities: readonly T[]): S;
|
|
77
|
+
/** Merges `changes` into one entity. Unknown ids are ignored. */
|
|
78
|
+
updateOne<S extends EntityState<T, Id>>(state: S, update: EntityUpdate<T, Id>): S;
|
|
79
|
+
updateMany<S extends EntityState<T, Id>>(state: S, updates: readonly EntityUpdate<T, Id>[]): S;
|
|
80
|
+
/** Adds, or merges into an existing entity. */
|
|
81
|
+
upsertOne<S extends EntityState<T, Id>>(state: S, entity: T): S;
|
|
82
|
+
upsertMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[]): S;
|
|
83
|
+
removeOne<S extends EntityState<T, Id>>(state: S, id: Id): S;
|
|
84
|
+
removeMany<S extends EntityState<T, Id>>(state: S, ids: readonly Id[]): S;
|
|
85
|
+
removeAll<S extends EntityState<T, Id>>(state: S): S;
|
|
86
|
+
selectIds(state: EntityState<T, Id>): readonly Id[];
|
|
87
|
+
selectEntities(state: EntityState<T, Id>): Readonly<Record<Id, T>>;
|
|
88
|
+
selectAll(state: EntityState<T, Id>): readonly T[];
|
|
89
|
+
selectById(state: EntityState<T, Id>, id: Id): T | undefined;
|
|
90
|
+
selectTotal(state: EntityState<T, Id>): number;
|
|
91
|
+
/** Path to the order array. Subscribe here for a list that reorders. */
|
|
92
|
+
readonly idsPath: string;
|
|
93
|
+
/** Path to one entity, or to a field of it. */
|
|
94
|
+
pathTo(id: Id, field?: string): string;
|
|
95
|
+
/** Wildcard across every entity's `field`, for the loose subscription registry. */
|
|
96
|
+
anyField(field: string): string;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Builds an adapter for one entity type.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* const todos = createEntityAdapter<Todo>();
|
|
104
|
+
*
|
|
105
|
+
* const spec: ReducerSpec<EntityState<Todo>, EM> = {
|
|
106
|
+
* state: todos.getInitialState(),
|
|
107
|
+
* when: { keys: eventKeys<EM>()([['todos', 'toggled']]) },
|
|
108
|
+
* reducer: (state, event) =>
|
|
109
|
+
* todos.updateOne(state, { id: event.payload.id, changes: { done: event.payload.done } }),
|
|
110
|
+
* };
|
|
111
|
+
*
|
|
112
|
+
* // and in a component
|
|
113
|
+
* useAtomicProp({ reducer: 'todos', property: todos.pathTo(id, 'title') });
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
116
|
+
* @public
|
|
117
|
+
*/
|
|
118
|
+
export declare function createEntityAdapter<T, Id extends EntityId = string>(options?: EntityAdapterOptions<T, Id>): EntityAdapter<T, Id>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EventMapBase } from '../types';
|
|
1
|
+
import { Event, EventMapBase } from '../types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Minimal, synchronous pub/sub event bus keyed by **channel** and **type**.
|
|
4
4
|
*
|
|
@@ -53,7 +53,10 @@ export declare class EventBus<EM extends EventMapBase> {
|
|
|
53
53
|
* @typeParam T - Type key within channel `C` (must be a string key of `EM[C]`).
|
|
54
54
|
* @param channel - Channel name to subscribe to.
|
|
55
55
|
* @param type - Event type within the channel.
|
|
56
|
-
* @param handler - Function invoked with the payload type `EM[C][T]`.
|
|
56
|
+
* @param handler - Function invoked with the payload type `EM[C][T]`. It optionally
|
|
57
|
+
* receives the **source event** as a second argument when the emitter supplies one, so
|
|
58
|
+
* subscribers can read the true `id` (and any `meta`) instead of reconstructing an event
|
|
59
|
+
* from the payload alone. Handlers that declare only `payload` remain valid.
|
|
57
60
|
* @returns An **unsubscribe** function that removes this handler.
|
|
58
61
|
*
|
|
59
62
|
* @example
|
|
@@ -66,9 +69,16 @@ export declare class EventBus<EM extends EventMapBase> {
|
|
|
66
69
|
* off();
|
|
67
70
|
* ```
|
|
68
71
|
*
|
|
72
|
+
* @example Reading the source event
|
|
73
|
+
* ```ts
|
|
74
|
+
* bus.on('data', 'loaded', (payload, event) => {
|
|
75
|
+
* console.log('event id:', event?.id);
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
69
79
|
* @public
|
|
70
80
|
*/
|
|
71
|
-
on<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, handler: (payload: EM[C][T]) => void): () => void;
|
|
81
|
+
on<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, handler: (payload: EM[C][T], event?: Event<EM, C, T>) => void): () => void;
|
|
72
82
|
/**
|
|
73
83
|
* Removes a specific handler previously added with {@link EventBus.on | `on`}.
|
|
74
84
|
*
|
|
@@ -89,7 +99,7 @@ export declare class EventBus<EM extends EventMapBase> {
|
|
|
89
99
|
*
|
|
90
100
|
* @public
|
|
91
101
|
*/
|
|
92
|
-
off<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, handler: (payload: EM[C][T]) => void): void;
|
|
102
|
+
off<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, handler: (payload: EM[C][T], event?: Event<EM, C, T>) => void): void;
|
|
93
103
|
/**
|
|
94
104
|
* Emits an event to all subscribers of the exact `(channel, type)`.
|
|
95
105
|
*
|
|
@@ -101,6 +111,9 @@ export declare class EventBus<EM extends EventMapBase> {
|
|
|
101
111
|
* @param channel - Channel name to emit on.
|
|
102
112
|
* @param type - Event type to emit.
|
|
103
113
|
* @param payload - Payload matching `EM[C][T]`.
|
|
114
|
+
* @param event - Optional **source event**, forwarded to handlers as a second argument.
|
|
115
|
+
* Supply it whenever the caller already holds the real event so subscribers observe its
|
|
116
|
+
* true `id` rather than reconstructing one; omitting it keeps the original behaviour.
|
|
104
117
|
*
|
|
105
118
|
* @example
|
|
106
119
|
* ```ts
|
|
@@ -109,7 +122,7 @@ export declare class EventBus<EM extends EventMapBase> {
|
|
|
109
122
|
*
|
|
110
123
|
* @public
|
|
111
124
|
*/
|
|
112
|
-
emit<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, payload: EM[C][T]): void;
|
|
125
|
+
emit<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, payload: EM[C][T], event?: Event<EM, C, T>): void;
|
|
113
126
|
/**
|
|
114
127
|
* Clears **all** listeners across all channels/types.
|
|
115
128
|
*
|
|
@@ -1,45 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module @yoltra/core
|
|
3
3
|
*/
|
|
4
|
-
/**
|
|
5
|
-
* Flexible, synchronous pub/sub bus that supports **exact** and **pattern** event subscriptions.
|
|
6
|
-
*
|
|
7
|
-
* @typeParam C - Channel name type (defaults to `string`).
|
|
8
|
-
* @typeParam T - Event type name type (defaults to `string`). Types are treated as **dot-separated paths** (e.g. `"a.b.c"`).
|
|
9
|
-
* @typeParam P - Payload type for all events (defaults to `any`).
|
|
10
|
-
*
|
|
11
|
-
* @remarks
|
|
12
|
-
* - **Exact handlers** subscribe to a specific `(channel, type)` pair. Type keys are **normalized** by stripping a single leading dot (`".foo"` → `"foo"`).
|
|
13
|
-
* - **Pattern handlers** subscribe using wildcards over dot-separated segments:
|
|
14
|
-
* - `*` matches **one** segment.
|
|
15
|
-
* - `**` matches **zero or more** segments (greedy).
|
|
16
|
-
* - On {@link LooseEventBus.emit | `emit`}, exact handlers fire first, then any matching pattern handlers.
|
|
17
|
-
* - Handlers are **de-duplicated**: if the same function is both exact and pattern-registered, it is called **once**.
|
|
18
|
-
* - Handler invocation is **synchronous**. Exceptions are caught and logged; remaining handlers still run.
|
|
19
|
-
*
|
|
20
|
-
* @example
|
|
21
|
-
* ```ts
|
|
22
|
-
* type C = 'ui' | 'data';
|
|
23
|
-
* type T = string;
|
|
24
|
-
* type P = unknown;
|
|
25
|
-
*
|
|
26
|
-
* const bus = new LooseEventBus<C, T, P>();
|
|
27
|
-
*
|
|
28
|
-
* // Exact
|
|
29
|
-
* const offA = bus.on('ui', 'panel.open', () => console.log('panel opened'));
|
|
30
|
-
*
|
|
31
|
-
* // Patterns
|
|
32
|
-
* const offB = bus.on('ui', 'panel.*', () => console.log('any single sub-event under panel'));
|
|
33
|
-
* const offC = bus.on('ui', 'panel.**', () => console.log('any depth under panel'));
|
|
34
|
-
*
|
|
35
|
-
* bus.emit('ui', 'panel.open', null);
|
|
36
|
-
* // => exact fires, then 'panel.*', then 'panel.**'
|
|
37
|
-
*
|
|
38
|
-
* offA(); offB(); offC(); // unsubscribe
|
|
39
|
-
* ```
|
|
40
|
-
*
|
|
41
|
-
* @public
|
|
42
|
-
*/
|
|
43
4
|
export declare class LooseEventBus<C extends string = string, T extends string = string, P = any> {
|
|
44
5
|
/**
|
|
45
6
|
* Exact handlers: `channel → type → [handlers]`.
|
|
@@ -51,6 +12,24 @@ export declare class LooseEventBus<C extends string = string, T extends string =
|
|
|
51
12
|
* @internal
|
|
52
13
|
*/
|
|
53
14
|
private patternHandlers;
|
|
15
|
+
/**
|
|
16
|
+
* Patterns bucketed by their first segment, so an emit tests only what could match.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* Delivery used to walk every pattern registered on the channel and run the full segment
|
|
20
|
+
* matcher against each. That is linear in the number of patterns rather than in the number
|
|
21
|
+
* that match, and it re-split both the pattern and the subject on every test — for a thousand
|
|
22
|
+
* patterns, two thousand string splits to deliver one event.
|
|
23
|
+
*
|
|
24
|
+
* A subject's first segment can only be matched by a pattern whose first segment is that same
|
|
25
|
+
* literal, or is `*` or `**`. Bucketing on that turns the common shape — distinct event
|
|
26
|
+
* families like `panel.*` and `order.**` — from a scan of everything into a map lookup plus
|
|
27
|
+
* the handful that begin with a wildcard.
|
|
28
|
+
*
|
|
29
|
+
* It buys nothing for a channel where every pattern starts with `**`, since all of those must
|
|
30
|
+
* still be tested. That is the honest worst case, and it is unchanged rather than worsened.
|
|
31
|
+
*/
|
|
32
|
+
private patternIndex;
|
|
54
33
|
/**
|
|
55
34
|
* Subscribes a handler to either an **exact** type or a **pattern**.
|
|
56
35
|
*
|
|
@@ -142,6 +121,23 @@ export declare class LooseEventBus<C extends string = string, T extends string =
|
|
|
142
121
|
* @public
|
|
143
122
|
*/
|
|
144
123
|
emit(channel: C, type: T, payload: P): void;
|
|
124
|
+
/**
|
|
125
|
+
* Emits a payload that is only built if somebody is listening.
|
|
126
|
+
*
|
|
127
|
+
* @param channel - Channel to emit on.
|
|
128
|
+
* @param type - Concrete event type.
|
|
129
|
+
* @param make - Builds the payload. Called at most once, and only when a handler matched.
|
|
130
|
+
*
|
|
131
|
+
* @remarks
|
|
132
|
+
* Same matching as {@link LooseEventBus.emit}; the difference is *when* the payload exists.
|
|
133
|
+
* The store's change notification carries the old and new value at a path, and reading those
|
|
134
|
+
* means walking the state tree twice per path. Doing that eagerly meant a slice nobody had
|
|
135
|
+
* subscribed to paid the full cost of describing changes to an audience of nobody — the
|
|
136
|
+
* matching work was already being done to discover there were no handlers.
|
|
137
|
+
*
|
|
138
|
+
* @public
|
|
139
|
+
*/
|
|
140
|
+
emitWith(channel: C, type: T, make: () => P): void;
|
|
145
141
|
/**
|
|
146
142
|
* Determines if a string is a **pattern** (contains `*`).
|
|
147
143
|
* @param s - Event type or pattern string.
|
|
@@ -169,29 +165,56 @@ export declare class LooseEventBus<C extends string = string, T extends string =
|
|
|
169
165
|
*/
|
|
170
166
|
private splitPath;
|
|
171
167
|
/**
|
|
172
|
-
*
|
|
168
|
+
* Files a pattern under the first segment that could select it.
|
|
169
|
+
* @internal
|
|
170
|
+
*/
|
|
171
|
+
private indexPattern;
|
|
172
|
+
/**
|
|
173
|
+
* Removes a pattern from the index. Paired with {@link LooseEventBus.offPattern}.
|
|
174
|
+
* @internal
|
|
175
|
+
*/
|
|
176
|
+
private unindexPattern;
|
|
177
|
+
/**
|
|
178
|
+
* The handler lists of every pattern matching this subject.
|
|
179
|
+
*
|
|
180
|
+
* @remarks
|
|
181
|
+
* Shared by `emit` and `emitWith` so the two cannot drift on what "matching" means — which
|
|
182
|
+
* they could, being two copies of the same walk before.
|
|
183
|
+
*
|
|
184
|
+
* The subject is split once here rather than once per pattern tested.
|
|
185
|
+
*
|
|
186
|
+
* @internal
|
|
187
|
+
*/
|
|
188
|
+
private matchingPatternHandlers;
|
|
189
|
+
/**
|
|
190
|
+
* Pattern matcher over dot-separated segments, which arrive already split.
|
|
173
191
|
*
|
|
174
192
|
* Rules:
|
|
175
193
|
* - **literal**: exact match.
|
|
176
194
|
* - `*` : matches exactly **one** segment.
|
|
177
195
|
* - `**` : matches **zero or more** remaining segments (including empty).
|
|
178
196
|
*
|
|
179
|
-
* @
|
|
180
|
-
*
|
|
181
|
-
*
|
|
197
|
+
* @remarks
|
|
198
|
+
* Takes segments rather than strings so delivery can split each pattern once at registration
|
|
199
|
+
* and the subject once per emit, instead of both once per test. Re-splitting per test was most
|
|
200
|
+
* of what made wildcard delivery expensive: a thousand patterns meant two thousand string
|
|
201
|
+
* splits to deliver one event.
|
|
202
|
+
*
|
|
203
|
+
* @param pSegs - Pattern segments (may include `*`/`**`).
|
|
204
|
+
* @param sSegs - Subject segments to test.
|
|
205
|
+
* @returns `true` if the pattern matches; otherwise `false`.
|
|
182
206
|
*
|
|
183
207
|
* @example
|
|
184
208
|
* ```ts
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* matchPattern('**.end', 'x.y.end') // true
|
|
209
|
+
* matchSegments(['a', '*'], ['a', 'b']) // true
|
|
210
|
+
* matchSegments(['a', '*'], ['a', 'b', 'c']) // false
|
|
211
|
+
* matchSegments(['a', '**'], ['a']) // true
|
|
212
|
+
* matchSegments(['**', 'end'], ['x', 'y', 'end']) // true
|
|
190
213
|
* ```
|
|
191
214
|
*
|
|
192
215
|
* @internal
|
|
193
216
|
*/
|
|
194
|
-
private
|
|
217
|
+
private matchSegments;
|
|
195
218
|
/**
|
|
196
219
|
* Removes **all** listeners (exact and pattern). Useful for tests/HMR teardown.
|
|
197
220
|
*
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from './EventBus';
|
|
2
|
-
export * from './LooseEventBus';
|
|
1
|
+
export * from './EventBus.js';
|
|
2
|
+
export * from './LooseEventBus.js';
|
package/dist/types/index.d.ts
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @packageDocumentation
|
|
7
7
|
*/
|
|
8
|
-
export { EventBus } from './eventBus/EventBus';
|
|
9
|
-
export { LooseEventBus } from './eventBus/LooseEventBus';
|
|
10
|
-
export { Reducer } from './reducer/Reducer';
|
|
11
|
-
export { Store, createStore, typedEvents } from './store/Store';
|
|
12
|
-
export { detectChangedProps } from './utils/detectChangedProps';
|
|
13
|
-
export { freezeState } from './utils/immutability';
|
|
14
|
-
export { eventKeys } from './types';
|
|
15
|
-
export type { EventMapBase, EventKey, Event, EventUnion, Change, Emit, EmitOptions, InstrumentedEvent, InstrumentationObserver, Unsubscribe, StoreSpec, StoreInstance, ReducerSpec, ReducerFunction, ReducersMapAny, StateFromReducers, EMFromReducersStrict, EffectSpec, EffectFunction, MiddlewareFunction, MiddlewareSpec, MiddlewareInput, DeepReadonly, DeepRO, Primitive, Path, PathValue, WithGlob, Dotted, EventPhase, EventSubscriptionHandler, NarrowedEventHandler, When, EventFromWhen, EventConsumerType, EventConsumerMeta, } from './types';
|
|
8
|
+
export { EventBus } from './eventBus/EventBus.js';
|
|
9
|
+
export { LooseEventBus } from './eventBus/LooseEventBus.js';
|
|
10
|
+
export { Reducer } from './reducer/Reducer.js';
|
|
11
|
+
export { Store, createStore, typedEvents } from './store/Store.js';
|
|
12
|
+
export { detectChangedProps } from './utils/detectChangedProps.js';
|
|
13
|
+
export { freezeState } from './utils/immutability.js';
|
|
14
|
+
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';
|
|
16
|
+
export { createEntityAdapter } from './entity/entityAdapter.js';
|
|
17
|
+
export type { EntityAdapter, EntityAdapterOptions, EntityId, EntityState, EntityUpdate, } from './entity/entityAdapter.js';
|
|
18
|
+
export { decodeState, encodeState, encodeStateBounded } from './serialize/codec.js';
|
|
19
|
+
export type { BoundedEncodeResult, EncodeOptions, EncodeReport, EncodeResult, } from './serialize/codec.js';
|
|
20
|
+
export { dehydrate, hydrate, persist, withHydration } from './persistence/persist.js';
|
|
21
|
+
export type { Hydration, PersistableStore, PersistenceAdapter, PersistencePhase, PersistOptions, } from './persistence/persist.js';
|
|
22
|
+
export { createMemoryAdapter, createWebStorageAdapter } from './persistence/adapters.js';
|
|
23
|
+
export type { WebStorageLike } from './persistence/adapters.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { PersistenceAdapter } from './persist.js';
|
|
2
|
+
/** The slice of the Web Storage API used here. */
|
|
3
|
+
export interface WebStorageLike {
|
|
4
|
+
getItem(key: string): string | null;
|
|
5
|
+
setItem(key: string, value: string): void;
|
|
6
|
+
removeItem(key: string): void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Wraps a Web Storage object.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Pass `localStorage` or `sessionStorage` explicitly. Reading the global here would make this
|
|
13
|
+
* module unusable anywhere one does not exist, which includes a server render — exactly where
|
|
14
|
+
* hydration payloads are produced.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const adapter = createWebStorageAdapter(localStorage);
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
export declare function createWebStorageAdapter(storage: WebStorageLike): PersistenceAdapter;
|
|
24
|
+
/**
|
|
25
|
+
* Keeps state in memory.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* For tests, and for a server render that wants the persistence path exercised without a
|
|
29
|
+
* store behind it. It forgets on restart, which is the whole of what it claims.
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
*/
|
|
33
|
+
export declare function createMemoryAdapter(initial?: Record<string, string>): PersistenceAdapter;
|