@colyseus/schema 5.0.5 → 5.0.8

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.
Files changed (69) hide show
  1. package/build/Reflection.d.ts +53 -0
  2. package/build/Schema.d.ts +26 -1
  3. package/build/annotations.d.ts +8 -1
  4. package/build/codegen/cli.cjs +74 -64
  5. package/build/codegen/cli.cjs.map +1 -1
  6. package/build/codegen/types.d.ts +0 -1
  7. package/build/decoder/Decoder.d.ts +28 -0
  8. package/build/decoder/Resync.d.ts +61 -0
  9. package/build/encoder/ChangeTree.d.ts +15 -0
  10. package/build/encoder/Encoder.d.ts +13 -0
  11. package/build/encoder/Pool.d.ts +62 -0
  12. package/build/encoder/Root.d.ts +5 -4
  13. package/build/encoder/StateView.d.ts +12 -3
  14. package/build/index.cjs +1192 -284
  15. package/build/index.cjs.map +1 -1
  16. package/build/index.d.ts +4 -1
  17. package/build/index.js +1192 -284
  18. package/build/index.mjs +1189 -285
  19. package/build/index.mjs.map +1 -1
  20. package/build/input/InputDecoder.d.ts +9 -4
  21. package/build/input/InputEncoder.d.ts +44 -53
  22. package/build/input/index.cjs +102 -7321
  23. package/build/input/index.cjs.map +1 -1
  24. package/build/input/index.mjs +97 -7316
  25. package/build/input/index.mjs.map +1 -1
  26. package/build/types/HelperTypes.d.ts +25 -0
  27. package/build/types/builder.d.ts +57 -4
  28. package/build/types/custom/ArraySchema.d.ts +11 -1
  29. package/build/types/custom/CollectionSchema.d.ts +9 -1
  30. package/build/types/custom/MapSchema.d.ts +9 -1
  31. package/build/types/custom/SetSchema.d.ts +9 -1
  32. package/build/types/custom/StreamSchema.d.ts +2 -1
  33. package/build/types/quantize.d.ts +102 -0
  34. package/build/types/symbols.d.ts +15 -0
  35. package/package.json +8 -1
  36. package/src/Metadata.ts +34 -9
  37. package/src/Reflection.ts +49 -11
  38. package/src/Schema.ts +64 -2
  39. package/src/annotations.ts +133 -51
  40. package/src/bench_churn.ts +121 -0
  41. package/src/codegen/languages/haxe.ts +0 -16
  42. package/src/codegen/parser.ts +29 -11
  43. package/src/codegen/types.ts +45 -63
  44. package/src/decoder/DecodeOperation.ts +46 -4
  45. package/src/decoder/Decoder.ts +48 -0
  46. package/src/decoder/ReferenceTracker.ts +9 -5
  47. package/src/decoder/Resync.ts +170 -0
  48. package/src/encoder/ChangeTree.ts +59 -0
  49. package/src/encoder/Encoder.ts +53 -12
  50. package/src/encoder/Pool.ts +90 -0
  51. package/src/encoder/Root.ts +22 -32
  52. package/src/encoder/StateView.ts +101 -50
  53. package/src/encoder/changeTree/liveIteration.ts +4 -0
  54. package/src/encoder/changeTree/treeAttachment.ts +28 -24
  55. package/src/index.ts +12 -2
  56. package/src/input/InputDecoder.ts +11 -5
  57. package/src/input/InputEncoder.ts +85 -126
  58. package/src/types/HelperTypes.ts +30 -0
  59. package/src/types/TypeContext.ts +7 -0
  60. package/src/types/builder.ts +62 -5
  61. package/src/types/custom/ArraySchema.ts +70 -12
  62. package/src/types/custom/CollectionSchema.ts +36 -1
  63. package/src/types/custom/MapSchema.ts +50 -1
  64. package/src/types/custom/SetSchema.ts +36 -1
  65. package/src/types/custom/StreamSchema.ts +7 -0
  66. package/src/types/quantize.ts +173 -0
  67. package/src/types/symbols.ts +16 -0
  68. package/build/encoder/RefIdAllocator.d.ts +0 -35
  69. package/src/encoder/RefIdAllocator.ts +0 -68
@@ -23,21 +23,11 @@ export class Context {
23
23
  enums: Enum[] = [];
24
24
 
25
25
  getStructures() {
26
+ // `isSchemaClass` already walks the full ancestor chain, so a class is
27
+ // emitted iff it (or any ancestor) descends from Schema — no need to
28
+ // re-walk parents here.
26
29
  return {
27
- classes: this.classes.filter(klass => {
28
- if (this.isSchemaClass(klass)) {
29
- return true;
30
-
31
- } else {
32
- let parentClass = klass;
33
- while (parentClass = this.getParentClass(parentClass)) {
34
- if (this.isSchemaClass(parentClass)) {
35
- return true;
36
- }
37
- }
38
- }
39
- return false;
40
- }),
30
+ classes: this.classes.filter(klass => this.isSchemaClass(klass)),
41
31
  interfaces: this.interfaces,
42
32
  enums: this.enums,
43
33
  };
@@ -56,37 +46,25 @@ export class Context {
56
46
  }
57
47
  }
58
48
 
59
- private getParentClass(klass: Class) {
60
- return this.classes.find(c => c.name === klass.extends);
61
- }
62
-
63
49
  private isSchemaClass(klass: Class) {
64
- let isSchema: boolean = false;
65
-
66
- let currentClass = klass;
67
- while (!isSchema && currentClass) {
68
- //
69
- // TODO: ideally we should check for actual @colyseus/schema module
70
- // reference rather than arbitrary strings.
71
- //
72
- isSchema = (
73
- currentClass.extends === "Schema" ||
74
- currentClass.extends === "schema.Schema" ||
75
- currentClass.extends === "Schema.Schema"
50
+ // True if `klass` or any ancestor extends Schema (directly or via the
51
+ // `schema.Schema` / `Schema.Schema` aliases).
52
+ //
53
+ // TODO: ideally we should check for the actual @colyseus/schema module
54
+ // reference rather than arbitrary strings.
55
+ for (const current of [klass, ...eachAncestor(klass, this.classes)]) {
56
+ const isSchema = (
57
+ current.extends === "Schema" ||
58
+ current.extends === "schema.Schema" ||
59
+ current.extends === "Schema.Schema"
76
60
  );
77
-
78
- //
79
- // When extending from `schema.Schema`, it is required to
80
- // normalize as "Schema" for code generation.
81
- //
82
- if (currentClass === klass && isSchema) {
83
- klass.extends = "Schema";
61
+ if (isSchema) {
62
+ // Normalize a `schema.Schema`-style base on the queried class itself.
63
+ if (current === klass) { klass.extends = "Schema"; }
64
+ return true;
84
65
  }
85
-
86
- currentClass = this.getParentClass(currentClass);
87
66
  }
88
-
89
- return isSchema;
67
+ return false;
90
68
  }
91
69
  }
92
70
 
@@ -127,17 +105,11 @@ export class Class implements IStructure {
127
105
  }
128
106
 
129
107
  postProcessing() {
130
- /**
131
- * Ensure the proprierties `index` are correct using inheritance
132
- */
133
- let parentKlass: Class = this;
134
-
135
- while (
136
- parentKlass &&
137
- (parentKlass = this.context.classes.find(k => k.name === parentKlass.extends))
138
- ) {
108
+ // Offset each property's `index` by the field count of every ancestor,
109
+ // so indexes stay correct across inheritance.
110
+ for (const parent of eachAncestor(this, this.context.classes)) {
139
111
  this.properties.forEach(prop => {
140
- prop.index += parentKlass.properties.length;
112
+ prop.index += parent.properties.length;
141
113
  });
142
114
  }
143
115
  }
@@ -181,18 +153,28 @@ export interface GeneratedFile {
181
153
  body: string;
182
154
  }
183
155
 
184
- export function getInheritanceTree(klass: Class, allClasses: Class[], includeSelf: boolean = true) {
185
- let currentClass = klass;
186
- let inheritanceTree: Class[] = [];
187
-
188
- if (includeSelf) {
189
- inheritanceTree.push(currentClass);
190
- }
191
-
192
- while (currentClass.extends !== "Schema") {
193
- currentClass = allClasses.find(klass => klass.name == currentClass.extends);
194
- inheritanceTree.push(currentClass);
156
+ /**
157
+ * Walk `klass`'s `extends` chain, parent-first, yielding each ancestor class
158
+ * (not `klass` itself). The single safe ancestor traversal every inheritance
159
+ * query is built on: it stops at the Schema root, an unresolved base, or a
160
+ * cycle — so a malformed class graph can never spin forever. This is why the
161
+ * `seen` cycle-guard lives here and nowhere else.
162
+ */
163
+ function* eachAncestor(klass: Class, allClasses: Class[]): Generator<Class> {
164
+ const seen = new Set<Class>([klass]);
165
+ let current = klass;
166
+ while (current.extends && current.extends !== "Schema") {
167
+ const parent = allClasses.find(c => c.name === current.extends);
168
+ if (!parent || seen.has(parent)) { return; }
169
+ seen.add(parent);
170
+ yield parent;
171
+ current = parent;
195
172
  }
173
+ }
196
174
 
197
- return inheritanceTree;
175
+ export function getInheritanceTree(klass: Class, allClasses: Class[], includeSelf: boolean = true) {
176
+ return [
177
+ ...(includeSelf ? [klass] : []),
178
+ ...eachAncestor(klass, allClasses),
179
+ ];
198
180
  }
@@ -10,6 +10,8 @@ import type { ArraySchema } from "../types/custom/ArraySchema.js";
10
10
 
11
11
  import { getType } from "../types/registry.js";
12
12
  import { Collection } from "../types/HelperTypes.js";
13
+ import { decodeQuantized, isQuantizedType } from "../types/quantize.js";
14
+ import { resyncMarkPresent, resyncTouchEntry } from "./Resync.js";
13
15
 
14
16
  export interface DataChange<T = any, F = string> {
15
17
  ref: IRef,
@@ -124,6 +126,12 @@ export function decodeValue<T extends Ref>(
124
126
  //
125
127
  value = (decode as any)[type](bytes, it);
126
128
 
129
+ } else if (isQuantizedType(type)) {
130
+ // Quantized scalar: read the unsigned-int wire value, then dequantize so
131
+ // the instance holds (and yields) the wire-exact float — `decodeSchema-
132
+ // Operation` writes it through the snapping setter (idempotent here).
133
+ value = decodeQuantized(type.quantized, bytes, it);
134
+
127
135
  } else if (Schema.is(type)) {
128
136
  const refId = decode.number(bytes, it);
129
137
  value = $root.refs.get(refId);
@@ -148,6 +156,9 @@ export function decodeValue<T extends Ref>(
148
156
  const typeDef = getType(Object.keys(type)[0]);
149
157
  const refId = decode.number(bytes, it);
150
158
 
159
+ // resync bookkeeping — see Resync.ts
160
+ if (decoder.resyncVisited !== null) { resyncMarkPresent(decoder, refId); }
161
+
151
162
  // `initializeForDecoder` is a static on every registered collection
152
163
  // class — it does `Object.create(Class.prototype)` + the class-
153
164
  // field init + assigns an untracked `$changes` directly. Keeps
@@ -163,18 +174,27 @@ export function decodeValue<T extends Ref>(
163
174
  let previousRefId = previousValue[$refId];
164
175
 
165
176
  if (previousRefId !== undefined && refId !== previousRefId) {
177
+ // Collection field replaced by a different instance.
166
178
  //
167
- // enqueue onRemove if structure has been replaced.
168
- //
179
+ // Don't decrement children here: GC (`garbageCollectDeletedRefs`)
180
+ // removes them once the previous collection's refId hits zero.
181
+ // Doing it here too would double-decrement a *shared* child and
182
+ // drop it while still referenced ("refId not found").
183
+ if ((operation & OPERATION.DELETE) !== OPERATION.DELETE) {
184
+ // Replacement not tagged DELETE (e.g. pending ADD not upgraded
185
+ // to DELETE_AND_ADD), so the previous refId wasn't decremented
186
+ // above. Release it here, else it never gets GC'd (leak).
187
+ $root.removeRef(previousRefId);
188
+ }
189
+
190
+ // enqueue onRemove callbacks for the previous collection's children.
169
191
  const entries: IterableIterator<[any, any]> = (previousValue as any).entries();
170
192
  let iter: IteratorResult<[any, any]>;
171
193
  while ((iter = entries.next()) && !iter.done) {
172
194
  const [key, value] = iter.value;
173
195
 
174
- // if value is a schema, remove its reference
175
196
  if (typeof(value) === "object") {
176
197
  previousRefId = value[$refId];
177
- $root.removeRef(previousRefId);
178
198
  }
179
199
 
180
200
  allChanges?.push({
@@ -317,6 +337,11 @@ export const decodeKeyValueOperation: DecodeOperation = function (
317
337
  allChanges,
318
338
  );
319
339
 
340
+ // resync bookkeeping — see Resync.ts
341
+ if (decoder.resyncVisited !== null) {
342
+ resyncTouchEntry(decoder, ref, operation, dynamicIndex, previousValue, value, allChanges);
343
+ }
344
+
320
345
  if (value !== null && value !== undefined) {
321
346
  switch (kind) {
322
347
  case CollectionKind.Map:
@@ -408,6 +433,16 @@ export const decodeArray: DecodeOperation = function (
408
433
  // TODO: refactor here, try to follow same flow as below
409
434
  const refId = decode.number(bytes, it);
410
435
  const previousValue = decoder.root.refs.get(refId);
436
+ // Decrement the removed child's ref-count so it can be garbage
437
+ // collected — this refId-based branch returns early and never reaches
438
+ // decodeValue(), so it must do the same DELETE bookkeeping itself.
439
+ // Without this the refId leaks; a later encoder reuse then aliases a
440
+ // different type → "field not defined" / "definition mismatch"
441
+ // (surfaces under StateView when a filtered ArraySchema element is
442
+ // spliced while its parent moves through view membership churn).
443
+ if (previousValue !== undefined) {
444
+ decoder.root.removeRef(refId);
445
+ }
411
446
  index = tgt.findIndex((value: any) => value === previousValue);
412
447
  tgt[$deleteByIndex](index);
413
448
  allChanges?.push({
@@ -459,6 +494,13 @@ export const decodeArray: DecodeOperation = function (
459
494
  allChanges,
460
495
  );
461
496
 
497
+ // resync bookkeeping — see Resync.ts. `index` is the RESOLVED position
498
+ // (ADD_BY_REFID lands on the instance's current client-side index), so
499
+ // visited entries form a sparse set.
500
+ if (decoder.resyncVisited !== null) {
501
+ resyncTouchEntry(decoder, ref, operation, index, previousValue, value, allChanges);
502
+ }
503
+
462
504
  if (
463
505
  value !== null && value !== undefined &&
464
506
  value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
@@ -7,6 +7,7 @@ import { type IRef, type Ref } from "../encoder/ChangeTree.js";
7
7
  import type { Iterator } from "../encoding/decode.js";
8
8
  import { ReferenceTracker } from "./ReferenceTracker.js";
9
9
  import { DEFINITION_MISMATCH, type DataChange, type DecodeOperation } from "./DecodeOperation.js";
10
+ import { resyncSweep } from "./Resync.js";
10
11
  import { Collection } from "../types/HelperTypes.js";
11
12
 
12
13
  export class Decoder<T extends IRef = any> {
@@ -19,6 +20,20 @@ export class Decoder<T extends IRef = any> {
19
20
 
20
21
  triggerChanges?: (allChanges: DataChange[]) => void;
21
22
 
23
+ /**
24
+ * @internal Non-null only while a `decodeResync()` walk is in progress:
25
+ * collection refId → entry identities the payload visited (map string
26
+ * keys; array/set/collection/stream indexes). Written by the collection
27
+ * DecodeOperation functions, read by the post-decode sweep.
28
+ */
29
+ resyncVisited: Map<number, Set<number | string>> | null = null;
30
+
31
+ /**
32
+ * @internal Set when a structure had to be skipped during a resync
33
+ * decode — visited data is incomplete, so the sweep must not delete.
34
+ */
35
+ resyncDamaged: boolean = false;
36
+
22
37
  constructor(root: T, context?: TypeContext) {
23
38
  this.setState(root);
24
39
 
@@ -103,6 +118,11 @@ export class Decoder<T extends IRef = any> {
103
118
  // than being extracted into a helper for a one-line body.
104
119
  (ref as any)[$onDecodeEnd]?.()
105
120
 
121
+ // resync mode: prune everything the snapshot didn't visit. Runs
122
+ // before triggerChanges (DELETE changes fire onRemove with the real
123
+ // previousValue) and before GC (removeRef feeds deletedRefs).
124
+ if (this.resyncVisited !== null) { resyncSweep(this, allChanges); }
125
+
106
126
  // trigger changes
107
127
  if (allChanges !== null) this.triggerChanges?.(allChanges);
108
128
 
@@ -112,7 +132,35 @@ export class Decoder<T extends IRef = any> {
112
132
  return allChanges;
113
133
  }
114
134
 
135
+ /**
136
+ * Full-snapshot reconciliation ("resync") decode.
137
+ *
138
+ * Behaves exactly like {@link decode}, plus: every collection entry the
139
+ * payload does NOT mention is removed through the regular DELETE path —
140
+ * `onRemove` callbacks fire with the real previous value and released
141
+ * refs are garbage-collected. Use it to apply a rejoin/reconnect full
142
+ * state over an existing decoded tree: DELETEs that happened while the
143
+ * client was off the wire are reconciled as if they had been received,
144
+ * while surviving entries keep their instance identity and callbacks.
145
+ *
146
+ * ONLY valid for full-snapshot payloads (`encodeAll` / `encodeAllView`
147
+ * output). Calling it on an incremental patch would prune everything
148
+ * the patch doesn't touch.
149
+ */
150
+ decodeResync(bytes: Uint8Array, it: Iterator = { offset: 0 }) {
151
+ this.resyncVisited = new Map();
152
+ this.resyncDamaged = false;
153
+ try {
154
+ return this.decode(bytes, it);
155
+ } finally {
156
+ this.resyncVisited = null;
157
+ }
158
+ }
159
+
115
160
  skipCurrentStructure(bytes: Uint8Array, it: Iterator, totalBytes: number) {
161
+ // A skipped range can swallow other structures' ops (their ADDs are
162
+ // never applied), so resync visited data is no longer trustworthy.
163
+ if (this.resyncVisited !== null) { this.resyncDamaged = true; }
116
164
  //
117
165
  // keep skipping next bytes until reaches a known structure
118
166
  // by local decoder.
@@ -20,6 +20,9 @@ class DecodingWarning extends Error {
20
20
 
21
21
  export type SchemaCallbacks = { [field: string | number]: Function[] };
22
22
 
23
+ // Reused across addRef calls — saves a descriptor object per decoded ref.
24
+ const $refIdDescriptor = { value: 0, enumerable: false, writable: true };
25
+
23
26
  export class ReferenceTracker {
24
27
  //
25
28
  // Relation of refId => Schema structure
@@ -45,11 +48,12 @@ export class ReferenceTracker {
45
48
  // on decoded instances, which WOULD walk enumerable Symbol-keyed
46
49
  // properties and include `$refId` in the comparison. Keep the
47
50
  // descriptor dance for semantic compatibility.
48
- Object.defineProperty(ref, $refId, {
49
- value: refId,
50
- enumerable: false,
51
- writable: true
52
- });
51
+ if (ref[$refId] === undefined) {
52
+ $refIdDescriptor.value = refId;
53
+ Object.defineProperty(ref, $refId, $refIdDescriptor);
54
+ } else if (ref[$refId] !== refId) {
55
+ ref[$refId] = refId; // property exists (writable) — plain write keeps flags
56
+ }
53
57
 
54
58
  if (incrementCount) {
55
59
  this.refCount[refId] = (this.refCount[refId] || 0) + 1;
@@ -0,0 +1,170 @@
1
+ import { OPERATION } from "../encoding/spec.js";
2
+ import { Schema } from "../Schema.js";
3
+ import { $proxyTarget, $refId, $refTypeFieldIndexes, $resyncPrune, $transientFieldIndexes } from "../types/symbols.js";
4
+ import type { Metadata } from "../Metadata.js";
5
+ import type { Decoder } from "./Decoder.js";
6
+ import type { DataChange } from "./DecodeOperation.js";
7
+
8
+ /**
9
+ * Resync ("full-snapshot reconciliation") support for {@link Decoder.decodeResync}.
10
+ *
11
+ * A rejoin snapshot is authoritative for everything it contains — but the
12
+ * plain decode path is additive: entries deleted (or hidden by a view)
13
+ * while the client was off the wire survive as ghosts. This module owns the
14
+ * generic reconciliation algorithm:
15
+ *
16
+ * - during the decode walk, the collection DecodeOperation functions report
17
+ * every entry the payload touches ({@link resyncRecordVisit}) and every
18
+ * collection that appears at all ({@link resyncMarkPresent});
19
+ * - after the walk, {@link resyncSweep} removes whatever was never reported,
20
+ * through the same DELETE bookkeeping the regular decode path uses
21
+ * (DataChange DELETE → onRemove; removeRef → GC).
22
+ *
23
+ * Storage-specific pruning (journal upkeep, array compaction, stream
24
+ * exemption) lives on each collection class as `[$resyncPrune]` — declared
25
+ * on the `Collection` interface, so every collection kind must state its
26
+ * own sweep semantics.
27
+ *
28
+ * All entry points are guarded by `decoder.resyncVisited !== null` at the
29
+ * call sites — the normal decode path never pays for any of this.
30
+ */
31
+
32
+ /**
33
+ * Record that the current structure's entry at `identity` (map string key /
34
+ * element index) appeared in the payload — even when its value is unchanged
35
+ * (`allChanges` cannot serve as this record: its pushes are guarded by
36
+ * `previousValue !== value`, so unchanged entries would look unvisited).
37
+ *
38
+ * Also releases a replaced occupant: full-sync emits plain ADD (never
39
+ * DELETE_AND_ADD), so an entry whose instance changed while this client was
40
+ * off the wire would otherwise leak its previous ref (no onRemove, never
41
+ * GC'd). This release is correct ONLY under a full snapshot — a live patch's
42
+ * plain ADD over a different instance can be a positional rewrite (array
43
+ * shift/unshift) where the occupant *moved* and is still alive; a snapshot
44
+ * re-adds moved instances elsewhere, so the refcounts balance.
45
+ */
46
+ export function resyncTouchEntry(
47
+ decoder: Decoder,
48
+ ref: any,
49
+ operation: OPERATION,
50
+ identity: number | string,
51
+ previousValue: any,
52
+ value: any,
53
+ allChanges: DataChange[] | null,
54
+ ) {
55
+ const visited = decoder.resyncVisited!;
56
+ let set = visited.get(decoder.currentRefId);
57
+ if (set === undefined) { visited.set(decoder.currentRefId, set = new Set()); }
58
+ set.add(identity);
59
+
60
+ if (previousValue !== undefined && operation === OPERATION.ADD && previousValue !== value) {
61
+ const previousRefId = previousValue[$refId];
62
+ if (previousRefId !== undefined) {
63
+ decoder.root.removeRef(previousRefId);
64
+ allChanges?.push({
65
+ ref,
66
+ refId: decoder.currentRefId,
67
+ op: OPERATION.DELETE,
68
+ dynamicIndex: identity,
69
+ value: undefined,
70
+ previousValue,
71
+ });
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Mark a collection as present in the payload — even with zero entries.
78
+ * The sweep only prunes collections reported here: absence means "not part
79
+ * of full-sync" (@transient, view-invisible), where pruning would destroy
80
+ * live data. Reflected clients have no @transient metadata, so payload
81
+ * presence is the only reliable signal.
82
+ */
83
+ export function resyncMarkPresent(decoder: Decoder, refId: number) {
84
+ const visited = decoder.resyncVisited!;
85
+ if (!visited.has(refId)) { visited.set(refId, new Set()); }
86
+ }
87
+
88
+ /**
89
+ * Post-decode phase of {@link Decoder.decodeResync}: remove every collection
90
+ * entry the snapshot did not visit.
91
+ *
92
+ * Walks the tree from the root — NOT `root.refs` — for three reasons:
93
+ * `@transient` fields are never part of a snapshot and must be left alone;
94
+ * entries of subtrees removed by the sweep itself are left to the GC's
95
+ * transitive walk (sweeping them directly would double-decrement shared
96
+ * children); and collections the snapshot never mentions (emptied
97
+ * server-side) are still reachable and get pruned.
98
+ */
99
+ export function resyncSweep(decoder: Decoder, allChanges: DataChange[] | null) {
100
+ if (decoder.resyncDamaged) {
101
+ console.warn(
102
+ "@colyseus/schema: resync sweep skipped — parts of the payload could not be decoded. " +
103
+ "Stale entries may persist until the next resync."
104
+ );
105
+ return;
106
+ }
107
+ sweepSchema(decoder, decoder.state as unknown as Schema, new Set(), allChanges);
108
+ }
109
+
110
+ function sweepSchema(decoder: Decoder, ref: Schema, seen: Set<number>, allChanges: DataChange[] | null) {
111
+ const refId = (ref as any)[$refId];
112
+ if (refId === undefined || seen.has(refId)) { return; }
113
+ seen.add(refId);
114
+
115
+ const metadata: Metadata = (ref.constructor as typeof Schema)[Symbol.metadata];
116
+ const refIndexes = metadata?.[$refTypeFieldIndexes] as number[] | undefined;
117
+ if (refIndexes === undefined) { return; }
118
+ const transient = metadata[$transientFieldIndexes] as number[] | undefined;
119
+
120
+ for (let i = 0; i < refIndexes.length; i++) {
121
+ const fieldIndex = refIndexes[i];
122
+ // @transient fields are never in a snapshot — leave them alone.
123
+ if (transient !== undefined && transient.includes(fieldIndex)) { continue; }
124
+
125
+ const field = metadata[fieldIndex];
126
+ const value = (ref as any)[field.name];
127
+ if (!value) { continue; }
128
+
129
+ if (Schema.is(field.type)) {
130
+ sweepSchema(decoder, value, seen, allChanges);
131
+ } else {
132
+ sweepCollection(decoder, value, seen, allChanges);
133
+ }
134
+ }
135
+ }
136
+
137
+ function sweepCollection(decoder: Decoder, coll: any, seen: Set<number>, allChanges: DataChange[] | null) {
138
+ const tgt: any = coll[$proxyTarget] ?? coll;
139
+ const refId = tgt[$refId];
140
+ if (refId === undefined || seen.has(refId)) { return; }
141
+ seen.add(refId);
142
+
143
+ // `undefined` = the collection never appeared in the payload at all
144
+ // (not even as its parent's field op) — it is not part of full-sync
145
+ // (@transient, view-invisible) and must be left alone. An empty Set
146
+ // means "present with zero entries" → prune everything.
147
+ const visited = decoder.resyncVisited!.get(refId);
148
+ if (visited === undefined) { return; }
149
+
150
+ const $root = decoder.root;
151
+ tgt[$resyncPrune](
152
+ visited,
153
+ (value: any, identity: number | string) => {
154
+ allChanges?.push({
155
+ ref: coll,
156
+ refId,
157
+ op: OPERATION.DELETE,
158
+ dynamicIndex: identity,
159
+ value: undefined,
160
+ previousValue: value,
161
+ });
162
+ const childRefId = value?.[$refId];
163
+ if (childRefId !== undefined) { $root.removeRef(childRefId); }
164
+ },
165
+ (value: any) => {
166
+ // recurse so nested collections of retained entries sweep too
167
+ if (Schema.isSchema(value)) { sweepSchema(decoder, value, seen, allChanges); }
168
+ },
169
+ );
170
+ }
@@ -127,6 +127,12 @@ export const IS_UNRELIABLE = 8, IS_TRANSIENT = 16, IS_STATIC = 32;
127
127
  // `t.map(X).stream()` / `t.set(X).stream()` route through the same
128
128
  // emission machinery.
129
129
  export const IS_STREAM_COLLECTION = 64;
130
+ // Set by `recycle()` (Schema.reset / pooling): the tree's values are live
131
+ // but its dirty buckets were cleared, so `Root.add` must re-stage every
132
+ // populated field as ADD when the instance re-enters a tree. Without this,
133
+ // only fields assigned after `pool.acquire()` would reach the wire — the
134
+ // retained ones (constructor-initialized children) would never be encoded.
135
+ export const NEEDS_RESTAGE = 128;
130
136
  /**
131
137
  * Flags a child inherits from its parent's own transitive state via
132
138
  * `checkInheritedFlags`. Read as a bitwise mask so the inheritance step
@@ -258,6 +264,8 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
258
264
  set isStatic(v: boolean) { this.flags = v ? (this.flags | IS_STATIC) : (this.flags & ~IS_STATIC); }
259
265
  get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
260
266
  set isStreamCollection(v: boolean) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
267
+ get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
268
+ set needsRestage(v: boolean) { this.flags = v ? (this.flags | NEEDS_RESTAGE) : (this.flags & ~NEEDS_RESTAGE); }
261
269
 
262
270
  // True iff tree inherits `isFiltered` OR its Schema class declares any
263
271
  // @view-tagged fields. StateView.addParentOf uses this to decide whether
@@ -532,6 +540,57 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
532
540
  if (this.collPureOps !== undefined) this.collPureOps.length = 0;
533
541
  }
534
542
 
543
+ /**
544
+ * Full reset to construction defaults so the owning ref can be returned to
545
+ * a pool and reused for a different logical entity (see encoder/Pool.ts +
546
+ * Schema.reset). Unlike `reset()` / `endEncode()` (which only clear the
547
+ * dirty bucket for the next encode), this also drops parent links, queue
548
+ * nodes and per-view bitmaps, and re-arms IS_NEW.
549
+ *
550
+ * Precondition: the tree must already be detached from the encoder
551
+ * (`root === undefined`) — i.e. the ref was removed from its parent
552
+ * collection/field, which `Root.remove` does before this runs.
553
+ */
554
+ recycle(): void {
555
+ if (this.root !== undefined) {
556
+ throw new Error(
557
+ `@colyseus/schema: cannot recycle an attached ChangeTree ` +
558
+ `(${this.ref?.constructor?.name}). Remove the instance from its ` +
559
+ `parent collection before releasing it to a pool.`
560
+ );
561
+ }
562
+
563
+ // dirty/ops buckets (Schema: dirtyLow/High + ops; Collection: collDirty/collPureOps)
564
+ this.reset();
565
+ // keep the recorder object allocated (re-alloc is the cost we avoid), clear contents
566
+ this.unreliableRecorder?.reset();
567
+
568
+ // back to a freshly-constructed tree: IS_NEW, no inherited flags
569
+ // (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
570
+ // NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
571
+ this.flags = IS_NEW | NEEDS_RESTAGE;
572
+ this._fullSyncGen = 0;
573
+
574
+ // drop parent links — Root.remove clears `root` and the CHILDREN's
575
+ // parent links, but leaves this tree's own parentRef dangling.
576
+ this.parentRef = undefined;
577
+ this._parentIndex = undefined;
578
+ this.extraParents = undefined;
579
+
580
+ // queue nodes (already nulled by Root.remove's queue removal; defensive)
581
+ this.changesNode = undefined;
582
+ this.unreliableChangesNode = undefined;
583
+
584
+ this.paused = false;
585
+
586
+ // per-view visibility lives on the tree (NOT keyed by refId), so a
587
+ // recycled tree must not inherit its previous life's view membership.
588
+ this.visibleViews = undefined;
589
+ this.invisibleViews = undefined;
590
+ this.tagViews = undefined;
591
+ this.subscribedViews = undefined;
592
+ }
593
+
535
594
  shift(shiftIndex: number): void {
536
595
  if (this._isSchema) throw new Error("ChangeTree (Schema): shift is not supported");
537
596
  const src = this.collDirty!;