@inglorious/store 11.3.0 → 12.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
@@ -1239,7 +1239,7 @@ const store = createStore({
1239
1239
  systems, // Array (optional): global state handlers
1240
1240
  autoCreateEntities, // Boolean (optional): false (default) or true
1241
1241
  updateMode, // String (optional): 'auto' (default) or 'manual'
1242
- updateStrategy, // String (optional): 'structural-sharing' (default) or 'full-clone'
1242
+ updateStrategy, // String (optional): 'structural-sharing' (default) or 'copy-on-dispatch'
1243
1243
  })
1244
1244
  ```
1245
1245
 
@@ -1258,7 +1258,7 @@ const store = createStore({
1258
1258
  - `'manual'` - Manual control via `api.update()`
1259
1259
  - **`updateStrategy`** (optional) - Controls how the state is copied before queued events are applied:
1260
1260
  - `'structural-sharing'` (default) - Only the entities that actually changed are copied
1261
- - `'full-clone'` - The whole state is deep-cloned and updated without a proxy
1261
+ - `'copy-on-dispatch'` - Each entity is deep-copied as it is handed out, and updated without a proxy
1262
1262
 
1263
1263
  #### Update Strategy
1264
1264
 
@@ -1271,10 +1271,26 @@ get copied. Untouched entities keep their previous object reference, which makes
1271
1271
  reference-equality change detection nearly free. This is what you want for UI stores, where a
1272
1272
  handful of entities change per interaction.
1273
1273
 
1274
- **`'full-clone'`** deep-clones the entire state with `structuredClone` and applies events directly to
1275
- the copy, with no proxy involved. Every update costs time proportional to the total state size,
1276
- but there's no per-entity proxy overhead, so it scales much better when thousands of entities
1277
- change every frame — the typical case for a game simulation.
1274
+ **`'copy-on-dispatch'`** deep-copies an entity the first time it is handed to one of its handlers, or read
1275
+ by a system, and applies events directly to the copy, with no proxy involved. Entities that nothing
1276
+ touches during an update are not copied and keep their previous reference. Writing to a copy costs
1277
+ nothing however often it happens, so it scales much better when thousands of entities change every
1278
+ frame — the typical case for a game simulation.
1279
+
1280
+ Systems receive every entity at once, so each entity a system reads through its first argument is
1281
+ copied in case it is written to. A system that only needs to know how things stood should read
1282
+ them from `api.getEntities()` and go through its first argument only for what it writes:
1283
+
1284
+ ```javascript
1285
+ const readouts = {
1286
+ update(entities, dt, api) {
1287
+ const current = api.getEntities() // read without copying
1288
+ const count = Object.keys(current).length
1289
+
1290
+ entities.readout.value = `${count} entities` // copies only the readout
1291
+ },
1292
+ }
1293
+ ```
1278
1294
 
1279
1295
  ```javascript
1280
1296
  // A UI store: few entities change, reference equality matters
@@ -1285,7 +1301,11 @@ const store = createStore({
1285
1301
  })
1286
1302
 
1287
1303
  // A game simulation: thousands of entities change every frame
1288
- const store = createStore({ types, entities, updateStrategy: "full-clone" })
1304
+ const store = createStore({
1305
+ types,
1306
+ entities,
1307
+ updateStrategy: "copy-on-dispatch",
1308
+ })
1289
1309
  ```
1290
1310
 
1291
1311
  #### State swapping
@@ -1311,23 +1331,23 @@ const types = {
1311
1331
  }
1312
1332
  ```
1313
1333
 
1314
- **Caveats for `'full-clone'`:**
1334
+ **Caveats for `'copy-on-dispatch'`:**
1315
1335
 
1316
- - Every value in the state must be [structured-cloneable](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm).
1317
- Functions, DOM nodes, and class instances with non-cloneable internals throw a
1318
- `DataCloneError`. Keep such values in module scope or in a closure inside a behavior factory
1336
+ - Plain objects, arrays, vectors and primitives are copied directly. Anything else must be
1337
+ [structured-cloneable](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm):
1338
+ DOM nodes and class instances with non-cloneable internals throw a `DataCloneError`. Keep such values in module scope or in a closure inside a behavior factory
1319
1339
  (as the `images()` and `audio()` engine behaviors do) rather than in the state.
1320
1340
  - The state is not frozen, even in `devMode`.
1321
1341
  - Writing to an entity obtained from `api.getEntity()` during an update mutates the _previous_
1322
- state and is discarded, because the draft is a deep copy. Under `'structural-sharing'` the
1323
- draft may still share that reference, so the write happens to survive. Always mutate the
1324
- entity passed to your handler rather than one fetched through the api.
1342
+ state. Whether the write also survives depends on whether that entity has been copied yet, under
1343
+ either strategy. Always mutate the entity passed to your handler rather than one fetched through
1344
+ the api.
1325
1345
 
1326
1346
  An unsupported strategy throws a `TypeError` at store creation:
1327
1347
 
1328
1348
  ```javascript
1329
1349
  createStore({ types, entities, updateStrategy: "immutable" })
1330
- // TypeError: Unsupported update strategy: immutable. Expected one of: structural-sharing, full-clone.
1350
+ // TypeError: Unsupported update strategy: immutable. Expected one of: structural-sharing, copy-on-dispatch.
1331
1351
  ```
1332
1352
 
1333
1353
  #### Auto-Create Entities
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "11.3.0",
3
+ "version": "12.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": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -75,11 +75,11 @@
75
75
  },
76
76
  "sideEffects": false,
77
77
  "dependencies": {
78
- "@inglorious/utils": "5.0.0",
78
+ "@inglorious/utils": "5.1.0",
79
79
  "mutative": "^1.3.0"
80
80
  },
81
81
  "peerDependencies": {
82
- "@inglorious/utils": "5.0.0"
82
+ "@inglorious/utils": "5.1.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@inglorious/eslint-config": "1.1.4",
@@ -2,18 +2,6 @@ import { deserialize, extend, serialize } from "@inglorious/utils/object.js"
2
2
 
3
3
  const DEFAULT_RECONNECTION_DELAY = 1000
4
4
 
5
- /**
6
- * Where the server is, when the caller has not said.
7
- *
8
- * Read when it is asked for rather than when this module is imported, so that importing it
9
- * does not require a browser -- which is what lets the store be imported anywhere.
10
- *
11
- * @returns {string} The server's WebSocket URL.
12
- */
13
- function defaultServerUrl() {
14
- return `ws://${window.location.hostname}:3000`
15
- }
16
-
17
5
  /**
18
6
  * Creates and returns the multiplayer middleware.
19
7
  * @returns {Function} The middleware function.
@@ -102,3 +90,15 @@ export function multiplayerMiddleware(config = {}) {
102
90
  }
103
91
  }
104
92
  }
93
+
94
+ /**
95
+ * Where the server is, when the caller has not said.
96
+ *
97
+ * Read when it is asked for rather than when this module is imported, so that importing it
98
+ * does not require a browser -- which is what lets the store be imported anywhere.
99
+ *
100
+ * @returns {string} The server's WebSocket URL.
101
+ */
102
+ function defaultServerUrl() {
103
+ return `ws://${window.location.hostname}:3000`
104
+ }
package/src/store.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { clone } from "@inglorious/utils/data-structures/object.js"
1
2
  import { toCamelCase } from "@inglorious/utils/data-structures/string.js"
2
3
  import { create } from "mutative"
3
4
 
@@ -10,10 +11,10 @@ import { augmentType, augmentTypes } from "./types.js"
10
11
  /** Copies only the entities that change, using a draft proxy. */
11
12
  const STRUCTURAL_SHARING = "structural-sharing"
12
13
 
13
- /** Deep-clones the whole state and applies events to the copy, without a proxy. */
14
- const FULL_CLONE = "full-clone"
14
+ /** Deep-copies each entity as it is handed out, and applies events to the copy, without a proxy. */
15
+ const COPY_ON_DISPATCH = "copy-on-dispatch"
15
16
 
16
- const UPDATE_STRATEGIES = [STRUCTURAL_SHARING, FULL_CLONE]
17
+ const UPDATE_STRATEGIES = [STRUCTURAL_SHARING, COPY_ON_DISPATCH]
17
18
 
18
19
  /**
19
20
  * Creates a store to manage state and events.
@@ -25,10 +26,10 @@ const UPDATE_STRATEGIES = [STRUCTURAL_SHARING, FULL_CLONE]
25
26
  * that actually changed are copied. Unchanged entities keep their previous reference, which
26
27
  * makes change detection cheap for consumers that rely on reference equality. This is the
27
28
  * 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.
29
+ * - `"copy-on-dispatch"` - Deep-copies each entity the first time it is handed to a handler or read by
30
+ * a system, and applies events directly to the copy, without a proxy. Entities nothing touches
31
+ * are not copied, and there is no per-entity proxy overhead, so it scales better when
32
+ * thousands of entities change every frame, as in a game simulation.
32
33
  *
33
34
  * Under both strategies the current state is kept intact while the queued events are applied to a
34
35
  * separate draft, and the two are swapped only once every event has been processed. Handlers and
@@ -132,14 +133,14 @@ export function createStore({
132
133
  let nextState
133
134
 
134
135
  if (updateStrategy === STRUCTURAL_SHARING) {
135
- nextState = create(state, patch, {
136
+ nextState = create(state, (draft) => patch(writeThrough(draft)), {
136
137
  enableAutoFreeze: state.game?.devMode,
137
138
  })
138
139
  } else {
139
- const draft = structuredClone(state)
140
+ const draft = copyOnDispatch(state)
140
141
 
141
142
  patch(draft)
142
- nextState = draft
143
+ nextState = draft.entities
143
144
  }
144
145
 
145
146
  state = nextState
@@ -152,9 +153,10 @@ export function createStore({
152
153
  /**
153
154
  * Applies every queued event to the given draft.
154
155
  *
155
- * The draft is either the proxy returned by `create` (structural sharing) or the
156
- * deep copy produced by `structuredClone` (full clone). The current state is not
157
- * modified, and only becomes the new state once this function returns.
156
+ * The draft wraps either the proxy returned by `create` (structural sharing) or a
157
+ * copy of the entity map whose entities are copied as they are handed out (copy on
158
+ * dispatch). The current state is not modified, and only becomes the new state once
159
+ * this function returns.
158
160
  *
159
161
  * A change made by a handler is not visible to the next handler in the same pass:
160
162
  * `getState` still returns the state as it was before the pass began. This is on
@@ -163,7 +165,7 @@ export function createStore({
163
165
  * left is asking about the past. Anything reacting to a change should be told the
164
166
  * change rather than go looking for it.
165
167
  *
166
- * @param {Entities} draft - The draft to apply the queued events to.
168
+ * @param {Draft} draft - The draft to apply the queued events to.
167
169
  * @returns {void}
168
170
  */
169
171
  function patch(draft) {
@@ -247,24 +249,26 @@ export function createStore({
247
249
  const halted = isHalted && handlerName === "update"
248
250
 
249
251
  for (const id of entityIds) {
250
- const entity = draft[id]
252
+ // Looked at before it is taken, because an entity the pause holds back is
253
+ // not going to be written to, and taking it would copy it for nothing.
254
+ const entity = draft.entities[id]
251
255
  const type = types[entity.type]
252
256
 
253
257
  if (halted && !entity.updatesWhilePaused) continue
254
258
 
255
259
  const handle = type[handlerName]
256
260
 
257
- handle?.(entity, event.payload, api)
261
+ handle?.(draft.take(id), event.payload, api)
258
262
  }
259
263
 
260
264
  if (event.type.endsWith(":destroy")) {
261
265
  const [target] = event.type.split(":")
262
266
  const [, id] = target.split("#")
263
- const entity = draft[id]
267
+ const entity = draft.entities[id]
264
268
  if (entity) {
265
269
  const type = types[entity.type]
266
270
  const typeName = entity.type
267
- delete draft[id]
271
+ delete draft.entities[id]
268
272
 
269
273
  eventMap.removeEntity(id, type, typeName)
270
274
  }
@@ -273,7 +277,7 @@ export function createStore({
273
277
  // Systems process events by handler name (not scoped)
274
278
  systems.forEach((system) => {
275
279
  const handle = system[handlerName]
276
- handle?.(draft, event.payload, api)
280
+ handle?.(draft.forSystems, event.payload, api)
277
281
  })
278
282
  }
279
283
  }
@@ -423,7 +427,7 @@ export function createStore({
423
427
 
424
428
  entitiesToCreate.forEach((id) => {
425
429
  const entity = newEntities[id]
426
- addEntity(state, { id, ...entity })
430
+ addEntity(writeThrough(state), { id, ...entity })
427
431
  })
428
432
 
429
433
  entitiesToUpdate.forEach((id) => {
@@ -445,7 +449,7 @@ export function createStore({
445
449
  )
446
450
 
447
451
  if (!hasEntity) {
448
- addEntity(state, { id: entityId, type: typeName })
452
+ addEntity(writeThrough(state), { id: entityId, type: typeName })
449
453
  }
450
454
  }
451
455
  }
@@ -462,13 +466,13 @@ export function createStore({
462
466
 
463
467
  /**
464
468
  * Adds an entity to the state and registers its event handlers.
465
- * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
469
+ * @param {Draft} draft - The draft to put it in.
466
470
  * @param {Entity} payload - The entity to add, including its id.
467
471
  * @returns {void}
468
472
  */
469
473
  function addEntity(draft, payload) {
470
474
  const { id, ...entity } = payload
471
- draft[id] = augmentEntity(id, entity)
475
+ draft.put(id, augmentEntity(id, entity))
472
476
  const type = types[entity.type]
473
477
 
474
478
  eventMap.addEntity(id, type, entity.type)
@@ -482,7 +486,7 @@ export function createStore({
482
486
  * as being half a sort, so naming a different one means taking the thing out of the old
483
487
  * one's list of entities before putting it in the new one.
484
488
  *
485
- * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
489
+ * @param {Draft} draft - The draft it stands in.
486
490
  * @param {string} id - The thing being moved.
487
491
  * @param {string} previousType - The type it was under.
488
492
  * @param {string|undefined} type - The type it is under now, if it has named one.
@@ -503,20 +507,22 @@ export function createStore({
503
507
  * new configuration says otherwise. A field named as `undefined` is set to nothing,
504
508
  * which is what taking a key off a mapping means.
505
509
  *
506
- * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
510
+ * @param {Draft} draft - The draft to change it in.
507
511
  * @param {Entity} payload - The thing and the fields to merge into it.
508
512
  * @returns {void}
509
513
  */
510
514
  function patchEntity(draft, payload) {
511
515
  const { id, ...config } = payload
512
- const existing = draft[id]
516
+ // Taken rather than looked at, because the merge keeps whatever the configuration
517
+ // does not mention, and what it keeps must not be shared with the previous state.
518
+ const existing = draft.take(id)
513
519
 
514
520
  // Configuring something that is not there is not a thing that can be done. The world
515
521
  // is built in one pass, so a scene naming a thing another scene is yet to put up is
516
522
  // ordinary rather than a mistake, and is not worth failing over.
517
523
  if (!existing) return
518
524
 
519
- draft[id] = augmentEntity(id, { ...existing, ...config })
525
+ draft.put(id, augmentEntity(id, { ...existing, ...config }))
520
526
 
521
527
  moveToType(draft, id, existing.type, config.type)
522
528
  }
@@ -529,24 +535,25 @@ export function createStore({
529
535
  * nothing here as much as it is in a patch -- the difference between the two is what
530
536
  * happens to the fields neither mentions.
531
537
  *
532
- * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
538
+ * @param {Draft} draft - The draft to change it in.
533
539
  * @param {Entity} payload - The thing and the fields it is to be.
534
540
  * @returns {void}
535
541
  */
536
542
  function replaceEntity(draft, payload) {
537
543
  const { id, ...config } = payload
538
- const existing = draft[id]
544
+ const existing = draft.entities[id]
539
545
 
540
546
  if (!existing) return
541
547
 
542
- draft[id] = augmentEntity(id, config)
548
+ draft.put(id, augmentEntity(id, config))
543
549
 
544
550
  moveToType(draft, id, existing.type, config.type)
545
551
  }
546
552
 
547
553
  /**
548
554
  * Queues the destruction of an entity. The entity is removed by the `#id:destroy` event.
549
- * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
555
+ * @param {Draft|Entities} draft - Where it stands. The removal itself is queued, so
556
+ * nothing is written to it here.
550
557
  * @param {string} id - The id of the entity to remove.
551
558
  * @returns {void}
552
559
  */
@@ -555,6 +562,83 @@ export function createStore({
555
562
  }
556
563
  }
557
564
 
565
+ /**
566
+ * A draft that writes straight into the entities it is given.
567
+ *
568
+ * This is the whole of structural sharing's draft, because Mutative's proxy already copies
569
+ * an entity the moment it is written to, and of the state being built by `setState`,
570
+ * which has no previous state to protect.
571
+ *
572
+ * @param {Entities} entities - The entities to write into.
573
+ * @returns {Draft} The draft.
574
+ */
575
+ function writeThrough(entities) {
576
+ return {
577
+ entities,
578
+ forSystems: entities,
579
+ take: (id) => entities[id],
580
+ put(id, entity) {
581
+ entities[id] = entity
582
+ },
583
+ }
584
+ }
585
+
586
+ /**
587
+ * A draft that copies an entity the first time it is handed to anything that could
588
+ * write to it.
589
+ *
590
+ * This is what `copy-on-dispatch` is named after: an entity is copied when an event is
591
+ * dispatched to it, and the entities that nothing touches are never paid for. The map is
592
+ * copied straight away, which is cheap -- a reference for every entity -- and an entity
593
+ * is deep-copied only when it is taken: handed to one of its handlers, merged by a
594
+ * `patch`, or read by a system. A frame on which only the pointer moves copies the
595
+ * pointer, and a frame on which nothing at all happens copies nothing but the map.
596
+ *
597
+ * There is no proxy on the entities themselves, which is what still sets this apart
598
+ * from structural sharing: a handler is given a plain object, and pays nothing for
599
+ * writing to it however many times it does. The one proxy is over the map handed to
600
+ * systems, which have no single entity to be given and so have to be given all of them.
601
+ * Each entity a system reads through it is taken, which means a system that walks every
602
+ * entity copies every entity: one that only needs to know how things stood can ask
603
+ * `api.getEntities()` instead, and take only what it writes to.
604
+ *
605
+ * @param {Entities} state - The state to leave as it is.
606
+ * @returns {Draft} The draft.
607
+ */
608
+ function copyOnDispatch(state) {
609
+ const entities = { ...state }
610
+ const taken = new Set()
611
+
612
+ function take(id) {
613
+ if (!taken.has(id) && Object.hasOwn(entities, id)) {
614
+ entities[id] = clone(entities[id])
615
+ taken.add(id)
616
+ }
617
+
618
+ return entities[id]
619
+ }
620
+
621
+ function put(id, entity) {
622
+ entities[id] = entity
623
+ taken.add(id)
624
+ }
625
+
626
+ const forSystems = new Proxy(entities, {
627
+ get(target, key, receiver) {
628
+ return typeof key === "string" && Object.hasOwn(target, key)
629
+ ? take(key)
630
+ : Reflect.get(target, key, receiver)
631
+ },
632
+
633
+ set(target, key, value) {
634
+ put(key, value)
635
+ return true
636
+ },
637
+ })
638
+
639
+ return { entities, forSystems, take, put }
640
+ }
641
+
558
642
  /**
559
643
  * Validates the given update strategy.
560
644
  * @param {UpdateStrategy} updateStrategy - The update strategy to validate.
@@ -572,12 +656,17 @@ function assertUpdateStrategy(updateStrategy) {
572
656
  }
573
657
 
574
658
  /**
575
- * @typedef {"structural-sharing" | "full-clone"} UpdateStrategy - How the state is copied before events are applied.
659
+ * @typedef {"structural-sharing" | "copy-on-dispatch"} UpdateStrategy - How the state is copied before events are applied.
576
660
  * @typedef {Object} StoreConfig - Configuration options accepted by {@link createStore}.
577
661
  * @typedef {Object.<string, any>} Type - An augmented entity type.
578
662
  * @typedef {Object.<string, Type>} Types - A collection of augmented types.
579
663
  * @typedef {Object.<string, any>} Entity - An object representing an entity.
580
664
  * @typedef {Object.<string, Entity>} Entities - A collection of entities indexed by id.
665
+ * @typedef {Object} Draft - The entities an update writes to, and how it gets at them.
666
+ * @property {Entities} Draft.entities - The entities being built, read without copying.
667
+ * @property {Entities} Draft.forSystems - The entities as handed to systems.
668
+ * @property {(id: string) => Entity|undefined} Draft.take - The entity to write to.
669
+ * @property {(id: string, entity: Entity) => void} Draft.put - Sets an entity outright.
581
670
  * @typedef {(listener: () => void) => () => void} Listener - Subscribes to state updates and returns an unsubscribe function.
582
671
  * @typedef {Object} Store - The store returned by {@link createStore}.
583
672
  */
package/types/store.d.ts CHANGED
@@ -154,17 +154,17 @@ export type UpdateMode = "auto" | "manual"
154
154
  * - `"structural-sharing"` (default) applies events to a draft proxy, so only the entities that
155
155
  * actually changed are copied. Unchanged entities keep their previous reference, which keeps
156
156
  * reference-equality change detection cheap. Best for UI stores.
157
- * - `"full-clone"` deep-clones the whole state with `structuredClone` and applies events to the
158
- * copy without a proxy. Cost is proportional to the total state size per update, but there is
159
- * no per-entity proxy overhead, so it scales better when thousands of entities change every
160
- * frame, as in a game simulation.
157
+ * - `"copy-on-dispatch"` deep-copies each entity the first time it is handed to a handler or read by a
158
+ * system, and applies events to the copy without a proxy. Entities nothing touches are not
159
+ * copied, and there is no per-entity proxy overhead, so it scales better when thousands of
160
+ * entities change every frame, as in a game simulation.
161
161
  *
162
162
  * Under both strategies the current state is left intact while the queued events are applied to
163
163
  * a separate draft, and the two are swapped only once every event has been processed. This means
164
164
  * that during an update `api.getEntity()` and `api.getEntities()` read the previous state rather
165
165
  * than the in-flight changes.
166
166
  */
167
- export type UpdateStrategy = "structural-sharing" | "full-clone"
167
+ export type UpdateStrategy = "structural-sharing" | "copy-on-dispatch"
168
168
 
169
169
  /**
170
170
  * Store configuration