@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,20 +1,56 @@
1
+ /**
2
+ * ChangeTree — the per-`Ref` mutation tracker attached via `$changes`.
3
+ *
4
+ * This file owns: class shape (fields, flags, ctor), inline
5
+ * ChangeRecorder implementation (record / forEach / …), mutation API
6
+ * (change / delete / operation / …), and encode lifecycle (endEncode /
7
+ * discard / …). Helpers split out into ./changeTree/:
8
+ *
9
+ * - parentChain.ts addParent / removeParent / find / has / getAll
10
+ * - liveIteration.ts forEachLive
11
+ * - inheritedFlags.ts filter / unreliable / transient / static inheritance
12
+ * - treeAttachment.ts setRoot / setParent / forEachChild(+WithCtx)
13
+ *
14
+ * Public surface on ChangeTree is unchanged — methods are thin pass-throughs
15
+ * into the helpers. V8 inlines the pass-throughs; the runtime shape stays
16
+ * a single class to preserve hidden-class + IC behavior.
17
+ */
1
18
  import { OPERATION } from "../encoding/spec.js";
2
19
  import { Schema } from "../Schema.js";
3
- import { $changes, $childType, $decoder, $onEncodeEnd, $encoder, $getByIndex, $refId, $refTypeFieldIndexes, $viewFieldIndexes, type $deleteByIndex } from "../types/symbols.js";
20
+ import { $changes, $childType, $decoder, $onEncodeEnd, $encoder, $getByIndex, $proxyTarget, $refId, $refTypeFieldIndexes, $numFields, type $deleteByIndex } from "../types/symbols.js";
4
21
 
5
22
  import type { MapSchema } from "../types/custom/MapSchema.js";
6
23
  import type { ArraySchema } from "../types/custom/ArraySchema.js";
7
24
  import type { CollectionSchema } from "../types/custom/CollectionSchema.js";
8
25
  import type { SetSchema } from "../types/custom/SetSchema.js";
26
+ import type { StreamSchema } from "../types/custom/StreamSchema.js";
9
27
 
10
28
  import { Root } from "./Root.js";
11
29
  import { Metadata } from "../Metadata.js";
30
+ import { type ChangeRecorder, type ICollectionChangeRecorder, SchemaChangeRecorder, CollectionChangeRecorder, popcount32 } from "./ChangeRecorder.js";
12
31
  import type { EncodeOperation } from "./EncodeOperation.js";
32
+ import { type EncodeDescriptor, getEncodeDescriptor } from "./EncodeDescriptor.js";
13
33
  import type { DecodeOperation } from "../decoder/DecodeOperation.js";
14
34
 
35
+ import {
36
+ addParent as _addParent, removeParent as _removeParent,
37
+ findParent as _findParent, hasParent as _hasParent,
38
+ getAllParents as _getAllParents,
39
+ } from "./changeTree/parentChain.js";
40
+ import { forEachLive as _forEachLive, forEachLiveWithCtx as _forEachLiveWithCtx } from "./changeTree/liveIteration.js";
41
+ import {
42
+ setRoot as _setRoot, setParent as _setParent,
43
+ forEachChild as _forEachChild, forEachChildWithCtx as _forEachChildWithCtx,
44
+ } from "./changeTree/treeAttachment.js";
45
+
46
+ // Augmenting the global `Object` interface is a deliberate trade-off:
47
+ // any Schema / collection instance — regardless of which bundled
48
+ // `@colyseus/schema` version created it — can be duck-typed against
49
+ // these Symbol-keyed slots. Narrower shapes (e.g. on `IRef`) would break
50
+ // cross-version interop where server-bundled types coexist with client-
51
+ // bundled ones in the same process.
15
52
  declare global {
16
53
  interface Object {
17
- // FIXME: not a good practice to extend globals here
18
54
  [$changes]?: ChangeTree;
19
55
  // [$refId]?: number;
20
56
  [$encoder]?: EncodeOperation,
@@ -22,25 +58,40 @@ declare global {
22
58
  }
23
59
  }
24
60
 
25
- export interface IRef {
26
- // FIXME: we only commented this out to allow mixing @colyseus/schema bundled types with server types in Cocos Creator
27
- // [$changes]?: ChangeTree;
28
- [$refId]?: number;
29
- [$getByIndex]?: (index: number, isEncodeAll?: boolean) => any;
30
- [$deleteByIndex]?: (index: number) => void;
61
+ // Pure arithmetic, no `this` — V8 inlines into encode-loop forEach.
62
+ // Mirror of `ChangeTree._opAt` for the inline-ops-only branch.
63
+ function readInlineOpByte(low: number, high: number, index: number): number {
64
+ const shift = (index & 3) << 3;
65
+ return (index < 4)
66
+ ? (low >>> shift) & 0xFF
67
+ : (high >>> shift) & 0xFF;
31
68
  }
32
69
 
33
- export type Ref = Schema | ArraySchema | MapSchema | CollectionSchema | SetSchema;
70
+ // Adapter that lets `forEach(cb)` delegate to `forEachWithCtx(cb, _invokeNoCtx)`
71
+ // no per-call closure allocation. See ChangeRecorder.ts for the same pattern.
72
+ const _invokeNoCtx = (
73
+ cb: (index: number, op: OPERATION) => void,
74
+ index: number,
75
+ op: OPERATION,
76
+ ) => cb(index, op);
34
77
 
35
- export type ChangeSetName = "changes"
36
- | "allChanges"
37
- | "filteredChanges"
38
- | "allFilteredChanges";
39
-
40
- export interface IndexedOperations {
41
- [index: number]: OPERATION;
78
+ export interface IRef {
79
+ // `[$changes]?: ChangeTree;` is intentionally omitted here — see the
80
+ // `declare global` augmentation above. Narrowing to IRef would break
81
+ // cross-version interop (Cocos Creator bundles server- and client-
82
+ // side schema types together; a strict declaration here would reject
83
+ // one side's instances at compile time).
84
+ [$refId]?: number;
85
+ // `$getByIndex` / `$deleteByIndex` are required on every actual ref
86
+ // the decoder / encoder ever touches (Schema + every collection
87
+ // implements both). Keeping them non-optional lets hot-path call
88
+ // sites skip the `(ref as any)` cast.
89
+ [$getByIndex](index: number, isEncodeAll?: boolean): any;
90
+ [$deleteByIndex](index: number): void;
42
91
  }
43
92
 
93
+ export type Ref = Schema | ArraySchema | MapSchema | CollectionSchema | SetSchema | StreamSchema;
94
+
44
95
  // Linked list node for change trees
45
96
  export interface ChangeTreeNode {
46
97
  changeTree: ChangeTree;
@@ -55,345 +106,548 @@ export interface ChangeTreeList {
55
106
  tail?: ChangeTreeNode;
56
107
  }
57
108
 
58
- export interface ChangeSet {
59
- // field index -> operation index
60
- indexes: { [index: number]: number };
61
- operations: number[];
62
- queueRootNode?: ChangeTreeNode; // direct reference to ChangeTreeNode in the linked list
63
- }
64
-
65
- function createChangeSet(queueRootNode?: ChangeTreeNode): ChangeSet {
66
- return { indexes: {}, operations: [], queueRootNode };
67
- }
68
-
69
109
  // Linked list helper functions
70
110
  export function createChangeTreeList(): ChangeTreeList {
71
111
  return { next: undefined, tail: undefined };
72
112
  }
73
113
 
74
- export function setOperationAtIndex(changeSet: ChangeSet, index: number) {
75
- const operationsIndex = changeSet.indexes[index];
76
- if (operationsIndex === undefined) {
77
- changeSet.indexes[index] = changeSet.operations.push(index) - 1;
78
- } else {
79
- changeSet.operations[operationsIndex] = index;
80
- }
81
- }
82
-
83
- export function deleteOperationAtIndex(changeSet: ChangeSet, index: number | string) {
84
- let operationsIndex = changeSet.indexes[index as any as number];
85
- if (operationsIndex === undefined) {
86
- //
87
- // if index is not found, we need to find the last operation
88
- // FIXME: this is not very efficient
89
- //
90
- // > See "should allow consecutive splices (same place)" tests
91
- //
92
- operationsIndex = Object.values(changeSet.indexes).at(-1);
93
- index = Object.entries(changeSet.indexes).find(([_, value]) => value === operationsIndex)?.[0];
94
- }
95
- changeSet.operations[operationsIndex] = undefined;
96
- delete changeSet.indexes[index as any as number];
97
- }
98
-
99
- export function debugChangeSet(label: string, changeSet: ChangeSet) {
100
- let indexes: string[] = [];
101
- let operations: string[] = [];
102
-
103
- for (const index in changeSet.indexes) {
104
- indexes.push(`\t${index} => [${changeSet.indexes[index]}]`);
105
- }
106
-
107
- for (let i = 0; i < changeSet.operations.length; i++) {
108
- const index = changeSet.operations[i];
109
- if (index !== undefined) {
110
- operations.push(`\t[${i}] => ${index}`);
111
- }
112
- }
113
-
114
- console.log(`${label} =>\nindexes (${Object.keys(changeSet.indexes).length}) {`);
115
- console.log(indexes.join("\n"), "\n}");
116
- console.log(`operations (${changeSet.operations.filter(op => op !== undefined).length}) {`);
117
- console.log(operations.join("\n"), "\n}");
118
- }
119
-
120
114
  export interface ParentChain {
121
115
  ref: Ref;
122
116
  index: number;
123
117
  next?: ParentChain;
124
118
  }
125
119
 
126
- export class ChangeTree<T extends Ref = any> {
120
+ // Flags bitfield. *_UNRELIABLE / _TRANSIENT / _STATIC mirror the parent
121
+ // field's annotation — inherited at setParent/setRoot time.
122
+ export const IS_FILTERED = 1, IS_VISIBILITY_SHARED = 2, IS_NEW = 4;
123
+ export const IS_UNRELIABLE = 8, IS_TRANSIENT = 16, IS_STATIC = 32;
124
+ // Collection tree attached to a parent field annotated `.stream()` —
125
+ // drives the encoder's priority/broadcast pass. Set in inheritedFlags
126
+ // so both `t.stream(X)` (via StreamSchema's `$isStream` brand) and
127
+ // `t.map(X).stream()` / `t.set(X).stream()` route through the same
128
+ // emission machinery.
129
+ export const IS_STREAM_COLLECTION = 64;
130
+ /**
131
+ * Flags a child inherits from its parent's own transitive state via
132
+ * `checkInheritedFlags`. Read as a bitwise mask so the inheritance step
133
+ * is a single OR instead of three getter/setter pairs.
134
+ *
135
+ * `IS_UNRELIABLE` is intentionally excluded: `@unreliable` is rejected
136
+ * at decoration time for ref-type fields (see `Metadata.setUnreliable`)
137
+ * because an unreliable ADD/DELETE could leave the decoder unable to
138
+ * interpret later packets referencing an orphan refId. Tree-level
139
+ * unreliable is therefore dead on every Schema/Collection tree today;
140
+ * the bit and its machinery are kept in place so this can be
141
+ * reconsidered if a safe semantics (e.g. reliable ADD + unreliable
142
+ * field mutations only) is designed later.
143
+ */
144
+ export const INHERITABLE_FLAGS = IS_TRANSIENT | IS_STATIC;
145
+
146
+ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
127
147
  ref: T;
128
- metadata: Metadata;
129
148
 
130
- root?: Root;
131
- parentChain?: ParentChain; // Linked list for tracking parents
149
+ /**
150
+ * Non-Proxy target of `ref` for encoder hot-path reads. For
151
+ * `ArraySchema`, `ref` is the Proxy users interact with; every property
152
+ * access on it runs through the `get` trap (even for symbol keys, which
153
+ * fall through to `Reflect.get` — one extra hop per lookup). The encoder
154
+ * loop reads `[$getByIndex]`, `[$childType]`, `.items`, `.tmpItems` at
155
+ * high frequency during `encode()` / `encodeAll()`; going through
156
+ * `refTarget` skips all of those traps.
157
+ *
158
+ * For non-proxied types (Schema, MapSchema, SetSchema, CollectionSchema,
159
+ * StreamSchema), `refTarget === ref`. Consumers that need the user-
160
+ * facing identity (debug output, callback parents) keep using `ref`.
161
+ */
162
+ refTarget: T;
163
+
164
+ metadata: Metadata;
132
165
 
133
166
  /**
134
- * Whether this structure is parent of a filtered structure.
167
+ * Per-class cache of encoder fn / filter fn / isSchema / filterBitmask /
168
+ * metadata, looked up once at construction. The encode loop reads
169
+ * `tree.encDescriptor` and never touches `ref.constructor` again. See
170
+ * EncodeDescriptor.ts.
135
171
  */
136
- isFiltered: boolean = false;
137
- isVisibilitySharedWithParent?: boolean; // See test case: 'should not be required to manually call view.add() items to child arrays without @view() tag'
172
+ encDescriptor: EncodeDescriptor;
138
173
 
139
- indexedOperations: IndexedOperations = {};
174
+ root?: Root;
140
175
 
141
- //
142
- // TODO:
143
- // try storing the index + operation per item.
144
- // example: 1024 & 1025 => ADD, 1026 => DELETE
145
- //
146
- // => https://chatgpt.com/share/67107d0c-bc20-8004-8583-83b17dd7c196
147
- //
148
- changes: ChangeSet = { indexes: {}, operations: [] };
149
- allChanges: ChangeSet = { indexes: {}, operations: [] };
150
- filteredChanges: ChangeSet;
151
- allFilteredChanges: ChangeSet;
176
+ // Inline single parent (the common case)
177
+ parentRef?: Ref;
178
+ _parentIndex?: number;
179
+ extraParents?: ParentChain; // linked list for 2nd+ parents (rare: instance sharing)
152
180
 
153
- indexes: { [index: string]: any }; // TODO: remove this, only used by MapSchema/SetSchema/CollectionSchema (`encodeKeyValueOperation`)
181
+ // Packed boolean flags. See IS_* constants above for bit layout.
182
+ flags: number = IS_NEW;
154
183
 
155
184
  /**
156
- * Is this a new instance? Used on ArraySchema to determine OPERATION.MOVE_AND_ADD operation.
185
+ * Per-walk visit stamp written by `Encoder.encodeFullSync`'s DFS. A
186
+ * tree is considered "already visited by the current walk" iff
187
+ * `tree._fullSyncGen === ctx.gen` — the encoder bumps its generation
188
+ * counter once per walk, then stamps each tree with that value on
189
+ * first visit; any later encounter of the same tree (shared refs
190
+ * reachable through multiple parents) short-circuits on the equality
191
+ * check instead of recursing again.
157
192
  */
158
- isNew = true;
193
+ _fullSyncGen: number = 0;
159
194
 
160
- constructor(ref: T) {
161
- this.ref = ref;
162
- this.metadata = (ref.constructor as typeof Schema)[Symbol.metadata];
195
+ // Schema vs Collection discriminator. Set once in ctor, never changes —
196
+ // per-tree-stable branch for inline ChangeRecorder dispatch.
197
+ _isSchema: boolean = false;
163
198
 
164
- //
165
- // Does this structure have "filters" declared?
166
- //
167
- if (this.metadata?.[$viewFieldIndexes]) {
168
- this.allFilteredChanges = { indexes: {}, operations: [] };
169
- this.filteredChanges = { indexes: {}, operations: [] };
170
- }
171
- }
199
+ // Inline reliable SchemaChangeRecorder state (valid only if _isSchema).
200
+ dirtyLow: number = 0;
201
+ dirtyHigh: number = 0;
172
202
 
173
- setRoot(root: Root) {
174
- this.root = root;
203
+ // Inline ops for Schemas with ≤8 fields (4 op-bytes per number).
204
+ // When `ops` is set (>8 fields), reads/writes go through the Uint8Array.
205
+ opsLow: number = 0;
206
+ opsHigh: number = 0;
207
+ ops?: Uint8Array;
175
208
 
176
- const isNewChangeTree = this.root.add(this);
209
+ // Inline reliable CollectionChangeRecorder state (valid only if !_isSchema).
210
+ // `collDirty` is allocated in the ctor. `collPureOps` stays undefined
211
+ // until the first CLEAR/REVERSE (most workloads never hit this).
212
+ collDirty?: Map<number, OPERATION>;
213
+ collPureOps?: Array<[number, OPERATION]>;
177
214
 
178
- this.checkIsFiltered(this.parent, this.parentIndex, isNewChangeTree);
215
+ // Lazy-allocated unreliable-channel recorder (rare opt-in via @unreliable).
216
+ unreliableRecorder?: ChangeRecorder;
179
217
 
180
- // Recursively set root on child structures
181
- if (isNewChangeTree) {
182
- this.forEachChild((child, _) => {
183
- if (child.root !== root) {
184
- child.setRoot(root);
185
- } else {
186
- root.add(child); // increment refCount
187
- }
188
- });
189
- }
190
- }
218
+ // When true, mutations on the ref are NOT tracked. See pause/resume/untracked.
219
+ paused: boolean = false;
191
220
 
192
- setParent(
193
- parent: Ref,
194
- root?: Root,
195
- parentIndex?: number,
196
- ) {
197
- this.addParent(parent, parentIndex);
221
+ changesNode?: ChangeTreeNode; // Root.changes linked-list node
222
+ unreliableChangesNode?: ChangeTreeNode; // Root.unreliableChanges linked-list node
198
223
 
199
- // avoid setting parents with empty `root`
200
- if (!root) { return; }
224
+ // Per-StateView visibility bitmaps. Bit `(viewId & 31)` in slot
225
+ // `(viewId >> 5)` is set iff the view can see this tree. Replaces
226
+ // per-view WeakSet lookups with direct bitwise ops.
227
+ // Lazy: undefined until the tree participates in any view.
228
+ visibleViews?: number[];
229
+ invisibleViews?: number[];
201
230
 
202
- const isNewChangeTree = root.add(this);
231
+ // Per-(view, tag) bitmap, indexed by tag. Custom tags only —
232
+ // DEFAULT_VIEW_TAG visibility lives in `visibleViews`.
233
+ tagViews?: Map<number, number[]>;
203
234
 
204
- // skip if parent is already set
205
- if (root !== this.root) {
206
- this.root = root;
207
- this.checkIsFiltered(parent, parentIndex, isNewChangeTree);
235
+ /**
236
+ * Per-view subscription bitmap — same layout as `visibleViews`. Set by
237
+ * `StateView.subscribe(collection)` to mark the view as persistently
238
+ * interested in this collection's contents. When a new child is
239
+ * attached to a subscribed collection (setParent hook), it's
240
+ * auto-propagated to every subscribed view (force-shipped for
241
+ * Array/Map/Set/Collection; enqueued into per-view pending for
242
+ * streams). Undefined until the first subscribe.
243
+ */
244
+ subscribedViews?: number[];
245
+
246
+ // Accessor properties for flags
247
+ get isFiltered() { return (this.flags & IS_FILTERED) !== 0; }
248
+ set isFiltered(v: boolean) { this.flags = v ? (this.flags | IS_FILTERED) : (this.flags & ~IS_FILTERED); }
249
+ get isVisibilitySharedWithParent() { return (this.flags & IS_VISIBILITY_SHARED) !== 0; }
250
+ set isVisibilitySharedWithParent(v: boolean) { this.flags = v ? (this.flags | IS_VISIBILITY_SHARED) : (this.flags & ~IS_VISIBILITY_SHARED); }
251
+ get isNew() { return (this.flags & IS_NEW) !== 0; }
252
+ set isNew(v: boolean) { this.flags = v ? (this.flags | IS_NEW) : (this.flags & ~IS_NEW); }
253
+ get isUnreliable() { return (this.flags & IS_UNRELIABLE) !== 0; }
254
+ set isUnreliable(v: boolean) { this.flags = v ? (this.flags | IS_UNRELIABLE) : (this.flags & ~IS_UNRELIABLE); }
255
+ get isTransient() { return (this.flags & IS_TRANSIENT) !== 0; }
256
+ set isTransient(v: boolean) { this.flags = v ? (this.flags | IS_TRANSIENT) : (this.flags & ~IS_TRANSIENT); }
257
+ get isStatic() { return (this.flags & IS_STATIC) !== 0; }
258
+ set isStatic(v: boolean) { this.flags = v ? (this.flags | IS_STATIC) : (this.flags & ~IS_STATIC); }
259
+ get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
260
+ set isStreamCollection(v: boolean) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
261
+
262
+ // True iff tree inherits `isFiltered` OR its Schema class declares any
263
+ // @view-tagged fields. StateView.addParentOf uses this to decide whether
264
+ // a parent must be included in a view's bootstrap. Reads the class-level
265
+ // "any viewed field" flag that `EncodeDescriptor` precomputes — same
266
+ // pattern as `hasAnyStatic` / `hasAnyUnreliable` / `hasAnyStream`.
267
+ get hasFilteredFields(): boolean {
268
+ return this.isFiltered || this.encDescriptor.hasAnyView;
269
+ }
270
+
271
+ ensureUnreliableRecorder(): ChangeRecorder {
272
+ if (this.unreliableRecorder === undefined) {
273
+ const isSchema = Metadata.isValidInstance(this.ref);
274
+ this.unreliableRecorder = isSchema
275
+ ? new SchemaChangeRecorder((this.metadata?.[$numFields] ?? 0) as number)
276
+ : new CollectionChangeRecorder();
208
277
  }
278
+ return this.unreliableRecorder;
279
+ }
280
+
281
+ isFieldUnreliable(index: number): boolean {
282
+ // Tree-level `isUnreliable` is disabled — @unreliable is rejected
283
+ // on ref-type fields at decoration time, so no tree ever carries
284
+ // the flag. Kept as a comment in case a safe semantics is added
285
+ // later (see INHERITABLE_FLAGS rationale).
286
+ // if (this.isUnreliable) return true;
287
+ // Class-level fast path: most schemas have zero unreliable fields,
288
+ // so the per-mutation check resolves without the symbol-keyed
289
+ // metadata lookup. For schemas that DO have unreliable fields, the
290
+ // bitmask answers fields 0-31 in one bitwise op (no Array.includes
291
+ // linear scan). Fields ≥32 always fall back to the metadata lookup
292
+ // (same limitation as filterBitmask — bitmask only covers low 32).
293
+ const desc = this.encDescriptor;
294
+ if (!desc.hasAnyUnreliable) return false;
295
+ if (index < 32) return (desc.unreliableBitmask & (1 << index)) !== 0;
296
+ return Metadata.hasUnreliableAtIndex(this.metadata, index);
297
+ }
298
+
299
+ // @static fields sync once via full-sync; post-init mutations are ignored
300
+ // by the tracker (the value still lives on the instance).
301
+ isFieldStatic(index: number): boolean {
302
+ if (this.isStatic) return true;
303
+ const desc = this.encDescriptor;
304
+ if (!desc.hasAnyStatic) return false;
305
+ if (index < 32) return (desc.staticBitmask & (1 << index)) !== 0;
306
+ return Metadata.hasStaticAtIndex(this.metadata, index);
307
+ }
308
+
309
+ // `t.stream(...)` collection fields — encoded via per-view priority/budget
310
+ // gate instead of emitting all dirty ADDs in one tick. Class-level short
311
+ // circuit avoids the metadata chase on schemas that carry no stream fields.
312
+ isFieldStream(index: number): boolean {
313
+ const desc = this.encDescriptor;
314
+ if (!desc.hasAnyStream) return false;
315
+ if (index < 32) return (desc.streamBitmask & (1 << index)) !== 0;
316
+ return Metadata.hasStreamAtIndex(this.metadata, index);
317
+ }
209
318
 
210
- // assign same parent on child structures
211
- if (isNewChangeTree) {
212
- //
213
- // assign same parent on child structures
214
- //
215
- this.forEachChild((child, index) => {
216
- if (child.root === root) {
217
- //
218
- // re-assigning a child of the same root, move it next to parent
219
- // so encoding order is preserved
220
- //
221
- root.add(child);
222
- root.moveNextToParent(child);
223
- return;
224
- }
225
- child.setParent(this.ref, root, index);
226
- });
319
+ constructor(ref: T) {
320
+ this.ref = ref;
321
+ // `$proxyTarget` is a self-reference set by ArraySchema on the raw
322
+ // target; for non-proxied refs it's undefined and we fall back to
323
+ // `ref`. Cached here so hot-path reads skip the Proxy `get` trap.
324
+ this.refTarget = ((ref as any)[$proxyTarget] ?? ref) as T;
325
+
326
+ // Single per-class lookup that subsumes Symbol.metadata,
327
+ // isValidInstance, $encoder, $filter, and the filter bitmask.
328
+ // After this, the encode loop never touches `ref.constructor`.
329
+ const desc = getEncodeDescriptor(ref);
330
+ this.encDescriptor = desc;
331
+ this.metadata = desc.metadata;
332
+
333
+ const isSchema = desc.isSchema;
334
+ this._isSchema = isSchema;
335
+
336
+ // Assign every optional slot so Schema and Collection trees share
337
+ // one hidden-class transition path (tsconfig useDefineForClassFields=false
338
+ // otherwise leaves uninitialized class fields absent from the shape).
339
+ this.ops = undefined;
340
+ this.collDirty = undefined;
341
+ this.collPureOps = undefined;
342
+
343
+ if (isSchema) {
344
+ const numFields = (this.metadata?.[$numFields] ?? 0) as number;
345
+ if (numFields > 7) this.ops = new Uint8Array(numFields + 1);
346
+ } else {
347
+ this.collDirty = new Map();
227
348
  }
228
349
  }
229
350
 
230
- forEachChild(callback: (change: ChangeTree, at: any) => void) {
231
- //
232
- // assign same parent on child structures
233
- //
234
- if ((this.ref as any)[$childType]) {
235
- if (typeof ((this.ref as any)[$childType]) !== "string") {
236
- // MapSchema / ArraySchema, etc.
237
- for (const [key, value] of (this.ref as MapSchema).entries()) {
238
- if (!value) { continue; } // sparse arrays can have undefined values
239
- callback(value[$changes], this.indexes?.[key] ?? key);
240
- };
241
- }
351
+ // ────────────────────────────────────────────────────────────────────
352
+ // Inline ChangeRecorder implementation. Each method branches once on
353
+ // `_isSchema` (per-tree-stable predictable branch). Kills one
354
+ // CollectionChangeRecorder+Map allocation per Collection tree.
355
+ // ────────────────────────────────────────────────────────────────────
242
356
 
243
- } else {
244
- for (const index of this.metadata?.[$refTypeFieldIndexes] ?? []) {
245
- const field = this.metadata[index as any as number];
246
- const value = this.ref[field.name as keyof Ref];
247
- if (!value) { continue; }
248
- callback(value[$changes], index);
357
+ // Schema-only helpers that own all inline-vs-array dispatch.
358
+ private _opAt(index: number): number {
359
+ const ops = this.ops;
360
+ if (ops !== undefined) return ops[index];
361
+ const shift = (index & 3) << 3;
362
+ return (index < 4)
363
+ ? (this.opsLow >>> shift) & 0xFF
364
+ : (this.opsHigh >>> shift) & 0xFF;
365
+ }
366
+
367
+ private _opPut(index: number, op: OPERATION): void {
368
+ const ops = this.ops;
369
+ if (ops !== undefined) {
370
+ ops[index] = op;
371
+ return;
372
+ }
373
+ const shift = (index & 3) << 3;
374
+ const mask = ~(0xFF << shift);
375
+ if (index < 4) this.opsLow = (this.opsLow & mask) | (op << shift);
376
+ else this.opsHigh = (this.opsHigh & mask) | (op << shift);
377
+ }
378
+
379
+ private _markDirty(index: number): void {
380
+ if (index < 32) this.dirtyLow |= (1 << index);
381
+ else this.dirtyHigh |= (1 << (index - 32));
382
+ }
383
+
384
+ record(index: number, op: OPERATION): void {
385
+ if (this._isSchema) {
386
+ const prev = this._opAt(index);
387
+ if (prev === 0) this._opPut(index, op);
388
+ else if (prev === OPERATION.DELETE) this._opPut(index, OPERATION.DELETE_AND_ADD);
389
+ // Promote ADD → DELETE_AND_ADD when a ref is replaced in the
390
+ // same tick. Otherwise the on-wire op collapses to plain ADD
391
+ // and the decoder's `refs` map leaks the displaced refId —
392
+ // harmless on its own, but refId pooling turns that leak into
393
+ // a catastrophic rebinding when the refId is later reused.
394
+ else if (prev === OPERATION.ADD && op === OPERATION.DELETE_AND_ADD) {
395
+ this._opPut(index, OPERATION.DELETE_AND_ADD);
249
396
  }
397
+ // else: existing ADD / DELETE_AND_ADD — preserve op-byte.
398
+ this._markDirty(index);
399
+ } else {
400
+ const dirty = this.collDirty!;
401
+ const prev = dirty.get(index);
402
+ let finalOp: OPERATION;
403
+ if (prev === undefined) finalOp = op;
404
+ else if (prev === OPERATION.DELETE) finalOp = OPERATION.DELETE_AND_ADD;
405
+ else if (prev === OPERATION.ADD && op === OPERATION.DELETE_AND_ADD) finalOp = OPERATION.DELETE_AND_ADD;
406
+ else finalOp = prev;
407
+ dirty.set(index, finalOp);
250
408
  }
251
409
  }
252
410
 
253
- operation(op: OPERATION) {
254
- // operations without index use negative values to represent them
255
- // this is checked during .encode() time.
256
- if (this.filteredChanges !== undefined) {
257
- this.filteredChanges.operations.push(-op);
258
- this.root?.enqueueChangeTree(this, 'filteredChanges');
411
+ recordDelete(index: number, op: OPERATION): void {
412
+ if (this._isSchema) {
413
+ this._opPut(index, op);
414
+ this._markDirty(index);
415
+ } else {
416
+ this.collDirty!.set(index, op);
417
+ }
418
+ }
259
419
 
420
+ recordRaw(index: number, op: OPERATION): void {
421
+ if (this._isSchema) {
422
+ this._opPut(index, op);
423
+ this._markDirty(index);
260
424
  } else {
261
- this.changes.operations.push(-op);
262
- this.root?.enqueueChangeTree(this, 'changes');
425
+ this.collDirty!.set(index, op);
263
426
  }
264
427
  }
265
428
 
266
- change(index: number, operation: OPERATION = OPERATION.ADD) {
267
- const isFiltered = this.isFiltered || (this.metadata?.[index]?.tag !== undefined);
268
- const changeSet = (isFiltered)
269
- ? this.filteredChanges
270
- : this.changes;
271
-
272
- const previousOperation = this.indexedOperations[index];
273
- if (!previousOperation || previousOperation === OPERATION.DELETE) {
274
- const op = (!previousOperation)
275
- ? operation
276
- : (previousOperation === OPERATION.DELETE)
277
- ? OPERATION.DELETE_AND_ADD
278
- : operation
279
- //
280
- // TODO: are DELETE operations being encoded as ADD here ??
281
- //
282
- this.indexedOperations[index] = op;
429
+ recordPure(op: OPERATION): void {
430
+ if (this._isSchema) {
431
+ throw new Error("ChangeTree (Schema): pure operations are not supported");
283
432
  }
433
+ (this.collPureOps ??= []).push([this.collDirty!.size, op]);
434
+ }
284
435
 
285
- setOperationAtIndex(changeSet, index);
436
+ operationAt(index: number): OPERATION | undefined {
437
+ if (this._isSchema) {
438
+ const op = this._opAt(index);
439
+ return op === 0 ? undefined : op;
440
+ }
441
+ return this.collDirty!.get(index);
442
+ }
286
443
 
287
- if (isFiltered) {
288
- setOperationAtIndex(this.allFilteredChanges, index);
444
+ setOperationAt(index: number, op: OPERATION): void {
445
+ // Schema: overwrite only (no dirty-mark). Collection: overwrite iff key exists (legacy).
446
+ if (this._isSchema) {
447
+ this._opPut(index, op);
448
+ } else {
449
+ const dirty = this.collDirty!;
450
+ if (dirty.has(index)) dirty.set(index, op);
451
+ }
452
+ }
289
453
 
290
- if (this.root) {
291
- this.root.enqueueChangeTree(this, 'filteredChanges');
292
- this.root.enqueueChangeTree(this, 'allFilteredChanges');
454
+ // Cold-path delegate: all `forEach` callers are debug/dump utilities
455
+ // (Schema.ts debug output, utils.ts change dump, discardAll in tests).
456
+ // The hot encode loop uses `forEachWithCtx` directly. See ChangeRecorder.ts
457
+ // for the same adapter pattern.
458
+ forEach(cb: (index: number, op: OPERATION) => void): void {
459
+ this.forEachWithCtx(cb, _invokeNoCtx);
460
+ }
461
+
462
+ forEachWithCtx<C>(ctx: C, cb: (ctx: C, index: number, op: OPERATION) => void): void {
463
+ if (this._isSchema) {
464
+ let low = this.dirtyLow;
465
+ let high = this.dirtyHigh;
466
+ const ops = this.ops;
467
+ if (ops !== undefined) {
468
+ while (low !== 0) {
469
+ const bit = low & -low;
470
+ const fieldIndex = 31 - Math.clz32(bit);
471
+ low ^= bit;
472
+ cb(ctx, fieldIndex, ops[fieldIndex]);
473
+ }
474
+ while (high !== 0) {
475
+ const bit = high & -high;
476
+ const fieldIndex = 31 - Math.clz32(bit) + 32;
477
+ high ^= bit;
478
+ cb(ctx, fieldIndex, ops[fieldIndex]);
479
+ }
480
+ } else {
481
+ const ol = this.opsLow;
482
+ const oh = this.opsHigh;
483
+ while (low !== 0) {
484
+ const bit = low & -low;
485
+ const fieldIndex = 31 - Math.clz32(bit);
486
+ low ^= bit;
487
+ cb(ctx, fieldIndex, readInlineOpByte(ol, oh, fieldIndex));
488
+ }
489
+ }
490
+ return;
491
+ }
492
+ const dirty = this.collDirty!;
493
+ const pure = this.collPureOps;
494
+ if (pure !== undefined && pure.length > 0) {
495
+ let pureIdx = 0, i = 0;
496
+ for (const [index, op] of dirty) {
497
+ while (pureIdx < pure.length && pure[pureIdx][0] <= i) {
498
+ const pureOp = pure[pureIdx++][1];
499
+ cb(ctx, -pureOp, pureOp);
500
+ }
501
+ cb(ctx, index, op);
502
+ i++;
503
+ }
504
+ while (pureIdx < pure.length) {
505
+ const pureOp = pure[pureIdx++][1];
506
+ cb(ctx, -pureOp, pureOp);
293
507
  }
294
-
295
508
  } else {
296
- setOperationAtIndex(this.allChanges, index);
297
- this.root?.enqueueChangeTree(this, 'changes');
509
+ for (const [index, op] of dirty) cb(ctx, index, op);
298
510
  }
299
511
  }
300
512
 
301
- shiftChangeIndexes(shiftIndex: number) {
302
- //
303
- // Used only during:
304
- //
305
- // - ArraySchema#unshift()
306
- //
307
- const changeSet = (this.isFiltered)
308
- ? this.filteredChanges
309
- : this.changes;
310
-
311
- const newIndexedOperations: any = {};
312
- const newIndexes: { [index: number]: number } = {};
313
- for (const index in this.indexedOperations) {
314
- newIndexedOperations[Number(index) + shiftIndex] = this.indexedOperations[index];
315
- newIndexes[Number(index) + shiftIndex] = changeSet.indexes[index];
513
+ size(): number {
514
+ if (this._isSchema) return popcount32(this.dirtyLow) + popcount32(this.dirtyHigh);
515
+ return this.collDirty!.size + (this.collPureOps?.length ?? 0);
516
+ }
517
+
518
+ has(): boolean {
519
+ if (this._isSchema) return (this.dirtyLow | this.dirtyHigh) !== 0;
520
+ return this.collDirty!.size > 0 || (this.collPureOps !== undefined && this.collPureOps.length > 0);
521
+ }
522
+
523
+ reset(): void {
524
+ if (this._isSchema) {
525
+ this.dirtyLow = 0;
526
+ this.dirtyHigh = 0;
527
+ if (this.ops !== undefined) this.ops.fill(0);
528
+ else { this.opsLow = 0; this.opsHigh = 0; }
529
+ return;
316
530
  }
317
- this.indexedOperations = newIndexedOperations;
318
- changeSet.indexes = newIndexes;
531
+ this.collDirty!.clear();
532
+ if (this.collPureOps !== undefined) this.collPureOps.length = 0;
533
+ }
319
534
 
320
- changeSet.operations = changeSet.operations.map((index) => index + shiftIndex);
535
+ shift(shiftIndex: number): void {
536
+ if (this._isSchema) throw new Error("ChangeTree (Schema): shift is not supported");
537
+ const src = this.collDirty!;
538
+ const dst = new Map<number, OPERATION>();
539
+ for (const [idx, val] of src) dst.set(idx + shiftIndex, val);
540
+ this.collDirty = dst;
321
541
  }
322
542
 
323
- shiftAllChangeIndexes(shiftIndex: number, startIndex: number = 0) {
324
- //
325
- // Used only during:
326
- //
327
- // - ArraySchema#splice()
328
- //
329
- if (this.filteredChanges !== undefined) {
330
- this._shiftAllChangeIndexes(shiftIndex, startIndex, this.allFilteredChanges);
331
- this._shiftAllChangeIndexes(shiftIndex, startIndex, this.allChanges);
543
+ // Tree attachment + child iteration — see ./changeTree/treeAttachment.ts.
544
+ setRoot(root: Root): void { _setRoot(this, root); }
545
+ setParent(parent: Ref, root?: Root, parentIndex?: number): void { _setParent(this, parent, root, parentIndex); }
546
+ forEachChild(cb: (change: ChangeTree, at: any) => void): void { _forEachChild(this, cb); }
547
+ forEachChildWithCtx<C>(ctx: C, cb: (ctx: C, change: ChangeTree, at: any) => void): void {
548
+ _forEachChildWithCtx(this, ctx, cb);
549
+ }
550
+ forEachLive(cb: (index: number) => void): void { _forEachLive(this, cb); }
551
+ forEachLiveWithCtx<C>(ctx: C, cb: (ctx: C, index: number) => void): void {
552
+ _forEachLiveWithCtx(this, ctx, cb);
553
+ }
332
554
 
333
- } else {
334
- this._shiftAllChangeIndexes(shiftIndex, startIndex, this.allChanges);
335
- }
555
+ operation(op: OPERATION) {
556
+ if (this.paused || this.isStatic) return;
557
+ // Pure ops (CLEAR/REVERSE) only emit from collection trees — the
558
+ // recorder here is always a CollectionChangeRecorder by construction.
559
+ //
560
+ // Tree-level `isUnreliable` is disabled (see INHERITABLE_FLAGS):
561
+ // no collection tree can be marked unreliable as a whole under the
562
+ // ref-field rejection rule in `Metadata.setUnreliable`. The branch
563
+ // is kept as a comment for re-enablement.
564
+ // if (this.isUnreliable) {
565
+ // (this.ensureUnreliableRecorder() as ICollectionChangeRecorder).recordPure(op);
566
+ // this.root?.enqueueUnreliable(this);
567
+ // } else {
568
+ this.recordPure(op);
569
+ this.root?.enqueueChangeTree(this);
570
+ // }
336
571
  }
337
572
 
338
- private _shiftAllChangeIndexes(shiftIndex: number, startIndex: number = 0, changeSet: ChangeSet) {
339
- const newIndexes: { [index: number]: number } = {};
340
- let newKey = 0;
341
- for (const key in changeSet.indexes) {
342
- newIndexes[newKey++] = changeSet.indexes[key];
573
+ /**
574
+ * Route a field-level mutation to the reliable or unreliable channel
575
+ * and enqueue into the matching queue. Shared by `change` and
576
+ * `indexedOperation`; `raw=true` bypasses DELETE→ADD merge
577
+ * (ArraySchema positional writes), `raw=false` merges inside `record`.
578
+ *
579
+ * Note: record() on both channels handles DELETE→ADD merge internally,
580
+ * so callers do not need to pre-compute the merged op.
581
+ *
582
+ * `@unreliable` is decoration-time-validated to apply only to primitive
583
+ * fields (see annotations.ts), so the per-field unreliable flag here
584
+ * always means "primitive value updates" — the structural-ADD-routes-
585
+ * reliable footgun for ref-type fields can't reach this code path.
586
+ */
587
+ private _routeAndRecord(index: number, op: OPERATION, raw: boolean): void {
588
+ if (this.paused || this.isFieldStatic(index)) return;
589
+ if (this.isFieldUnreliable(index)) {
590
+ const r = this.ensureUnreliableRecorder();
591
+ if (raw) r.recordRaw(index, op);
592
+ else r.record(index, op);
593
+ this.root?.enqueueUnreliable(this);
594
+ return;
343
595
  }
344
- changeSet.indexes = newIndexes;
596
+ if (raw) this.recordRaw(index, op);
597
+ else this.record(index, op);
598
+ this.root?.enqueueChangeTree(this);
599
+ }
345
600
 
346
- for (let i = 0; i < changeSet.operations.length; i++) {
347
- const index = changeSet.operations[i];
348
- if (index > startIndex) {
349
- changeSet.operations[i] = index + shiftIndex;
350
- }
351
- }
601
+ change(index: number, operation: OPERATION = OPERATION.ADD) {
602
+ this._routeAndRecord(index, operation, false);
352
603
  }
353
604
 
354
- indexedOperation(index: number, operation: OPERATION, allChangesIndex: number = index) {
355
- this.indexedOperations[index] = operation;
605
+ indexedOperation(index: number, operation: OPERATION) {
606
+ this._routeAndRecord(index, operation, true);
607
+ }
356
608
 
357
- if (this.filteredChanges !== undefined) {
358
- setOperationAtIndex(this.allFilteredChanges, allChangesIndex);
359
- setOperationAtIndex(this.filteredChanges, index);
360
- this.root?.enqueueChangeTree(this, 'filteredChanges');
609
+ // ArraySchema#unshift(): apply shift to both channels.
610
+ // Unreliable recorder on an array is always a CollectionChangeRecorder.
611
+ shiftChangeIndexes(shiftIndex: number) {
612
+ this.shift(shiftIndex);
613
+ (this.unreliableRecorder as ICollectionChangeRecorder | undefined)?.shift(shiftIndex);
614
+ }
361
615
 
362
- } else {
363
- setOperationAtIndex(this.allChanges, allChangesIndex);
364
- setOperationAtIndex(this.changes, index);
365
- this.root?.enqueueChangeTree(this, 'changes');
366
- }
616
+ getChange(index: number) {
617
+ return this.operationAt(index);
367
618
  }
368
619
 
369
- getType(index?: number) {
370
- return (
371
- //
372
- // Get the child type from parent structure.
373
- // - ["string"] => "string"
374
- // - { map: "string" } => "string"
375
- // - { set: "string" } => "string"
376
- //
377
- (this.ref as any)[$childType] || // ArraySchema | MapSchema | SetSchema | CollectionSchema
378
- this.metadata[index].type // Schema
379
- );
620
+ // ────────────────────────────────────────────────────────────────────
621
+ // Change-tracking control API
622
+ // ────────────────────────────────────────────────────────────────────
623
+
624
+ pause(): void { this.paused = true; }
625
+ resume(): void { this.paused = false; }
626
+
627
+ untracked<T>(fn: () => T): T {
628
+ const wasPaused = this.paused;
629
+ this.paused = true;
630
+ try { return fn(); }
631
+ finally { this.paused = wasPaused; }
380
632
  }
381
633
 
382
- getChange(index: number) {
383
- return this.indexedOperations[index];
634
+ // Manually mark a field dirty for the next encode(). Useful after a
635
+ // paused mutation or a nested mutation that bypassed the setter.
636
+ markDirty(index: number, operation: OPERATION = OPERATION.ADD): void {
637
+ const wasPaused = this.paused;
638
+ this.paused = false;
639
+ try { this.change(index, operation); }
640
+ finally { this.paused = wasPaused; }
384
641
  }
385
642
 
386
- //
387
- // used during `.encode()`
388
- //
643
+ // used during `.encode()` — `isEncodeAll` is only consumed by ArraySchema.
644
+ // Reads via `refTarget` so ArraySchema's Proxy trap is bypassed on the
645
+ // hot per-field encode path.
389
646
  getValue(index: number, isEncodeAll: boolean = false) {
390
- //
391
- // `isEncodeAll` param is only used by ArraySchema
392
- //
393
- return (this.ref as any)[$getByIndex](index, isEncodeAll);
647
+ return this.refTarget[$getByIndex](index, isEncodeAll);
394
648
  }
395
649
 
396
- delete(index: number, operation?: OPERATION, allChangesIndex = index) {
650
+ delete(index: number, operation?: OPERATION) {
397
651
  if (index === undefined) {
398
652
  try {
399
653
  throw new Error(`@colyseus/schema ${this.ref.constructor.name}: trying to delete non-existing index '${index}'`);
@@ -403,292 +657,193 @@ export class ChangeTree<T extends Ref = any> {
403
657
  return;
404
658
  }
405
659
 
406
- const changeSet = (this.filteredChanges !== undefined)
407
- ? this.filteredChanges
408
- : this.changes;
660
+ if (this.paused || this.isFieldStatic(index)) return this.getValue(index);
409
661
 
410
- this.indexedOperations[index] = operation ?? OPERATION.DELETE;
411
- setOperationAtIndex(changeSet, index);
412
- deleteOperationAtIndex(this.allChanges, allChangesIndex);
662
+ const unreliable = this.isFieldUnreliable(index);
663
+ if (unreliable) this.ensureUnreliableRecorder().recordDelete(index, operation ?? OPERATION.DELETE);
664
+ else this.recordDelete(index, operation ?? OPERATION.DELETE);
413
665
 
414
666
  const previousValue = this.getValue(index);
415
667
 
416
- // remove `root` reference
417
- if (previousValue && previousValue[$changes]) {
418
- //
419
- // FIXME: this.root is "undefined"
420
- //
421
- // This method is being called at decoding time when a DELETE operation is found.
422
- //
423
- // - This is due to using the concrete Schema class at decoding time.
424
- // - "Reflected" structures do not have this problem.
425
- //
426
- // (The property descriptors should NOT be used at decoding time. only at encoding time.)
427
- //
428
- this.root?.remove(previousValue[$changes]);
429
- }
668
+ // `this.root` is always undefined on decoder-side instances
669
+ // (they're built via `initializeForDecoder`, which skips Root
670
+ // attachment). The optional chain handles both sides; this is
671
+ // an intentional invariant, not a bug.
672
+ if (previousValue && previousValue[$changes]) this.root?.remove(previousValue[$changes]);
430
673
 
431
- //
432
- // FIXME: this is looking a ugly and repeated
433
- //
434
- if (this.filteredChanges !== undefined) {
435
- deleteOperationAtIndex(this.allFilteredChanges, allChangesIndex);
436
- this.root?.enqueueChangeTree(this, 'filteredChanges');
437
-
438
- } else {
439
- this.root?.enqueueChangeTree(this, 'changes');
440
- }
674
+ if (unreliable) this.root?.enqueueUnreliable(this);
675
+ else this.root?.enqueueChangeTree(this);
441
676
 
442
677
  return previousValue;
443
678
  }
444
679
 
445
- endEncode(changeSetName: ChangeSetName) {
446
- this.indexedOperations = {};
447
-
448
- // clear changeset
449
- this[changeSetName] = createChangeSet();
450
-
451
- // ArraySchema and MapSchema have a custom "encode end" method
680
+ // Clear the reliable dirty bucket after a reliable encode pass.
681
+ endEncode() {
682
+ this.reset();
683
+ this.changesNode = undefined;
452
684
  (this.ref as any)[$onEncodeEnd]?.();
453
-
454
- // Not a new instance anymore
455
685
  this.isNew = false;
456
686
  }
457
687
 
458
- discard(discardAll: boolean = false) {
459
- //
460
- // > MapSchema:
461
- // Remove cached key to ensure ADD operations is unsed instead of
462
- // REPLACE in case same key is used on next patches.
463
- //
688
+ // Clear the unreliable dirty bucket after an unreliable encode pass.
689
+ endEncodeUnreliable() {
690
+ this.unreliableRecorder?.reset();
691
+ this.unreliableChangesNode = undefined;
464
692
  (this.ref as any)[$onEncodeEnd]?.();
693
+ }
465
694
 
466
- this.indexedOperations = {};
467
- this.changes = createChangeSet(this.changes.queueRootNode);
468
-
469
- if (this.filteredChanges !== undefined) {
470
- this.filteredChanges = createChangeSet(this.filteredChanges.queueRootNode);
471
- }
472
-
473
- if (discardAll) {
474
- // preserve queueRootNode references
475
- this.allChanges = createChangeSet(this.allChanges.queueRootNode);
476
-
477
- if (this.allFilteredChanges !== undefined) {
478
- this.allFilteredChanges = createChangeSet(this.allFilteredChanges.queueRootNode);
479
- }
480
- }
695
+ discard() {
696
+ (this.ref as any)[$onEncodeEnd]?.();
697
+ this.reset();
698
+ this.unreliableRecorder?.reset();
481
699
  }
482
700
 
483
- /**
484
- * Recursively discard all changes from this, and child structures.
485
- * (Used in tests only)
486
- */
701
+ // Recursively discard all changes on this + child structures. Tests only.
487
702
  discardAll() {
488
- const keys = Object.keys(this.indexedOperations);
489
- for (let i = 0, len = keys.length; i < len; i++) {
490
- const value = this.getValue(Number(keys[i]));
491
-
492
- if (value && value[$changes]) {
493
- value[$changes].discardAll();
494
- }
495
- }
496
-
703
+ const discardChild = (index: number) => {
704
+ if (index < 0) return;
705
+ const value = this.getValue(index);
706
+ if (value && value[$changes]) value[$changes].discardAll();
707
+ };
708
+ this.forEach(discardChild);
709
+ this.unreliableRecorder?.forEach(discardChild);
497
710
  this.discard();
498
711
  }
499
712
 
500
713
  get changed() {
501
- return (Object.entries(this.indexedOperations).length > 0);
714
+ return this.has() || (this.unreliableRecorder?.has() ?? false);
502
715
  }
503
716
 
504
- protected checkIsFiltered(parent: Ref, parentIndex: number, isNewChangeTree: boolean) {
505
- if (this.root.types.hasFilters) {
506
- //
507
- // At Schema initialization, the "root" structure might not be available
508
- // yet, as it only does once the "Encoder" has been set up.
509
- //
510
- // So the "parent" may be already set without a "root".
511
- //
512
- this._checkFilteredByParent(parent, parentIndex);
717
+ // ────────────────────────────────────────────────────────────────────
718
+ // Parent chain — implementations in ./changeTree/parentChain.ts.
719
+ // ────────────────────────────────────────────────────────────────────
513
720
 
514
- if (this.filteredChanges !== undefined) {
515
- this.root?.enqueueChangeTree(this, 'filteredChanges');
721
+ /** Immediate parent (primary). See `extraParents` for the 2nd+ chain. */
722
+ get parent(): Ref | undefined { return this.parentRef; }
723
+ get parentIndex(): number | undefined { return this._parentIndex; }
516
724
 
517
- if (isNewChangeTree) {
518
- this.root?.enqueueChangeTree(this, 'allFilteredChanges');
519
- }
520
- }
521
- }
725
+ addParent(parent: Ref, index: number): void { _addParent(this, parent, index); }
522
726
 
523
- if (!this.isFiltered) {
524
- this.root?.enqueueChangeTree(this, 'changes');
727
+ /** @returns true if parent was found and removed */
728
+ removeParent(parent: Ref = this.parent): boolean { return _removeParent(this, parent); }
525
729
 
526
- if (isNewChangeTree) {
527
- this.root?.enqueueChangeTree(this, 'allChanges');
528
- }
529
- }
730
+ findParent(predicate: (parent: Ref, index: number) => boolean): ParentChain | undefined {
731
+ return _findParent(this, predicate);
530
732
  }
531
733
 
532
- protected _checkFilteredByParent(parent: Ref, parentIndex: number) {
533
- // skip if parent is not set
534
- if (!parent) { return; }
535
-
536
- //
537
- // ArraySchema | MapSchema - get the child type
538
- // (if refType is typeof string, the parentFiltered[key] below will always be invalid)
539
- //
540
- const refType = Metadata.isValidInstance(this.ref)
541
- ? this.ref.constructor
542
- : (this.ref as any)[$childType];
543
-
544
- let parentChangeTree: ChangeTree;
545
-
546
- let parentIsCollection = !Metadata.isValidInstance(parent);
547
- if (parentIsCollection) {
548
- parentChangeTree = parent[$changes];
549
- parent = parentChangeTree.parent;
550
- parentIndex = parentChangeTree.parentIndex;
551
-
552
- } else {
553
- parentChangeTree = parent[$changes]
554
- }
555
-
556
- const parentConstructor = parent.constructor as typeof Schema;
557
-
558
- let key = `${this.root.types.getTypeId(refType as typeof Schema)}`;
559
- if (parentConstructor) {
560
- key += `-${this.root.types.schemas.get(parentConstructor)}`;
561
- }
562
- key += `-${parentIndex}`;
563
-
564
- const fieldHasViewTag = Metadata.hasViewTagAtIndex(parentConstructor?.[Symbol.metadata], parentIndex);
565
-
566
- this.isFiltered = parent[$changes].isFiltered // in case parent is already filtered
567
- || this.root.types.parentFiltered[key]
568
- || fieldHasViewTag;
569
-
570
- //
571
- // "isFiltered" may not be imedialely available during `change()` due to the instance not being attached to the root yet.
572
- // when it's available, we need to enqueue the "changes" changeset into the "filteredChanges" changeset.
573
- //
574
- if (this.isFiltered) {
575
-
576
- this.isVisibilitySharedWithParent = (
577
- parentChangeTree.isFiltered &&
578
- typeof (refType) !== "string" &&
579
- !fieldHasViewTag &&
580
- parentIsCollection
581
- );
582
-
583
- if (!this.filteredChanges) {
584
- this.filteredChanges = createChangeSet();
585
- this.allFilteredChanges = createChangeSet();
586
- }
587
-
588
- if (this.changes.operations.length > 0) {
589
- this.changes.operations.forEach((index) =>
590
- setOperationAtIndex(this.filteredChanges, index));
591
-
592
- this.allChanges.operations.forEach((index) =>
593
- setOperationAtIndex(this.allFilteredChanges, index));
594
-
595
- this.changes = createChangeSet();
596
- this.allChanges = createChangeSet();
597
- }
598
- }
734
+ hasParent(predicate: (parent: Ref, index: number) => boolean): boolean {
735
+ return _hasParent(this, predicate);
599
736
  }
600
737
 
601
- /**
602
- * Get the immediate parent
603
- */
604
- get parent(): Ref | undefined {
605
- return this.parentChain?.ref;
606
- }
738
+ getAllParents(): Array<{ ref: Ref, index: number }> { return _getAllParents(this); }
607
739
 
608
- /**
609
- * Get the immediate parent index
610
- */
611
- get parentIndex(): number | undefined {
612
- return this.parentChain?.index;
613
- }
740
+ }
614
741
 
615
- /**
616
- * Add a parent to the chain
617
- */
618
- addParent(parent: Ref, index: number) {
619
- // Check if this parent already exists in the chain
620
- if (this.hasParent((p, _) => p[$changes] === parent[$changes])) {
621
- // if (this.hasParent((p, i) => p[$changes] === parent[$changes] && i === index)) {
622
- this.parentChain.index = index;
623
- return;
624
- }
742
+ /**
743
+ * Lightweight per-instance no-op ChangeTree used for instances the decoder
744
+ * builds. Those instances never feed back into an Encoder, so the full
745
+ * `ChangeTree` machinery (EncodeDescriptor lookup, recorder state, Maps /
746
+ * Uint8Arrays for change slots) is pure overhead — this stub carries only a
747
+ * `ref` back-pointer and no-op methods, so tree walkers and debug tooling
748
+ * continue to work.
749
+ *
750
+ * Plug-in contract: each collection class and the `Decoder` pick between
751
+ * `new ChangeTree(ref)` and `createUntrackedChangeTree(ref)` explicitly via
752
+ * dedicated factories (`initializeForDecoder` on collections,
753
+ * `createInstanceOfType` on the `Decoder`). There is no global state — every
754
+ * decision is local to the call site.
755
+ */
756
+ export class UntrackedChangeTree {
757
+ ref: Ref;
625
758
 
626
- this.parentChain = {
627
- ref: parent,
628
- index,
629
- next: this.parentChain
630
- };
759
+ // Mirror the subset of ChangeTree state that decoder-path readers touch.
760
+ // Everything else is deliberately undefined (matches the shape of a
761
+ // freshly-constructed tree that never participated in a Root).
762
+ root: undefined = undefined;
763
+ parentRef: undefined = undefined;
764
+ paused: boolean = false;
765
+ isNew: boolean = false;
766
+ flags: number = 0;
767
+
768
+ constructor(ref: Ref) {
769
+ this.ref = ref;
631
770
  }
632
771
 
633
- /**
634
- * Remove a parent from the chain
635
- * @param parent - The parent to remove
636
- * @returns true if parent was removed
637
- */
638
- removeParent(parent: Ref = this.parent): boolean {
639
- let current = this.parentChain;
640
- let previous = null;
641
- while (current) {
642
- //
643
- // FIXME: it is required to check against `$changes` here because
644
- // ArraySchema is instance of Proxy
645
- //
646
- if (current.ref[$changes] === parent[$changes]) {
647
- if (previous) {
648
- previous.next = current.next;
649
- } else {
650
- this.parentChain = current.next;
772
+ // Mutation surface — all no-ops.
773
+ change(): void {}
774
+ delete(): void {}
775
+ indexedOperation(): void {}
776
+ operation(): void {}
777
+ setParent(): void {}
778
+ addParent(): void {}
779
+ removeParent(): boolean { return false; }
780
+ getChange(): number { return 0; }
781
+ discard(): void {}
782
+ discardAll(): void {}
783
+ pause(): void {}
784
+ resume(): void {}
785
+ untracked<T>(fn: () => T): T { return fn(); }
786
+ markDirty(): void {}
787
+
788
+ // Tree-walk surface. Mirrors `treeAttachment.forEachChild` so debug tools
789
+ // and `ArraySchema.clear()` can still descend from a tracked root into
790
+ // decoder-built subtrees and read each child's `$changes` (which is
791
+ // itself an UntrackedChangeTree carrying the right `ref`).
792
+ forEachChild(callback: (change: any, at: any) => void): void {
793
+ const ref = this.ref as any;
794
+ if (ref[$childType]) {
795
+ if (typeof ref[$childType] !== "string") {
796
+ for (const [key, value] of ref.entries()) {
797
+ if (!value) continue;
798
+ callback(value[$changes], ref._collectionIndexes?.[key] ?? key);
651
799
  }
652
- return true;
653
800
  }
654
- previous = current;
655
- current = current.next;
801
+ return;
656
802
  }
657
- return this.parentChain === undefined;
658
- }
659
-
660
- /**
661
- * Find a specific parent in the chain
662
- */
663
- findParent(predicate: (parent: Ref, index: number) => boolean): ParentChain | undefined {
664
- let current = this.parentChain;
665
- while (current) {
666
- if (predicate(current.ref, current.index)) {
667
- return current;
668
- }
669
- current = current.next;
803
+ const ctor = ref.constructor as any;
804
+ const metadata = ctor?.[Symbol.metadata];
805
+ if (!metadata) return;
806
+ const refFieldIndexes: number[] = metadata[$refTypeFieldIndexes] ?? [];
807
+ for (let i = 0; i < refFieldIndexes.length; i++) {
808
+ const index = refFieldIndexes[i];
809
+ const value = ref[metadata[index].name];
810
+ if (!value) continue;
811
+ callback(value[$changes], index);
670
812
  }
671
- return undefined;
672
813
  }
673
814
 
674
- /**
675
- * Check if this ChangeTree has a specific parent
676
- */
677
- hasParent(predicate: (parent: Ref, index: number) => boolean): boolean {
678
- return this.findParent(predicate) !== undefined;
815
+ forEachChildWithCtx<C>(ctx: C, callback: (ctx: C, change: any, at: any) => void): void {
816
+ this.forEachChild((change, at) => callback(ctx, change, at));
679
817
  }
680
818
 
681
- /**
682
- * Get all parents as an array (for debugging/testing)
683
- */
684
- getAllParents(): Array<{ ref: Ref, index: number }> {
685
- const parents: Array<{ ref: Ref, index: number }> = [];
686
- let current = this.parentChain;
687
- while (current) {
688
- parents.push({ ref: current.ref, index: current.index });
689
- current = current.next;
690
- }
691
- return parents;
692
- }
819
+ forEachLive(): void {}
820
+ forEachLiveWithCtx(): void {}
821
+ forEach(): void {}
822
+ }
823
+
824
+ // Factory, cast to ChangeTree so call sites that type `$changes` as
825
+ // `ChangeTree` accept it. The surface overlap above covers every read/write
826
+ // the decoder path reaches.
827
+ export function createUntrackedChangeTree(ref: Ref): ChangeTree {
828
+ return new UntrackedChangeTree(ref) as unknown as ChangeTree;
829
+ }
693
830
 
831
+ /**
832
+ * Install a non-enumerable `$changes: UntrackedChangeTree` on `target`.
833
+ * Shared by `Schema.initializeForDecoder` and every collection's
834
+ * `initializeForDecoder`. `publicRef` defaults to `target` — pass a Proxy
835
+ * instead (ArraySchema) so children attached to this tree see the Proxy
836
+ * as their parent, not the raw target.
837
+ *
838
+ * `enumerable: false` is load-bearing — tests use `deepStrictEqual` on
839
+ * decoded instances and walking into `$changes` would recurse through
840
+ * circular refs. Same descriptor shape as the tracked `Schema.initialize`
841
+ * + collection ctors.
842
+ */
843
+ export function installUntrackedChangeTree(target: object, publicRef: object = target): void {
844
+ Object.defineProperty(target, $changes, {
845
+ value: createUntrackedChangeTree(publicRef as Ref),
846
+ enumerable: false,
847
+ writable: true,
848
+ });
694
849
  }