@inglorious/store 11.1.0 → 11.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
@@ -400,6 +400,9 @@ The practical use is a world whose rules change with its state — a keyboard wh
400
400
  mean one thing on a menu and another in play:
401
401
 
402
402
  ```javascript
403
+ import { fsm } from "@inglorious/store/fsm"
404
+ import { scenes } from "@inglorious/store/scenes"
405
+
403
406
  const types = {
404
407
  Keyboard: { keyDown: (entity, key) => report(key) },
405
408
  Game: [
@@ -730,7 +733,7 @@ Inglorious Store now provides utilities to **gradually migrate from RTK slices a
730
733
  ### Converting Async Thunks
731
734
 
732
735
  ```javascript
733
- import { convertAsyncThunk } from "@inglorious/store/rtk"
736
+ import { convertAsyncThunk } from "@inglorious/store/migration/rtk"
734
737
 
735
738
  const fetchTodos = async (userId) => {
736
739
  const res = await fetch(`/api/users/${userId}/todos`)
@@ -765,7 +768,7 @@ const todoList = {
765
768
  ### Converting Slices
766
769
 
767
770
  ```javascript
768
- import { convertSlice } from "@inglorious/store/rtk"
771
+ import { convertSlice } from "@inglorious/store/migration/rtk"
769
772
 
770
773
  const todoListType = convertSlice(todosSlice, {
771
774
  asyncThunks: {
@@ -806,7 +809,7 @@ dispatch({ type: "todos/addTodo", payload: "Buy milk" })
806
809
  ### Migration Guide
807
810
 
808
811
  ```javascript
809
- import { createMigrationGuide } from "@inglorious/store/rtk"
812
+ import { createMigrationGuide } from "@inglorious/store/migration/rtk"
810
813
 
811
814
  const guide = createMigrationGuide(todosSlice)
812
815
  console.log(guide)
@@ -968,7 +971,108 @@ const withLoading = (type) => ({
968
971
  const form = [resettable, validated, withLoading]
969
972
  ```
970
973
 
971
- When multiple behaviors define the same event, they all run in order. This allows you to build middleware-like patterns: validation, logging, error handling, loading states, etc.
974
+ When multiple behaviors define the same event, the later one replaces the earlier: a type has one handler per event. That is why a wrapper like `validated` receives the type built so far and calls `type.submit?.(...)` itself, which is how you build middleware-like patterns: validation, logging, error handling, loading states, etc.
975
+
976
+ ### 🚦 State Machines
977
+
978
+ Some entities answer the same event differently depending on the state they are in: a door
979
+ opens when it is closed and does nothing when it is locked. `fsm()` turns a type into a
980
+ finite state machine in the manner of the OOP State pattern, where each state says what it
981
+ answers and the entity's `state` field says which state is in charge:
982
+
983
+ ```javascript
984
+ import { fsm } from "@inglorious/store/fsm"
985
+
986
+ const types = {
987
+ Door: [
988
+ {
989
+ // What every state does, unless a state says otherwise
990
+ knock(entity) {
991
+ entity.knocks++
992
+ },
993
+ },
994
+ fsm({
995
+ closed: {
996
+ open(entity) {
997
+ entity.state = "open"
998
+ },
999
+ lock(entity) {
1000
+ entity.state = "locked"
1001
+ },
1002
+ },
1003
+ open: {
1004
+ close(entity) {
1005
+ entity.state = "closed"
1006
+ },
1007
+ },
1008
+ locked: {
1009
+ // Takes over knock while locked
1010
+ knock(entity, _, api) {
1011
+ api.notify("bark")
1012
+ },
1013
+ unlock(entity) {
1014
+ entity.state = "closed"
1015
+ },
1016
+ },
1017
+ }),
1018
+ ],
1019
+ }
1020
+
1021
+ const entities = { door: { type: "Door", state: "closed", knocks: 0 } }
1022
+ ```
1023
+
1024
+ - A state's handler **replaces** the type's handler for the same event, so the type's
1025
+ handler is the default and a state takes it over by naming the event. An event that
1026
+ neither the current state nor the type answers is ignored.
1027
+ - A handler moves the entity by assigning `entity.state`. An entity with no `state` starts
1028
+ in `"default"`.
1029
+ - When a handler has actually moved the entity, `fsm` broadcasts
1030
+ `stateChange` with `{ entityId, from, to }`. It is broadcast rather than addressed to the
1031
+ entity, because what wants to hear it is usually something else, so a listener checks
1032
+ `entityId`.
1033
+
1034
+ Typical uses: request and connection status, wizards, editors that are viewing, editing or
1035
+ saving, media players, drag and drop, and the screens of a game.
1036
+
1037
+ #### Scenes
1038
+
1039
+ `scenes()` is the usual partner of `fsm()`: the machine says when it moves, and `scenes()`
1040
+ says which entities stand in each state. On `create` and on its own `stateChange` it `add`s
1041
+ what the new state has, `remove`s what only the old one had, and `patch`es what both have
1042
+ but configure differently, comparing entities by `id`:
1043
+
1044
+ ```javascript
1045
+ import { fsm } from "@inglorious/store/fsm"
1046
+ import { scenes } from "@inglorious/store/scenes"
1047
+
1048
+ const types = {
1049
+ Wizard: [
1050
+ scenes({
1051
+ account: () => [{ id: "accountForm", type: "AccountForm" }],
1052
+ billing: (wizard) => [
1053
+ { id: "billingForm", type: "BillingForm", plan: wizard.plan },
1054
+ ],
1055
+ }),
1056
+ fsm({
1057
+ account: {
1058
+ next(entity) {
1059
+ entity.state = "billing"
1060
+ },
1061
+ },
1062
+ }),
1063
+ ],
1064
+ }
1065
+
1066
+ const entities = { wizard: { type: "Wizard", state: "account", plan: "pro" } }
1067
+ ```
1068
+
1069
+ The entity running the machine keeps what it has up in `scene`.
1070
+
1071
+ The name comes from games, where each screen is a scene, but nothing about it is game-specific.
1072
+ In a web app it gives entities a lifetime tied to a state: a page's forms and lists exist only
1073
+ while you are on it (their `create` and `destroy` can open and close subscriptions), each wizard
1074
+ step has its own form, and a logged-out app has a `loginForm` that a logged-in one does not —
1075
+ while anything both states share, like a header or a cart, survives the move untouched.
972
1076
 
973
1077
  ### ⏱️ Batched Mode
974
1078
 
@@ -1325,6 +1429,7 @@ Each handler receives three arguments:
1325
1429
  - **`patch` / `replace`** - change a thing that is already standing, the first touching only
1326
1430
  what it names and the second installing all of it
1327
1431
  - **`update`** - the frame tick, disabled while the world is paused
1432
+ - **`stateChange`** - broadcast by `fsm()` when an entity moves, with `{ entityId, from, to }`
1328
1433
 
1329
1434
  #### Pausing the world
1330
1435
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "11.1.0",
3
+ "version": "11.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",
@@ -40,9 +40,29 @@
40
40
  "types": "./types/migration/rtk.d.ts",
41
41
  "import": "./src/migration/rtk.js"
42
42
  },
43
+ "./async": {
44
+ "types": "./types/behaviors/async.d.ts",
45
+ "import": "./src/behaviors/async.js"
46
+ },
47
+ "./optimistic": {
48
+ "types": "./types/behaviors/optimistic.d.ts",
49
+ "import": "./src/behaviors/optimistic.js"
50
+ },
51
+ "./fsm": {
52
+ "types": "./types/behaviors/state-machine/fsm.d.ts",
53
+ "import": "./src/behaviors/state-machine/fsm.js"
54
+ },
55
+ "./scenes": {
56
+ "types": "./types/behaviors/state-machine/scenes.d.ts",
57
+ "import": "./src/behaviors/state-machine/scenes.js"
58
+ },
59
+ "./*.js": {
60
+ "types": "./types/*.d.ts",
61
+ "import": "./src/*.js"
62
+ },
43
63
  "./*": {
44
64
  "types": "./types/*.d.ts",
45
- "import": "./src/*"
65
+ "import": "./src/*.js"
46
66
  }
47
67
  },
48
68
  "files": [
@@ -5,7 +5,7 @@
5
5
  * success, error, and finally. The lifecycle events are automatically scoped based
6
6
  * on the provided options.
7
7
  *
8
- * @template {import('./store').BaseEntity} TEntity - The entity type
8
+ * @template {import('../store').BaseEntity} TEntity - The entity type
9
9
  * @template TPayload - The payload type passed to the operation
10
10
  * @template TResult - The result type returned by the async operation
11
11
  *
@@ -13,19 +13,19 @@
13
13
  * Generated handlers will be named: `type`, `typeStart`, `typeRun`, `typeSuccess`, `typeError`, `typeFinally`
14
14
  *
15
15
  * @param {Object} handlers - The handler functions for each lifecycle stage.
16
- * @param {(entity: TEntity, payload: TPayload, api: import('./store').Api) => void} [handlers.start]
16
+ * @param {(entity: TEntity, payload: TPayload, api: import('../store').Api) => void} [handlers.start]
17
17
  * Called synchronously before the async operation starts.
18
18
  * Use for setting loading states. Receives: (entity, payload, api)
19
- * @param {(payload: TPayload, api: import('./store').Api) => Promise<TResult> | TResult} handlers.run
19
+ * @param {(payload: TPayload, api: import('../store').Api) => Promise<TResult> | TResult} handlers.run
20
20
  * The async operation to perform. Must return a Promise or value.
21
21
  * **Note:** Receives (payload, api) - NOT entity. Entity state should be modified in other handlers.
22
- * @param {(entity: TEntity, result: TResult, api: import('./store').Api) => void} [handlers.success]
22
+ * @param {(entity: TEntity, result: TResult, api: import('../store').Api) => void} [handlers.success]
23
23
  * Called when the operation succeeds.
24
24
  * Receives: (entity, result, api) where result is the resolved value from run()
25
- * @param {(entity: TEntity, error: any, api: import('./store').Api) => void} [handlers.error]
25
+ * @param {(entity: TEntity, error: any, api: import('../store').Api) => void} [handlers.error]
26
26
  * Called when the operation fails.
27
27
  * Receives: (entity, error, api) where error is the caught exception
28
- * @param {(entity: TEntity, api: import('./store').Api) => void} [handlers.finally]
28
+ * @param {(entity: TEntity, api: import('../store').Api) => void} [handlers.finally]
29
29
  * Called after the operation completes (success or failure).
30
30
  * Use for cleanup, resetting loading states. Receives: (entity, api)
31
31
  *
@@ -8,15 +8,16 @@ const START = 0
8
8
  * Pass either a static shallow patch object or a factory that returns one.
9
9
  * The wrapper applies the patch during `Start` and rolls it back during `Error`.
10
10
  *
11
- * @template {import('./store').BaseEntity} TEntity
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 {Record<string, any> | ((entity: TEntity, payload: TPayload, api: import('./store').Api) => Record<string, any> | null | undefined)} optimistic
16
+ * @param {import('./async').AsyncEvents<TType, TEntity, TPayload, TResult>} behavior
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
  }
@@ -0,0 +1,55 @@
1
+ const DEFAULT_STATE = "default"
2
+
3
+ export function fsm(states) {
4
+ const uniqueEventNames = [
5
+ ...new Set(Object.values(states).flatMap(Object.keys)),
6
+ ]
7
+
8
+ return (type) => {
9
+ return {
10
+ create(entity, payload, api) {
11
+ type.create?.(entity, payload, api)
12
+
13
+ entity.state ??= DEFAULT_STATE
14
+ },
15
+
16
+ ...uniqueEventNames.reduce(
17
+ (acc, eventName) => ({
18
+ ...acc,
19
+
20
+ [eventName](entity, event, api) {
21
+ const from = entity.state
22
+
23
+ // A state answers for itself, and what it has to say replaces what the type
24
+ // would have said. The type-stage handler is the default -- the thing every
25
+ // state would otherwise do -- and a state takes it over by naming the same
26
+ // event, which is how a state can take the way out for itself rather than
27
+ // being quit by, without whatever answers by default having to know that
28
+ // state exists.
29
+ const handler = states[entity.state]?.[eventName] ?? type[eventName]
30
+
31
+ handler?.(entity, event, api)
32
+
33
+ announce(entity, from, api)
34
+ },
35
+ }),
36
+ {},
37
+ ),
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * A machine announces where it has landed, but only when it has actually moved: a
44
+ * handler that reads the state without changing it should not wake anything up.
45
+ *
46
+ * The announcement is broadcast and carries the id, rather than being addressed to
47
+ * this entity, because what wants to hear it is usually something else.
48
+ */
49
+ function announce(entity, from, api) {
50
+ const to = entity.state
51
+
52
+ if (to === from) return
53
+
54
+ api.notify("stateChange", { entityId: entity.id, from, to })
55
+ }
@@ -0,0 +1,89 @@
1
+ import { isDeepEqual } from "@inglorious/utils/object.js"
2
+
3
+ /**
4
+ * Puts an entity up, and takes down whatever the state before it had up.
5
+ *
6
+ * A state machine says when it moves; this says what each state is made of, and is
7
+ * usually the pair to `fsm`. Together they cover the ordinary case of a game that is a
8
+ * few screens and a world that survives the move between them -- a menu has nothing on it
9
+ * that the play has, and two states of the same play have all of it in common.
10
+ *
11
+ * Only the difference between two states is touched. Anything they have in common is
12
+ * left standing as it is, which is what carries whatever the two share from one into the
13
+ * next without rebuilding it, and so without losing whatever the world has already done
14
+ * to it.
15
+ *
16
+ * A thing that stands on both sides but is not configured the same way is patched rather
17
+ * than rebuilt, so one state may take over part of it -- a keyboard whose keys mean
18
+ * something else there -- without the rest of it being torn up to make room.
19
+ *
20
+ * @example
21
+ * ```js
22
+ * import { fsm } from "@inglorious/store/fsm"
23
+ * import { scenes } from "@inglorious/store/scenes"
24
+ *
25
+ * const SCENES = {
26
+ * title: () => [createTitleEntity(), createMenuEntity()],
27
+ * play: () => [createPaddleEntity(), createBallEntity()],
28
+ * }
29
+ *
30
+ * const types = {
31
+ * Game: [scenes(SCENES), fsm({ title: { start(e) { e.state = "play" } } })],
32
+ * }
33
+ *
34
+ * const entities = { game: { type: "Game", state: "title" } }
35
+ * ```
36
+ *
37
+ * @param {object} scenesByState - What stands in each state, given the state and the
38
+ * entity asking. Returns the entity configurations to have standing.
39
+ * @returns {object} The behaviour.
40
+ */
41
+ export function scenes(scenesByState) {
42
+ function build(entity, state, api) {
43
+ const leaving = entity.scene ?? []
44
+ const arriving = scenesByState[state]?.(entity) ?? []
45
+
46
+ const leavingIds = leaving.map(({ id }) => id)
47
+ const arrivingIds = arriving.map(({ id }) => id)
48
+
49
+ for (const { id } of leaving) {
50
+ if (arrivingIds.includes(id)) continue
51
+
52
+ api.notify("remove", id)
53
+ }
54
+
55
+ for (const added of arriving) {
56
+ if (!leavingIds.includes(added.id)) {
57
+ api.notify("add", added)
58
+
59
+ continue
60
+ }
61
+
62
+ // The same thing stands on both sides of the move, so it is neither added nor
63
+ // removed -- but it may be wanted to be a different thing now, and then only what
64
+ // has actually changed is merged over it. That is what leaves a shared thing where
65
+ // the world had put it when the next state says nothing different about it.
66
+ const previous = leaving.find(({ id }) => id === added.id)
67
+
68
+ if (!isDeepEqual(previous, added)) {
69
+ api.notify("patch", added)
70
+ }
71
+ }
72
+
73
+ entity.scene = arriving
74
+ }
75
+
76
+ return {
77
+ create(entity, payload, api) {
78
+ build(entity, entity.state, api)
79
+ },
80
+
81
+ // The announcement is broadcast and carries the id, so that anything else listening
82
+ // for it can tell which machine moved. This one is only interested in its own.
83
+ stateChange(entity, { entityId, to }, api) {
84
+ if (entityId !== entity.id) return
85
+
86
+ build(entity, to, api)
87
+ },
88
+ }
89
+ }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { extend } from "@inglorious/utils/object.js"
9
9
 
10
- import { handleAsync } from "../async"
10
+ import { handleAsync } from "../behaviors/async.js"
11
11
 
12
12
  const SEP_LENGTH = 50
13
13
  const INDENT_SPACES = 2
@@ -1,5 +1,5 @@
1
- import { Api } from "./api"
2
- import { BaseEntity } from "./store"
1
+ import { Api } from "../api"
2
+ import { BaseEntity } from "../store"
3
3
 
4
4
  /**
5
5
  * Scope option for async handler events
@@ -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,6 +1,6 @@
1
- import { Api } from "./api"
2
- import { AsyncEventHandlers } from "./async"
3
- import { BaseEntity } from "./store"
1
+ import { Api } from "../api"
2
+ import { AsyncEvents } from "./async"
3
+ import { BaseEntity } from "../store"
4
4
 
5
5
  /**
6
6
  * Decorates an async behavior with optimistic update boilerplate.
@@ -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
+ }
@@ -0,0 +1,109 @@
1
+ import type { Api } from "../../api"
2
+ import type { BaseEntity, EntityType, EventHandler } from "../../store"
3
+
4
+ /** The state an entity is in until a handler moves it. */
5
+ export type DefaultState = "default"
6
+
7
+ /**
8
+ * The states an entity can be in: the values of its `state` field, as declared
9
+ * on its interface. An entity that declares no `state` can be in any.
10
+ */
11
+ export type StateOf<TEntity extends BaseEntity> = TEntity extends {
12
+ state?: infer TState extends string
13
+ }
14
+ ? TState
15
+ : string
16
+
17
+ /**
18
+ * What one state answers to: its handlers, by event name. A handler moves the
19
+ * entity by assigning `entity.state`.
20
+ */
21
+ export type FsmState<
22
+ TEntity extends BaseEntity = BaseEntity,
23
+ TApi extends Api = Api,
24
+ > = {
25
+ [eventName: string]: EventHandler<TEntity, TApi> | undefined
26
+ }
27
+
28
+ /**
29
+ * Each state's handlers, by state name. A state that answers nothing of its
30
+ * own can be left out, and then it answers what the type does.
31
+ */
32
+ export type FsmStates<
33
+ TEntity extends BaseEntity = BaseEntity,
34
+ TApi extends Api = Api,
35
+ > = {
36
+ [TState in StateOf<TEntity> | DefaultState]?: FsmState<TEntity, TApi>
37
+ }
38
+
39
+ /**
40
+ * The payload of `stateChange`, which `fsm` broadcasts whenever a handler has
41
+ * moved the entity: the entity's id, the state it left and the one it is in.
42
+ */
43
+ export interface StateChangePayload<TState extends string = string> {
44
+ entityId: string
45
+ from: TState
46
+ to: TState
47
+ }
48
+
49
+ /**
50
+ * Makes a type a finite state machine, in the manner of the OOP State pattern:
51
+ * what the entity does on an event depends on the state it is in.
52
+ *
53
+ * Each state names the events it answers. A state's handler replaces the
54
+ * type's handler for the same event, so the type's handler is what every state
55
+ * does by default and a state takes it over by naming the event. A handler
56
+ * moves the entity by assigning `entity.state`; the entity starts in its own
57
+ * `state`, or in `"default"` when it has none.
58
+ *
59
+ * After a handler has moved the entity, the machine broadcasts `stateChange`
60
+ * with a `StateChangePayload`. A handler that leaves the state as it was
61
+ * announces nothing.
62
+ *
63
+ * The entity and the api are inferred from where the behavior is used, such as
64
+ * `types: { Door: [Door, fsm({ ... })] }` in a typed store. Declaring `state`
65
+ * on the entity's interface checks the state names as well.
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * interface DoorEntity extends BaseEntity {
70
+ * type: "Door"
71
+ * state: "closed" | "open" | "locked"
72
+ * }
73
+ *
74
+ * const types = {
75
+ * Door: [
76
+ * { knock: (entity) => console.log("Nobody's home") },
77
+ * fsm({
78
+ * closed: {
79
+ * open(entity) {
80
+ * entity.state = "open"
81
+ * },
82
+ * lock(entity) {
83
+ * entity.state = "locked"
84
+ * },
85
+ * },
86
+ * open: {
87
+ * close(entity) {
88
+ * entity.state = "closed"
89
+ * },
90
+ * },
91
+ * locked: {
92
+ * unlock(entity) {
93
+ * entity.state = "closed"
94
+ * },
95
+ * },
96
+ * }),
97
+ * ],
98
+ * }
99
+ * ```
100
+ *
101
+ * @param states - Each state's handlers, by state name
102
+ * @returns A behavior that wraps the type it is applied to
103
+ */
104
+ export function fsm<
105
+ TEntity extends BaseEntity = BaseEntity,
106
+ TApi extends Api = Api,
107
+ >(
108
+ states: FsmStates<TEntity, TApi>,
109
+ ): (type: EntityType<TEntity, TApi>) => EntityType<TEntity, TApi>
@@ -0,0 +1,53 @@
1
+ import type { Api } from "../../api"
2
+ import type { StateOf } from "./fsm"
3
+ import type { BaseEntity, EntityType, Stored } from "../../store"
4
+
5
+ /**
6
+ * An entity a scene puts up. It needs its `id`, which is how two scenes tell
7
+ * what they have in common.
8
+ */
9
+ export type SceneEntity = BaseEntity & {
10
+ id: string
11
+ [field: string]: unknown
12
+ }
13
+
14
+ /**
15
+ * What stands in each state, by state name: a function of the entity running
16
+ * the machine that returns the entities to have standing. A state left out has
17
+ * nothing standing.
18
+ */
19
+ export type ScenesByState<TEntity extends BaseEntity = BaseEntity> = {
20
+ [TState in StateOf<TEntity>]?: (entity: Stored<TEntity>) => SceneEntity[]
21
+ }
22
+
23
+ /**
24
+ * Puts a state's entities up and takes down whatever the state before it had
25
+ * up. It is usually paired with `fsm`, which says when the state moves: on
26
+ * `create` it builds the entity's current state, and on its own `stateChange`
27
+ * it builds the state it moved to.
28
+ *
29
+ * Only the difference between two states is touched: an entity that is in
30
+ * both is left standing, and is patched rather than rebuilt when the next
31
+ * state configures it differently. Entities are compared by `id`, and the
32
+ * entity running the machine keeps what it has up in `scene`.
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * const types = {
37
+ * Game: [
38
+ * scenes({
39
+ * title: () => [{ id: "menu", type: "Menu" }],
40
+ * play: () => [{ id: "board", type: "Board" }],
41
+ * }),
42
+ * fsm({ title: { start(entity) { entity.state = "play" } } }),
43
+ * ],
44
+ * }
45
+ * ```
46
+ *
47
+ * @param scenesByState - What stands in each state
48
+ * @returns A behavior handling `create` and `stateChange`
49
+ */
50
+ export function scenes<
51
+ TEntity extends BaseEntity = BaseEntity,
52
+ TApi extends Api = Api,
53
+ >(scenesByState: ScenesByState<TEntity>): EntityType<TEntity, TApi>
package/types/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./api"
2
- export * from "./async"
2
+ export * from "./behaviors/async"
3
3
  export * from "./select"
4
4
  export * from "./store"
5
5
  export * from "./test"
package/types/store.d.ts CHANGED
@@ -41,7 +41,7 @@ export interface Event<T = any> {
41
41
  */
42
42
  export type EventHandler<
43
43
  TEntity extends BaseEntity = BaseEntity,
44
- TApi extends Api<any, any> = Api,
44
+ TApi extends Api = Api,
45
45
  > = (entity: Stored<TEntity>, payload: any, api: TApi) => unknown
46
46
 
47
47
  /**
@@ -75,7 +75,7 @@ export type EventHandler<
75
75
  */
76
76
  export type EntityType<
77
77
  TEntity extends BaseEntity = BaseEntity,
78
- TApi extends Api<any, any> = Api,
78
+ TApi extends Api = Api,
79
79
  > = {
80
80
  /** Draws the entity. Unlike an event handler, it receives the api second. */
81
81
  render?: Renderer<TEntity, TApi>
@@ -85,7 +85,7 @@ export type EntityType<
85
85
  /** A type's `render`: it receives the entity and the api. */
86
86
  export type Renderer<
87
87
  TEntity extends BaseEntity = BaseEntity,
88
- TApi extends Api<any, any> = Api,
88
+ TApi extends Api = Api,
89
89
  > = (entity: Stored<TEntity>, api: TApi) => unknown
90
90
 
91
91
  /**
@@ -94,7 +94,7 @@ export type Renderer<
94
94
  */
95
95
  export type Behavior<
96
96
  TEntity extends BaseEntity = BaseEntity,
97
- TApi extends Api<any, any> = Api,
97
+ TApi extends Api = Api,
98
98
  > =
99
99
  | EntityType<TEntity, TApi>
100
100
  | ((type: EntityType<TEntity, TApi>) => EntityType<TEntity, TApi>)
@@ -102,7 +102,7 @@ export type Behavior<
102
102
  /** What the store's `types` holds for one type name: a behavior, or several. */
103
103
  export type TypeDefinition<
104
104
  TEntity extends BaseEntity = BaseEntity,
105
- TApi extends Api<any, any> = Api,
105
+ TApi extends Api = Api,
106
106
  > = Behavior<TEntity, TApi> | Behavior<TEntity, TApi>[]
107
107
 
108
108
  /**