@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 +109 -4
- package/package.json +22 -2
- package/src/{async.js → behaviors/async.js} +6 -6
- package/src/{optimistic.js → behaviors/optimistic.js} +7 -6
- package/src/behaviors/state-machine/fsm.js +55 -0
- package/src/behaviors/state-machine/scenes.js +89 -0
- package/src/migration/rtk.js +1 -1
- package/types/{async.d.ts → behaviors/async.d.ts} +62 -27
- package/types/{optimistic.d.ts → behaviors/optimistic.d.ts} +17 -6
- package/types/behaviors/state-machine/fsm.d.ts +109 -0
- package/types/behaviors/state-machine/scenes.d.ts +53 -0
- package/types/index.d.ts +1 -1
- package/types/store.d.ts +5 -5
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,
|
|
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.
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
11
|
+
* @template {import('../store').BaseEntity} TEntity
|
|
12
12
|
* @template TPayload
|
|
13
13
|
* @template TResult
|
|
14
|
+
* @template {string} TType
|
|
14
15
|
*
|
|
15
|
-
* @param {import('./async').
|
|
16
|
-
* @param {Record<string, any> | ((entity: TEntity, payload: TPayload, api: import('
|
|
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').
|
|
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
|
+
}
|
package/src/migration/rtk.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Api } from "
|
|
2
|
-
import { BaseEntity } from "
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
61
|
+
finally?: (entity: TEntity, api: TApi) => void
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
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:
|
|
80
|
-
) => 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: '
|
|
140
|
+
* type: 'TodoList'
|
|
108
141
|
* todos: Todo[]
|
|
109
142
|
* status: 'idle' | 'loading' | 'success' | 'error'
|
|
110
143
|
* error?: string
|
|
111
144
|
* }
|
|
112
145
|
*
|
|
113
|
-
* const
|
|
114
|
-
*
|
|
146
|
+
* const TodoList = {
|
|
147
|
+
* create(entity) {
|
|
115
148
|
* entity.todos = []
|
|
116
149
|
* entity.status = 'idle'
|
|
117
150
|
* },
|
|
118
151
|
*
|
|
119
|
-
* ...handleAsync
|
|
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:
|
|
145
|
-
handlers: AsyncHandlers<TEntity, TPayload, TResult>,
|
|
179
|
+
type: TType,
|
|
180
|
+
handlers: AsyncHandlers<TEntity, TPayload, TResult, TApi>,
|
|
146
181
|
options?: AsyncOptions,
|
|
147
|
-
):
|
|
182
|
+
): AsyncEvents<TType, TEntity, TPayload, TResult, TApi>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Api } from "
|
|
2
|
-
import {
|
|
3
|
-
import { BaseEntity } from "
|
|
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:
|
|
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:
|
|
28
|
+
api: TApi,
|
|
24
29
|
) => Record<string, any> | null | undefined),
|
|
25
|
-
):
|
|
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
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
105
|
+
TApi extends Api = Api,
|
|
106
106
|
> = Behavior<TEntity, TApi> | Behavior<TEntity, TApi>[]
|
|
107
107
|
|
|
108
108
|
/**
|