@inglorious/store 11.0.5 → 11.2.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
@@ -1381,75 +1381,145 @@ store.dispatch({ type: "eventName", payload }) // Redux-compatible alternative
1381
1381
 
1382
1382
  ### 🧩 Type Safety
1383
1383
 
1384
- Inglorious Store is written in JavaScript but comes with powerful TypeScript support out of the box, allowing for a fully type-safe experience similar to Redux Toolkit, but with less boilerplate.
1384
+ Inglorious Store is written in JavaScript but comes with TypeScript types that check your whole app with no casts: each type against its entity, each handler's parameters, and every entity read through the api.
1385
1385
 
1386
- You can achieve strong type safety by defining an interface for your `types` configuration. This allows you to statically define the shape of your entity handlers, ensuring that all required handlers are present and correctly typed.
1386
+ **1. Describe your entities and state**
1387
1387
 
1388
- Here’s how you can set it up for a TodoMVC-style application:
1388
+ ```typescript
1389
+ // types/index.d.ts
1390
+ import type {
1391
+ Api,
1392
+ BaseEntity,
1393
+ EntitiesState,
1394
+ EntityType,
1395
+ } from "@inglorious/store"
1396
+
1397
+ export interface CounterEntity extends BaseEntity {
1398
+ type: "Counter"
1399
+ value: number
1400
+ }
1401
+
1402
+ export interface LabelEntity extends BaseEntity {
1403
+ type: "Label"
1404
+ text: string
1405
+ }
1406
+
1407
+ export type AppEntity = CounterEntity | LabelEntity
1408
+
1409
+ // The entities by id
1410
+ export interface AppState extends EntitiesState<AppEntity> {
1411
+ counter: CounterEntity
1412
+ label: LabelEntity
1413
+ }
1414
+
1415
+ export type AppApi = Api<AppEntity, AppState>
1416
+
1417
+ // A type for one of the app's entities
1418
+ export type AppType<TEntity extends AppEntity> = EntityType<TEntity, AppApi>
1419
+ ```
1389
1420
 
1390
- **1. Define Your Types**
1421
+ Every field of an entity is declared, so a misspelled one is an error. The store gives each entity its key in the state as `id`: leave it out of the initial entities, and handlers receive it as a `string`. With `@inglorious/web`, import `Api` from there instead: it is the same type, with `render`.
1391
1422
 
1392
- First, create an interface that describes your entire `types` configuration. This interface will enforce the structure of your event handlers.
1423
+ **2. Write your types**
1424
+
1425
+ Declare each type's events and their payloads, then write the type against that declaration:
1393
1426
 
1394
1427
  ```typescript
1395
- // src/store/types.ts
1396
- import type {
1397
- FormEntity,
1398
- ListEntity,
1399
- FooterEntity,
1400
- // ... other payload types
1401
- } from "../../types"
1402
-
1403
- // Define the static shape of the types configuration
1404
- interface TodoListTypes {
1405
- form: {
1406
- inputChange: (entity: FormEntity, value: string) => void
1407
- formSubmit: (entity: FormEntity) => void
1408
- }
1409
- list: {
1410
- formSubmit: (entity: ListEntity, value: string) => void
1411
- toggleClick: (entity: ListEntity, id: number) => void
1412
- // ... other handlers
1413
- }
1414
- footer: {
1415
- filterClick: (entity: FooterEntity, id: string) => void
1416
- }
1428
+ // types/index.d.ts
1429
+ export type CounterType = {
1430
+ increment: (entity: CounterEntity) => void
1431
+ add: (entity: CounterEntity, amount: number) => void
1432
+ copyLabel: (entity: CounterEntity, payload: void, api: AppApi) => void
1417
1433
  }
1434
+ ```
1418
1435
 
1419
- export const types: TodoListTypes = {
1420
- form: {
1421
- inputChange(entity, value) {
1422
- entity.value = value
1423
- },
1424
- formSubmit(entity) {
1425
- entity.value = ""
1426
- },
1436
+ ```typescript
1437
+ // src/store/counter.ts
1438
+ import type { CounterType } from "../../types"
1439
+
1440
+ export const Counter: CounterType = {
1441
+ increment(entity) {
1442
+ entity.value++
1443
+ },
1444
+
1445
+ add(entity, amount) {
1446
+ entity.value += amount
1447
+ },
1448
+
1449
+ copyLabel(entity, _payload, api) {
1450
+ entity.value = api.getEntity("label")?.text.length ?? 0
1427
1451
  },
1428
- // ... other type implementations
1429
1452
  }
1430
1453
  ```
1431
1454
 
1432
- With `TodoListTypes`, TypeScript will throw an error if you forget a handler (e.g., `formSubmit`) or if its signature is incorrect.
1455
+ The parameters are inferred from `CounterType`, and TypeScript checks the type against it: a misspelled or missing handler is an error, and so is `Counter.add(counter, "2")`. Declare it with `type`, not `interface`: an interface does not fit the store's `types`.
1433
1456
 
1434
- **2. Create the Store**
1457
+ For less rigour with less to write, skip the declaration and use the shorthand, `satisfies`. It still infers the entity and the api, but it neither lists the events nor types a payload you do not annotate:
1435
1458
 
1436
- When creating your store, you'll pass the `types` object. To satisfy the store's generic `TypesConfig`, you may need to use a double cast (`as unknown as`). This is a safe and intentional way to bridge your specific, statically-checked configuration with the store's more generic type.
1459
+ ```typescript
1460
+ export const Counter = {
1461
+ increment(entity) {
1462
+ entity.value++
1463
+ },
1464
+ add(entity, amount: number) {
1465
+ entity.value += amount
1466
+ },
1467
+ } satisfies AppType<CounterEntity>
1468
+ ```
1469
+
1470
+ **3. Create the store**
1437
1471
 
1438
1472
  ```typescript
1439
1473
  // src/store/index.ts
1440
- import { createStore, type TypesConfig } from "@inglorious/store"
1441
- import { types } from "./types"
1442
- import type { TodoListEntity, TodoListState } from "../../types"
1474
+ import { createStore } from "@inglorious/store"
1443
1475
 
1444
- export const store = createStore<TodoListEntity, TodoListState>({
1445
- types: types as unknown as TypesConfig<TodoListEntity>,
1446
- // ... other store config
1476
+ import type { AppEntity, AppState } from "../../types"
1477
+ import { Counter } from "./counter"
1478
+ import { Label } from "./label"
1479
+
1480
+ export const store = createStore<AppEntity, AppState>({
1481
+ types: { Counter, Label },
1482
+ entities: {
1483
+ counter: { type: "Counter", value: 0 },
1484
+ label: { type: "Label", text: "Hello" },
1485
+ },
1447
1486
  })
1448
1487
  ```
1449
1488
 
1450
- **3. Enjoy Full Type Safety**
1489
+ Each type is checked against the entities of its name: `Counter` against `CounterEntity`. A name no entity has, such as a page rendered by name, takes any type. Behaviors type the same way: `Live: [Chart, withRealtime]`.
1490
+
1491
+ **4. Use types from libraries**
1492
+
1493
+ An entity drawn by a library type extends that type's entity interface:
1494
+
1495
+ ```typescript
1496
+ import type { ChartEntity } from "@inglorious/charts"
1497
+ import type { RouterEntity } from "@inglorious/web/router"
1498
+
1499
+ interface AppRouterEntity extends RouterEntity {
1500
+ type: "Router"
1501
+ }
1502
+
1503
+ interface SalesEntity extends ChartEntity {
1504
+ type: "Sales"
1505
+ }
1506
+
1507
+ // types: { Router, Sales: Chart }
1508
+ ```
1509
+
1510
+ **5. Test your handlers**
1511
+
1512
+ ```typescript
1513
+ import { trigger } from "@inglorious/store/test"
1514
+
1515
+ const { entity, events } = trigger(counter, Counter.add, 2)
1516
+ ```
1517
+
1518
+ `trigger` checks the payload against the handler, and returns the updated entity, typed.
1519
+
1520
+ **Upgrading from casts**
1451
1521
 
1452
- Now, your store is fully type-safe. The hooks provided by `@inglorious/react-store` will also be correctly typed.
1522
+ Older apps passed `types` as `types as unknown as TypesConfig<AppEntity>` and described each type with an interface. Drop the cast, turn each `interface CounterType {` into `type CounterType = {`, and declare every entity field, which `BaseEntity` no longer allows by default. An entity that extended `BaseEntity, Omit<RouterEntity, "id">` now just extends `RouterEntity`.
1453
1523
 
1454
1524
  ---
1455
1525
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "11.0.5",
3
+ "version": "11.2.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",
@@ -65,6 +65,7 @@
65
65
  "@inglorious/eslint-config": "1.1.4",
66
66
  "@reduxjs/toolkit": "^2.12.0",
67
67
  "prettier": "^3.9.6",
68
+ "typescript": "~7.0.2",
68
69
  "vite": "^8.2.2",
69
70
  "vitest": "^5.0.0"
70
71
  },
@@ -75,6 +76,7 @@
75
76
  "format": "prettier --write .",
76
77
  "lint": "eslint .",
77
78
  "test:watch": "vitest",
78
- "test": "vitest run"
79
+ "test": "vitest run && tsc -p type-tests",
80
+ "test:types": "tsc -p type-tests"
79
81
  }
80
82
  }
package/src/optimistic.js CHANGED
@@ -11,12 +11,13 @@ const START = 0
11
11
  * @template {import('./store').BaseEntity} TEntity
12
12
  * @template TPayload
13
13
  * @template TResult
14
+ * @template {string} TType
14
15
  *
15
- * @param {import('./async').AsyncEventHandlers<TEntity, TPayload, TResult>} behavior
16
+ * @param {import('./async').AsyncEvents<TType, TEntity, TPayload, TResult>} behavior
16
17
  * @param {Record<string, any> | ((entity: TEntity, payload: TPayload, api: import('./store').Api) => Record<string, any> | null | undefined)} optimistic
17
18
  * Shallow patch applied optimistically on start, or a factory that returns it.
18
19
  *
19
- * @returns {import('./async').AsyncEventHandlers<TEntity, TPayload, TResult>}
20
+ * @returns {import('./async').AsyncEvents<TType, TEntity, TPayload, TResult>}
20
21
  *
21
22
  * @example
22
23
  * const saveItem = optimistic(
@@ -108,9 +109,9 @@ export function optimistic(behavior, optimistic) {
108
109
  behavior[errorEvent]?.(entity, error, api)
109
110
  },
110
111
 
111
- [finallyEvent](entity, api) {
112
+ [finallyEvent](entity, payload, api) {
112
113
  try {
113
- behavior[finallyEvent]?.(entity, api)
114
+ behavior[finallyEvent]?.(entity, payload, api)
114
115
  } finally {
115
116
  snapshots.delete(getEntityKey(entity))
116
117
  }
package/src/test.js CHANGED
@@ -130,7 +130,7 @@ export function createMockApi(entities) {
130
130
  * expect(entity.value).toBe(10)
131
131
  */
132
132
  export function trigger(entity, eventHandler, eventPayload, api) {
133
- api ??= createMockApi({ entities: { [entity.id]: entity } })
133
+ api ??= createMockApi({ [entity.id]: entity })
134
134
 
135
135
  return {
136
136
  entity: create(entity, (draft) => {
package/types/api.d.ts CHANGED
@@ -1,30 +1,37 @@
1
1
  import type {
2
+ AnyTypeDefinition,
2
3
  BaseEntity,
3
4
  EntitiesState,
4
5
  EntityType,
5
6
  Event,
6
7
  Store,
7
- TypesConfig,
8
+ Stored,
8
9
  } from "./store"
9
10
 
10
11
  /**
11
- * API object exposed to handlers
12
+ * API object exposed to handlers.
13
+ *
14
+ * An app's own `Api<AppEntity, AppState>` is an `Api`: a component typed with
15
+ * the plain `Api` accepts it. So the members that read types give the plain
16
+ * `EntityType`, rather than one typed for the app's entities.
12
17
  */
13
18
  export interface Api<
14
19
  TEntity extends BaseEntity = BaseEntity,
15
20
  TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
16
21
  > {
17
- getTypes: () => TypesConfig<TEntity>
18
- getType: (typeName: string) => EntityType<TEntity>
19
- setType: (typeName: string, type: EntityType<TEntity>) => void
22
+ getTypes: () => Record<string, EntityType>
23
+ getType: (typeName: string) => EntityType
24
+ setType: (typeName: string, type: AnyTypeDefinition) => void
20
25
  getEntities: {
21
26
  (): TState
22
- (typeName: string): TEntity[]
27
+ (typeName: string): Stored<TEntity>[]
23
28
  }
24
29
  /** A known id gives that entity's own type; any other id, the union. */
25
30
  getEntity: {
26
- <TId extends keyof TState & string>(id: TId): TState[TId] | undefined
27
- (id: string): TEntity | undefined
31
+ <TId extends keyof TState & string>(
32
+ id: TId,
33
+ ): Stored<TState[TId]> | undefined
34
+ (id: string): Stored<TEntity> | undefined
28
35
  }
29
36
  select: <TResult>(selector: (state: TState) => TResult) => TResult
30
37
  dispatch: (event: Event) => void
package/types/async.d.ts CHANGED
@@ -27,57 +27,83 @@ export interface AsyncHandlers<
27
27
  TEntity extends BaseEntity = BaseEntity,
28
28
  TPayload = any,
29
29
  TResult = any,
30
+ TApi extends Api = Api,
30
31
  > {
31
32
  /**
32
33
  * Called synchronously before the async operation starts
33
34
  * Use for setting loading states
34
35
  */
35
- start?: (entity: TEntity, payload: TPayload, api: Api) => void
36
+ start?: (entity: TEntity, payload: TPayload, api: TApi) => void
36
37
 
37
38
  /**
38
39
  * The async operation to perform
39
40
  * Receives payload and api (NOT entity - entity state should be modified in other handlers)
40
41
  * @returns Promise or synchronous result
41
42
  */
42
- run: (payload: TPayload, api: Api) => Promise<TResult> | TResult
43
+ run: (payload: TPayload, api: TApi) => Promise<TResult> | TResult
43
44
 
44
45
  /**
45
46
  * Called when the operation succeeds
46
47
  * Receives the entity, the result from run(), and api
47
48
  */
48
- success?: (entity: TEntity, result: TResult, api: Api) => void
49
+ success?: (entity: TEntity, result: TResult, api: TApi) => void
49
50
 
50
51
  /**
51
52
  * Called when the operation fails
52
53
  * Receives the entity, the error, and api
53
54
  */
54
- error?: (entity: TEntity, error: any, api: Api) => void
55
+ error?: (entity: TEntity, error: any, api: TApi) => void
55
56
 
56
57
  /**
57
58
  * Called after the operation completes (success or failure)
58
59
  * Use for cleanup, resetting loading states, etc.
59
60
  */
60
- finally?: (entity: TEntity, api: Api) => void
61
+ finally?: (entity: TEntity, api: TApi) => void
61
62
  }
62
63
 
63
64
  /**
64
- * Generated event handlers returned by handleAsync
65
- * Contains handlers for all lifecycle stages
65
+ * The events `handleAsync(type, ...)` generates, by name: `type`, `typeRun`,
66
+ * `typeSuccess`, `typeError`, `typeFinally` and, when a `start` handler is
67
+ * given, `typeStart`. A type alias can list them all in one go:
68
+ *
69
+ * ```ts
70
+ * type TodoListType = {
71
+ * render: (entity: TodoListEntity, api: AppApi) => TemplateResult
72
+ * } & AsyncEvents<"fetchTodos", TodoListEntity, void, Todo[], AppApi>
73
+ * ```
74
+ *
75
+ * The `scope` option changes where the events are sent, not their names.
66
76
  */
67
- export interface AsyncEventHandlers<
77
+ export type AsyncEvents<
78
+ TType extends string,
68
79
  TEntity extends BaseEntity = BaseEntity,
69
80
  TPayload = any,
70
81
  TResult = any,
71
- > {
72
- /**
73
- * Main trigger handler
74
- * Dispatches start (if defined) and run events
75
- */
76
- [key: string]: (
82
+ TApi extends Api = Api,
83
+ > = {
84
+ [K in TType]: (entity: TEntity, payload: TPayload, api: TApi) => void
85
+ } & {
86
+ [K in `${TType}Start`]?: (
77
87
  entity: TEntity,
78
88
  payload: TPayload,
79
- api: Api,
80
- ) => void | Promise<void>
89
+ api: TApi,
90
+ ) => void
91
+ } & {
92
+ [K in `${TType}Run`]: (
93
+ entity: TEntity,
94
+ payload: TPayload,
95
+ api: TApi,
96
+ ) => Promise<void>
97
+ } & {
98
+ [K in `${TType}Success`]: (
99
+ entity: TEntity,
100
+ result: TResult,
101
+ api: TApi,
102
+ ) => void
103
+ } & {
104
+ [K in `${TType}Error`]: (entity: TEntity, error: any, api: TApi) => void
105
+ } & {
106
+ [K in `${TType}Finally`]: (entity: TEntity, payload: void, api: TApi) => void
81
107
  }
82
108
 
83
109
  /**
@@ -86,9 +112,16 @@ export interface AsyncEventHandlers<
86
112
  * Generates handlers for: trigger, start, run, success, error, finally
87
113
  * These can be spread into an entity type definition.
88
114
  *
115
+ * The event name, the entity, the payload, the result and the api are inferred
116
+ * from `type` and from the handlers' parameters, or from the type the handlers
117
+ * are spread into (`satisfies EntityType<TodoListEntity, AppApi>`). Explicit
118
+ * type arguments still work, but the event names are then plain strings.
119
+ *
89
120
  * @template TEntity - The entity type (must extend BaseEntity)
90
121
  * @template TPayload - The payload type passed to the operation
91
122
  * @template TResult - The result type returned by the async operation
123
+ * @template TApi - The api the handlers receive, such as the app's own
124
+ * @template TType - The base event name, inferred from `type`
92
125
  *
93
126
  * @param type - The base event name (e.g., 'fetchTodos')
94
127
  * @param handlers - Handler functions for each lifecycle stage
@@ -104,23 +137,23 @@ export interface AsyncEventHandlers<
104
137
  * }
105
138
  *
106
139
  * interface TodoListEntity extends BaseEntity {
107
- * type: 'todoList'
140
+ * type: 'TodoList'
108
141
  * todos: Todo[]
109
142
  * status: 'idle' | 'loading' | 'success' | 'error'
110
143
  * error?: string
111
144
  * }
112
145
  *
113
- * const todoList = {
114
- * init(entity: TodoListEntity) {
146
+ * const TodoList = {
147
+ * create(entity) {
115
148
  * entity.todos = []
116
149
  * entity.status = 'idle'
117
150
  * },
118
151
  *
119
- * ...handleAsync<TodoListEntity, void, Todo[]>('fetchTodos', {
152
+ * ...handleAsync('fetchTodos', {
120
153
  * start(entity) {
121
154
  * entity.status = 'loading'
122
155
  * },
123
- * async run(_, api) {
156
+ * async run(_: void, api): Promise<Todo[]> {
124
157
  * const response = await fetch('/api/todos')
125
158
  * return response.json()
126
159
  * },
@@ -133,15 +166,17 @@ export interface AsyncEventHandlers<
133
166
  * entity.error = error.message
134
167
  * }
135
168
  * })
136
- * }
169
+ * } satisfies EntityType<TodoListEntity, AppApi>
137
170
  * ```
138
171
  */
139
172
  export function handleAsync<
140
173
  TEntity extends BaseEntity = BaseEntity,
141
174
  TPayload = any,
142
175
  TResult = any,
176
+ TApi extends Api = Api,
177
+ TType extends string = string,
143
178
  >(
144
- type: string,
145
- handlers: AsyncHandlers<TEntity, TPayload, TResult>,
179
+ type: TType,
180
+ handlers: AsyncHandlers<TEntity, TPayload, TResult, TApi>,
146
181
  options?: AsyncOptions,
147
- ): AsyncEventHandlers<TEntity, TPayload, TResult>
182
+ ): AsyncEvents<TType, TEntity, TPayload, TResult, TApi>
@@ -1,5 +1,5 @@
1
1
  import { Api } from "./api"
2
- import { AsyncEventHandlers } from "./async"
2
+ import { AsyncEvents } from "./async"
3
3
  import { BaseEntity } from "./store"
4
4
 
5
5
  /**
@@ -8,18 +8,29 @@ import { BaseEntity } from "./store"
8
8
  * Pass either a static shallow patch object or a factory that returns one.
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
+ *
12
+ * The behavior keeps the events `handleAsync` named and typed, and always has
13
+ * a start event.
11
14
  */
12
15
  export function optimistic<
13
16
  TEntity extends BaseEntity = BaseEntity,
14
17
  TPayload = any,
15
18
  TResult = any,
19
+ TApi extends Api = Api,
20
+ TType extends string = string,
16
21
  >(
17
- behavior: AsyncEventHandlers<TEntity, TPayload, TResult>,
22
+ behavior: AsyncEvents<TType, TEntity, TPayload, TResult, TApi>,
18
23
  optimistic:
19
24
  | Record<string, any>
20
25
  | ((
21
26
  entity: TEntity,
22
27
  payload: TPayload,
23
- api: Api,
28
+ api: TApi,
24
29
  ) => Record<string, any> | null | undefined),
25
- ): AsyncEventHandlers<TEntity, TPayload, TResult>
30
+ ): AsyncEvents<TType, TEntity, TPayload, TResult, TApi> & {
31
+ [K in `${TType}Start`]: (
32
+ entity: TEntity,
33
+ payload: TPayload,
34
+ api: TApi,
35
+ ) => void
36
+ }
package/types/select.d.ts CHANGED
@@ -12,9 +12,36 @@ export type OutputSelector<TState = any, TResult = any> = (
12
12
  state: TState,
13
13
  ) => TResult
14
14
 
15
+ /** Any input selector, whatever state it reads. */
16
+ type AnySelector = (state: never) => unknown
17
+
18
+ /** What the input selectors return, in order: the result function's arguments. */
19
+ type SelectedValues<TSelectors extends readonly AnySelector[]> = {
20
+ [K in keyof TSelectors]: ReturnType<TSelectors[K]>
21
+ }
22
+
23
+ /** The state every input selector can read: the intersection of theirs. */
24
+ type SelectorsState<TSelectors extends readonly AnySelector[]> =
25
+ UnionToIntersection<Parameters<TSelectors[number]>[0]>
26
+
27
+ type UnionToIntersection<TUnion> = (
28
+ TUnion extends unknown ? (value: TUnion) => void : never
29
+ ) extends (value: infer TIntersection) => void
30
+ ? TIntersection
31
+ : never
32
+
15
33
  /**
16
- * Creates a memoized derived computation from input selectors.
34
+ * Creates a memoized derived computation from input selectors. Everything is
35
+ * inferred: the result function's arguments, and the state the selector reads.
17
36
  */
37
+ export function compute<
38
+ const TSelectors extends readonly AnySelector[],
39
+ TResult,
40
+ >(
41
+ resultFunc: (...args: SelectedValues<TSelectors>) => TResult,
42
+ inputSelectors: TSelectors,
43
+ ): OutputSelector<SelectorsState<TSelectors>, TResult>
44
+ /** With explicit type arguments: the state, the selected values and the result. */
18
45
  export function compute<TState, TInputs extends readonly unknown[], TResult>(
19
46
  resultFunc: (...args: TInputs) => TResult,
20
47
  inputSelectors: {
@@ -26,6 +53,14 @@ export function compute<TState, TInputs extends readonly unknown[], TResult>(
26
53
  * Redux-compatible alias for `compute`.
27
54
  * Prefer `compute` in new code.
28
55
  */
56
+ export function createSelector<
57
+ const TSelectors extends readonly AnySelector[],
58
+ TResult,
59
+ >(
60
+ inputSelectors: TSelectors,
61
+ resultFunc: (...args: SelectedValues<TSelectors>) => TResult,
62
+ ): OutputSelector<SelectorsState<TSelectors>, TResult>
63
+ /** With explicit type arguments: the state, the selected values and the result. */
29
64
  export function createSelector<
30
65
  TState,
31
66
  TInputs extends readonly unknown[],
package/types/store.d.ts CHANGED
@@ -1,13 +1,33 @@
1
1
  import type { Api } from "./api"
2
2
 
3
3
  /**
4
- * Base entity structure
4
+ * What every entity has. Declare an entity's other fields on its own interface:
5
+ *
6
+ * ```ts
7
+ * interface CounterEntity extends BaseEntity {
8
+ * type: "Counter"
9
+ * value: number
10
+ * }
11
+ * ```
12
+ *
13
+ * The store gives each entity its key in the state as `id`, so it can be left
14
+ * out of the initial entities; handlers always receive it (see `Stored`).
5
15
  */
6
16
  export interface BaseEntity {
7
17
  type: string
8
- [key: string]: any
18
+ id?: string
19
+ /**
20
+ * Opts the entity out of a pause: the store stops handing out `update`
21
+ * events while the world is halted, and an entity that says this keeps
22
+ * receiving them. It is for overlays and menus; anything that moves should
23
+ * leave it off so that it stops.
24
+ */
25
+ updatesWhilePaused?: boolean
9
26
  }
10
27
 
28
+ /** An entity as the store holds it, and as handlers receive it: with its id. */
29
+ export type Stored<TEntity extends BaseEntity> = TEntity & { id: string }
30
+
11
31
  /**
12
32
  * Event structure
13
33
  */
@@ -17,25 +37,79 @@ export interface Event<T = any> {
17
37
  }
18
38
 
19
39
  /**
20
- * Entity type definition with event handlers.
21
- *
22
- * `updatesWhilePaused` opts the type out of a pause: the store stops handing out
23
- * `update` events while the world is halted, and a type that says this keeps
24
- * receiving them. It is for overlays and menus; anything that moves should leave it
25
- * off so that it stops.
40
+ * An event handler: it receives the entity, the event's payload and the api.
26
41
  */
27
- export type EntityType<TEntity extends BaseEntity = BaseEntity> = {
28
- [K: string]: EventHandler<TEntity>
42
+ export type EventHandler<
43
+ TEntity extends BaseEntity = BaseEntity,
44
+ TApi extends Api = Api,
45
+ > = (entity: Stored<TEntity>, payload: any, api: TApi) => unknown
46
+
47
+ /**
48
+ * Any type for one kind of entity: its event handlers, by event name, and its
49
+ * `render`. The store checks `types` against it.
50
+ *
51
+ * For rigour, declare a type's events and payloads with a `type` alias (an
52
+ * interface does not fit `types`) and write the type against it:
53
+ *
54
+ * ```ts
55
+ * type CounterType = {
56
+ * add: (entity: CounterEntity, amount: number) => void
57
+ * }
58
+ * const Counter: CounterType = {
59
+ * add(entity, amount) {
60
+ * entity.value += amount
61
+ * },
62
+ * }
63
+ * ```
64
+ *
65
+ * As a shorthand, `satisfies` infers the entity and the api without the
66
+ * declaration, but neither lists the events nor types unannotated payloads:
67
+ *
68
+ * ```ts
69
+ * const Counter = {
70
+ * add(entity, amount: number) {
71
+ * entity.value += amount
72
+ * },
73
+ * } satisfies EntityType<CounterEntity, AppApi>
74
+ * ```
75
+ */
76
+ export type EntityType<
77
+ TEntity extends BaseEntity = BaseEntity,
78
+ TApi extends Api = Api,
79
+ > = {
80
+ /** Draws the entity. Unlike an event handler, it receives the api second. */
81
+ render?: Renderer<TEntity, TApi>
82
+ [member: string]: EventHandler<TEntity, TApi> | undefined
29
83
  }
30
84
 
85
+ /** A type's `render`: it receives the entity and the api. */
86
+ export type Renderer<
87
+ TEntity extends BaseEntity = BaseEntity,
88
+ TApi extends Api = Api,
89
+ > = (entity: Stored<TEntity>, api: TApi) => unknown
90
+
91
+ /**
92
+ * A behavior: a type, or a function that extends the type it is given. A type
93
+ * can also be an array of behaviors, applied in order: `[Chart, withRealtime]`.
94
+ */
95
+ export type Behavior<
96
+ TEntity extends BaseEntity = BaseEntity,
97
+ TApi extends Api = Api,
98
+ > =
99
+ | EntityType<TEntity, TApi>
100
+ | ((type: EntityType<TEntity, TApi>) => EntityType<TEntity, TApi>)
101
+
102
+ /** What the store's `types` holds for one type name: a behavior, or several. */
103
+ export type TypeDefinition<
104
+ TEntity extends BaseEntity = BaseEntity,
105
+ TApi extends Api = Api,
106
+ > = Behavior<TEntity, TApi> | Behavior<TEntity, TApi>[]
107
+
31
108
  /**
32
- * A handler, checked as a method is: a type for a specific entity, such as
33
- * `Api<AppEntity>`'s, then fits where `BaseEntity`'s is expected, so a component
34
- * typed with the plain `Api` accepts an app's.
109
+ * A type definition for any entity. A handler that accepts some entity also
110
+ * accepts `never`, so every type fits here, whatever entity it is for.
35
111
  */
36
- type EventHandler<TEntity extends BaseEntity> = {
37
- bivarianceHack(entity: TEntity, payload: any, api: Api): void
38
- }["bivarianceHack"]
112
+ export type AnyTypeDefinition = TypeDefinition<never, never>
39
113
 
40
114
  /**
41
115
  * System definition with event handlers
@@ -52,11 +126,23 @@ export type EntitiesState<TEntity extends BaseEntity = BaseEntity> = {
52
126
  }
53
127
 
54
128
  /**
55
- * Types configuration
129
+ * The store's `types`. In a typed store, each entity's type name takes a type
130
+ * for that entity, so `types: { Counter }` checks `Counter` against
131
+ * `CounterEntity`. Names no entity has, such as a page rendered by name, take
132
+ * any type.
56
133
  */
57
- export type TypesConfig<TEntity extends BaseEntity = BaseEntity> = {
58
- [typeName: string]: EntityType<TEntity>
59
- }
134
+ export type TypesConfig<
135
+ TEntity extends BaseEntity = BaseEntity,
136
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
137
+ > = string extends TEntity["type"]
138
+ ? { [typeName: string]: AnyTypeDefinition }
139
+ : {
140
+ [TName in TEntity["type"]]?: TypeDefinition<
141
+ Extract<TEntity, { type: TName }>,
142
+ Api<TEntity, TState>
143
+ >
144
+ } & { [typeName: string]: unknown }
145
+
60
146
  /**
61
147
  * Whether each event triggers an update, or updates are batched until `update()` is called.
62
148
  */
@@ -87,9 +173,9 @@ export interface StoreConfig<
87
173
  TEntity extends BaseEntity = BaseEntity,
88
174
  TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
89
175
  > {
90
- // Types do not pick the entity type: their handlers accept any entity, so
91
- // it comes from `entities`, `middlewares` or the explicit type arguments.
92
- types?: TypesConfig<NoInfer<TEntity>>
176
+ // The entity type comes from the type arguments, `entities` or
177
+ // `middlewares`, never from `types`, which are checked against it.
178
+ types?: NoInfer<TypesConfig<TEntity, TState>>
93
179
  /** The initial entities. With `autoCreateEntities`, those the store creates itself can be left out. */
94
180
  entities?: Partial<TState>
95
181
  systems?: System<TState>[]
@@ -120,13 +206,15 @@ export interface Store<
120
206
  update: () => Event[]
121
207
  notify: (type: string, payload?: any) => void
122
208
  dispatch: (event: Event) => void
123
- getTypes: () => TypesConfig<TEntity>
124
- getType: (typeName: string) => EntityType<TEntity>
125
- setType: (typeName: string, type: EntityType<TEntity>) => void
209
+ getTypes: () => Record<string, EntityType>
210
+ getType: (typeName: string) => EntityType
211
+ setType: (typeName: string, type: AnyTypeDefinition) => void
126
212
  getState: () => TState
127
213
  getEntity: {
128
- <TId extends keyof TState & string>(id: TId): TState[TId] | undefined
129
- (id: string): TState[string] | undefined
214
+ <TId extends keyof TState & string>(
215
+ id: TId,
216
+ ): Stored<TState[TId]> | undefined
217
+ (id: string): Stored<TEntity> | undefined
130
218
  }
131
219
  setState: (nextState: TState) => void
132
220
  reset: () => void
package/types/test.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BaseEntity, EntitiesState } from "./store"
1
+ import type { BaseEntity, EntitiesState, Event, Stored } from "./store"
2
2
 
3
3
  /**
4
4
  * Mock API for testing (subset of full API)
@@ -9,28 +9,33 @@ export interface MockApi<
9
9
  > {
10
10
  getEntities: {
11
11
  (): TState
12
- (typeName: string): TEntity[]
12
+ (typeName: string): Stored<TEntity>[]
13
13
  }
14
+ /** A known id gives that entity's own type; any other id, the union. */
14
15
  getEntity: {
15
- <TId extends keyof TState & string>(id: TId): TState[TId] | undefined
16
- (id: string): TEntity | undefined
16
+ <TId extends keyof TState & string>(
17
+ id: TId,
18
+ ): Stored<TState[TId]> | undefined
19
+ (id: string): Stored<TEntity> | undefined
17
20
  }
18
21
  select: <TResult>(selector: (state: TState) => TResult) => TResult
19
22
  dispatch: (event: Event) => void
20
23
  notify: (type: string, payload?: any) => void
24
+ /** The events dispatched so far. */
21
25
  getEvents: () => Event[]
22
26
  }
23
27
 
24
28
  /**
25
- * Result of trigger function
29
+ * Result of trigger function: the entity after the handler ran, and the
30
+ * events it dispatched.
26
31
  */
27
- export interface TriggerResult<TState extends EntitiesState = EntitiesState> {
28
- entities: TState
32
+ export interface TriggerResult<TEntity extends BaseEntity = BaseEntity> {
33
+ entity: TEntity
29
34
  events: Event[]
30
35
  }
31
36
 
32
37
  /**
33
- * Creates a mock API for testing event handlers
38
+ * Creates a mock API for testing event handlers, over the given entities.
34
39
  */
35
40
  export function createMockApi<
36
41
  TEntity extends BaseEntity = BaseEntity,
@@ -38,19 +43,24 @@ export function createMockApi<
38
43
  >(entities: TState): MockApi<TEntity, TState>
39
44
 
40
45
  /**
41
- * Triggers an event handler in isolation for testing
46
+ * Runs an event handler on a copy of an entity, as the store would, and
47
+ * returns the updated copy with the events it dispatched. Without an api, the
48
+ * handler gets a mock one holding just this entity.
49
+ *
50
+ * Any type's handler fits, whatever api it is typed with: it receives a
51
+ * `MockApi`, which covers reading entities and dispatching events.
52
+ *
53
+ * ```ts
54
+ * const { entity, events } = trigger(counter, Counter.increment, 5)
55
+ * ```
42
56
  */
43
- export function trigger<
44
- TEntity extends BaseEntity = BaseEntity,
45
- TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
46
- TPayload = any,
47
- >(
48
- entities: TState,
57
+ export function trigger<TEntity extends BaseEntity, TPayload = any>(
58
+ entity: TEntity,
49
59
  eventHandler: (
50
- state: TState,
60
+ entity: Stored<TEntity>,
51
61
  payload: TPayload,
52
- api: MockApi<TEntity, TState>,
53
- ) => void,
54
- eventPayload: TPayload,
55
- api?: MockApi<TEntity, TState>,
56
- ): TriggerResult<TState>
62
+ api: never,
63
+ ) => unknown,
64
+ eventPayload?: TPayload,
65
+ api?: MockApi,
66
+ ): TriggerResult<TEntity>