@inglorious/store 11.0.1 → 11.0.2

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
@@ -1072,6 +1072,7 @@ const store = createStore({
1072
1072
  systems, // Array (optional): global state handlers
1073
1073
  autoCreateEntities, // Boolean (optional): false (default) or true
1074
1074
  updateMode, // String (optional): 'auto' (default) or 'manual'
1075
+ updateStrategy, // String (optional): 'structural-sharing' (default) or 'full-clone'
1075
1076
  })
1076
1077
  ```
1077
1078
 
@@ -1088,6 +1089,79 @@ const store = createStore({
1088
1089
  - **`updateMode`** (optional) - Controls when React components re-render:
1089
1090
  - `'auto'` (default) - Automatic updates after each event
1090
1091
  - `'manual'` - Manual control via `api.update()`
1092
+ - **`updateStrategy`** (optional) - Controls how the state is copied before queued events are applied:
1093
+ - `'structural-sharing'` (default) - Only the entities that actually changed are copied
1094
+ - `'full-clone'` - The whole state is deep-cloned and updated without a proxy
1095
+
1096
+ #### Update Strategy
1097
+
1098
+ `updateStrategy` decides how much copying work each update does. It exists because the right
1099
+ answer depends on how many entities change per update.
1100
+
1101
+ **`'structural-sharing'`** (default) applies events to a draft proxy (via
1102
+ [Mutative](https://github.com/pmndrs/mutative)), so only the entities that actually changed
1103
+ get copied. Untouched entities keep their previous object reference, which makes
1104
+ reference-equality change detection nearly free. This is what you want for UI stores, where a
1105
+ handful of entities change per interaction.
1106
+
1107
+ **`'full-clone'`** deep-clones the entire state with `structuredClone` and applies events directly to
1108
+ the copy, with no proxy involved. Every update costs time proportional to the total state size,
1109
+ but there's no per-entity proxy overhead, so it scales much better when thousands of entities
1110
+ change every frame — the typical case for a game simulation.
1111
+
1112
+ ```javascript
1113
+ // A UI store: few entities change, reference equality matters
1114
+ const store = createStore({
1115
+ types,
1116
+ entities,
1117
+ updateStrategy: "structural-sharing",
1118
+ })
1119
+
1120
+ // A game simulation: thousands of entities change every frame
1121
+ const store = createStore({ types, entities, updateStrategy: "full-clone" })
1122
+ ```
1123
+
1124
+ #### State swapping
1125
+
1126
+ Both strategies keep the current state intact while the queued events are applied to a separate
1127
+ draft, and swap the two only once every event has been processed. Handlers and systems always
1128
+ receive that draft as their first argument and mutate it directly, and subscribers are notified
1129
+ only after the swap.
1130
+
1131
+ A consequence worth knowing: during an update, `api.getEntity()` and `api.getEntities()` read the
1132
+ **previous** state, not the in-flight changes. This holds under both strategies, so code that
1133
+ needs to read another entity's up-to-date value must go through the event payload, or read it in
1134
+ a system that runs after the entity handlers.
1135
+
1136
+ ```javascript
1137
+ const types = {
1138
+ Counter: {
1139
+ // Reads the previous value of `other`, not the value set earlier in this same update.
1140
+ increment(entity, payload, api) {
1141
+ entity.previous = api.getEntity("other").count
1142
+ },
1143
+ },
1144
+ }
1145
+ ```
1146
+
1147
+ **Caveats for `'full-clone'`:**
1148
+
1149
+ - Every value in the state must be [structured-cloneable](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm).
1150
+ Functions, DOM nodes, and class instances with non-cloneable internals throw a
1151
+ `DataCloneError`. Keep such values in module scope or in a closure inside a behavior factory
1152
+ (as the `images()` and `audio()` engine behaviors do) rather than in the state.
1153
+ - The state is not frozen, even in `devMode`.
1154
+ - Writing to an entity obtained from `api.getEntity()` during an update mutates the _previous_
1155
+ state and is discarded, because the draft is a deep copy. Under `'structural-sharing'` the
1156
+ draft may still share that reference, so the write happens to survive. Always mutate the
1157
+ entity passed to your handler rather than one fetched through the api.
1158
+
1159
+ An unsupported strategy throws a `TypeError` at store creation:
1160
+
1161
+ ```javascript
1162
+ createStore({ types, entities, updateStrategy: "immutable" })
1163
+ // TypeError: Unsupported update strategy: immutable. Expected one of: structural-sharing, full-clone.
1164
+ ```
1091
1165
 
1092
1166
  #### Auto-Create Entities
1093
1167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "11.0.1",
3
+ "version": "11.0.2",
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",
@@ -55,18 +55,18 @@
55
55
  },
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "@inglorious/utils": "3.8.0",
58
+ "@inglorious/utils": "4.0.0",
59
59
  "mutative": "^1.3.0"
60
60
  },
61
61
  "peerDependencies": {
62
- "@inglorious/utils": "3.8.0"
62
+ "@inglorious/utils": "4.0.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@inglorious/eslint-config": "1.1.2",
66
- "@reduxjs/toolkit": "^2.11.2",
67
- "prettier": "^3.6.2",
68
- "vite": "^7.1.3",
69
- "vitest": "^1.6.1"
66
+ "@reduxjs/toolkit": "^2.12.0",
67
+ "prettier": "^3.9.6",
68
+ "vite": "^8.2.2",
69
+ "vitest": "^5.0.0"
70
70
  },
71
71
  "engines": {
72
72
  "node": ">= 22"
@@ -1,4 +1,4 @@
1
- import { deserialize } from "@inglorious/utils/data-structures/object.js"
1
+ import { deserialize } from "@inglorious/utils/objects"
2
2
 
3
3
  const LAST_STATE = 1
4
4
 
@@ -55,21 +55,21 @@ function connectDevTools(store, config = {}) {
55
55
  // Hot-swap store without resetting DevTools history
56
56
  existing.restoreSetState?.()
57
57
  const baseSetState = store.setState
58
- let restoreSetState = null
58
+ const restoreSetState =
59
+ updateMode === "auto"
60
+ ? () => {
61
+ if (store.setState !== baseSetState) {
62
+ store.setState = baseSetState
63
+ }
64
+ }
65
+ : () => {
66
+ // no-op when not wrapping setState
67
+ }
59
68
  if (updateMode === "auto") {
60
- restoreSetState = () => {
61
- if (store.setState !== baseSetState) {
62
- store.setState = baseSetState
63
- }
64
- }
65
69
  store.setState = (newState) => {
66
70
  baseSetState(newState)
67
71
  sendAction({ type: "stateInit", payload: newState }, store.getState())
68
72
  }
69
- } else {
70
- restoreSetState = () => {
71
- // no-op when not wrapping setState
72
- }
73
73
  }
74
74
  existing.store = store
75
75
  existing.restoreSetState = restoreSetState
@@ -90,22 +90,22 @@ function connectDevTools(store, config = {}) {
90
90
 
91
91
  const name = config.name ?? document.title
92
92
  const baseSetState = store.setState
93
- let restoreSetState = null
93
+ const restoreSetState =
94
+ updateMode === "auto"
95
+ ? () => {
96
+ if (store.setState !== baseSetState) {
97
+ store.setState = baseSetState
98
+ }
99
+ }
100
+ : () => {
101
+ // noop; we didn't wrap setState
102
+ }
94
103
  // Only add setState side-effects in auto update mode; manual update mode logs explicitly from engine.
95
104
  if (updateMode === "auto") {
96
- restoreSetState = () => {
97
- if (store.setState !== baseSetState) {
98
- store.setState = baseSetState
99
- }
100
- }
101
105
  store.setState = (newState) => {
102
106
  baseSetState(newState)
103
107
  sendAction({ type: "stateInit", payload: newState }, store.getState())
104
108
  }
105
- } else {
106
- restoreSetState = () => {
107
- // noop; we didn't wrap setState
108
- }
109
109
  }
110
110
 
111
111
  const devToolsInstance = window.__REDUX_DEVTOOLS_EXTENSION__.connect({
@@ -1,8 +1,4 @@
1
- import {
2
- deserialize,
3
- serialize,
4
- } from "@inglorious/utils/data-structures/object.js"
5
- import { extend } from "@inglorious/utils/data-structures/objects.js"
1
+ import { deserialize, extend, serialize } from "@inglorious/utils/objects"
6
2
 
7
3
  // A constant for the server's WebSocket URL.
8
4
  const DEFAULT_SERVER_URL = `ws://${window.location.hostname}:3000`
package/src/entities.js CHANGED
@@ -1,4 +1,4 @@
1
- import { map } from "@inglorious/utils/data-structures/object.js"
1
+ import { map } from "@inglorious/utils/objects"
2
2
 
3
3
  /**
4
4
  * @typedef {Object.<string, any>} Entity - An object representing an entity.
@@ -1,4 +1,4 @@
1
- import { compose } from "@inglorious/utils/functions/functions.js"
1
+ import { compose } from "@inglorious/utils/functions"
2
2
 
3
3
  /**
4
4
  * Applies a list of middleware functions to a store's dispatch method.
@@ -5,7 +5,7 @@
5
5
  * Inglorious types, enabling gradual migration from RTK to Inglorious Store.
6
6
  */
7
7
 
8
- import { extend } from "@inglorious/utils/data-structures/objects.js"
8
+ import { extend } from "@inglorious/utils/objects"
9
9
 
10
10
  import { handleAsync } from "../async"
11
11
 
package/src/store.js CHANGED
@@ -7,16 +7,46 @@ import { EventMap, parseEvent } from "./event-map.js"
7
7
  import { applyMiddlewares } from "./middlewares.js"
8
8
  import { augmentType, augmentTypes } from "./types.js"
9
9
 
10
+ /** Copies only the entities that change, using a draft proxy. */
11
+ const STRUCTURAL_SHARING = "structural-sharing"
12
+
13
+ /** Deep-clones the whole state and applies events to the copy, without a proxy. */
14
+ const FULL_CLONE = "full-clone"
15
+
16
+ const UPDATE_STRATEGIES = [STRUCTURAL_SHARING, FULL_CLONE]
17
+
10
18
  /**
11
19
  * Creates a store to manage state and events.
12
- * @param {Object} config - Configuration options for the store.
13
- * @param {Object} [config.types] - The initial types configuration.
14
- * @param {Object} [config.entities] - The initial entities configuration.
15
- * @param {Array} [config.systems] - The initial systems configuration.
16
- * @param {Array} [config.middlewares] - The initial middlewares configuration.
17
- * @param {boolean} [config.autoCreateEntities] - Creates entities if not defined in `config.entities`.
18
- * @param {"auto" | "manual"} [config.updateMode] - The update mode (defaults to "auto").
19
- * @returns {Object} The store with methods to interact with state and events.
20
+ *
21
+ * Events are queued and drained by {@link createStore|update}. The `updateStrategy` option
22
+ * controls how the state is copied before the queued events are applied:
23
+ *
24
+ * - `"structural-sharing"` (default) - Applies events to a draft proxy, so only the entities
25
+ * that actually changed are copied. Unchanged entities keep their previous reference, which
26
+ * makes change detection cheap for consumers that rely on reference equality. This is the
27
+ * right choice for UI stores, where a small number of entities change at a time.
28
+ * - `"full-clone"` - Deep-clones the whole state with `structuredClone` and applies events directly
29
+ * to the copy, without a proxy. The cost is proportional to the total state size on every
30
+ * update, but there is no per-entity proxy overhead, so it scales better when thousands of
31
+ * entities change every frame, as in a game simulation.
32
+ *
33
+ * Under both strategies the current state is kept intact while the queued events are applied to a
34
+ * separate draft, and the two are swapped only once every event has been processed. Handlers and
35
+ * systems therefore always receive the draft as their first argument and mutate it directly, and
36
+ * subscribers are notified only after the swap. This also means that, during an update,
37
+ * `api.getEntity()` and `api.getEntities()` read the *previous* state rather than the in-flight
38
+ * changes, under either strategy.
39
+ *
40
+ * @param {StoreConfig} config - Configuration options for the store.
41
+ * @param {Types} [config.types] - The initial types configuration.
42
+ * @param {Entities} [config.entities] - The initial entities configuration.
43
+ * @param {Object[]} [config.systems] - The initial systems configuration.
44
+ * @param {Function[]} [config.middlewares] - The initial middlewares configuration.
45
+ * @param {boolean} [config.autoCreateEntities=false] - Creates entities if not defined in `config.entities`.
46
+ * @param {"auto" | "manual"} [config.updateMode="auto"] - Whether each event triggers an update, or updates are batched until `update()` is called.
47
+ * @param {UpdateStrategy} [config.updateStrategy="structural-sharing"] - How the state is copied before events are applied.
48
+ * @returns {Store} The store with methods to interact with state and events.
49
+ * @throws {TypeError} If `updateStrategy` is not a supported strategy.
20
50
  */
21
51
  export function createStore({
22
52
  types: originalTypes = {},
@@ -25,7 +55,10 @@ export function createStore({
25
55
  middlewares = [],
26
56
  autoCreateEntities = false,
27
57
  updateMode = "auto",
58
+ updateStrategy = STRUCTURAL_SHARING,
28
59
  } = {}) {
60
+ assertUpdateStrategy(updateStrategy)
61
+
29
62
  const listeners = new Set()
30
63
 
31
64
  const types = augmentTypes(originalTypes)
@@ -73,6 +106,12 @@ export function createStore({
73
106
 
74
107
  /**
75
108
  * Updates the state based on elapsed time and processes events.
109
+ *
110
+ * The current state is left untouched while the queued events are applied to a separate
111
+ * draft, according to the configured `updateStrategy`. The draft only replaces the current
112
+ * state once every queued event has been processed, and subscribers are notified afterwards.
113
+ *
114
+ * @returns {Event[]} The events processed during this update.
76
115
  */
77
116
  function update() {
78
117
  if (isProcessing) {
@@ -81,18 +120,37 @@ export function createStore({
81
120
 
82
121
  isProcessing = true
83
122
  const processedEvents = []
123
+ let nextState
84
124
 
85
- state = create(state, patcher, {
86
- enableAutoFreeze: state.game?.devMode,
87
- })
125
+ if (updateStrategy === STRUCTURAL_SHARING) {
126
+ nextState = create(state, patch, {
127
+ enableAutoFreeze: state.game?.devMode,
128
+ })
129
+ } else {
130
+ const draft = structuredClone(state)
131
+
132
+ patch(draft)
133
+ nextState = draft
134
+ }
88
135
 
136
+ state = nextState
89
137
  isProcessing = false
90
138
 
91
139
  listeners.forEach((onUpdate) => onUpdate())
92
140
 
93
141
  return processedEvents
94
142
 
95
- function patcher(draft) {
143
+ /**
144
+ * Applies every queued event to the given draft.
145
+ *
146
+ * The draft is either the proxy returned by `create` (structural sharing) or the
147
+ * deep copy produced by `structuredClone` (full clone). The current state is not
148
+ * modified, and only becomes the new state once this function returns.
149
+ *
150
+ * @param {Entities} draft - The draft to apply the queued events to.
151
+ * @returns {void}
152
+ */
153
+ function patch(draft) {
96
154
  while (incomingEvents.length) {
97
155
  const event = incomingEvents.shift()
98
156
  processedEvents.push(event)
@@ -193,7 +251,8 @@ export function createStore({
193
251
  /**
194
252
  * Sets an augmented type configuration given its name.
195
253
  * @param {string} typeName - The name of the type to set.
196
- * @param {Object} type - The type configuration.
254
+ * @param {Type} type - The type configuration.
255
+ * @returns {void}
197
256
  */
198
257
  function setType(typeName, type) {
199
258
  const oldType = types[typeName]
@@ -229,7 +288,8 @@ export function createStore({
229
288
  /**
230
289
  * Sets the entire state of the store.
231
290
  * This is useful for importing state or setting initial state from a server.
232
- * @param {Object} nextState - The new state to set.
291
+ * @param {Entities} nextState - The new state to set.
292
+ * @returns {void}
233
293
  */
234
294
  function setState(nextState) {
235
295
  const oldEntities = state ?? {}
@@ -289,11 +349,18 @@ export function createStore({
289
349
 
290
350
  /**
291
351
  * Resets the store to its initial state.
352
+ * @returns {void}
292
353
  */
293
354
  function reset() {
294
355
  setState(originalEntities)
295
356
  }
296
357
 
358
+ /**
359
+ * Adds an entity to the state and registers its event handlers.
360
+ * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
361
+ * @param {Entity} payload - The entity to add, including its id.
362
+ * @returns {void}
363
+ */
297
364
  function addEntity(draft, payload) {
298
365
  const { id, ...entity } = payload
299
366
  draft[id] = augmentEntity(id, entity)
@@ -303,9 +370,40 @@ export function createStore({
303
370
  incomingEvents.unshift({ type: `#${id}:create` })
304
371
  }
305
372
 
306
- function removeEntity(draft, payload) {
307
- const id = payload
308
-
373
+ /**
374
+ * Queues the destruction of an entity. The entity is removed by the `#id:destroy` event.
375
+ * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
376
+ * @param {string} id - The id of the entity to remove.
377
+ * @returns {void}
378
+ */
379
+ function removeEntity(draft, id) {
309
380
  incomingEvents.unshift({ type: `#${id}:destroy` })
310
381
  }
311
382
  }
383
+
384
+ /**
385
+ * Validates the given update strategy.
386
+ * @param {UpdateStrategy} updateStrategy - The update strategy to validate.
387
+ * @returns {void}
388
+ * @throws {TypeError} If the update strategy is not supported.
389
+ */
390
+ function assertUpdateStrategy(updateStrategy) {
391
+ if (UPDATE_STRATEGIES.includes(updateStrategy)) {
392
+ return
393
+ }
394
+
395
+ throw new TypeError(
396
+ `Unsupported update strategy: ${updateStrategy}. Expected one of: ${UPDATE_STRATEGIES.join(", ")}.`,
397
+ )
398
+ }
399
+
400
+ /**
401
+ * @typedef {"structural-sharing" | "full-clone"} UpdateStrategy - How the state is copied before events are applied.
402
+ * @typedef {Object} StoreConfig - Configuration options accepted by {@link createStore}.
403
+ * @typedef {Object.<string, any>} Type - An augmented entity type.
404
+ * @typedef {Object.<string, Type>} Types - A collection of augmented types.
405
+ * @typedef {Object.<string, any>} Entity - An object representing an entity.
406
+ * @typedef {Object.<string, Entity>} Entities - A collection of entities indexed by id.
407
+ * @typedef {(listener: () => void) => () => void} Listener - Subscribes to state updates and returns an unsubscribe function.
408
+ * @typedef {Object} Store - The store returned by {@link createStore}.
409
+ */
package/src/types.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { ensureArray } from "@inglorious/utils/data-structures/array.js"
2
- import { map } from "@inglorious/utils/data-structures/object.js"
3
- import { extend } from "@inglorious/utils/data-structures/objects.js"
4
- import { pipe } from "@inglorious/utils/functions/functions.js"
2
+ import { pipe } from "@inglorious/utils/functions"
3
+ import { extend, map } from "@inglorious/utils/objects"
5
4
 
6
5
  /**
7
6
  * Augments a single type by composing its behaviors and mixins.
package/types/store.d.ts CHANGED
@@ -43,6 +43,28 @@ export type EntitiesState<TEntity extends BaseEntity = BaseEntity> = {
43
43
  export type TypesConfig<TEntity extends BaseEntity = BaseEntity> = {
44
44
  [typeName: string]: EntityType<TEntity>
45
45
  }
46
+ /**
47
+ * Whether each event triggers an update, or updates are batched until `update()` is called.
48
+ */
49
+ export type UpdateMode = "auto" | "manual"
50
+
51
+ /**
52
+ * How the state is copied before queued events are applied.
53
+ *
54
+ * - `"structural-sharing"` (default) applies events to a draft proxy, so only the entities that
55
+ * actually changed are copied. Unchanged entities keep their previous reference, which keeps
56
+ * reference-equality change detection cheap. Best for UI stores.
57
+ * - `"full-clone"` deep-clones the whole state with `structuredClone` and applies events to the
58
+ * copy without a proxy. Cost is proportional to the total state size per update, but there is
59
+ * no per-entity proxy overhead, so it scales better when thousands of entities change every
60
+ * frame, as in a game simulation.
61
+ *
62
+ * Under both strategies the current state is left intact while the queued events are applied to
63
+ * a separate draft, and the two are swapped only once every event has been processed. This means
64
+ * that during an update `api.getEntity()` and `api.getEntities()` read the previous state rather
65
+ * than the in-flight changes.
66
+ */
67
+ export type UpdateStrategy = "structural-sharing" | "full-clone"
46
68
 
47
69
  /**
48
70
  * Store configuration
@@ -56,7 +78,8 @@ export interface StoreConfig<
56
78
  systems?: System<TState>[]
57
79
  middlewares?: Middleware<TEntity, TState>[]
58
80
  autoCreateEntities?: boolean
59
- mode?: "eager" | "batched"
81
+ updateMode?: UpdateMode
82
+ updateStrategy?: UpdateStrategy
60
83
  }
61
84
 
62
85
  /**