@inglorious/store 10.0.0 → 11.0.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 +82 -15
- package/package.json +1 -1
- package/src/async.js +10 -1
- package/src/optimistic.js +195 -0
- package/types/optimistic.d.ts +25 -0
package/README.md
CHANGED
|
@@ -508,27 +508,39 @@ The `handleAsync` helper generates a set of event handlers representing the life
|
|
|
508
508
|
handleAsync(type, handlers, options?)
|
|
509
509
|
```
|
|
510
510
|
|
|
511
|
+
Think of the flow like a newspaper article:
|
|
512
|
+
|
|
513
|
+
- `start` writes the headline and sets the scene
|
|
514
|
+
- `run` gathers the reporting
|
|
515
|
+
- `success` publishes the story
|
|
516
|
+
- `error` prints the correction
|
|
517
|
+
- `finally` archives the notes
|
|
518
|
+
|
|
511
519
|
Example:
|
|
512
520
|
|
|
513
521
|
```ts
|
|
514
|
-
handleAsync
|
|
515
|
-
async run(payload) {
|
|
516
|
-
const res = await fetch("/api/todos")
|
|
517
|
-
return res.json()
|
|
518
|
-
},
|
|
522
|
+
import { handleAsync } from "@inglorious/store/async"
|
|
519
523
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
524
|
+
const todoList = {
|
|
525
|
+
...handleAsync("fetchTodos", {
|
|
526
|
+
async run(payload) {
|
|
527
|
+
const res = await fetch("/api/todos")
|
|
528
|
+
return res.json()
|
|
529
|
+
},
|
|
523
530
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
531
|
+
success(entity, todos) {
|
|
532
|
+
entity.todos = todos
|
|
533
|
+
},
|
|
527
534
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
535
|
+
error(entity, error) {
|
|
536
|
+
entity.error = error.message
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
finally(entity) {
|
|
540
|
+
entity.loading = false
|
|
541
|
+
},
|
|
542
|
+
}),
|
|
543
|
+
}
|
|
532
544
|
```
|
|
533
545
|
|
|
534
546
|
---
|
|
@@ -565,6 +577,61 @@ handleAsync("save", {
|
|
|
565
577
|
|
|
566
578
|
If omitted, no `Start` event is generated.
|
|
567
579
|
|
|
580
|
+
### `optimistic`
|
|
581
|
+
|
|
582
|
+
Wrap an async behavior to apply a shallow optimistic patch on `Start` and roll it back on `Error`.
|
|
583
|
+
|
|
584
|
+
This helper lives at `@inglorious/store/optimistic`, so only users who need it import it.
|
|
585
|
+
|
|
586
|
+
```ts
|
|
587
|
+
import { handleAsync } from "@inglorious/store/async"
|
|
588
|
+
import { optimistic } from "@inglorious/store/optimistic"
|
|
589
|
+
|
|
590
|
+
const saveItem = optimistic(
|
|
591
|
+
handleAsync("saveItem", {
|
|
592
|
+
async run(payload) {
|
|
593
|
+
const res = await fetch("/api/items", {
|
|
594
|
+
method: "POST",
|
|
595
|
+
body: JSON.stringify(payload),
|
|
596
|
+
})
|
|
597
|
+
if (!res.ok) throw new Error("Failed")
|
|
598
|
+
return res.json()
|
|
599
|
+
},
|
|
600
|
+
success(entity, result) {
|
|
601
|
+
entity.items = entity.items.map((item) =>
|
|
602
|
+
item.id === result.tempId ? result : item,
|
|
603
|
+
)
|
|
604
|
+
},
|
|
605
|
+
}),
|
|
606
|
+
(entity, payload) => ({
|
|
607
|
+
items: [
|
|
608
|
+
...entity.items,
|
|
609
|
+
{
|
|
610
|
+
id: payload.tempId,
|
|
611
|
+
title: payload.title,
|
|
612
|
+
completed: payload.completed,
|
|
613
|
+
pending: true,
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
}),
|
|
617
|
+
)
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
If the optimistic state does not depend on the payload, use a static patch instead:
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
const saveSettings = optimistic(
|
|
624
|
+
handleAsync("saveSettings", {
|
|
625
|
+
async run(payload) {
|
|
626
|
+
return api.save(payload)
|
|
627
|
+
},
|
|
628
|
+
}),
|
|
629
|
+
{ status: "saving" },
|
|
630
|
+
)
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
The wrapper snapshots the keys in the patch, applies them in `Start`, restores the previous values in `Error`, and leaves `Success`/`Finally` handlers in control of cleanup.
|
|
634
|
+
|
|
568
635
|
---
|
|
569
636
|
|
|
570
637
|
### Event scoping
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/store",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.0",
|
|
4
4
|
"description": "A state manager for real-time, collaborative apps, inspired by game development patterns and compatible with Redux.",
|
|
5
5
|
"author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
package/src/async.js
CHANGED
|
@@ -146,7 +146,7 @@ export function handleAsync(type, handlers, options = {}) {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
const asyncHandlers = {
|
|
150
150
|
[type](entity, payload, api) {
|
|
151
151
|
if (handlers.start) {
|
|
152
152
|
notify(api, entity, `${type}Start`, payload)
|
|
@@ -184,4 +184,13 @@ export function handleAsync(type, handlers, options = {}) {
|
|
|
184
184
|
handlers.finally?.(entity, api)
|
|
185
185
|
},
|
|
186
186
|
}
|
|
187
|
+
|
|
188
|
+
Object.defineProperty(asyncHandlers, ASYNC_META, {
|
|
189
|
+
value: { type, scope },
|
|
190
|
+
enumerable: false,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
return asyncHandlers
|
|
187
194
|
}
|
|
195
|
+
|
|
196
|
+
export const ASYNC_META = Symbol.for("@inglorious/store/asyncMeta")
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { ASYNC_META } from "./async.js"
|
|
2
|
+
|
|
3
|
+
const START = 0
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Decorates an async behavior with optimistic update boilerplate.
|
|
7
|
+
*
|
|
8
|
+
* Pass either a static shallow patch object or a factory that returns one.
|
|
9
|
+
* The wrapper applies the patch during `Start` and rolls it back during `Error`.
|
|
10
|
+
*
|
|
11
|
+
* @template {import('./store').BaseEntity} TEntity
|
|
12
|
+
* @template TPayload
|
|
13
|
+
* @template TResult
|
|
14
|
+
*
|
|
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
|
|
17
|
+
* Shallow patch applied optimistically on start, or a factory that returns it.
|
|
18
|
+
*
|
|
19
|
+
* @returns {import('./async').AsyncEventHandlers<TEntity, TPayload, TResult>}
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* const saveItem = optimistic(
|
|
23
|
+
* handleAsync("saveItem", {
|
|
24
|
+
* async run(payload) {
|
|
25
|
+
* return fetch("/api/items", {
|
|
26
|
+
* method: "POST",
|
|
27
|
+
* body: JSON.stringify(payload),
|
|
28
|
+
* }).then((res) => res.json())
|
|
29
|
+
* },
|
|
30
|
+
* success(entity, result) {
|
|
31
|
+
* entity.data = result
|
|
32
|
+
* },
|
|
33
|
+
* }),
|
|
34
|
+
* { status: "saving" },
|
|
35
|
+
* )
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const saveDraft = optimistic(
|
|
39
|
+
* handleAsync("saveDraft", {
|
|
40
|
+
* async run(payload) {
|
|
41
|
+
* return api.save(payload)
|
|
42
|
+
* },
|
|
43
|
+
* }),
|
|
44
|
+
* (entity, payload) => ({
|
|
45
|
+
* data: { ...entity.data, ...payload },
|
|
46
|
+
* }),
|
|
47
|
+
* )
|
|
48
|
+
*/
|
|
49
|
+
export function optimistic(behavior, optimistic) {
|
|
50
|
+
const { type, scope } = resolveAsyncMeta(behavior)
|
|
51
|
+
|
|
52
|
+
const startEvent = `${type}Start`
|
|
53
|
+
const runEvent = `${type}Run`
|
|
54
|
+
const errorEvent = `${type}Error`
|
|
55
|
+
const finallyEvent = `${type}Finally`
|
|
56
|
+
|
|
57
|
+
const snapshots = new Map()
|
|
58
|
+
|
|
59
|
+
function notify(api, entity, event, payload) {
|
|
60
|
+
switch (scope) {
|
|
61
|
+
case "entity":
|
|
62
|
+
api.notify(`#${entity.id}:${event}`, payload)
|
|
63
|
+
break
|
|
64
|
+
case "type":
|
|
65
|
+
api.notify(`${entity.type}:${event}`, payload)
|
|
66
|
+
break
|
|
67
|
+
case "global":
|
|
68
|
+
api.notify(event, payload)
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const wrapped = {
|
|
74
|
+
...behavior,
|
|
75
|
+
|
|
76
|
+
[type](entity, payload, api) {
|
|
77
|
+
notify(api, entity, startEvent, payload)
|
|
78
|
+
notify(api, entity, runEvent, payload)
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
[startEvent](entity, payload, api) {
|
|
82
|
+
const patch = toPatch(optimistic, entity, payload, api)
|
|
83
|
+
|
|
84
|
+
if (!patch) {
|
|
85
|
+
behavior[startEvent]?.(entity, payload, api)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof patch !== "object") {
|
|
90
|
+
throw new TypeError(
|
|
91
|
+
"optimistic expects the patch to be an object or a factory that returns an object",
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const key = getEntityKey(entity)
|
|
96
|
+
const currentEntity = api.getEntity?.(key) ?? entity
|
|
97
|
+
snapshots.set(key, snapshotPatch(currentEntity, patch))
|
|
98
|
+
Object.assign(entity, patch)
|
|
99
|
+
|
|
100
|
+
behavior[startEvent]?.(entity, payload, api)
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
[runEvent]: behavior[runEvent],
|
|
104
|
+
|
|
105
|
+
[errorEvent](entity, error, api) {
|
|
106
|
+
const key = getEntityKey(entity)
|
|
107
|
+
restorePatch(entity, snapshots.get(key))
|
|
108
|
+
behavior[errorEvent]?.(entity, error, api)
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
[finallyEvent](entity, api) {
|
|
112
|
+
try {
|
|
113
|
+
behavior[finallyEvent]?.(entity, api)
|
|
114
|
+
} finally {
|
|
115
|
+
snapshots.delete(getEntityKey(entity))
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
Object.defineProperty(wrapped, ASYNC_META, {
|
|
121
|
+
value: { type, scope },
|
|
122
|
+
enumerable: false,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return wrapped
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveAsyncMeta(behavior) {
|
|
129
|
+
const meta = behavior?.[ASYNC_META]
|
|
130
|
+
if (meta?.type) {
|
|
131
|
+
return meta
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const runHandlers = Object.keys(behavior ?? {}).filter(
|
|
135
|
+
(key) => key.endsWith("Run") && typeof behavior[key] === "function",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const [runHandler, ...rest] = runHandlers
|
|
139
|
+
|
|
140
|
+
if (!runHandler && rest.length) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"optimistic expects a single handleAsync result or one '*Run' handler",
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
type: runHandler.slice(START, -"Run".length),
|
|
148
|
+
scope: "entity",
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getEntityKey(entity) {
|
|
153
|
+
const id = entity?.id
|
|
154
|
+
if (id == null) {
|
|
155
|
+
throw new Error("optimistic requires entities to have a stable `id`")
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return id
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toPatch(optimistic, entity, payload, api) {
|
|
162
|
+
if (typeof optimistic === "function") {
|
|
163
|
+
return optimistic(entity, payload, api)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return optimistic
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function snapshotPatch(entity, patch) {
|
|
170
|
+
const entries = []
|
|
171
|
+
|
|
172
|
+
for (const key of Object.keys(patch)) {
|
|
173
|
+
entries.push([
|
|
174
|
+
key,
|
|
175
|
+
Object.prototype.hasOwnProperty.call(entity, key),
|
|
176
|
+
entity[key],
|
|
177
|
+
])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return entries
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function restorePatch(entity, entries) {
|
|
184
|
+
if (!entries) {
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const [key, existed, value] of entries) {
|
|
189
|
+
if (existed) {
|
|
190
|
+
entity[key] = value
|
|
191
|
+
} else {
|
|
192
|
+
delete entity[key]
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Api } from "./api"
|
|
2
|
+
import { AsyncEventHandlers } from "./async"
|
|
3
|
+
import { BaseEntity } from "./store"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Decorates an async behavior with optimistic update boilerplate.
|
|
7
|
+
*
|
|
8
|
+
* Pass either a static shallow patch object or a factory that returns one.
|
|
9
|
+
* The wrapper snapshots the current values for those keys, applies the patch
|
|
10
|
+
* on start, and restores the previous values automatically if the operation fails.
|
|
11
|
+
*/
|
|
12
|
+
export function optimistic<
|
|
13
|
+
TEntity extends BaseEntity = BaseEntity,
|
|
14
|
+
TPayload = any,
|
|
15
|
+
TResult = any,
|
|
16
|
+
>(
|
|
17
|
+
behavior: AsyncEventHandlers<TEntity, TPayload, TResult>,
|
|
18
|
+
optimistic:
|
|
19
|
+
| Record<string, any>
|
|
20
|
+
| ((
|
|
21
|
+
entity: TEntity,
|
|
22
|
+
payload: TPayload,
|
|
23
|
+
api: Api,
|
|
24
|
+
) => Record<string, any> | null | undefined),
|
|
25
|
+
): AsyncEventHandlers<TEntity, TPayload, TResult>
|