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