@inglorious/store 12.1.1 → 12.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.
@@ -76,8 +76,6 @@ export function convertAsyncThunk(name, payloadCreator, options = {}) {
76
76
  : undefined,
77
77
 
78
78
  run: async (payload, api) => {
79
- // RTK payloadCreator signature: (arg, thunkAPI)
80
- // Use dispatch for action compatibility, with notify fallback in tests/minimal APIs.
81
79
  const dispatch = api.dispatch
82
80
  ? api.dispatch.bind(api)
83
81
  : (action) => {
@@ -170,15 +168,12 @@ export function convertSlice(slice, options = {}) {
170
168
  const { asyncThunks = {}, extraHandlers = {}, extraActions = [] } = options
171
169
 
172
170
  const type = {
173
- // Convert initialState to create handler.
174
- // Preserve explicitly preloaded fields from entities (common in tests/SSR hydration).
175
171
  create(entity) {
176
172
  const initialState = slice.getInitialState()
177
173
  Object.assign(entity, extend(initialState, entity))
178
174
  },
179
175
  }
180
176
 
181
- // Convert each reducer to an event handler
182
177
  for (const [actionName, reducer] of Object.entries(slice.caseReducers)) {
183
178
  const thunkConfig = asyncThunks[actionName]
184
179
  const isAsyncThunkReducer =
@@ -249,22 +244,15 @@ export function convertSlice(slice, options = {}) {
249
244
  }
250
245
 
251
246
  const handler = (entity, payload) => {
252
- // Create a mock action object for the RTK reducer
253
247
  const action = { type: `${slice.name}/${actionName}`, payload }
254
248
 
255
- // RTK reducers expect to mutate state directly (via Immer)
256
- // Inglorious handlers do the same (via Mutative)
257
- // So we can call the reducer directly on the entity
258
249
  reducer(entity, action)
259
250
  }
260
251
 
261
- // Expose both Inglorious-style event names and RTK action-type aliases.
262
- // This enables direct dispatch of RTK slice actions without extra bridge wiring.
263
252
  type[actionName] = handler
264
253
  type[`${slice.name}/${actionName}`] = handler
265
254
  }
266
255
 
267
- // Convert explicitly configured async thunks that are not embedded in slice reducers.
268
256
  for (const [thunkName, thunkHandlers] of Object.entries(asyncThunks)) {
269
257
  if (thunkHandlers?.payloadCreator && !type[thunkName]) {
270
258
  const asyncHandlers = convertAsyncThunk(
@@ -283,9 +271,6 @@ export function convertSlice(slice, options = {}) {
283
271
  }
284
272
  }
285
273
 
286
- // Register additional RTK action types (e.g. createAction + extraReducers).
287
- // RTK does not expose extraReducers through slice.caseReducers, so callers can
288
- // provide the action creators/types explicitly and we route them through slice.reducer.
289
274
  for (const action of extraActions) {
290
275
  const actionType = typeof action === "string" ? action : action?.type
291
276
  if (typeof actionType !== "string" || type[actionType]) continue
@@ -295,7 +280,6 @@ export function convertSlice(slice, options = {}) {
295
280
  }
296
281
  }
297
282
 
298
- // Add any extra handlers
299
283
  Object.assign(type, extraHandlers)
300
284
 
301
285
  return type
@@ -330,7 +314,6 @@ export function createMigrationGuide(slice) {
330
314
  lines.push("=".repeat(SEP_LENGTH))
331
315
  lines.push("")
332
316
 
333
- // Show state structure
334
317
  lines.push("STATE STRUCTURE:")
335
318
  lines.push("RTK:")
336
319
  lines.push(
@@ -350,7 +333,6 @@ export function createMigrationGuide(slice) {
350
333
  lines.push("=".repeat(SEP_LENGTH))
351
334
  lines.push("")
352
335
 
353
- // Show each reducer conversion
354
336
  lines.push("ACTIONS / EVENTS:")
355
337
  for (const actionName of Object.keys(slice.caseReducers)) {
356
338
  lines.push("")
@@ -365,7 +347,6 @@ export function createMigrationGuide(slice) {
365
347
  lines.push("=".repeat(SEP_LENGTH))
366
348
  lines.push("")
367
349
 
368
- // Show selector conversion
369
350
  lines.push("SELECTORS:")
370
351
  lines.push("RTK:")
371
352
  lines.push(
@@ -403,22 +384,17 @@ export function createMigrationGuide(slice) {
403
384
  export function createRTKCompatDispatch(api, entityId) {
404
385
  return (action) => {
405
386
  if (typeof action === "function") {
406
- // Thunk - not supported in compat mode
407
387
  console.warn("Thunks are not supported in RTK compat mode")
408
388
  return
409
389
  }
410
390
 
411
- // Extract action type and payload
412
391
  const { type, payload } = action
413
392
 
414
- // Convert RTK action type to Inglorious event
415
- // RTK: 'todos/addTodo' -> Inglorious: '#todos:addTodo'
416
393
  const parts = type.split("/")
417
394
  if (parts.length === SLICE_PLUS_ACTION) {
418
395
  const [, actionName] = parts
419
396
  api.notify(`#${entityId}:${actionName}`, payload)
420
397
  } else {
421
- // Fallback for non-standard action types
422
398
  api.notify(`#${entityId}:${type}`, payload)
423
399
  }
424
400
  }
package/src/store.js CHANGED
@@ -72,7 +72,7 @@ export function createStore({
72
72
  subscribe,
73
73
  update,
74
74
  notify,
75
- dispatch, // needed for compatibility with Redux
75
+ dispatch,
76
76
  getTypes,
77
77
  getType,
78
78
  setType,
@@ -119,10 +119,6 @@ export function createStore({
119
119
  return []
120
120
  }
121
121
 
122
- // Nothing queued means nothing to apply and nothing to publish: skipping the draft
123
- // entirely, rather than copying the world just to arrive back where it started.
124
- // This is what keeps a loop that pumps every animation frame cheap on the frames
125
- // where neither the game nor the player had anything to say.
126
122
  if (!incomingEvents.length) {
127
123
  return []
128
124
  }
@@ -172,9 +168,6 @@ export function createStore({
172
168
  const event = incomingEvents.shift()
173
169
  processedEvents.push(event)
174
170
 
175
- // Pausing and resuming are the store's own, and they keep dispatching like any
176
- // other event, so the game behaviour still sees them and anything else that
177
- // wants to react gets the same notification.
178
171
  if (event.type === "pause") {
179
172
  isHalted = true
180
173
  }
@@ -183,24 +176,6 @@ export function createStore({
183
176
  isHalted = false
184
177
  }
185
178
 
186
- // Handle special system events
187
- // `add` and `remove` change the world and are then dispatched like any
188
- // other event, so that whatever is watching can hear about it. They are
189
- // broadcast: the entity named by the payload is not the only one that wants
190
- // to know, and something counting down what it put up needs to hear about
191
- // each of them going.
192
- //
193
- // They are not the counterpart to `create` and `destroy`, which reach only
194
- // the entity they are about so that those read as a constructor and a
195
- // destructor without every handler beginning by checking whether it is the
196
- // one being talked about.
197
- //
198
- // The two carry different payloads, and that is on purpose: a payload
199
- // carries the least that can be had. A removal needs only the id, because
200
- // everything else about the entity can still be looked up by it. An
201
- // addition carries the entity itself, because there is nothing yet to look
202
- // it up by -- the same asymmetry as removing a user by id but updating one
203
- // by the patched user.
204
179
  if (event.type === "add") {
205
180
  addEntity(draft, event.payload)
206
181
  }
@@ -209,21 +184,6 @@ export function createStore({
209
184
  removeEntity(draft, event.payload)
210
185
  }
211
186
 
212
- // A thing that stands through a change of state can still be told a different
213
- // configuration, in either of the two ways the world offers.
214
- //
215
- // `patch` changes some of it and leaves the rest alone, which is how a screen
216
- // takes over one field of something it shares with the screen before it. A field
217
- // named as `undefined` is set to nothing rather than being left alone, which is
218
- // how a key is taken off a mapping.
219
- //
220
- // `replace` installs what it is given and drops everything it does not mention,
221
- // which is how the whole of something is said to be different.
222
- //
223
- // Neither takes the thing out of the world, so neither runs `create` or `destroy`:
224
- // it never stopped standing, and a thing that was never removed cannot have been
225
- // destroyed. This is the same pair the world has to hand for anything else --
226
- // `add` and `remove` beside them, and post/delete/put/patch before that.
227
187
  if (event.type === "patch") {
228
188
  patchEntity(draft, event.payload)
229
189
  }
@@ -232,10 +192,8 @@ export function createStore({
232
192
  replaceEntity(draft, event.payload)
233
193
  }
234
194
 
235
- // Parse the event to get handler name
236
195
  const { event: handlerName } = parseEvent(event.type)
237
196
 
238
- // Get entities that should handle this event (filtered by EventMap)
239
197
  const entityIds = eventMap.getEntitiesForEvent(event.type)
240
198
 
241
199
  // A halted world stops handing out updates, because anything that moves by
@@ -248,8 +206,6 @@ export function createStore({
248
206
  const halted = isHalted && handlerName === "update"
249
207
 
250
208
  for (const id of entityIds) {
251
- // Looked at before it is taken, because an entity the pause holds back is
252
- // not going to be written to, and taking it would copy it for nothing.
253
209
  const entity = draft.entities[id]
254
210
  const type = types[entity.type]
255
211
 
@@ -273,7 +229,6 @@ export function createStore({
273
229
  }
274
230
  }
275
231
 
276
- // Systems process events by handler name (not scoped)
277
232
  systems.forEach((system) => {
278
233
  const handle = system[handlerName]
279
234
  handle?.(draft.forSystems, event.payload, api)
@@ -338,7 +293,8 @@ export function createStore({
338
293
  * Sets an augmented type configuration given its name. Replacing a type, as
339
294
  * hot module reloading does, notifies subscribers, since what its entities
340
295
  * render may have changed. Adding a type does not: the store is unchanged,
341
- * and a type can be added while rendering.
296
+ * and a type can be added while rendering. Entities already of a type being
297
+ * added get their `create` now: when they were added, it reached no handler.
342
298
  * @param {string} typeName - The name of the type to set.
343
299
  * @param {Type} type - The type configuration.
344
300
  * @returns {void}
@@ -354,6 +310,7 @@ export function createStore({
354
310
  if (entity.type === typeName) {
355
311
  eventMap.removeEntity(id, oldType, typeName)
356
312
  eventMap.addEntity(id, newType, typeName)
313
+ if (!oldType) notify(`#${id}:create`)
357
314
  }
358
315
  }
359
316
 
@@ -516,9 +473,6 @@ export function createStore({
516
473
  // does not mention, and what it keeps must not be shared with the previous state.
517
474
  const existing = draft.take(id)
518
475
 
519
- // Configuring something that is not there is not a thing that can be done. The world
520
- // is built in one pass, so a scene naming a thing another scene is yet to put up is
521
- // ordinary rather than a mistake, and is not worth failing over.
522
476
  if (!existing) return
523
477
 
524
478
  draft.put(id, augmentEntity(id, { ...existing, ...config }))
@@ -561,6 +515,22 @@ export function createStore({
561
515
  }
562
516
  }
563
517
 
518
+ /**
519
+ * Defines a behavior for an entity. It does nothing at runtime: it hands back
520
+ * the behavior it is given, and JavaScript apps never need it.
521
+ *
522
+ * It is for TypeScript apps, like Vue's `defineComponent` or Vite's
523
+ * `defineConfig`: its declaration in `types/store.d.ts` checks each member of
524
+ * the behavior by its name, so that a template's sub-renderers can take props
525
+ * while the handlers still get the entity, which a plain annotation cannot
526
+ * express. See that declaration for why.
527
+ *
528
+ * @returns {(behavior: object) => object} A function that returns the behavior it is given.
529
+ */
530
+ export function defineType() {
531
+ return (behavior) => behavior
532
+ }
533
+
564
534
  /**
565
535
  * A draft that writes straight into the entities it is given.
566
536
  *
package/types/api.d.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  Store,
8
8
  Stored,
9
9
  } from "./store"
10
+ import type { AnyEvents, Notify } from "./events"
10
11
 
11
12
  /**
12
13
  * API object exposed to handlers.
@@ -14,10 +15,14 @@ import type {
14
15
  * An app's own `Api<AppEntity, AppState>` is an `Api`: a component typed with
15
16
  * the plain `Api` accepts it. So the members that read types give the plain
16
17
  * `EntityType`, rather than one typed for the app's entities.
18
+ *
19
+ * Told the app's events, `Api<AppEntity, AppState, AppEvents>` checks what
20
+ * `notify` sends (see `EventsOf`), and is still an `Api`.
17
21
  */
18
22
  export interface Api<
19
23
  TEntity extends BaseEntity = BaseEntity,
20
24
  TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
25
+ TEvents = AnyEvents,
21
26
  > {
22
27
  getTypes: () => Record<string, EntityType>
23
28
  getType: (typeName: string) => EntityType
@@ -35,8 +40,8 @@ export interface Api<
35
40
  }
36
41
  select: <TResult>(selector: (state: TState) => TResult) => TResult
37
42
  dispatch: (event: Event) => void
38
- notify: (type: string, payload?: any) => void
39
- [key: string]: any // For middleware extras
43
+ notify: Notify<TEvents, TEntity>
44
+ [key: string]: any
40
45
  }
41
46
 
42
47
  /**
@@ -45,7 +50,8 @@ export interface Api<
45
50
  export function createApi<
46
51
  TEntity extends BaseEntity = BaseEntity,
47
52
  TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
53
+ TEvents = AnyEvents,
48
54
  >(
49
- store: Store<TEntity, TState>,
55
+ store: Store<TEntity, TState, TEvents>,
50
56
  extras?: Record<string, any>,
51
- ): Api<TEntity, TState>
57
+ ): Api<TEntity, TState, TEvents>
@@ -73,6 +73,9 @@ export interface AsyncHandlers<
73
73
  * ```
74
74
  *
75
75
  * The `scope` option changes where the events are sent, not their names.
76
+ *
77
+ * Each call gets a `callId` that all its events carry, so that a wrapper such
78
+ * as `optimistic` can tell overlapping calls apart.
76
79
  */
77
80
  export type AsyncEvents<
78
81
  TType extends string,
@@ -85,27 +88,38 @@ export type AsyncEvents<
85
88
  } & {
86
89
  [K in `${TType}Start`]?: (
87
90
  entity: TEntity,
88
- payload: TPayload,
91
+ event: AsyncCall<{ payload: TPayload }>,
89
92
  api: TApi,
90
93
  ) => void
91
94
  } & {
92
95
  [K in `${TType}Run`]: (
93
96
  entity: TEntity,
94
- payload: TPayload,
97
+ event: AsyncCall<{ payload: TPayload }>,
95
98
  api: TApi,
96
99
  ) => Promise<void>
97
100
  } & {
98
101
  [K in `${TType}Success`]: (
99
102
  entity: TEntity,
100
- result: TResult,
103
+ event: AsyncCall<{ result: TResult }>,
101
104
  api: TApi,
102
105
  ) => void
103
106
  } & {
104
- [K in `${TType}Error`]: (entity: TEntity, error: any, api: TApi) => void
107
+ [K in `${TType}Error`]: (
108
+ entity: TEntity,
109
+ event: AsyncCall<{ error: any }>,
110
+ api: TApi,
111
+ ) => void
105
112
  } & {
106
- [K in `${TType}Finally`]: (entity: TEntity, payload: void, api: TApi) => void
113
+ [K in `${TType}Finally`]: (
114
+ entity: TEntity,
115
+ event: AsyncCall,
116
+ api: TApi,
117
+ ) => void
107
118
  }
108
119
 
120
+ /** What an event of one call of an async behavior carries: its `callId`. */
121
+ export type AsyncCall<T = {}> = T & { callId: number }
122
+
109
123
  /**
110
124
  * Creates a set of event handlers for managing an asynchronous operation lifecycle.
111
125
  *
@@ -1,5 +1,5 @@
1
1
  import { Api } from "../api"
2
- import { AsyncEvents } from "./async"
2
+ import { AsyncCall, AsyncEvents } from "./async"
3
3
  import { BaseEntity } from "../store"
4
4
 
5
5
  /**
@@ -9,6 +9,10 @@ import { BaseEntity } from "../store"
9
9
  * The wrapper snapshots the current values for those keys, applies the patch
10
10
  * on start, and restores the previous values automatically if the operation fails.
11
11
  *
12
+ * Each call keeps its own snapshot, so calls can overlap. Give `rollback` to
13
+ * undo a failed call's change yourself, such as removing the temporary item it
14
+ * added to a list, in place of restoring the snapshot.
15
+ *
12
16
  * The behavior keeps the events `handleAsync` named and typed, and always has
13
17
  * a start event.
14
18
  */
@@ -27,10 +31,13 @@ export function optimistic<
27
31
  payload: TPayload,
28
32
  api: TApi,
29
33
  ) => Record<string, any> | null | undefined),
34
+ options?: {
35
+ rollback?: (entity: TEntity, payload: TPayload, api: TApi) => void
36
+ },
30
37
  ): AsyncEvents<TType, TEntity, TPayload, TResult, TApi> & {
31
38
  [K in `${TType}Start`]: (
32
39
  entity: TEntity,
33
- payload: TPayload,
40
+ event: AsyncCall<{ payload: TPayload }>,
34
41
  api: TApi,
35
42
  ) => void
36
43
  }
@@ -0,0 +1,149 @@
1
+ // The events a store answers, so that `notify` can check them: the event's
2
+ // name, wherever it is sent, and its payload.
3
+
4
+ import type { BaseEntity, Stored } from "./store"
5
+
6
+ declare const anyEvents: unique symbol
7
+
8
+ /**
9
+ * The events of an api or a store that has not been told its events: any
10
+ * name, any payload. It is the default, so an untyped app type-checks as
11
+ * before.
12
+ */
13
+ export type AnyEvents = { readonly [anyEvents]: true }
14
+
15
+ /**
16
+ * The events a store's types answer, by name, with their payloads, read from
17
+ * the types' own declarations: the second parameter of each handler is its
18
+ * payload, and a handler that takes none has no payload. `render` and the
19
+ * other `render…` members are renderers, not events, so they are left out.
20
+ *
21
+ * ```ts
22
+ * type CounterType = {
23
+ * add: (entity: CounterEntity, amount: number) => void
24
+ * reset: (entity: CounterEntity) => void
25
+ * }
26
+ *
27
+ * type AppEvents = EventsOf<CounterType | LabelType>
28
+ * // { add: number; reset: void; ...the label's events }
29
+ *
30
+ * type AppApi = Api<AppEntity, AppState, AppEvents>
31
+ * ```
32
+ *
33
+ * An event two types answer takes either type's payload.
34
+ */
35
+ export type EventsOf<TTypes> = {
36
+ [TEvent in HandlerName<TTypes>]: TTypes extends unknown
37
+ ? TEvent extends keyof TTypes
38
+ ? PayloadOf<TTypes[TEvent]>
39
+ : never
40
+ : never
41
+ }
42
+
43
+ /**
44
+ * The events the store answers itself, whatever its types: an entity
45
+ * arriving, leaving or changing, and the world pausing and resuming.
46
+ */
47
+ export type BuiltInEvents<TEntity extends BaseEntity = BaseEntity> = {
48
+ add: TEntity & { id: string }
49
+ remove: string
50
+ patch: Partial<TEntity> & { id: string }
51
+ replace: Stored<TEntity>
52
+ pause: void
53
+ resume: void
54
+ update: number | undefined
55
+ }
56
+
57
+ /**
58
+ * How an event is sent: by its name alone, broadcast to whoever answers it,
59
+ * or after a target that narrows it down to a type (`Type:event`), an entity
60
+ * (`#id:event`) or both (`Type#id:event`). Only the event's name is checked:
61
+ * the target is usually built at runtime, such as `` `#${entity.id}:submit` ``.
62
+ */
63
+ export type EventType<TEvents> =
64
+ EventName<TEvents> | `${string}:${EventName<TEvents>}`
65
+
66
+ /**
67
+ * Sends an event. Told the store's events, it checks the event's name and
68
+ * its payload: `notify("#debate:lineAdd", line)` must name an event the
69
+ * store's types answer, and `line` must be that event's payload. An event
70
+ * with no payload takes none. Otherwise any name and payload go.
71
+ */
72
+ export type Notify<
73
+ TEvents = AnyEvents,
74
+ TEntity extends BaseEntity = BaseEntity,
75
+ > = AnyEvents extends TEvents
76
+ ? (type: string, payload?: any) => void
77
+ : TypedNotify<WithBuiltIns<TEvents, BuiltInEvents<TEntity>>>
78
+
79
+ type EventName<TEvents> = keyof TEvents & string
80
+
81
+ /**
82
+ * The app's events and the store's own, by name. An app event named like a
83
+ * built-in one, such as an `add` that takes an amount, takes either payload,
84
+ * because its handlers hear both: the store broadcasts entities as `add` too.
85
+ */
86
+ type WithBuiltIns<TEvents, TBuiltIns> = {
87
+ [K in keyof TEvents | keyof TBuiltIns]:
88
+ | (K extends keyof TEvents ? TEvents[K] : never)
89
+ | (K extends keyof TBuiltIns ? TBuiltIns[K] : never)
90
+ }
91
+
92
+ /** The event a sent type names: what follows its target, if it has one. */
93
+ type EventOf<TType extends string> = TType extends `${string}:${infer TEvent}`
94
+ ? TEvent
95
+ : TType
96
+
97
+ /** The payload as `notify`'s last parameters: none, optional or required. */
98
+ type PayloadArgs<TPayload> = [TPayload] extends [void]
99
+ ? [payload?: undefined]
100
+ : undefined extends TPayload
101
+ ? [payload?: TPayload]
102
+ : [payload: TPayload]
103
+
104
+ /**
105
+ * A typed `notify`, declared as a method and taken out by name, with an
106
+ * overload that nothing can call: it only lets a typed api or store fit
107
+ * where a plain one is expected, such as a component that takes any app's
108
+ * api. It comes first, so that a call that fits neither is explained by the
109
+ * typed one.
110
+ */
111
+ type TypedNotify<TEvents> = {
112
+ notify(type: never, payload: never): void
113
+ notify<TType extends EventType<TEvents>>(
114
+ type: TType,
115
+ ...payload: PayloadArgs<TEvents[EventOf<TType> & keyof TEvents]>
116
+ ): void
117
+ }["notify"]
118
+
119
+ /**
120
+ * The handlers' names of each type: every function but the renderers. Only
121
+ * named members count: a type that also takes any handler, through an index
122
+ * signature such as `[handler: string]: unknown`, keeps its named events.
123
+ */
124
+ type HandlerName<TTypes> = TTypes extends unknown
125
+ ? keyof {
126
+ [
127
+ K in keyof TTypes as string extends K
128
+ ? never
129
+ : number extends K
130
+ ? never
131
+ : K extends `render${string}`
132
+ ? never
133
+ : TTypes[K] extends (...args: never[]) => unknown
134
+ ? K
135
+ : never
136
+ ]: true
137
+ } &
138
+ string
139
+ : never
140
+
141
+ /** A handler's payload: its second parameter, or none if it takes only the entity. */
142
+ type PayloadOf<THandler> = THandler extends (
143
+ entity: never,
144
+ ...rest: infer TRest
145
+ ) => unknown
146
+ ? TRest extends []
147
+ ? void
148
+ : TRest[0]
149
+ : never
package/types/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./api"
2
2
  export * from "./behaviors/async"
3
+ export * from "./events"
3
4
  export * from "./select"
4
5
  export * from "./store"
5
6
  export * from "./test"
@@ -6,8 +6,6 @@ import { Api, BaseEntity } from "../store"
6
6
  */
7
7
  export interface ThunkAPI {
8
8
  dispatch: Api["notify"]
9
- // Add other thunk API properties as needed
10
- // getState, extra, requestId, signal, rejectWithValue, fulfillWithValue
11
9
  }
12
10
 
13
11
  /**