@colyseus/schema 5.0.6 → 5.0.8
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 +8 -1
- package/build/codegen/cli.cjs +74 -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 +1192 -284
- package/build/index.cjs.map +1 -1
- package/build/index.d.ts +3 -0
- package/build/index.js +1192 -284
- package/build/index.mjs +1189 -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 +133 -51
- package/src/bench_churn.ts +121 -0
- package/src/codegen/languages/haxe.ts +0 -16
- package/src/codegen/parser.ts +29 -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 +11 -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/codegen/types.ts
CHANGED
|
@@ -23,21 +23,11 @@ export class Context {
|
|
|
23
23
|
enums: Enum[] = [];
|
|
24
24
|
|
|
25
25
|
getStructures() {
|
|
26
|
+
// `isSchemaClass` already walks the full ancestor chain, so a class is
|
|
27
|
+
// emitted iff it (or any ancestor) descends from Schema — no need to
|
|
28
|
+
// re-walk parents here.
|
|
26
29
|
return {
|
|
27
|
-
classes: this.classes.filter(klass =>
|
|
28
|
-
if (this.isSchemaClass(klass)) {
|
|
29
|
-
return true;
|
|
30
|
-
|
|
31
|
-
} else {
|
|
32
|
-
let parentClass = klass;
|
|
33
|
-
while (parentClass = this.getParentClass(parentClass)) {
|
|
34
|
-
if (this.isSchemaClass(parentClass)) {
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return false;
|
|
40
|
-
}),
|
|
30
|
+
classes: this.classes.filter(klass => this.isSchemaClass(klass)),
|
|
41
31
|
interfaces: this.interfaces,
|
|
42
32
|
enums: this.enums,
|
|
43
33
|
};
|
|
@@ -56,37 +46,25 @@ export class Context {
|
|
|
56
46
|
}
|
|
57
47
|
}
|
|
58
48
|
|
|
59
|
-
private getParentClass(klass: Class) {
|
|
60
|
-
return this.classes.find(c => c.name === klass.extends);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
49
|
private isSchemaClass(klass: Class) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
currentClass.extends === "schema.Schema" ||
|
|
75
|
-
currentClass.extends === "Schema.Schema"
|
|
50
|
+
// True if `klass` or any ancestor extends Schema (directly or via the
|
|
51
|
+
// `schema.Schema` / `Schema.Schema` aliases).
|
|
52
|
+
//
|
|
53
|
+
// TODO: ideally we should check for the actual @colyseus/schema module
|
|
54
|
+
// reference rather than arbitrary strings.
|
|
55
|
+
for (const current of [klass, ...eachAncestor(klass, this.classes)]) {
|
|
56
|
+
const isSchema = (
|
|
57
|
+
current.extends === "Schema" ||
|
|
58
|
+
current.extends === "schema.Schema" ||
|
|
59
|
+
current.extends === "Schema.Schema"
|
|
76
60
|
);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
if (currentClass === klass && isSchema) {
|
|
83
|
-
klass.extends = "Schema";
|
|
61
|
+
if (isSchema) {
|
|
62
|
+
// Normalize a `schema.Schema`-style base on the queried class itself.
|
|
63
|
+
if (current === klass) { klass.extends = "Schema"; }
|
|
64
|
+
return true;
|
|
84
65
|
}
|
|
85
|
-
|
|
86
|
-
currentClass = this.getParentClass(currentClass);
|
|
87
66
|
}
|
|
88
|
-
|
|
89
|
-
return isSchema;
|
|
67
|
+
return false;
|
|
90
68
|
}
|
|
91
69
|
}
|
|
92
70
|
|
|
@@ -127,17 +105,11 @@ export class Class implements IStructure {
|
|
|
127
105
|
}
|
|
128
106
|
|
|
129
107
|
postProcessing() {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
let parentKlass: Class = this;
|
|
134
|
-
|
|
135
|
-
while (
|
|
136
|
-
parentKlass &&
|
|
137
|
-
(parentKlass = this.context.classes.find(k => k.name === parentKlass.extends))
|
|
138
|
-
) {
|
|
108
|
+
// Offset each property's `index` by the field count of every ancestor,
|
|
109
|
+
// so indexes stay correct across inheritance.
|
|
110
|
+
for (const parent of eachAncestor(this, this.context.classes)) {
|
|
139
111
|
this.properties.forEach(prop => {
|
|
140
|
-
prop.index +=
|
|
112
|
+
prop.index += parent.properties.length;
|
|
141
113
|
});
|
|
142
114
|
}
|
|
143
115
|
}
|
|
@@ -181,18 +153,28 @@ export interface GeneratedFile {
|
|
|
181
153
|
body: string;
|
|
182
154
|
}
|
|
183
155
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Walk `klass`'s `extends` chain, parent-first, yielding each ancestor class
|
|
158
|
+
* (not `klass` itself). The single safe ancestor traversal every inheritance
|
|
159
|
+
* query is built on: it stops at the Schema root, an unresolved base, or a
|
|
160
|
+
* cycle — so a malformed class graph can never spin forever. This is why the
|
|
161
|
+
* `seen` cycle-guard lives here and nowhere else.
|
|
162
|
+
*/
|
|
163
|
+
function* eachAncestor(klass: Class, allClasses: Class[]): Generator<Class> {
|
|
164
|
+
const seen = new Set<Class>([klass]);
|
|
165
|
+
let current = klass;
|
|
166
|
+
while (current.extends && current.extends !== "Schema") {
|
|
167
|
+
const parent = allClasses.find(c => c.name === current.extends);
|
|
168
|
+
if (!parent || seen.has(parent)) { return; }
|
|
169
|
+
seen.add(parent);
|
|
170
|
+
yield parent;
|
|
171
|
+
current = parent;
|
|
195
172
|
}
|
|
173
|
+
}
|
|
196
174
|
|
|
197
|
-
|
|
175
|
+
export function getInheritanceTree(klass: Class, allClasses: Class[], includeSelf: boolean = true) {
|
|
176
|
+
return [
|
|
177
|
+
...(includeSelf ? [klass] : []),
|
|
178
|
+
...eachAncestor(klass, allClasses),
|
|
179
|
+
];
|
|
198
180
|
}
|
|
@@ -10,6 +10,8 @@ import type { ArraySchema } from "../types/custom/ArraySchema.js";
|
|
|
10
10
|
|
|
11
11
|
import { getType } from "../types/registry.js";
|
|
12
12
|
import { Collection } from "../types/HelperTypes.js";
|
|
13
|
+
import { decodeQuantized, isQuantizedType } from "../types/quantize.js";
|
|
14
|
+
import { resyncMarkPresent, resyncTouchEntry } from "./Resync.js";
|
|
13
15
|
|
|
14
16
|
export interface DataChange<T = any, F = string> {
|
|
15
17
|
ref: IRef,
|
|
@@ -124,6 +126,12 @@ export function decodeValue<T extends Ref>(
|
|
|
124
126
|
//
|
|
125
127
|
value = (decode as any)[type](bytes, it);
|
|
126
128
|
|
|
129
|
+
} else if (isQuantizedType(type)) {
|
|
130
|
+
// Quantized scalar: read the unsigned-int wire value, then dequantize so
|
|
131
|
+
// the instance holds (and yields) the wire-exact float — `decodeSchema-
|
|
132
|
+
// Operation` writes it through the snapping setter (idempotent here).
|
|
133
|
+
value = decodeQuantized(type.quantized, bytes, it);
|
|
134
|
+
|
|
127
135
|
} else if (Schema.is(type)) {
|
|
128
136
|
const refId = decode.number(bytes, it);
|
|
129
137
|
value = $root.refs.get(refId);
|
|
@@ -148,6 +156,9 @@ export function decodeValue<T extends Ref>(
|
|
|
148
156
|
const typeDef = getType(Object.keys(type)[0]);
|
|
149
157
|
const refId = decode.number(bytes, it);
|
|
150
158
|
|
|
159
|
+
// resync bookkeeping — see Resync.ts
|
|
160
|
+
if (decoder.resyncVisited !== null) { resyncMarkPresent(decoder, refId); }
|
|
161
|
+
|
|
151
162
|
// `initializeForDecoder` is a static on every registered collection
|
|
152
163
|
// class — it does `Object.create(Class.prototype)` + the class-
|
|
153
164
|
// field init + assigns an untracked `$changes` directly. Keeps
|
|
@@ -163,18 +174,27 @@ export function decodeValue<T extends Ref>(
|
|
|
163
174
|
let previousRefId = previousValue[$refId];
|
|
164
175
|
|
|
165
176
|
if (previousRefId !== undefined && refId !== previousRefId) {
|
|
177
|
+
// Collection field replaced by a different instance.
|
|
166
178
|
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
179
|
+
// Don't decrement children here: GC (`garbageCollectDeletedRefs`)
|
|
180
|
+
// removes them once the previous collection's refId hits zero.
|
|
181
|
+
// Doing it here too would double-decrement a *shared* child and
|
|
182
|
+
// drop it while still referenced ("refId not found").
|
|
183
|
+
if ((operation & OPERATION.DELETE) !== OPERATION.DELETE) {
|
|
184
|
+
// Replacement not tagged DELETE (e.g. pending ADD not upgraded
|
|
185
|
+
// to DELETE_AND_ADD), so the previous refId wasn't decremented
|
|
186
|
+
// above. Release it here, else it never gets GC'd (leak).
|
|
187
|
+
$root.removeRef(previousRefId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// enqueue onRemove callbacks for the previous collection's children.
|
|
169
191
|
const entries: IterableIterator<[any, any]> = (previousValue as any).entries();
|
|
170
192
|
let iter: IteratorResult<[any, any]>;
|
|
171
193
|
while ((iter = entries.next()) && !iter.done) {
|
|
172
194
|
const [key, value] = iter.value;
|
|
173
195
|
|
|
174
|
-
// if value is a schema, remove its reference
|
|
175
196
|
if (typeof(value) === "object") {
|
|
176
197
|
previousRefId = value[$refId];
|
|
177
|
-
$root.removeRef(previousRefId);
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
allChanges?.push({
|
|
@@ -317,6 +337,11 @@ export const decodeKeyValueOperation: DecodeOperation = function (
|
|
|
317
337
|
allChanges,
|
|
318
338
|
);
|
|
319
339
|
|
|
340
|
+
// resync bookkeeping — see Resync.ts
|
|
341
|
+
if (decoder.resyncVisited !== null) {
|
|
342
|
+
resyncTouchEntry(decoder, ref, operation, dynamicIndex, previousValue, value, allChanges);
|
|
343
|
+
}
|
|
344
|
+
|
|
320
345
|
if (value !== null && value !== undefined) {
|
|
321
346
|
switch (kind) {
|
|
322
347
|
case CollectionKind.Map:
|
|
@@ -408,6 +433,16 @@ export const decodeArray: DecodeOperation = function (
|
|
|
408
433
|
// TODO: refactor here, try to follow same flow as below
|
|
409
434
|
const refId = decode.number(bytes, it);
|
|
410
435
|
const previousValue = decoder.root.refs.get(refId);
|
|
436
|
+
// Decrement the removed child's ref-count so it can be garbage
|
|
437
|
+
// collected — this refId-based branch returns early and never reaches
|
|
438
|
+
// decodeValue(), so it must do the same DELETE bookkeeping itself.
|
|
439
|
+
// Without this the refId leaks; a later encoder reuse then aliases a
|
|
440
|
+
// different type → "field not defined" / "definition mismatch"
|
|
441
|
+
// (surfaces under StateView when a filtered ArraySchema element is
|
|
442
|
+
// spliced while its parent moves through view membership churn).
|
|
443
|
+
if (previousValue !== undefined) {
|
|
444
|
+
decoder.root.removeRef(refId);
|
|
445
|
+
}
|
|
411
446
|
index = tgt.findIndex((value: any) => value === previousValue);
|
|
412
447
|
tgt[$deleteByIndex](index);
|
|
413
448
|
allChanges?.push({
|
|
@@ -459,6 +494,13 @@ export const decodeArray: DecodeOperation = function (
|
|
|
459
494
|
allChanges,
|
|
460
495
|
);
|
|
461
496
|
|
|
497
|
+
// resync bookkeeping — see Resync.ts. `index` is the RESOLVED position
|
|
498
|
+
// (ADD_BY_REFID lands on the instance's current client-side index), so
|
|
499
|
+
// visited entries form a sparse set.
|
|
500
|
+
if (decoder.resyncVisited !== null) {
|
|
501
|
+
resyncTouchEntry(decoder, ref, operation, index, previousValue, value, allChanges);
|
|
502
|
+
}
|
|
503
|
+
|
|
462
504
|
if (
|
|
463
505
|
value !== null && value !== undefined &&
|
|
464
506
|
value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
|
package/src/decoder/Decoder.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { type IRef, type Ref } from "../encoder/ChangeTree.js";
|
|
|
7
7
|
import type { Iterator } from "../encoding/decode.js";
|
|
8
8
|
import { ReferenceTracker } from "./ReferenceTracker.js";
|
|
9
9
|
import { DEFINITION_MISMATCH, type DataChange, type DecodeOperation } from "./DecodeOperation.js";
|
|
10
|
+
import { resyncSweep } from "./Resync.js";
|
|
10
11
|
import { Collection } from "../types/HelperTypes.js";
|
|
11
12
|
|
|
12
13
|
export class Decoder<T extends IRef = any> {
|
|
@@ -19,6 +20,20 @@ export class Decoder<T extends IRef = any> {
|
|
|
19
20
|
|
|
20
21
|
triggerChanges?: (allChanges: DataChange[]) => void;
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* @internal Non-null only while a `decodeResync()` walk is in progress:
|
|
25
|
+
* collection refId → entry identities the payload visited (map string
|
|
26
|
+
* keys; array/set/collection/stream indexes). Written by the collection
|
|
27
|
+
* DecodeOperation functions, read by the post-decode sweep.
|
|
28
|
+
*/
|
|
29
|
+
resyncVisited: Map<number, Set<number | string>> | null = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @internal Set when a structure had to be skipped during a resync
|
|
33
|
+
* decode — visited data is incomplete, so the sweep must not delete.
|
|
34
|
+
*/
|
|
35
|
+
resyncDamaged: boolean = false;
|
|
36
|
+
|
|
22
37
|
constructor(root: T, context?: TypeContext) {
|
|
23
38
|
this.setState(root);
|
|
24
39
|
|
|
@@ -103,6 +118,11 @@ export class Decoder<T extends IRef = any> {
|
|
|
103
118
|
// than being extracted into a helper for a one-line body.
|
|
104
119
|
(ref as any)[$onDecodeEnd]?.()
|
|
105
120
|
|
|
121
|
+
// resync mode: prune everything the snapshot didn't visit. Runs
|
|
122
|
+
// before triggerChanges (DELETE changes fire onRemove with the real
|
|
123
|
+
// previousValue) and before GC (removeRef feeds deletedRefs).
|
|
124
|
+
if (this.resyncVisited !== null) { resyncSweep(this, allChanges); }
|
|
125
|
+
|
|
106
126
|
// trigger changes
|
|
107
127
|
if (allChanges !== null) this.triggerChanges?.(allChanges);
|
|
108
128
|
|
|
@@ -112,7 +132,35 @@ export class Decoder<T extends IRef = any> {
|
|
|
112
132
|
return allChanges;
|
|
113
133
|
}
|
|
114
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Full-snapshot reconciliation ("resync") decode.
|
|
137
|
+
*
|
|
138
|
+
* Behaves exactly like {@link decode}, plus: every collection entry the
|
|
139
|
+
* payload does NOT mention is removed through the regular DELETE path —
|
|
140
|
+
* `onRemove` callbacks fire with the real previous value and released
|
|
141
|
+
* refs are garbage-collected. Use it to apply a rejoin/reconnect full
|
|
142
|
+
* state over an existing decoded tree: DELETEs that happened while the
|
|
143
|
+
* client was off the wire are reconciled as if they had been received,
|
|
144
|
+
* while surviving entries keep their instance identity and callbacks.
|
|
145
|
+
*
|
|
146
|
+
* ONLY valid for full-snapshot payloads (`encodeAll` / `encodeAllView`
|
|
147
|
+
* output). Calling it on an incremental patch would prune everything
|
|
148
|
+
* the patch doesn't touch.
|
|
149
|
+
*/
|
|
150
|
+
decodeResync(bytes: Uint8Array, it: Iterator = { offset: 0 }) {
|
|
151
|
+
this.resyncVisited = new Map();
|
|
152
|
+
this.resyncDamaged = false;
|
|
153
|
+
try {
|
|
154
|
+
return this.decode(bytes, it);
|
|
155
|
+
} finally {
|
|
156
|
+
this.resyncVisited = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
115
160
|
skipCurrentStructure(bytes: Uint8Array, it: Iterator, totalBytes: number) {
|
|
161
|
+
// A skipped range can swallow other structures' ops (their ADDs are
|
|
162
|
+
// never applied), so resync visited data is no longer trustworthy.
|
|
163
|
+
if (this.resyncVisited !== null) { this.resyncDamaged = true; }
|
|
116
164
|
//
|
|
117
165
|
// keep skipping next bytes until reaches a known structure
|
|
118
166
|
// by local decoder.
|
|
@@ -20,6 +20,9 @@ class DecodingWarning extends Error {
|
|
|
20
20
|
|
|
21
21
|
export type SchemaCallbacks = { [field: string | number]: Function[] };
|
|
22
22
|
|
|
23
|
+
// Reused across addRef calls — saves a descriptor object per decoded ref.
|
|
24
|
+
const $refIdDescriptor = { value: 0, enumerable: false, writable: true };
|
|
25
|
+
|
|
23
26
|
export class ReferenceTracker {
|
|
24
27
|
//
|
|
25
28
|
// Relation of refId => Schema structure
|
|
@@ -45,11 +48,12 @@ export class ReferenceTracker {
|
|
|
45
48
|
// on decoded instances, which WOULD walk enumerable Symbol-keyed
|
|
46
49
|
// properties and include `$refId` in the comparison. Keep the
|
|
47
50
|
// descriptor dance for semantic compatibility.
|
|
48
|
-
|
|
49
|
-
value
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
if (ref[$refId] === undefined) {
|
|
52
|
+
$refIdDescriptor.value = refId;
|
|
53
|
+
Object.defineProperty(ref, $refId, $refIdDescriptor);
|
|
54
|
+
} else if (ref[$refId] !== refId) {
|
|
55
|
+
ref[$refId] = refId; // property exists (writable) — plain write keeps flags
|
|
56
|
+
}
|
|
53
57
|
|
|
54
58
|
if (incrementCount) {
|
|
55
59
|
this.refCount[refId] = (this.refCount[refId] || 0) + 1;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { OPERATION } from "../encoding/spec.js";
|
|
2
|
+
import { Schema } from "../Schema.js";
|
|
3
|
+
import { $proxyTarget, $refId, $refTypeFieldIndexes, $resyncPrune, $transientFieldIndexes } from "../types/symbols.js";
|
|
4
|
+
import type { Metadata } from "../Metadata.js";
|
|
5
|
+
import type { Decoder } from "./Decoder.js";
|
|
6
|
+
import type { DataChange } from "./DecodeOperation.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resync ("full-snapshot reconciliation") support for {@link Decoder.decodeResync}.
|
|
10
|
+
*
|
|
11
|
+
* A rejoin snapshot is authoritative for everything it contains — but the
|
|
12
|
+
* plain decode path is additive: entries deleted (or hidden by a view)
|
|
13
|
+
* while the client was off the wire survive as ghosts. This module owns the
|
|
14
|
+
* generic reconciliation algorithm:
|
|
15
|
+
*
|
|
16
|
+
* - during the decode walk, the collection DecodeOperation functions report
|
|
17
|
+
* every entry the payload touches ({@link resyncRecordVisit}) and every
|
|
18
|
+
* collection that appears at all ({@link resyncMarkPresent});
|
|
19
|
+
* - after the walk, {@link resyncSweep} removes whatever was never reported,
|
|
20
|
+
* through the same DELETE bookkeeping the regular decode path uses
|
|
21
|
+
* (DataChange DELETE → onRemove; removeRef → GC).
|
|
22
|
+
*
|
|
23
|
+
* Storage-specific pruning (journal upkeep, array compaction, stream
|
|
24
|
+
* exemption) lives on each collection class as `[$resyncPrune]` — declared
|
|
25
|
+
* on the `Collection` interface, so every collection kind must state its
|
|
26
|
+
* own sweep semantics.
|
|
27
|
+
*
|
|
28
|
+
* All entry points are guarded by `decoder.resyncVisited !== null` at the
|
|
29
|
+
* call sites — the normal decode path never pays for any of this.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Record that the current structure's entry at `identity` (map string key /
|
|
34
|
+
* element index) appeared in the payload — even when its value is unchanged
|
|
35
|
+
* (`allChanges` cannot serve as this record: its pushes are guarded by
|
|
36
|
+
* `previousValue !== value`, so unchanged entries would look unvisited).
|
|
37
|
+
*
|
|
38
|
+
* Also releases a replaced occupant: full-sync emits plain ADD (never
|
|
39
|
+
* DELETE_AND_ADD), so an entry whose instance changed while this client was
|
|
40
|
+
* off the wire would otherwise leak its previous ref (no onRemove, never
|
|
41
|
+
* GC'd). This release is correct ONLY under a full snapshot — a live patch's
|
|
42
|
+
* plain ADD over a different instance can be a positional rewrite (array
|
|
43
|
+
* shift/unshift) where the occupant *moved* and is still alive; a snapshot
|
|
44
|
+
* re-adds moved instances elsewhere, so the refcounts balance.
|
|
45
|
+
*/
|
|
46
|
+
export function resyncTouchEntry(
|
|
47
|
+
decoder: Decoder,
|
|
48
|
+
ref: any,
|
|
49
|
+
operation: OPERATION,
|
|
50
|
+
identity: number | string,
|
|
51
|
+
previousValue: any,
|
|
52
|
+
value: any,
|
|
53
|
+
allChanges: DataChange[] | null,
|
|
54
|
+
) {
|
|
55
|
+
const visited = decoder.resyncVisited!;
|
|
56
|
+
let set = visited.get(decoder.currentRefId);
|
|
57
|
+
if (set === undefined) { visited.set(decoder.currentRefId, set = new Set()); }
|
|
58
|
+
set.add(identity);
|
|
59
|
+
|
|
60
|
+
if (previousValue !== undefined && operation === OPERATION.ADD && previousValue !== value) {
|
|
61
|
+
const previousRefId = previousValue[$refId];
|
|
62
|
+
if (previousRefId !== undefined) {
|
|
63
|
+
decoder.root.removeRef(previousRefId);
|
|
64
|
+
allChanges?.push({
|
|
65
|
+
ref,
|
|
66
|
+
refId: decoder.currentRefId,
|
|
67
|
+
op: OPERATION.DELETE,
|
|
68
|
+
dynamicIndex: identity,
|
|
69
|
+
value: undefined,
|
|
70
|
+
previousValue,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mark a collection as present in the payload — even with zero entries.
|
|
78
|
+
* The sweep only prunes collections reported here: absence means "not part
|
|
79
|
+
* of full-sync" (@transient, view-invisible), where pruning would destroy
|
|
80
|
+
* live data. Reflected clients have no @transient metadata, so payload
|
|
81
|
+
* presence is the only reliable signal.
|
|
82
|
+
*/
|
|
83
|
+
export function resyncMarkPresent(decoder: Decoder, refId: number) {
|
|
84
|
+
const visited = decoder.resyncVisited!;
|
|
85
|
+
if (!visited.has(refId)) { visited.set(refId, new Set()); }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Post-decode phase of {@link Decoder.decodeResync}: remove every collection
|
|
90
|
+
* entry the snapshot did not visit.
|
|
91
|
+
*
|
|
92
|
+
* Walks the tree from the root — NOT `root.refs` — for three reasons:
|
|
93
|
+
* `@transient` fields are never part of a snapshot and must be left alone;
|
|
94
|
+
* entries of subtrees removed by the sweep itself are left to the GC's
|
|
95
|
+
* transitive walk (sweeping them directly would double-decrement shared
|
|
96
|
+
* children); and collections the snapshot never mentions (emptied
|
|
97
|
+
* server-side) are still reachable and get pruned.
|
|
98
|
+
*/
|
|
99
|
+
export function resyncSweep(decoder: Decoder, allChanges: DataChange[] | null) {
|
|
100
|
+
if (decoder.resyncDamaged) {
|
|
101
|
+
console.warn(
|
|
102
|
+
"@colyseus/schema: resync sweep skipped — parts of the payload could not be decoded. " +
|
|
103
|
+
"Stale entries may persist until the next resync."
|
|
104
|
+
);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
sweepSchema(decoder, decoder.state as unknown as Schema, new Set(), allChanges);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sweepSchema(decoder: Decoder, ref: Schema, seen: Set<number>, allChanges: DataChange[] | null) {
|
|
111
|
+
const refId = (ref as any)[$refId];
|
|
112
|
+
if (refId === undefined || seen.has(refId)) { return; }
|
|
113
|
+
seen.add(refId);
|
|
114
|
+
|
|
115
|
+
const metadata: Metadata = (ref.constructor as typeof Schema)[Symbol.metadata];
|
|
116
|
+
const refIndexes = metadata?.[$refTypeFieldIndexes] as number[] | undefined;
|
|
117
|
+
if (refIndexes === undefined) { return; }
|
|
118
|
+
const transient = metadata[$transientFieldIndexes] as number[] | undefined;
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < refIndexes.length; i++) {
|
|
121
|
+
const fieldIndex = refIndexes[i];
|
|
122
|
+
// @transient fields are never in a snapshot — leave them alone.
|
|
123
|
+
if (transient !== undefined && transient.includes(fieldIndex)) { continue; }
|
|
124
|
+
|
|
125
|
+
const field = metadata[fieldIndex];
|
|
126
|
+
const value = (ref as any)[field.name];
|
|
127
|
+
if (!value) { continue; }
|
|
128
|
+
|
|
129
|
+
if (Schema.is(field.type)) {
|
|
130
|
+
sweepSchema(decoder, value, seen, allChanges);
|
|
131
|
+
} else {
|
|
132
|
+
sweepCollection(decoder, value, seen, allChanges);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function sweepCollection(decoder: Decoder, coll: any, seen: Set<number>, allChanges: DataChange[] | null) {
|
|
138
|
+
const tgt: any = coll[$proxyTarget] ?? coll;
|
|
139
|
+
const refId = tgt[$refId];
|
|
140
|
+
if (refId === undefined || seen.has(refId)) { return; }
|
|
141
|
+
seen.add(refId);
|
|
142
|
+
|
|
143
|
+
// `undefined` = the collection never appeared in the payload at all
|
|
144
|
+
// (not even as its parent's field op) — it is not part of full-sync
|
|
145
|
+
// (@transient, view-invisible) and must be left alone. An empty Set
|
|
146
|
+
// means "present with zero entries" → prune everything.
|
|
147
|
+
const visited = decoder.resyncVisited!.get(refId);
|
|
148
|
+
if (visited === undefined) { return; }
|
|
149
|
+
|
|
150
|
+
const $root = decoder.root;
|
|
151
|
+
tgt[$resyncPrune](
|
|
152
|
+
visited,
|
|
153
|
+
(value: any, identity: number | string) => {
|
|
154
|
+
allChanges?.push({
|
|
155
|
+
ref: coll,
|
|
156
|
+
refId,
|
|
157
|
+
op: OPERATION.DELETE,
|
|
158
|
+
dynamicIndex: identity,
|
|
159
|
+
value: undefined,
|
|
160
|
+
previousValue: value,
|
|
161
|
+
});
|
|
162
|
+
const childRefId = value?.[$refId];
|
|
163
|
+
if (childRefId !== undefined) { $root.removeRef(childRefId); }
|
|
164
|
+
},
|
|
165
|
+
(value: any) => {
|
|
166
|
+
// recurse so nested collections of retained entries sweep too
|
|
167
|
+
if (Schema.isSchema(value)) { sweepSchema(decoder, value, seen, allChanges); }
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -127,6 +127,12 @@ export const IS_UNRELIABLE = 8, IS_TRANSIENT = 16, IS_STATIC = 32;
|
|
|
127
127
|
// `t.map(X).stream()` / `t.set(X).stream()` route through the same
|
|
128
128
|
// emission machinery.
|
|
129
129
|
export const IS_STREAM_COLLECTION = 64;
|
|
130
|
+
// Set by `recycle()` (Schema.reset / pooling): the tree's values are live
|
|
131
|
+
// but its dirty buckets were cleared, so `Root.add` must re-stage every
|
|
132
|
+
// populated field as ADD when the instance re-enters a tree. Without this,
|
|
133
|
+
// only fields assigned after `pool.acquire()` would reach the wire — the
|
|
134
|
+
// retained ones (constructor-initialized children) would never be encoded.
|
|
135
|
+
export const NEEDS_RESTAGE = 128;
|
|
130
136
|
/**
|
|
131
137
|
* Flags a child inherits from its parent's own transitive state via
|
|
132
138
|
* `checkInheritedFlags`. Read as a bitwise mask so the inheritance step
|
|
@@ -258,6 +264,8 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
|
|
|
258
264
|
set isStatic(v: boolean) { this.flags = v ? (this.flags | IS_STATIC) : (this.flags & ~IS_STATIC); }
|
|
259
265
|
get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
|
|
260
266
|
set isStreamCollection(v: boolean) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
|
|
267
|
+
get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
|
|
268
|
+
set needsRestage(v: boolean) { this.flags = v ? (this.flags | NEEDS_RESTAGE) : (this.flags & ~NEEDS_RESTAGE); }
|
|
261
269
|
|
|
262
270
|
// True iff tree inherits `isFiltered` OR its Schema class declares any
|
|
263
271
|
// @view-tagged fields. StateView.addParentOf uses this to decide whether
|
|
@@ -532,6 +540,57 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
|
|
|
532
540
|
if (this.collPureOps !== undefined) this.collPureOps.length = 0;
|
|
533
541
|
}
|
|
534
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Full reset to construction defaults so the owning ref can be returned to
|
|
545
|
+
* a pool and reused for a different logical entity (see encoder/Pool.ts +
|
|
546
|
+
* Schema.reset). Unlike `reset()` / `endEncode()` (which only clear the
|
|
547
|
+
* dirty bucket for the next encode), this also drops parent links, queue
|
|
548
|
+
* nodes and per-view bitmaps, and re-arms IS_NEW.
|
|
549
|
+
*
|
|
550
|
+
* Precondition: the tree must already be detached from the encoder
|
|
551
|
+
* (`root === undefined`) — i.e. the ref was removed from its parent
|
|
552
|
+
* collection/field, which `Root.remove` does before this runs.
|
|
553
|
+
*/
|
|
554
|
+
recycle(): void {
|
|
555
|
+
if (this.root !== undefined) {
|
|
556
|
+
throw new Error(
|
|
557
|
+
`@colyseus/schema: cannot recycle an attached ChangeTree ` +
|
|
558
|
+
`(${this.ref?.constructor?.name}). Remove the instance from its ` +
|
|
559
|
+
`parent collection before releasing it to a pool.`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// dirty/ops buckets (Schema: dirtyLow/High + ops; Collection: collDirty/collPureOps)
|
|
564
|
+
this.reset();
|
|
565
|
+
// keep the recorder object allocated (re-alloc is the cost we avoid), clear contents
|
|
566
|
+
this.unreliableRecorder?.reset();
|
|
567
|
+
|
|
568
|
+
// back to a freshly-constructed tree: IS_NEW, no inherited flags
|
|
569
|
+
// (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
|
|
570
|
+
// NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
|
|
571
|
+
this.flags = IS_NEW | NEEDS_RESTAGE;
|
|
572
|
+
this._fullSyncGen = 0;
|
|
573
|
+
|
|
574
|
+
// drop parent links — Root.remove clears `root` and the CHILDREN's
|
|
575
|
+
// parent links, but leaves this tree's own parentRef dangling.
|
|
576
|
+
this.parentRef = undefined;
|
|
577
|
+
this._parentIndex = undefined;
|
|
578
|
+
this.extraParents = undefined;
|
|
579
|
+
|
|
580
|
+
// queue nodes (already nulled by Root.remove's queue removal; defensive)
|
|
581
|
+
this.changesNode = undefined;
|
|
582
|
+
this.unreliableChangesNode = undefined;
|
|
583
|
+
|
|
584
|
+
this.paused = false;
|
|
585
|
+
|
|
586
|
+
// per-view visibility lives on the tree (NOT keyed by refId), so a
|
|
587
|
+
// recycled tree must not inherit its previous life's view membership.
|
|
588
|
+
this.visibleViews = undefined;
|
|
589
|
+
this.invisibleViews = undefined;
|
|
590
|
+
this.tagViews = undefined;
|
|
591
|
+
this.subscribedViews = undefined;
|
|
592
|
+
}
|
|
593
|
+
|
|
535
594
|
shift(shiftIndex: number): void {
|
|
536
595
|
if (this._isSchema) throw new Error("ChangeTree (Schema): shift is not supported");
|
|
537
596
|
const src = this.collDirty!;
|