@inglorious/store 9.2.0 → 9.3.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "9.2.0",
3
+ "version": "9.3.1",
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/*"
@@ -49,18 +53,19 @@
49
53
  "publishConfig": {
50
54
  "access": "public"
51
55
  },
56
+ "sideEffects": false,
52
57
  "dependencies": {
53
58
  "mutative": "^1.3.0",
54
- "@inglorious/utils": "3.7.2"
59
+ "@inglorious/utils": "3.7.3"
55
60
  },
56
61
  "peerDependencies": {
57
- "@inglorious/utils": "3.7.2"
62
+ "@inglorious/utils": "3.7.3"
58
63
  },
59
64
  "devDependencies": {
60
65
  "prettier": "^3.6.2",
61
66
  "vite": "^7.1.3",
62
67
  "vitest": "^1.6.1",
63
- "@inglorious/eslint-config": "1.1.1"
68
+ "@inglorious/eslint-config": "1.1.2"
64
69
  },
65
70
  "engines": {
66
71
  "node": ">= 22"
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
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * @fileoverview Adapter for migrating Redux Toolkit code to Inglorious Store
3
+ *
4
+ * This module provides utilities to convert RTK slices and async thunks into
5
+ * Inglorious types, enabling gradual migration from RTK to Inglorious Store.
6
+ */
7
+
8
+ import { handleAsync } from "../async"
9
+
10
+ const SEP_LENGTH = 50
11
+ const INDENT_SPACES = 2
12
+ const SLICE_PLUS_ACTION = 2
13
+
14
+ /**
15
+ * Converts an RTK createAsyncThunk to Inglorious handleAsync handlers
16
+ *
17
+ * @param {string} name - The thunk name (e.g., 'fetchTodos')
18
+ * @param {Function} payloadCreator - The async function that performs the operation
19
+ * @param {Object} [options] - Options for the conversion
20
+ * @param {Function} [options.onPending] - Handler for pending state (maps to start)
21
+ * @param {Function} [options.onFulfilled] - Handler for fulfilled state (maps to success)
22
+ * @param {Function} [options.onRejected] - Handler for rejected state (maps to error)
23
+ * @param {Function} [options.onSettled] - Handler called after completion (maps to finally)
24
+ * @param {"entity"|"type"|"global"} [options.scope] - Event scope
25
+ *
26
+ * @returns {Object} Inglorious handleAsync handlers
27
+ *
28
+ * @example
29
+ * // RTK async thunk
30
+ * const fetchTodos = createAsyncThunk(
31
+ * 'todos/fetch',
32
+ * async (userId) => {
33
+ * const response = await fetch(`/api/users/${userId}/todos`)
34
+ * return response.json()
35
+ * }
36
+ * )
37
+ *
38
+ * // Convert to Inglorious
39
+ * const todoHandlers = convertAsyncThunk('fetchTodos', fetchTodos.payloadCreator, {
40
+ * onPending: (entity) => { entity.status = 'loading' },
41
+ * onFulfilled: (entity, todos) => {
42
+ * entity.status = 'success'
43
+ * entity.todos = todos
44
+ * },
45
+ * onRejected: (entity, error) => {
46
+ * entity.status = 'error'
47
+ * entity.error = error.message
48
+ * }
49
+ * })
50
+ *
51
+ * // Use in type
52
+ * const todoList = {
53
+ * init(entity) { entity.todos = []; entity.status = 'idle' },
54
+ * ...todoHandlers
55
+ * }
56
+ */
57
+ export function convertAsyncThunk(name, payloadCreator, options = {}) {
58
+ const {
59
+ onPending,
60
+ onFulfilled,
61
+ onRejected,
62
+ onSettled,
63
+ scope = "entity",
64
+ } = options
65
+
66
+ return handleAsync(
67
+ name,
68
+ {
69
+ start: onPending
70
+ ? (entity, payload, api) => {
71
+ onPending(entity, payload, api)
72
+ }
73
+ : undefined,
74
+
75
+ run: async (payload, api) => {
76
+ // RTK payloadCreator signature: (arg, thunkAPI)
77
+ // We need to adapt it to our simpler signature
78
+ return await payloadCreator(payload, { dispatch: api.notify })
79
+ },
80
+
81
+ success: onFulfilled
82
+ ? (entity, result, api) => {
83
+ onFulfilled(entity, result, api)
84
+ }
85
+ : undefined,
86
+
87
+ error: onRejected
88
+ ? (entity, error, api) => {
89
+ onRejected(entity, error, api)
90
+ }
91
+ : undefined,
92
+
93
+ finally: onSettled
94
+ ? (entity, api) => {
95
+ onSettled(entity, api)
96
+ }
97
+ : undefined,
98
+ },
99
+ { scope },
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Converts an RTK slice to an Inglorious type
105
+ *
106
+ * This is a helper for gradual migration. It converts RTK reducers to Inglorious
107
+ * event handlers while preserving the same mutation-based syntax (both use Immer/Mutative).
108
+ *
109
+ * @param {Object} slice - RTK slice created with createSlice
110
+ * @param {Object} [options] - Conversion options
111
+ * @param {Object} [options.asyncThunks] - Map of async thunk handlers
112
+ * Keys are thunk names, values are objects with onPending/onFulfilled/onRejected
113
+ * @param {Object} [options.extraHandlers] - Additional event handlers not from the slice
114
+ *
115
+ * @returns {Object} Inglorious type definition
116
+ *
117
+ * @example
118
+ * // RTK slice
119
+ * const todosSlice = createSlice({
120
+ * name: 'todos',
121
+ * initialState: { items: [], filter: 'all' },
122
+ * reducers: {
123
+ * addTodo: (state, action) => {
124
+ * state.items.push({ id: Date.now(), text: action.payload })
125
+ * },
126
+ * toggleTodo: (state, action) => {
127
+ * const todo = state.items.find(t => t.id === action.payload)
128
+ * if (todo) todo.completed = !todo.completed
129
+ * },
130
+ * setFilter: (state, action) => {
131
+ * state.filter = action.payload
132
+ * }
133
+ * }
134
+ * })
135
+ *
136
+ * const fetchTodos = createAsyncThunk('todos/fetch', async () => {
137
+ * const response = await fetch('/api/todos')
138
+ * return response.json()
139
+ * })
140
+ *
141
+ * // Convert to Inglorious
142
+ * const todoList = convertSlice(todosSlice, {
143
+ * asyncThunks: {
144
+ * fetchTodos: {
145
+ * onPending: (entity) => { entity.status = 'loading' },
146
+ * onFulfilled: (entity, todos) => { entity.items = todos },
147
+ * onRejected: (entity, error) => { entity.error = error.message }
148
+ * }
149
+ * }
150
+ * })
151
+ *
152
+ * // Use in store
153
+ * const store = createStore({
154
+ * types: { todoList },
155
+ * entities: { todos: { type: 'todoList', id: 'todos' } }
156
+ * })
157
+ */
158
+ export function convertSlice(slice, options = {}) {
159
+ const { asyncThunks = {}, extraHandlers = {} } = options
160
+
161
+ const type = {
162
+ // Convert initialState to init handler
163
+ init(entity) {
164
+ const initialState = slice.getInitialState()
165
+ Object.assign(entity, initialState)
166
+ },
167
+ }
168
+
169
+ // Convert each reducer to an event handler
170
+ for (const [actionName, reducer] of Object.entries(slice.caseReducers)) {
171
+ type[actionName] = (entity, payload) => {
172
+ // Create a mock action object for the RTK reducer
173
+ const action = { type: `${slice.name}/${actionName}`, payload }
174
+
175
+ // RTK reducers expect to mutate state directly (via Immer)
176
+ // Inglorious handlers do the same (via Mutative)
177
+ // So we can call the reducer directly on the entity
178
+ reducer(entity, action)
179
+ }
180
+ }
181
+
182
+ // Convert async thunks
183
+ for (const [thunkName, thunkHandlers] of Object.entries(asyncThunks)) {
184
+ const asyncHandlers = convertAsyncThunk(
185
+ thunkName,
186
+ thunkHandlers.payloadCreator || (async () => {}),
187
+ {
188
+ onPending: thunkHandlers.onPending,
189
+ onFulfilled: thunkHandlers.onFulfilled,
190
+ onRejected: thunkHandlers.onRejected,
191
+ onSettled: thunkHandlers.onSettled,
192
+ scope: thunkHandlers.scope,
193
+ },
194
+ )
195
+
196
+ Object.assign(type, asyncHandlers)
197
+ }
198
+
199
+ // Add any extra handlers
200
+ Object.assign(type, extraHandlers)
201
+
202
+ return type
203
+ }
204
+
205
+ /**
206
+ * Creates a migration guide for a slice
207
+ *
208
+ * Analyzes an RTK slice and generates a readable migration guide showing
209
+ * the equivalent Inglorious code.
210
+ *
211
+ * @param {Object} slice - RTK slice
212
+ * @returns {string} Migration guide text
213
+ *
214
+ * @example
215
+ * const guide = createMigrationGuide(todosSlice)
216
+ * console.log(guide)
217
+ * // Outputs:
218
+ * // Migration Guide for 'todos' slice
219
+ * //
220
+ * // RTK:
221
+ * // dispatch(addTodo('Buy milk'))
222
+ * //
223
+ * // Inglorious:
224
+ * // api.notify('#todos:addTodo', 'Buy milk')
225
+ */
226
+ export function createMigrationGuide(slice) {
227
+ const lines = []
228
+
229
+ lines.push(`Migration Guide for '${slice.name}' slice`)
230
+ lines.push("")
231
+ lines.push("=".repeat(SEP_LENGTH))
232
+ lines.push("")
233
+
234
+ // Show state structure
235
+ lines.push("STATE STRUCTURE:")
236
+ lines.push("RTK:")
237
+ lines.push(
238
+ ` state.${slice.name} = ${JSON.stringify(slice.getInitialState(), null, INDENT_SPACES)}`,
239
+ )
240
+ lines.push("")
241
+ lines.push("Inglorious:")
242
+ lines.push(` entities.${slice.name} = {`)
243
+ lines.push(` type: '${slice.name}',`)
244
+ lines.push(` id: '${slice.name}',`)
245
+ const initialState = slice.getInitialState()
246
+ for (const [key, value] of Object.entries(initialState)) {
247
+ lines.push(` ${key}: ${JSON.stringify(value)},`)
248
+ }
249
+ lines.push(" }")
250
+ lines.push("")
251
+ lines.push("=".repeat(SEP_LENGTH))
252
+ lines.push("")
253
+
254
+ // Show each reducer conversion
255
+ lines.push("ACTIONS / EVENTS:")
256
+ for (const actionName of Object.keys(slice.caseReducers)) {
257
+ lines.push("")
258
+ lines.push(`${actionName}:`)
259
+ lines.push(" RTK:")
260
+ lines.push(` dispatch(${actionName}(payload))`)
261
+ lines.push(" Inglorious:")
262
+ lines.push(` api.notify('#${slice.name}:${actionName}', payload)`)
263
+ }
264
+
265
+ lines.push("")
266
+ lines.push("=".repeat(SEP_LENGTH))
267
+ lines.push("")
268
+
269
+ // Show selector conversion
270
+ lines.push("SELECTORS:")
271
+ lines.push("RTK:")
272
+ lines.push(
273
+ ` const data = useSelector(state => state.${slice.name}.someField)`,
274
+ )
275
+ lines.push("Inglorious:")
276
+ lines.push(` const { someField } = api.getEntity('${slice.name}')`)
277
+ lines.push(" // or")
278
+ lines.push(
279
+ ` const data = useSelector(state => state.entities.${slice.name}.someField)`,
280
+ )
281
+
282
+ return lines.join("\n")
283
+ }
284
+
285
+ /**
286
+ * Helper to create a compatibility layer for existing RTK code
287
+ *
288
+ * This allows RTK-style dispatch calls to work with Inglorious Store
289
+ * during the migration period.
290
+ *
291
+ * @param {Object} api - Inglorious API
292
+ * @param {string} entityId - The entity ID to target
293
+ * @returns {Function} A dispatch function compatible with RTK actions
294
+ *
295
+ * @example
296
+ * const dispatch = createRTKCompatDispatch(api, 'todos')
297
+ *
298
+ * // RTK-style action
299
+ * dispatch({ type: 'todos/addTodo', payload: 'Buy milk' })
300
+ *
301
+ * // Translates to:
302
+ * // api.notify('#todos:addTodo', 'Buy milk')
303
+ */
304
+ export function createRTKCompatDispatch(api, entityId) {
305
+ return (action) => {
306
+ if (typeof action === "function") {
307
+ // Thunk - not supported in compat mode
308
+ console.warn("Thunks are not supported in RTK compat mode")
309
+ return
310
+ }
311
+
312
+ // Extract action type and payload
313
+ const { type, payload } = action
314
+
315
+ // Convert RTK action type to Inglorious event
316
+ // RTK: 'todos/addTodo' -> Inglorious: '#todos:addTodo'
317
+ const parts = type.split("/")
318
+ if (parts.length === SLICE_PLUS_ACTION) {
319
+ const [, actionName] = parts
320
+ api.notify(`#${entityId}:${actionName}`, payload)
321
+ } else {
322
+ // Fallback for non-standard action types
323
+ api.notify(`#${entityId}:${type}`, payload)
324
+ }
325
+ }
326
+ }
@@ -0,0 +1,146 @@
1
+ import { Api, BaseEntity } from "./store"
2
+
3
+ /**
4
+ * Scope option for async handler events
5
+ * - "entity": Events scoped to specific entity (#entityId:event)
6
+ * - "type": Events scoped to entity type (typeName:event)
7
+ * - "global": Global events (event)
8
+ */
9
+ export type AsyncScope = "entity" | "type" | "global"
10
+
11
+ /**
12
+ * Configuration options for async handlers
13
+ */
14
+ export interface AsyncOptions {
15
+ /**
16
+ * Controls how lifecycle events are routed
17
+ * @default "entity"
18
+ */
19
+ scope?: AsyncScope
20
+ }
21
+
22
+ /**
23
+ * Handler functions for async operation lifecycle
24
+ */
25
+ export interface AsyncHandlers<
26
+ TEntity extends BaseEntity = BaseEntity,
27
+ TPayload = any,
28
+ TResult = any,
29
+ > {
30
+ /**
31
+ * Called synchronously before the async operation starts
32
+ * Use for setting loading states
33
+ */
34
+ start?: (entity: TEntity, payload: TPayload, api: Api) => void
35
+
36
+ /**
37
+ * The async operation to perform
38
+ * Receives payload and api (NOT entity - entity state should be modified in other handlers)
39
+ * @returns Promise or synchronous result
40
+ */
41
+ run: (payload: TPayload, api: Api) => Promise<TResult> | TResult
42
+
43
+ /**
44
+ * Called when the operation succeeds
45
+ * Receives the entity, the result from run(), and api
46
+ */
47
+ success?: (entity: TEntity, result: TResult, api: Api) => void
48
+
49
+ /**
50
+ * Called when the operation fails
51
+ * Receives the entity, the error, and api
52
+ */
53
+ error?: (entity: TEntity, error: any, api: Api) => void
54
+
55
+ /**
56
+ * Called after the operation completes (success or failure)
57
+ * Use for cleanup, resetting loading states, etc.
58
+ */
59
+ finally?: (entity: TEntity, api: Api) => void
60
+ }
61
+
62
+ /**
63
+ * Generated event handlers returned by handleAsync
64
+ * Contains handlers for all lifecycle stages
65
+ */
66
+ export interface AsyncEventHandlers<
67
+ TEntity extends BaseEntity = BaseEntity,
68
+ TPayload = any,
69
+ TResult = any,
70
+ > {
71
+ /**
72
+ * Main trigger handler
73
+ * Dispatches start (if defined) and run events
74
+ */
75
+ [key: string]: (
76
+ entity: TEntity,
77
+ payload: TPayload,
78
+ api: Api,
79
+ ) => void | Promise<void>
80
+ }
81
+
82
+ /**
83
+ * Creates a set of event handlers for managing an asynchronous operation lifecycle.
84
+ *
85
+ * Generates handlers for: trigger, start, run, success, error, finally
86
+ * These can be spread into an entity type definition.
87
+ *
88
+ * @template TEntity - The entity type (must extend BaseEntity)
89
+ * @template TPayload - The payload type passed to the operation
90
+ * @template TResult - The result type returned by the async operation
91
+ *
92
+ * @param type - The base event name (e.g., 'fetchTodos')
93
+ * @param handlers - Handler functions for each lifecycle stage
94
+ * @param options - Configuration options
95
+ * @returns Object containing all generated event handlers
96
+ *
97
+ * @example
98
+ * ```typescript
99
+ * interface Todo {
100
+ * id: number
101
+ * text: string
102
+ * completed: boolean
103
+ * }
104
+ *
105
+ * interface TodoListEntity extends BaseEntity {
106
+ * type: 'todoList'
107
+ * todos: Todo[]
108
+ * status: 'idle' | 'loading' | 'success' | 'error'
109
+ * error?: string
110
+ * }
111
+ *
112
+ * const todoList = {
113
+ * init(entity: TodoListEntity) {
114
+ * entity.todos = []
115
+ * entity.status = 'idle'
116
+ * },
117
+ *
118
+ * ...handleAsync<TodoListEntity, void, Todo[]>('fetchTodos', {
119
+ * start(entity) {
120
+ * entity.status = 'loading'
121
+ * },
122
+ * async run(_, api) {
123
+ * const response = await fetch('/api/todos')
124
+ * return response.json()
125
+ * },
126
+ * success(entity, todos) {
127
+ * entity.status = 'success'
128
+ * entity.todos = todos
129
+ * },
130
+ * error(entity, error) {
131
+ * entity.status = 'error'
132
+ * entity.error = error.message
133
+ * }
134
+ * })
135
+ * }
136
+ * ```
137
+ */
138
+ export function handleAsync<
139
+ TEntity extends BaseEntity = BaseEntity,
140
+ TPayload = any,
141
+ TResult = any,
142
+ >(
143
+ type: string,
144
+ handlers: AsyncHandlers<TEntity, TPayload, TResult>,
145
+ options?: AsyncOptions,
146
+ ): AsyncEventHandlers<TEntity, TPayload, TResult>
package/types/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./async"
1
2
  export * from "./select"
2
3
  export * from "./store"
3
4
  export * from "./test"
@@ -0,0 +1,402 @@
1
+ import { Api, BaseEntity } from "../store"
2
+
3
+ /**
4
+ * RTK-compatible thunk API
5
+ * Minimal subset needed for payload creators
6
+ */
7
+ export interface ThunkAPI {
8
+ dispatch: Api["notify"]
9
+ // Add other thunk API properties as needed
10
+ // getState, extra, requestId, signal, rejectWithValue, fulfillWithValue
11
+ }
12
+
13
+ /**
14
+ * RTK async thunk payload creator function
15
+ */
16
+ export type PayloadCreator<TPayload = any, TResult = any> = (
17
+ arg: TPayload,
18
+ thunkAPI: ThunkAPI,
19
+ ) => Promise<TResult> | TResult
20
+
21
+ /**
22
+ * Options for converting an async thunk
23
+ */
24
+ export interface ConvertAsyncThunkOptions<
25
+ TEntity extends BaseEntity = BaseEntity,
26
+ TPayload = any,
27
+ TResult = any,
28
+ > {
29
+ /**
30
+ * Handler for pending state (RTK: addCase(thunk.pending))
31
+ * Maps to handleAsync 'start'
32
+ */
33
+ onPending?: (entity: TEntity, payload: TPayload, api: Api) => void
34
+
35
+ /**
36
+ * Handler for fulfilled state (RTK: addCase(thunk.fulfilled))
37
+ * Maps to handleAsync 'success'
38
+ */
39
+ onFulfilled?: (entity: TEntity, result: TResult, api: Api) => void
40
+
41
+ /**
42
+ * Handler for rejected state (RTK: addCase(thunk.rejected))
43
+ * Maps to handleAsync 'error'
44
+ */
45
+ onRejected?: (entity: TEntity, error: any, api: Api) => void
46
+
47
+ /**
48
+ * Handler called after operation completes (success or failure)
49
+ * Maps to handleAsync 'finally'
50
+ */
51
+ onSettled?: (entity: TEntity, api: Api) => void
52
+
53
+ /**
54
+ * Event scope for lifecycle handlers
55
+ * @default "entity"
56
+ */
57
+ scope?: "entity" | "type" | "global"
58
+ }
59
+
60
+ /**
61
+ * RTK slice reducer function
62
+ */
63
+ export type SliceReducer<TState = any, TPayload = any> = (
64
+ state: TState,
65
+ action: { type: string; payload: TPayload },
66
+ ) => void
67
+
68
+ /**
69
+ * RTK slice structure (minimal subset)
70
+ */
71
+ export interface RTKSlice<TState = any> {
72
+ /**
73
+ * Slice name (used in action types)
74
+ */
75
+ name: string
76
+
77
+ /**
78
+ * Function that returns initial state
79
+ */
80
+ getInitialState: () => TState
81
+
82
+ /**
83
+ * Map of case reducers
84
+ */
85
+ caseReducers: Record<string, SliceReducer<TState, any>>
86
+
87
+ /**
88
+ * Optional: The combined reducer function
89
+ */
90
+ reducer?: (state: TState, action: any) => TState
91
+ }
92
+
93
+ /**
94
+ * Async thunk configuration for slice conversion
95
+ */
96
+ export interface AsyncThunkConfig<
97
+ TEntity extends BaseEntity = BaseEntity,
98
+ TPayload = any,
99
+ TResult = any,
100
+ > {
101
+ /**
102
+ * The async operation to perform
103
+ */
104
+ payloadCreator?: PayloadCreator<TPayload, TResult>
105
+
106
+ /**
107
+ * Handler for pending state
108
+ */
109
+ onPending?: (entity: TEntity, payload: TPayload, api: Api) => void
110
+
111
+ /**
112
+ * Handler for fulfilled state
113
+ */
114
+ onFulfilled?: (entity: TEntity, result: TResult, api: Api) => void
115
+
116
+ /**
117
+ * Handler for rejected state
118
+ */
119
+ onRejected?: (entity: TEntity, error: any, api: Api) => void
120
+
121
+ /**
122
+ * Handler called after completion
123
+ */
124
+ onSettled?: (entity: TEntity, api: Api) => void
125
+
126
+ /**
127
+ * Event scope
128
+ */
129
+ scope?: "entity" | "type" | "global"
130
+ }
131
+
132
+ /**
133
+ * Options for converting a slice
134
+ */
135
+ export interface ConvertSliceOptions<TEntity extends BaseEntity = BaseEntity> {
136
+ /**
137
+ * Map of async thunks to convert
138
+ * Key is the thunk name, value is the thunk configuration
139
+ */
140
+ asyncThunks?: Record<string, AsyncThunkConfig<TEntity, any, any>>
141
+
142
+ /**
143
+ * Additional event handlers not from the slice
144
+ * These will be merged into the resulting type
145
+ */
146
+ extraHandlers?: Record<
147
+ string,
148
+ (entity: TEntity, payload?: any, api?: Api) => void
149
+ >
150
+ }
151
+
152
+ /**
153
+ * Inglorious type definition (minimal structure)
154
+ */
155
+ export interface InglorisousType<TEntity extends BaseEntity = BaseEntity> {
156
+ /**
157
+ * Initialization handler
158
+ */
159
+ init?: (entity: TEntity, payload?: any, api?: Api) => void
160
+
161
+ /**
162
+ * Other event handlers
163
+ */
164
+ [key: string]:
165
+ | ((entity: TEntity, payload?: any, api?: Api) => void | Promise<void>)
166
+ | undefined
167
+ }
168
+
169
+ /**
170
+ * Converts an RTK createAsyncThunk to Inglorious handleAsync handlers
171
+ *
172
+ * This function adapts RTK's pending/fulfilled/rejected lifecycle to
173
+ * Inglorious's start/run/success/error/finally lifecycle.
174
+ *
175
+ * @template TEntity - The entity type
176
+ * @template TPayload - The payload type
177
+ * @template TResult - The result type returned by the async operation
178
+ *
179
+ * @param name - The thunk name (e.g., 'fetchTodos')
180
+ * @param payloadCreator - The async function that performs the operation
181
+ * @param options - Lifecycle handlers and configuration
182
+ * @returns Inglorious handleAsync event handlers
183
+ *
184
+ * @example
185
+ * ```typescript
186
+ * interface User {
187
+ * id: number
188
+ * name: string
189
+ * }
190
+ *
191
+ * interface UserEntity extends BaseEntity {
192
+ * type: 'user'
193
+ * currentUser: User | null
194
+ * loading: boolean
195
+ * error: string | null
196
+ * }
197
+ *
198
+ * const userHandlers = convertAsyncThunk<UserEntity, number, User>(
199
+ * 'fetchUser',
200
+ * async (userId, thunkAPI) => {
201
+ * const response = await fetch(`/api/users/${userId}`)
202
+ * return response.json()
203
+ * },
204
+ * {
205
+ * onPending: (entity) => {
206
+ * entity.loading = true
207
+ * entity.error = null
208
+ * },
209
+ * onFulfilled: (entity, user) => {
210
+ * entity.loading = false
211
+ * entity.currentUser = user
212
+ * },
213
+ * onRejected: (entity, error) => {
214
+ * entity.loading = false
215
+ * entity.error = error.message
216
+ * }
217
+ * }
218
+ * )
219
+ * ```
220
+ */
221
+ export function convertAsyncThunk<
222
+ TEntity extends BaseEntity = BaseEntity,
223
+ TPayload = any,
224
+ TResult = any,
225
+ >(
226
+ name: string,
227
+ payloadCreator: PayloadCreator<TPayload, TResult>,
228
+ options?: ConvertAsyncThunkOptions<TEntity, TPayload, TResult>,
229
+ ): InglorisousType<TEntity>
230
+
231
+ /**
232
+ * Converts an RTK slice to an Inglorious type
233
+ *
234
+ * This function converts RTK reducers to Inglorious event handlers while
235
+ * preserving the same mutation-based syntax (both use Immer/Mutative).
236
+ * The resulting type can be used directly in an Inglorious store.
237
+ *
238
+ * @template TEntity - The entity type (should match slice state + BaseEntity fields)
239
+ * @template TState - The slice state type
240
+ *
241
+ * @param slice - RTK slice created with createSlice
242
+ * @param options - Async thunks and additional handlers
243
+ * @returns Inglorious type definition
244
+ *
245
+ * @example
246
+ * ```typescript
247
+ * interface Todo {
248
+ * id: number
249
+ * text: string
250
+ * completed: boolean
251
+ * }
252
+ *
253
+ * interface TodoState {
254
+ * items: Todo[]
255
+ * filter: 'all' | 'active' | 'completed'
256
+ * status: 'idle' | 'loading' | 'success' | 'error'
257
+ * }
258
+ *
259
+ * interface TodoListEntity extends BaseEntity, TodoState {
260
+ * type: 'todoList'
261
+ * }
262
+ *
263
+ * // RTK slice
264
+ * const todosSlice = createSlice({
265
+ * name: 'todos',
266
+ * initialState: { items: [], filter: 'all', status: 'idle' } as TodoState,
267
+ * reducers: {
268
+ * addTodo: (state, action: PayloadAction<string>) => {
269
+ * state.items.push({
270
+ * id: Date.now(),
271
+ * text: action.payload,
272
+ * completed: false
273
+ * })
274
+ * },
275
+ * toggleTodo: (state, action: PayloadAction<number>) => {
276
+ * const todo = state.items.find(t => t.id === action.payload)
277
+ * if (todo) todo.completed = !todo.completed
278
+ * }
279
+ * }
280
+ * })
281
+ *
282
+ * // Convert to Inglorious
283
+ * const todoList = convertSlice<TodoListEntity, TodoState>(todosSlice, {
284
+ * asyncThunks: {
285
+ * fetchTodos: {
286
+ * payloadCreator: async () => {
287
+ * const response = await fetch('/api/todos')
288
+ * return response.json()
289
+ * },
290
+ * onPending: (entity) => {
291
+ * entity.status = 'loading'
292
+ * },
293
+ * onFulfilled: (entity, todos: Todo[]) => {
294
+ * entity.status = 'success'
295
+ * entity.items = todos
296
+ * },
297
+ * onRejected: (entity, error) => {
298
+ * entity.status = 'error'
299
+ * }
300
+ * }
301
+ * }
302
+ * })
303
+ * ```
304
+ */
305
+ export function convertSlice<
306
+ TEntity extends BaseEntity = BaseEntity,
307
+ TState = any,
308
+ >(
309
+ slice: RTKSlice<TState>,
310
+ options?: ConvertSliceOptions<TEntity>,
311
+ ): InglorisousType<TEntity>
312
+
313
+ /**
314
+ * Creates a migration guide for a slice
315
+ *
316
+ * Analyzes an RTK slice and generates a readable migration guide showing
317
+ * the equivalent Inglorious code. Useful for documentation and planning.
318
+ *
319
+ * @param slice - RTK slice
320
+ * @returns Migration guide as formatted text
321
+ *
322
+ * @example
323
+ * ```typescript
324
+ * const guide = createMigrationGuide(todosSlice)
325
+ * console.log(guide)
326
+ * ```
327
+ */
328
+ export function createMigrationGuide(slice: RTKSlice): string
329
+
330
+ /**
331
+ * Creates a compatibility layer for existing RTK code
332
+ *
333
+ * This allows RTK-style dispatch calls to work with Inglorious Store
334
+ * during the migration period. The returned dispatch function translates
335
+ * RTK action objects to Inglorious notify calls.
336
+ *
337
+ * @param api - Inglorious API
338
+ * @param entityId - The entity ID to target
339
+ * @returns A dispatch function compatible with RTK actions
340
+ *
341
+ * @example
342
+ * ```typescript
343
+ * const dispatch = createRTKCompatDispatch(api, 'todos')
344
+ *
345
+ * // RTK-style action still works
346
+ * dispatch({
347
+ * type: 'todos/addTodo',
348
+ * payload: 'Buy milk'
349
+ * })
350
+ *
351
+ * // Translates to: api.notify('#todos:addTodo', 'Buy milk')
352
+ * ```
353
+ */
354
+ export function createRTKCompatDispatch(
355
+ api: Api,
356
+ entityId: string,
357
+ ): (action: { type: string; payload?: any } | Function) => void
358
+
359
+ /**
360
+ * RTK Action type for type safety
361
+ */
362
+ export interface RTKAction<TPayload = any> {
363
+ type: string
364
+ payload?: TPayload
365
+ error?: any
366
+ meta?: any
367
+ }
368
+
369
+ /**
370
+ * Helper type to extract state from RTK slice
371
+ */
372
+ export type SliceState<T extends RTKSlice> = ReturnType<T["getInitialState"]>
373
+
374
+ /**
375
+ * Helper type to create entity from slice state
376
+ */
377
+ export type EntityFromSlice<
378
+ T extends RTKSlice,
379
+ TType extends string = string,
380
+ > = SliceState<T> & BaseEntity & { type: TType }
381
+
382
+ /**
383
+ * Type-safe wrapper for convertSlice with inferred types
384
+ *
385
+ * @example
386
+ * ```typescript
387
+ * const todosSlice = createSlice({ ... })
388
+ *
389
+ * // Type is automatically inferred
390
+ * const todoList = convertSliceTyped(todosSlice, 'todoList', {
391
+ * asyncThunks: { ... }
392
+ * })
393
+ * ```
394
+ */
395
+ export function convertSliceTyped<
396
+ TSlice extends RTKSlice,
397
+ TType extends string,
398
+ >(
399
+ slice: TSlice,
400
+ typeName: TType,
401
+ options?: ConvertSliceOptions<EntityFromSlice<TSlice, TType>>,
402
+ ): InglorisousType<EntityFromSlice<TSlice, TType>>
package/types/store.d.ts CHANGED
@@ -146,3 +146,15 @@ export function createApi<
146
146
  store: Store<TEntity, TState>,
147
147
  extras?: Record<string, any>,
148
148
  ): Api<TEntity, TState>
149
+
150
+ /**
151
+ * Helper to create a set of handlers for an async operation
152
+ */
153
+ export function handleAsync<
154
+ TEntity extends BaseEntity = BaseEntity,
155
+ TPayload = any,
156
+ TResult = any,
157
+ >(
158
+ type: string,
159
+ handlers: AsyncHandlers<TEntity, TPayload, TResult>,
160
+ ): EntityType<TEntity>