@colyseus/schema 5.0.6 → 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 +3 -0
  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 +3 -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 +11 -1
  56. package/src/input/InputDecoder.ts +11 -5
  57. package/src/input/InputEncoder.ts +85 -126
  58. package/src/types/HelperTypes.ts +7 -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
package/build/index.js CHANGED
@@ -63,6 +63,13 @@
63
63
  const $filter = "~filter";
64
64
  const $getByIndex = "~getByIndex";
65
65
  const $deleteByIndex = "~deleteByIndex";
66
+ /**
67
+ * Resync-sweep hook (see decoder/Resync.ts): prune every entry the rejoin
68
+ * snapshot did not visit. Each collection owns its storage-specific
69
+ * bookkeeping (journal pruning, compaction, item indexes); the generic
70
+ * DELETE/ref/callback bookkeeping arrives via the `prune`/`keep` callbacks.
71
+ */
72
+ const $resyncPrune = "~resyncPrune";
66
73
  /**
67
74
  * Used to hold ChangeTree instances whitin the structures.
68
75
  *
@@ -87,6 +94,14 @@
87
94
  * (Discards changes for next serialization)
88
95
  */
89
96
  const $onEncodeEnd = '~onEncodeEnd';
97
+ /**
98
+ * Optional "reset" method on every poolable Ref (Schema + collections).
99
+ * Empties the instance's backing store and recycles its ChangeTree WITHOUT
100
+ * emitting any wire op, and recurses into ref-type children — so the instance
101
+ * can be returned to a SchemaPool and reused, avoiding the cost of `new`.
102
+ * See encoder/Pool.ts and Schema.reset().
103
+ */
104
+ const $reset = "~reset";
90
105
  /**
91
106
  * When decoding, this method is called after the instance is fully decoded
92
107
  */
@@ -812,6 +827,92 @@
812
827
  st.sentByView.delete(viewId);
813
828
  }
814
829
 
830
+ const WIRE_BY_BITS = {
831
+ 8: "uint8",
832
+ 16: "uint16",
833
+ 32: "uint32",
834
+ };
835
+ /** Validate options and precompute the wire codec + scale span. */
836
+ function resolveQuantize(opts) {
837
+ if (opts == null || typeof opts !== "object") {
838
+ throw new Error("t.quantized(): options object with { min, max } is required.");
839
+ }
840
+ const { min, max } = opts;
841
+ if (typeof min !== "number" || typeof max !== "number" || !(max > min)
842
+ || !Number.isFinite(min) || !Number.isFinite(max)) {
843
+ throw new Error(`t.quantized(): require finite min < max (got min=${min}, max=${max}).`);
844
+ }
845
+ const bits = opts.bits ?? 16;
846
+ if (bits !== 8 && bits !== 16 && bits !== 32) {
847
+ throw new Error(`t.quantized(): bits must be 8, 16 or 32 (got ${bits}).`);
848
+ }
849
+ const mode = opts.mode ?? "clamp";
850
+ if (mode !== "clamp" && mode !== "wrap") {
851
+ throw new Error(`t.quantized(): mode must be "clamp" or "wrap" (got ${JSON.stringify(mode)}).`);
852
+ }
853
+ const wrap = mode === "wrap"; // descriptor + wire stay boolean; `mode` is the public knob
854
+ const steps = Math.pow(2, bits);
855
+ return {
856
+ min,
857
+ max,
858
+ bits,
859
+ wrap,
860
+ wire: WIRE_BY_BITS[bits],
861
+ range: max - min,
862
+ // wrapping spreads `2^bits` steps across [min,max) (top ≡ bottom); clamped
863
+ // maps the endpoints onto `0` and `2^bits − 1` inclusive.
864
+ span: wrap ? steps : steps - 1,
865
+ };
866
+ }
867
+ /** Type guard for the `{ quantized: QuantizeDescriptor }` field-type shape. */
868
+ function isQuantizedType(type) {
869
+ return type !== null && typeof type === "object" && type.quantized !== undefined;
870
+ }
871
+ /**
872
+ * Float → unsigned integer. Rounding is explicit half-up `floor(x + 0.5)`;
873
+ * wrapping ranges are reduced in the float domain first (no huge→int cast).
874
+ */
875
+ function quantize(desc, value) {
876
+ if (desc.wrap) {
877
+ // Non-finite can't be range-reduced (Inf % range = NaN); NaN would flow to
878
+ // the wire as garbage while the local instance kept NaN — a silent peer
879
+ // divergence. Pin to q=0 (= min): garbage in, deterministic out, both agree.
880
+ if (!Number.isFinite(value))
881
+ return 0;
882
+ const range = desc.range;
883
+ // float-domain range reduction → [0, range)
884
+ let a = (value - desc.min) % range;
885
+ if (a < 0)
886
+ a += range;
887
+ const steps = desc.span; // 2^bits
888
+ // `% steps` (not `& mask`) so bits=32 doesn't overflow JS's int32 bitwise.
889
+ return Math.floor((a / range) * steps + 0.5) % steps;
890
+ }
891
+ if (value !== value)
892
+ return 0; // NaN → min (±Inf clamps naturally below)
893
+ const v = value < desc.min ? desc.min : value > desc.max ? desc.max : value;
894
+ return Math.floor(((v - desc.min) / desc.range) * desc.span + 0.5);
895
+ }
896
+ /** Unsigned integer → float. Correctly-rounded mul/div ⇒ bit-identical across languages. */
897
+ function dequantize(desc, q) {
898
+ return desc.min + (q / desc.span) * desc.range;
899
+ }
900
+ /**
901
+ * Pre-baked encoder for a quantized field: quantize the (snapped) float held on
902
+ * the instance, then write it with the field's unsigned-int wire codec. Stored in
903
+ * `metadata[$encoders]` so the encode hot path reaches it via the fast lane,
904
+ * exactly like the pre-computed primitive encoders.
905
+ */
906
+ function makeQuantizedEncoder(desc) {
907
+ const writeWire = encode[desc.wire];
908
+ return (bytes, value, it) => writeWire(bytes, quantize(desc, value), it);
909
+ }
910
+ /** Decode a quantized field: read the unsigned-int wire value, then dequantize. */
911
+ function decodeQuantized(desc, bytes, it) {
912
+ const readWire = decode[desc.wire];
913
+ return dequantize(desc, readWire(bytes, it));
914
+ }
915
+
815
916
  class TypeContext {
816
917
  types = {};
817
918
  schemas = new Map();
@@ -914,6 +1015,11 @@
914
1015
  if (typeof (fieldType) === "string") {
915
1016
  continue;
916
1017
  }
1018
+ // Quantized fields are scalar — their object `type` only carries the
1019
+ // descriptor, there's no child Schema to discover.
1020
+ if (isQuantizedType(fieldType)) {
1021
+ continue;
1022
+ }
917
1023
  if (typeof (fieldType) === "function") {
918
1024
  this.discoverTypes(fieldType, klass, index, parentHasViewTag || fieldHasViewTag);
919
1025
  }
@@ -982,6 +1088,14 @@
982
1088
  if (Array.isArray(type)) {
983
1089
  return { array: getNormalizedType(type[0]) };
984
1090
  }
1091
+ else if (isQuantizedType(type)) {
1092
+ // `{ quantized: ... }` — a scalar wire type, NOT a collection/ref. Resolve
1093
+ // raw options (the `@type({quantized:{min,max}})` path) once; an already-
1094
+ // resolved descriptor (the `t.quantized()` builder path) passes through.
1095
+ return (typeof type.quantized.wire === "string")
1096
+ ? type
1097
+ : { quantized: resolveQuantize(type.quantized) };
1098
+ }
985
1099
  else if (typeof (type['type']) !== "undefined") {
986
1100
  return type['type'];
987
1101
  }
@@ -1060,8 +1174,9 @@
1060
1174
  enumerable: false,
1061
1175
  configurable: true,
1062
1176
  });
1063
- // if child Ref/complex type, add to -4
1064
- if (typeof (metadata[index].type) !== "string") {
1177
+ // if child Ref/complex type, add to -4. Quantized fields are scalar (their
1178
+ // `type` is an object only to carry the descriptor) — not refs, so skip them.
1179
+ if (typeof (metadata[index].type) !== "string" && !isQuantizedType(metadata[index].type)) {
1065
1180
  if (metadata[$refTypeFieldIndexes] === undefined) {
1066
1181
  Object.defineProperty(metadata, $refTypeFieldIndexes, {
1067
1182
  value: [],
@@ -1123,10 +1238,25 @@
1123
1238
  });
1124
1239
  }
1125
1240
  metadata[$viewFieldIndexes].push(index);
1126
- if (!metadata[$fieldIndexesByViewTag][tag]) {
1127
- metadata[$fieldIndexesByViewTag][tag] = [];
1241
+ // Populate $fieldIndexesByViewTag: for a bitmask tag, register the field
1242
+ // index under each individual set bit so that view.add(obj, Tag.ONE) finds
1243
+ // fields tagged @view(Tag.ONE|Tag.TWO).
1244
+ // Negative tags (i.e. DEFAULT_VIEW_TAG = -1) are stored as-is.
1245
+ if (tag < 0) {
1246
+ if (!metadata[$fieldIndexesByViewTag][tag]) {
1247
+ metadata[$fieldIndexesByViewTag][tag] = [];
1248
+ }
1249
+ metadata[$fieldIndexesByViewTag][tag].push(index);
1250
+ }
1251
+ else {
1252
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
1253
+ const bit = bits & (-bits); // isolate lowest set bit
1254
+ if (!metadata[$fieldIndexesByViewTag][bit]) {
1255
+ metadata[$fieldIndexesByViewTag][bit] = [];
1256
+ }
1257
+ metadata[$fieldIndexesByViewTag][bit].push(index);
1258
+ }
1128
1259
  }
1129
- metadata[$fieldIndexesByViewTag][tag].push(index);
1130
1260
  },
1131
1261
  setUnreliable(metadata, fieldName) {
1132
1262
  const index = metadata[fieldName];
@@ -1232,8 +1362,8 @@
1232
1362
  if (metadata[$descriptors][fieldName]) {
1233
1363
  Object.defineProperty(target.prototype, fieldName, metadata[$descriptors][fieldName]);
1234
1364
  }
1235
- // Pre-compute encoder function for primitive types.
1236
- if (typeof normalized === "string") {
1365
+ // Pre-compute encoder function for primitive + quantized types.
1366
+ if (typeof normalized === "string" || isQuantizedType(normalized)) {
1237
1367
  if (!metadata[$encoders]) {
1238
1368
  Object.defineProperty(metadata, $encoders, {
1239
1369
  value: [],
@@ -1242,7 +1372,9 @@
1242
1372
  writable: true,
1243
1373
  });
1244
1374
  }
1245
- metadata[$encoders][fieldIndex] = encode[normalized];
1375
+ metadata[$encoders][fieldIndex] = (typeof normalized === "string")
1376
+ ? encode[normalized]
1377
+ : makeQuantizedEncoder(normalized.quantized);
1246
1378
  }
1247
1379
  },
1248
1380
  setFields(target, fields) {
@@ -1888,6 +2020,8 @@
1888
2020
  const ref = tree.refTarget;
1889
2021
  if (ref[$childType] !== undefined) {
1890
2022
  // Collection inheriting @transient from parent field: skip entirely.
2023
+ // The resync sweep (decoder/Resync.ts) relies on this: a collection
2024
+ // absent from full-sync output is never pruned client-side.
1891
2025
  if (tree.isTransient)
1892
2026
  return;
1893
2027
  // Collection types: dispatch by shape.
@@ -1917,6 +2051,8 @@
1917
2051
  // Schema: walk declared fields. `null` is treated as absent —
1918
2052
  // the setter records a DELETE when a field is set to null or
1919
2053
  // undefined, so it should not appear in full-sync output.
2054
+ // (@transient skips below matter to the resync sweep — see
2055
+ // decoder/Resync.ts: absent-from-payload means never pruned.)
1920
2056
  //
1921
2057
  // Read names from the per-class descriptor's parallel array —
1922
2058
  // saves the `metadata[i]` (per-field obj) + `.name` chain on
@@ -2180,6 +2316,12 @@
2180
2316
  }
2181
2317
  }
2182
2318
 
2319
+ /**
2320
+ * Tree-attachment helpers: setRoot / setParent + child-iteration recursion.
2321
+ * Hot path: every new Schema/Collection instance attached to the root
2322
+ * goes through here, which is why the recursive walk uses a hoisted
2323
+ * callback + ctx-pool instead of per-call closures.
2324
+ */
2183
2325
  function setRoot(tree, root) {
2184
2326
  tree.root = root;
2185
2327
  const isNewChangeTree = root.add(tree);
@@ -2232,30 +2374,10 @@
2232
2374
  }
2233
2375
  }
2234
2376
  function forEachChild(tree, callback) {
2235
- //
2236
- // assign same parent on child structures
2237
- //
2238
- if (tree.ref[$childType]) {
2239
- if (typeof (tree.ref[$childType]) !== "string") {
2240
- // MapSchema / ArraySchema, etc.
2241
- for (const [key, value] of tree.ref.entries()) {
2242
- if (!value) {
2243
- continue;
2244
- } // sparse arrays can have undefined values
2245
- callback(value[$changes], tree.ref._collectionIndexes?.[key] ?? key);
2246
- }
2247
- }
2248
- }
2249
- else {
2250
- const names = tree.encDescriptor.names;
2251
- for (const index of tree.metadata?.[$refTypeFieldIndexes] ?? []) {
2252
- const value = tree.ref[names[index]];
2253
- if (!value) {
2254
- continue;
2255
- }
2256
- callback(value[$changes], index);
2257
- }
2258
- }
2377
+ forEachChildWithCtx(tree, callback, _forEachChildTrampoline);
2378
+ }
2379
+ function _forEachChildTrampoline(cb, change, at) {
2380
+ cb(change, at);
2259
2381
  }
2260
2382
  /**
2261
2383
  * Closure-free variant of {@link forEachChild}. Hot setRoot / setParent
@@ -2274,12 +2396,35 @@
2274
2396
  const ref = tree.refTarget;
2275
2397
  if (ref[$childType]) {
2276
2398
  if (typeof ref[$childType] !== "string") {
2277
- const collectionIndexes = ref._collectionIndexes;
2278
- for (const [key, value] of ref.entries()) {
2279
- if (!value) {
2280
- continue;
2399
+ const items = ref.items;
2400
+ if (items !== undefined) {
2401
+ // ArraySchema (raw target): dense index loop — the previous
2402
+ // `for..of entries()` allocated an iterator + a [key, value]
2403
+ // pair array per child (top-10 allocation site in the
2404
+ // stateview and deep-nested heap profiles).
2405
+ for (let i = 0, len = items.length; i < len; i++) {
2406
+ const value = items[i];
2407
+ if (!value) {
2408
+ continue;
2409
+ } // sparse arrays can have undefined values
2410
+ callback(ctx, value[$changes], i);
2411
+ }
2412
+ }
2413
+ else {
2414
+ // Map-backed collections (MapSchema/SetSchema/CollectionSchema/
2415
+ // StreamSchema all store `$items: Map`): keys() loop skips the
2416
+ // per-child [key, value] pair arrays of entries(), with no
2417
+ // closure either (a forEach closure showed up as a GC
2418
+ // regression on the construct bench).
2419
+ const $items = ref.$items;
2420
+ const collectionIndexes = ref._collectionIndexes;
2421
+ for (const key of $items.keys()) {
2422
+ const value = $items.get(key);
2423
+ if (!value) {
2424
+ continue;
2425
+ }
2426
+ callback(ctx, value[$changes], collectionIndexes?.[key] ?? key);
2281
2427
  }
2282
- callback(ctx, value[$changes], collectionIndexes?.[key] ?? key);
2283
2428
  }
2284
2429
  }
2285
2430
  }
@@ -2364,6 +2509,12 @@
2364
2509
  // `t.map(X).stream()` / `t.set(X).stream()` route through the same
2365
2510
  // emission machinery.
2366
2511
  const IS_STREAM_COLLECTION = 64;
2512
+ // Set by `recycle()` (Schema.reset / pooling): the tree's values are live
2513
+ // but its dirty buckets were cleared, so `Root.add` must re-stage every
2514
+ // populated field as ADD when the instance re-enters a tree. Without this,
2515
+ // only fields assigned after `pool.acquire()` would reach the wire — the
2516
+ // retained ones (constructor-initialized children) would never be encoded.
2517
+ const NEEDS_RESTAGE = 128;
2367
2518
  /**
2368
2519
  * Flags a child inherits from its parent's own transitive state via
2369
2520
  * `checkInheritedFlags`. Read as a bitwise mask so the inheritance step
@@ -2476,6 +2627,8 @@
2476
2627
  set isStatic(v) { this.flags = v ? (this.flags | IS_STATIC) : (this.flags & ~IS_STATIC); }
2477
2628
  get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
2478
2629
  set isStreamCollection(v) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
2630
+ get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
2631
+ set needsRestage(v) { this.flags = v ? (this.flags | NEEDS_RESTAGE) : (this.flags & ~NEEDS_RESTAGE); }
2479
2632
  // True iff tree inherits `isFiltered` OR its Schema class declares any
2480
2633
  // @view-tagged fields. StateView.addParentOf uses this to decide whether
2481
2634
  // a parent must be included in a view's bootstrap. Reads the class-level
@@ -2759,6 +2912,48 @@
2759
2912
  if (this.collPureOps !== undefined)
2760
2913
  this.collPureOps.length = 0;
2761
2914
  }
2915
+ /**
2916
+ * Full reset to construction defaults so the owning ref can be returned to
2917
+ * a pool and reused for a different logical entity (see encoder/Pool.ts +
2918
+ * Schema.reset). Unlike `reset()` / `endEncode()` (which only clear the
2919
+ * dirty bucket for the next encode), this also drops parent links, queue
2920
+ * nodes and per-view bitmaps, and re-arms IS_NEW.
2921
+ *
2922
+ * Precondition: the tree must already be detached from the encoder
2923
+ * (`root === undefined`) — i.e. the ref was removed from its parent
2924
+ * collection/field, which `Root.remove` does before this runs.
2925
+ */
2926
+ recycle() {
2927
+ if (this.root !== undefined) {
2928
+ throw new Error(`@colyseus/schema: cannot recycle an attached ChangeTree ` +
2929
+ `(${this.ref?.constructor?.name}). Remove the instance from its ` +
2930
+ `parent collection before releasing it to a pool.`);
2931
+ }
2932
+ // dirty/ops buckets (Schema: dirtyLow/High + ops; Collection: collDirty/collPureOps)
2933
+ this.reset();
2934
+ // keep the recorder object allocated (re-alloc is the cost we avoid), clear contents
2935
+ this.unreliableRecorder?.reset();
2936
+ // back to a freshly-constructed tree: IS_NEW, no inherited flags
2937
+ // (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
2938
+ // NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
2939
+ this.flags = IS_NEW | NEEDS_RESTAGE;
2940
+ this._fullSyncGen = 0;
2941
+ // drop parent links — Root.remove clears `root` and the CHILDREN's
2942
+ // parent links, but leaves this tree's own parentRef dangling.
2943
+ this.parentRef = undefined;
2944
+ this._parentIndex = undefined;
2945
+ this.extraParents = undefined;
2946
+ // queue nodes (already nulled by Root.remove's queue removal; defensive)
2947
+ this.changesNode = undefined;
2948
+ this.unreliableChangesNode = undefined;
2949
+ this.paused = false;
2950
+ // per-view visibility lives on the tree (NOT keyed by refId), so a
2951
+ // recycled tree must not inherit its previous life's view membership.
2952
+ this.visibleViews = undefined;
2953
+ this.invisibleViews = undefined;
2954
+ this.tagViews = undefined;
2955
+ this.subscribedViews = undefined;
2956
+ }
2762
2957
  shift(shiftIndex) {
2763
2958
  if (this._isSchema)
2764
2959
  throw new Error("ChangeTree (Schema): shift is not supported");
@@ -3214,6 +3409,165 @@
3214
3409
  encodeValue(encoder, bytes, type, value, operation, it);
3215
3410
  };
3216
3411
 
3412
+ /**
3413
+ * Resync ("full-snapshot reconciliation") support for {@link Decoder.decodeResync}.
3414
+ *
3415
+ * A rejoin snapshot is authoritative for everything it contains — but the
3416
+ * plain decode path is additive: entries deleted (or hidden by a view)
3417
+ * while the client was off the wire survive as ghosts. This module owns the
3418
+ * generic reconciliation algorithm:
3419
+ *
3420
+ * - during the decode walk, the collection DecodeOperation functions report
3421
+ * every entry the payload touches ({@link resyncRecordVisit}) and every
3422
+ * collection that appears at all ({@link resyncMarkPresent});
3423
+ * - after the walk, {@link resyncSweep} removes whatever was never reported,
3424
+ * through the same DELETE bookkeeping the regular decode path uses
3425
+ * (DataChange DELETE → onRemove; removeRef → GC).
3426
+ *
3427
+ * Storage-specific pruning (journal upkeep, array compaction, stream
3428
+ * exemption) lives on each collection class as `[$resyncPrune]` — declared
3429
+ * on the `Collection` interface, so every collection kind must state its
3430
+ * own sweep semantics.
3431
+ *
3432
+ * All entry points are guarded by `decoder.resyncVisited !== null` at the
3433
+ * call sites — the normal decode path never pays for any of this.
3434
+ */
3435
+ /**
3436
+ * Record that the current structure's entry at `identity` (map string key /
3437
+ * element index) appeared in the payload — even when its value is unchanged
3438
+ * (`allChanges` cannot serve as this record: its pushes are guarded by
3439
+ * `previousValue !== value`, so unchanged entries would look unvisited).
3440
+ *
3441
+ * Also releases a replaced occupant: full-sync emits plain ADD (never
3442
+ * DELETE_AND_ADD), so an entry whose instance changed while this client was
3443
+ * off the wire would otherwise leak its previous ref (no onRemove, never
3444
+ * GC'd). This release is correct ONLY under a full snapshot — a live patch's
3445
+ * plain ADD over a different instance can be a positional rewrite (array
3446
+ * shift/unshift) where the occupant *moved* and is still alive; a snapshot
3447
+ * re-adds moved instances elsewhere, so the refcounts balance.
3448
+ */
3449
+ function resyncTouchEntry(decoder, ref, operation, identity, previousValue, value, allChanges) {
3450
+ const visited = decoder.resyncVisited;
3451
+ let set = visited.get(decoder.currentRefId);
3452
+ if (set === undefined) {
3453
+ visited.set(decoder.currentRefId, set = new Set());
3454
+ }
3455
+ set.add(identity);
3456
+ if (previousValue !== undefined && operation === exports.OPERATION.ADD && previousValue !== value) {
3457
+ const previousRefId = previousValue[$refId];
3458
+ if (previousRefId !== undefined) {
3459
+ decoder.root.removeRef(previousRefId);
3460
+ allChanges?.push({
3461
+ ref,
3462
+ refId: decoder.currentRefId,
3463
+ op: exports.OPERATION.DELETE,
3464
+ dynamicIndex: identity,
3465
+ value: undefined,
3466
+ previousValue,
3467
+ });
3468
+ }
3469
+ }
3470
+ }
3471
+ /**
3472
+ * Mark a collection as present in the payload — even with zero entries.
3473
+ * The sweep only prunes collections reported here: absence means "not part
3474
+ * of full-sync" (@transient, view-invisible), where pruning would destroy
3475
+ * live data. Reflected clients have no @transient metadata, so payload
3476
+ * presence is the only reliable signal.
3477
+ */
3478
+ function resyncMarkPresent(decoder, refId) {
3479
+ const visited = decoder.resyncVisited;
3480
+ if (!visited.has(refId)) {
3481
+ visited.set(refId, new Set());
3482
+ }
3483
+ }
3484
+ /**
3485
+ * Post-decode phase of {@link Decoder.decodeResync}: remove every collection
3486
+ * entry the snapshot did not visit.
3487
+ *
3488
+ * Walks the tree from the root — NOT `root.refs` — for three reasons:
3489
+ * `@transient` fields are never part of a snapshot and must be left alone;
3490
+ * entries of subtrees removed by the sweep itself are left to the GC's
3491
+ * transitive walk (sweeping them directly would double-decrement shared
3492
+ * children); and collections the snapshot never mentions (emptied
3493
+ * server-side) are still reachable and get pruned.
3494
+ */
3495
+ function resyncSweep(decoder, allChanges) {
3496
+ if (decoder.resyncDamaged) {
3497
+ console.warn("@colyseus/schema: resync sweep skipped — parts of the payload could not be decoded. " +
3498
+ "Stale entries may persist until the next resync.");
3499
+ return;
3500
+ }
3501
+ sweepSchema(decoder, decoder.state, new Set(), allChanges);
3502
+ }
3503
+ function sweepSchema(decoder, ref, seen, allChanges) {
3504
+ const refId = ref[$refId];
3505
+ if (refId === undefined || seen.has(refId)) {
3506
+ return;
3507
+ }
3508
+ seen.add(refId);
3509
+ const metadata = ref.constructor[Symbol.metadata];
3510
+ const refIndexes = metadata?.[$refTypeFieldIndexes];
3511
+ if (refIndexes === undefined) {
3512
+ return;
3513
+ }
3514
+ const transient = metadata[$transientFieldIndexes];
3515
+ for (let i = 0; i < refIndexes.length; i++) {
3516
+ const fieldIndex = refIndexes[i];
3517
+ // @transient fields are never in a snapshot — leave them alone.
3518
+ if (transient !== undefined && transient.includes(fieldIndex)) {
3519
+ continue;
3520
+ }
3521
+ const field = metadata[fieldIndex];
3522
+ const value = ref[field.name];
3523
+ if (!value) {
3524
+ continue;
3525
+ }
3526
+ if (Schema.is(field.type)) {
3527
+ sweepSchema(decoder, value, seen, allChanges);
3528
+ }
3529
+ else {
3530
+ sweepCollection(decoder, value, seen, allChanges);
3531
+ }
3532
+ }
3533
+ }
3534
+ function sweepCollection(decoder, coll, seen, allChanges) {
3535
+ const tgt = coll[$proxyTarget] ?? coll;
3536
+ const refId = tgt[$refId];
3537
+ if (refId === undefined || seen.has(refId)) {
3538
+ return;
3539
+ }
3540
+ seen.add(refId);
3541
+ // `undefined` = the collection never appeared in the payload at all
3542
+ // (not even as its parent's field op) — it is not part of full-sync
3543
+ // (@transient, view-invisible) and must be left alone. An empty Set
3544
+ // means "present with zero entries" → prune everything.
3545
+ const visited = decoder.resyncVisited.get(refId);
3546
+ if (visited === undefined) {
3547
+ return;
3548
+ }
3549
+ const $root = decoder.root;
3550
+ tgt[$resyncPrune](visited, (value, identity) => {
3551
+ allChanges?.push({
3552
+ ref: coll,
3553
+ refId,
3554
+ op: exports.OPERATION.DELETE,
3555
+ dynamicIndex: identity,
3556
+ value: undefined,
3557
+ previousValue: value,
3558
+ });
3559
+ const childRefId = value?.[$refId];
3560
+ if (childRefId !== undefined) {
3561
+ $root.removeRef(childRefId);
3562
+ }
3563
+ }, (value) => {
3564
+ // recurse so nested collections of retained entries sweep too
3565
+ if (Schema.isSchema(value)) {
3566
+ sweepSchema(decoder, value, seen, allChanges);
3567
+ }
3568
+ });
3569
+ }
3570
+
3217
3571
  const DEFINITION_MISMATCH = -1;
3218
3572
  /**
3219
3573
  * Collection-kind discriminator declared on each collection class as
@@ -3271,6 +3625,12 @@
3271
3625
  //
3272
3626
  value = decode[type](bytes, it);
3273
3627
  }
3628
+ else if (isQuantizedType(type)) {
3629
+ // Quantized scalar: read the unsigned-int wire value, then dequantize so
3630
+ // the instance holds (and yields) the wire-exact float — `decodeSchema-
3631
+ // Operation` writes it through the snapping setter (idempotent here).
3632
+ value = decodeQuantized(type.quantized, bytes, it);
3633
+ }
3274
3634
  else if (Schema.is(type)) {
3275
3635
  const refId = decode.number(bytes, it);
3276
3636
  value = $root.refs.get(refId);
@@ -3287,6 +3647,10 @@
3287
3647
  else {
3288
3648
  const typeDef = getType(Object.keys(type)[0]);
3289
3649
  const refId = decode.number(bytes, it);
3650
+ // resync bookkeeping — see Resync.ts
3651
+ if (decoder.resyncVisited !== null) {
3652
+ resyncMarkPresent(decoder, refId);
3653
+ }
3290
3654
  // `initializeForDecoder` is a static on every registered collection
3291
3655
  // class — it does `Object.create(Class.prototype)` + the class-
3292
3656
  // field init + assigns an untracked `$changes` directly. Keeps
@@ -3299,17 +3663,25 @@
3299
3663
  if (previousValue) {
3300
3664
  let previousRefId = previousValue[$refId];
3301
3665
  if (previousRefId !== undefined && refId !== previousRefId) {
3666
+ // Collection field replaced by a different instance.
3302
3667
  //
3303
- // enqueue onRemove if structure has been replaced.
3304
- //
3668
+ // Don't decrement children here: GC (`garbageCollectDeletedRefs`)
3669
+ // removes them once the previous collection's refId hits zero.
3670
+ // Doing it here too would double-decrement a *shared* child and
3671
+ // drop it while still referenced ("refId not found").
3672
+ if ((operation & exports.OPERATION.DELETE) !== exports.OPERATION.DELETE) {
3673
+ // Replacement not tagged DELETE (e.g. pending ADD not upgraded
3674
+ // to DELETE_AND_ADD), so the previous refId wasn't decremented
3675
+ // above. Release it here, else it never gets GC'd (leak).
3676
+ $root.removeRef(previousRefId);
3677
+ }
3678
+ // enqueue onRemove callbacks for the previous collection's children.
3305
3679
  const entries = previousValue.entries();
3306
3680
  let iter;
3307
3681
  while ((iter = entries.next()) && !iter.done) {
3308
3682
  const [key, value] = iter.value;
3309
- // if value is a schema, remove its reference
3310
3683
  if (typeof (value) === "object") {
3311
3684
  previousRefId = value[$refId];
3312
- $root.removeRef(previousRefId);
3313
3685
  }
3314
3686
  allChanges?.push({
3315
3687
  ref: previousValue,
@@ -3401,6 +3773,10 @@
3401
3773
  }
3402
3774
  const previousValue = tgt[$getByIndex](index);
3403
3775
  const value = decodeValue(decoder, operation, ref, index, previousValue, type, bytes, it, allChanges);
3776
+ // resync bookkeeping — see Resync.ts
3777
+ if (decoder.resyncVisited !== null) {
3778
+ resyncTouchEntry(decoder, ref, operation, dynamicIndex, previousValue, value, allChanges);
3779
+ }
3404
3780
  if (value !== null && value !== undefined) {
3405
3781
  switch (kind) {
3406
3782
  case CollectionKind.Map:
@@ -3477,6 +3853,16 @@
3477
3853
  // TODO: refactor here, try to follow same flow as below
3478
3854
  const refId = decode.number(bytes, it);
3479
3855
  const previousValue = decoder.root.refs.get(refId);
3856
+ // Decrement the removed child's ref-count so it can be garbage
3857
+ // collected — this refId-based branch returns early and never reaches
3858
+ // decodeValue(), so it must do the same DELETE bookkeeping itself.
3859
+ // Without this the refId leaks; a later encoder reuse then aliases a
3860
+ // different type → "field not defined" / "definition mismatch"
3861
+ // (surfaces under StateView when a filtered ArraySchema element is
3862
+ // spliced while its parent moves through view membership churn).
3863
+ if (previousValue !== undefined) {
3864
+ decoder.root.removeRef(refId);
3865
+ }
3480
3866
  index = tgt.findIndex((value) => value === previousValue);
3481
3867
  tgt[$deleteByIndex](index);
3482
3868
  allChanges?.push({
@@ -3511,6 +3897,12 @@
3511
3897
  // maintain). The decoder's authoritative state is `items`.
3512
3898
  const previousValue = tgt.items[index];
3513
3899
  const value = decodeValue(decoder, operation, ref, index, previousValue, type, bytes, it, allChanges);
3900
+ // resync bookkeeping — see Resync.ts. `index` is the RESOLVED position
3901
+ // (ADD_BY_REFID lands on the instance's current client-side index), so
3902
+ // visited entries form a sparse set.
3903
+ if (decoder.resyncVisited !== null) {
3904
+ resyncTouchEntry(decoder, ref, operation, index, previousValue, value, allChanges);
3905
+ }
3514
3906
  if (value !== null && value !== undefined &&
3515
3907
  value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
3516
3908
  ) {
@@ -3636,6 +4028,8 @@
3636
4028
  tmpItems = [];
3637
4029
  deletedIndexes = [];
3638
4030
  isMovingItems = false;
4031
+ /** Decode-side: `items` has holes (delete or gap-write) — `$onDecodeEnd` must compact. */
4032
+ _needsCompaction = false;
3639
4033
  static [$encoder] = encodeArray;
3640
4034
  static [$decoder] = decodeArray;
3641
4035
  /** Integer tag read by `decodeKeyValueOperation` — see `CollectionKind`. */
@@ -3650,9 +4044,11 @@
3650
4044
  * - Then, the encoder iterates over all "owned" properties per instance and encodes them.
3651
4045
  */
3652
4046
  static [$filter](ref, index, view) {
3653
- return (!view ||
3654
- typeof (ref[$childType]) === "string" ||
3655
- view.isChangeTreeVisible(ref['tmpItems'][index]?.[$changes]));
4047
+ if (!view)
4048
+ return true; // must stay first — encodeAll hits this per element
4049
+ const self = ref[$proxyTarget] ?? ref; // ref arrives proxied — skip traps below
4050
+ return (typeof (self[$childType]) === "string" ||
4051
+ view.isChangeTreeVisible(self['tmpItems'][index]?.[$changes]));
3656
4052
  }
3657
4053
  static is(type) {
3658
4054
  return (
@@ -3696,6 +4092,7 @@
3696
4092
  // staged-snapshot path in `$getByIndex`, `$onEncodeEnd`, etc.). The
3697
4093
  // decoder reads from `items` directly and never maintains them.
3698
4094
  self.isMovingItems = false;
4095
+ self._needsCompaction = false;
3699
4096
  self[$childType] = undefined;
3700
4097
  self[$proxyTarget] = self;
3701
4098
  const proxy = new Proxy(self, ARRAY_PROXY_HANDLER);
@@ -3823,6 +4220,9 @@
3823
4220
  this.items[index] = value;
3824
4221
  }
3825
4222
  else {
4223
+ if (index > this.items.length) {
4224
+ this._needsCompaction = true; // gap-write (filtered/out-of-order ADD) leaves holes
4225
+ }
3826
4226
  this.items[index] = value;
3827
4227
  }
3828
4228
  }
@@ -3843,6 +4243,27 @@
3843
4243
  self.items.length = 0;
3844
4244
  self.tmpItems.length = 0;
3845
4245
  }
4246
+ /**
4247
+ * Pool reset: empty this array and recycle its ChangeTree WITHOUT recording
4248
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
4249
+ * ref-type children. Called by Schema.reset when a pooled entity has an
4250
+ * array field. The instance must already be detached from the encoder.
4251
+ */
4252
+ [$reset]() {
4253
+ const self = this[$proxyTarget] ?? this;
4254
+ const changeTree = self[$changes];
4255
+ if (changeTree.isStreamCollection) {
4256
+ throw new Error(`@colyseus/schema: cannot reset a streamed ArraySchema (pooling not supported).`);
4257
+ }
4258
+ const items = self.items;
4259
+ for (let i = 0; i < items.length; i++)
4260
+ items[i]?.[$reset]?.();
4261
+ self.items.length = 0;
4262
+ self.tmpItems.length = 0;
4263
+ self.deletedIndexes.length = 0;
4264
+ changeTree.recycle();
4265
+ self[$refId] = undefined; // assign (not delete) to avoid V8 dictionary-mode deopt
4266
+ }
3846
4267
  /**
3847
4268
  * Combines two or more arrays.
3848
4269
  * @param items Additional items to add to the end of array1.
@@ -4242,21 +4663,51 @@
4242
4663
  * `$deleteByIndex` below.
4243
4664
  */
4244
4665
  [$getByIndex](index, isEncodeAll = false) {
4666
+ const self = this[$proxyTarget] ?? this; // called via Proxy — one trap here beats one per field read
4245
4667
  return (isEncodeAll)
4246
- ? this.items[index]
4247
- : this.deletedIndexes[index]
4248
- ? this.items[index]
4249
- : this.tmpItems[index] || this.items[index];
4668
+ ? self.items[index]
4669
+ : self.deletedIndexes[index]
4670
+ ? self.items[index]
4671
+ : self.tmpItems[index] || self.items[index];
4250
4672
  }
4251
4673
  [$deleteByIndex](index) {
4252
- this.items[index] = undefined;
4674
+ const self = this[$proxyTarget] ?? this;
4675
+ self.items[index] = undefined;
4676
+ self._needsCompaction = true;
4253
4677
  }
4254
4678
  [$onEncodeEnd]() {
4255
- this.tmpItems = this.items.slice();
4256
- this.deletedIndexes.length = 0;
4679
+ const self = this[$proxyTarget] ?? this;
4680
+ self.tmpItems = self.items.slice();
4681
+ self.deletedIndexes.length = 0;
4257
4682
  }
4258
4683
  [$onDecodeEnd]() {
4259
- this.items = this.items.filter((item) => item !== undefined);
4684
+ const self = this[$proxyTarget] ?? this;
4685
+ if (self._needsCompaction) {
4686
+ self._needsCompaction = false;
4687
+ self.items = self.items.filter((item) => item !== undefined);
4688
+ }
4689
+ }
4690
+ [$resyncPrune](visited, prune, keep) {
4691
+ // `items` is hole-free here: the decode loop's $onDecodeEnd already
4692
+ // ran, and a full-sync emits dense ADDs (no DELETEs, no gap-writes)
4693
+ // so no compaction happened mid-decode. Visited indexes may still be
4694
+ // sparse (ADD_BY_REFID resolves to the current client-side index).
4695
+ const self = this[$proxyTarget] ?? this;
4696
+ const items = self.items;
4697
+ let removed = false;
4698
+ for (let i = 0; i < items.length; i++) {
4699
+ const value = items[i];
4700
+ if (visited.has(i)) {
4701
+ keep(value);
4702
+ continue;
4703
+ }
4704
+ removed = true;
4705
+ prune(value, i);
4706
+ self[$deleteByIndex](i);
4707
+ }
4708
+ if (removed) {
4709
+ self[$onDecodeEnd]();
4710
+ } // compact the holes
4260
4711
  }
4261
4712
  toArray() {
4262
4713
  return this.items.slice(0);
@@ -4623,6 +5074,24 @@
4623
5074
  this.$items.clear();
4624
5075
  changeTree.operation(exports.OPERATION.CLEAR);
4625
5076
  }
5077
+ /**
5078
+ * Pool reset: empty this map and recycle its ChangeTree WITHOUT recording
5079
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
5080
+ * ref-type children. Called by Schema.reset when a pooled entity has a
5081
+ * map field. The instance must already be detached from the encoder.
5082
+ */
5083
+ [$reset]() {
5084
+ const changeTree = this[$changes];
5085
+ if (changeTree.isStreamCollection) {
5086
+ throw new Error(`@colyseus/schema: cannot reset a streamed MapSchema (pooling not supported).`);
5087
+ }
5088
+ // reset ref-type children first (primitives optional-chain away)
5089
+ this.$items.forEach((value) => value?.[$reset]?.());
5090
+ this.$items.clear();
5091
+ this.journal.reset();
5092
+ changeTree.recycle();
5093
+ this[$refId] = undefined; // assign (not delete) to avoid V8 dictionary-mode deopt
5094
+ }
4626
5095
  has(key) {
4627
5096
  return this.$items.has(key);
4628
5097
  }
@@ -4663,6 +5132,36 @@
4663
5132
  this.journal.keyByIndex.delete(index);
4664
5133
  }
4665
5134
  }
5135
+ [$resyncPrune](visited, prune, keep) {
5136
+ // maps prune by string key, NOT wire index — the decoder-side
5137
+ // journal never evicts stale index→key mappings on re-indexing.
5138
+ let deletedKeys = null;
5139
+ this.$items.forEach((value, key) => {
5140
+ if (visited.has(key)) {
5141
+ keep(value);
5142
+ return;
5143
+ }
5144
+ (deletedKeys ??= new Set()).add(key);
5145
+ prune(value, key);
5146
+ });
5147
+ if (deletedKeys !== null) {
5148
+ deletedKeys.forEach((key) => {
5149
+ this.$items.delete(key);
5150
+ delete this.journal.indexByKey[key];
5151
+ });
5152
+ // drop index→key mappings of swept keys — including stale ones
5153
+ // left behind by re-indexing.
5154
+ const staleIndexes = [];
5155
+ this.journal.keyByIndex.forEach((key, index) => {
5156
+ if (deletedKeys.has(key)) {
5157
+ staleIndexes.push(index);
5158
+ }
5159
+ });
5160
+ for (let i = 0; i < staleIndexes.length; i++) {
5161
+ this.journal.keyByIndex.delete(staleIndexes[i]);
5162
+ }
5163
+ }
5164
+ }
4666
5165
  [$onEncodeEnd]() {
4667
5166
  this.journal.cleanupAfterEncode();
4668
5167
  }
@@ -4855,6 +5354,24 @@
4855
5354
  this.$items.clear();
4856
5355
  changeTree.operation(exports.OPERATION.CLEAR);
4857
5356
  }
5357
+ /**
5358
+ * Pool reset: empty this collection and recycle its ChangeTree WITHOUT
5359
+ * recording any wire op (the parent field's ADD/DELETE owns the wire).
5360
+ * Recurses into ref-type children. Called by Schema.reset when a pooled
5361
+ * entity has a collection field. Must already be detached from the encoder.
5362
+ */
5363
+ [$reset]() {
5364
+ const changeTree = this[$changes];
5365
+ if (changeTree.isStreamCollection) {
5366
+ throw new Error(`@colyseus/schema: cannot reset a streamed CollectionSchema (pooling not supported).`);
5367
+ }
5368
+ this.$items.forEach((value) => value?.[$reset]?.());
5369
+ this.$items.clear();
5370
+ this.deletedItems = {};
5371
+ this.$refId = 0; // reset the monotonic index counter (field, not the symbol)
5372
+ changeTree.recycle();
5373
+ this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt)
5374
+ }
4858
5375
  has(value) {
4859
5376
  return Array.from(this.$items.values()).some((v) => v === value);
4860
5377
  }
@@ -4893,6 +5410,22 @@
4893
5410
  [$deleteByIndex](index) {
4894
5411
  this.$items.delete(index);
4895
5412
  }
5413
+ [$resyncPrune](visited, prune, keep) {
5414
+ let toDelete = null;
5415
+ this.$items.forEach((value, index) => {
5416
+ if (visited.has(index)) {
5417
+ keep(value);
5418
+ return;
5419
+ }
5420
+ (toDelete ??= []).push(index);
5421
+ prune(value, index);
5422
+ });
5423
+ if (toDelete !== null) {
5424
+ for (let i = 0; i < toDelete.length; i++) {
5425
+ this[$deleteByIndex](toDelete[i]);
5426
+ }
5427
+ }
5428
+ }
4896
5429
  [$onEncodeEnd]() {
4897
5430
  for (const key in this.deletedItems) {
4898
5431
  delete this.deletedItems[key];
@@ -5094,6 +5627,24 @@
5094
5627
  this.$items.clear();
5095
5628
  changeTree.operation(exports.OPERATION.CLEAR);
5096
5629
  }
5630
+ /**
5631
+ * Pool reset: empty this set and recycle its ChangeTree WITHOUT recording
5632
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
5633
+ * ref-type children. Called by Schema.reset when a pooled entity has a set
5634
+ * field. The instance must already be detached from the encoder.
5635
+ */
5636
+ [$reset]() {
5637
+ const changeTree = this[$changes];
5638
+ if (changeTree.isStreamCollection) {
5639
+ throw new Error(`@colyseus/schema: cannot reset a streamed SetSchema (pooling not supported).`);
5640
+ }
5641
+ this.$items.forEach((value) => value?.[$reset]?.());
5642
+ this.$items.clear();
5643
+ this.deletedItems = {};
5644
+ this.$refId = 0; // reset the monotonic index counter (field, not the symbol)
5645
+ changeTree.recycle();
5646
+ this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt)
5647
+ }
5097
5648
  has(value) {
5098
5649
  const values = this.$items.values();
5099
5650
  let has = false;
@@ -5144,6 +5695,22 @@
5144
5695
  [$deleteByIndex](index) {
5145
5696
  this.$items.delete(index);
5146
5697
  }
5698
+ [$resyncPrune](visited, prune, keep) {
5699
+ let toDelete = null;
5700
+ this.$items.forEach((value, index) => {
5701
+ if (visited.has(index)) {
5702
+ keep(value);
5703
+ return;
5704
+ }
5705
+ (toDelete ??= []).push(index);
5706
+ prune(value, index);
5707
+ });
5708
+ if (toDelete !== null) {
5709
+ for (let i = 0; i < toDelete.length; i++) {
5710
+ this[$deleteByIndex](toDelete[i]);
5711
+ }
5712
+ }
5713
+ }
5147
5714
  [$onEncodeEnd]() {
5148
5715
  for (const key in this.deletedItems) {
5149
5716
  delete this.deletedItems[key];
@@ -5405,6 +5972,11 @@
5405
5972
  this.$items.delete(index);
5406
5973
  }
5407
5974
  }
5975
+ [$resyncPrune]() {
5976
+ // Stream contents are delivered by the trickle/priority pass, NOT
5977
+ // by full-sync (encodeAll carries none of them) — a snapshot is not
5978
+ // authoritative for streams, so absence ≠ deleted. Never prune.
5979
+ }
5408
5980
  [$onEncodeEnd]() {
5409
5981
  // No per-tick cleanup: pending/sent state spans encode ticks by design.
5410
5982
  }
@@ -5485,7 +6057,22 @@
5485
6057
  constructor(type) {
5486
6058
  this._type = type;
5487
6059
  }
5488
- /** Provide a default value for this field. */
6060
+ /**
6061
+ * Provide a default value for this field.
6062
+ *
6063
+ * Pass a **factory function** `() => T` to build a FRESH value per instance
6064
+ * (invoked once per construction) instead of sharing a single default — the
6065
+ * clean way to default a ref to a plain custom class, or any field that must
6066
+ * not share a mutable default across instances:
6067
+ *
6068
+ * ```ts
6069
+ * acc: t.ref(GunAccuracy).noSync().default(() => new GunAccuracy()),
6070
+ * ```
6071
+ *
6072
+ * Schema fields are never function-typed, so a function is always treated as a
6073
+ * factory. A non-function value is shared (and cloned per instance if it is
6074
+ * clone-able, e.g. a Schema/collection).
6075
+ */
5489
6076
  default(value) {
5490
6077
  this._default = value;
5491
6078
  this._hasDefault = true;
@@ -5656,6 +6243,20 @@
5656
6243
  return b;
5657
6244
  });
5658
6245
  const refFactory = ((ctor) => new FieldBuilder(ctor));
6246
+ /**
6247
+ * A bounded float carried on the wire as a fixed-width unsigned integer. App code
6248
+ * reads/writes the FLOAT; the wire carries the quantized int and the field only
6249
+ * ever yields `dequant(q)`, so client predict and server sim read the same value
6250
+ * (no full-precision path to leak ⇒ no shot misprediction). See
6251
+ * {@link QuantizeOptions} for the precision/wire trade-offs.
6252
+ *
6253
+ * yaw: t.quantized({ min: 0, max: TWO_PI, mode: "wrap" }), // 16-bit
6254
+ * pitch: t.quantized({ min: -PITCH_LIMIT, max: PITCH_LIMIT }), // clamp (default)
6255
+ * throttle: t.quantized({ min: 0, max: 1, bits: 8 }), // 1 byte
6256
+ */
6257
+ function quantizedFactory(opts) {
6258
+ return new FieldBuilder({ quantized: resolveQuantize(opts) });
6259
+ }
5659
6260
  const t = Object.freeze({
5660
6261
  // Primitives
5661
6262
  string: primitive("string"),
@@ -5673,16 +6274,46 @@
5673
6274
  float64: primitive("float64"),
5674
6275
  bigint64: primitive("bigint64"),
5675
6276
  biguint64: primitive("biguint64"),
5676
- /** Reference to a Schema subtype. `t.array(Item)` usually reads better, but this is available when a plain ref is needed. */
6277
+ /**
6278
+ * Reference to a Schema subtype — `t.array(Item)` usually reads better, but
6279
+ * this is available when a plain ref is needed.
6280
+ *
6281
+ * The target may also be a **non-Schema custom class**. A synced ref still
6282
+ * requires it to be encodable — a `Schema` subclass, or a class retrofitted
6283
+ * with `Metadata.setFields(...)` (both carry `[Symbol.metadata]`); a bare
6284
+ * custom class is rejected at `schema()` time. A `.noSync()` (local-only)
6285
+ * field accepts ANY zero-arg class and auto-instantiates one per parent.
6286
+ */
5677
6287
  ref: refFactory,
5678
6288
  array: arrayFactory,
5679
6289
  map: mapFactory,
5680
6290
  set: setFactory,
5681
6291
  collection: collectionFactory,
5682
6292
  stream: streamFactory,
6293
+ /**
6294
+ * A bounded float quantized to a fixed-width unsigned int on the wire — half
6295
+ * (or a quarter) the bytes of a `float32` at a precision you pick. The field
6296
+ * reads/writes the float and only ever yields `dequant(q)`. {@see QuantizeOptions}
6297
+ */
6298
+ quantized: quantizedFactory,
6299
+ /**
6300
+ * Sugar for a full-circle wrapping angle in radians:
6301
+ * `t.quantized({ min: 0, max: 2π, mode: "wrap", bits })` (default 16-bit,
6302
+ * ~0.0055°/step). Any input angle is range-reduced into `[0, 2π)`. Render note:
6303
+ * lerp interpolated remotes shortest-arc (`attach({ angle: true })`) — the
6304
+ * wrap fixes the WIRE seam, not interpolation (see {@link QuantizeOptions.mode}).
6305
+ */
6306
+ angle: (opts) => quantizedFactory({ min: 0, max: Math.PI * 2, mode: "wrap", bits: opts?.bits ?? 16 }),
5683
6307
  });
5684
6308
 
5685
6309
  const DEFAULT_VIEW_TAG = -1;
6310
+ /**
6311
+ * Class decorator that registers a `@type`-style Schema class with the
6312
+ * TypeContext (required for reflection / cross-language codegen).
6313
+ *
6314
+ * @entity
6315
+ * class Player extends Schema { ... }
6316
+ */
5686
6317
  function entity(constructor) {
5687
6318
  TypeContext.register(constructor);
5688
6319
  return constructor;
@@ -5914,8 +6545,8 @@
5914
6545
  if (metadata[$descriptors][field]) {
5915
6546
  Object.defineProperty(target, field, metadata[$descriptors][field]);
5916
6547
  }
5917
- // Pre-compute encoder function for primitive types.
5918
- if (typeof type === "string") {
6548
+ // Pre-compute encoder function for primitive + quantized types.
6549
+ if (typeof type === "string" || isQuantizedType(type)) {
5919
6550
  if (!metadata[$encoders]) {
5920
6551
  Object.defineProperty(metadata, $encoders, {
5921
6552
  value: [],
@@ -5924,7 +6555,9 @@
5924
6555
  writable: true,
5925
6556
  });
5926
6557
  }
5927
- metadata[$encoders][fieldIndex] = encode[type];
6558
+ metadata[$encoders][fieldIndex] = (typeof type === "string")
6559
+ ? encode[type]
6560
+ : makeQuantizedEncoder(type.quantized);
5928
6561
  }
5929
6562
  };
5930
6563
  }
@@ -6035,6 +6668,38 @@
6035
6668
  values[fieldIndex] = value;
6036
6669
  };
6037
6670
  }
6671
+ /**
6672
+ * Setter for a `t.quantized()` field. SNAPS the assigned float to the wire-exact
6673
+ * value (`dequant(quant(x))`) on write, so the stored value — and every read,
6674
+ * including the reconciler's live step off the staged input — is identical to
6675
+ * what the server decodes off the wire. This is the half that kills the
6676
+ * predict-from-the-wrong-value footgun; the encoder re-quantizes the snapped
6677
+ * value at send (a lossless round-trip). Change tracking keys on the SNAPPED
6678
+ * value, so a sub-step jitter that quantizes to the same integer emits no delta.
6679
+ */
6680
+ function makeQuantizedSetter(fieldName, fieldIndex, desc) {
6681
+ return function (value) {
6682
+ const values = this[$values];
6683
+ const previousValue = values[fieldIndex];
6684
+ if (value !== undefined && value !== null) {
6685
+ if (typeof value !== "number") {
6686
+ throw new EncodeSchemaError(`a 'number' was expected, but '${JSON.stringify(value)}' was provided in ${this.constructor.name}#${fieldName}`);
6687
+ }
6688
+ value = dequantize(desc, quantize(desc, value)); // snap to wire-exact
6689
+ if (value === previousValue)
6690
+ return;
6691
+ this.constructor[$track](this[$changes], fieldIndex, exports.OPERATION.ADD);
6692
+ }
6693
+ else {
6694
+ if (value === previousValue)
6695
+ return; // undefined === undefined
6696
+ if (previousValue !== undefined && previousValue !== null) {
6697
+ this[$changes].delete(fieldIndex);
6698
+ }
6699
+ }
6700
+ values[fieldIndex] = value;
6701
+ };
6702
+ }
6038
6703
  function getPropertyDescriptor(fieldName, fieldIndex, type, complexTypeKlass) {
6039
6704
  let setter;
6040
6705
  if (complexTypeKlass) {
@@ -6043,10 +6708,15 @@
6043
6708
  else if (typeof type === "string") {
6044
6709
  setter = makePrimitiveSetter(fieldName, fieldIndex, type);
6045
6710
  }
6711
+ else if (isQuantizedType(type)) {
6712
+ setter = makeQuantizedSetter(fieldName, fieldIndex, type.quantized);
6713
+ }
6046
6714
  else {
6047
6715
  setter = makeSchemaRefSetter(fieldName, fieldIndex, type);
6048
6716
  }
6049
6717
  return {
6718
+ // Quantized stores the already-snapped float, so the getter is the plain
6719
+ // $values read — the field yields dequant(q) with no per-read math.
6050
6720
  get: function () { return this[$values][fieldIndex]; },
6051
6721
  set: setter,
6052
6722
  enumerable: true,
@@ -6082,31 +6752,32 @@
6082
6752
  };
6083
6753
  }
6084
6754
  /**
6085
- * Produce the auto-instantiated construction default for a builder type
6086
- * (empty collection or zero-arg Schema ref), or `undefined` when the type
6087
- * has no auto-default. Shared by synced and `.noSync()` field handling.
6755
+ * Build a per-construction factory for a builder type's auto-instantiated
6756
+ * default (empty collection or zero-arg Schema ref), or `undefined` when the
6757
+ * type has no auto-default. Returning a factory lets each construction `new` a
6758
+ * fresh value directly instead of cloning a shared prototype instance.
6088
6759
  */
6089
- function autoInstantiateDefault(rawType) {
6760
+ function makeAutoDefaultFactory(rawType) {
6090
6761
  if (rawType && typeof rawType === "object") {
6091
6762
  if (rawType.array !== undefined) {
6092
- return new ArraySchema();
6763
+ return () => new ArraySchema();
6093
6764
  }
6094
6765
  if (rawType.map !== undefined) {
6095
- return new MapSchema();
6766
+ return () => new MapSchema();
6096
6767
  }
6097
6768
  if (rawType.set !== undefined) {
6098
- return new SetSchema();
6769
+ return () => new SetSchema();
6099
6770
  }
6100
6771
  if (rawType.collection !== undefined) {
6101
- return new CollectionSchema();
6772
+ return () => new CollectionSchema();
6102
6773
  }
6103
6774
  if (rawType.stream !== undefined) {
6104
- return new StreamSchema();
6775
+ return () => new StreamSchema();
6105
6776
  }
6106
6777
  }
6107
6778
  else if (typeof rawType === "function" && Schema.is(rawType)) {
6108
6779
  if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
6109
- return new rawType();
6780
+ return () => new rawType();
6110
6781
  }
6111
6782
  }
6112
6783
  return undefined;
@@ -6133,7 +6804,39 @@
6133
6804
  }
6134
6805
  const fields = {};
6135
6806
  const methods = {};
6807
+ // Two buckets, both keyed by field name and applied at construction:
6808
+ // - `defaultValues`: static values copied as-is (shared reference).
6809
+ // - `defaultFactories`: invoked per construction for a fresh value —
6810
+ // `.default(fn)`, clone-able defaults, and auto-instantiated collections/refs.
6136
6811
  const defaultValues = {};
6812
+ const defaultFactories = {};
6813
+ // Decide once (at definition time) how each `.default(v)` materializes per
6814
+ // construction: a function is a factory; a clone-able value clones fresh;
6815
+ // anything else is a shared static value.
6816
+ const assignDefault = (field, value) => {
6817
+ if (typeof value === "function") {
6818
+ defaultFactories[field] = value;
6819
+ }
6820
+ else if (value && typeof value.clone === "function") {
6821
+ defaultFactories[field] = () => value.clone();
6822
+ }
6823
+ else {
6824
+ defaultValues[field] = value;
6825
+ }
6826
+ };
6827
+ // Seed a field's construction default: explicit `.default(v)`, else the
6828
+ // auto-instantiated empty collection / zero-arg ref (skipped for `.optional()`).
6829
+ const seedDefault = (field, def) => {
6830
+ if (def.hasDefault) {
6831
+ assignDefault(field, def.default);
6832
+ }
6833
+ else if (!def.optional) {
6834
+ const factory = makeAutoDefaultFactory(def.type);
6835
+ if (factory) {
6836
+ defaultFactories[field] = factory;
6837
+ }
6838
+ }
6839
+ };
6137
6840
  const viewTagFields = {};
6138
6841
  const ownedFields = [];
6139
6842
  const unreliableFields = [];
@@ -6157,18 +6860,16 @@
6157
6860
  `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
6158
6861
  `A local-only field cannot be synchronized.`);
6159
6862
  }
6160
- if (def.hasDefault) {
6161
- defaultValues[fieldName] = def.default;
6162
- }
6163
- else if (!def.optional) {
6164
- const autoDefault = autoInstantiateDefault(def.type);
6165
- if (autoDefault !== undefined) {
6166
- defaultValues[fieldName] = autoDefault;
6167
- }
6168
- }
6863
+ seedDefault(fieldName, def);
6169
6864
  continue;
6170
6865
  }
6171
- fields[fieldName] = getNormalizedType(def.type);
6866
+ const normalizedType = getNormalizedType(def.type);
6867
+ // A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
6868
+ if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
6869
+ throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' is a synced ref to non-Schema ` +
6870
+ `class '${normalizedType.name || "(anonymous)"}' — use .noSync(), or Metadata.setFields().`);
6871
+ }
6872
+ fields[fieldName] = normalizedType;
6172
6873
  if (def.view !== undefined) {
6173
6874
  viewTagFields[fieldName] = def.view;
6174
6875
  }
@@ -6196,24 +6897,14 @@
6196
6897
  if (def.optional) {
6197
6898
  optionalFields.push(fieldName);
6198
6899
  }
6199
- if (def.hasDefault) {
6200
- defaultValues[fieldName] = def.default;
6201
- }
6202
- else if (!def.optional) {
6203
- // Auto-instantiate collection/Schema defaults when none is provided.
6204
- // `.optional()` opts out — field starts as undefined.
6205
- const autoDefault = autoInstantiateDefault(def.type);
6206
- if (autoDefault !== undefined) {
6207
- defaultValues[fieldName] = autoDefault;
6208
- }
6209
- }
6900
+ seedDefault(fieldName, def);
6210
6901
  }
6211
6902
  else if (typeof value === "function") {
6212
6903
  if (Schema.is(value)) {
6213
6904
  // Convenience: allow a bare Schema subclass (equivalent to `t.ref(Class)`).
6214
6905
  fields[fieldName] = getNormalizedType(value);
6215
6906
  if (!value.prototype.initialize || value.prototype.initialize.length === 0) {
6216
- defaultValues[fieldName] = new value();
6907
+ defaultFactories[fieldName] = () => new value();
6217
6908
  }
6218
6909
  }
6219
6910
  else {
@@ -6225,17 +6916,19 @@
6225
6916
  `Schema subclass, or method (got ${typeof value}).`);
6226
6917
  }
6227
6918
  }
6228
- const getDefaultValues = () => {
6229
- const defaults = {};
6919
+ // Write construction defaults onto `target` — either the instance directly
6920
+ // (no-args fast path) or a throwaway object that gets merged with props.
6921
+ const applyDefaults = (target) => {
6230
6922
  for (const fieldName in defaultValues) {
6231
- const defaultValue = defaultValues[fieldName];
6232
- if (defaultValue && typeof defaultValue.clone === "function") {
6233
- defaults[fieldName] = defaultValue.clone();
6234
- }
6235
- else {
6236
- defaults[fieldName] = defaultValue;
6237
- }
6923
+ target[fieldName] = defaultValues[fieldName];
6924
+ }
6925
+ for (const fieldName in defaultFactories) {
6926
+ target[fieldName] = defaultFactories[fieldName]();
6238
6927
  }
6928
+ };
6929
+ const getDefaultValues = () => {
6930
+ const defaults = {};
6931
+ applyDefaults(defaults);
6239
6932
  return defaults;
6240
6933
  };
6241
6934
  const getParentProps = (props) => {
@@ -6248,18 +6941,26 @@
6248
6941
  }
6249
6942
  return parentProps;
6250
6943
  };
6944
+ const hasInitialize = typeof methods.initialize === "function";
6251
6945
  /** @codegen-ignore */
6252
6946
  const klass = Metadata.setFields(class extends inherits {
6253
6947
  constructor(...args) {
6254
- if (methods.initialize && typeof methods.initialize === "function") {
6255
- super(Object.assign({}, getDefaultValues(), getParentProps(args[0] || {})));
6256
- // Only call initialize() on the exact target class, not parents.
6257
- if (new.target === klass) {
6258
- methods.initialize.apply(this, args);
6259
- }
6948
+ const props = args[0];
6949
+ if (props === undefined) {
6950
+ // No-args: write defaults straight onto the instance skips the
6951
+ // throwaway defaults object + Object.assign + assignProps walk.
6952
+ super();
6953
+ applyDefaults(this);
6260
6954
  }
6261
6955
  else {
6262
- super(Object.assign({}, getDefaultValues(), args[0] || {}));
6956
+ // With props: merge into the fresh defaults object in place (no
6957
+ // extra `{}` target); the super chain runs assignProps once. An
6958
+ // `initialize()` owns the schema fields, so only parent props flow up.
6959
+ super(Object.assign(getDefaultValues(), hasInitialize ? getParentProps(props) : props));
6960
+ }
6961
+ // Only call initialize() on the exact target class, not parents.
6962
+ if (hasInitialize && new.target === klass) {
6963
+ methods.initialize.apply(this, args);
6263
6964
  }
6264
6965
  }
6265
6966
  }, fields);
@@ -6386,6 +7087,60 @@
6386
7087
  inst[$values] = [];
6387
7088
  return inst;
6388
7089
  }
7090
+ /**
7091
+ * Reset a DETACHED instance to construction defaults so it can be returned
7092
+ * to a {@link SchemaPool} and reused, avoiding the cost of `new`. Recurses
7093
+ * into ref-type fields (child Schemas / collections).
7094
+ *
7095
+ * Preconditions (enforced):
7096
+ * - The instance must be tracked (encoder-side), not a decoder mirror.
7097
+ * - The instance must NOT be shared across multiple parents.
7098
+ * - The instance must already be removed from its parent collection/field
7099
+ * (so the encoder detached it: `root === undefined`).
7100
+ *
7101
+ * NOTE: primitive field values are NOT reset to class defaults — re-assign
7102
+ * the fields you care about when you reuse the instance (standard
7103
+ * object-pool discipline).
7104
+ */
7105
+ static reset(instance) {
7106
+ const changeTree = instance?.[$changes];
7107
+ // Only tracked (encoder-side) instances are poolable. Decoder-side
7108
+ // instances carry an UntrackedChangeTree, which has no recycle().
7109
+ if (changeTree === undefined || typeof changeTree.recycle !== "function") {
7110
+ throw new Error(`@colyseus/schema: Schema.reset() requires a tracked (encoder-side) instance.`);
7111
+ }
7112
+ // Instances reachable through more than one parent are unsafe to pool:
7113
+ // another owner may still hold this instance.
7114
+ if (changeTree.extraParents !== undefined) {
7115
+ throw new Error(`@colyseus/schema: cannot reset a shared instance (${instance.constructor.name}) with multiple parents.`);
7116
+ }
7117
+ instance[$reset]();
7118
+ }
7119
+ /**
7120
+ * Per-instance reset primitive (the recursive worker behind
7121
+ * {@link Schema.reset}). Resets ref-type children first (depth-first),
7122
+ * then recycles this instance's ChangeTree and drops its `$refId` so a
7123
+ * re-add is assigned a fresh refId exactly like a freshly constructed
7124
+ * instance. Dropping `$refId` is what makes instance reuse
7125
+ * wire-format-identical to `new T()`.
7126
+ */
7127
+ [$reset]() {
7128
+ const metadata = this.constructor[Symbol.metadata];
7129
+ const refIndexes = metadata?.[$refTypeFieldIndexes] ?? [];
7130
+ const values = this[$values];
7131
+ for (let i = 0; i < refIndexes.length; i++) {
7132
+ const child = values[refIndexes[i]];
7133
+ // ref fields hold a child Schema or collection (both implement
7134
+ // [$reset]); skip undefined/null. Optional-chain is a cheap guard.
7135
+ child?.[$reset]?.();
7136
+ }
7137
+ this[$changes].recycle();
7138
+ // Clear the refId by ASSIGNMENT (not `delete`): `delete` would force the
7139
+ // instance into V8 dictionary mode, making release() as expensive as the
7140
+ // construction it saves. `=== undefined` in Root.add still assigns a fresh
7141
+ // refId exactly like a freshly-constructed instance.
7142
+ this[$refId] = undefined;
7143
+ }
6389
7144
  /**
6390
7145
  * Check whether `type` describes a Schema *class* (a subclass
6391
7146
  * constructor carrying `Symbol.metadata`, as installed by `@type`).
@@ -6448,7 +7203,8 @@
6448
7203
  return view.isChangeTreeVisible(ref[$changes]);
6449
7204
  }
6450
7205
  else {
6451
- // view pass: custom tag
7206
+ // view pass: custom tag (bitmask) — field's stored mask matches
7207
+ // if it shares any bit with a tag this view was add()ed with.
6452
7208
  return view.hasTagOnTree(ref[$changes], tag);
6453
7209
  }
6454
7210
  }
@@ -6753,83 +7509,18 @@
6753
7509
  }
6754
7510
  }
6755
7511
 
6756
- /**
6757
- * Allocates monotonically-increasing refIds with a reuse pool.
6758
- *
6759
- * `acquire()` pops from the free pool when available, otherwise bumps a
6760
- * counter. `release()` queues a refId for reuse; the id doesn't become
6761
- * acquirable until `flushReleases()` runs — the one-tick defer is what
6762
- * lets the encoder guarantee a DELETE for the old instance reaches the
6763
- * wire before the refId is handed to a new one.
6764
- *
6765
- * `reclaim()` handles "resurrection": a ref that was released but whose
6766
- * JS instance is still alive can be re-added to the tree, in which case
6767
- * the encoder must pull the refId back out of the pool before it's
6768
- * handed to an unrelated instance.
6769
- */
6770
- class RefIdAllocator {
6771
- nextUniqueId;
6772
- _free = [];
6773
- _pending = [];
6774
- _pooled = new Set();
6775
- constructor(startRefId = 0) {
6776
- this.nextUniqueId = startRefId;
6777
- }
6778
- acquire() {
6779
- if (this._free.length > 0) {
6780
- const id = this._free.pop();
6781
- this._pooled.delete(id);
6782
- return id;
6783
- }
6784
- return this.nextUniqueId++;
6785
- }
6786
- release(refId) {
6787
- this._pending.push(refId);
6788
- this._pooled.add(refId);
6789
- }
6790
- isPooled(refId) {
6791
- return this._pooled.has(refId);
6792
- }
6793
- /**
6794
- * Remove a refId from the pool. Called when a ref whose refId was
6795
- * released is being resurrected. O(n) scan of the relevant array,
6796
- * but resurrection is rare.
6797
- */
6798
- reclaim(refId) {
6799
- if (!this._pooled.delete(refId))
6800
- return;
6801
- let i = this._free.indexOf(refId);
6802
- if (i !== -1) {
6803
- this._free.splice(i, 1);
6804
- return;
6805
- }
6806
- i = this._pending.indexOf(refId);
6807
- if (i !== -1) {
6808
- this._pending.splice(i, 1);
6809
- }
6810
- }
6811
- /**
6812
- * Promote this tick's releases into the acquirable set. Called from
6813
- * `Encoder.discardChanges()` — never mid-encode.
6814
- */
6815
- flushReleases() {
6816
- const pending = this._pending;
6817
- if (pending.length === 0)
6818
- return;
6819
- const free = this._free;
6820
- for (let i = 0; i < pending.length; i++)
6821
- free.push(pending[i]);
6822
- pending.length = 0;
6823
- }
6824
- }
6825
-
7512
+ // Reused across Root.add calls — defineProperty is unavoidable ($refId must
7513
+ // stay non-enumerable for deepStrictEqual) but the descriptor literal isn't.
7514
+ const $refIdDescriptor$1 = { value: 0, enumerable: false, writable: true };
6826
7515
  class Root {
6827
7516
  types;
6828
7517
  /**
6829
- * Allocates and recycles refIds. See `RefIdAllocator` for the reuse
6830
- * pool semantics (one-tick defer + resurrection).
7518
+ * Monotonic refId counter. RefIds are never recycled — a refId is a
7519
+ * stable identity for the lifetime of the room, so a client that
7520
+ * missed DELETEs (reconnect) can never see an old id rebound to a
7521
+ * different instance. DevMode reads/writes this across HMR cycles.
6831
7522
  */
6832
- refIds;
7523
+ nextUniqueId = 0;
6833
7524
  refCount = {};
6834
7525
  changeTrees = {};
6835
7526
  /**
@@ -6922,7 +7613,7 @@
6922
7613
  }
6923
7614
  constructor(types, startRefId = 0) {
6924
7615
  this.types = types;
6925
- this.refIds = new RefIdAllocator(startRefId);
7616
+ this.nextUniqueId = startRefId;
6926
7617
  }
6927
7618
  add(changeTree) {
6928
7619
  const ref = changeTree.ref;
@@ -6931,31 +7622,27 @@
6931
7622
  // *enumerable* own Symbols, so we keep defineProperty(enumerable:false)
6932
7623
  // to keep $refId hidden from deep-equal comparisons in tests.
6933
7624
  if (ref[$refId] === undefined) {
6934
- Object.defineProperty(ref, $refId, {
6935
- value: this.refIds.acquire(),
6936
- enumerable: false,
6937
- writable: true
6938
- });
7625
+ $refIdDescriptor$1.value = this.nextUniqueId++;
7626
+ Object.defineProperty(ref, $refId, $refIdDescriptor$1);
6939
7627
  }
6940
7628
  const refId = ref[$refId];
6941
7629
  const isNewChangeTree = (this.changeTrees[refId] === undefined);
6942
7630
  if (isNewChangeTree) {
6943
7631
  this.changeTrees[refId] = changeTree;
6944
7632
  }
6945
- // Resurrection path: a ref whose refId is still queued for reuse
6946
- // is being re-added. Pull the refId out of the pool before it gets
6947
- // handed out to someone else.
6948
- if (this.refIds.isPooled(refId)) {
6949
- this.refIds.reclaim(refId);
6950
- }
6951
7633
  const previousRefCount = this.refCount[refId];
6952
- if (previousRefCount === 0) {
7634
+ if (previousRefCount === 0 || changeTree.needsRestage) {
6953
7635
  //
6954
- // When a ChangeTree is re-added, it means that it was previously
6955
- // removed. Re-stage every currently-populated non-transient index
6956
- // as a fresh ADD in the matching dirty bucket so the next encode
6957
- // re-emits it on the correct channel.
7636
+ // Re-stage every currently-populated non-transient index as a
7637
+ // fresh ADD in the matching dirty bucket so the next encode
7638
+ // re-emits it on the correct channel. Two triggers:
7639
+ // - refCount 0: a previously-removed tree re-added under the
7640
+ // same refId (its ops were consumed by an earlier encode).
7641
+ // - NEEDS_RESTAGE: a `Schema.reset` instance re-entering under
7642
+ // a fresh refId (reset cleared the buckets; its retained
7643
+ // values would otherwise never be encoded).
6958
7644
  //
7645
+ changeTree.needsRestage = false;
6959
7646
  changeTree.forEachLive((fieldIndex) => {
6960
7647
  if (changeTree.isFieldUnreliable(fieldIndex)) {
6961
7648
  changeTree.ensureUnreliableRecorder().record(fieldIndex, exports.OPERATION.ADD);
@@ -6988,15 +7675,6 @@
6988
7675
  this.removeFromQueue(changeTree);
6989
7676
  this.removeFromUnreliableQueue(changeTree);
6990
7677
  this.refCount[refId] = 0;
6991
- // Return refId to the reuse pool (deferred to end-of-tick via
6992
- // the allocator's pending set). Stream collections are excluded
6993
- // because their per-view delivery bookkeeping is harder to
6994
- // audit for reuse safety and the savings there are negligible.
6995
- // If the ref is later resurrected, `add()` evicts the refId
6996
- // from the pool before it's handed to another instance.
6997
- if (!changeTree.isStreamCollection) {
6998
- this.refIds.release(refId);
6999
- }
7000
7678
  changeTree.forEachChild((child, _) => {
7001
7679
  if (child.removeParent(changeTree.ref)) {
7002
7680
  if ((child.parentRef === undefined || // no parent, remove it
@@ -7169,6 +7847,12 @@
7169
7847
  * Module-level adapter for `forEachLiveWithCtx`. Full-sync emits every live
7170
7848
  * field as ADD, so we re-enter `encodeChangeCb` with that fixed op — keeps
7171
7849
  * the callback closure-free across the entire DFS walk.
7850
+ *
7851
+ * The resync sweep (decoder/Resync.ts) depends on this shape: full-sync
7852
+ * output is dense plain ADDs — no DELETEs, no gap-writes (so decoding it
7853
+ * never compacts arrays mid-walk), and replaced occupants arrive as plain
7854
+ * ADD (the sweep's touch hook releases them). Changing full-sync emission
7855
+ * means revisiting the sweep.
7172
7856
  */
7173
7857
  function encodeFullSyncCb(ctx, fieldIndex) {
7174
7858
  encodeChangeCb(ctx, fieldIndex, exports.OPERATION.ADD);
@@ -7274,7 +7958,18 @@
7274
7958
  return result;
7275
7959
  }
7276
7960
  class Encoder {
7277
- static BUFFER_SIZE = 8 * 1024; // 8KB
7961
+ /**
7962
+ * Per-encoder shared output buffer size. The encoder auto-grows on
7963
+ * overflow and logs a one-time warning suggesting a higher value, so
7964
+ * the default just needs to comfortably cover typical room state.
7965
+ *
7966
+ * Sized to fit ~100 items in a `MapSchema<{x,y,z}>` keyed by
7967
+ * `nanoid(9)` (~4.5 KB worst-case full encode, float64-heavy) with
7968
+ * ~3-4× headroom for surrounding state (player list, world refs,
7969
+ * etc.). Raise per app via `Encoder.BUFFER_SIZE = N * 1024` before
7970
+ * constructing any Encoder.
7971
+ */
7972
+ static BUFFER_SIZE = 16 * 1024;
7278
7973
  sharedBuffer = new Uint8Array(Encoder.BUFFER_SIZE);
7279
7974
  context;
7280
7975
  state;
@@ -7378,7 +8073,12 @@
7378
8073
  }
7379
8074
  if (it.offset > buffer.byteLength) {
7380
8075
  buffer = this._resizeBuffer(buffer, it.offset);
7381
- return this._encodeChannel({ offset: initialOffset }, view, buffer, initialOffset, unreliable);
8076
+ // Reuse `it` (reset its offset) instead of a fresh iterator so the
8077
+ // caller's `it.offset` ends at the true final offset. A fresh one
8078
+ // strands `it.offset` at the overflow value and corrupts the next
8079
+ // view's region in a multi-view encode.
8080
+ it.offset = initialOffset;
8081
+ return this._encodeChannel(it, view, buffer, initialOffset, unreliable);
7382
8082
  }
7383
8083
  return buffer.subarray(0, it.offset);
7384
8084
  }
@@ -7410,7 +8110,8 @@
7410
8110
  _fullSyncWalk(ctx, rootChangeTree);
7411
8111
  if (it.offset > buffer.byteLength) {
7412
8112
  buffer = this._resizeBuffer(buffer, it.offset);
7413
- return this.encodeFullSync({ offset: initialOffset }, buffer, emitFiltered, view);
8113
+ it.offset = initialOffset; // reuse `it` so the caller's offset stays accurate
8114
+ return this.encodeFullSync(it, buffer, emitFiltered, view, initialOffset);
7414
8115
  }
7415
8116
  return buffer.subarray(0, it.offset);
7416
8117
  }
@@ -7433,9 +8134,24 @@
7433
8134
  }
7434
8135
  encodeAllView(view, sharedOffset, it, bytes = this.sharedBuffer) {
7435
8136
  const viewOffset = it.offset;
7436
- this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
8137
+ // encodeFullSync() may reallocate the buffer on overflow — keep its
8138
+ // return, not the stale `bytes`, or the concat below reads a dead buffer.
8139
+ bytes = this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
7437
8140
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
7438
8141
  }
8142
+ /** Grow `buffer` to keep BUFFER_SIZE free bytes past `offset`, preserving `[0, offset)`. */
8143
+ ensureCapacity(buffer, offset) {
8144
+ if (offset + Encoder.BUFFER_SIZE <= buffer.byteLength) {
8145
+ return buffer;
8146
+ }
8147
+ const size = Math.ceil((offset + Encoder.BUFFER_SIZE) / Encoder.BUFFER_SIZE) * Encoder.BUFFER_SIZE;
8148
+ const grown = new Uint8Array(size);
8149
+ grown.set(buffer.subarray(0, offset));
8150
+ if (buffer === this.sharedBuffer) {
8151
+ this.sharedBuffer = grown;
8152
+ }
8153
+ return grown;
8154
+ }
7439
8155
  encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
7440
8156
  const viewOffset = it.offset;
7441
8157
  // Stream priority pass: drain up to `maxPerTick` per-view entries
@@ -7473,6 +8189,9 @@
7473
8189
  // `[$getByIndex]` lookup that runs once per change.
7474
8190
  const ref = changeTree.ref;
7475
8191
  const refTarget = changeTree.refTarget;
8192
+ // These writes are unguarded and unrecoverable (view.changes is cleared
8193
+ // below), so unlike encode() they can't re-encode on overflow — grow ahead.
8194
+ bytes = this.ensureCapacity(bytes, it.offset);
7476
8195
  bytes[it.offset++] = SWITCH_TO_STRUCTURE & 255;
7477
8196
  encode.number(bytes, ref[$refId], it);
7478
8197
  // Iterate entries directly — the inner Map gives us the (index, op)
@@ -7491,9 +8210,14 @@
7491
8210
  // (to allow re-using StateView's for multiple clients)
7492
8211
  //
7493
8212
  view.changes.clear();
7494
- // per-tick view-scoped pass: walks the same `changes` queue as the
8213
+ // Per-tick view-scoped pass: walks the same `changes` queue as the
7495
8214
  // shared pass, but `encodeChangeCb` emits only filtered fields.
7496
- this.encode(it, view, bytes, viewOffset);
8215
+ // encode() may reallocate the buffer on overflow — keep its return,
8216
+ // not the stale `bytes`. Anchor the re-encode at the current offset
8217
+ // (the default `initialOffset = it.offset`), NOT `viewOffset`: a
8218
+ // resize must not clobber the view.changes already written at
8219
+ // [viewOffset, it.offset).
8220
+ bytes = this.encode(it, view, bytes);
7497
8221
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
7498
8222
  }
7499
8223
  /**
@@ -7505,7 +8229,9 @@
7505
8229
  */
7506
8230
  encodeUnreliableView(view, sharedOffset, it, bytes = this.sharedBuffer) {
7507
8231
  const viewOffset = it.offset;
7508
- this.encodeUnreliable(it, view, bytes, viewOffset);
8232
+ // Capture the return: a resize on overflow reallocates the buffer, and
8233
+ // the concat below must read from the live one, not the stale `bytes`.
8234
+ bytes = this.encodeUnreliable(it, view, bytes, viewOffset);
7509
8235
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
7510
8236
  }
7511
8237
  /**
@@ -7731,10 +8457,6 @@
7731
8457
  }
7732
8458
  list.next = undefined;
7733
8459
  list.tail = undefined;
7734
- // End-of-tick: refIds released during this tick have now had their
7735
- // DELETEs emitted through `encode()` / `encodeView()`, so they are
7736
- // safe to recycle on the next tick.
7737
- root.refIds.flushReleases();
7738
8460
  }
7739
8461
  discardUnreliableChanges() {
7740
8462
  const list = this.root.unreliableChanges;
@@ -7788,6 +8510,8 @@
7788
8510
  this.name = "DecodingWarning";
7789
8511
  }
7790
8512
  }
8513
+ // Reused across addRef calls — saves a descriptor object per decoded ref.
8514
+ const $refIdDescriptor = { value: 0, enumerable: false, writable: true };
7791
8515
  class ReferenceTracker {
7792
8516
  //
7793
8517
  // Relation of refId => Schema structure
@@ -7808,11 +8532,13 @@
7808
8532
  // on decoded instances, which WOULD walk enumerable Symbol-keyed
7809
8533
  // properties and include `$refId` in the comparison. Keep the
7810
8534
  // descriptor dance for semantic compatibility.
7811
- Object.defineProperty(ref, $refId, {
7812
- value: refId,
7813
- enumerable: false,
7814
- writable: true
7815
- });
8535
+ if (ref[$refId] === undefined) {
8536
+ $refIdDescriptor.value = refId;
8537
+ Object.defineProperty(ref, $refId, $refIdDescriptor);
8538
+ }
8539
+ else if (ref[$refId] !== refId) {
8540
+ ref[$refId] = refId; // property exists (writable) — plain write keeps flags
8541
+ }
7816
8542
  if (incrementCount) {
7817
8543
  this.refCount[refId] = (this.refCount[refId] || 0) + 1;
7818
8544
  }
@@ -7926,6 +8652,18 @@
7926
8652
  root;
7927
8653
  currentRefId = 0;
7928
8654
  triggerChanges;
8655
+ /**
8656
+ * @internal Non-null only while a `decodeResync()` walk is in progress:
8657
+ * collection refId → entry identities the payload visited (map string
8658
+ * keys; array/set/collection/stream indexes). Written by the collection
8659
+ * DecodeOperation functions, read by the post-decode sweep.
8660
+ */
8661
+ resyncVisited = null;
8662
+ /**
8663
+ * @internal Set when a structure had to be skipped during a resync
8664
+ * decode — visited data is incomplete, so the sweep must not delete.
8665
+ */
8666
+ resyncDamaged = false;
7929
8667
  constructor(root, context) {
7930
8668
  this.setState(root);
7931
8669
  this.context = context || new TypeContext(root.constructor);
@@ -7990,6 +8728,12 @@
7990
8728
  // currently hooks it; the dual call sites stay as-is rather
7991
8729
  // than being extracted into a helper for a one-line body.
7992
8730
  ref[$onDecodeEnd]?.();
8731
+ // resync mode: prune everything the snapshot didn't visit. Runs
8732
+ // before triggerChanges (DELETE changes fire onRemove with the real
8733
+ // previousValue) and before GC (removeRef feeds deletedRefs).
8734
+ if (this.resyncVisited !== null) {
8735
+ resyncSweep(this, allChanges);
8736
+ }
7993
8737
  // trigger changes
7994
8738
  if (allChanges !== null)
7995
8739
  this.triggerChanges?.(allChanges);
@@ -7997,7 +8741,37 @@
7997
8741
  $root.garbageCollectDeletedRefs();
7998
8742
  return allChanges;
7999
8743
  }
8744
+ /**
8745
+ * Full-snapshot reconciliation ("resync") decode.
8746
+ *
8747
+ * Behaves exactly like {@link decode}, plus: every collection entry the
8748
+ * payload does NOT mention is removed through the regular DELETE path —
8749
+ * `onRemove` callbacks fire with the real previous value and released
8750
+ * refs are garbage-collected. Use it to apply a rejoin/reconnect full
8751
+ * state over an existing decoded tree: DELETEs that happened while the
8752
+ * client was off the wire are reconciled as if they had been received,
8753
+ * while surviving entries keep their instance identity and callbacks.
8754
+ *
8755
+ * ONLY valid for full-snapshot payloads (`encodeAll` / `encodeAllView`
8756
+ * output). Calling it on an incremental patch would prune everything
8757
+ * the patch doesn't touch.
8758
+ */
8759
+ decodeResync(bytes, it = { offset: 0 }) {
8760
+ this.resyncVisited = new Map();
8761
+ this.resyncDamaged = false;
8762
+ try {
8763
+ return this.decode(bytes, it);
8764
+ }
8765
+ finally {
8766
+ this.resyncVisited = null;
8767
+ }
8768
+ }
8000
8769
  skipCurrentStructure(bytes, it, totalBytes) {
8770
+ // A skipped range can swallow other structures' ops (their ADDs are
8771
+ // never applied), so resync visited data is no longer trustworthy.
8772
+ if (this.resyncVisited !== null) {
8773
+ this.resyncDamaged = true;
8774
+ }
8001
8775
  //
8002
8776
  // keep skipping next bytes until reaches a known structure
8003
8777
  // by local decoder.
@@ -8047,10 +8821,27 @@
8047
8821
  /**
8048
8822
  * Reflection
8049
8823
  */
8824
+ /**
8825
+ * `t.quantized()` field descriptor as it rides the reflection handshake —
8826
+ * schema-typed (bit-exact float64 bounds), NOT a string grammar, so every
8827
+ * language port decodes it with the schema decoder it already has.
8828
+ */
8829
+ const QuantizedDescriptor = schema({
8830
+ min: t.float64(),
8831
+ max: t.float64(),
8832
+ bits: t.uint8(),
8833
+ mode: t.uint8(), // 0 = clamp, 1 = wrap
8834
+ }, "QuantizedDescriptor");
8050
8835
  const ReflectionField = schema({
8051
8836
  name: t.string(),
8052
8837
  type: t.string(),
8053
8838
  referencedType: t.number(),
8839
+ /** Primitive child of a collection (`array`/`map`/... of "string" etc.) —
8840
+ * its own slot, replacing the legacy `"array:string"` colon packing. */
8841
+ childPrimitive: t.string(),
8842
+ /** Set only on `t.quantized()` fields (`.optional()` — no auto-instantiated
8843
+ * default; its absence is the "not quantized" signal on decode). */
8844
+ quantized: t.ref(QuantizedDescriptor).optional(),
8054
8845
  }, "ReflectionField");
8055
8846
  const ReflectionType = schema({
8056
8847
  id: t.number(),
@@ -8120,6 +8911,18 @@
8120
8911
  if (typeof (field.type) === "string") {
8121
8912
  fieldType = field.type;
8122
8913
  }
8914
+ else if (isQuantizedType(field.type)) {
8915
+ // Params ride as a schema-typed descriptor (bit-exact float64) —
8916
+ // no string grammar for the peer (or a language port) to parse.
8917
+ const d = field.type.quantized;
8918
+ fieldType = "quantized";
8919
+ const desc = new QuantizedDescriptor();
8920
+ desc.min = d.min;
8921
+ desc.max = d.max;
8922
+ desc.bits = d.bits;
8923
+ desc.mode = d.wrap ? 1 : 0;
8924
+ reflectionField.quantized = desc;
8925
+ }
8123
8926
  else {
8124
8927
  let childTypeSchema;
8125
8928
  //
@@ -8132,7 +8935,8 @@
8132
8935
  else {
8133
8936
  fieldType = Object.keys(field.type)[0];
8134
8937
  if (typeof (field.type[fieldType]) === "string") {
8135
- fieldType += ":" + field.type[fieldType]; // array:string
8938
+ // primitive child gets its own slot (was packed as "array:string")
8939
+ reflectionField.childPrimitive = field.type[fieldType];
8136
8940
  }
8137
8941
  else {
8138
8942
  childTypeSchema = field.type[fieldType];
@@ -8173,15 +8977,20 @@
8173
8977
  const addFields = (metadata, reflectionType, parentFieldIndex) => {
8174
8978
  reflectionType.fields.forEach((field, i) => {
8175
8979
  const fieldIndex = parentFieldIndex + i;
8176
- if (field.referencedType !== undefined) {
8177
- let fieldType = field.type;
8178
- let refType = typeContext.get(field.referencedType);
8179
- // map or array of primitive type (-1)
8180
- if (!refType) {
8181
- const typeInfo = field.type.split(":");
8182
- fieldType = typeInfo[0];
8183
- refType = typeInfo[1]; // string
8184
- }
8980
+ if (field.quantized !== undefined) {
8981
+ // Schema-typed descriptor → resolved codec (validation stays in
8982
+ // resolveQuantize, same as the builder path).
8983
+ const q = field.quantized;
8984
+ Metadata.addField(metadata, fieldIndex, field.name, {
8985
+ quantized: resolveQuantize({ min: q.min, max: q.max, bits: q.bits, mode: q.mode === 1 ? "wrap" : "clamp" }),
8986
+ });
8987
+ }
8988
+ else if (field.referencedType !== undefined) {
8989
+ const fieldType = field.type;
8990
+ // Schema child by type id; a primitive child (referencedType -1)
8991
+ // rides its own childPrimitive slot.
8992
+ const refType = typeContext.get(field.referencedType)
8993
+ ?? field.childPrimitive;
8185
8994
  if (fieldType === "ref") {
8186
8995
  Metadata.addField(metadata, fieldIndex, field.name, refType);
8187
8996
  }
@@ -8864,6 +9673,48 @@
8864
9673
  }
8865
9674
  };
8866
9675
 
9676
+ /**
9677
+ * Object pool for Schema instances. Exported as a **type only** — construct one
9678
+ * via {@link createPool}, which is the public entry point. The class is public
9679
+ * for typing (`SchemaPool<Entity>` annotations) and `instanceof` checks.
9680
+ */
9681
+ class SchemaPool {
9682
+ _free = [];
9683
+ _factory;
9684
+ _maxSize;
9685
+ constructor(factory, opts = {}) {
9686
+ this._factory = factory;
9687
+ this._maxSize = opts.maxSize ?? Infinity;
9688
+ const preallocate = opts.preallocate ?? 0;
9689
+ for (let i = 0; i < preallocate; i++) {
9690
+ this._free.push(factory());
9691
+ }
9692
+ }
9693
+ /** Pop a pre-reset free instance, or construct a fresh one. */
9694
+ acquire() {
9695
+ return this._free.length > 0 ? this._free.pop() : this._factory();
9696
+ }
9697
+ /**
9698
+ * Reset `instance` to construction defaults and return it to the pool.
9699
+ * PRECONDITION: the instance must already be detached from the state tree
9700
+ * (removed from its parent collection/field, so the encoder released it).
9701
+ */
9702
+ release(instance) {
9703
+ Schema.reset(instance);
9704
+ if (this._free.length < this._maxSize) {
9705
+ this._free.push(instance);
9706
+ }
9707
+ }
9708
+ /** Number of instances currently available for reuse. */
9709
+ get size() {
9710
+ return this._free.length;
9711
+ }
9712
+ }
9713
+ /** Convenience factory: `createPool(Entity, { preallocate: 64 })`. */
9714
+ function createPool(ctor, opts = {}) {
9715
+ return new SchemaPool(() => new ctor(), opts);
9716
+ }
9717
+
8867
9718
  /**
8868
9719
  * Clear the bit for `(slot, bit)` on every ChangeTree in `root`. Called
8869
9720
  * from `dispose()` and from the FinalizationRegistry callback so a view's
@@ -9066,42 +9917,66 @@
9066
9917
  // `tags: WeakMap<ChangeTree, Set<number>>` storage. Hot read site is
9067
9918
  // `Schema.ts` filter check — `hasTagOnTree` is O(1) bitwise.
9068
9919
  // ──────────────────────────────────────────────────────────────────
9069
- /** True iff this view has `tag` associated with `tree`. */
9920
+ /**
9921
+ * True iff this view shares at least one tag bit with `tree`.
9922
+ *
9923
+ * `tagViews` is keyed by individual power-of-two bits (custom tags must
9924
+ * be powers of two; `@view(A|B)` field masks are decomposed on store).
9925
+ * A field whose mask is `tag` is visible if the view was `add()`ed with
9926
+ * any overlapping bit — so we walk `tag`'s set bits and return on the
9927
+ * first match. Passing DEFAULT_VIEW_TAG (-1, all bits) answers "does
9928
+ * this view hold ANY custom tag on the tree".
9929
+ */
9070
9930
  hasTagOnTree(tree, tag) {
9071
9931
  const map = tree.tagViews;
9072
9932
  if (map === undefined)
9073
9933
  return false;
9074
- const arr = map.get(tag);
9075
9934
  const slot = this._slot;
9076
- return arr !== undefined && slot < arr.length && (arr[slot] & this._bit) !== 0;
9935
+ const bit = this._bit;
9936
+ for (let bits = tag; bits !== 0; bits &= bits - 1) {
9937
+ const arr = map.get(bits & -bits); // isolate lowest set bit
9938
+ if (arr !== undefined && slot < arr.length && (arr[slot] & bit) !== 0)
9939
+ return true;
9940
+ }
9941
+ return false;
9077
9942
  }
9078
- /** Mark `tree` as carrying `tag` for this view. */
9943
+ /** Mark `tree` as carrying `tag` (each of its bits) for this view. */
9079
9944
  addTag(tree, tag) {
9945
+ // DEFAULT_VIEW_TAG visibility lives in `visibleViews`, not here.
9946
+ if (tag === DEFAULT_VIEW_TAG)
9947
+ return;
9080
9948
  let map = tree.tagViews;
9081
9949
  if (map === undefined) {
9082
9950
  map = tree.tagViews = new Map();
9083
9951
  }
9084
- let arr = map.get(tag);
9085
- if (arr === undefined) {
9086
- arr = [];
9087
- map.set(tag, arr);
9088
- }
9089
9952
  const slot = this._slot;
9090
- while (arr.length <= slot)
9091
- arr.push(0);
9092
- arr[slot] |= this._bit;
9953
+ const bit = this._bit;
9954
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
9955
+ const b = bits & -bits; // isolate lowest set bit
9956
+ let arr = map.get(b);
9957
+ if (arr === undefined) {
9958
+ arr = [];
9959
+ map.set(b, arr);
9960
+ }
9961
+ while (arr.length <= slot)
9962
+ arr.push(0);
9963
+ arr[slot] |= bit;
9964
+ }
9093
9965
  }
9094
- /** Clear this view's `tag` bit on `tree`. */
9966
+ /** Clear each of `tag`'s bits for this view on `tree`. */
9095
9967
  removeTag(tree, tag) {
9968
+ if (tag === DEFAULT_VIEW_TAG)
9969
+ return;
9096
9970
  const map = tree.tagViews;
9097
9971
  if (map === undefined)
9098
9972
  return;
9099
- const arr = map.get(tag);
9100
- if (arr === undefined)
9101
- return;
9102
9973
  const slot = this._slot;
9103
- if (slot < arr.length)
9104
- arr[slot] &= ~this._bit;
9974
+ const clearMask = ~this._bit;
9975
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
9976
+ const arr = map.get(bits & -bits);
9977
+ if (arr !== undefined && slot < arr.length)
9978
+ arr[slot] &= clearMask;
9979
+ }
9105
9980
  }
9106
9981
  /** Clear ALL tag bits this view holds on `tree` (used when the per-tag isn't known). */
9107
9982
  removeAllTagsOnTree(tree) {
@@ -9233,10 +10108,16 @@
9233
10108
  // direct array index instead of a per-field-object hop.
9234
10109
  const tags = changeTree.encDescriptor.tags;
9235
10110
  changeTree.forEachChild((change, index) => {
9236
- // Do not ADD children that don't have the same tag
10111
+ // Do not ADD children whose field tag shares no bit with `tag`.
10112
+ // DEFAULT_VIEW_TAG fields are visible to all clients; custom-tag
10113
+ // fields only when bits overlap, and never to default-tag clients.
9237
10114
  const fieldTag = tags[index];
9238
- if (fieldTag !== undefined && fieldTag !== tag) {
9239
- return;
10115
+ if (fieldTag !== undefined) {
10116
+ const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
10117
+ (tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
10118
+ if (!tagMatch) {
10119
+ return;
10120
+ }
9240
10121
  }
9241
10122
  if (this.add(change.ref, tag, false)) {
9242
10123
  isChildAdded = true;
@@ -9245,12 +10126,19 @@
9245
10126
  // set tag
9246
10127
  if (tag !== DEFAULT_VIEW_TAG) {
9247
10128
  this.addTag(changeTree, tag);
9248
- // Ref: add tagged properties
9249
- metadata?.[$fieldIndexesByViewTag]?.[tag]?.forEach((index) => {
9250
- if (changeTree.getChange(index) !== exports.OPERATION.DELETE) {
9251
- changes.set(index, exports.OPERATION.ADD);
10129
+ // Ref: add tagged properties. `$fieldIndexesByViewTag` is keyed
10130
+ // per-bit, so a combined add-tag (`view.add(obj, A|B)`) must look
10131
+ // up each set bit to force-ADD every field that shares a bit.
10132
+ const byTag = metadata?.[$fieldIndexesByViewTag];
10133
+ if (byTag !== undefined) {
10134
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
10135
+ byTag[bits & -bits]?.forEach((index) => {
10136
+ if (changeTree.getChange(index) !== exports.OPERATION.DELETE) {
10137
+ changes.set(index, exports.OPERATION.ADD);
10138
+ }
10139
+ });
9252
10140
  }
9253
- });
10141
+ }
9254
10142
  }
9255
10143
  else if (!changeTree.isNew || isChildAdded) {
9256
10144
  // new structures will be added as part of .encode() call, no need to force it to .encodeView()
@@ -9266,7 +10154,8 @@
9266
10154
  const tagAtIndex = tags[index];
9267
10155
  if (isInvisible || // if "invisible", include all
9268
10156
  tagAtIndex === undefined || // "all change" with no tag
9269
- tagAtIndex === tag // tagged property
10157
+ tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
10158
+ (tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
9270
10159
  ) {
9271
10160
  changes.set(index, exports.OPERATION.ADD);
9272
10161
  isChildAdded = true;
@@ -9296,8 +10185,12 @@
9296
10185
  const tags = tree.encDescriptor.tags;
9297
10186
  tree.forEachChild((child, index) => {
9298
10187
  const fieldTag = tags[index];
9299
- if (fieldTag !== undefined && fieldTag !== tag)
9300
- return;
10188
+ if (fieldTag !== undefined) {
10189
+ const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
10190
+ (tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
10191
+ if (!tagMatch)
10192
+ return;
10193
+ }
9301
10194
  if (child.isNew) {
9302
10195
  this.markVisible(child);
9303
10196
  this._markSubtreeVisible(child, tag);
@@ -9313,20 +10206,25 @@
9313
10206
  if (!this.isVisible(changeTree)) {
9314
10207
  // view must have all "changeTree" parent tree
9315
10208
  this.markVisible(changeTree);
9316
- // Recurse all the way to the root regardless of whether the
9317
- // parent is filtered. Walking the full chain is what makes
9318
- // `view.changes` topologically ordered by construction any
9319
- // filtered ancestor up the chain is touched here, before the
9320
- // descendant's entry. The actual entry-write below is gated
9321
- // on `hasFilteredFields` so non-filtered ancestors don't
9322
- // emit redundant wire bytes (the decoder already knows them
9323
- // via the shared encode pass). Marking them visible is
9324
- // still useful: it makes this short-circuit fire on the next
9325
- // `view.add` instead of re-walking the chain.
9326
- const parentChangeTree = changeTree.parent?.[$changes];
9327
- if (parentChangeTree) {
9328
- this.addParentOf(changeTree, tag);
9329
- }
10209
+ }
10210
+ // Recurse all the way to the root REGARDLESS of whether this parent
10211
+ // is already visible. Walking the full chain keeps `view.changes`
10212
+ // topologically ordered by construction (ancestors touched before
10213
+ // the descendant's entry), and crucially — re-queues the ancestor
10214
+ // binding ops every time: visibility bits are per-VIEW, but the ADD
10215
+ // ops they once queued are consumed per-ENCODE. With a shared view,
10216
+ // an earlier encode (for other clients) or a dropped backlog leaves
10217
+ // an already-visible ancestor whose binding a late-attached client
10218
+ // never received its filtered-container refId would then arrive
10219
+ // unbound ("refId not found"). Re-writing the entry ops is cheap
10220
+ // (Map.set dedupes within a patch) and decodes as a no-op for
10221
+ // clients that already hold the refs. The entry-write below is
10222
+ // still gated on `hasFilteredFields` so non-filtered ancestors
10223
+ // don't emit redundant wire bytes (the decoder already knows them
10224
+ // via the shared encode pass).
10225
+ const parentChangeTree = changeTree.parent?.[$changes];
10226
+ if (parentChangeTree) {
10227
+ this.addParentOf(changeTree, tag);
9330
10228
  }
9331
10229
  // Skip the entry-write for non-filtered ancestors: their refIds
9332
10230
  // are already known to the decoder through the shared pass, and
@@ -9492,19 +10390,25 @@
9492
10390
  }
9493
10391
  }
9494
10392
  else {
9495
- // delete only tagged properties
10393
+ // delete only tagged properties. `$fieldIndexesByViewTag` is
10394
+ // keyed per-bit, so a combined tag iterates each set bit.
9496
10395
  const names = changeTree.encDescriptor.names;
9497
- metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
9498
- changes.set(index, exports.OPERATION.DELETE);
9499
- // Remove child structures from visible set
9500
- const value = changeTree.ref[names[index]];
9501
- if (value?.[$changes]) {
9502
- this.unmarkVisible(value[$changes]);
9503
- this._recursiveDeleteVisibleChangeTree(value[$changes]);
10396
+ const byTag = metadata?.[$fieldIndexesByViewTag];
10397
+ if (byTag !== undefined) {
10398
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
10399
+ byTag[bits & -bits]?.forEach((index) => {
10400
+ changes.set(index, exports.OPERATION.DELETE);
10401
+ // Remove child structures from visible set
10402
+ const value = changeTree.ref[names[index]];
10403
+ if (value?.[$changes]) {
10404
+ this.unmarkVisible(value[$changes]);
10405
+ this._recursiveDeleteVisibleChangeTree(value[$changes]);
10406
+ }
10407
+ });
9504
10408
  }
9505
- });
10409
+ }
9506
10410
  }
9507
- // remove tag bit for this view
10411
+ // remove tag bits for this view
9508
10412
  if (tag === undefined) {
9509
10413
  this.removeAllTagsOnTree(changeTree);
9510
10414
  }
@@ -9681,8 +10585,10 @@
9681
10585
  exports.$encoder = $encoder;
9682
10586
  exports.$filter = $filter;
9683
10587
  exports.$getByIndex = $getByIndex;
10588
+ exports.$numFields = $numFields;
9684
10589
  exports.$refId = $refId;
9685
10590
  exports.$track = $track;
10591
+ exports.$values = $values;
9686
10592
  exports.ArraySchema = ArraySchema;
9687
10593
  exports.Callbacks = Callbacks;
9688
10594
  exports.ChangeTree = ChangeTree;
@@ -9702,6 +10608,7 @@
9702
10608
  exports.StateView = StateView;
9703
10609
  exports.StreamSchema = StreamSchema;
9704
10610
  exports.TypeContext = TypeContext;
10611
+ exports.createPool = createPool;
9705
10612
  exports.decode = decode;
9706
10613
  exports.decodeKeyValueOperation = decodeKeyValueOperation;
9707
10614
  exports.decodeSchemaOperation = decodeSchemaOperation;
@@ -9716,6 +10623,7 @@
9716
10623
  exports.encodeSchemaOperation = encodeSchemaOperation;
9717
10624
  exports.entity = entity;
9718
10625
  exports.getDecoderStateCallbacks = getDecoderStateCallbacks;
10626
+ exports.getEncodeDescriptor = getEncodeDescriptor;
9719
10627
  exports.getRawChangesCallback = getRawChangesCallback;
9720
10628
  exports.isBuilder = isBuilder;
9721
10629
  exports.owned = owned;