@inglorious/store 9.2.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 +206 -0
- package/package.json +5 -1
- package/src/async.js +187 -0
- package/src/migration/rtk.js +326 -0
- package/types/async.d.ts +146 -0
- package/types/index.d.ts +1 -0
- package/types/migration/rtk.d.ts +402 -0
- package/types/store.d.ts +12 -0
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.
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/types/async.d.ts
ADDED
|
@@ -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
|
@@ -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>
|