@colyseus/schema 4.0.20 → 4.0.22

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/build/index.js CHANGED
@@ -1370,14 +1370,13 @@
1370
1370
  || this.root.types.parentFiltered[key]
1371
1371
  || fieldHasViewTag;
1372
1372
  //
1373
- // "isFiltered" may not be imedialely available during `change()` due to the instance not being attached to the root yet.
1373
+ // "isFiltered" may not be immediately available during `change()` due to the instance not being attached to the root yet.
1374
1374
  // when it's available, we need to enqueue the "changes" changeset into the "filteredChanges" changeset.
1375
1375
  //
1376
1376
  if (this.isFiltered) {
1377
1377
  this.isVisibilitySharedWithParent = (parentChangeTree.isFiltered &&
1378
1378
  typeof (refType) !== "string" &&
1379
- !fieldHasViewTag &&
1380
- parentIsCollection);
1379
+ !fieldHasViewTag);
1381
1380
  if (!this.filteredChanges) {
1382
1381
  this.filteredChanges = createChangeSet();
1383
1382
  this.allFilteredChanges = createChangeSet();
@@ -4394,8 +4393,25 @@
4394
4393
  }
4395
4394
  encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
4396
4395
  const viewOffset = it.offset;
4397
- // encode visibility changes (add/remove for this view)
4398
- for (const [refId, changes] of view.changes) {
4396
+ //
4397
+ // Iterate `view.changes` in topological order so a refId is never
4398
+ // SWITCH_TO_STRUCTURE'd before an earlier op has introduced it on
4399
+ // the decoder. Map insertion order alone isn't sufficient: a
4400
+ // sequence like view.remove(child) → view.add(child) on a child
4401
+ // whose ancestor wasn't yet visible can put the child entry into
4402
+ // the Map before its newly-visible ancestor.
4403
+ //
4404
+ // Hot-path optimization: `view.add` preserves topo order by
4405
+ // construction (addParentOf walks deepest-ancestor-first before
4406
+ // touching the obj's own entry). Only `view.remove` can leave the
4407
+ // Map dirty. `StateView.changesOutOfOrder` tracks this so most
4408
+ // encodes can iterate `view.changes` directly, paying nothing.
4409
+ //
4410
+ const orderedRefIds = view.changesOutOfOrder
4411
+ ? this.topoOrderViewChanges(view)
4412
+ : view.changes.keys();
4413
+ for (const refId of orderedRefIds) {
4414
+ const changes = view.changes.get(refId);
4399
4415
  const changeTree = this.root.changeTrees[refId];
4400
4416
  if (changeTree === undefined) {
4401
4417
  // detached instance, remove from view and skip.
@@ -4431,10 +4447,53 @@
4431
4447
  //
4432
4448
  // clear "view" changes after encoding
4433
4449
  view.changes.clear();
4450
+ view.changesOutOfOrder = false;
4434
4451
  // try to encode "filtered" changes
4435
4452
  this.encode(it, view, bytes, "filteredChanges", false, viewOffset);
4436
4453
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
4437
4454
  }
4455
+ /**
4456
+ * Produce a topological ordering of `view.changes` keys so each refId
4457
+ * is preceded by any ancestor that's also in the same view's changeset.
4458
+ *
4459
+ * The wire stream uses SWITCH_TO_STRUCTURE pointers; if a child is
4460
+ * encoded before any earlier op has introduced its refId on the
4461
+ * decoder, decode fails with "refId not found". An entry's refId can
4462
+ * only be introduced by an ADD on one of its ancestors — so any
4463
+ * ancestor that itself appears in this view's pending changes must
4464
+ * be encoded first.
4465
+ *
4466
+ * Implementation: DFS post-order over the parent chain. The `visited`
4467
+ * Set guards against duplicates; cycles are not expected in a
4468
+ * well-formed parent chain but the visited check is a cheap safety
4469
+ * net. Cost is O(n × d) for n entries with parent-chain depth d.
4470
+ */
4471
+ topoOrderViewChanges(view) {
4472
+ const result = [];
4473
+ const visited = new Set();
4474
+ const visit = (refId) => {
4475
+ if (visited.has(refId)) {
4476
+ return;
4477
+ }
4478
+ visited.add(refId);
4479
+ const changeTree = this.root.changeTrees[refId];
4480
+ if (changeTree !== undefined) {
4481
+ let chain = changeTree.parentChain;
4482
+ while (chain) {
4483
+ const parentRefId = chain.ref[$refId];
4484
+ if (parentRefId !== undefined && view.changes.has(parentRefId)) {
4485
+ visit(parentRefId);
4486
+ }
4487
+ chain = chain.next;
4488
+ }
4489
+ }
4490
+ result.push(refId);
4491
+ };
4492
+ for (const refId of view.changes.keys()) {
4493
+ visit(refId);
4494
+ }
4495
+ return result;
4496
+ }
4438
4497
  discardChanges() {
4439
4498
  // discard shared changes
4440
4499
  let current = this.root.changes.next;
@@ -5506,12 +5565,45 @@
5506
5565
  * (This is used to force encoding a property, even if it was not changed)
5507
5566
  */
5508
5567
  changes = new Map();
5568
+ /**
5569
+ * Set when an operation may have left `changes` out of topological
5570
+ * order (a parent that needs to be encoded before its descendants is
5571
+ * positioned after them in the Map). `Encoder.encodeView` consults
5572
+ * this flag and only runs the topo-ordering pass when it's true,
5573
+ * skipping the work in the common case where insertion order already
5574
+ * coincides with topo order.
5575
+ *
5576
+ * Only `remove()` can break the invariant: it writes entries that
5577
+ * bypass `addParentOf`'s deepest-ancestor-first ordering. Everything
5578
+ * else (including multi-parent re-adds) preserves order by
5579
+ * construction. Reset to false at the end of each encodeView pass
5580
+ * (when `changes` is cleared).
5581
+ */
5582
+ changesOutOfOrder = false;
5509
5583
  constructor(iterable = false) {
5510
5584
  this.iterable = iterable;
5511
5585
  if (iterable) {
5512
5586
  this.items = [];
5513
5587
  }
5514
5588
  }
5589
+ /**
5590
+ * Get the IndexedOperations entry for `refId`, creating one if missing.
5591
+ *
5592
+ * Map insertion order alone doesn't guarantee parent-before-child
5593
+ * iteration in all cases (a `view.remove()` followed by `view.add()`
5594
+ * can put a child entry into the Map before its newly-visible
5595
+ * ancestor). The wire-order invariant (parent SWITCH_TO_STRUCTURE
5596
+ * before any of its children's) is enforced at encode time by
5597
+ * `Encoder.encodeView` via a topological pass over `view.changes`.
5598
+ */
5599
+ touchChanges(refId) {
5600
+ let entry = this.changes.get(refId);
5601
+ if (entry === undefined) {
5602
+ entry = {};
5603
+ this.changes.set(refId, entry);
5604
+ }
5605
+ return entry;
5606
+ }
5515
5607
  // TODO: allow to set multiple tags at once
5516
5608
  add(obj, tag = DEFAULT_VIEW_TAG, checkIncludeParent = true) {
5517
5609
  const changeTree = obj?.[$changes];
@@ -5545,12 +5637,8 @@
5545
5637
  if (checkIncludeParent && parentChangeTree) {
5546
5638
  this.addParentOf(changeTree, tag);
5547
5639
  }
5548
- let changes = this.changes.get(obj[$refId]);
5549
- if (changes === undefined) {
5550
- changes = {};
5551
- // FIXME / OPTIMIZE: do not add if no changes are needed
5552
- this.changes.set(obj[$refId], changes);
5553
- }
5640
+ // FIXME / OPTIMIZE: do not add if no changes are needed
5641
+ const changes = this.touchChanges(obj[$refId]);
5554
5642
  let isChildAdded = false;
5555
5643
  //
5556
5644
  // Add children of this ChangeTree first.
@@ -5629,11 +5717,7 @@
5629
5717
  }
5630
5718
  // add parent's tag properties
5631
5719
  if (changeTree.getChange(parentIndex) !== exports.OPERATION.DELETE) {
5632
- let changes = this.changes.get(changeTree.ref[$refId]);
5633
- if (changes === undefined) {
5634
- changes = {};
5635
- this.changes.set(changeTree.ref[$refId], changes);
5636
- }
5720
+ const changes = this.touchChanges(changeTree.ref[$refId]);
5637
5721
  if (!this.tags) {
5638
5722
  this.tags = new WeakMap();
5639
5723
  }
@@ -5655,6 +5739,9 @@
5655
5739
  console.warn("StateView#remove(), invalid object:", obj);
5656
5740
  return this;
5657
5741
  }
5742
+ // remove() bypasses addParentOf's ordering guarantee — flag the
5743
+ // changeset as potentially out of topological order.
5744
+ this.changesOutOfOrder = true;
5658
5745
  this.visible.delete(changeTree);
5659
5746
  // remove from iterable list
5660
5747
  if (this.iterable &&
@@ -5665,22 +5752,12 @@
5665
5752
  const ref = changeTree.ref;
5666
5753
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
5667
5754
  const refId = ref[$refId];
5668
- let changes = this.changes.get(refId);
5669
- if (changes === undefined) {
5670
- changes = {};
5671
- this.changes.set(refId, changes);
5672
- }
5673
5755
  if (tag === DEFAULT_VIEW_TAG) {
5674
5756
  // parent is collection (Map/Array)
5675
5757
  const parent = changeTree.parent;
5676
5758
  if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) {
5677
- const parentRefId = parent[$refId];
5678
- let changes = this.changes.get(parentRefId);
5679
- if (changes === undefined) {
5680
- changes = {};
5681
- this.changes.set(parentRefId, changes);
5682
- }
5683
- else if (changes[changeTree.parentIndex] === exports.OPERATION.ADD) {
5759
+ const parentChanges = this.touchChanges(parent[$refId]);
5760
+ if (parentChanges[changeTree.parentIndex] === exports.OPERATION.ADD) {
5684
5761
  //
5685
5762
  // SAME PATCH ADD + REMOVE:
5686
5763
  // The 'changes' of deleted structure should be ignored.
@@ -5688,12 +5765,13 @@
5688
5765
  this.changes.delete(refId);
5689
5766
  }
5690
5767
  // DELETE / DELETE BY REF ID
5691
- changes[changeTree.parentIndex] = exports.OPERATION.DELETE;
5768
+ parentChanges[changeTree.parentIndex] = exports.OPERATION.DELETE;
5692
5769
  // Remove child schema from visible set
5693
5770
  this._recursiveDeleteVisibleChangeTree(changeTree);
5694
5771
  }
5695
5772
  else {
5696
5773
  // delete all "tagged" properties.
5774
+ const changes = this.touchChanges(refId);
5697
5775
  metadata?.[$viewFieldIndexes]?.forEach((index) => {
5698
5776
  changes[index] = exports.OPERATION.DELETE;
5699
5777
  // Remove child structures of @view() fields from visible set.
@@ -5708,6 +5786,7 @@
5708
5786
  }
5709
5787
  else {
5710
5788
  // delete only tagged properties
5789
+ const changes = this.touchChanges(refId);
5711
5790
  metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
5712
5791
  changes[index] = exports.OPERATION.DELETE;
5713
5792
  // Remove child structures from visible set
package/build/index.mjs CHANGED
@@ -1364,14 +1364,13 @@ class ChangeTree {
1364
1364
  || this.root.types.parentFiltered[key]
1365
1365
  || fieldHasViewTag;
1366
1366
  //
1367
- // "isFiltered" may not be imedialely available during `change()` due to the instance not being attached to the root yet.
1367
+ // "isFiltered" may not be immediately available during `change()` due to the instance not being attached to the root yet.
1368
1368
  // when it's available, we need to enqueue the "changes" changeset into the "filteredChanges" changeset.
1369
1369
  //
1370
1370
  if (this.isFiltered) {
1371
1371
  this.isVisibilitySharedWithParent = (parentChangeTree.isFiltered &&
1372
1372
  typeof (refType) !== "string" &&
1373
- !fieldHasViewTag &&
1374
- parentIsCollection);
1373
+ !fieldHasViewTag);
1375
1374
  if (!this.filteredChanges) {
1376
1375
  this.filteredChanges = createChangeSet();
1377
1376
  this.allFilteredChanges = createChangeSet();
@@ -4388,8 +4387,25 @@ class Encoder {
4388
4387
  }
4389
4388
  encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
4390
4389
  const viewOffset = it.offset;
4391
- // encode visibility changes (add/remove for this view)
4392
- for (const [refId, changes] of view.changes) {
4390
+ //
4391
+ // Iterate `view.changes` in topological order so a refId is never
4392
+ // SWITCH_TO_STRUCTURE'd before an earlier op has introduced it on
4393
+ // the decoder. Map insertion order alone isn't sufficient: a
4394
+ // sequence like view.remove(child) → view.add(child) on a child
4395
+ // whose ancestor wasn't yet visible can put the child entry into
4396
+ // the Map before its newly-visible ancestor.
4397
+ //
4398
+ // Hot-path optimization: `view.add` preserves topo order by
4399
+ // construction (addParentOf walks deepest-ancestor-first before
4400
+ // touching the obj's own entry). Only `view.remove` can leave the
4401
+ // Map dirty. `StateView.changesOutOfOrder` tracks this so most
4402
+ // encodes can iterate `view.changes` directly, paying nothing.
4403
+ //
4404
+ const orderedRefIds = view.changesOutOfOrder
4405
+ ? this.topoOrderViewChanges(view)
4406
+ : view.changes.keys();
4407
+ for (const refId of orderedRefIds) {
4408
+ const changes = view.changes.get(refId);
4393
4409
  const changeTree = this.root.changeTrees[refId];
4394
4410
  if (changeTree === undefined) {
4395
4411
  // detached instance, remove from view and skip.
@@ -4425,10 +4441,53 @@ class Encoder {
4425
4441
  //
4426
4442
  // clear "view" changes after encoding
4427
4443
  view.changes.clear();
4444
+ view.changesOutOfOrder = false;
4428
4445
  // try to encode "filtered" changes
4429
4446
  this.encode(it, view, bytes, "filteredChanges", false, viewOffset);
4430
4447
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
4431
4448
  }
4449
+ /**
4450
+ * Produce a topological ordering of `view.changes` keys so each refId
4451
+ * is preceded by any ancestor that's also in the same view's changeset.
4452
+ *
4453
+ * The wire stream uses SWITCH_TO_STRUCTURE pointers; if a child is
4454
+ * encoded before any earlier op has introduced its refId on the
4455
+ * decoder, decode fails with "refId not found". An entry's refId can
4456
+ * only be introduced by an ADD on one of its ancestors — so any
4457
+ * ancestor that itself appears in this view's pending changes must
4458
+ * be encoded first.
4459
+ *
4460
+ * Implementation: DFS post-order over the parent chain. The `visited`
4461
+ * Set guards against duplicates; cycles are not expected in a
4462
+ * well-formed parent chain but the visited check is a cheap safety
4463
+ * net. Cost is O(n × d) for n entries with parent-chain depth d.
4464
+ */
4465
+ topoOrderViewChanges(view) {
4466
+ const result = [];
4467
+ const visited = new Set();
4468
+ const visit = (refId) => {
4469
+ if (visited.has(refId)) {
4470
+ return;
4471
+ }
4472
+ visited.add(refId);
4473
+ const changeTree = this.root.changeTrees[refId];
4474
+ if (changeTree !== undefined) {
4475
+ let chain = changeTree.parentChain;
4476
+ while (chain) {
4477
+ const parentRefId = chain.ref[$refId];
4478
+ if (parentRefId !== undefined && view.changes.has(parentRefId)) {
4479
+ visit(parentRefId);
4480
+ }
4481
+ chain = chain.next;
4482
+ }
4483
+ }
4484
+ result.push(refId);
4485
+ };
4486
+ for (const refId of view.changes.keys()) {
4487
+ visit(refId);
4488
+ }
4489
+ return result;
4490
+ }
4432
4491
  discardChanges() {
4433
4492
  // discard shared changes
4434
4493
  let current = this.root.changes.next;
@@ -5500,12 +5559,45 @@ class StateView {
5500
5559
  * (This is used to force encoding a property, even if it was not changed)
5501
5560
  */
5502
5561
  changes = new Map();
5562
+ /**
5563
+ * Set when an operation may have left `changes` out of topological
5564
+ * order (a parent that needs to be encoded before its descendants is
5565
+ * positioned after them in the Map). `Encoder.encodeView` consults
5566
+ * this flag and only runs the topo-ordering pass when it's true,
5567
+ * skipping the work in the common case where insertion order already
5568
+ * coincides with topo order.
5569
+ *
5570
+ * Only `remove()` can break the invariant: it writes entries that
5571
+ * bypass `addParentOf`'s deepest-ancestor-first ordering. Everything
5572
+ * else (including multi-parent re-adds) preserves order by
5573
+ * construction. Reset to false at the end of each encodeView pass
5574
+ * (when `changes` is cleared).
5575
+ */
5576
+ changesOutOfOrder = false;
5503
5577
  constructor(iterable = false) {
5504
5578
  this.iterable = iterable;
5505
5579
  if (iterable) {
5506
5580
  this.items = [];
5507
5581
  }
5508
5582
  }
5583
+ /**
5584
+ * Get the IndexedOperations entry for `refId`, creating one if missing.
5585
+ *
5586
+ * Map insertion order alone doesn't guarantee parent-before-child
5587
+ * iteration in all cases (a `view.remove()` followed by `view.add()`
5588
+ * can put a child entry into the Map before its newly-visible
5589
+ * ancestor). The wire-order invariant (parent SWITCH_TO_STRUCTURE
5590
+ * before any of its children's) is enforced at encode time by
5591
+ * `Encoder.encodeView` via a topological pass over `view.changes`.
5592
+ */
5593
+ touchChanges(refId) {
5594
+ let entry = this.changes.get(refId);
5595
+ if (entry === undefined) {
5596
+ entry = {};
5597
+ this.changes.set(refId, entry);
5598
+ }
5599
+ return entry;
5600
+ }
5509
5601
  // TODO: allow to set multiple tags at once
5510
5602
  add(obj, tag = DEFAULT_VIEW_TAG, checkIncludeParent = true) {
5511
5603
  const changeTree = obj?.[$changes];
@@ -5539,12 +5631,8 @@ class StateView {
5539
5631
  if (checkIncludeParent && parentChangeTree) {
5540
5632
  this.addParentOf(changeTree, tag);
5541
5633
  }
5542
- let changes = this.changes.get(obj[$refId]);
5543
- if (changes === undefined) {
5544
- changes = {};
5545
- // FIXME / OPTIMIZE: do not add if no changes are needed
5546
- this.changes.set(obj[$refId], changes);
5547
- }
5634
+ // FIXME / OPTIMIZE: do not add if no changes are needed
5635
+ const changes = this.touchChanges(obj[$refId]);
5548
5636
  let isChildAdded = false;
5549
5637
  //
5550
5638
  // Add children of this ChangeTree first.
@@ -5623,11 +5711,7 @@ class StateView {
5623
5711
  }
5624
5712
  // add parent's tag properties
5625
5713
  if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
5626
- let changes = this.changes.get(changeTree.ref[$refId]);
5627
- if (changes === undefined) {
5628
- changes = {};
5629
- this.changes.set(changeTree.ref[$refId], changes);
5630
- }
5714
+ const changes = this.touchChanges(changeTree.ref[$refId]);
5631
5715
  if (!this.tags) {
5632
5716
  this.tags = new WeakMap();
5633
5717
  }
@@ -5649,6 +5733,9 @@ class StateView {
5649
5733
  console.warn("StateView#remove(), invalid object:", obj);
5650
5734
  return this;
5651
5735
  }
5736
+ // remove() bypasses addParentOf's ordering guarantee — flag the
5737
+ // changeset as potentially out of topological order.
5738
+ this.changesOutOfOrder = true;
5652
5739
  this.visible.delete(changeTree);
5653
5740
  // remove from iterable list
5654
5741
  if (this.iterable &&
@@ -5659,22 +5746,12 @@ class StateView {
5659
5746
  const ref = changeTree.ref;
5660
5747
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
5661
5748
  const refId = ref[$refId];
5662
- let changes = this.changes.get(refId);
5663
- if (changes === undefined) {
5664
- changes = {};
5665
- this.changes.set(refId, changes);
5666
- }
5667
5749
  if (tag === DEFAULT_VIEW_TAG) {
5668
5750
  // parent is collection (Map/Array)
5669
5751
  const parent = changeTree.parent;
5670
5752
  if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) {
5671
- const parentRefId = parent[$refId];
5672
- let changes = this.changes.get(parentRefId);
5673
- if (changes === undefined) {
5674
- changes = {};
5675
- this.changes.set(parentRefId, changes);
5676
- }
5677
- else if (changes[changeTree.parentIndex] === OPERATION.ADD) {
5753
+ const parentChanges = this.touchChanges(parent[$refId]);
5754
+ if (parentChanges[changeTree.parentIndex] === OPERATION.ADD) {
5678
5755
  //
5679
5756
  // SAME PATCH ADD + REMOVE:
5680
5757
  // The 'changes' of deleted structure should be ignored.
@@ -5682,12 +5759,13 @@ class StateView {
5682
5759
  this.changes.delete(refId);
5683
5760
  }
5684
5761
  // DELETE / DELETE BY REF ID
5685
- changes[changeTree.parentIndex] = OPERATION.DELETE;
5762
+ parentChanges[changeTree.parentIndex] = OPERATION.DELETE;
5686
5763
  // Remove child schema from visible set
5687
5764
  this._recursiveDeleteVisibleChangeTree(changeTree);
5688
5765
  }
5689
5766
  else {
5690
5767
  // delete all "tagged" properties.
5768
+ const changes = this.touchChanges(refId);
5691
5769
  metadata?.[$viewFieldIndexes]?.forEach((index) => {
5692
5770
  changes[index] = OPERATION.DELETE;
5693
5771
  // Remove child structures of @view() fields from visible set.
@@ -5702,6 +5780,7 @@ class StateView {
5702
5780
  }
5703
5781
  else {
5704
5782
  // delete only tagged properties
5783
+ const changes = this.touchChanges(refId);
5705
5784
  metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
5706
5785
  changes[index] = OPERATION.DELETE;
5707
5786
  // Remove child structures from visible set