@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
@@ -89,6 +89,12 @@ function ensureStructSwitch(ctx: EncodeCtx): void {
89
89
  * Module-level adapter for `forEachLiveWithCtx`. Full-sync emits every live
90
90
  * field as ADD, so we re-enter `encodeChangeCb` with that fixed op — keeps
91
91
  * the callback closure-free across the entire DFS walk.
92
+ *
93
+ * The resync sweep (decoder/Resync.ts) depends on this shape: full-sync
94
+ * output is dense plain ADDs — no DELETEs, no gap-writes (so decoding it
95
+ * never compacts arrays mid-walk), and replaced occupants arrive as plain
96
+ * ADD (the sweep's touch hook releases them). Changing full-sync emission
97
+ * means revisiting the sweep.
92
98
  */
93
99
  function encodeFullSyncCb(ctx: EncodeCtx, fieldIndex: number): void {
94
100
  encodeChangeCb(ctx, fieldIndex, OPERATION.ADD);
@@ -200,7 +206,18 @@ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
200
206
  }
201
207
 
202
208
  export class Encoder<T extends Schema = any> {
203
- static BUFFER_SIZE = 8 * 1024; // 8KB
209
+ /**
210
+ * Per-encoder shared output buffer size. The encoder auto-grows on
211
+ * overflow and logs a one-time warning suggesting a higher value, so
212
+ * the default just needs to comfortably cover typical room state.
213
+ *
214
+ * Sized to fit ~100 items in a `MapSchema<{x,y,z}>` keyed by
215
+ * `nanoid(9)` (~4.5 KB worst-case full encode, float64-heavy) with
216
+ * ~3-4× headroom for surrounding state (player list, world refs,
217
+ * etc.). Raise per app via `Encoder.BUFFER_SIZE = N * 1024` before
218
+ * constructing any Encoder.
219
+ */
220
+ static BUFFER_SIZE = 16 * 1024;
204
221
  sharedBuffer: Uint8Array = new Uint8Array(Encoder.BUFFER_SIZE);
205
222
 
206
223
  context: TypeContext;
@@ -337,7 +354,12 @@ export class Encoder<T extends Schema = any> {
337
354
 
338
355
  if (it.offset > buffer.byteLength) {
339
356
  buffer = this._resizeBuffer(buffer, it.offset);
340
- return this._encodeChannel({ offset: initialOffset }, view, buffer, initialOffset, unreliable);
357
+ // Reuse `it` (reset its offset) instead of a fresh iterator so the
358
+ // caller's `it.offset` ends at the true final offset. A fresh one
359
+ // strands `it.offset` at the overflow value and corrupts the next
360
+ // view's region in a multi-view encode.
361
+ it.offset = initialOffset;
362
+ return this._encodeChannel(it, view, buffer, initialOffset, unreliable);
341
363
  }
342
364
 
343
365
  return buffer.subarray(0, it.offset);
@@ -380,7 +402,8 @@ export class Encoder<T extends Schema = any> {
380
402
 
381
403
  if (it.offset > buffer.byteLength) {
382
404
  buffer = this._resizeBuffer(buffer, it.offset);
383
- return this.encodeFullSync({ offset: initialOffset }, buffer, emitFiltered, view);
405
+ it.offset = initialOffset; // reuse `it` so the caller's offset stays accurate
406
+ return this.encodeFullSync(it, buffer, emitFiltered, view, initialOffset);
384
407
  }
385
408
 
386
409
  return buffer.subarray(0, it.offset);
@@ -420,7 +443,9 @@ export class Encoder<T extends Schema = any> {
420
443
  ) {
421
444
  const viewOffset = it.offset;
422
445
 
423
- this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
446
+ // encodeFullSync() may reallocate the buffer on overflow — keep its
447
+ // return, not the stale `bytes`, or the concat below reads a dead buffer.
448
+ bytes = this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
424
449
 
425
450
  return concatBytes(
426
451
  bytes.subarray(0, sharedOffset),
@@ -428,6 +453,16 @@ export class Encoder<T extends Schema = any> {
428
453
  );
429
454
  }
430
455
 
456
+ /** Grow `buffer` to keep BUFFER_SIZE free bytes past `offset`, preserving `[0, offset)`. */
457
+ protected ensureCapacity(buffer: Uint8Array, offset: number): Uint8Array {
458
+ if (offset + Encoder.BUFFER_SIZE <= buffer.byteLength) { return buffer; }
459
+ const size = Math.ceil((offset + Encoder.BUFFER_SIZE) / Encoder.BUFFER_SIZE) * Encoder.BUFFER_SIZE;
460
+ const grown = new Uint8Array(size);
461
+ grown.set(buffer.subarray(0, offset));
462
+ if (buffer === this.sharedBuffer) { this.sharedBuffer = grown; }
463
+ return grown;
464
+ }
465
+
431
466
  encodeView(
432
467
  view: StateView,
433
468
  sharedOffset: number,
@@ -476,6 +511,10 @@ export class Encoder<T extends Schema = any> {
476
511
  const ref = changeTree.ref;
477
512
  const refTarget = changeTree.refTarget;
478
513
 
514
+ // These writes are unguarded and unrecoverable (view.changes is cleared
515
+ // below), so unlike encode() they can't re-encode on overflow — grow ahead.
516
+ bytes = this.ensureCapacity(bytes, it.offset);
517
+
479
518
  bytes[it.offset++] = SWITCH_TO_STRUCTURE & 255;
480
519
  encode.number(bytes, ref[$refId], it);
481
520
 
@@ -498,9 +537,14 @@ export class Encoder<T extends Schema = any> {
498
537
  //
499
538
  view.changes.clear();
500
539
 
501
- // per-tick view-scoped pass: walks the same `changes` queue as the
540
+ // Per-tick view-scoped pass: walks the same `changes` queue as the
502
541
  // shared pass, but `encodeChangeCb` emits only filtered fields.
503
- this.encode(it, view, bytes, viewOffset);
542
+ // encode() may reallocate the buffer on overflow — keep its return,
543
+ // not the stale `bytes`. Anchor the re-encode at the current offset
544
+ // (the default `initialOffset = it.offset`), NOT `viewOffset`: a
545
+ // resize must not clobber the view.changes already written at
546
+ // [viewOffset, it.offset).
547
+ bytes = this.encode(it, view, bytes);
504
548
 
505
549
  return concatBytes(
506
550
  bytes.subarray(0, sharedOffset),
@@ -523,7 +567,9 @@ export class Encoder<T extends Schema = any> {
523
567
  ) {
524
568
  const viewOffset = it.offset;
525
569
 
526
- this.encodeUnreliable(it, view, bytes, viewOffset);
570
+ // Capture the return: a resize on overflow reallocates the buffer, and
571
+ // the concat below must read from the live one, not the stale `bytes`.
572
+ bytes = this.encodeUnreliable(it, view, bytes, viewOffset);
527
573
 
528
574
  return concatBytes(
529
575
  bytes.subarray(0, sharedOffset),
@@ -763,11 +809,6 @@ export class Encoder<T extends Schema = any> {
763
809
  }
764
810
  list.next = undefined;
765
811
  list.tail = undefined;
766
-
767
- // End-of-tick: refIds released during this tick have now had their
768
- // DELETEs emitted through `encode()` / `encodeView()`, so they are
769
- // safe to recycle on the next tick.
770
- root.refIds.flushReleases();
771
812
  }
772
813
 
773
814
  discardUnreliableChanges() {
@@ -0,0 +1,90 @@
1
+ import { Schema } from "../Schema.js";
2
+
3
+ /**
4
+ * Server-side object pool for Schema instances.
5
+ *
6
+ * Constructing a Schema is comparatively expensive (per-instance ChangeTree +
7
+ * `Object.defineProperty($changes)` + values array, multiplied by the number
8
+ * of instances in the entity tree). For workloads that spawn/despawn entities
9
+ * (ECS, matchmaking bots, projectiles), reusing instances avoids that cost.
10
+ *
11
+ * The pool relies on {@link Schema.reset} to return a detached instance to a
12
+ * pristine, construction-default state — including dropping its `$refId` so a
13
+ * re-add re-acquires a fresh id (making reuse wire-format-identical to `new`).
14
+ *
15
+ * Lifecycle:
16
+ * ```ts
17
+ * const pool = createPool(Entity, { preallocate: 1000 });
18
+ *
19
+ * // spawn
20
+ * const e = pool.acquire();
21
+ * e.x = 10; e.y = 20; // re-assign fields (pool does NOT reset primitives)
22
+ * state.entities.set(id, e);
23
+ *
24
+ * // despawn — remove from the state tree FIRST, then release
25
+ * state.entities.delete(id);
26
+ * pool.release(e);
27
+ * ```
28
+ *
29
+ * Not supported (throws on release): instances shared across multiple parents,
30
+ * `@stream` collections, and decoder-side (mirror) instances.
31
+ */
32
+ export interface PoolOptions {
33
+ /** Construct this many instances up front, so the first spawns reuse rather than allocate. */
34
+ preallocate?: number;
35
+ /**
36
+ * Cap on retained free instances. Releases beyond the cap are dropped (and
37
+ * garbage-collected), keeping a long-running pool bounded. Default: no cap.
38
+ */
39
+ maxSize?: number;
40
+ }
41
+
42
+ /**
43
+ * Object pool for Schema instances. Exported as a **type only** — construct one
44
+ * via {@link createPool}, which is the public entry point. The class is public
45
+ * for typing (`SchemaPool<Entity>` annotations) and `instanceof` checks.
46
+ */
47
+ export class SchemaPool<T extends Schema> {
48
+ private readonly _free: T[] = [];
49
+ private readonly _factory: () => T;
50
+ private readonly _maxSize: number;
51
+
52
+ constructor(factory: () => T, opts: PoolOptions = {}) {
53
+ this._factory = factory;
54
+ this._maxSize = opts.maxSize ?? Infinity;
55
+ const preallocate = opts.preallocate ?? 0;
56
+ for (let i = 0; i < preallocate; i++) {
57
+ this._free.push(factory());
58
+ }
59
+ }
60
+
61
+ /** Pop a pre-reset free instance, or construct a fresh one. */
62
+ acquire(): T {
63
+ return this._free.length > 0 ? this._free.pop()! : this._factory();
64
+ }
65
+
66
+ /**
67
+ * Reset `instance` to construction defaults and return it to the pool.
68
+ * PRECONDITION: the instance must already be detached from the state tree
69
+ * (removed from its parent collection/field, so the encoder released it).
70
+ */
71
+ release(instance: T): void {
72
+ Schema.reset(instance);
73
+ if (this._free.length < this._maxSize) {
74
+ this._free.push(instance);
75
+ }
76
+ }
77
+
78
+ /** Number of instances currently available for reuse. */
79
+ get size(): number {
80
+ return this._free.length;
81
+ }
82
+ }
83
+
84
+ /** Convenience factory: `createPool(Entity, { preallocate: 64 })`. */
85
+ export function createPool<T extends Schema>(
86
+ ctor: new () => T,
87
+ opts: PoolOptions = {},
88
+ ): SchemaPool<T> {
89
+ return new SchemaPool<T>(() => new ctor(), opts);
90
+ }
@@ -2,7 +2,6 @@ import { OPERATION } from "../encoding/spec.js";
2
2
  import { TypeContext } from "../types/TypeContext.js";
3
3
  import { ChangeTree, ChangeTreeList, createChangeTreeList, type ChangeTreeNode } from "./ChangeTree.js";
4
4
  import { $changes, $refId } from "../types/symbols.js";
5
- import { RefIdAllocator } from "./RefIdAllocator.js";
6
5
  import type { StateView } from "./StateView.js";
7
6
  import type { StreamSchema } from "../types/custom/StreamSchema.js";
8
7
  import type { StreamableState } from "./streaming.js";
@@ -23,12 +22,18 @@ export interface Streamable {
23
22
  _unregister(): void;
24
23
  }
25
24
 
25
+ // Reused across Root.add calls — defineProperty is unavoidable ($refId must
26
+ // stay non-enumerable for deepStrictEqual) but the descriptor literal isn't.
27
+ const $refIdDescriptor = { value: 0, enumerable: false, writable: true };
28
+
26
29
  export class Root {
27
30
  /**
28
- * Allocates and recycles refIds. See `RefIdAllocator` for the reuse
29
- * pool semantics (one-tick defer + resurrection).
31
+ * Monotonic refId counter. RefIds are never recycled — a refId is a
32
+ * stable identity for the lifetime of the room, so a client that
33
+ * missed DELETEs (reconnect) can never see an old id rebound to a
34
+ * different instance. DevMode reads/writes this across HMR cycles.
30
35
  */
31
- public readonly refIds: RefIdAllocator;
36
+ protected nextUniqueId: number = 0;
32
37
 
33
38
  refCount: {[id: number]: number} = {};
34
39
  changeTrees: {[refId: number]: ChangeTree} = {};
@@ -134,7 +139,7 @@ export class Root {
134
139
  }
135
140
 
136
141
  constructor(public types: TypeContext, startRefId: number = 0) {
137
- this.refIds = new RefIdAllocator(startRefId);
142
+ this.nextUniqueId = startRefId;
138
143
  }
139
144
 
140
145
  add(changeTree: ChangeTree) {
@@ -145,11 +150,8 @@ export class Root {
145
150
  // *enumerable* own Symbols, so we keep defineProperty(enumerable:false)
146
151
  // to keep $refId hidden from deep-equal comparisons in tests.
147
152
  if (ref[$refId] === undefined) {
148
- Object.defineProperty(ref, $refId, {
149
- value: this.refIds.acquire(),
150
- enumerable: false,
151
- writable: true
152
- });
153
+ $refIdDescriptor.value = this.nextUniqueId++;
154
+ Object.defineProperty(ref, $refId, $refIdDescriptor);
153
155
  }
154
156
 
155
157
  const refId = ref[$refId];
@@ -157,21 +159,19 @@ export class Root {
157
159
  const isNewChangeTree = (this.changeTrees[refId] === undefined);
158
160
  if (isNewChangeTree) { this.changeTrees[refId] = changeTree; }
159
161
 
160
- // Resurrection path: a ref whose refId is still queued for reuse
161
- // is being re-added. Pull the refId out of the pool before it gets
162
- // handed out to someone else.
163
- if (this.refIds.isPooled(refId)) {
164
- this.refIds.reclaim(refId);
165
- }
166
-
167
162
  const previousRefCount = this.refCount[refId];
168
- if (previousRefCount === 0) {
163
+ if (previousRefCount === 0 || changeTree.needsRestage) {
169
164
  //
170
- // When a ChangeTree is re-added, it means that it was previously
171
- // removed. Re-stage every currently-populated non-transient index
172
- // as a fresh ADD in the matching dirty bucket so the next encode
173
- // re-emits it on the correct channel.
165
+ // Re-stage every currently-populated non-transient index as a
166
+ // fresh ADD in the matching dirty bucket so the next encode
167
+ // re-emits it on the correct channel. Two triggers:
168
+ // - refCount 0: a previously-removed tree re-added under the
169
+ // same refId (its ops were consumed by an earlier encode).
170
+ // - NEEDS_RESTAGE: a `Schema.reset` instance re-entering under
171
+ // a fresh refId (reset cleared the buckets; its retained
172
+ // values would otherwise never be encoded).
174
173
  //
174
+ changeTree.needsRestage = false;
175
175
  changeTree.forEachLive((fieldIndex) => {
176
176
  if (changeTree.isFieldUnreliable(fieldIndex)) {
177
177
  changeTree.ensureUnreliableRecorder().record(fieldIndex, OPERATION.ADD);
@@ -211,16 +211,6 @@ export class Root {
211
211
 
212
212
  this.refCount[refId] = 0;
213
213
 
214
- // Return refId to the reuse pool (deferred to end-of-tick via
215
- // the allocator's pending set). Stream collections are excluded
216
- // because their per-view delivery bookkeeping is harder to
217
- // audit for reuse safety and the savings there are negligible.
218
- // If the ref is later resurrected, `add()` evicts the refId
219
- // from the pool before it's handed to another instance.
220
- if (!changeTree.isStreamCollection) {
221
- this.refIds.release(refId);
222
- }
223
-
224
214
  changeTree.forEachChild((child, _) => {
225
215
  if (child.removeParent(changeTree.ref)) {
226
216
  if ((
@@ -224,39 +224,61 @@ export class StateView {
224
224
  // `Schema.ts` filter check — `hasTagOnTree` is O(1) bitwise.
225
225
  // ──────────────────────────────────────────────────────────────────
226
226
 
227
- /** True iff this view has `tag` associated with `tree`. */
227
+ /**
228
+ * True iff this view shares at least one tag bit with `tree`.
229
+ *
230
+ * `tagViews` is keyed by individual power-of-two bits (custom tags must
231
+ * be powers of two; `@view(A|B)` field masks are decomposed on store).
232
+ * A field whose mask is `tag` is visible if the view was `add()`ed with
233
+ * any overlapping bit — so we walk `tag`'s set bits and return on the
234
+ * first match. Passing DEFAULT_VIEW_TAG (-1, all bits) answers "does
235
+ * this view hold ANY custom tag on the tree".
236
+ */
228
237
  public hasTagOnTree(tree: ChangeTree, tag: number): boolean {
229
238
  const map = tree.tagViews;
230
239
  if (map === undefined) return false;
231
- const arr = map.get(tag);
232
240
  const slot = this._slot;
233
- return arr !== undefined && slot < arr.length && (arr[slot] & this._bit) !== 0;
241
+ const bit = this._bit;
242
+ for (let bits = tag; bits !== 0; bits &= bits - 1) {
243
+ const arr = map.get(bits & -bits); // isolate lowest set bit
244
+ if (arr !== undefined && slot < arr.length && (arr[slot] & bit) !== 0) return true;
245
+ }
246
+ return false;
234
247
  }
235
248
 
236
- /** Mark `tree` as carrying `tag` for this view. */
249
+ /** Mark `tree` as carrying `tag` (each of its bits) for this view. */
237
250
  public addTag(tree: ChangeTree, tag: number): void {
251
+ // DEFAULT_VIEW_TAG visibility lives in `visibleViews`, not here.
252
+ if (tag === DEFAULT_VIEW_TAG) return;
238
253
  let map = tree.tagViews;
239
254
  if (map === undefined) {
240
255
  map = tree.tagViews = new Map();
241
256
  }
242
- let arr = map.get(tag);
243
- if (arr === undefined) {
244
- arr = [];
245
- map.set(tag, arr);
246
- }
247
257
  const slot = this._slot;
248
- while (arr.length <= slot) arr.push(0);
249
- arr[slot] |= this._bit;
258
+ const bit = this._bit;
259
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
260
+ const b = bits & -bits; // isolate lowest set bit
261
+ let arr = map.get(b);
262
+ if (arr === undefined) {
263
+ arr = [];
264
+ map.set(b, arr);
265
+ }
266
+ while (arr.length <= slot) arr.push(0);
267
+ arr[slot] |= bit;
268
+ }
250
269
  }
251
270
 
252
- /** Clear this view's `tag` bit on `tree`. */
271
+ /** Clear each of `tag`'s bits for this view on `tree`. */
253
272
  public removeTag(tree: ChangeTree, tag: number): void {
273
+ if (tag === DEFAULT_VIEW_TAG) return;
254
274
  const map = tree.tagViews;
255
275
  if (map === undefined) return;
256
- const arr = map.get(tag);
257
- if (arr === undefined) return;
258
276
  const slot = this._slot;
259
- if (slot < arr.length) arr[slot] &= ~this._bit;
277
+ const clearMask = ~this._bit;
278
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
279
+ const arr = map.get(bits & -bits);
280
+ if (arr !== undefined && slot < arr.length) arr[slot] &= clearMask;
281
+ }
260
282
  }
261
283
 
262
284
  /** Clear ALL tag bits this view holds on `tree` (used when the per-tag isn't known). */
@@ -409,10 +431,16 @@ export class StateView {
409
431
  // direct array index instead of a per-field-object hop.
410
432
  const tags = changeTree.encDescriptor.tags;
411
433
  changeTree.forEachChild((change, index) => {
412
- // Do not ADD children that don't have the same tag
434
+ // Do not ADD children whose field tag shares no bit with `tag`.
435
+ // DEFAULT_VIEW_TAG fields are visible to all clients; custom-tag
436
+ // fields only when bits overlap, and never to default-tag clients.
413
437
  const fieldTag = tags[index];
414
- if (fieldTag !== undefined && fieldTag !== tag) {
415
- return;
438
+ if (fieldTag !== undefined) {
439
+ const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
440
+ (tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
441
+ if (!tagMatch) {
442
+ return;
443
+ }
416
444
  }
417
445
 
418
446
  if (this.add(change.ref, tag, false)) {
@@ -424,12 +452,19 @@ export class StateView {
424
452
  if (tag !== DEFAULT_VIEW_TAG) {
425
453
  this.addTag(changeTree, tag);
426
454
 
427
- // Ref: add tagged properties
428
- metadata?.[$fieldIndexesByViewTag]?.[tag]?.forEach((index) => {
429
- if (changeTree.getChange(index) !== OPERATION.DELETE) {
430
- changes.set(index, OPERATION.ADD);
455
+ // Ref: add tagged properties. `$fieldIndexesByViewTag` is keyed
456
+ // per-bit, so a combined add-tag (`view.add(obj, A|B)`) must look
457
+ // up each set bit to force-ADD every field that shares a bit.
458
+ const byTag = metadata?.[$fieldIndexesByViewTag];
459
+ if (byTag !== undefined) {
460
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
461
+ byTag[bits & -bits]?.forEach((index) => {
462
+ if (changeTree.getChange(index) !== OPERATION.DELETE) {
463
+ changes.set(index, OPERATION.ADD);
464
+ }
465
+ });
431
466
  }
432
- });
467
+ }
433
468
 
434
469
  } else if (!changeTree.isNew || isChildAdded) {
435
470
  // new structures will be added as part of .encode() call, no need to force it to .encodeView()
@@ -447,7 +482,8 @@ export class StateView {
447
482
  if (
448
483
  isInvisible || // if "invisible", include all
449
484
  tagAtIndex === undefined || // "all change" with no tag
450
- tagAtIndex === tag // tagged property
485
+ tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
486
+ (tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
451
487
  ) {
452
488
  changes.set(index, OPERATION.ADD);
453
489
  isChildAdded = true;
@@ -479,7 +515,11 @@ export class StateView {
479
515
  const tags = tree.encDescriptor.tags;
480
516
  tree.forEachChild((child, index) => {
481
517
  const fieldTag = tags[index];
482
- if (fieldTag !== undefined && fieldTag !== tag) return;
518
+ if (fieldTag !== undefined) {
519
+ const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
520
+ (tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
521
+ if (!tagMatch) return;
522
+ }
483
523
 
484
524
  if (child.isNew) {
485
525
  this.markVisible(child);
@@ -497,21 +537,26 @@ export class StateView {
497
537
  if (!this.isVisible(changeTree)) {
498
538
  // view must have all "changeTree" parent tree
499
539
  this.markVisible(changeTree);
540
+ }
500
541
 
501
- // Recurse all the way to the root regardless of whether the
502
- // parent is filtered. Walking the full chain is what makes
503
- // `view.changes` topologically ordered by construction any
504
- // filtered ancestor up the chain is touched here, before the
505
- // descendant's entry. The actual entry-write below is gated
506
- // on `hasFilteredFields` so non-filtered ancestors don't
507
- // emit redundant wire bytes (the decoder already knows them
508
- // via the shared encode pass). Marking them visible is
509
- // still useful: it makes this short-circuit fire on the next
510
- // `view.add` instead of re-walking the chain.
511
- const parentChangeTree: ChangeTree = changeTree.parent?.[$changes];
512
- if (parentChangeTree) {
513
- this.addParentOf(changeTree, tag);
514
- }
542
+ // Recurse all the way to the root REGARDLESS of whether this parent
543
+ // is already visible. Walking the full chain keeps `view.changes`
544
+ // topologically ordered by construction (ancestors touched before
545
+ // the descendant's entry), and crucially re-queues the ancestor
546
+ // binding ops every time: visibility bits are per-VIEW, but the ADD
547
+ // ops they once queued are consumed per-ENCODE. With a shared view,
548
+ // an earlier encode (for other clients) or a dropped backlog leaves
549
+ // an already-visible ancestor whose binding a late-attached client
550
+ // never received its filtered-container refId would then arrive
551
+ // unbound ("refId not found"). Re-writing the entry ops is cheap
552
+ // (Map.set dedupes within a patch) and decodes as a no-op for
553
+ // clients that already hold the refs. The entry-write below is
554
+ // still gated on `hasFilteredFields` so non-filtered ancestors
555
+ // don't emit redundant wire bytes (the decoder already knows them
556
+ // via the shared encode pass).
557
+ const parentChangeTree: ChangeTree = changeTree.parent?.[$changes];
558
+ if (parentChangeTree) {
559
+ this.addParentOf(changeTree, tag);
515
560
  }
516
561
 
517
562
  // Skip the entry-write for non-filtered ancestors: their refIds
@@ -702,21 +747,27 @@ export class StateView {
702
747
  }
703
748
 
704
749
  } else {
705
- // delete only tagged properties
750
+ // delete only tagged properties. `$fieldIndexesByViewTag` is
751
+ // keyed per-bit, so a combined tag iterates each set bit.
706
752
  const names = changeTree.encDescriptor.names;
707
- metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
708
- changes.set(index, OPERATION.DELETE);
709
-
710
- // Remove child structures from visible set
711
- const value = changeTree.ref[names[index] as keyof Ref];
712
- if (value?.[$changes]) {
713
- this.unmarkVisible(value[$changes]);
714
- this._recursiveDeleteVisibleChangeTree(value[$changes]);
753
+ const byTag = metadata?.[$fieldIndexesByViewTag];
754
+ if (byTag !== undefined) {
755
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
756
+ byTag[bits & -bits]?.forEach((index) => {
757
+ changes.set(index, OPERATION.DELETE);
758
+
759
+ // Remove child structures from visible set
760
+ const value = changeTree.ref[names[index] as keyof Ref];
761
+ if (value?.[$changes]) {
762
+ this.unmarkVisible(value[$changes]);
763
+ this._recursiveDeleteVisibleChangeTree(value[$changes]);
764
+ }
765
+ });
715
766
  }
716
- });
767
+ }
717
768
  }
718
769
 
719
- // remove tag bit for this view
770
+ // remove tag bits for this view
720
771
  if (tag === undefined) {
721
772
  this.removeAllTagsOnTree(changeTree);
722
773
  } else {
@@ -30,6 +30,8 @@ export function forEachLiveWithCtx<C>(
30
30
 
31
31
  if (ref[$childType] !== undefined) {
32
32
  // Collection inheriting @transient from parent field: skip entirely.
33
+ // The resync sweep (decoder/Resync.ts) relies on this: a collection
34
+ // absent from full-sync output is never pruned client-side.
33
35
  if (tree.isTransient) return;
34
36
 
35
37
  // Collection types: dispatch by shape.
@@ -54,6 +56,8 @@ export function forEachLiveWithCtx<C>(
54
56
  // Schema: walk declared fields. `null` is treated as absent —
55
57
  // the setter records a DELETE when a field is set to null or
56
58
  // undefined, so it should not appear in full-sync output.
59
+ // (@transient skips below matter to the resync sweep — see
60
+ // decoder/Resync.ts: absent-from-payload means never pruned.)
57
61
  //
58
62
  // Read names from the per-class descriptor's parallel array —
59
63
  // saves the `metadata[i]` (per-field obj) + `.name` chain on
@@ -4,7 +4,6 @@
4
4
  * goes through here, which is why the recursive walk uses a hoisted
5
5
  * callback + ctx-pool instead of per-call closures.
6
6
  */
7
- import type { MapSchema } from "../../types/custom/MapSchema.js";
8
7
  import { $changes, $childType, $refTypeFieldIndexes } from "../../types/symbols.js";
9
8
  import { Root } from "../Root.js";
10
9
  import type { ChangeTree, Ref } from "../ChangeTree.js";
@@ -81,26 +80,11 @@ export function forEachChild(
81
80
  tree: ChangeTree,
82
81
  callback: (change: ChangeTree, at: any) => void,
83
82
  ): void {
84
- //
85
- // assign same parent on child structures
86
- //
87
- if ((tree.ref as any)[$childType]) {
88
- if (typeof ((tree.ref as any)[$childType]) !== "string") {
89
- // MapSchema / ArraySchema, etc.
90
- for (const [key, value] of (tree.ref as MapSchema).entries()) {
91
- if (!value) { continue; } // sparse arrays can have undefined values
92
- callback(value[$changes], (tree.ref as any)._collectionIndexes?.[key] ?? key);
93
- };
94
- }
83
+ forEachChildWithCtx(tree, callback, _forEachChildTrampoline);
84
+ }
95
85
 
96
- } else {
97
- const names = tree.encDescriptor.names;
98
- for (const index of tree.metadata?.[$refTypeFieldIndexes] ?? []) {
99
- const value = tree.ref[names[index] as keyof Ref];
100
- if (!value) { continue; }
101
- callback(value[$changes], index);
102
- }
103
- }
86
+ function _forEachChildTrampoline(cb: (change: ChangeTree, at: any) => void, change: ChangeTree, at: any): void {
87
+ cb(change, at);
104
88
  }
105
89
 
106
90
  /**
@@ -124,10 +108,30 @@ export function forEachChildWithCtx<C>(
124
108
  const ref = tree.refTarget as any;
125
109
  if (ref[$childType]) {
126
110
  if (typeof ref[$childType] !== "string") {
127
- const collectionIndexes = ref._collectionIndexes;
128
- for (const [key, value] of (ref as MapSchema).entries()) {
129
- if (!value) { continue; }
130
- callback(ctx, value[$changes], collectionIndexes?.[key] ?? key);
111
+ const items = ref.items;
112
+ if (items !== undefined) {
113
+ // ArraySchema (raw target): dense index loop — the previous
114
+ // `for..of entries()` allocated an iterator + a [key, value]
115
+ // pair array per child (top-10 allocation site in the
116
+ // stateview and deep-nested heap profiles).
117
+ for (let i = 0, len = items.length; i < len; i++) {
118
+ const value = items[i];
119
+ if (!value) { continue; } // sparse arrays can have undefined values
120
+ callback(ctx, value[$changes], i);
121
+ }
122
+ } else {
123
+ // Map-backed collections (MapSchema/SetSchema/CollectionSchema/
124
+ // StreamSchema all store `$items: Map`): keys() loop skips the
125
+ // per-child [key, value] pair arrays of entries(), with no
126
+ // closure either (a forEach closure showed up as a GC
127
+ // regression on the construct bench).
128
+ const $items = ref.$items as Map<any, any>;
129
+ const collectionIndexes = ref._collectionIndexes;
130
+ for (const key of $items.keys()) {
131
+ const value = $items.get(key);
132
+ if (!value) { continue; }
133
+ callback(ctx, value[$changes], collectionIndexes?.[key] ?? key);
134
+ }
131
135
  }
132
136
  }
133
137
  } else {