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