@colyseus/schema 4.0.20 → 5.0.0

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 (96) hide show
  1. package/README.md +2 -0
  2. package/build/Metadata.d.ts +55 -2
  3. package/build/Reflection.d.ts +24 -30
  4. package/build/Schema.d.ts +70 -9
  5. package/build/annotations.d.ts +56 -13
  6. package/build/codegen/cli.cjs +84 -67
  7. package/build/codegen/cli.cjs.map +1 -1
  8. package/build/decoder/DecodeOperation.d.ts +48 -5
  9. package/build/decoder/Decoder.d.ts +2 -2
  10. package/build/decoder/strategy/Callbacks.d.ts +1 -1
  11. package/build/encoder/ChangeRecorder.d.ts +107 -0
  12. package/build/encoder/ChangeTree.d.ts +218 -69
  13. package/build/encoder/EncodeDescriptor.d.ts +63 -0
  14. package/build/encoder/EncodeOperation.d.ts +25 -2
  15. package/build/encoder/Encoder.d.ts +59 -3
  16. package/build/encoder/MapJournal.d.ts +62 -0
  17. package/build/encoder/RefIdAllocator.d.ts +35 -0
  18. package/build/encoder/Root.d.ts +94 -13
  19. package/build/encoder/StateView.d.ts +116 -8
  20. package/build/encoder/changeTree/inheritedFlags.d.ts +34 -0
  21. package/build/encoder/changeTree/liveIteration.d.ts +3 -0
  22. package/build/encoder/changeTree/parentChain.d.ts +24 -0
  23. package/build/encoder/changeTree/treeAttachment.d.ts +13 -0
  24. package/build/encoder/streaming.d.ts +73 -0
  25. package/build/encoder/subscriptions.d.ts +25 -0
  26. package/build/index.cjs +5202 -1552
  27. package/build/index.cjs.map +1 -1
  28. package/build/index.d.ts +7 -3
  29. package/build/index.js +5202 -1552
  30. package/build/index.mjs +5193 -1552
  31. package/build/index.mjs.map +1 -1
  32. package/build/input/InputDecoder.d.ts +32 -0
  33. package/build/input/InputEncoder.d.ts +117 -0
  34. package/build/input/index.cjs +7429 -0
  35. package/build/input/index.cjs.map +1 -0
  36. package/build/input/index.d.ts +3 -0
  37. package/build/input/index.mjs +7426 -0
  38. package/build/input/index.mjs.map +1 -0
  39. package/build/types/HelperTypes.d.ts +22 -8
  40. package/build/types/TypeContext.d.ts +9 -0
  41. package/build/types/builder.d.ts +162 -0
  42. package/build/types/custom/ArraySchema.d.ts +25 -4
  43. package/build/types/custom/CollectionSchema.d.ts +30 -2
  44. package/build/types/custom/MapSchema.d.ts +52 -3
  45. package/build/types/custom/SetSchema.d.ts +32 -2
  46. package/build/types/custom/StreamSchema.d.ts +114 -0
  47. package/build/types/symbols.d.ts +48 -5
  48. package/package.json +9 -3
  49. package/src/Metadata.ts +258 -31
  50. package/src/Reflection.ts +15 -13
  51. package/src/Schema.ts +176 -134
  52. package/src/annotations.ts +308 -236
  53. package/src/bench_bloat.ts +173 -0
  54. package/src/bench_decode.ts +221 -0
  55. package/src/bench_decode_mem.ts +165 -0
  56. package/src/bench_encode.ts +108 -0
  57. package/src/bench_init.ts +150 -0
  58. package/src/bench_static.ts +109 -0
  59. package/src/bench_stream.ts +295 -0
  60. package/src/bench_view_cmp.ts +142 -0
  61. package/src/codegen/languages/csharp.ts +0 -24
  62. package/src/codegen/parser.ts +83 -61
  63. package/src/decoder/DecodeOperation.ts +168 -63
  64. package/src/decoder/Decoder.ts +20 -10
  65. package/src/decoder/ReferenceTracker.ts +4 -0
  66. package/src/decoder/strategy/Callbacks.ts +30 -26
  67. package/src/decoder/strategy/getDecoderStateCallbacks.ts +16 -13
  68. package/src/encoder/ChangeRecorder.ts +276 -0
  69. package/src/encoder/ChangeTree.ts +674 -519
  70. package/src/encoder/EncodeDescriptor.ts +213 -0
  71. package/src/encoder/EncodeOperation.ts +107 -65
  72. package/src/encoder/Encoder.ts +630 -119
  73. package/src/encoder/MapJournal.ts +124 -0
  74. package/src/encoder/RefIdAllocator.ts +68 -0
  75. package/src/encoder/Root.ts +247 -120
  76. package/src/encoder/StateView.ts +592 -121
  77. package/src/encoder/changeTree/inheritedFlags.ts +217 -0
  78. package/src/encoder/changeTree/liveIteration.ts +74 -0
  79. package/src/encoder/changeTree/parentChain.ts +131 -0
  80. package/src/encoder/changeTree/treeAttachment.ts +171 -0
  81. package/src/encoder/streaming.ts +232 -0
  82. package/src/encoder/subscriptions.ts +71 -0
  83. package/src/index.ts +15 -3
  84. package/src/input/InputDecoder.ts +57 -0
  85. package/src/input/InputEncoder.ts +303 -0
  86. package/src/input/index.ts +3 -0
  87. package/src/types/HelperTypes.ts +21 -9
  88. package/src/types/TypeContext.ts +14 -2
  89. package/src/types/builder.ts +285 -0
  90. package/src/types/custom/ArraySchema.ts +210 -197
  91. package/src/types/custom/CollectionSchema.ts +115 -35
  92. package/src/types/custom/MapSchema.ts +162 -58
  93. package/src/types/custom/SetSchema.ts +128 -39
  94. package/src/types/custom/StreamSchema.ts +310 -0
  95. package/src/types/symbols.ts +54 -6
  96. package/src/utils.ts +4 -6
@@ -1,15 +1,58 @@
1
- import { ChangeTree, IndexedOperations, Ref } from "./ChangeTree.js";
1
+ import { ChangeTree, Ref } from "./ChangeTree.js";
2
2
  import { $changes, $fieldIndexesByViewTag, $refId, $viewFieldIndexes } from "../types/symbols.js";
3
3
  import { DEFAULT_VIEW_TAG } from "../annotations.js";
4
4
  import { OPERATION } from "../encoding/spec.js";
5
5
  import { Metadata } from "../Metadata.js";
6
6
  import { spliceOne } from "../types/utils.js";
7
+ import { streamDequeueForView, streamEnqueueForView } from "./streaming.js";
7
8
  import type { Schema } from "../Schema.js";
9
+ import type { Root, Streamable } from "./Root.js";
8
10
 
9
11
  export function createView(iterable: boolean = false) {
10
12
  return new StateView(iterable);
11
13
  }
12
14
 
15
+ /**
16
+ * Clear the bit for `(slot, bit)` on every ChangeTree in `root`. Called
17
+ * from `dispose()` and from the FinalizationRegistry callback so a view's
18
+ * leftover visibility bits don't leak to whoever next acquires its ID.
19
+ *
20
+ * Cost: O(N trees) per dispose. dispose is rare (once per view lifecycle,
21
+ * typically once per client disconnect), so the per-tick encode hot path
22
+ * is unaffected.
23
+ */
24
+ function _clearViewBitFromAllTrees(root: Root, slot: number, bit: number): void {
25
+ const clearMask = ~bit;
26
+ const trees = root.changeTrees;
27
+ for (const refId in trees) {
28
+ const tree = trees[refId];
29
+ const v = tree.visibleViews;
30
+ if (v !== undefined && slot < v.length) v[slot] &= clearMask;
31
+ const i = tree.invisibleViews;
32
+ if (i !== undefined && slot < i.length) i[slot] &= clearMask;
33
+ const s = tree.subscribedViews;
34
+ if (s !== undefined && slot < s.length) s[slot] &= clearMask;
35
+ const t = tree.tagViews;
36
+ if (t !== undefined) {
37
+ t.forEach((bitmap) => {
38
+ if (slot < bitmap.length) bitmap[slot] &= clearMask;
39
+ });
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * `FinalizationRegistry` returns a view's ID to its Root's freelist AND
46
+ * clears the view's leftover bits from every ChangeTree. Backstop for
47
+ * forgotten `view.dispose()` calls; timing is non-deterministic but bounded.
48
+ */
49
+ const _disposeRegistry = new FinalizationRegistry<{ root: Root; id: number; slot: number; bit: number }>(
50
+ ({ root, id, slot, bit }) => {
51
+ _clearViewBitFromAllTrees(root, slot, bit);
52
+ root.releaseViewId(id);
53
+ },
54
+ );
55
+
13
56
  export class StateView {
14
57
  /**
15
58
  * Iterable list of items that are visible to this view
@@ -18,22 +61,34 @@ export class StateView {
18
61
  items: Ref[];
19
62
 
20
63
  /**
21
- * List of ChangeTree's that are visible to this view
64
+ * Unique ID assigned by the Root that owns this view's encoder. Used
65
+ * to address per-StateView visibility bits stored on each ChangeTree.
66
+ * Lazily allocated on first `add()` because the StateView itself
67
+ * doesn't know its Root until then.
22
68
  */
23
- visible: WeakSet<ChangeTree> = new WeakSet<ChangeTree>();
69
+ id: number = -1;
70
+ private _root?: Root;
71
+
72
+ /** Cached `id >> 5` and `1 << (id & 31)` for the hot encode-loop check. */
73
+ private _slot: number = 0;
74
+ private _bit: number = 0;
24
75
 
25
76
  /**
26
- * List of ChangeTree's that are invisible to this view
77
+ * Per-tree custom-tag membership lives on each ChangeTree's `tagViews`
78
+ * map (keyed by tag, value is a per-view bitmap). The StateView only
79
+ * needs its slot/bit pair to read/write it. Replaces the legacy
80
+ * `tags: WeakMap<ChangeTree, Set<number>>` allocation per (view, tree).
27
81
  */
28
- invisible: WeakSet<ChangeTree> = new WeakSet<ChangeTree>();
29
-
30
- tags?: WeakMap<ChangeTree, Set<number>>; // TODO: use bit manipulation instead of Set<number> ()
31
82
 
32
83
  /**
33
84
  * Manual "ADD" operations for changes per ChangeTree, specific to this view.
34
- * (This is used to force encoding a property, even if it was not changed)
85
+ * (Used to force encoding a property even if it was not changed.)
86
+ *
87
+ * Inner storage is a Map so the encode loop in `encodeView` can iterate
88
+ * directly with numeric keys — the legacy `{[index]: OPERATION}` shape
89
+ * forced an `Object.keys(...)` allocation + `Number(key)` parse per ref.
35
90
  */
36
- changes = new Map<number, IndexedOperations>();
91
+ changes = new Map<number, Map<number, OPERATION>>();
37
92
 
38
93
  constructor(public iterable: boolean = false) {
39
94
  if (iterable) {
@@ -41,8 +96,197 @@ export class StateView {
41
96
  }
42
97
  }
43
98
 
99
+ /**
100
+ * Lazily bind this view to a Root and acquire a view ID. Called on
101
+ * the first add() because StateView is constructed before its target
102
+ * Root is known.
103
+ */
104
+ private _bindRoot(root: Root): void {
105
+ if (this._root !== undefined) return;
106
+ this._root = root;
107
+ this.id = root.acquireViewId();
108
+ this._slot = this.id >> 5;
109
+ this._bit = 1 << (this.id & 31);
110
+ root.registerView(this);
111
+ _disposeRegistry.register(
112
+ this,
113
+ { root, id: this.id, slot: this._slot, bit: this._bit },
114
+ this,
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Release this view's ID back to the Root for reuse, AND clear all
120
+ * visibility bits this view set on any ChangeTree. The clear is
121
+ * essential — without it, a future view that acquires this same ID
122
+ * would inherit our visibility state and see things it shouldn't
123
+ * (privacy bug). Documented in StateViewInternals.test.ts.
124
+ *
125
+ * Optional API but strongly recommended on client-leave; otherwise
126
+ * the FinalizationRegistry backstop runs at GC (non-deterministic).
127
+ */
128
+ public dispose(): void {
129
+ if (this._root === undefined) return;
130
+ this._root.unregisterView(this);
131
+ _clearViewBitFromAllTrees(this._root, this._slot, this._bit);
132
+ this._root.releaseViewId(this.id);
133
+ _disposeRegistry.unregister(this);
134
+ this._root = undefined;
135
+ this.id = -1;
136
+ }
137
+
138
+ // ──────────────────────────────────────────────────────────────────
139
+ // Per-tree visibility bitmap helpers. Replace the old WeakSet ops
140
+ // with O(1) bitwise ops on a chunked number[] stored on each tree.
141
+ // ──────────────────────────────────────────────────────────────────
142
+
143
+ /** True iff this view can see `tree`. */
144
+ public isVisible(tree: ChangeTree): boolean {
145
+ const arr = tree.visibleViews;
146
+ const slot = this._slot;
147
+ return arr !== undefined && slot < arr.length && (arr[slot] & this._bit) !== 0;
148
+ }
149
+
150
+ /** Mark `tree` as visible to this view. */
151
+ public markVisible(tree: ChangeTree): void {
152
+ const slot = this._slot;
153
+ let arr = tree.visibleViews;
154
+ if (arr === undefined) {
155
+ arr = tree.visibleViews = [];
156
+ }
157
+ while (arr.length <= slot) arr.push(0);
158
+ arr[slot] |= this._bit;
159
+ }
160
+
161
+ /** Clear visibility bit. */
162
+ public unmarkVisible(tree: ChangeTree): void {
163
+ const arr = tree.visibleViews;
164
+ if (arr === undefined) return;
165
+ const slot = this._slot;
166
+ if (slot < arr.length) arr[slot] &= ~this._bit;
167
+ }
168
+
169
+ /** True iff this view is subscribed to `tree`. */
170
+ public isSubscribed(tree: ChangeTree): boolean {
171
+ const arr = tree.subscribedViews;
172
+ const slot = this._slot;
173
+ return arr !== undefined && slot < arr.length && (arr[slot] & this._bit) !== 0;
174
+ }
175
+
176
+ /** Set the subscription bit on `tree`. */
177
+ private _setSubscribed(tree: ChangeTree): void {
178
+ const slot = this._slot;
179
+ let arr = tree.subscribedViews;
180
+ if (arr === undefined) {
181
+ arr = tree.subscribedViews = [];
182
+ }
183
+ while (arr.length <= slot) arr.push(0);
184
+ arr[slot] |= this._bit;
185
+ }
186
+
187
+ /** Clear the subscription bit on `tree`. */
188
+ private _clearSubscribed(tree: ChangeTree): void {
189
+ const arr = tree.subscribedViews;
190
+ if (arr === undefined) return;
191
+ const slot = this._slot;
192
+ if (slot < arr.length) arr[slot] &= ~this._bit;
193
+ }
194
+
195
+ /** True iff this view has previously marked `tree` as invisible. */
196
+ public isInvisible(tree: ChangeTree): boolean {
197
+ const arr = tree.invisibleViews;
198
+ const slot = this._slot;
199
+ return arr !== undefined && slot < arr.length && (arr[slot] & this._bit) !== 0;
200
+ }
201
+
202
+ /** Mark `tree` as invisible to this view (used by encode loop). */
203
+ public markInvisible(tree: ChangeTree): void {
204
+ const slot = this._slot;
205
+ let arr = tree.invisibleViews;
206
+ if (arr === undefined) {
207
+ arr = tree.invisibleViews = [];
208
+ }
209
+ while (arr.length <= slot) arr.push(0);
210
+ arr[slot] |= this._bit;
211
+ }
212
+
213
+ /** Clear invisible bit. */
214
+ public unmarkInvisible(tree: ChangeTree): void {
215
+ const arr = tree.invisibleViews;
216
+ if (arr === undefined) return;
217
+ const slot = this._slot;
218
+ if (slot < arr.length) arr[slot] &= ~this._bit;
219
+ }
220
+
221
+ // ──────────────────────────────────────────────────────────────────
222
+ // Per-tag, per-view bitmap. Replaces the legacy
223
+ // `tags: WeakMap<ChangeTree, Set<number>>` storage. Hot read site is
224
+ // `Schema.ts` filter check — `hasTagOnTree` is O(1) bitwise.
225
+ // ──────────────────────────────────────────────────────────────────
226
+
227
+ /** True iff this view has `tag` associated with `tree`. */
228
+ public hasTagOnTree(tree: ChangeTree, tag: number): boolean {
229
+ const map = tree.tagViews;
230
+ if (map === undefined) return false;
231
+ const arr = map.get(tag);
232
+ const slot = this._slot;
233
+ return arr !== undefined && slot < arr.length && (arr[slot] & this._bit) !== 0;
234
+ }
235
+
236
+ /** Mark `tree` as carrying `tag` for this view. */
237
+ public addTag(tree: ChangeTree, tag: number): void {
238
+ let map = tree.tagViews;
239
+ if (map === undefined) {
240
+ map = tree.tagViews = new Map();
241
+ }
242
+ let arr = map.get(tag);
243
+ if (arr === undefined) {
244
+ arr = [];
245
+ map.set(tag, arr);
246
+ }
247
+ const slot = this._slot;
248
+ while (arr.length <= slot) arr.push(0);
249
+ arr[slot] |= this._bit;
250
+ }
251
+
252
+ /** Clear this view's `tag` bit on `tree`. */
253
+ public removeTag(tree: ChangeTree, tag: number): void {
254
+ const map = tree.tagViews;
255
+ if (map === undefined) return;
256
+ const arr = map.get(tag);
257
+ if (arr === undefined) return;
258
+ const slot = this._slot;
259
+ if (slot < arr.length) arr[slot] &= ~this._bit;
260
+ }
261
+
262
+ /** Clear ALL tag bits this view holds on `tree` (used when the per-tag isn't known). */
263
+ public removeAllTagsOnTree(tree: ChangeTree): void {
264
+ const map = tree.tagViews;
265
+ if (map === undefined) return;
266
+ const slot = this._slot;
267
+ const clearMask = ~this._bit;
268
+ map.forEach((arr) => {
269
+ if (slot < arr.length) arr[slot] &= clearMask;
270
+ });
271
+ }
272
+
44
273
  // TODO: allow to set multiple tags at once
45
274
  add(obj: Ref, tag: number = DEFAULT_VIEW_TAG, checkIncludeParent: boolean = true) {
275
+ return this._add(obj, tag, checkIncludeParent, /* _skipStreamRouting */ false);
276
+ }
277
+
278
+ /**
279
+ * Internal: force-ship an object through `view.changes` without
280
+ * applying stream-element routing. Called by `Encoder._emitStreamPriority`
281
+ * when it's draining `_pendingByView` — the element is already out of
282
+ * pending at that point, so re-routing back into pending would be a
283
+ * loop. User code should always call `add()`.
284
+ */
285
+ _addImmediate(obj: Ref, tag: number = DEFAULT_VIEW_TAG): void {
286
+ this._add(obj, tag, /* checkIncludeParent */ true, /* _skipStreamRouting */ true);
287
+ }
288
+
289
+ private _add(obj: Ref, tag: number, checkIncludeParent: boolean, _skipStreamRouting: boolean) {
46
290
  const changeTree: ChangeTree = obj?.[$changes];
47
291
  const parentChangeTree = changeTree.parent;
48
292
 
@@ -55,20 +299,57 @@ export class StateView {
55
299
  obj[$refId] !== 0 // allow root object
56
300
  ) {
57
301
  /**
58
- * TODO: can we avoid this?
59
- *
60
- * When the "parent" structure has the @view() tag, it is currently
61
- * not possible to identify it has to be added to the view as well
62
- * (this.addParentOf() is not called).
302
+ * Detached adds are refused: addParentOf() walks the parent
303
+ * chain to propagate visibility upward, which requires a real
304
+ * parent reference. A detached instance has neither a parent
305
+ * ChangeTree nor a parentIndex, so we can't decide whether an
306
+ * ancestor carries a @view tag that should bring the subtree
307
+ * along. Users must assign the ref into the state tree before
308
+ * calling view.add().
63
309
  */
64
310
  throw new Error(
65
311
  `Cannot add a detached instance to the StateView. Make sure to assign the "${changeTree.ref.constructor.name}" instance to the state before calling view.add()`
66
312
  );
67
313
  }
68
314
 
69
- // FIXME: ArraySchema/MapSchema do not have metadata
315
+ // Bind to Root + acquire view ID on first add(). Until then, we have
316
+ // no per-tree bit position to write into.
317
+ if (this._root === undefined && changeTree.root !== undefined) {
318
+ this._bindRoot(changeTree.root);
319
+ }
320
+
321
+ // Streamable-element routing: when `obj` is a child of a streamable
322
+ // collection (StreamSchema element, or an entry in a .stream()
323
+ // MapSchema), subscribe this element to the stream's per-view
324
+ // pending. The element is NOT marked visible here — visibility is
325
+ // flipped on by the encoder's priority pass (`_addImmediate`) when
326
+ // it actually ships the element. This is load-bearing: if the
327
+ // element were visible before the priority pass, `encodeAllView`
328
+ // would full-sync-emit it on bootstrap and `encodeView`'s normal
329
+ // pass would emit its dirty state — both bypass `maxPerTick`.
330
+ //
331
+ // StateView mode is imperative by design — users call
332
+ // `view.add(entity)` per-entity as the game loop's AOI / interest
333
+ // logic discovers visibility. This matches the rationale that led
334
+ // to StateView in the first place: per-client visibility as a
335
+ // game-loop-cadence operation, not an encode-time predicate.
336
+ const parentStreamTree = parentChangeTree?.[$changes];
337
+ if (!_skipStreamRouting && parentStreamTree?.isStreamCollection) {
338
+ streamEnqueueForView(
339
+ parentChangeTree as unknown as Streamable,
340
+ this.id,
341
+ changeTree.parentIndex!,
342
+ );
343
+ return true;
344
+ }
345
+
346
+ // Collection types (ArraySchema / MapSchema / etc.) have no
347
+ // `Symbol.metadata` — `metadata` is undefined here and consumers
348
+ // below use `metadata?.[...]` null-safe access. Only Schema
349
+ // subclasses yield a real Metadata object.
70
350
  const metadata: Metadata = (obj.constructor as typeof Schema)[Symbol.metadata];
71
- this.visible.add(changeTree);
351
+
352
+ this.markVisible(changeTree);
72
353
 
73
354
  // add to iterable list (only the explicitly added items)
74
355
  if (this.iterable && checkIncludeParent) {
@@ -82,10 +363,38 @@ export class StateView {
82
363
  this.addParentOf(changeTree, tag);
83
364
  }
84
365
 
366
+ // Streamable-collection (the stream itself, not an element): mark
367
+ // visible only. No auto-seed of elements — users must explicitly
368
+ // `view.add(entity)` per element (see rationale above).
369
+ if (!_skipStreamRouting && changeTree.isStreamCollection) {
370
+ return true;
371
+ }
372
+
373
+ // Fast path: fresh (isNew) subtree added with default tag. The
374
+ // shared encode pass walks the whole subtree and emits ADDs for
375
+ // every field, so the view pass only needs visibility bits — no
376
+ // `view.changes` entries are needed for this subtree itself.
377
+ // `addParentOf` above already emitted the parent collection's
378
+ // ADD. Skipping the full `_add` cascade avoids ~N empty Map
379
+ // allocations per bootstrap where N = descendant count.
380
+ //
381
+ // Safe against the insertion-order invariant below because this
382
+ // path performs no writes — there is no order to preserve.
383
+ if (tag === DEFAULT_VIEW_TAG && changeTree.isNew) {
384
+ this._markSubtreeVisible(changeTree, tag);
385
+ return false;
386
+ }
387
+
388
+ // Insertion order here is load-bearing: the encoder drains
389
+ // `view.changes` in Map iteration order, and the decoder needs the
390
+ // parent's SWITCH_TO_STRUCTURE to register its refId before any
391
+ // entries for nested refs arrive. `forEachChild` below recurses
392
+ // into `this.add(child, ...)`, which inserts child refIds — if we
393
+ // deferred this insert past that point, children would be emitted
394
+ // first and the decoder would see "refId not found".
85
395
  let changes = this.changes.get(obj[$refId]);
86
396
  if (changes === undefined) {
87
- changes = {};
88
- // FIXME / OPTIMIZE: do not add if no changes are needed
397
+ changes = new Map<number, OPERATION>();
89
398
  this.changes.set(obj[$refId], changes);
90
399
  }
91
400
 
@@ -95,13 +404,14 @@ export class StateView {
95
404
  // Add children of this ChangeTree first.
96
405
  // If successful, we must link the current ChangeTree to the child.
97
406
  //
407
+ // Read per-field tags from the class's precomputed `tags[]` array
408
+ // rather than chasing `metadata[index].tag` — same source, but a
409
+ // direct array index instead of a per-field-object hop.
410
+ const tags = changeTree.encDescriptor.tags;
98
411
  changeTree.forEachChild((change, index) => {
99
412
  // Do not ADD children that don't have the same tag
100
- if (
101
- metadata &&
102
- metadata[index].tag !== undefined &&
103
- metadata[index].tag !== tag
104
- ) {
413
+ const fieldTag = tags[index];
414
+ if (fieldTag !== undefined && fieldTag !== tag) {
105
415
  return;
106
416
  }
107
417
 
@@ -112,96 +422,100 @@ export class StateView {
112
422
 
113
423
  // set tag
114
424
  if (tag !== DEFAULT_VIEW_TAG) {
115
- if (!this.tags) {
116
- this.tags = new WeakMap<ChangeTree, Set<number>>();
117
- }
118
- let tags: Set<number>;
119
- if (!this.tags.has(changeTree)) {
120
- tags = new Set<number>();
121
- this.tags.set(changeTree, tags);
122
- } else {
123
- tags = this.tags.get(changeTree);
124
- }
125
- tags.add(tag);
425
+ this.addTag(changeTree, tag);
126
426
 
127
427
  // Ref: add tagged properties
128
428
  metadata?.[$fieldIndexesByViewTag]?.[tag]?.forEach((index) => {
129
429
  if (changeTree.getChange(index) !== OPERATION.DELETE) {
130
- changes[index] = OPERATION.ADD;
430
+ changes.set(index, OPERATION.ADD);
131
431
  }
132
432
  });
133
433
 
134
434
  } else if (!changeTree.isNew || isChildAdded) {
135
435
  // new structures will be added as part of .encode() call, no need to force it to .encodeView()
136
- const changeSet = (changeTree.filteredChanges !== undefined)
137
- ? changeTree.allFilteredChanges
138
- : changeTree.allChanges;
139
-
140
- const isInvisible = this.invisible.has(changeTree);
141
-
142
- for (let i = 0, len = changeSet.operations.length; i < len; i++) {
143
- const index = changeSet.operations[i];
144
- if (index === undefined) { continue; } // skip "undefined" indexes
145
-
146
- const op = changeTree.indexedOperations[index] ?? OPERATION.ADD;
147
- const tagAtIndex = metadata?.[index].tag;
436
+ const isInvisible = this.isInvisible(changeTree);
437
+
438
+ // Full-sync snapshot: walk the live ref structurally instead of
439
+ // iterating a cumulative recorder bucket. Every populated index
440
+ // is emitted as ADD (matching the op-coercion previously done
441
+ // at encode time). Per-field tags come from the descriptor's
442
+ // precomputed `tags[]` array direct index vs a metadata[i].tag
443
+ // object hop.
444
+ const tags = changeTree.encDescriptor.tags;
445
+ changeTree.forEachLive((index) => {
446
+ const tagAtIndex = tags[index];
148
447
  if (
149
- op !== OPERATION.DELETE &&
150
- (
151
- isInvisible || // if "invisible", include all
152
- tagAtIndex === undefined || // "all change" with no tag
153
- tagAtIndex === tag // tagged property
154
- )
448
+ isInvisible || // if "invisible", include all
449
+ tagAtIndex === undefined || // "all change" with no tag
450
+ tagAtIndex === tag // tagged property
155
451
  ) {
156
- changes[index] = op;
157
- isChildAdded = true; // FIXME: assign only once
452
+ changes.set(index, OPERATION.ADD);
453
+ isChildAdded = true;
158
454
  }
159
- }
455
+ });
160
456
  }
161
457
 
162
458
  return isChildAdded;
163
459
  }
164
460
 
461
+ /**
462
+ * Walk an isNew subtree marking each descendant visible. Counterpart
463
+ * to the `_add()` fast path: skips `view.changes` allocations because
464
+ * the shared encode pass emits the whole fresh subtree structurally
465
+ * — the view pass just needs visibility bits to let those emissions
466
+ * through the per-tree filter.
467
+ *
468
+ * Preserves the `@view()`-tag filter from `_add`'s forEachChild: a
469
+ * Schema descendant behind a non-matching field tag is skipped so
470
+ * tagged fields don't leak into a default-tag view. Collections have
471
+ * no per-field tags (`encDescriptor.tags` is empty), so the filter
472
+ * is a no-op for collection children.
473
+ *
474
+ * If a descendant has `isNew=false` (rare: a detached sub-collection
475
+ * was re-attached to a fresh parent), fall back to the full `_add`
476
+ * path for that branch so its cumulative state is emitted correctly.
477
+ */
478
+ private _markSubtreeVisible(tree: ChangeTree, tag: number): void {
479
+ const tags = tree.encDescriptor.tags;
480
+ tree.forEachChild((child, index) => {
481
+ const fieldTag = tags[index];
482
+ if (fieldTag !== undefined && fieldTag !== tag) return;
483
+
484
+ if (child.isNew) {
485
+ this.markVisible(child);
486
+ this._markSubtreeVisible(child, tag);
487
+ } else {
488
+ this._add(child.ref, tag, false, false);
489
+ }
490
+ });
491
+ }
492
+
165
493
  protected addParentOf(childChangeTree: ChangeTree, tag: number) {
166
494
  const changeTree = childChangeTree.parent[$changes];
167
495
  const parentIndex = childChangeTree.parentIndex;
168
496
 
169
- if (!this.visible.has(changeTree)) {
497
+ if (!this.isVisible(changeTree)) {
170
498
  // view must have all "changeTree" parent tree
171
- this.visible.add(changeTree);
499
+ this.markVisible(changeTree);
172
500
 
173
501
  // add parent's parent
174
502
  const parentChangeTree: ChangeTree = changeTree.parent?.[$changes];
175
- if (parentChangeTree && (parentChangeTree.filteredChanges !== undefined)) {
503
+ if (parentChangeTree && parentChangeTree.hasFilteredFields) {
176
504
  this.addParentOf(changeTree, tag);
177
505
  }
178
-
179
- // // parent is already available, no need to add it!
180
- // if (!this.invisible.has(changeTree)) { return; }
181
506
  }
182
507
 
183
508
  // add parent's tag properties
184
509
  if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
185
510
  let changes = this.changes.get(changeTree.ref[$refId]);
186
511
  if (changes === undefined) {
187
- changes = {};
512
+ changes = new Map<number, OPERATION>();
188
513
  this.changes.set(changeTree.ref[$refId], changes);
189
514
  }
190
515
 
191
- if (!this.tags) {
192
- this.tags = new WeakMap<ChangeTree, Set<number>>();
193
- }
516
+ this.addTag(changeTree, tag);
194
517
 
195
- let tags: Set<number>;
196
- if (!this.tags.has(changeTree)) {
197
- tags = new Set<number>();
198
- this.tags.set(changeTree, tags);
199
- } else {
200
- tags = this.tags.get(changeTree);
201
- }
202
- tags.add(tag);
203
-
204
- changes[parentIndex] = OPERATION.ADD;
518
+ changes.set(parentIndex, OPERATION.ADD);
205
519
  }
206
520
  }
207
521
 
@@ -214,7 +528,58 @@ export class StateView {
214
528
  return this;
215
529
  }
216
530
 
217
- this.visible.delete(changeTree);
531
+ // ── Streamable-element unsubscribe ─────────────────────────────
532
+ // Symmetric to the `add(streamElement)` routing: pull the element
533
+ // out of the stream's per-view state. If it never made it to the
534
+ // wire (still in pending), silent drop; if already sent, queue
535
+ // DELETE via `view.changes` for the next encodeView drain.
536
+ const parentStreamTree = changeTree.parent?.[$changes];
537
+ if (parentStreamTree?.isStreamCollection) {
538
+ this.unmarkVisible(changeTree);
539
+ if (this.iterable && !_isClear) {
540
+ spliceOne(this.items, this.items.indexOf(obj));
541
+ }
542
+ streamDequeueForView(
543
+ changeTree.parent as unknown as Streamable,
544
+ this.id,
545
+ (changeTree.parent as any)[$refId],
546
+ changeTree.parentIndex!,
547
+ this.changes,
548
+ );
549
+ this._recursiveDeleteVisibleChangeTree(changeTree);
550
+ return this;
551
+ }
552
+
553
+ // ── Streamable-collection unsubscribe (the stream itself) ─────
554
+ // Flush DELETE for every sent position and drop pending. After
555
+ // this, the stream is marked invisible to this view — any future
556
+ // `stream.add()` would still seed broadcast pending (if no views)
557
+ // but would NOT re-seed per-view pending (user must re-subscribe).
558
+ if (changeTree.isStreamCollection) {
559
+ this.unmarkVisible(changeTree);
560
+ if (this.iterable && !_isClear) {
561
+ spliceOne(this.items, this.items.indexOf(obj));
562
+ }
563
+ const streamRef: any = changeTree.ref;
564
+ const st = streamRef._stream;
565
+ if (st !== undefined) {
566
+ st.pendingByView.get(this.id)?.clear();
567
+ const sent = st.sentByView.get(this.id);
568
+ if (sent !== undefined && sent.size > 0) {
569
+ const streamRefId = streamRef[$refId];
570
+ let changes = this.changes.get(streamRefId);
571
+ if (changes === undefined) {
572
+ changes = new Map();
573
+ this.changes.set(streamRefId, changes);
574
+ }
575
+ for (const pos of sent) changes.set(pos, OPERATION.DELETE);
576
+ sent.clear();
577
+ }
578
+ }
579
+ return this;
580
+ }
581
+
582
+ this.unmarkVisible(changeTree);
218
583
 
219
584
  // remove from iterable list
220
585
  if (
@@ -231,7 +596,7 @@ export class StateView {
231
596
 
232
597
  let changes = this.changes.get(refId);
233
598
  if (changes === undefined) {
234
- changes = {};
599
+ changes = new Map<number, OPERATION>();
235
600
  this.changes.set(refId, changes);
236
601
  }
237
602
 
@@ -242,10 +607,10 @@ export class StateView {
242
607
  const parentRefId = parent[$refId];
243
608
  let changes = this.changes.get(parentRefId);
244
609
  if (changes === undefined) {
245
- changes = {};
610
+ changes = new Map<number, OPERATION>();
246
611
  this.changes.set(parentRefId, changes);
247
612
 
248
- } else if (changes[changeTree.parentIndex] === OPERATION.ADD) {
613
+ } else if (changes.get(changeTree.parentIndex) === OPERATION.ADD) {
249
614
  //
250
615
  // SAME PATCH ADD + REMOVE:
251
616
  // The 'changes' of deleted structure should be ignored.
@@ -254,21 +619,22 @@ export class StateView {
254
619
  }
255
620
 
256
621
  // DELETE / DELETE BY REF ID
257
- changes[changeTree.parentIndex] = OPERATION.DELETE;
622
+ changes.set(changeTree.parentIndex, OPERATION.DELETE);
258
623
 
259
624
  // Remove child schema from visible set
260
625
  this._recursiveDeleteVisibleChangeTree(changeTree);
261
626
 
262
627
  } else {
263
628
  // delete all "tagged" properties.
629
+ const names = changeTree.encDescriptor.names;
264
630
  metadata?.[$viewFieldIndexes]?.forEach((index) => {
265
- changes[index] = OPERATION.DELETE;
631
+ changes.set(index, OPERATION.DELETE);
266
632
 
267
633
  // Remove child structures of @view() fields from visible set.
268
634
  // (They were added during view.add() via forEachChild)
269
- const value = changeTree.ref[metadata[index].name as keyof Ref];
635
+ const value = changeTree.ref[names[index] as keyof Ref];
270
636
  if (value?.[$changes]) {
271
- this.visible.delete(value[$changes]);
637
+ this.unmarkVisible(value[$changes]);
272
638
  this._recursiveDeleteVisibleChangeTree(value[$changes]);
273
639
  }
274
640
  });
@@ -276,45 +642,154 @@ export class StateView {
276
642
 
277
643
  } else {
278
644
  // delete only tagged properties
645
+ const names = changeTree.encDescriptor.names;
279
646
  metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
280
- changes[index] = OPERATION.DELETE;
647
+ changes.set(index, OPERATION.DELETE);
281
648
 
282
649
  // Remove child structures from visible set
283
- const value = changeTree.ref[metadata[index].name as keyof Ref];
650
+ const value = changeTree.ref[names[index] as keyof Ref];
284
651
  if (value?.[$changes]) {
285
- this.visible.delete(value[$changes]);
652
+ this.unmarkVisible(value[$changes]);
286
653
  this._recursiveDeleteVisibleChangeTree(value[$changes]);
287
654
  }
288
655
  });
289
656
  }
290
657
 
291
- // remove tag
292
- if (this.tags && this.tags.has(changeTree)) {
293
- const tags = this.tags.get(changeTree);
294
- if (tag === undefined) {
295
- // delete all tags
296
- this.tags.delete(changeTree);
297
- } else {
298
- // delete specific tag
299
- tags.delete(tag);
300
-
301
- // if tag set is empty, delete it entirely
302
- if (tags.size === 0) {
303
- this.tags.delete(changeTree);
304
- }
305
- }
658
+ // remove tag bit for this view
659
+ if (tag === undefined) {
660
+ this.removeAllTagsOnTree(changeTree);
661
+ } else {
662
+ this.removeTag(changeTree, tag);
306
663
  }
307
664
 
308
665
  return this;
309
666
  }
310
667
 
311
668
  has(obj: Ref) {
312
- return this.visible.has(obj[$changes]);
669
+ return this.isVisible(obj[$changes]);
313
670
  }
314
671
 
315
672
  hasTag(ob: Ref, tag: number = DEFAULT_VIEW_TAG) {
316
- const tags = this.tags?.get(ob[$changes]);
317
- return tags?.has(tag) ?? false;
673
+ return this.hasTagOnTree(ob[$changes], tag);
674
+ }
675
+
676
+ /**
677
+ * Persistent subscription to a collection's contents. Unlike `add()`,
678
+ * which is a one-shot bootstrap, `subscribe()` enrolls this view in
679
+ * future content changes — every subsequent push / set / add to the
680
+ * collection automatically flows to this view, and every removal
681
+ * queues a DELETE op. Works on every collection type:
682
+ *
683
+ * - `ArraySchema` / `MapSchema` / `SetSchema` / `CollectionSchema`:
684
+ * new children are force-shipped immediately (equivalent to
685
+ * `view.add(child)` per item).
686
+ * - `StreamSchema` (or `.stream()` maps/sets): new positions are
687
+ * enqueued into `_pendingByView` so the priority pass drains them
688
+ * respecting `maxPerTick`.
689
+ *
690
+ * Idempotent on re-subscribe. Subscribing to an already-subscribed
691
+ * collection is a no-op.
692
+ */
693
+ subscribe(collection: Ref): this {
694
+ const tree: ChangeTree = collection?.[$changes];
695
+ if (!tree) {
696
+ console.warn("StateView#subscribe(), invalid collection:", collection);
697
+ return this;
698
+ }
699
+ if (this._root === undefined && tree.root !== undefined) {
700
+ this._bindRoot(tree.root);
701
+ }
702
+ if (this.isSubscribed(tree)) return this;
703
+
704
+ // Mark collection visible so its own ADD/DELETE ops emit in the
705
+ // view pass. Also flip on the subscription bit.
706
+ this.markVisible(tree);
707
+ this._setSubscribed(tree);
708
+
709
+ // Bootstrap: walk current children and mark them visible to this
710
+ // view. We DO NOT force-seed via `_addImmediate` / view.changes
711
+ // — the encoder's natural emission paths handle it:
712
+ //
713
+ // - `encodeAllView` (first-tick bootstrap): walks the tree
714
+ // structurally and emits every visible child.
715
+ // - Normal `encodeView` pass: walks `root.changes` and emits
716
+ // dirty children + parent collection's ADD ops.
717
+ //
718
+ // Seeding view.changes ourselves would cause duplicate emission,
719
+ // fine for idempotent collections (Array/Map/Set dedup by index
720
+ // or value), but breaks `CollectionSchema` which appends on
721
+ // every decode-side ADD (no dedup).
722
+ //
723
+ // Streams are the exception — they bypass the recorder flow, so
724
+ // subscription must enqueue positions into `_pendingByView`
725
+ // where the priority pass drains them per `maxPerTick`.
726
+ if (tree.isStreamCollection) {
727
+ const streamable = collection as unknown as Streamable;
728
+ tree.forEachChild((_child, index) => {
729
+ streamEnqueueForView(streamable, this.id, index);
730
+ });
731
+ } else {
732
+ tree.forEachChild((child) => {
733
+ this.markVisible(child);
734
+ });
735
+ }
736
+
737
+ return this;
738
+ }
739
+
740
+ /**
741
+ * End a persistent subscription. Queues DELETE for every already-sent
742
+ * child and clears any pending. After this call, future content
743
+ * changes on the collection no longer auto-flow to this view (though
744
+ * direct `view.add(element)` calls still work for per-entity use).
745
+ */
746
+ unsubscribe(collection: Ref): this {
747
+ const tree: ChangeTree = collection?.[$changes];
748
+ if (!tree) {
749
+ console.warn("StateView#unsubscribe(), invalid collection:", collection);
750
+ return this;
751
+ }
752
+ if (!this.isSubscribed(tree)) return this;
753
+ this._clearSubscribed(tree);
754
+
755
+ const collectionRefId = tree.ref[$refId];
756
+
757
+ if (tree.isStreamCollection) {
758
+ // Streams: clear pending + queue DELETE for everything in sent.
759
+ const st = (collection as any)._stream;
760
+ if (st !== undefined) {
761
+ st.pendingByView.get(this.id)?.clear();
762
+ const sent: Set<number> | undefined = st.sentByView.get(this.id);
763
+ if (sent !== undefined && sent.size > 0) {
764
+ let changes = this.changes.get(collectionRefId);
765
+ if (changes === undefined) {
766
+ changes = new Map();
767
+ this.changes.set(collectionRefId, changes);
768
+ }
769
+ for (const pos of sent) changes.set(pos, OPERATION.DELETE);
770
+ sent.clear();
771
+ }
772
+ }
773
+ } else {
774
+ // Non-streams: queue DELETE for every current child and
775
+ // unmark their visibility so subsequent mutations stop
776
+ // reaching this view.
777
+ let changes = this.changes.get(collectionRefId);
778
+ tree.forEachChild((childTree, index) => {
779
+ if (changes === undefined) {
780
+ changes = new Map();
781
+ this.changes.set(collectionRefId, changes);
782
+ }
783
+ changes.set(index, OPERATION.DELETE);
784
+ this.unmarkVisible(childTree);
785
+ });
786
+ }
787
+
788
+ // Unmark the collection itself so future ops don't emit to this
789
+ // view (add() / subscribe() again re-marks it).
790
+ this.unmarkVisible(tree);
791
+
792
+ return this;
318
793
  }
319
794
 
320
795
  clear() {
@@ -331,22 +806,18 @@ export class StateView {
331
806
  }
332
807
 
333
808
  isChangeTreeVisible(changeTree: ChangeTree) {
334
- let isVisible = this.visible.has(changeTree);
335
-
336
- //
337
- // TODO: avoid checking for parent visibility, most of the time it's not needed
338
- // See test case: 'should not be required to manually call view.add() items to child arrays without @view() tag'
339
- //
809
+ let isVisible = this.isVisible(changeTree);
810
+
811
+ // The parent-visibility fallback handles child collections without
812
+ // their own @view tag (see StateView.test.ts "should not be required
813
+ // to manually call view.add() items to child arrays..."). The
814
+ // `isVisibilitySharedWithParent` flag — precomputed at attach-time in
815
+ // inheritedFlags.ts — short-circuits for the common case, and
816
+ // `markVisible` memoizes so the branch fires at most once per
817
+ // (tree, view) pair.
340
818
  if (!isVisible && changeTree.isVisibilitySharedWithParent){
341
-
342
- // console.log("CHECK AGAINST PARENT...", {
343
- // ref: changeTree.ref.constructor.name,
344
- // refId: changeTree.ref[$refId],
345
- // parent: changeTree.parent.constructor.name,
346
- // });
347
-
348
- if (this.visible.has(changeTree.parent[$changes])) {
349
- this.visible.add(changeTree);
819
+ if (this.isVisible(changeTree.parent[$changes])) {
820
+ this.markVisible(changeTree);
350
821
  isVisible = true;
351
822
  }
352
823
  }
@@ -356,7 +827,7 @@ export class StateView {
356
827
 
357
828
  protected _recursiveDeleteVisibleChangeTree(changeTree: ChangeTree) {
358
829
  changeTree.forEachChild((childChangeTree) => {
359
- this.visible.delete(childChangeTree);
830
+ this.unmarkVisible(childChangeTree);
360
831
  this._recursiveDeleteVisibleChangeTree(childChangeTree);
361
832
  });
362
833
  }