@inglorious/store 9.6.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 CHANGED
@@ -118,8 +118,8 @@ const types = {
118
118
 
119
119
  // Define initial entities
120
120
  const entities = {
121
- work: { type: "todoList", todos: [] },
122
- personal: { type: "todoList", todos: [] },
121
+ work: { type: "TodoList", todos: [] },
122
+ personal: { type: "TodoList", todos: [] },
123
123
  }
124
124
 
125
125
  // Create store
@@ -171,9 +171,9 @@ const types = {
171
171
  }
172
172
 
173
173
  const entities = {
174
- workTodos: { type: "todoList", todos: [], priority: "high" },
175
- personalTodos: { type: "todoList", todos: [], priority: "low" },
176
- settings: { type: "settings", theme: "dark", language: "en" },
174
+ workTodos: { type: "TodoList", todos: [], priority: "high" },
175
+ personalTodos: { type: "TodoList", todos: [], priority: "low" },
176
+ settings: { type: "Settings", theme: "dark", language: "en" },
177
177
  }
178
178
  ```
179
179
 
@@ -241,7 +241,7 @@ const types = {
241
241
 
242
242
  // 2. Define initial entities
243
243
  const entities = {
244
- counter1: { type: "counter", value: 0 },
244
+ counter1: { type: "Counter", value: 0 },
245
245
  }
246
246
 
247
247
  // 3. Create the store
@@ -352,12 +352,12 @@ const types = {
352
352
  }
353
353
 
354
354
  const entities = {
355
- counter1: { type: "counter", value: 0 },
356
- counter2: { type: "counter", value: 0 },
357
- counter3: { type: "counter", value: 0 },
355
+ counter1: { type: "Counter", value: 0 },
356
+ counter2: { type: "Counter", value: 0 },
357
+ counter3: { type: "Counter", value: 0 },
358
358
  }
359
359
 
360
- store.notify("add", { id: "counter4", type: "counter", value: 0 })
360
+ store.notify("add", { id: "counter4", type: "Counter", value: 0 })
361
361
  ```
362
362
 
363
363
  Inglorious Store has a few built-in events that you can use:
@@ -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
@@ -707,7 +774,7 @@ function increment(entity, payload, api) {
707
774
 
708
775
  // Test it
709
776
  const { entity, events } = trigger(
710
- { type: "counter", id: "counter1", value: 99 },
777
+ { type: "Counter", id: "counter1", value: 99 },
711
778
  increment,
712
779
  { amount: 5 },
713
780
  )
@@ -725,8 +792,8 @@ import { createMockApi, trigger } from "@inglorious/store/test"
725
792
 
726
793
  // Create a mock API with some initial entities
727
794
  const api = createMockApi({
728
- counter1: { type: "counter", value: 10 },
729
- counter2: { type: "counter", value: 20 },
795
+ counter1: { type: "Counter", value: 10 },
796
+ counter2: { type: "Counter", value: 20 },
730
797
  })
731
798
 
732
799
  // A handler that copies a value from another entity
@@ -737,7 +804,7 @@ function copyValue(entity, payload, api) {
737
804
 
738
805
  // Trigger the handler with the custom mock API
739
806
  const { entity } = trigger(
740
- { type: "counter", id: "counter2", value: 20 },
807
+ { type: "Counter", id: "counter2", value: 20 },
741
808
  copyValue,
742
809
  { sourceId: "counter1" },
743
810
  api,
@@ -1042,15 +1109,15 @@ const types = {
1042
1109
 
1043
1110
  // Without autoCreateEntities (default)
1044
1111
  const entities = {
1045
- settings: { type: "settings", theme: "dark" },
1046
- analytics: { type: "analytics", events: [] },
1112
+ settings: { type: "Settings", theme: "dark" },
1113
+ analytics: { type: "Analytics", events: [] },
1047
1114
  }
1048
1115
 
1049
1116
  // With autoCreateEntities: true
1050
1117
  const entities = {
1051
1118
  // settings and analytics will be auto-created as:
1052
- // settings: { type: "settings" }
1053
- // analytics: { type: "analytics" }
1119
+ // settings: { type: "Settings" }
1120
+ // analytics: { type: "Analytics" }
1054
1121
  }
1055
1122
 
1056
1123
  const store = createStore({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "9.6.0",
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",
@@ -56,10 +56,10 @@
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
58
  "mutative": "^1.3.0",
59
- "@inglorious/utils": "3.7.3"
59
+ "@inglorious/utils": "3.8.0"
60
60
  },
61
61
  "peerDependencies": {
62
- "@inglorious/utils": "3.7.3"
62
+ "@inglorious/utils": "3.8.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@reduxjs/toolkit": "^2.11.2",
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)
@@ -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
+ }
package/src/store.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { toCamelCase } from "@inglorious/utils/data-structures/string.js"
1
2
  import { create } from "mutative"
2
3
 
3
4
  import { createApi } from "./api.js"
@@ -118,8 +119,19 @@ export function createStore({
118
119
  const type = types[entity.type]
119
120
  const handle = type[handlerName]
120
121
 
121
- if (handle) {
122
- handle(entity, event.payload, api)
122
+ handle?.(entity, event.payload, api)
123
+ }
124
+
125
+ if (event.type.endsWith(":destroy")) {
126
+ const [target] = event.type.split(":")
127
+ const [, id] = target.split("#")
128
+ const entity = draft[id]
129
+ if (entity) {
130
+ const type = types[entity.type]
131
+ const typeName = entity.type
132
+ delete draft[id]
133
+
134
+ eventMap.removeEntity(id, type, typeName)
123
135
  }
124
136
  }
125
137
 
@@ -197,9 +209,10 @@ export function createStore({
197
209
  }
198
210
  }
199
211
 
200
- if (autoCreateEntities && !state[typeName]) {
212
+ const entityId = toCamelCase(typeName)
213
+ if (autoCreateEntities && !state[entityId]) {
201
214
  notify("add", {
202
- id: typeName,
215
+ id: entityId,
203
216
  type: typeName,
204
217
  })
205
218
  }
@@ -248,12 +261,13 @@ export function createStore({
248
261
 
249
262
  if (autoCreateEntities) {
250
263
  for (const typeName of Object.keys(types)) {
264
+ const entityId = toCamelCase(typeName)
251
265
  const hasEntity = Object.values(state).some(
252
266
  (entity) => entity.type === typeName,
253
267
  )
254
268
 
255
269
  if (!hasEntity) {
256
- addEntity(state, { id: typeName, type: typeName })
270
+ addEntity(state, { id: entityId, type: typeName })
257
271
  }
258
272
  }
259
273
  }
@@ -277,12 +291,7 @@ export function createStore({
277
291
 
278
292
  function removeEntity(draft, payload) {
279
293
  const id = payload
280
- const entity = draft[id]
281
- const type = types[entity.type]
282
- const typeName = entity.type
283
- delete draft[id]
284
294
 
285
- eventMap.removeEntity(id, type, typeName)
286
295
  incomingEvents.unshift({ type: `#${id}:destroy` })
287
296
  }
288
297
  }
@@ -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>