@colyseus/schema 4.0.21 → 4.0.23

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;
@@ -5478,6 +5538,7 @@
5478
5538
  else if ('decoder' in roomOrDecoder.serializer) {
5479
5539
  return getDecoderStateCallbacks(roomOrDecoder.serializer.decoder);
5480
5540
  }
5541
+ throw new Error('Invalid room or decoder');
5481
5542
  },
5482
5543
  getRawChanges(decoder, callback) {
5483
5544
  return getRawChangesCallback(decoder, callback);
@@ -5505,12 +5566,45 @@
5505
5566
  * (This is used to force encoding a property, even if it was not changed)
5506
5567
  */
5507
5568
  changes = new Map();
5569
+ /**
5570
+ * Set when an operation may have left `changes` out of topological
5571
+ * order (a parent that needs to be encoded before its descendants is
5572
+ * positioned after them in the Map). `Encoder.encodeView` consults
5573
+ * this flag and only runs the topo-ordering pass when it's true,
5574
+ * skipping the work in the common case where insertion order already
5575
+ * coincides with topo order.
5576
+ *
5577
+ * Only `remove()` can break the invariant: it writes entries that
5578
+ * bypass `addParentOf`'s deepest-ancestor-first ordering. Everything
5579
+ * else (including multi-parent re-adds) preserves order by
5580
+ * construction. Reset to false at the end of each encodeView pass
5581
+ * (when `changes` is cleared).
5582
+ */
5583
+ changesOutOfOrder = false;
5508
5584
  constructor(iterable = false) {
5509
5585
  this.iterable = iterable;
5510
5586
  if (iterable) {
5511
5587
  this.items = [];
5512
5588
  }
5513
5589
  }
5590
+ /**
5591
+ * Get the IndexedOperations entry for `refId`, creating one if missing.
5592
+ *
5593
+ * Map insertion order alone doesn't guarantee parent-before-child
5594
+ * iteration in all cases (a `view.remove()` followed by `view.add()`
5595
+ * can put a child entry into the Map before its newly-visible
5596
+ * ancestor). The wire-order invariant (parent SWITCH_TO_STRUCTURE
5597
+ * before any of its children's) is enforced at encode time by
5598
+ * `Encoder.encodeView` via a topological pass over `view.changes`.
5599
+ */
5600
+ touchChanges(refId) {
5601
+ let entry = this.changes.get(refId);
5602
+ if (entry === undefined) {
5603
+ entry = {};
5604
+ this.changes.set(refId, entry);
5605
+ }
5606
+ return entry;
5607
+ }
5514
5608
  // TODO: allow to set multiple tags at once
5515
5609
  add(obj, tag = DEFAULT_VIEW_TAG, checkIncludeParent = true) {
5516
5610
  const changeTree = obj?.[$changes];
@@ -5544,12 +5638,8 @@
5544
5638
  if (checkIncludeParent && parentChangeTree) {
5545
5639
  this.addParentOf(changeTree, tag);
5546
5640
  }
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
- }
5641
+ // FIXME / OPTIMIZE: do not add if no changes are needed
5642
+ const changes = this.touchChanges(obj[$refId]);
5553
5643
  let isChildAdded = false;
5554
5644
  //
5555
5645
  // Add children of this ChangeTree first.
@@ -5628,11 +5718,7 @@
5628
5718
  }
5629
5719
  // add parent's tag properties
5630
5720
  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
- }
5721
+ const changes = this.touchChanges(changeTree.ref[$refId]);
5636
5722
  if (!this.tags) {
5637
5723
  this.tags = new WeakMap();
5638
5724
  }
@@ -5654,6 +5740,9 @@
5654
5740
  console.warn("StateView#remove(), invalid object:", obj);
5655
5741
  return this;
5656
5742
  }
5743
+ // remove() bypasses addParentOf's ordering guarantee — flag the
5744
+ // changeset as potentially out of topological order.
5745
+ this.changesOutOfOrder = true;
5657
5746
  this.visible.delete(changeTree);
5658
5747
  // remove from iterable list
5659
5748
  if (this.iterable &&
@@ -5664,22 +5753,12 @@
5664
5753
  const ref = changeTree.ref;
5665
5754
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
5666
5755
  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
5756
  if (tag === DEFAULT_VIEW_TAG) {
5673
5757
  // parent is collection (Map/Array)
5674
5758
  const parent = changeTree.parent;
5675
5759
  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) {
5760
+ const parentChanges = this.touchChanges(parent[$refId]);
5761
+ if (parentChanges[changeTree.parentIndex] === exports.OPERATION.ADD) {
5683
5762
  //
5684
5763
  // SAME PATCH ADD + REMOVE:
5685
5764
  // The 'changes' of deleted structure should be ignored.
@@ -5687,12 +5766,13 @@
5687
5766
  this.changes.delete(refId);
5688
5767
  }
5689
5768
  // DELETE / DELETE BY REF ID
5690
- changes[changeTree.parentIndex] = exports.OPERATION.DELETE;
5769
+ parentChanges[changeTree.parentIndex] = exports.OPERATION.DELETE;
5691
5770
  // Remove child schema from visible set
5692
5771
  this._recursiveDeleteVisibleChangeTree(changeTree);
5693
5772
  }
5694
5773
  else {
5695
5774
  // delete all "tagged" properties.
5775
+ const changes = this.touchChanges(refId);
5696
5776
  metadata?.[$viewFieldIndexes]?.forEach((index) => {
5697
5777
  changes[index] = exports.OPERATION.DELETE;
5698
5778
  // Remove child structures of @view() fields from visible set.
@@ -5707,6 +5787,7 @@
5707
5787
  }
5708
5788
  else {
5709
5789
  // delete only tagged properties
5790
+ const changes = this.touchChanges(refId);
5710
5791
  metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
5711
5792
  changes[index] = exports.OPERATION.DELETE;
5712
5793
  // 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;
@@ -5472,6 +5532,7 @@ const Callbacks = {
5472
5532
  else if ('decoder' in roomOrDecoder.serializer) {
5473
5533
  return getDecoderStateCallbacks(roomOrDecoder.serializer.decoder);
5474
5534
  }
5535
+ throw new Error('Invalid room or decoder');
5475
5536
  },
5476
5537
  getRawChanges(decoder, callback) {
5477
5538
  return getRawChangesCallback(decoder, callback);
@@ -5499,12 +5560,45 @@ class StateView {
5499
5560
  * (This is used to force encoding a property, even if it was not changed)
5500
5561
  */
5501
5562
  changes = new Map();
5563
+ /**
5564
+ * Set when an operation may have left `changes` out of topological
5565
+ * order (a parent that needs to be encoded before its descendants is
5566
+ * positioned after them in the Map). `Encoder.encodeView` consults
5567
+ * this flag and only runs the topo-ordering pass when it's true,
5568
+ * skipping the work in the common case where insertion order already
5569
+ * coincides with topo order.
5570
+ *
5571
+ * Only `remove()` can break the invariant: it writes entries that
5572
+ * bypass `addParentOf`'s deepest-ancestor-first ordering. Everything
5573
+ * else (including multi-parent re-adds) preserves order by
5574
+ * construction. Reset to false at the end of each encodeView pass
5575
+ * (when `changes` is cleared).
5576
+ */
5577
+ changesOutOfOrder = false;
5502
5578
  constructor(iterable = false) {
5503
5579
  this.iterable = iterable;
5504
5580
  if (iterable) {
5505
5581
  this.items = [];
5506
5582
  }
5507
5583
  }
5584
+ /**
5585
+ * Get the IndexedOperations entry for `refId`, creating one if missing.
5586
+ *
5587
+ * Map insertion order alone doesn't guarantee parent-before-child
5588
+ * iteration in all cases (a `view.remove()` followed by `view.add()`
5589
+ * can put a child entry into the Map before its newly-visible
5590
+ * ancestor). The wire-order invariant (parent SWITCH_TO_STRUCTURE
5591
+ * before any of its children's) is enforced at encode time by
5592
+ * `Encoder.encodeView` via a topological pass over `view.changes`.
5593
+ */
5594
+ touchChanges(refId) {
5595
+ let entry = this.changes.get(refId);
5596
+ if (entry === undefined) {
5597
+ entry = {};
5598
+ this.changes.set(refId, entry);
5599
+ }
5600
+ return entry;
5601
+ }
5508
5602
  // TODO: allow to set multiple tags at once
5509
5603
  add(obj, tag = DEFAULT_VIEW_TAG, checkIncludeParent = true) {
5510
5604
  const changeTree = obj?.[$changes];
@@ -5538,12 +5632,8 @@ class StateView {
5538
5632
  if (checkIncludeParent && parentChangeTree) {
5539
5633
  this.addParentOf(changeTree, tag);
5540
5634
  }
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
- }
5635
+ // FIXME / OPTIMIZE: do not add if no changes are needed
5636
+ const changes = this.touchChanges(obj[$refId]);
5547
5637
  let isChildAdded = false;
5548
5638
  //
5549
5639
  // Add children of this ChangeTree first.
@@ -5622,11 +5712,7 @@ class StateView {
5622
5712
  }
5623
5713
  // add parent's tag properties
5624
5714
  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
- }
5715
+ const changes = this.touchChanges(changeTree.ref[$refId]);
5630
5716
  if (!this.tags) {
5631
5717
  this.tags = new WeakMap();
5632
5718
  }
@@ -5648,6 +5734,9 @@ class StateView {
5648
5734
  console.warn("StateView#remove(), invalid object:", obj);
5649
5735
  return this;
5650
5736
  }
5737
+ // remove() bypasses addParentOf's ordering guarantee — flag the
5738
+ // changeset as potentially out of topological order.
5739
+ this.changesOutOfOrder = true;
5651
5740
  this.visible.delete(changeTree);
5652
5741
  // remove from iterable list
5653
5742
  if (this.iterable &&
@@ -5658,22 +5747,12 @@ class StateView {
5658
5747
  const ref = changeTree.ref;
5659
5748
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
5660
5749
  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
5750
  if (tag === DEFAULT_VIEW_TAG) {
5667
5751
  // parent is collection (Map/Array)
5668
5752
  const parent = changeTree.parent;
5669
5753
  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) {
5754
+ const parentChanges = this.touchChanges(parent[$refId]);
5755
+ if (parentChanges[changeTree.parentIndex] === OPERATION.ADD) {
5677
5756
  //
5678
5757
  // SAME PATCH ADD + REMOVE:
5679
5758
  // The 'changes' of deleted structure should be ignored.
@@ -5681,12 +5760,13 @@ class StateView {
5681
5760
  this.changes.delete(refId);
5682
5761
  }
5683
5762
  // DELETE / DELETE BY REF ID
5684
- changes[changeTree.parentIndex] = OPERATION.DELETE;
5763
+ parentChanges[changeTree.parentIndex] = OPERATION.DELETE;
5685
5764
  // Remove child schema from visible set
5686
5765
  this._recursiveDeleteVisibleChangeTree(changeTree);
5687
5766
  }
5688
5767
  else {
5689
5768
  // delete all "tagged" properties.
5769
+ const changes = this.touchChanges(refId);
5690
5770
  metadata?.[$viewFieldIndexes]?.forEach((index) => {
5691
5771
  changes[index] = OPERATION.DELETE;
5692
5772
  // Remove child structures of @view() fields from visible set.
@@ -5701,6 +5781,7 @@ class StateView {
5701
5781
  }
5702
5782
  else {
5703
5783
  // delete only tagged properties
5784
+ const changes = this.touchChanges(refId);
5704
5785
  metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
5705
5786
  changes[index] = OPERATION.DELETE;
5706
5787
  // Remove child structures from visible set