@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.
@@ -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/src/store.js CHANGED
@@ -13,6 +13,7 @@ import { augmentType, augmentTypes } from "./types.js"
13
13
  * @param {Object} [config.entities] - The initial entities configuration.
14
14
  * @param {Array} [config.systems] - The initial systems configuration.
15
15
  * @param {Array} [config.middlewares] - The initial middlewares configuration.
16
+ * @param {boolean} [config.autoCreateEntities] - Creates entities if not defined in `config.entities`.
16
17
  * @param {"auto" | "manual"} [config.updateMode] - The update mode (defaults to "auto").
17
18
  * @returns {Object} The store with methods to interact with state and events.
18
19
  */
@@ -21,6 +22,7 @@ export function createStore({
21
22
  entities: originalEntities = {},
22
23
  systems = [],
23
24
  middlewares = [],
25
+ autoCreateEntities = false,
24
26
  updateMode = "auto",
25
27
  } = {}) {
26
28
  const listeners = new Set()
@@ -220,8 +222,25 @@ export function createStore({
220
222
  const oldEntities = state ?? {}
221
223
  const newEntities = augmentEntities(nextState)
222
224
 
225
+ if (autoCreateEntities) {
226
+ for (const typeName of Object.keys(types)) {
227
+ // Check if entity already exists
228
+ const hasEntity = Object.values(newEntities).some(
229
+ (entity) => entity.type === typeName,
230
+ )
231
+
232
+ if (!hasEntity) {
233
+ // No entity for this type → auto-create minimal entity
234
+ newEntities[typeName] = {
235
+ id: typeName,
236
+ type: typeName,
237
+ }
238
+ }
239
+ }
240
+ }
241
+
223
242
  state = newEntities
224
- eventMap = new EventMap(types, nextState)
243
+ eventMap = new EventMap(types, newEntities)
225
244
  incomingEvents = []
226
245
  isProcessing = false
227
246
 
@@ -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"