@inglorious/store 12.2.0 → 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.
package/README.md CHANGED
@@ -1659,7 +1659,52 @@ interface SalesEntity extends ChartEntity {
1659
1659
  // types: { Router, Sales: Chart }
1660
1660
  ```
1661
1661
 
1662
- **5. Test your handlers**
1662
+ **5. Check the events you send**
1663
+
1664
+ The type declarations already list every event and its payload, so the api can check what `notify` sends. Read the events from them with `EventsOf`, and give them to the api as its third type argument:
1665
+
1666
+ ```typescript
1667
+ // types/index.d.ts
1668
+ import type { EventsOf } from "@inglorious/store"
1669
+
1670
+ export type AppEvents = EventsOf<CounterType | LabelType>
1671
+ // { increment: void; add: number; copyLabel: void; ...the label's events }
1672
+
1673
+ export type AppApi = Api<AppEntity, AppState, AppEvents>
1674
+ ```
1675
+
1676
+ Now every handler's `api.notify` checks the event's name, wherever it is sent (`"add"`, `"Counter:add"`, `` `#${entity.id}:add` ``), and its payload:
1677
+
1678
+ ```typescript
1679
+ api.notify(`#${entity.id}:add`, 2) // fine
1680
+ api.notify(`#${entity.id}:ad`, 2) // error: no type answers `ad`
1681
+ api.notify(`#${entity.id}:add`, "2") // error: `add` takes a number
1682
+ api.notify(`#${entity.id}:increment`, 1) // error: `increment` takes no payload
1683
+ ```
1684
+
1685
+ The store's own events (`add`, `remove`, `patch`, `replace`, `pause`, `resume`, `update`) are always there. Only the event's name is checked, not the target before it, because the target is usually built at runtime. Pass the events to `createStore<AppEntity, AppState, AppEvents>` too, so that `store.notify` checks them, and `mount` gives them to the root render. A typed api is still an `Api`: components that take any app's api accept it.
1686
+
1687
+ Without the third type argument, `notify` takes any name and any payload, as before.
1688
+
1689
+ **6. Give sub-renderers their props**
1690
+
1691
+ Every render method takes props first: `render(props, api)`, where a type's props are its entity, and sub-renderers that draw one item, `renderX(props, ownProps, api)`, which get the component's props, then the item's own (`renderLine(props, line, api)`). `EntityType` reads every member as a handler, whose first parameter is the entity, so it rejects sub-renderers whose props are not the entity. `defineType` checks each member by its name instead: `render` takes the entity as its props, a sub-renderer keeps the types it declares, and every other member is a handler, whose entity and api are inferred:
1692
+
1693
+ ```typescript
1694
+ import { defineType } from "@inglorious/store"
1695
+
1696
+ export const Scoreboard = defineType<ScoreboardEntity, AppApi>()({
1697
+ ...template, // renderScoreboard(props, api), renderContender(props, speaker, api), ...
1698
+ render(props, api) {
1699
+ const debate = api.getEntity("debate")
1700
+ return this.renderScoreboard({ ...props, memory: debate?.memory }, api)
1701
+ },
1702
+ })
1703
+ ```
1704
+
1705
+ Inside its methods, `this` is the behavior, so `this.renderScoreboard` is checked too. It takes the entity and the api first, then the behavior, because TypeScript cannot infer one while being told the others. The result is an `EntityType`, so it goes in `types` or in a list of behaviors like any other: `[Chat, handlers, fsm(states), defineType<DebateEntity, AppApi>()({ ...template, render })]`.
1706
+
1707
+ **7. Test your handlers**
1663
1708
 
1664
1709
  ```typescript
1665
1710
  import { trigger } from "@inglorious/store/test"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "12.2.0",
3
+ "version": "12.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": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -78,11 +78,11 @@
78
78
  "mutative": "^1.3.0"
79
79
  },
80
80
  "peerDependencies": {
81
- "@inglorious/utils": "^5.1.0"
81
+ "@inglorious/utils": "^5.2.0"
82
82
  },
83
83
  "devDependencies": {
84
84
  "@inglorious/eslint-config": "1.2.0",
85
- "@inglorious/utils": "5.1.0",
85
+ "@inglorious/utils": "5.2.0",
86
86
  "@reduxjs/toolkit": "^2.12.0",
87
87
  "prettier": "^3.9.6",
88
88
  "typescript": "~7.0.2",
package/src/store.js CHANGED
@@ -515,6 +515,22 @@ export function createStore({
515
515
  }
516
516
  }
517
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
+
518
534
  /**
519
535
  * A draft that writes straight into the entities it is given.
520
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,7 +40,7 @@ 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
43
+ notify: Notify<TEvents, TEntity>
39
44
  [key: string]: any
40
45
  }
41
46
 
@@ -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>
@@ -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"
package/types/store.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Api } from "./api"
2
+ import type { AnyEvents, Notify } from "./events"
2
3
 
3
4
  /**
4
5
  * What every entity has. Declare an entity's other fields on its own interface:
@@ -111,6 +112,68 @@ export type TypeDefinition<
111
112
  */
112
113
  export type AnyTypeDefinition = TypeDefinition<never, never>
113
114
 
115
+ /**
116
+ * What each member of a type must be, told by its name: `render(props, api)`
117
+ * draws the type, and a type's props are its entity; any other `render…` is
118
+ * a sub-renderer, `renderX(props, ownProps, api)`, which keeps the types it
119
+ * declares; every other member handles an event.
120
+ */
121
+ export type CheckedType<
122
+ TType,
123
+ TEntity extends BaseEntity = BaseEntity,
124
+ TApi extends Api = Api,
125
+ > = {
126
+ [K in keyof TType]: K extends "render"
127
+ ? Renderer<TEntity, TApi>
128
+ : K extends `render${string}`
129
+ ? TType[K]
130
+ : EventHandler<TEntity, TApi>
131
+ }
132
+
133
+ /**
134
+ * For TypeScript only. At runtime it hands back the behavior it is given, and
135
+ * JavaScript apps never need it: it exists so that TypeScript can check a
136
+ * behavior that a plain annotation cannot describe, the same trick as Vue's
137
+ * `defineComponent` or Vite's `defineConfig`.
138
+ *
139
+ * Why it is needed: an annotation such as `EntityType<ScoreboardEntity, AppApi>`
140
+ * checks every member as an event handler, `(entity, payload, api)`. That is
141
+ * wrong for a template's sub-renderers, `renderX(props, ownProps, api)`,
142
+ * whose first parameter is the component's props rather than the entity, so
143
+ * the annotation rejects them. TypeScript cannot exempt members by their name
144
+ * in an annotation; it can only check them one by one after inferring the
145
+ * object, which takes a generic function. This is that function.
146
+ *
147
+ * What it checks, member by member: `render(props, api)` takes the entity as
148
+ * its props; any other `render…` keeps the types it declares; every other
149
+ * member is an event handler, whose entity and api are inferred. Inside its
150
+ * methods, `this` is the behavior itself.
151
+ *
152
+ * Use it only for a behavior whose sub-renderers take props; otherwise an
153
+ * annotation is enough. It takes the entity and the api first, then the
154
+ * behavior, because TypeScript cannot infer the behavior while being told
155
+ * the rest:
156
+ *
157
+ * ```ts
158
+ * export const Scoreboard = defineType<ScoreboardEntity, AppApi>()({
159
+ * ...template, // renderScoreboard(props, api), renderContender(props, speaker, api), ...
160
+ * render(props, api) {
161
+ * return this.renderScoreboard({ ...props, memory: readMemory(api) }, api)
162
+ * },
163
+ * })
164
+ * ```
165
+ *
166
+ * The result is an `EntityType` for the entity, so it goes wherever a
167
+ * behavior goes: in the store's `types`, or in a list of behaviors such as
168
+ * `[Chat, handlers, fsm(states), defineType<DebateEntity, AppApi>()({ ... })]`.
169
+ */
170
+ export function defineType<
171
+ TEntity extends BaseEntity,
172
+ TApi extends Api = Api,
173
+ >(): <TType>(
174
+ behavior: CheckedType<TType, TEntity, TApi> & ThisType<TType>,
175
+ ) => EntityType<TEntity, TApi>
176
+
114
177
  /**
115
178
  * System definition with event handlers
116
179
  */
@@ -134,12 +197,13 @@ export type EntitiesState<TEntity extends BaseEntity = BaseEntity> = {
134
197
  export type TypesConfig<
135
198
  TEntity extends BaseEntity = BaseEntity,
136
199
  TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
200
+ TEvents = AnyEvents,
137
201
  > = string extends TEntity["type"]
138
202
  ? { [typeName: string]: AnyTypeDefinition }
139
203
  : {
140
204
  [TName in TEntity["type"]]?: TypeDefinition<
141
205
  Extract<TEntity, { type: TName }>,
142
- Api<TEntity, TState>
206
+ Api<TEntity, TState, TEvents>
143
207
  >
144
208
  } & { [typeName: string]: unknown }
145
209
 
@@ -172,10 +236,11 @@ export type UpdateStrategy = "structural-sharing" | "copy-on-dispatch"
172
236
  export interface StoreConfig<
173
237
  TEntity extends BaseEntity = BaseEntity,
174
238
  TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
239
+ TEvents = AnyEvents,
175
240
  > {
176
241
  // The entity type comes from the type arguments, `entities` or
177
242
  // `middlewares`, never from `types`, which are checked against it.
178
- types?: NoInfer<TypesConfig<TEntity, TState>>
243
+ types?: NoInfer<TypesConfig<TEntity, TState, TEvents>>
179
244
  /** The initial entities. With `autoCreateEntities`, those the store creates itself can be left out. */
180
245
  entities?: Partial<TState>
181
246
  systems?: System<TState>[]
@@ -201,10 +266,12 @@ export type Unsubscribe = () => void
201
266
  export interface Store<
202
267
  TEntity extends BaseEntity = BaseEntity,
203
268
  TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
269
+ TEvents = AnyEvents,
204
270
  > {
205
271
  subscribe: (listener: Listener) => Unsubscribe
206
272
  update: () => Event[]
207
- notify: (type: string, payload?: any) => void
273
+ /** Sends an event; told the store's events, it checks them (see `Notify`). */
274
+ notify: Notify<TEvents, TEntity>
208
275
  dispatch: (event: Event) => void
209
276
  getTypes: () => Record<string, EntityType>
210
277
  getType: (typeName: string) => EntityType
@@ -218,7 +285,7 @@ export interface Store<
218
285
  }
219
286
  setState: (nextState: TState) => void
220
287
  reset: () => void
221
- _api?: Api<TEntity, TState>
288
+ _api?: Api<TEntity, TState, TEvents>
222
289
  extras?: Record<string, any>
223
290
  }
224
291
 
@@ -248,9 +315,14 @@ export type AddEventPayload<TEntity extends BaseEntity = BaseEntity> =
248
315
  export type RemoveEventPayload = string
249
316
 
250
317
  /**
251
- * Creates a store to manage state and events
318
+ * Creates a store to manage state and events. Told the app's events as a
319
+ * third type argument, its `notify` and its handlers' api check them:
320
+ * `createStore<AppEntity, AppState, AppEvents>({ ... })`.
252
321
  */
253
322
  export function createStore<
254
323
  TEntity extends BaseEntity = BaseEntity,
255
324
  TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
256
- >(config: StoreConfig<TEntity, TState>): Store<TEntity, TState>
325
+ TEvents = AnyEvents,
326
+ >(
327
+ config: StoreConfig<TEntity, TState, TEvents>,
328
+ ): Store<TEntity, TState, TEvents>