@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.
@@ -16,6 +16,23 @@ export declare class Encoder<T extends Schema = any> {
16
16
  encodeAll(it?: Iterator, buffer?: Uint8Array): Uint8Array<ArrayBufferLike>;
17
17
  encodeAllView(view: StateView, sharedOffset: number, it: Iterator, bytes?: Uint8Array): Uint8Array<ArrayBufferLike>;
18
18
  encodeView(view: StateView, sharedOffset: number, it: Iterator, bytes?: Uint8Array): Uint8Array<ArrayBufferLike>;
19
+ /**
20
+ * Produce a topological ordering of `view.changes` keys so each refId
21
+ * is preceded by any ancestor that's also in the same view's changeset.
22
+ *
23
+ * The wire stream uses SWITCH_TO_STRUCTURE pointers; if a child is
24
+ * encoded before any earlier op has introduced its refId on the
25
+ * decoder, decode fails with "refId not found". An entry's refId can
26
+ * only be introduced by an ADD on one of its ancestors — so any
27
+ * ancestor that itself appears in this view's pending changes must
28
+ * be encoded first.
29
+ *
30
+ * Implementation: DFS post-order over the parent chain. The `visited`
31
+ * Set guards against duplicates; cycles are not expected in a
32
+ * well-formed parent chain but the visited check is a cheap safety
33
+ * net. Cost is O(n × d) for n entries with parent-chain depth d.
34
+ */
35
+ protected topoOrderViewChanges(view: StateView): number[];
19
36
  discardChanges(): void;
20
37
  tryEncodeTypeId(bytes: Uint8Array, baseType: typeof Schema, targetType: typeof Schema, it: Iterator): void;
21
38
  get hasChanges(): boolean;
@@ -21,7 +21,33 @@ export declare class StateView {
21
21
  * (This is used to force encoding a property, even if it was not changed)
22
22
  */
23
23
  changes: Map<number, IndexedOperations>;
24
+ /**
25
+ * Set when an operation may have left `changes` out of topological
26
+ * order (a parent that needs to be encoded before its descendants is
27
+ * positioned after them in the Map). `Encoder.encodeView` consults
28
+ * this flag and only runs the topo-ordering pass when it's true,
29
+ * skipping the work in the common case where insertion order already
30
+ * coincides with topo order.
31
+ *
32
+ * Only `remove()` can break the invariant: it writes entries that
33
+ * bypass `addParentOf`'s deepest-ancestor-first ordering. Everything
34
+ * else (including multi-parent re-adds) preserves order by
35
+ * construction. Reset to false at the end of each encodeView pass
36
+ * (when `changes` is cleared).
37
+ */
38
+ changesOutOfOrder: boolean;
24
39
  constructor(iterable?: boolean);
40
+ /**
41
+ * Get the IndexedOperations entry for `refId`, creating one if missing.
42
+ *
43
+ * Map insertion order alone doesn't guarantee parent-before-child
44
+ * iteration in all cases (a `view.remove()` followed by `view.add()`
45
+ * can put a child entry into the Map before its newly-visible
46
+ * ancestor). The wire-order invariant (parent SWITCH_TO_STRUCTURE
47
+ * before any of its children's) is enforced at encode time by
48
+ * `Encoder.encodeView` via a topological pass over `view.changes`.
49
+ */
50
+ protected touchChanges(refId: number): IndexedOperations;
25
51
  add(obj: Ref, tag?: number, checkIncludeParent?: boolean): boolean;
26
52
  protected addParentOf(childChangeTree: ChangeTree, tag: number): void;
27
53
  remove(obj: Ref, tag?: number): this;
package/build/index.cjs CHANGED
@@ -4389,8 +4389,25 @@ class Encoder {
4389
4389
  }
4390
4390
  encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
4391
4391
  const viewOffset = it.offset;
4392
- // encode visibility changes (add/remove for this view)
4393
- for (const [refId, changes] of view.changes) {
4392
+ //
4393
+ // Iterate `view.changes` in topological order so a refId is never
4394
+ // SWITCH_TO_STRUCTURE'd before an earlier op has introduced it on
4395
+ // the decoder. Map insertion order alone isn't sufficient: a
4396
+ // sequence like view.remove(child) → view.add(child) on a child
4397
+ // whose ancestor wasn't yet visible can put the child entry into
4398
+ // the Map before its newly-visible ancestor.
4399
+ //
4400
+ // Hot-path optimization: `view.add` preserves topo order by
4401
+ // construction (addParentOf walks deepest-ancestor-first before
4402
+ // touching the obj's own entry). Only `view.remove` can leave the
4403
+ // Map dirty. `StateView.changesOutOfOrder` tracks this so most
4404
+ // encodes can iterate `view.changes` directly, paying nothing.
4405
+ //
4406
+ const orderedRefIds = view.changesOutOfOrder
4407
+ ? this.topoOrderViewChanges(view)
4408
+ : view.changes.keys();
4409
+ for (const refId of orderedRefIds) {
4410
+ const changes = view.changes.get(refId);
4394
4411
  const changeTree = this.root.changeTrees[refId];
4395
4412
  if (changeTree === undefined) {
4396
4413
  // detached instance, remove from view and skip.
@@ -4426,10 +4443,53 @@ class Encoder {
4426
4443
  //
4427
4444
  // clear "view" changes after encoding
4428
4445
  view.changes.clear();
4446
+ view.changesOutOfOrder = false;
4429
4447
  // try to encode "filtered" changes
4430
4448
  this.encode(it, view, bytes, "filteredChanges", false, viewOffset);
4431
4449
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
4432
4450
  }
4451
+ /**
4452
+ * Produce a topological ordering of `view.changes` keys so each refId
4453
+ * is preceded by any ancestor that's also in the same view's changeset.
4454
+ *
4455
+ * The wire stream uses SWITCH_TO_STRUCTURE pointers; if a child is
4456
+ * encoded before any earlier op has introduced its refId on the
4457
+ * decoder, decode fails with "refId not found". An entry's refId can
4458
+ * only be introduced by an ADD on one of its ancestors — so any
4459
+ * ancestor that itself appears in this view's pending changes must
4460
+ * be encoded first.
4461
+ *
4462
+ * Implementation: DFS post-order over the parent chain. The `visited`
4463
+ * Set guards against duplicates; cycles are not expected in a
4464
+ * well-formed parent chain but the visited check is a cheap safety
4465
+ * net. Cost is O(n × d) for n entries with parent-chain depth d.
4466
+ */
4467
+ topoOrderViewChanges(view) {
4468
+ const result = [];
4469
+ const visited = new Set();
4470
+ const visit = (refId) => {
4471
+ if (visited.has(refId)) {
4472
+ return;
4473
+ }
4474
+ visited.add(refId);
4475
+ const changeTree = this.root.changeTrees[refId];
4476
+ if (changeTree !== undefined) {
4477
+ let chain = changeTree.parentChain;
4478
+ while (chain) {
4479
+ const parentRefId = chain.ref[$refId];
4480
+ if (parentRefId !== undefined && view.changes.has(parentRefId)) {
4481
+ visit(parentRefId);
4482
+ }
4483
+ chain = chain.next;
4484
+ }
4485
+ }
4486
+ result.push(refId);
4487
+ };
4488
+ for (const refId of view.changes.keys()) {
4489
+ visit(refId);
4490
+ }
4491
+ return result;
4492
+ }
4433
4493
  discardChanges() {
4434
4494
  // discard shared changes
4435
4495
  let current = this.root.changes.next;
@@ -5501,12 +5561,45 @@ class StateView {
5501
5561
  * (This is used to force encoding a property, even if it was not changed)
5502
5562
  */
5503
5563
  changes = new Map();
5564
+ /**
5565
+ * Set when an operation may have left `changes` out of topological
5566
+ * order (a parent that needs to be encoded before its descendants is
5567
+ * positioned after them in the Map). `Encoder.encodeView` consults
5568
+ * this flag and only runs the topo-ordering pass when it's true,
5569
+ * skipping the work in the common case where insertion order already
5570
+ * coincides with topo order.
5571
+ *
5572
+ * Only `remove()` can break the invariant: it writes entries that
5573
+ * bypass `addParentOf`'s deepest-ancestor-first ordering. Everything
5574
+ * else (including multi-parent re-adds) preserves order by
5575
+ * construction. Reset to false at the end of each encodeView pass
5576
+ * (when `changes` is cleared).
5577
+ */
5578
+ changesOutOfOrder = false;
5504
5579
  constructor(iterable = false) {
5505
5580
  this.iterable = iterable;
5506
5581
  if (iterable) {
5507
5582
  this.items = [];
5508
5583
  }
5509
5584
  }
5585
+ /**
5586
+ * Get the IndexedOperations entry for `refId`, creating one if missing.
5587
+ *
5588
+ * Map insertion order alone doesn't guarantee parent-before-child
5589
+ * iteration in all cases (a `view.remove()` followed by `view.add()`
5590
+ * can put a child entry into the Map before its newly-visible
5591
+ * ancestor). The wire-order invariant (parent SWITCH_TO_STRUCTURE
5592
+ * before any of its children's) is enforced at encode time by
5593
+ * `Encoder.encodeView` via a topological pass over `view.changes`.
5594
+ */
5595
+ touchChanges(refId) {
5596
+ let entry = this.changes.get(refId);
5597
+ if (entry === undefined) {
5598
+ entry = {};
5599
+ this.changes.set(refId, entry);
5600
+ }
5601
+ return entry;
5602
+ }
5510
5603
  // TODO: allow to set multiple tags at once
5511
5604
  add(obj, tag = DEFAULT_VIEW_TAG, checkIncludeParent = true) {
5512
5605
  const changeTree = obj?.[$changes];
@@ -5540,12 +5633,8 @@ class StateView {
5540
5633
  if (checkIncludeParent && parentChangeTree) {
5541
5634
  this.addParentOf(changeTree, tag);
5542
5635
  }
5543
- let changes = this.changes.get(obj[$refId]);
5544
- if (changes === undefined) {
5545
- changes = {};
5546
- // FIXME / OPTIMIZE: do not add if no changes are needed
5547
- this.changes.set(obj[$refId], changes);
5548
- }
5636
+ // FIXME / OPTIMIZE: do not add if no changes are needed
5637
+ const changes = this.touchChanges(obj[$refId]);
5549
5638
  let isChildAdded = false;
5550
5639
  //
5551
5640
  // Add children of this ChangeTree first.
@@ -5624,11 +5713,7 @@ class StateView {
5624
5713
  }
5625
5714
  // add parent's tag properties
5626
5715
  if (changeTree.getChange(parentIndex) !== exports.OPERATION.DELETE) {
5627
- let changes = this.changes.get(changeTree.ref[$refId]);
5628
- if (changes === undefined) {
5629
- changes = {};
5630
- this.changes.set(changeTree.ref[$refId], changes);
5631
- }
5716
+ const changes = this.touchChanges(changeTree.ref[$refId]);
5632
5717
  if (!this.tags) {
5633
5718
  this.tags = new WeakMap();
5634
5719
  }
@@ -5650,6 +5735,9 @@ class StateView {
5650
5735
  console.warn("StateView#remove(), invalid object:", obj);
5651
5736
  return this;
5652
5737
  }
5738
+ // remove() bypasses addParentOf's ordering guarantee — flag the
5739
+ // changeset as potentially out of topological order.
5740
+ this.changesOutOfOrder = true;
5653
5741
  this.visible.delete(changeTree);
5654
5742
  // remove from iterable list
5655
5743
  if (this.iterable &&
@@ -5660,22 +5748,12 @@ class StateView {
5660
5748
  const ref = changeTree.ref;
5661
5749
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
5662
5750
  const refId = ref[$refId];
5663
- let changes = this.changes.get(refId);
5664
- if (changes === undefined) {
5665
- changes = {};
5666
- this.changes.set(refId, changes);
5667
- }
5668
5751
  if (tag === DEFAULT_VIEW_TAG) {
5669
5752
  // parent is collection (Map/Array)
5670
5753
  const parent = changeTree.parent;
5671
5754
  if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) {
5672
- const parentRefId = parent[$refId];
5673
- let changes = this.changes.get(parentRefId);
5674
- if (changes === undefined) {
5675
- changes = {};
5676
- this.changes.set(parentRefId, changes);
5677
- }
5678
- else if (changes[changeTree.parentIndex] === exports.OPERATION.ADD) {
5755
+ const parentChanges = this.touchChanges(parent[$refId]);
5756
+ if (parentChanges[changeTree.parentIndex] === exports.OPERATION.ADD) {
5679
5757
  //
5680
5758
  // SAME PATCH ADD + REMOVE:
5681
5759
  // The 'changes' of deleted structure should be ignored.
@@ -5683,12 +5761,13 @@ class StateView {
5683
5761
  this.changes.delete(refId);
5684
5762
  }
5685
5763
  // DELETE / DELETE BY REF ID
5686
- changes[changeTree.parentIndex] = exports.OPERATION.DELETE;
5764
+ parentChanges[changeTree.parentIndex] = exports.OPERATION.DELETE;
5687
5765
  // Remove child schema from visible set
5688
5766
  this._recursiveDeleteVisibleChangeTree(changeTree);
5689
5767
  }
5690
5768
  else {
5691
5769
  // delete all "tagged" properties.
5770
+ const changes = this.touchChanges(refId);
5692
5771
  metadata?.[$viewFieldIndexes]?.forEach((index) => {
5693
5772
  changes[index] = exports.OPERATION.DELETE;
5694
5773
  // Remove child structures of @view() fields from visible set.
@@ -5703,6 +5782,7 @@ class StateView {
5703
5782
  }
5704
5783
  else {
5705
5784
  // delete only tagged properties
5785
+ const changes = this.touchChanges(refId);
5706
5786
  metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
5707
5787
  changes[index] = exports.OPERATION.DELETE;
5708
5788
  // Remove child structures from visible set