@yoltra/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,468 @@
1
+ ![yoltra logo](../../assets/yoltra-logo.png)
2
+
3
+ # @yoltra/core
4
+
5
+ > [ πŸ‡²πŸ‡½ VersiΓ³n en EspaΓ±ol](https://github.com/yoltra/yoltra/blob/main/packages/core/README.es.md) 
6
+ > |   πŸ‘‰ πŸ‡ΊπŸ‡Έ English Version
7
+
8
+ ![npm downloads](https://badgen.net/npm/dm/@yoltra/core)
9
+ ![License](https://badgen.net/npm/license/@yoltra/core)
10
+
11
+ **Framework-agnostic event-driven state container with fine-grained path subscriptions.**
12
+
13
+ `@yoltra/core` is the foundation of
14
+ [yoltra](https://github.com/yoltra/yoltra/blob/main/README.md). It provides the store, event
15
+ pipeline, middleware, effects, and the `connect()` subscription system. Zero framework
16
+ dependencies.
17
+
18
+ ---
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install @yoltra/core
24
+ ```
25
+
26
+ ---
27
+
28
+ ## The Event Pipeline
29
+
30
+ Every `emit()` call flows through a deterministic pipeline:
31
+
32
+ ```
33
+ emit(channel, type, payload)
34
+ β”‚
35
+ β”œβ”€ 1. Dedup ─── Skip if identical fingerprint within time window
36
+ β”‚
37
+ β”œβ”€ 2. Middleware ─── Pre-reducer hooks (can reject β†’ "uncommitted" event)
38
+ β”‚
39
+ β”œβ”€ 3. Reducers ─── Synchronous state updates, fine-grained path change detection
40
+ β”‚
41
+ β”œβ”€ 4. Event subscribers ─── Committed/uncommitted event notifications
42
+ β”‚
43
+ β”œβ”€ 5. Effects ─── Async side-effects (post-reducer, keyed for O(1) lookup)
44
+ β”‚
45
+ └─ 6. Coarse subscribers ─── External store listeners (useSyncExternalStore, etc.)
46
+ ```
47
+
48
+ Every stage is hook-able. Middleware can cancel events, creating "uncommitted" events that the
49
+ UI can still react to. Effects run after reducers and see the final state.
50
+
51
+ ---
52
+
53
+ ## Core Concepts
54
+
55
+ ### Channel-based events
56
+
57
+ Events are `(channel, type, payload)` tuples. Channels provide natural namespacing that scales
58
+ in large codebases:
59
+
60
+ ```typescript
61
+ await store.emit("auth", "login", credentials);
62
+ await store.emit("analytics", "track", { event: "page_view" });
63
+ await store.emit("ui", "toast", { message: "Saved!" });
64
+ ```
65
+
66
+ ### Fine-grained subscriptions via `connect()`
67
+
68
+ Subscribe to exact state paths using dotted notation. Supports `*` (one segment) and `**` (zero
69
+ or more segments) wildcards:
70
+
71
+ ```typescript
72
+ // Exact path β€” fires when items[0].title changes
73
+ store.connect({ reducer: "todos", property: "items.0.title" }, (change) =>
74
+ console.log("title:", change.oldValue, "β†’", change.newValue),
75
+ );
76
+
77
+ // Single-segment wildcard β€” fires when ANY item's title changes
78
+ store.connect({ reducer: "todos", property: "items.*.title" }, (change) =>
79
+ console.log("some title changed at", change.path),
80
+ );
81
+
82
+ // Deep wildcard β€” fires when anything under items changes
83
+ store.connect({ reducer: "todos", property: "items.**" }, (change) =>
84
+ console.log("items tree changed at", change.path),
85
+ );
86
+ ```
87
+
88
+ ### Immutability
89
+
90
+ State is deep-frozen before committing. Mutations throw in strict mode:
91
+
92
+ ```typescript
93
+ const state = store.getState();
94
+ state.counter.value = 999; // TypeError: Cannot assign to read-only property
95
+ ```
96
+
97
+ ---
98
+
99
+ ## Event Targeting with `When` Matchers
100
+
101
+ Reducers, effects, and middleware use a unified `When` matcher to declare which events they
102
+ respond to:
103
+
104
+ ```typescript
105
+ import { createStore, eventKeys } from "@yoltra/core";
106
+
107
+ type AppEM = {
108
+ ui: { increment: number; decrement: number; reset: void };
109
+ admin: { setCounter: number };
110
+ system: { init: void; shutdown: void };
111
+ };
112
+
113
+ // Match specific event keys (recommended β€” preserves type correlation)
114
+ const counterReducer = {
115
+ state: { value: 0 },
116
+ when: {
117
+ keys: eventKeys<AppEM>()([
118
+ ["ui", "increment"],
119
+ ["ui", "decrement"],
120
+ ]),
121
+ },
122
+ reducer: (state, event) => {
123
+ if (event.type === "increment") return { value: state.value + event.payload };
124
+ if (event.type === "decrement") return { value: state.value - event.payload };
125
+ return state;
126
+ },
127
+ };
128
+
129
+ // Match all events in a channel
130
+ const uiLogger = {
131
+ when: { channel: "ui" },
132
+ effect: (event) => console.log("UI event:", event.type),
133
+ };
134
+
135
+ // Match events across multiple channels
136
+ const auditTrail = {
137
+ when: { channels: ["ui", "admin"] },
138
+ effect: (event) => logToAuditTrail(event),
139
+ };
140
+
141
+ // Match ALL events
142
+ const globalLogger = {
143
+ when: { any: true },
144
+ middleware: (state, event) => {
145
+ console.log(`[${event.channel}] ${event.type}`);
146
+ return true;
147
+ },
148
+ };
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Middleware
154
+
155
+ Middleware runs **before** reducers and can cancel event propagation. Supports both raw
156
+ functions (legacy) and `MiddlewareSpec` objects with targeting:
157
+
158
+ ```typescript
159
+ import type { MiddlewareSpec } from "@yoltra/core";
160
+
161
+ // Targeted middleware β€” only runs for admin channel events
162
+ const adminGuard: MiddlewareSpec<AppState, AppEM> = {
163
+ when: { channel: "admin" },
164
+ middleware: (state, event) => {
165
+ if (!state.auth.isAdmin) return false; // Reject β†’ creates "uncommitted" event
166
+ return true;
167
+ },
168
+ meta: { type: "middleware", name: "adminGuard" },
169
+ };
170
+
171
+ // Global middleware β€” runs for all events
172
+ const logger = async (state, event, emit) => {
173
+ console.log("Event:", event.channel, event.type);
174
+ return true;
175
+ };
176
+
177
+ const store = createStore({
178
+ name: "App",
179
+ reducer: {
180
+ /* ... */
181
+ },
182
+ middleware: [adminGuard, logger],
183
+ });
184
+ ```
185
+
186
+ ### Dynamic middleware
187
+
188
+ ```typescript
189
+ const off = store.registerMiddleware(async (state, event) => {
190
+ return event.type !== "forbidden";
191
+ });
192
+ off(); // Remove later
193
+ ```
194
+
195
+ ---
196
+
197
+ ## Effects
198
+
199
+ Effects run **after** reducers and see the final state. They are keyed by event for O(1) lookup:
200
+
201
+ ```typescript
202
+ // Via store spec
203
+ const store = createStore({
204
+ name: "App",
205
+ reducer: {
206
+ /* ... */
207
+ },
208
+ effects: [
209
+ {
210
+ when: {
211
+ keys: eventKeys<AppEM>()([
212
+ ["todos", "add"],
213
+ ["todos", "delete"],
214
+ ]),
215
+ },
216
+ effect: async (event, getState, emit) => {
217
+ await saveToServer(getState());
218
+ },
219
+ meta: { type: "effect", name: "syncToServer" },
220
+ },
221
+ ],
222
+ });
223
+
224
+ // Dynamic registration
225
+ const off = store.registerEffect({
226
+ when: { channel: "analytics" },
227
+ effect: async (event) => sendToAnalytics(event),
228
+ });
229
+
230
+ // Convenience helper for single event
231
+ const off2 = store.onEffect("ui", "save", async (payload, getState, emit) => {
232
+ await saveToCloud(payload);
233
+ });
234
+ ```
235
+
236
+ ---
237
+
238
+ ## Event Subscriptions
239
+
240
+ Subscribe to events (not state) from the view layer. Useful for notifications, animations, and
241
+ responding to rejected events:
242
+
243
+ ```typescript
244
+ // Committed events (default) β€” events that passed middleware
245
+ const off = store.onEvent("ui", "save", (event, getState, emit, phase) => {
246
+ console.log("Save committed:", event.payload);
247
+ });
248
+
249
+ // Uncommitted events β€” events rejected by middleware
250
+ store.onEvent(
251
+ "ui",
252
+ "delete",
253
+ (event, getState, emit, phase) => {
254
+ console.log("Delete was rejected");
255
+ },
256
+ "uncommitted",
257
+ );
258
+
259
+ // All events β€” both committed and uncommitted
260
+ store.onEvent(
261
+ "ui",
262
+ "action",
263
+ (event, getState, emit, phase) => {
264
+ console.log(`Action ${phase}:`, event.type);
265
+ },
266
+ "all",
267
+ );
268
+ ```
269
+
270
+ ---
271
+
272
+ ## Event Deduplication
273
+
274
+ Yoltra automatically deduplicates identical events within a configurable time window. This
275
+ prevents double-processing in React Strict Mode:
276
+
277
+ ```typescript
278
+ const store = createStore({
279
+ name: "Yoltra_Rocks",
280
+ reducer: {
281
+ /* ... */
282
+ },
283
+ dedupWindowMs: 100, // default: 50ms dev, 100ms prod
284
+ });
285
+ ```
286
+
287
+ ---
288
+
289
+ ## Dynamic Reducers
290
+
291
+ Add or remove reducer slices at runtime:
292
+
293
+ ```typescript
294
+ const dispose = store.registerReducer("filters", {
295
+ state: { q: "" },
296
+ when: { keys: eventKeys<AppEM>()([["ui", "setQuery"]]) },
297
+ reducer: (state, event) => (event.type === "setQuery" ? { q: event.payload } : state),
298
+ });
299
+
300
+ // Later: remove the slice and its state
301
+ dispose();
302
+ ```
303
+
304
+ ---
305
+
306
+ ## Hot Module Replacement
307
+
308
+ ```typescript
309
+ if (import.meta.hot) {
310
+ import.meta.hot.accept("./reducers", (mod) => {
311
+ store.replaceReducers(mod.reducers, { preserveState: true });
312
+ });
313
+
314
+ import.meta.hot.accept("./middleware", (mod) => {
315
+ store.replaceMiddleware(mod.middleware);
316
+ });
317
+
318
+ import.meta.hot.accept("./effects", (mod) => {
319
+ store.replaceEffects(mod.effects);
320
+ });
321
+
322
+ // Or replace everything at once
323
+ store.hotReplace({
324
+ reducer: newReducers,
325
+ middleware: newMiddleware,
326
+ effects: newEffects,
327
+ preserveState: true,
328
+ });
329
+ }
330
+ ```
331
+
332
+ ---
333
+
334
+ ## Best Practices
335
+
336
+ ### Always await `emit()`
337
+
338
+ ```typescript
339
+ await emit("todo", "add", todo);
340
+ const state = store.getState(); // Guaranteed to reflect the new todo
341
+ ```
342
+
343
+ ### Keep reducers fast
344
+
345
+ Reducers are synchronous and block the event queue. Move expensive work to effects:
346
+
347
+ ```typescript
348
+ // Reducer: just set a loading flag
349
+ reducer: ((state, event) => ({ ...state, loading: true }),
350
+ // Effect: do the heavy lifting
351
+ store.onEffect("data", "compute", async (payload, getState, emit) => {
352
+ const result = await computeAsync();
353
+ await emit("data", "computeComplete", result);
354
+ }));
355
+ ```
356
+
357
+ ### Handle effect errors
358
+
359
+ ```typescript
360
+ store.registerEffect({
361
+ when: { channel: "data" },
362
+ effect: async (event, getState, emit) => {
363
+ try {
364
+ const data = await fetch(url);
365
+ await emit("data", "loadSuccess", data);
366
+ } catch (error) {
367
+ await emit("data", "loadFailure", { error: error.message });
368
+ }
369
+ },
370
+ });
371
+ ```
372
+
373
+ ---
374
+
375
+ ## API Overview
376
+
377
+ ### Store Creation
378
+
379
+ | API | Description |
380
+ | ----------------------------------------------- | ---------------------------------------------- |
381
+ | `createStore(spec)` | Create a store (types inferred from reducers) |
382
+ | `createStore<S, EM>(spec)` | Create a store with explicit state/event types |
383
+ | `store.emit(channel, type, payload)` | Emit an event (returns a promise) |
384
+ | `store.getState()` | Get current readonly state snapshot |
385
+ | `store.subscribe(listener)` | Coarse subscription (any state change) |
386
+ | `store.connect(spec, handler)` | Fine-grained path subscription with wildcards |
387
+ | `store.onEvent(channel, type, handler, phase?)` | Event subscription (committed/uncommitted/all) |
388
+ | `store.onEffect(channel, type, handler)` | Single-event effect shorthand |
389
+ | `store.dispose()` | Cleanup timers and resources |
390
+
391
+ ### Dynamic Registration
392
+
393
+ | API | Description |
394
+ | ----------------------------------- | ------------------------- |
395
+ | `store.registerReducer(name, spec)` | Add a slice at runtime |
396
+ | `store.registerMiddleware(fn)` | Add middleware at runtime |
397
+ | `store.registerEffect(spec)` | Add an effect at runtime |
398
+
399
+ ### HMR
400
+
401
+ | API | Description |
402
+ | --------------------------------------- | -------------------------- |
403
+ | `store.replaceReducers(reducers, opts)` | Replace all reducers |
404
+ | `store.replaceMiddleware(middleware)` | Replace all middleware |
405
+ | `store.replaceEffects(effects)` | Replace all effects |
406
+ | `store.hotReplace(partial)` | Replace any subset at once |
407
+
408
+ ### Helpers
409
+
410
+ | API | Description |
411
+ | ------------------------ | --------------------------------------------- |
412
+ | `eventKeys<EM>()([...])` | Type-safe event key arrays without `as const` |
413
+
414
+ ---
415
+
416
+ ## Performance
417
+
418
+ | Metric | Value |
419
+ | ------------------ | ------------------------------ |
420
+ | **Bundle size** | ~8KB (minified + gzipped) |
421
+ | **Tree-shakeable** | Yes (ES modules) |
422
+ | **Dependencies** | Zero |
423
+ | **TypeScript** | Full type definitions included |
424
+
425
+ ---
426
+
427
+ ## Documentation
428
+
429
+ - **[yoltra Root README](https://github.com/yoltra/yoltra/blob/main/README.md)** β€” Overview and
430
+ quick start
431
+ - **[@yoltra/react](https://github.com/yoltra/yoltra/blob/main/packages/react/README.md)** β€”
432
+ React hooks and Suspense
433
+ - **[Quick Start Guide](https://github.com/yoltra/yoltra/blob/main/docs/en/QUICK_START_GUIDE.md)**
434
+ β€” Five steps to a working app
435
+ - **[Event Queue Architecture](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)**
436
+ β€” Technical deep-dive
437
+ - **[Library Comparison](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)**
438
+ β€” Architectural comparison
439
+
440
+ ---
441
+
442
+ ## Examples
443
+
444
+ - **[Todo App](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)** β€” Full
445
+ CRUD with performance profiling
446
+ - **[Kinetic Logo](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-kinetic-logo)**
447
+ β€” 3000 circles with physics simulation
448
+ - **[Next.js Integration](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)**
449
+ β€” SSR + App Router + theme switcher
450
+
451
+ ---
452
+
453
+ ## Contributing
454
+
455
+ - [Monorepo Root](https://github.com/yoltra/yoltra/blob/main/README.md)
456
+ - [Contributing Guide](https://github.com/yoltra/yoltra/blob/main/CONTRIBUTING.md)
457
+
458
+ ---
459
+
460
+ ## Status
461
+
462
+ **Release Candidate** β€” APIs are stable, used in production, minor changes possible before v1.0.
463
+
464
+ ---
465
+
466
+ ## License
467
+
468
+ **MIT** β€” Free to use in commercial and open-source projects.
@@ -0,0 +1,127 @@
1
+ import { EventMapBase } from '../types';
2
+ /**
3
+ * Minimal, synchronous pub/sub event bus keyed by **channel** and **type**.
4
+ *
5
+ * @typeParam EM - Event map shape:
6
+ * ```ts
7
+ * type EventMapBase = Record<string, Record<string, unknown>>;
8
+ * // Example:
9
+ * type EM = {
10
+ * ui: { toggle: boolean };
11
+ * data: { loaded: { items: string[] } };
12
+ * };
13
+ * ```
14
+ *
15
+ * @remarks
16
+ * - Handlers are stored per `(channel, type)` and invoked **synchronously** in subscription order.
17
+ * - Exceptions thrown by a handler are **caught and logged**, and do **not** stop other handlers.
18
+ * - Intended for in-memory, single-process usage (no cross-tab/process broadcasting).
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * type EM = {
23
+ * ui: { toggle: boolean };
24
+ * data: { loaded: { items: string[] } };
25
+ * };
26
+ *
27
+ * const bus = new EventBus<EM>();
28
+ *
29
+ * // Subscribe
30
+ * const off = bus.on('ui', 'toggle', (on) => {
31
+ * console.log('UI toggled:', on);
32
+ * });
33
+ *
34
+ * // Emit
35
+ * bus.emit('ui', 'toggle', true); // logs: "UI toggled: true"
36
+ *
37
+ * // Unsubscribe
38
+ * off();
39
+ * ```
40
+ *
41
+ * @public
42
+ */
43
+ export declare class EventBus<EM extends EventMapBase> {
44
+ /**
45
+ * Internal registry: `channel β†’ type β†’ Set<handler>`.
46
+ * @internal
47
+ */
48
+ private handlers;
49
+ /**
50
+ * Subscribes a handler to an exact `(channel, type)`.
51
+ *
52
+ * @typeParam C - Channel key (must be a string key of `EM`).
53
+ * @typeParam T - Type key within channel `C` (must be a string key of `EM[C]`).
54
+ * @param channel - Channel name to subscribe to.
55
+ * @param type - Event type within the channel.
56
+ * @param handler - Function invoked with the payload type `EM[C][T]`.
57
+ * @returns An **unsubscribe** function that removes this handler.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const off = bus.on('data', 'loaded', ({ items }) => {
62
+ * console.log('Loaded', items.length, 'items');
63
+ * });
64
+ *
65
+ * // Later, stop listening:
66
+ * off();
67
+ * ```
68
+ *
69
+ * @public
70
+ */
71
+ on<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, handler: (payload: EM[C][T]) => void): () => void;
72
+ /**
73
+ * Removes a specific handler previously added with {@link EventBus.on | `on`}.
74
+ *
75
+ * @typeParam C - Channel key (string key of `EM`).
76
+ * @typeParam T - Type key within channel `C` (string key of `EM[C]`).
77
+ * @param channel - Channel name of the subscription to remove.
78
+ * @param type - Event type of the subscription to remove.
79
+ * @param handler - The same handler reference that was passed to `on`.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * const h = (n: number) => console.log('inc', n);
84
+ * bus.on('math', 'inc', h);
85
+ *
86
+ * // Explicitly remove this handler:
87
+ * bus.off('math', 'inc', h);
88
+ * ```
89
+ *
90
+ * @public
91
+ */
92
+ off<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, handler: (payload: EM[C][T]) => void): void;
93
+ /**
94
+ * Emits an event to all subscribers of the exact `(channel, type)`.
95
+ *
96
+ * Handlers are invoked **synchronously**. Any exception thrown by a handler is
97
+ * caught and logged, and other handlers still run.
98
+ *
99
+ * @typeParam C - Channel key (string key of `EM`).
100
+ * @typeParam T - Type key within channel `C` (string key of `EM[C]`).
101
+ * @param channel - Channel name to emit on.
102
+ * @param type - Event type to emit.
103
+ * @param payload - Payload matching `EM[C][T]`.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * bus.emit('ui', 'toggle', false);
108
+ * ```
109
+ *
110
+ * @public
111
+ */
112
+ emit<C extends keyof EM & string, T extends keyof EM[C] & string>(channel: C, type: T, payload: EM[C][T]): void;
113
+ /**
114
+ * Clears **all** listeners across all channels/types.
115
+ *
116
+ * Useful for tests or during HMR teardown to avoid duplicate handlers.
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * // In a test teardown:
121
+ * afterEach(() => bus.clear());
122
+ * ```
123
+ *
124
+ * @public
125
+ */
126
+ clear(): void;
127
+ }