@inglorious/store 10.0.0 → 11.0.1

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
@@ -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("fetchTodos", {
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
- success(entity, todos) {
521
- entity.todos = todos
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
- error(entity, error) {
525
- entity.error = error.message
526
- },
531
+ success(entity, todos) {
532
+ entity.todos = todos
533
+ },
527
534
 
528
- finally(entity) {
529
- entity.loading = false
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,8 +1,8 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "10.0.0",
3
+ "version": "11.0.1",
4
4
  "description": "A state manager for real-time, collaborative apps, inspired by game development patterns and compatible with Redux.",
5
- "author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
5
+ "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -55,18 +55,18 @@
55
55
  },
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "mutative": "^1.3.0",
59
- "@inglorious/utils": "3.8.0"
58
+ "@inglorious/utils": "3.8.0",
59
+ "mutative": "^1.3.0"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "@inglorious/utils": "3.8.0"
63
63
  },
64
64
  "devDependencies": {
65
+ "@inglorious/eslint-config": "1.1.2",
65
66
  "@reduxjs/toolkit": "^2.11.2",
66
67
  "prettier": "^3.6.2",
67
68
  "vite": "^7.1.3",
68
- "vitest": "^1.6.1",
69
- "@inglorious/eslint-config": "1.1.2"
69
+ "vitest": "^1.6.1"
70
70
  },
71
71
  "engines": {
72
72
  "node": ">= 22"
package/src/async.js CHANGED
@@ -146,7 +146,7 @@ export function handleAsync(type, handlers, options = {}) {
146
146
  }
147
147
  }
148
148
 
149
- return {
149
+ const asyncHandlers = {
150
150
  [type](entity, payload, api) {
151
151
  if (handlers.start) {
152
152
  notify(api, entity, `${type}Start`, payload)
@@ -162,13 +162,16 @@ export function handleAsync(type, handlers, options = {}) {
162
162
  }),
163
163
 
164
164
  async [`${type}Run`](entity, payload, api) {
165
+ // Snapshot routing keys before await — mutative revokes draft proxies after the patch.
166
+ const scopedEntity = { id: entity.id, type: entity.type }
167
+
165
168
  try {
166
169
  const result = await handlers.run(payload, api)
167
- notify(api, entity, `${type}Success`, result)
170
+ notify(api, scopedEntity, `${type}Success`, result)
168
171
  } catch (error) {
169
- notify(api, entity, `${type}Error`, error)
172
+ notify(api, scopedEntity, `${type}Error`, error)
170
173
  } finally {
171
- notify(api, entity, `${type}Finally`)
174
+ notify(api, scopedEntity, `${type}Finally`)
172
175
  }
173
176
  },
174
177
 
@@ -184,4 +187,13 @@ export function handleAsync(type, handlers, options = {}) {
184
187
  handlers.finally?.(entity, api)
185
188
  },
186
189
  }
190
+
191
+ Object.defineProperty(asyncHandlers, ASYNC_META, {
192
+ value: { type, scope },
193
+ enumerable: false,
194
+ })
195
+
196
+ return asyncHandlers
187
197
  }
198
+
199
+ 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
+ }
package/src/store.js CHANGED
@@ -244,6 +244,9 @@ export function createStore({
244
244
  const entitiesToDestroy = [...oldEntityIds].filter(
245
245
  (id) => !newEntityIds.has(id),
246
246
  )
247
+ const entitiesToUpdate = [...newEntityIds].filter((id) =>
248
+ oldEntityIds.has(id),
249
+ )
247
250
 
248
251
  state = oldEntities
249
252
  eventMap = new EventMap(types, oldEntities)
@@ -259,6 +262,17 @@ export function createStore({
259
262
  addEntity(state, { id, ...entity })
260
263
  })
261
264
 
265
+ entitiesToUpdate.forEach((id) => {
266
+ const oldType = oldEntities[id].type
267
+ const newEntity = newEntities[id]
268
+ state[id] = newEntity
269
+
270
+ if (newEntity.type !== oldType) {
271
+ eventMap.removeEntity(id, types[oldType], oldType)
272
+ eventMap.addEntity(id, types[newEntity.type], newEntity.type)
273
+ }
274
+ })
275
+
262
276
  if (autoCreateEntities) {
263
277
  for (const typeName of Object.keys(types)) {
264
278
  const entityId = toCamelCase(typeName)
@@ -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>