@colyseus/schema 4.0.21 → 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
@@ -4393,8 +4393,25 @@
4393
4393
  }
4394
4394
  encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
4395
4395
  const viewOffset = it.offset;
4396
- // encode visibility changes (add/remove for this view)
4397
- 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);
4398
4415
  const changeTree = this.root.changeTrees[refId];
4399
4416
  if (changeTree === undefined) {
4400
4417
  // detached instance, remove from view and skip.
@@ -4430,10 +4447,53 @@
4430
4447
  //
4431
4448
  // clear "view" changes after encoding
4432
4449
  view.changes.clear();
4450
+ view.changesOutOfOrder = false;
4433
4451
  // try to encode "filtered" changes
4434
4452
  this.encode(it, view, bytes, "filteredChanges", false, viewOffset);
4435
4453
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
4436
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
+ }
4437
4497
  discardChanges() {
4438
4498
  // discard shared changes
4439
4499
  let current = this.root.changes.next;
@@ -5505,12 +5565,45 @@
5505
5565
  * (This is used to force encoding a property, even if it was not changed)
5506
5566
  */
5507
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;
5508
5583
  constructor(iterable = false) {
5509
5584
  this.iterable = iterable;
5510
5585
  if (iterable) {
5511
5586
  this.items = [];
5512
5587
  }
5513
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
+ }
5514
5607
  // TODO: allow to set multiple tags at once
5515
5608
  add(obj, tag = DEFAULT_VIEW_TAG, checkIncludeParent = true) {
5516
5609
  const changeTree = obj?.[$changes];
@@ -5544,12 +5637,8 @@
5544
5637
  if (checkIncludeParent && parentChangeTree) {
5545
5638
  this.addParentOf(changeTree, tag);
5546
5639
  }
5547
- let changes = this.changes.get(obj[$refId]);
5548
- if (changes === undefined) {
5549
- changes = {};
5550
- // FIXME / OPTIMIZE: do not add if no changes are needed
5551
- this.changes.set(obj[$refId], changes);
5552
- }
5640
+ // FIXME / OPTIMIZE: do not add if no changes are needed
5641
+ const changes = this.touchChanges(obj[$refId]);
5553
5642
  let isChildAdded = false;
5554
5643
  //
5555
5644
  // Add children of this ChangeTree first.
@@ -5628,11 +5717,7 @@
5628
5717
  }
5629
5718
  // add parent's tag properties
5630
5719
  if (changeTree.getChange(parentIndex) !== exports.OPERATION.DELETE) {
5631
- let changes = this.changes.get(changeTree.ref[$refId]);
5632
- if (changes === undefined) {
5633
- changes = {};
5634
- this.changes.set(changeTree.ref[$refId], changes);
5635
- }
5720
+ const changes = this.touchChanges(changeTree.ref[$refId]);
5636
5721
  if (!this.tags) {
5637
5722
  this.tags = new WeakMap();
5638
5723
  }
@@ -5654,6 +5739,9 @@
5654
5739
  console.warn("StateView#remove(), invalid object:", obj);
5655
5740
  return this;
5656
5741
  }
5742
+ // remove() bypasses addParentOf's ordering guarantee — flag the
5743
+ // changeset as potentially out of topological order.
5744
+ this.changesOutOfOrder = true;
5657
5745
  this.visible.delete(changeTree);
5658
5746
  // remove from iterable list
5659
5747
  if (this.iterable &&
@@ -5664,22 +5752,12 @@
5664
5752
  const ref = changeTree.ref;
5665
5753
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
5666
5754
  const refId = ref[$refId];
5667
- let changes = this.changes.get(refId);
5668
- if (changes === undefined) {
5669
- changes = {};
5670
- this.changes.set(refId, changes);
5671
- }
5672
5755
  if (tag === DEFAULT_VIEW_TAG) {
5673
5756
  // parent is collection (Map/Array)
5674
5757
  const parent = changeTree.parent;
5675
5758
  if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) {
5676
- const parentRefId = parent[$refId];
5677
- let changes = this.changes.get(parentRefId);
5678
- if (changes === undefined) {
5679
- changes = {};
5680
- this.changes.set(parentRefId, changes);
5681
- }
5682
- else if (changes[changeTree.parentIndex] === exports.OPERATION.ADD) {
5759
+ const parentChanges = this.touchChanges(parent[$refId]);
5760
+ if (parentChanges[changeTree.parentIndex] === exports.OPERATION.ADD) {
5683
5761
  //
5684
5762
  // SAME PATCH ADD + REMOVE:
5685
5763
  // The 'changes' of deleted structure should be ignored.
@@ -5687,12 +5765,13 @@
5687
5765
  this.changes.delete(refId);
5688
5766
  }
5689
5767
  // DELETE / DELETE BY REF ID
5690
- changes[changeTree.parentIndex] = exports.OPERATION.DELETE;
5768
+ parentChanges[changeTree.parentIndex] = exports.OPERATION.DELETE;
5691
5769
  // Remove child schema from visible set
5692
5770
  this._recursiveDeleteVisibleChangeTree(changeTree);
5693
5771
  }
5694
5772
  else {
5695
5773
  // delete all "tagged" properties.
5774
+ const changes = this.touchChanges(refId);
5696
5775
  metadata?.[$viewFieldIndexes]?.forEach((index) => {
5697
5776
  changes[index] = exports.OPERATION.DELETE;
5698
5777
  // Remove child structures of @view() fields from visible set.
@@ -5707,6 +5786,7 @@
5707
5786
  }
5708
5787
  else {
5709
5788
  // delete only tagged properties
5789
+ const changes = this.touchChanges(refId);
5710
5790
  metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
5711
5791
  changes[index] = exports.OPERATION.DELETE;
5712
5792
  // Remove child structures from visible set
package/build/index.mjs CHANGED
@@ -4387,8 +4387,25 @@ class Encoder {
4387
4387
  }
4388
4388
  encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
4389
4389
  const viewOffset = it.offset;
4390
- // encode visibility changes (add/remove for this view)
4391
- 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);
4392
4409
  const changeTree = this.root.changeTrees[refId];
4393
4410
  if (changeTree === undefined) {
4394
4411
  // detached instance, remove from view and skip.
@@ -4424,10 +4441,53 @@ class Encoder {
4424
4441
  //
4425
4442
  // clear "view" changes after encoding
4426
4443
  view.changes.clear();
4444
+ view.changesOutOfOrder = false;
4427
4445
  // try to encode "filtered" changes
4428
4446
  this.encode(it, view, bytes, "filteredChanges", false, viewOffset);
4429
4447
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
4430
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
+ }
4431
4491
  discardChanges() {
4432
4492
  // discard shared changes
4433
4493
  let current = this.root.changes.next;
@@ -5499,12 +5559,45 @@ class StateView {
5499
5559
  * (This is used to force encoding a property, even if it was not changed)
5500
5560
  */
5501
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;
5502
5577
  constructor(iterable = false) {
5503
5578
  this.iterable = iterable;
5504
5579
  if (iterable) {
5505
5580
  this.items = [];
5506
5581
  }
5507
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
+ }
5508
5601
  // TODO: allow to set multiple tags at once
5509
5602
  add(obj, tag = DEFAULT_VIEW_TAG, checkIncludeParent = true) {
5510
5603
  const changeTree = obj?.[$changes];
@@ -5538,12 +5631,8 @@ class StateView {
5538
5631
  if (checkIncludeParent && parentChangeTree) {
5539
5632
  this.addParentOf(changeTree, tag);
5540
5633
  }
5541
- let changes = this.changes.get(obj[$refId]);
5542
- if (changes === undefined) {
5543
- changes = {};
5544
- // FIXME / OPTIMIZE: do not add if no changes are needed
5545
- this.changes.set(obj[$refId], changes);
5546
- }
5634
+ // FIXME / OPTIMIZE: do not add if no changes are needed
5635
+ const changes = this.touchChanges(obj[$refId]);
5547
5636
  let isChildAdded = false;
5548
5637
  //
5549
5638
  // Add children of this ChangeTree first.
@@ -5622,11 +5711,7 @@ class StateView {
5622
5711
  }
5623
5712
  // add parent's tag properties
5624
5713
  if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
5625
- let changes = this.changes.get(changeTree.ref[$refId]);
5626
- if (changes === undefined) {
5627
- changes = {};
5628
- this.changes.set(changeTree.ref[$refId], changes);
5629
- }
5714
+ const changes = this.touchChanges(changeTree.ref[$refId]);
5630
5715
  if (!this.tags) {
5631
5716
  this.tags = new WeakMap();
5632
5717
  }
@@ -5648,6 +5733,9 @@ class StateView {
5648
5733
  console.warn("StateView#remove(), invalid object:", obj);
5649
5734
  return this;
5650
5735
  }
5736
+ // remove() bypasses addParentOf's ordering guarantee — flag the
5737
+ // changeset as potentially out of topological order.
5738
+ this.changesOutOfOrder = true;
5651
5739
  this.visible.delete(changeTree);
5652
5740
  // remove from iterable list
5653
5741
  if (this.iterable &&
@@ -5658,22 +5746,12 @@ class StateView {
5658
5746
  const ref = changeTree.ref;
5659
5747
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
5660
5748
  const refId = ref[$refId];
5661
- let changes = this.changes.get(refId);
5662
- if (changes === undefined) {
5663
- changes = {};
5664
- this.changes.set(refId, changes);
5665
- }
5666
5749
  if (tag === DEFAULT_VIEW_TAG) {
5667
5750
  // parent is collection (Map/Array)
5668
5751
  const parent = changeTree.parent;
5669
5752
  if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) {
5670
- const parentRefId = parent[$refId];
5671
- let changes = this.changes.get(parentRefId);
5672
- if (changes === undefined) {
5673
- changes = {};
5674
- this.changes.set(parentRefId, changes);
5675
- }
5676
- else if (changes[changeTree.parentIndex] === OPERATION.ADD) {
5753
+ const parentChanges = this.touchChanges(parent[$refId]);
5754
+ if (parentChanges[changeTree.parentIndex] === OPERATION.ADD) {
5677
5755
  //
5678
5756
  // SAME PATCH ADD + REMOVE:
5679
5757
  // The 'changes' of deleted structure should be ignored.
@@ -5681,12 +5759,13 @@ class StateView {
5681
5759
  this.changes.delete(refId);
5682
5760
  }
5683
5761
  // DELETE / DELETE BY REF ID
5684
- changes[changeTree.parentIndex] = OPERATION.DELETE;
5762
+ parentChanges[changeTree.parentIndex] = OPERATION.DELETE;
5685
5763
  // Remove child schema from visible set
5686
5764
  this._recursiveDeleteVisibleChangeTree(changeTree);
5687
5765
  }
5688
5766
  else {
5689
5767
  // delete all "tagged" properties.
5768
+ const changes = this.touchChanges(refId);
5690
5769
  metadata?.[$viewFieldIndexes]?.forEach((index) => {
5691
5770
  changes[index] = OPERATION.DELETE;
5692
5771
  // Remove child structures of @view() fields from visible set.
@@ -5701,6 +5780,7 @@ class StateView {
5701
5780
  }
5702
5781
  else {
5703
5782
  // delete only tagged properties
5783
+ const changes = this.touchChanges(refId);
5704
5784
  metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
5705
5785
  changes[index] = OPERATION.DELETE;
5706
5786
  // Remove child structures from visible set