@inglorious/store 9.1.0 → 9.3.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 CHANGED
@@ -433,6 +433,26 @@ store.notify("toggle", "todo1")
433
433
  // Each list's toggle handler runs; only the one with todo1 actually updates
434
434
  ```
435
435
 
436
+ Alternatively, you can use the **targeted notification syntax** to filter events at the dispatch level:
437
+
438
+ - `notify("type:event")`: notifies only entities of a specific type.
439
+ - `notify("#id:event")`: notifies only a specific entity by ID.
440
+ - `notify("type#id:event")`: notifies a specific entity of a specific type.
441
+
442
+ ```javascript
443
+ const types = {
444
+ todoList: {
445
+ toggle(entity) {
446
+ const todo = entity.todos.find((t) => t.id === entity.id)
447
+ if (todo) todo.completed = !todo.completed
448
+ },
449
+ },
450
+ }
451
+
452
+ // Notify only the entity with ID 'work'
453
+ store.notify("#todo1:toggle")
454
+ ```
455
+
436
456
  ### ⚡ Async Operations
437
457
 
438
458
  In **Redux/RTK**, logic should be written inside pure functions as much as possible — specifically in reducers, not action creators. But what if I need to access some other part of the state that is not visible to the reducer? What if I need to combine async behavior with sync behavior? This is where the choice of "where does my logic live?" matters.
@@ -478,6 +498,192 @@ Notice: you don't need pending/fulfilled/rejected actions. You stay in control o
478
498
 
479
499
  All events triggered via `api.notify()` enter the queue and process together, maintaining predictability and testability.
480
500
 
501
+ ### `handleAsync`
502
+
503
+ The `handleAsync` helper generates a set of event handlers representing the lifecycle of an async operation.
504
+
505
+ ```ts
506
+ handleAsync(type, handlers, options?)
507
+ ```
508
+
509
+ Example:
510
+
511
+ ```ts
512
+ handleAsync("fetchTodos", {
513
+ async run(payload) {
514
+ const res = await fetch("/api/todos")
515
+ return res.json()
516
+ },
517
+
518
+ success(entity, todos) {
519
+ entity.todos = todos
520
+ },
521
+
522
+ error(entity, error) {
523
+ entity.error = error.message
524
+ },
525
+
526
+ finally(entity) {
527
+ entity.loading = false
528
+ },
529
+ })
530
+ ```
531
+
532
+ ---
533
+
534
+ ### Lifecycle events
535
+
536
+ Triggering `fetchTodos` emits the following events:
537
+
538
+ ```
539
+ fetchTodos
540
+ fetchTodosRun
541
+ fetchTodosSuccess | fetchTodosError
542
+ fetchTodosFinally
543
+ ```
544
+
545
+ Each step is an **event handler**, not an implicit callback.
546
+
547
+ ---
548
+
549
+ ### Optional `start` handler
550
+
551
+ Use `start` for synchronous setup (loading flags, resets, optimistic state):
552
+
553
+ ```ts
554
+ handleAsync("save", {
555
+ start(entity) {
556
+ entity.loading = true
557
+ },
558
+ async run(payload) {
559
+ return api.save(payload)
560
+ },
561
+ })
562
+ ```
563
+
564
+ If omitted, no `Start` event is generated.
565
+
566
+ ---
567
+
568
+ ### Event scoping
569
+
570
+ By default, lifecycle events are **scoped to the triggering entity**:
571
+
572
+ ```
573
+ #entityId:fetchTodosSuccess
574
+ ```
575
+
576
+ You can override this behavior:
577
+
578
+ ```ts
579
+ handleAsync("bootstrap", handlers, { scope: "global" })
580
+ ```
581
+
582
+ Available scopes:
583
+
584
+ - `"entity"` (default)
585
+ - `"type"`
586
+ - `"global"`
587
+
588
+ ---
589
+
590
+ > **Key rule:** Async code must not access entities after `await`. All updates happen in event handlers.
591
+
592
+ ---
593
+
594
+ ## 🧩 Migrating from Redux Toolkit (RTK)
595
+
596
+ Inglorious Store now provides utilities to **gradually migrate from RTK slices and thunks**, leveraging `handleAsync` to simplify async logic.
597
+
598
+ ### Converting Async Thunks
599
+
600
+ ```javascript
601
+ import { convertAsyncThunk } from "@inglorious/store/rtk"
602
+
603
+ const fetchTodos = async (userId) => {
604
+ const res = await fetch(`/api/users/${userId}/todos`)
605
+ return res.json()
606
+ }
607
+
608
+ const todoHandlers = convertAsyncThunk("fetchTodos", fetchTodos, {
609
+ onPending: (entity) => {
610
+ entity.status = "loading"
611
+ },
612
+ onFulfilled: (entity, todos) => {
613
+ entity.status = "success"
614
+ entity.todos = todos
615
+ },
616
+ onRejected: (entity, error) => {
617
+ entity.status = "error"
618
+ entity.error = error.message
619
+ },
620
+ })
621
+ ```
622
+
623
+ ```javascript
624
+ const todoList = {
625
+ init(entity) {
626
+ entity.todos = []
627
+ entity.status = "idle"
628
+ },
629
+ ...todoHandlers,
630
+ }
631
+ ```
632
+
633
+ ### Converting Slices
634
+
635
+ ```javascript
636
+ import { convertSlice } from "@inglorious/store/rtk"
637
+
638
+ const todoListType = convertSlice(todosSlice, {
639
+ asyncThunks: {
640
+ fetchTodos: {
641
+ onPending: (entity) => {
642
+ entity.status = "loading"
643
+ },
644
+ onFulfilled: (entity, todos) => {
645
+ entity.items = todos
646
+ },
647
+ onRejected: (entity, error) => {
648
+ entity.error = error.message
649
+ },
650
+ },
651
+ },
652
+ })
653
+ ```
654
+
655
+ - Reducers become event handlers automatically.
656
+ - Async thunks become `handleAsync` events.
657
+ - Initial state is applied via an `init` handler.
658
+ - Extra handlers can be added if needed.
659
+
660
+ ---
661
+
662
+ ### RTK-Style Dispatch Compatibility
663
+
664
+ ```javascript
665
+ const dispatch = createRTKCompatDispatch(api, "todos")
666
+ dispatch({ type: "todos/addTodo", payload: "Buy milk" })
667
+ // becomes: api.notify('#todos:addTodo', 'Buy milk')
668
+ ```
669
+
670
+ > Thunks are **not supported** in compat mode; convert them using `convertAsyncThunk`.
671
+
672
+ ---
673
+
674
+ ### Migration Guide
675
+
676
+ ```javascript
677
+ import { createMigrationGuide } from "@inglorious/store/rtk"
678
+
679
+ const guide = createMigrationGuide(todosSlice)
680
+ console.log(guide)
681
+ ```
682
+
683
+ Outputs a readable guide mapping RTK calls to Inglorious events.
684
+
685
+ ---
686
+
481
687
  ### 🧪 Testing
482
688
 
483
689
  Event handlers are pure functions (or can be treated as such), making them easy to test in isolation, much like Redux reducers. The `@inglorious/store/test` module provides utility functions to make this even simpler.
@@ -794,12 +1000,75 @@ const store = createStore({
794
1000
  types, // Object: entity type definitions
795
1001
  entities, // Object: initial entities
796
1002
  systems, // Array (optional): global state handlers
1003
+ autoCreateEntities, // Boolean (optional): false (default) or true
797
1004
  updateMode, // String (optional): 'auto' (default) or 'manual'
798
1005
  })
799
1006
  ```
800
1007
 
801
1008
  **Returns:** A Redux-compatible store
802
1009
 
1010
+ **Options:**
1011
+
1012
+ - **`types`** (required) - Object defining entity type behaviors
1013
+ - **`entities`** (required) - Object containing initial entity instances
1014
+ - **`systems`** (optional) - Array of global state handlers
1015
+ - **`autoCreateEntities`** (optional) - Automatically create singleton entities for types not defined in `entities`:
1016
+ - `false` (default) - Only use explicitly defined entities
1017
+ - `true` - Auto-create entities matching their type name
1018
+ - **`updateMode`** (optional) - Controls when React components re-render:
1019
+ - `'auto'` (default) - Automatic updates after each event
1020
+ - `'manual'` - Manual control via `api.update()`
1021
+
1022
+ #### Auto-Create Entities
1023
+
1024
+ When `autoCreateEntities: true`, the store automatically creates singleton entities for any type that doesn't have a corresponding entity defined. This is particularly useful for singleton-type entities that behave like components, eliminating the need to switch between type definitions and entity declarations.
1025
+
1026
+ ```javascript
1027
+ const types = {
1028
+ settings: {
1029
+ setTheme(entity, theme) {
1030
+ entity.theme = theme
1031
+ },
1032
+ },
1033
+ analytics: {
1034
+ track(entity, event) {
1035
+ entity.events.push(event)
1036
+ },
1037
+ },
1038
+ }
1039
+
1040
+ // Without autoCreateEntities (default)
1041
+ const entities = {
1042
+ settings: { type: "settings", theme: "dark" },
1043
+ analytics: { type: "analytics", events: [] },
1044
+ }
1045
+
1046
+ // With autoCreateEntities: true
1047
+ const entities = {
1048
+ // settings and analytics will be auto-created as:
1049
+ // settings: { type: "settings" }
1050
+ // analytics: { type: "analytics" }
1051
+ }
1052
+
1053
+ const store = createStore({
1054
+ types,
1055
+ entities,
1056
+ autoCreateEntities: true,
1057
+ })
1058
+
1059
+ // Both approaches work the same way
1060
+ store.notify("settings:setTheme", "light")
1061
+ store.notify("analytics:track", { action: "click" })
1062
+ ```
1063
+
1064
+ **When to use `autoCreateEntities`:**
1065
+
1066
+ - ✅ Building web applications with singleton services (settings, auth, analytics)
1067
+ - ✅ Component-like entities that only need one instance
1068
+ - ✅ Rapid prototyping where you want to add types without ceremony
1069
+ - ❌ Game development with multiple entity instances (players, enemies, items)
1070
+ - ❌ When you need fine control over initial entity state
1071
+
803
1072
  ### Types Definition
804
1073
 
805
1074
  ```javascript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "9.1.0",
3
+ "version": "9.3.0",
4
4
  "description": "A state manager for real-time, collaborative apps, inspired by game development patterns and compatible with Redux.",
5
5
  "author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -36,6 +36,10 @@
36
36
  "types": "./types/client/devtools.d.ts",
37
37
  "import": "./src/client/devtools.js"
38
38
  },
39
+ "./migration/rtk": {
40
+ "types": "./types/migration/rtk.d.ts",
41
+ "import": "./src/migration/rtk.js"
42
+ },
39
43
  "./*": {
40
44
  "types": "./types/*.d.ts",
41
45
  "import": "./src/*"
package/src/async.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Creates a set of event handlers for managing an asynchronous operation lifecycle.
3
+ *
4
+ * This helper generates handlers for all stages of an async operation: start, run,
5
+ * success, error, and finally. The lifecycle events are automatically scoped based
6
+ * on the provided options.
7
+ *
8
+ * @template {import('./store').BaseEntity} TEntity - The entity type
9
+ * @template TPayload - The payload type passed to the operation
10
+ * @template TResult - The result type returned by the async operation
11
+ *
12
+ * @param {string} type - The base event name (e.g., 'fetchTodos').
13
+ * Generated handlers will be named: `type`, `typeStart`, `typeRun`, `typeSuccess`, `typeError`, `typeFinally`
14
+ *
15
+ * @param {Object} handlers - The handler functions for each lifecycle stage.
16
+ * @param {(entity: TEntity, payload: TPayload, api: import('./store').Api) => void} [handlers.start]
17
+ * Called synchronously before the async operation starts.
18
+ * Use for setting loading states. Receives: (entity, payload, api)
19
+ * @param {(payload: TPayload, api: import('./store').Api) => Promise<TResult> | TResult} handlers.run
20
+ * The async operation to perform. Must return a Promise or value.
21
+ * **Note:** Receives (payload, api) - NOT entity. Entity state should be modified in other handlers.
22
+ * @param {(entity: TEntity, result: TResult, api: import('./store').Api) => void} [handlers.success]
23
+ * Called when the operation succeeds.
24
+ * Receives: (entity, result, api) where result is the resolved value from run()
25
+ * @param {(entity: TEntity, error: any, api: import('./store').Api) => void} [handlers.error]
26
+ * Called when the operation fails.
27
+ * Receives: (entity, error, api) where error is the caught exception
28
+ * @param {(entity: TEntity, api: import('./store').Api) => void} [handlers.finally]
29
+ * Called after the operation completes (success or failure).
30
+ * Use for cleanup, resetting loading states. Receives: (entity, api)
31
+ *
32
+ * @param {Object} [options] - Configuration options.
33
+ * @param {"entity" | "type" | "global"} [options.scope="entity"]
34
+ * Controls how lifecycle events are routed:
35
+ * - "entity": notify `#entityId:event` (default, safest - events only affect the triggering entity)
36
+ * - "type": notify `typeName:event` (broadcasts to all entities of this type)
37
+ * - "global": notify `event` (global broadcast to any listener)
38
+ *
39
+ * @returns {Object} An object containing the generated event handlers that can be spread into a type:
40
+ * - `[type]`: Main trigger - dispatches Start (if defined) and Run events
41
+ * - `[typeStart]`: (if start handler provided) Executes the start handler
42
+ * - `[typeRun]`: Executes the async operation, then dispatches Success or Error, then Finally
43
+ * - `[typeSuccess]`: Executes the success handler
44
+ * - `[typeError]`: Executes the error handler
45
+ * - `[typeFinally]`: Executes the finally handler
46
+ *
47
+ * @example
48
+ * // Basic usage - fetch todos with loading state
49
+ * const todoList = {
50
+ * init(entity) {
51
+ * entity.todos = []
52
+ * entity.status = 'idle'
53
+ * },
54
+ *
55
+ * ...handleAsync('fetchTodos', {
56
+ * start(entity) {
57
+ * entity.status = 'loading'
58
+ * },
59
+ * async run(payload, api) {
60
+ * const response = await fetch('/api/todos')
61
+ * return response.json()
62
+ * },
63
+ * success(entity, todos) {
64
+ * entity.status = 'success'
65
+ * entity.todos = todos
66
+ * },
67
+ * error(entity, error) {
68
+ * entity.status = 'error'
69
+ * entity.error = error.message
70
+ * },
71
+ * finally(entity) {
72
+ * entity.lastFetched = Date.now()
73
+ * }
74
+ * })
75
+ * }
76
+ *
77
+ * // Trigger from UI
78
+ * api.notify('#todoList:fetchTodos')
79
+ *
80
+ * @example
81
+ * // With type scope - affects all entities of this type
82
+ * const counter = {
83
+ * ...handleAsync('sync', {
84
+ * async run(payload, api) {
85
+ * const response = await fetch('/api/sync')
86
+ * return response.json()
87
+ * },
88
+ * success(entity, result) {
89
+ * entity.synced = true
90
+ * entity.lastSync = result.timestamp
91
+ * }
92
+ * }, { scope: 'type' })
93
+ * }
94
+ *
95
+ * // Triggers sync for ALL counter entities
96
+ * api.notify('counter:sync')
97
+ *
98
+ * @example
99
+ * // Minimal - just run and success
100
+ * const user = {
101
+ * ...handleAsync('login', {
102
+ * async run({ username, password }, api) {
103
+ * const response = await fetch('/api/login', {
104
+ * method: 'POST',
105
+ * body: JSON.stringify({ username, password })
106
+ * })
107
+ * return response.json()
108
+ * },
109
+ * success(entity, user) {
110
+ * entity.currentUser = user
111
+ * entity.isAuthenticated = true
112
+ * }
113
+ * })
114
+ * }
115
+ *
116
+ * @example
117
+ * // Without start handler
118
+ * const data = {
119
+ * ...handleAsync('fetch', {
120
+ * async run(payload, api) {
121
+ * return fetch(`/api/data/${payload.id}`).then(r => r.json())
122
+ * },
123
+ * success(entity, result) {
124
+ * entity.data = result
125
+ * },
126
+ * error(entity, error) {
127
+ * console.error('Fetch failed:', error)
128
+ * }
129
+ * })
130
+ * }
131
+ */
132
+ export function handleAsync(type, handlers, options = {}) {
133
+ const { scope = "entity" } = options
134
+
135
+ function notify(api, entity, event, payload) {
136
+ switch (scope) {
137
+ case "entity":
138
+ api.notify(`#${entity.id}:${event}`, payload)
139
+ break
140
+ case "type":
141
+ api.notify(`${entity.type}:${event}`, payload)
142
+ break
143
+ case "global":
144
+ api.notify(event, payload)
145
+ break
146
+ }
147
+ }
148
+
149
+ return {
150
+ [type](entity, payload, api) {
151
+ if (handlers.start) {
152
+ notify(api, entity, `${type}Start`, payload)
153
+ }
154
+
155
+ notify(api, entity, `${type}Run`, payload)
156
+ },
157
+
158
+ ...(handlers.start && {
159
+ [`${type}Start`](entity, payload, api) {
160
+ handlers.start(entity, payload, api)
161
+ },
162
+ }),
163
+
164
+ async [`${type}Run`](entity, payload, api) {
165
+ try {
166
+ const result = await handlers.run(payload, api)
167
+ notify(api, entity, `${type}Success`, result)
168
+ } catch (error) {
169
+ notify(api, entity, `${type}Error`, error)
170
+ } finally {
171
+ notify(api, entity, `${type}Finally`)
172
+ }
173
+ },
174
+
175
+ [`${type}Success`](entity, result, api) {
176
+ handlers.success?.(entity, result, api)
177
+ },
178
+
179
+ [`${type}Error`](entity, error, api) {
180
+ handlers.error?.(entity, error, api)
181
+ },
182
+
183
+ [`${type}Finally`](entity, _, api) {
184
+ handlers.finally?.(entity, api)
185
+ },
186
+ }
187
+ }