@yoltra/core 0.3.0 → 0.4.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 CHANGED
@@ -1,4 +1,4 @@
1
- ![yoltra logo](../../assets/yoltra-logo.png)
1
+ ![Yoltra logo](https://yoltra.dev/assets/yoltra-logo.png)
2
2
 
3
3
  # @yoltra/core
4
4
 
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![yoltra logo](../../assets/yoltra-logo.png)
1
+ ![Yoltra logo](https://yoltra.dev/assets/yoltra-logo.png)
2
2
 
3
3
  # @yoltra/core
4
4
 
@@ -426,14 +426,119 @@ store.registerEffect({
426
426
 
427
427
  ---
428
428
 
429
+ ## Saving and restoring state
430
+
431
+ Two functions, because the halves happen on opposite sides of the store's existence.
432
+ `hydrate` produces *initial slice state*, so the store is born with it:
433
+
434
+ ```ts
435
+ import { createStore, createWebStorageAdapter, hydrate, persist, withHydration } from '@yoltra/core';
436
+
437
+ const adapter = createWebStorageAdapter(localStorage);
438
+ const hydration = await hydrate({ key: 'app', adapter, version: 3 });
439
+
440
+ const store = createStore({
441
+ name: 'App',
442
+ reducer: withHydration({ todos: todosSpec, ui: uiSpec }, hydration),
443
+ });
444
+
445
+ const stop = persist(store, { key: 'app', adapter, version: 3, slices: ['todos'] });
446
+ ```
447
+
448
+ Restoring *after* construction is the obvious alternative and the wrong one: applying a
449
+ snapshot to a live store emits a change across every path, which on boot is a flash, a burst
450
+ of instrumentation entries describing changes nobody made, and effects observing a transition
451
+ that never happened.
452
+
453
+ **Nothing throws on boot.** A missing, unparseable or unmigratable payload falls back to your
454
+ declared defaults and reports through `onError`. A store that will not start because storage
455
+ holds stale JSON is worse than one that starts fresh — and a full disk should not take down a
456
+ page, so write failures are reported the same way rather than raised.
457
+
458
+ **Version mismatches are refused, not trusted.** Reducers change, and a snapshot written
459
+ against an older shape may not be valid state for this build at all. Supply `migrate` to
460
+ upgrade it, or it is discarded.
461
+
462
+ Writes are driven by instrumentation, so a change confined to a slice you are not persisting
463
+ costs nothing, and a burst is coalesced into one write. `Map`, `Set`, `Date`, `BigInt`,
464
+ `undefined` and circular references all survive the round trip: `JSON.stringify` does not fail
465
+ on those, it silently destroys them.
466
+
467
+ For a server render, `dehydrate(store, { version })` produces the payload and
468
+ `hydrate({ source, version })` consumes it.
469
+
470
+ ## Lists that reorder
471
+
472
+ Path notification is positional for arrays. `items.0.title` names a *slot*, not a thing, so
473
+ `unshift`, `splice(0, 1)` and `sort` move nearly every element into a different slot — and the
474
+ diff correctly reports that nearly every leaf changed. Inserting one row at the front of a
475
+ thousand wakes a thousand subscribers.
476
+
477
+ That is honest rather than noisy: with positional paths the value at almost every index really
478
+ did change. The remedy is the shape of the state, not a diff that stays quiet.
479
+
480
+ ```ts
481
+ import { createEntityAdapter } from '@yoltra/core';
482
+
483
+ const todos = createEntityAdapter<Todo>();
484
+
485
+ // state is { ids: [...], entities: { abc: {...} } }
486
+ todos.updateOne(state, { id: 'abc', changes: { done: true } });
487
+
488
+ // and the adapter hands out the paths, so they are never typed by hand
489
+ todos.pathTo('abc', 'title'); // "entities.abc.title"
490
+ todos.idsPath; // "ids"
491
+ ```
492
+
493
+ `entities.abc.title` survives insert, remove and reorder. A list container subscribes to `ids`
494
+ and reorders its children; rows subscribe to their own entity and stay asleep through a sort.
495
+
496
+ `ids` is still an array, so a reorder still reports `ids.0`, `ids.1` and so on — that cost is
497
+ confined, not removed. What you get is cost proportional to what actually changed.
498
+
499
+ For a small list that only ever grows at the end, `items.0.title` is fine and simpler. The
500
+ adapter is for collections that reorder, or that are large enough for the difference to show.
501
+
502
+ ### What it costs, measured
503
+
504
+ At 1000 rows, diffing after an insert at the front costs 1200 µs for an array and 371 µs
505
+ normalised, and the array reports roughly a thousand changed paths against two. That is the
506
+ case the adapter is for.
507
+
508
+ A single-field update runs the other way: 20 µs for the array against 470 µs normalised.
509
+ `detectChangedProps` indexes an array but enumerates an object's keys — building two key
510
+ arrays and a `Set` per comparison — so a wide entity map is more expensive to walk even when
511
+ almost nothing in it moved. The numbers are in `benchmarks/`, and closing that gap is tracked
512
+ work rather than a property of normalising as such.
513
+
514
+ So: normalise collections that reorder or churn. A large collection that only ever has
515
+ individual fields edited is better off as an array today.
516
+
429
517
  ## Performance
430
518
 
431
- | Metric | Value |
432
- | ------------------ | ------------------------------ |
433
- | **Bundle size** | ~8KB (minified + gzipped) |
434
- | **Tree-shakeable** | Yes (ES modules) |
435
- | **Dependencies** | Zero |
436
- | **TypeScript** | Full type definitions included |
519
+ | Metric | Value |
520
+ | ------------------ | ----------------------------------------- |
521
+ | **Bundle size** | 6.7 KB for the store (minified + gzipped) |
522
+ | **Tree-shakeable** | Yes (ES modules) |
523
+ | **Dependencies** | Zero |
524
+ | **TypeScript** | Full type definitions included |
525
+
526
+ Bundle size is checked, not asserted: `rush size` bundles the package the way a consumer
527
+ would — tree-shaken, minified, gzipped — and fails when it exceeds the budget declared in
528
+ `package.json`.
529
+
530
+ The number that matters is what you import, not what the package exports:
531
+
532
+ | Import | Size |
533
+ | ----------------------------------- | ------ |
534
+ | `{ createStore }` | 6.7 KB |
535
+ | `{ createStore, hydrate, persist }` | 8.2 KB |
536
+ | everything | 9.5 KB |
537
+
538
+ Persistence and the entity adapter cost nothing to anyone who does not import them — the
539
+ first row has not moved as either was added, which is the tree-shaking claim being checked
540
+ rather than repeated. The last row is a growth tripwire; `import * as all` is not something
541
+ anybody writes.
437
542
 
438
543
  ---
439
544
 
@@ -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';
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
- * Pattern matcher over dot-separated segments.
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
- * @param pattern - Pattern (may include `*`/`**`).
180
- * @param path - Subject event type key to test.
181
- * @returns `true` if the pattern matches the path; otherwise `false`.
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
- * matchPattern('a.*', 'a.b') // true
186
- * matchPattern('a.*', 'a.b.c') // false
187
- * matchPattern('a.**', 'a') // true
188
- * matchPattern('a.**', 'a.b.c') // true
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 matchPattern;
217
+ private matchSegments;
195
218
  /**
196
219
  * Removes **all** listeners (exact and pattern). Useful for tests/HMR teardown.
197
220
  *
@@ -12,4 +12,12 @@ export { Store, createStore, typedEvents } from './store/Store';
12
12
  export { detectChangedProps } from './utils/detectChangedProps';
13
13
  export { freezeState } from './utils/immutability';
14
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';
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, Path, PathValue, WithGlob, Dotted, EventPhase, EventSubscriptionHandler, NarrowedEventHandler, When, EventFromWhen, EventConsumerType, EventConsumerMeta, } from './types';
16
+ export { createEntityAdapter } from './entity/entityAdapter';
17
+ export type { EntityAdapter, EntityAdapterOptions, EntityId, EntityState, EntityUpdate, } from './entity/entityAdapter';
18
+ export { decodeState, encodeState, encodeStateBounded } from './serialize/codec';
19
+ export type { BoundedEncodeResult, EncodeOptions, EncodeReport, EncodeResult, } from './serialize/codec';
20
+ export { dehydrate, hydrate, persist, withHydration } from './persistence/persist';
21
+ export type { Hydration, PersistableStore, PersistenceAdapter, PersistencePhase, PersistOptions, } from './persistence/persist';
22
+ export { createMemoryAdapter, createWebStorageAdapter } from './persistence/adapters';
23
+ export type { WebStorageLike } from './persistence/adapters';
@@ -0,0 +1,33 @@
1
+ import { PersistenceAdapter } from './persist';
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;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Saving state, and starting from saved state.
3
+ *
4
+ * @remarks
5
+ * The two halves happen on opposite sides of the store's existence, which is why this is two
6
+ * functions rather than one. {@link hydrate} produces *initial slice state*, so the store is
7
+ * born hydrated; {@link persist} subscribes to a store that already exists.
8
+ *
9
+ * Restoring after construction is the obvious alternative and the wrong one. It means applying
10
+ * a whole-state snapshot to a live store, which emits a change across every path: a visible
11
+ * flash on boot, a burst of instrumentation entries describing changes nobody made, and
12
+ * effects observing a transition that never happened.
13
+ *
14
+ * @module @yoltra/core
15
+ */
16
+ /** Where persisted state lives. Bring your own; core imports no platform global. */
17
+ export interface PersistenceAdapter {
18
+ read(key: string): string | null | Promise<string | null>;
19
+ write(key: string, value: string): void | Promise<void>;
20
+ remove(key: string): void | Promise<void>;
21
+ }
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";
24
+ /** Shared configuration. */
25
+ export interface PersistOptions {
26
+ /** Storage key. */
27
+ readonly key: string;
28
+ readonly adapter: PersistenceAdapter;
29
+ /**
30
+ * Schema version of what is written.
31
+ *
32
+ * @remarks
33
+ * Compared on read. A mismatch is handed to {@link PersistOptions.migrate}, and without one
34
+ * the stored value is discarded rather than trusted — reducers change, and a snapshot
35
+ * written against an older shape is not merely stale, it may not be valid state at all.
36
+ */
37
+ readonly version: number;
38
+ /** Slices to persist. Every slice by default. */
39
+ readonly slices?: readonly string[];
40
+ /** Coalescing window for writes, in milliseconds. Defaults to 250. */
41
+ readonly throttleMs?: number;
42
+ /**
43
+ * Upgrades a payload written by an older version.
44
+ *
45
+ * @returns The slices to restore, or `null` to start fresh.
46
+ */
47
+ readonly migrate?: (persisted: unknown, from: number) => Record<string, unknown> | null;
48
+ /**
49
+ * Called on any failure.
50
+ *
51
+ * @remarks
52
+ * Persistence never throws into the application it is persisting. A store that will not
53
+ * start because storage holds stale JSON is worse than one that starts fresh, and a full
54
+ * disk should not take down a page.
55
+ */
56
+ readonly onError?: (error: unknown, phase: PersistencePhase) => void;
57
+ }
58
+ /** What {@link hydrate} recovered. */
59
+ export interface Hydration {
60
+ /** Slice states to start from. Empty when there was nothing usable to restore. */
61
+ readonly slices: Readonly<Record<string, unknown>>;
62
+ /** `true` when a payload was found, decoded and accepted. */
63
+ readonly restored: boolean;
64
+ }
65
+ /**
66
+ * Reads persisted state, ready to seed a store.
67
+ *
68
+ * @remarks
69
+ * Every read-side failure — missing, unparseable, wrong version with no migration, a
70
+ * migration that declines — resolves to "nothing to restore" and reports through
71
+ * {@link PersistOptions.onError}. Nothing throws.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * const hydration = await hydrate({ key: 'app', adapter, version: 3 });
76
+ * const store = createStore({
77
+ * name: 'App',
78
+ * reducer: withHydration({ todos: todosSpec }, hydration),
79
+ * });
80
+ * ```
81
+ *
82
+ * @public
83
+ */
84
+ export declare function hydrate(options: PersistOptions & {
85
+ readonly source?: string;
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
+ /**
92
+ * Replaces each reducer's initial state with what was restored for it.
93
+ *
94
+ * @remarks
95
+ * Slices absent from the payload keep their declared defaults, so adding a reducer does not
96
+ * invalidate everything written before it existed.
97
+ *
98
+ * @public
99
+ */
100
+ export declare function withHydration<R extends Record<string, HasState>>(reducers: R, hydration: Hydration): R;
101
+ /** The store surface persistence needs, which is two methods wide. */
102
+ export interface PersistableStore {
103
+ getState(): unknown;
104
+ instrument(observer: (info: {
105
+ changedPaths?: readonly string[];
106
+ }) => void): () => void;
107
+ }
108
+ /**
109
+ * Writes state as it changes.
110
+ *
111
+ * @returns A function that stops persisting and flushes anything pending.
112
+ *
113
+ * @remarks
114
+ * Driven by `instrument` rather than the coarse subscription, so a change confined to a slice
115
+ * that is not persisted costs nothing at all. Writes are coalesced on the trailing edge.
116
+ *
117
+ * @public
118
+ */
119
+ export declare function persist(store: PersistableStore, options: PersistOptions): () => void;
120
+ /**
121
+ * Serializes a store for handoff, for example from a server render to the client.
122
+ *
123
+ * @public
124
+ */
125
+ export declare function dehydrate(store: Pick<PersistableStore, "getState">, options: Pick<PersistOptions, "version" | "slices">): string;
126
+ export {};