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