@colyseus/schema 4.0.20 → 5.0.1

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 +56 -2
  3. package/build/Reflection.d.ts +28 -34
  4. package/build/Schema.d.ts +70 -9
  5. package/build/annotations.d.ts +64 -17
  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 +5258 -1549
  27. package/build/index.cjs.map +1 -1
  28. package/build/index.d.ts +7 -3
  29. package/build/index.js +5258 -1549
  30. package/build/index.mjs +5249 -1549
  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 +7453 -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 +7450 -0
  38. package/build/input/index.mjs.map +1 -0
  39. package/build/types/HelperTypes.d.ts +67 -9
  40. package/build/types/TypeContext.d.ts +9 -0
  41. package/build/types/builder.d.ts +192 -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 +259 -31
  50. package/src/Reflection.ts +15 -13
  51. package/src/Schema.ts +176 -134
  52. package/src/annotations.ts +365 -252
  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 +121 -24
  88. package/src/types/TypeContext.ts +14 -2
  89. package/src/types/builder.ts +331 -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 +93 -6
  96. package/src/utils.ts +4 -6
@@ -0,0 +1,124 @@
1
+ /**
2
+ * MapJournal — owns the change-tracking and wire-protocol identity for a MapSchema.
3
+ *
4
+ * Replaces three parallel structures that previously lived on MapSchema:
5
+ * - `$indexes: Map<number, K>` → `keyByIndex`
6
+ * - `_collectionIndexes: { [key]: number }` (+ counter) → `indexByKey` + `nextIndex`
7
+ * - `deletedItems: { [index]: V }` → `snapshots`
8
+ *
9
+ * The journal is the single source of truth for:
10
+ * - assigning wire-protocol indexes to keys (server side)
11
+ * - looking up keys from wire indexes (server + client)
12
+ * - holding snapshots of removed values (for view-filter visibility checks)
13
+ *
14
+ * The journal does NOT track per-index operation types or maintain enqueue
15
+ * order — those remain on `ChangeTree` for now. A future iteration may pull
16
+ * them in too, but this version is intentionally scoped to the data-model
17
+ * cleanup so we can validate the abstraction before going deeper.
18
+ */
19
+ export class MapJournal<K = any> {
20
+ /** index → key (was MapSchema.$indexes). Used by encoder and decoder. */
21
+ keyByIndex: Map<number, K> = new Map();
22
+
23
+ /**
24
+ * key → index (was MapSchema._collectionIndexes — forward direction).
25
+ * Server-only. Plain object so MapSchema can expose it via a getter
26
+ * for backwards-compatible `_collectionIndexes?.[key]` access from
27
+ * ChangeTree.forEachChild and similar polymorphic call sites.
28
+ */
29
+ indexByKey: { [key: string]: number } = {};
30
+
31
+ /** Monotonic counter for assigning new indexes. Server-only. */
32
+ private nextIndex: number = 0;
33
+
34
+ /**
35
+ * Snapshot of values at the moment they were deleted. Lazy — only
36
+ * allocated on first delete, since most maps are pure-grow and never
37
+ * touch this. Used by `MapSchema[$filter]` to check view visibility
38
+ * of a value that's already been removed from `$items` but whose
39
+ * DELETE op is still in the encode queue.
40
+ */
41
+ snapshots?: Map<number, any>;
42
+
43
+ // ──────────────────────────────────────────────────────────────────
44
+ // Server-side: recording mutations
45
+ // ──────────────────────────────────────────────────────────────────
46
+
47
+ /** Get the index assigned to a key, or undefined if never assigned. */
48
+ indexOf(key: K): number | undefined {
49
+ const idx = this.indexByKey[key as unknown as string];
50
+ return idx === undefined ? undefined : idx;
51
+ }
52
+
53
+ /** Assign and return a new wire index for an unseen key. */
54
+ assign(key: K): number {
55
+ const index = this.nextIndex++;
56
+ this.indexByKey[key as unknown as string] = index;
57
+ this.keyByIndex.set(index, key);
58
+ return index;
59
+ }
60
+
61
+ /** Stash a value at the moment it's deleted (for filter visibility checks). */
62
+ snapshot(index: number, value: any): void {
63
+ (this.snapshots ??= new Map()).set(index, value);
64
+ }
65
+
66
+ /** Discard a snapshot — called when a deleted slot is being re-set. */
67
+ forgetSnapshot(index: number): void {
68
+ this.snapshots?.delete(index);
69
+ }
70
+
71
+ /** Look up a snapshot. Returns undefined if no DELETE is pending for this index. */
72
+ snapshotAt(index: number): any {
73
+ return this.snapshots?.get(index);
74
+ }
75
+
76
+ // ──────────────────────────────────────────────────────────────────
77
+ // Client-side (decoder): index↔key sync from the wire
78
+ // ──────────────────────────────────────────────────────────────────
79
+
80
+ /** Decoder calls this when it sees an ADD/DELETE_AND_ADD on the wire. */
81
+ setIndex(index: number, key: K): void {
82
+ this.keyByIndex.set(index, key);
83
+ // Forward direction maintained for symmetry, even though decoder
84
+ // rarely needs it. Cheap insert; keeps invariants aligned.
85
+ this.indexByKey[key as unknown as string] = index;
86
+ }
87
+
88
+ // ──────────────────────────────────────────────────────────────────
89
+ // Lookups (both sides)
90
+ // ──────────────────────────────────────────────────────────────────
91
+
92
+ /** Reverse lookup: wire index → key. */
93
+ keyOf(index: number): K | undefined {
94
+ return this.keyByIndex.get(index);
95
+ }
96
+
97
+ // ──────────────────────────────────────────────────────────────────
98
+ // Lifecycle
99
+ // ──────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Called from MapSchema's $onEncodeEnd hook.
103
+ * Cleans up index/key mappings for entries that were deleted in this tick.
104
+ */
105
+ cleanupAfterEncode(): void {
106
+ if (this.snapshots === undefined) return;
107
+ for (const [index] of this.snapshots) {
108
+ const key = this.keyByIndex.get(index);
109
+ if (key !== undefined) {
110
+ delete this.indexByKey[key as unknown as string];
111
+ this.keyByIndex.delete(index);
112
+ }
113
+ }
114
+ this.snapshots.clear();
115
+ }
116
+
117
+ /** Reset everything (called on .clear()). */
118
+ reset(): void {
119
+ this.indexByKey = {};
120
+ this.keyByIndex.clear();
121
+ this.snapshots?.clear();
122
+ this.nextIndex = 0;
123
+ }
124
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Allocates monotonically-increasing refIds with a reuse pool.
3
+ *
4
+ * `acquire()` pops from the free pool when available, otherwise bumps a
5
+ * counter. `release()` queues a refId for reuse; the id doesn't become
6
+ * acquirable until `flushReleases()` runs — the one-tick defer is what
7
+ * lets the encoder guarantee a DELETE for the old instance reaches the
8
+ * wire before the refId is handed to a new one.
9
+ *
10
+ * `reclaim()` handles "resurrection": a ref that was released but whose
11
+ * JS instance is still alive can be re-added to the tree, in which case
12
+ * the encoder must pull the refId back out of the pool before it's
13
+ * handed to an unrelated instance.
14
+ */
15
+ export class RefIdAllocator {
16
+ protected nextUniqueId: number;
17
+
18
+ private _free: number[] = [];
19
+ private _pending: number[] = [];
20
+ private _pooled: Set<number> = new Set();
21
+
22
+ constructor(startRefId: number = 0) {
23
+ this.nextUniqueId = startRefId;
24
+ }
25
+
26
+ acquire(): number {
27
+ if (this._free.length > 0) {
28
+ const id = this._free.pop()!;
29
+ this._pooled.delete(id);
30
+ return id;
31
+ }
32
+ return this.nextUniqueId++;
33
+ }
34
+
35
+ release(refId: number): void {
36
+ this._pending.push(refId);
37
+ this._pooled.add(refId);
38
+ }
39
+
40
+ isPooled(refId: number): boolean {
41
+ return this._pooled.has(refId);
42
+ }
43
+
44
+ /**
45
+ * Remove a refId from the pool. Called when a ref whose refId was
46
+ * released is being resurrected. O(n) scan of the relevant array,
47
+ * but resurrection is rare.
48
+ */
49
+ reclaim(refId: number): void {
50
+ if (!this._pooled.delete(refId)) return;
51
+ let i = this._free.indexOf(refId);
52
+ if (i !== -1) { this._free.splice(i, 1); return; }
53
+ i = this._pending.indexOf(refId);
54
+ if (i !== -1) { this._pending.splice(i, 1); }
55
+ }
56
+
57
+ /**
58
+ * Promote this tick's releases into the acquirable set. Called from
59
+ * `Encoder.discardChanges()` — never mid-encode.
60
+ */
61
+ flushReleases(): void {
62
+ const pending = this._pending;
63
+ if (pending.length === 0) return;
64
+ const free = this._free;
65
+ for (let i = 0; i < pending.length; i++) free.push(pending[i]);
66
+ pending.length = 0;
67
+ }
68
+ }
@@ -1,35 +1,152 @@
1
1
  import { OPERATION } from "../encoding/spec.js";
2
2
  import { TypeContext } from "../types/TypeContext.js";
3
- import { ChangeTree, setOperationAtIndex, ChangeTreeList, createChangeTreeList, ChangeSetName, type ChangeTreeNode } from "./ChangeTree.js";
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
+ import type { StateView } from "./StateView.js";
7
+ import type { StreamSchema } from "../types/custom/StreamSchema.js";
8
+ import type { StreamableState } from "./streaming.js";
9
+
10
+ /**
11
+ * Minimal shape the encoder needs from a streamable collection. Both
12
+ * `StreamSchema` and `.stream()`-decorated `MapSchema`/`SetSchema` etc.
13
+ * satisfy this via a single lazily-allocated `_stream` slot — the
14
+ * per-view / broadcast bookkeeping lives on that object, not directly
15
+ * on the collection, so non-streaming instances pay zero Map/Set
16
+ * allocation cost.
17
+ */
18
+ export interface Streamable {
19
+ [$refId]?: number;
20
+ [$changes]: ChangeTree;
21
+ _stream?: StreamableState;
22
+ _dropView(viewId: number): void;
23
+ _unregister(): void;
24
+ }
5
25
 
6
26
  export class Root {
7
- protected nextUniqueId: number = 0;
27
+ /**
28
+ * Allocates and recycles refIds. See `RefIdAllocator` for the reuse
29
+ * pool semantics (one-tick defer + resurrection).
30
+ */
31
+ public readonly refIds: RefIdAllocator;
8
32
 
9
33
  refCount: {[id: number]: number} = {};
10
34
  changeTrees: {[refId: number]: ChangeTree} = {};
11
35
 
12
- // all changes
13
- allChanges: ChangeTreeList = createChangeTreeList();
14
- allFilteredChanges: ChangeTreeList = createChangeTreeList();// TODO: do not initialize it if filters are not used
15
-
16
- // pending changes to be encoded
36
+ /**
37
+ * Queue of all ChangeTrees with reliable dirty state. Per-tick encode()
38
+ * walks this queue; per-view encodeView() walks it too (filtering at
39
+ * emission time via tree.isFiltered + per-field @view tag).
40
+ */
17
41
  changes: ChangeTreeList = createChangeTreeList();
18
- filteredChanges: ChangeTreeList = createChangeTreeList();// TODO: do not initialize it if filters are not used
19
42
 
20
- constructor(public types: TypeContext) { }
43
+ /**
44
+ * Queue of all ChangeTrees with unreliable dirty state. Walked by
45
+ * `Encoder.encodeUnreliable` / `encodeUnreliableView`. A tree may live
46
+ * in both queues when the Schema has both reliable and unreliable
47
+ * fields dirty at the same time.
48
+ */
49
+ unreliableChanges: ChangeTreeList = createChangeTreeList();
50
+
51
+ /**
52
+ * Free-list of ChangeTreeNode objects. Both queues share this pool —
53
+ * a node carries no queue affinity, only `{ changeTree, prev, next, position }`.
54
+ * Reusing nodes turns ~1,250 per-tick allocations (in bench) into 0.
55
+ */
56
+ private _nodePool: ChangeTreeNode[] = [];
57
+
58
+ /**
59
+ * View ID allocator for StateView visibility bitmaps on ChangeTree.
60
+ * Each new StateView claims the lowest free ID; releaseViewId() puts
61
+ * the ID back. Avoids unbounded bitmap growth across long-running rooms
62
+ * with view churn (clients joining/leaving).
63
+ */
64
+ private _nextViewId: number = 0;
65
+ private _freeViewIds: number[] = [];
66
+
67
+ /** Allocate a fresh view ID (lowest available). */
68
+ public acquireViewId(): number {
69
+ return this._freeViewIds.length > 0
70
+ ? this._freeViewIds.pop()!
71
+ : this._nextViewId++;
72
+ }
73
+
74
+ /** Return a view ID to the freelist for reuse. */
75
+ public releaseViewId(id: number): void {
76
+ this._freeViewIds.push(id);
77
+ }
78
+
79
+ /**
80
+ * Currently-bound StateViews, keyed by view ID and held via `WeakRef`
81
+ * so the FinalizationRegistry backstop in StateView still works when
82
+ * the user forgets `dispose()`. Callers must iterate via
83
+ * `forEachActiveView`, which prunes dead entries.
84
+ */
85
+ public activeViews: Map<number, WeakRef<StateView>> = new Map();
86
+
87
+ /**
88
+ * Streamable collections attached under this Root — `StreamSchema`
89
+ * plus any collection opted into streaming via `.stream()` on the
90
+ * builder. Encoder.encodeView / broadcast pass iterates this set to
91
+ * dispatch per-view / per-tick budget gates.
92
+ */
93
+ public streamTrees: Set<Streamable> = new Set();
94
+
95
+ public registerView(view: StateView): void {
96
+ this.activeViews.set(view.id, new WeakRef(view));
97
+ }
98
+
99
+ public unregisterView(view: StateView): void {
100
+ this.activeViews.delete(view.id);
101
+ // Clear per-view state on every registered stream so dispose()ing
102
+ // a view doesn't leak its `_pendingByView` / `_sentByView` entries
103
+ // indefinitely. O(streams) on dispose, acceptable since dispose is
104
+ // rare (once per client disconnect).
105
+ const id = view.id;
106
+ for (const stream of this.streamTrees) {
107
+ stream._dropView(id);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Iterate all live StateViews bound to this Root. Prunes entries
113
+ * whose underlying view has been garbage collected without an
114
+ * explicit `dispose()`.
115
+ */
116
+ public forEachActiveView(cb: (view: StateView) => void): void {
117
+ for (const [id, ref] of this.activeViews) {
118
+ const view = ref.deref();
119
+ if (view === undefined) {
120
+ this.activeViews.delete(id);
121
+ for (const stream of this.streamTrees) stream._dropView(id);
122
+ continue;
123
+ }
124
+ cb(view);
125
+ }
126
+ }
127
+
128
+ public registerStream(stream: Streamable): void {
129
+ this.streamTrees.add(stream);
130
+ }
131
+
132
+ public unregisterStream(stream: Streamable): void {
133
+ this.streamTrees.delete(stream);
134
+ }
21
135
 
22
- getNextUniqueId() {
23
- return this.nextUniqueId++;
136
+ constructor(public types: TypeContext, startRefId: number = 0) {
137
+ this.refIds = new RefIdAllocator(startRefId);
24
138
  }
25
139
 
26
140
  add(changeTree: ChangeTree) {
27
141
  const ref = changeTree.ref;
28
142
 
29
143
  // Assign unique `refId` to ref if it doesn't have one yet.
144
+ // $refId is a Symbol but assert.deepStrictEqual still walks
145
+ // *enumerable* own Symbols, so we keep defineProperty(enumerable:false)
146
+ // to keep $refId hidden from deep-equal comparisons in tests.
30
147
  if (ref[$refId] === undefined) {
31
148
  Object.defineProperty(ref, $refId, {
32
- value: this.getNextUniqueId(),
149
+ value: this.refIds.acquire(),
33
150
  enumerable: false,
34
151
  writable: true
35
152
  });
@@ -40,24 +157,32 @@ export class Root {
40
157
  const isNewChangeTree = (this.changeTrees[refId] === undefined);
41
158
  if (isNewChangeTree) { this.changeTrees[refId] = changeTree; }
42
159
 
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
+
43
167
  const previousRefCount = this.refCount[refId];
44
168
  if (previousRefCount === 0) {
45
169
  //
46
- // When a ChangeTree is re-added, it means that it was previously removed.
47
- // We need to re-add all changes to the `changes` map.
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.
48
174
  //
49
- const ops = changeTree.allChanges.operations;
50
- let len = ops.length;
51
- while (len--) {
52
- changeTree.indexedOperations[ops[len]] = OPERATION.ADD;
53
- setOperationAtIndex(changeTree.changes, len);
54
- }
175
+ changeTree.forEachLive((fieldIndex) => {
176
+ if (changeTree.isFieldUnreliable(fieldIndex)) {
177
+ changeTree.ensureUnreliableRecorder().record(fieldIndex, OPERATION.ADD);
178
+ } else {
179
+ changeTree.record(fieldIndex, OPERATION.ADD);
180
+ }
181
+ });
55
182
  }
56
183
 
57
184
  this.refCount[refId] = (previousRefCount || 0) + 1;
58
185
 
59
- // console.log("ADD", { refId, ref: ref.constructor.name, refCount: this.refCount[refId], isNewChangeTree });
60
-
61
186
  return isNewChangeTree;
62
187
  }
63
188
 
@@ -65,8 +190,6 @@ export class Root {
65
190
  const refId = changeTree.ref[$refId];
66
191
  const refCount = (this.refCount[refId]) - 1;
67
192
 
68
- // console.log("REMOVE", { refId, ref: changeTree.ref.constructor.name, refCount, needRemove: refCount <= 0 });
69
-
70
193
  if (refCount <= 0) {
71
194
  //
72
195
  // Only remove "root" reference if it's the last reference
@@ -74,25 +197,39 @@ export class Root {
74
197
  changeTree.root = undefined;
75
198
  delete this.changeTrees[refId];
76
199
 
77
- this.removeChangeFromChangeSet("allChanges", changeTree);
78
- this.removeChangeFromChangeSet("changes", changeTree);
79
-
80
- if (changeTree.filteredChanges) {
81
- this.removeChangeFromChangeSet("allFilteredChanges", changeTree);
82
- this.removeChangeFromChangeSet("filteredChanges", changeTree);
200
+ // Streamable-collection detach (StreamSchema + any `.stream()`
201
+ // collection). Tree flag is cheaper than the class-level
202
+ // brand and covers both cases uniformly.
203
+ if (changeTree.isStreamCollection) {
204
+ const streamable = changeTree.ref as unknown as Streamable;
205
+ streamable._unregister?.();
206
+ this.unregisterStream(streamable);
83
207
  }
84
208
 
209
+ this.removeFromQueue(changeTree);
210
+ this.removeFromUnreliableQueue(changeTree);
211
+
85
212
  this.refCount[refId] = 0;
86
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
+
87
224
  changeTree.forEachChild((child, _) => {
88
225
  if (child.removeParent(changeTree.ref)) {
89
226
  if ((
90
- child.parentChain === undefined || // no parent, remove it
91
- (child.parentChain && this.refCount[child.ref[$refId]] > 0) // parent is still in use, but has more than one reference, remove it
227
+ child.parentRef === undefined || // no parent, remove it
228
+ (child.parentRef && this.refCount[child.ref[$refId]] > 0) // parent is still in use, but has more than one reference, remove it
92
229
  )) {
93
230
  this.remove(child);
94
231
 
95
- } else if (child.parentChain) {
232
+ } else if (child.parentRef) {
96
233
  // re-assigning a child of the same root, move it next to parent
97
234
  this.moveNextToParent(child);
98
235
  }
@@ -122,37 +259,34 @@ export class Root {
122
259
  changeTree.forEachChild((child, _) => this.recursivelyMoveNextToParent(child));
123
260
  }
124
261
 
125
- moveNextToParent(changeTree: ChangeTree) {
126
- if (changeTree.filteredChanges) {
127
- this.moveNextToParentInChangeTreeList("filteredChanges", changeTree);
128
- this.moveNextToParentInChangeTreeList("allFilteredChanges", changeTree);
129
- } else {
130
- this.moveNextToParentInChangeTreeList("changes", changeTree);
131
- this.moveNextToParentInChangeTreeList("allChanges", changeTree);
262
+ moveNextToParent(changeTree: ChangeTree): void {
263
+ if (changeTree.changesNode) {
264
+ this._moveNextToParentInList(this.changes, changeTree, changeTree.changesNode, "changesNode");
265
+ }
266
+ if (changeTree.unreliableChangesNode) {
267
+ this._moveNextToParentInList(this.unreliableChanges, changeTree, changeTree.unreliableChangesNode, "unreliableChangesNode");
132
268
  }
133
269
  }
134
270
 
135
- moveNextToParentInChangeTreeList(changeSetName: ChangeSetName, changeTree: ChangeTree): void {
136
- const changeSet = this[changeSetName];
137
- const node = changeTree[changeSetName].queueRootNode;
138
- if (!node) return;
139
-
140
- // Find the parent in the linked list
271
+ private _moveNextToParentInList(
272
+ changeSet: ChangeTreeList,
273
+ changeTree: ChangeTree,
274
+ node: ChangeTreeNode,
275
+ nodeField: "changesNode" | "unreliableChangesNode",
276
+ ): void {
141
277
  const parent = changeTree.parent;
142
278
  if (!parent || !parent[$changes]) return;
143
279
 
144
- const parentNode = parent[$changes][changeSetName]?.queueRootNode;
280
+ const parentNode = parent[$changes][nodeField];
145
281
  if (!parentNode || parentNode === node) return;
146
282
 
147
- // Use cached positions - no iteration needed!
148
- const parentPosition = parentNode.position;
149
- const childPosition = node.position;
150
-
151
- // If child is already after parent, no need to move
152
- if (childPosition > parentPosition) return;
153
-
154
- // Child is before parent, so we need to move it after parent
155
- // This maintains decoding order (parent before child)
283
+ // Check if child is already after parent by walking from parent
284
+ let cursor = parentNode.next;
285
+ while (cursor) {
286
+ if (cursor === node) return; // already after parent
287
+ cursor = cursor.next;
288
+ }
289
+ // If we reach here, node is before parent — need to move
156
290
 
157
291
  // Remove node from current position
158
292
  if (node.prev) {
@@ -178,31 +312,36 @@ export class Root {
178
312
  }
179
313
 
180
314
  parentNode.next = node;
181
-
182
- // Update positions after the move
183
- this.updatePositionsAfterMove(changeSet, node, parentPosition + 1);
184
315
  }
185
316
 
186
317
  public enqueueChangeTree(
187
318
  changeTree: ChangeTree,
188
- changeSet: 'changes' | 'filteredChanges' | 'allFilteredChanges' | 'allChanges',
189
- queueRootNode = changeTree[changeSet].queueRootNode
319
+ existingNode = changeTree.changesNode
190
320
  ) {
191
- // skip
192
- if (queueRootNode) { return; }
193
-
194
- // Add to linked list if not already present
195
- changeTree[changeSet].queueRootNode = this.addToChangeTreeList(this[changeSet], changeTree);
321
+ if (existingNode) { return; }
322
+ changeTree.changesNode = this._appendToList(this.changes, changeTree);
196
323
  }
197
324
 
198
- protected addToChangeTreeList(list: ChangeTreeList, changeTree: ChangeTree): ChangeTreeNode {
199
- const node: ChangeTreeNode = {
200
- changeTree,
201
- next: undefined,
202
- prev: undefined,
203
- position: list.tail ? list.tail.position + 1 : 0
204
- };
325
+ public enqueueUnreliable(
326
+ changeTree: ChangeTree,
327
+ existingNode = changeTree.unreliableChangesNode
328
+ ) {
329
+ if (existingNode) { return; }
330
+ changeTree.unreliableChangesNode = this._appendToList(this.unreliableChanges, changeTree);
331
+ }
205
332
 
333
+ private _appendToList(list: ChangeTreeList, changeTree: ChangeTree): ChangeTreeNode {
334
+ const pool = this._nodePool;
335
+ let node: ChangeTreeNode;
336
+ if (pool.length > 0) {
337
+ node = pool.pop()!;
338
+ node.changeTree = changeTree;
339
+ node.next = undefined;
340
+ node.prev = undefined;
341
+ node.position = 0;
342
+ } else {
343
+ node = { changeTree, next: undefined, prev: undefined, position: 0 };
344
+ }
206
345
  if (!list.next) {
207
346
  list.next = node;
208
347
  list.tail = node;
@@ -211,64 +350,52 @@ export class Root {
211
350
  list.tail!.next = node;
212
351
  list.tail = node;
213
352
  }
214
-
215
353
  return node;
216
354
  }
217
355
 
218
- protected updatePositionsAfterRemoval(list: ChangeTreeList, removedPosition: number) {
219
- // Update positions for all nodes after the removed position
220
- let current = list.next;
221
- let position = 0;
222
-
223
- while (current) {
224
- if (position >= removedPosition) {
225
- current.position = position;
226
- }
227
- current = current.next;
228
- position++;
229
- }
356
+ /**
357
+ * Release a detached node back to the free-list. Caller must have
358
+ * already unlinked it from any list and cleared the changeTree's
359
+ * pointer to it. Clears `changeTree`/`prev`/`next` so the pool
360
+ * doesn't retain references through the GC root.
361
+ */
362
+ public releaseNode(node: ChangeTreeNode): void {
363
+ node.changeTree = undefined!;
364
+ node.prev = undefined;
365
+ node.next = undefined;
366
+ this._nodePool.push(node);
230
367
  }
231
368
 
232
- protected updatePositionsAfterMove(list: ChangeTreeList, node: ChangeTreeNode, newPosition: number) {
233
- // Recalculate all positions - this is more reliable than trying to be clever
234
- let current = list.next;
235
- let position = 0;
236
-
237
- while (current) {
238
- current.position = position;
239
- current = current.next;
240
- position++;
241
- }
369
+ public removeFromQueue(changeTree: ChangeTree): boolean {
370
+ return this._removeNode(this.changes, changeTree, changeTree.changesNode, "changesNode");
242
371
  }
243
372
 
244
- public removeChangeFromChangeSet(changeSetName: ChangeSetName, changeTree: ChangeTree) {
245
- const changeSet = this[changeSetName];
246
- const node = changeTree[changeSetName].queueRootNode;
247
-
248
- if (node && node.changeTree === changeTree) {
249
- const removedPosition = node.position;
250
-
251
- // Remove the node from the linked list
252
- if (node.prev) {
253
- node.prev.next = node.next;
254
- } else {
255
- changeSet.next = node.next;
256
- }
373
+ public removeFromUnreliableQueue(changeTree: ChangeTree): boolean {
374
+ return this._removeNode(this.unreliableChanges, changeTree, changeTree.unreliableChangesNode, "unreliableChangesNode");
375
+ }
257
376
 
258
- if (node.next) {
259
- node.next.prev = node.prev;
260
- } else {
261
- changeSet.tail = node.prev;
262
- }
377
+ private _removeNode(
378
+ changeSet: ChangeTreeList,
379
+ changeTree: ChangeTree,
380
+ node: ChangeTreeNode | undefined,
381
+ nodeField: "changesNode" | "unreliableChangesNode",
382
+ ): boolean {
383
+ if (!node || node.changeTree !== changeTree) return false;
263
384
 
264
- // Update positions for nodes that came after the removed node
265
- this.updatePositionsAfterRemoval(changeSet, removedPosition);
385
+ if (node.prev) {
386
+ node.prev.next = node.next;
387
+ } else {
388
+ changeSet.next = node.next;
389
+ }
266
390
 
267
- // Clear ChangeTree reference
268
- changeTree[changeSetName].queueRootNode = undefined;
269
- return true;
391
+ if (node.next) {
392
+ node.next.prev = node.prev;
393
+ } else {
394
+ changeSet.tail = node.prev;
270
395
  }
271
396
 
272
- return false;
397
+ changeTree[nodeField] = undefined;
398
+ this.releaseNode(node);
399
+ return true;
273
400
  }
274
401
  }