@colyseus/schema
Version:
Binary state serializer with delta encoding for games
6,019 lines • 231 kB
JavaScript
const SWITCH_TO_STRUCTURE = 255; // (decoding collides with DELETE_AND_ADD + fieldIndex = 63)
const TYPE_ID = 213;
/**
* Encoding Schema field operations.
*/
var OPERATION;
(function (OPERATION) {
OPERATION[OPERATION["ADD"] = 128] = "ADD";
OPERATION[OPERATION["REPLACE"] = 0] = "REPLACE";
OPERATION[OPERATION["DELETE"] = 64] = "DELETE";
OPERATION[OPERATION["DELETE_AND_MOVE"] = 96] = "DELETE_AND_MOVE";
OPERATION[OPERATION["MOVE_AND_ADD"] = 160] = "MOVE_AND_ADD";
OPERATION[OPERATION["DELETE_AND_ADD"] = 192] = "DELETE_AND_ADD";
/**
* Collection operations
*/
OPERATION[OPERATION["CLEAR"] = 10] = "CLEAR";
/**
* ArraySchema operations
*/
OPERATION[OPERATION["REVERSE"] = 15] = "REVERSE";
OPERATION[OPERATION["MOVE"] = 32] = "MOVE";
OPERATION[OPERATION["DELETE_BY_REFID"] = 33] = "DELETE_BY_REFID";
OPERATION[OPERATION["ADD_BY_REFID"] = 129] = "ADD_BY_REFID";
})(OPERATION || (OPERATION = {}));
Symbol.metadata ??= Symbol.for("Symbol.metadata");
const $refId = "~refId";
const $track = "~track";
const $encoder = "~encoder";
const $decoder = "~decoder";
const $filter = "~filter";
const $getByIndex = "~getByIndex";
const $deleteByIndex = "~deleteByIndex";
/**
* Used to hold ChangeTree instances whitin the structures
*/
const $changes = '~changes';
/**
* Used to keep track of the type of the child elements of a collection
* (MapSchema, ArraySchema, etc.)
*/
const $childType = '~childType';
/**
* Optional "discard" method for custom types (ArraySchema)
* (Discards changes for next serialization)
*/
const $onEncodeEnd = '~onEncodeEnd';
/**
* When decoding, this method is called after the instance is fully decoded
*/
const $onDecodeEnd = "~onDecodeEnd";
/**
* Metadata
*/
const $descriptors = "~descriptors";
const $numFields = "~__numFields";
const $refTypeFieldIndexes = "~__refTypeFieldIndexes";
const $viewFieldIndexes = "~__viewFieldIndexes";
const $fieldIndexesByViewTag = "$__fieldIndexesByViewTag";
// @ts-nocheck
/**
* msgpack implementation highly based on notepack.io
* https://github.com/darrachequesne/notepack
*/
let textEncoder;
// @ts-ignore
try {
textEncoder = new TextEncoder();
}
catch (e) { }
const _convoBuffer$1 = new ArrayBuffer(8);
const _int32$1 = new Int32Array(_convoBuffer$1);
const _float32$1 = new Float32Array(_convoBuffer$1);
const _float64$1 = new Float64Array(_convoBuffer$1);
const _int64$1 = new BigInt64Array(_convoBuffer$1);
const hasBufferByteLength = (typeof Buffer !== 'undefined' && Buffer.byteLength);
const utf8Length = (hasBufferByteLength)
? Buffer.byteLength // node
: function (str, _) {
var c = 0, length = 0;
for (var i = 0, l = str.length; i < l; i++) {
c = str.charCodeAt(i);
if (c < 0x80) {
length += 1;
}
else if (c < 0x800) {
length += 2;
}
else if (c < 0xd800 || c >= 0xe000) {
length += 3;
}
else {
i++;
length += 4;
}
}
return length;
};
function utf8Write(view, str, it) {
var c = 0;
for (var i = 0, l = str.length; i < l; i++) {
c = str.charCodeAt(i);
if (c < 0x80) {
view[it.offset++] = c;
}
else if (c < 0x800) {
view[it.offset] = 0xc0 | (c >> 6);
view[it.offset + 1] = 0x80 | (c & 0x3f);
it.offset += 2;
}
else if (c < 0xd800 || c >= 0xe000) {
view[it.offset] = 0xe0 | (c >> 12);
view[it.offset + 1] = 0x80 | (c >> 6 & 0x3f);
view[it.offset + 2] = 0x80 | (c & 0x3f);
it.offset += 3;
}
else {
i++;
c = 0x10000 + (((c & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff));
view[it.offset] = 0xf0 | (c >> 18);
view[it.offset + 1] = 0x80 | (c >> 12 & 0x3f);
view[it.offset + 2] = 0x80 | (c >> 6 & 0x3f);
view[it.offset + 3] = 0x80 | (c & 0x3f);
it.offset += 4;
}
}
}
function int8$1(bytes, value, it) {
bytes[it.offset++] = value & 255;
}
function uint8$1(bytes, value, it) {
bytes[it.offset++] = value & 255;
}
function int16$1(bytes, value, it) {
bytes[it.offset++] = value & 255;
bytes[it.offset++] = (value >> 8) & 255;
}
function uint16$1(bytes, value, it) {
bytes[it.offset++] = value & 255;
bytes[it.offset++] = (value >> 8) & 255;
}
function int32$1(bytes, value, it) {
bytes[it.offset++] = value & 255;
bytes[it.offset++] = (value >> 8) & 255;
bytes[it.offset++] = (value >> 16) & 255;
bytes[it.offset++] = (value >> 24) & 255;
}
function uint32$1(bytes, value, it) {
const b4 = value >> 24;
const b3 = value >> 16;
const b2 = value >> 8;
const b1 = value;
bytes[it.offset++] = b1 & 255;
bytes[it.offset++] = b2 & 255;
bytes[it.offset++] = b3 & 255;
bytes[it.offset++] = b4 & 255;
}
function int64$1(bytes, value, it) {
const high = Math.floor(value / Math.pow(2, 32));
const low = value >>> 0;
uint32$1(bytes, low, it);
uint32$1(bytes, high, it);
}
function uint64$1(bytes, value, it) {
const high = (value / Math.pow(2, 32)) >> 0;
const low = value >>> 0;
uint32$1(bytes, low, it);
uint32$1(bytes, high, it);
}
function bigint64$1(bytes, value, it) {
_int64$1[0] = BigInt.asIntN(64, value);
int32$1(bytes, _int32$1[0], it);
int32$1(bytes, _int32$1[1], it);
}
function biguint64$1(bytes, value, it) {
_int64$1[0] = BigInt.asIntN(64, value);
int32$1(bytes, _int32$1[0], it);
int32$1(bytes, _int32$1[1], it);
}
function float32$1(bytes, value, it) {
_float32$1[0] = value;
int32$1(bytes, _int32$1[0], it);
}
function float64$1(bytes, value, it) {
_float64$1[0] = value;
int32$1(bytes, _int32$1[0 ], it);
int32$1(bytes, _int32$1[1 ], it);
}
function boolean$1(bytes, value, it) {
bytes[it.offset++] = value ? 1 : 0; // uint8
}
function string$1(bytes, value, it) {
// encode `null` strings as empty.
if (!value) {
value = "";
}
let length = utf8Length(value, "utf8");
let size = 0;
// fixstr
if (length < 0x20) {
bytes[it.offset++] = length | 0xa0;
size = 1;
}
// str 8
else if (length < 0x100) {
bytes[it.offset++] = 0xd9;
bytes[it.offset++] = length;
size = 2;
}
// str 16
else if (length < 0x10000) {
bytes[it.offset++] = 0xda;
uint16$1(bytes, length, it);
size = 3;
}
// str 32
else if (length < 0x100000000) {
bytes[it.offset++] = 0xdb;
uint32$1(bytes, length, it);
size = 5;
}
else {
throw new Error('String too long');
}
utf8Write(bytes, value, it);
return size + length;
}
function number$1(bytes, value, it) {
if (isNaN(value)) {
return number$1(bytes, 0, it);
}
else if (!isFinite(value)) {
return number$1(bytes, (value > 0) ? Number.MAX_SAFE_INTEGER : -Number.MAX_SAFE_INTEGER, it);
}
else if (value !== (value | 0)) {
if (Math.abs(value) <= 3.4028235e+38) { // range check
_float32$1[0] = value;
if (Math.abs(Math.abs(_float32$1[0]) - Math.abs(value)) < 1e-4) { // precision check; adjust 1e-n (n = precision) to in-/decrease acceptable precision loss
// now we know value is in range for f32 and has acceptable precision for f32
bytes[it.offset++] = 0xca;
float32$1(bytes, value, it);
return 5;
}
}
bytes[it.offset++] = 0xcb;
float64$1(bytes, value, it);
return 9;
}
if (value >= 0) {
// positive fixnum
if (value < 0x80) {
bytes[it.offset++] = value & 255; // uint8
return 1;
}
// uint 8
if (value < 0x100) {
bytes[it.offset++] = 0xcc;
bytes[it.offset++] = value & 255; // uint8
return 2;
}
// uint 16
if (value < 0x10000) {
bytes[it.offset++] = 0xcd;
uint16$1(bytes, value, it);
return 3;
}
// uint 32
if (value < 0x100000000) {
bytes[it.offset++] = 0xce;
uint32$1(bytes, value, it);
return 5;
}
// uint 64
bytes[it.offset++] = 0xcf;
uint64$1(bytes, value, it);
return 9;
}
else {
// negative fixnum
if (value >= -32) {
bytes[it.offset++] = 0xe0 | (value + 0x20);
return 1;
}
// int 8
if (value >= -128) {
bytes[it.offset++] = 0xd0;
int8$1(bytes, value, it);
return 2;
}
// int 16
if (value >= -32768) {
bytes[it.offset++] = 0xd1;
int16$1(bytes, value, it);
return 3;
}
// int 32
if (value >= -2147483648) {
bytes[it.offset++] = 0xd2;
int32$1(bytes, value, it);
return 5;
}
// int 64
bytes[it.offset++] = 0xd3;
int64$1(bytes, value, it);
return 9;
}
}
const encode = {
int8: int8$1,
uint8: uint8$1,
int16: int16$1,
uint16: uint16$1,
int32: int32$1,
uint32: uint32$1,
int64: int64$1,
uint64: uint64$1,
bigint64: bigint64$1,
biguint64: biguint64$1,
float32: float32$1,
float64: float64$1,
boolean: boolean$1,
string: string$1,
number: number$1,
utf8Write,
utf8Length,
};
// @ts-nocheck
// force little endian to facilitate decoding on multiple implementations
const _convoBuffer = new ArrayBuffer(8);
const _int32 = new Int32Array(_convoBuffer);
const _float32 = new Float32Array(_convoBuffer);
const _float64 = new Float64Array(_convoBuffer);
const _uint64 = new BigUint64Array(_convoBuffer);
const _int64 = new BigInt64Array(_convoBuffer);
function utf8Read(bytes, it, length) {
// boundary check
if (length > bytes.length - it.offset) {
length = bytes.length - it.offset;
}
var string = '', chr = 0;
for (var i = it.offset, end = it.offset + length; i < end; i++) {
var byte = bytes[i];
if ((byte & 0x80) === 0x00) {
string += String.fromCharCode(byte);
continue;
}
if ((byte & 0xe0) === 0xc0) {
string += String.fromCharCode(((byte & 0x1f) << 6) |
(bytes[++i] & 0x3f));
continue;
}
if ((byte & 0xf0) === 0xe0) {
string += String.fromCharCode(((byte & 0x0f) << 12) |
((bytes[++i] & 0x3f) << 6) |
((bytes[++i] & 0x3f) << 0));
continue;
}
if ((byte & 0xf8) === 0xf0) {
chr = ((byte & 0x07) << 18) |
((bytes[++i] & 0x3f) << 12) |
((bytes[++i] & 0x3f) << 6) |
((bytes[++i] & 0x3f) << 0);
if (chr >= 0x010000) { // surrogate pair
chr -= 0x010000;
string += String.fromCharCode((chr >>> 10) + 0xD800, (chr & 0x3FF) + 0xDC00);
}
else {
string += String.fromCharCode(chr);
}
continue;
}
// (do not throw error to avoid server/client from crashing due to hack attemps)
// throw new Error('Invalid byte ' + byte.toString(16));
console.error('decode.utf8Read(): Invalid byte ' + byte + ' at offset ' + i + '. Skip to end of string: ' + (it.offset + length));
break;
}
it.offset += length;
return string;
}
function int8(bytes, it) {
return uint8(bytes, it) << 24 >> 24;
}
function uint8(bytes, it) {
return bytes[it.offset++];
}
function int16(bytes, it) {
return uint16(bytes, it) << 16 >> 16;
}
function uint16(bytes, it) {
return bytes[it.offset++] | bytes[it.offset++] << 8;
}
function int32(bytes, it) {
return bytes[it.offset++] | bytes[it.offset++] << 8 | bytes[it.offset++] << 16 | bytes[it.offset++] << 24;
}
function uint32(bytes, it) {
return int32(bytes, it) >>> 0;
}
function float32(bytes, it) {
_int32[0] = int32(bytes, it);
return _float32[0];
}
function float64(bytes, it) {
_int32[0 ] = int32(bytes, it);
_int32[1 ] = int32(bytes, it);
return _float64[0];
}
function int64(bytes, it) {
const low = uint32(bytes, it);
const high = int32(bytes, it) * Math.pow(2, 32);
return high + low;
}
function uint64(bytes, it) {
const low = uint32(bytes, it);
const high = uint32(bytes, it) * Math.pow(2, 32);
return high + low;
}
function bigint64(bytes, it) {
_int32[0] = int32(bytes, it);
_int32[1] = int32(bytes, it);
return _int64[0];
}
function biguint64(bytes, it) {
_int32[0] = int32(bytes, it);
_int32[1] = int32(bytes, it);
return _uint64[0];
}
function boolean(bytes, it) {
return uint8(bytes, it) > 0;
}
function string(bytes, it) {
const prefix = bytes[it.offset++];
let length;
if (prefix < 0xc0) {
// fixstr
length = prefix & 0x1f;
}
else if (prefix === 0xd9) {
length = uint8(bytes, it);
}
else if (prefix === 0xda) {
length = uint16(bytes, it);
}
else if (prefix === 0xdb) {
length = uint32(bytes, it);
}
return utf8Read(bytes, it, length);
}
function number(bytes, it) {
const prefix = bytes[it.offset++];
if (prefix < 0x80) {
// positive fixint
return prefix;
}
else if (prefix === 0xca) {
// float 32
return float32(bytes, it);
}
else if (prefix === 0xcb) {
// float 64
return float64(bytes, it);
}
else if (prefix === 0xcc) {
// uint 8
return uint8(bytes, it);
}
else if (prefix === 0xcd) {
// uint 16
return uint16(bytes, it);
}
else if (prefix === 0xce) {
// uint 32
return uint32(bytes, it);
}
else if (prefix === 0xcf) {
// uint 64
return uint64(bytes, it);
}
else if (prefix === 0xd0) {
// int 8
return int8(bytes, it);
}
else if (prefix === 0xd1) {
// int 16
return int16(bytes, it);
}
else if (prefix === 0xd2) {
// int 32
return int32(bytes, it);
}
else if (prefix === 0xd3) {
// int 64
return int64(bytes, it);
}
else if (prefix > 0xdf) {
// negative fixint
return (0xff - prefix + 1) * -1;
}
}
function stringCheck(bytes, it) {
const prefix = bytes[it.offset];
return (
// fixstr
(prefix < 0xc0 && prefix > 0xa0) ||
// str 8
prefix === 0xd9 ||
// str 16
prefix === 0xda ||
// str 32
prefix === 0xdb);
}
const decode = {
utf8Read,
int8,
uint8,
int16,
uint16,
int32,
uint32,
float32,
float64,
int64,
uint64,
bigint64,
biguint64,
boolean,
string,
number,
stringCheck,
};
const registeredTypes = {};
const identifiers = new Map();
function registerType(identifier, definition) {
if (definition.constructor) {
identifiers.set(definition.constructor, identifier);
registeredTypes[identifier] = definition;
}
if (definition.encode) {
encode[identifier] = definition.encode;
}
if (definition.decode) {
decode[identifier] = definition.decode;
}
}
function getType(identifier) {
return registeredTypes[identifier];
}
function defineCustomTypes(types) {
for (const identifier in types) {
registerType(identifier, types[identifier]);
}
return (t) => type(t);
}
class TypeContext {
types = {};
schemas = new Map();
hasFilters = false;
parentFiltered = {};
/**
* For inheritance support
* Keeps track of which classes extends which. (parent -> children)
*/
static inheritedTypes = new Map();
static cachedContexts = new Map();
static register(target) {
const parent = Object.getPrototypeOf(target);
if (parent !== Schema) {
let inherits = TypeContext.inheritedTypes.get(parent);
if (!inherits) {
inherits = new Set();
TypeContext.inheritedTypes.set(parent, inherits);
}
inherits.add(target);
}
}
static cache(rootClass) {
let context = TypeContext.cachedContexts.get(rootClass);
if (!context) {
context = new TypeContext(rootClass);
TypeContext.cachedContexts.set(rootClass, context);
}
return context;
}
constructor(rootClass) {
if (rootClass) {
this.discoverTypes(rootClass);
}
}
has(schema) {
return this.schemas.has(schema);
}
get(typeid) {
return this.types[typeid];
}
add(schema, typeid = this.schemas.size) {
// skip if already registered
if (this.schemas.has(schema)) {
return false;
}
this.types[typeid] = schema;
//
// Workaround to allow using an empty Schema (with no `@type()` fields)
//
if (schema[Symbol.metadata] === undefined) {
Metadata.initialize(schema);
}
this.schemas.set(schema, typeid);
return true;
}
getTypeId(klass) {
return this.schemas.get(klass);
}
discoverTypes(klass, parentType, parentIndex, parentHasViewTag) {
if (parentHasViewTag) {
this.registerFilteredByParent(klass, parentType, parentIndex);
}
// skip if already registered
if (!this.add(klass)) {
return;
}
// add classes inherited from this base class
TypeContext.inheritedTypes.get(klass)?.forEach((child) => {
this.discoverTypes(child, parentType, parentIndex, parentHasViewTag);
});
// add parent classes
let parent = klass;
while ((parent = Object.getPrototypeOf(parent)) &&
parent !== Schema && // stop at root (Schema)
parent !== Function.prototype // stop at root (non-Schema)
) {
this.discoverTypes(parent);
}
const metadata = (klass[Symbol.metadata] ??= {});
// if any schema/field has filters, mark "context" as having filters.
if (metadata[$viewFieldIndexes]) {
this.hasFilters = true;
}
for (const fieldIndex in metadata) {
const index = fieldIndex;
const fieldType = metadata[index].type;
const fieldHasViewTag = (metadata[index].tag !== undefined);
if (typeof (fieldType) === "string") {
continue;
}
if (typeof (fieldType) === "function") {
this.discoverTypes(fieldType, klass, index, parentHasViewTag || fieldHasViewTag);
}
else {
const type = Object.values(fieldType)[0];
// skip primitive types
if (typeof (type) === "string") {
continue;
}
this.discoverTypes(type, klass, index, parentHasViewTag || fieldHasViewTag);
}
}
}
/**
* Keep track of which classes have filters applied.
* Format: `${typeid}-${parentTypeid}-${parentIndex}`
*/
registerFilteredByParent(schema, parentType, parentIndex) {
const typeid = this.schemas.get(schema) ?? this.schemas.size;
let key = `${typeid}`;
if (parentType) {
key += `-${this.schemas.get(parentType)}`;
}
key += `-${parentIndex}`;
this.parentFiltered[key] = true;
}
debug() {
let parentFiltered = "";
for (const key in this.parentFiltered) {
const keys = key.split("-").map(Number);
const fieldIndex = keys.pop();
parentFiltered += `\n\t\t`;
parentFiltered += `${key}: ${keys.reverse().map((id, i) => {
const klass = this.types[id];
const metadata = klass[Symbol.metadata];
let txt = klass.name;
if (i === 0) {
txt += `[${metadata[fieldIndex].name}]`;
}
return `${txt}`;
}).join(" -> ")}`;
}
return `TypeContext ->\n` +
`\tSchema types: ${this.schemas.size}\n` +
`\thasFilters: ${this.hasFilters}\n` +
`\tparentFiltered:${parentFiltered}`;
}
}
function getNormalizedType(type) {
if (Array.isArray(type)) {
return { array: getNormalizedType(type[0]) };
}
else if (typeof (type['type']) !== "undefined") {
return type['type'];
}
else if (isTSEnum(type)) {
// Detect TS Enum type (either string or number)
return Object.keys(type).every(key => typeof type[key] === "string")
? "string"
: "number";
}
else if (typeof type === "object" && type !== null) {
// Handle collection types
const collectionType = Object.keys(type).find(k => registeredTypes[k] !== undefined);
if (collectionType) {
type[collectionType] = getNormalizedType(type[collectionType]);
return type;
}
}
return type;
}
function isTSEnum(_enum) {
if (typeof _enum === 'function' && _enum[Symbol.metadata]) {
return false;
}
const keys = Object.keys(_enum);
const numericFields = keys.filter(k => /\d+/.test(k));
// Check for number enum (has numeric keys and reverse mapping)
if (numericFields.length > 0 && numericFields.length === (keys.length / 2) && _enum[_enum[numericFields[0]]] == numericFields[0]) {
return true;
}
// Check for string enum (all values are strings and keys match values)
if (keys.length > 0 && keys.every(key => typeof _enum[key] === 'string' && _enum[key] === key)) {
return true;
}
return false;
}
const Metadata = {
addField(metadata, index, name, type, descriptor) {
if (index > 64) {
throw new Error(`Can't define field '${name}'.\nSchema instances may only have up to 64 fields.`);
}
metadata[index] = Object.assign(metadata[index] || {}, // avoid overwriting previous field metadata (@owned / @deprecated)
{
type: getNormalizedType(type),
index,
name,
});
// create "descriptors" map
Object.defineProperty(metadata, $descriptors, {
value: metadata[$descriptors] || {},
enumerable: false,
configurable: true,
});
if (descriptor) {
// for encoder
metadata[$descriptors][name] = descriptor;
metadata[$descriptors][`_${name}`] = {
value: undefined,
writable: true,
enumerable: false,
configurable: true,
};
}
else {
// for decoder
metadata[$descriptors][name] = {
value: undefined,
writable: true,
enumerable: true,
configurable: true,
};
}
// map -1 as last field index
Object.defineProperty(metadata, $numFields, {
value: index,
enumerable: false,
configurable: true
});
// map field name => index (non enumerable)
Object.defineProperty(metadata, name, {
value: index,
enumerable: false,
configurable: true,
});
// if child Ref/complex type, add to -4
if (typeof (metadata[index].type) !== "string") {
if (metadata[$refTypeFieldIndexes] === undefined) {
Object.defineProperty(metadata, $refTypeFieldIndexes, {
value: [],
enumerable: false,
configurable: true,
});
}
metadata[$refTypeFieldIndexes].push(index);
}
},
setTag(metadata, fieldName, tag) {
const index = metadata[fieldName];
const field = metadata[index];
// add 'tag' to the field
field.tag = tag;
if (!metadata[$viewFieldIndexes]) {
// -2: all field indexes with "view" tag
Object.defineProperty(metadata, $viewFieldIndexes, {
value: [],
enumerable: false,
configurable: true
});
// -3: field indexes by "view" tag
Object.defineProperty(metadata, $fieldIndexesByViewTag, {
value: {},
enumerable: false,
configurable: true
});
}
metadata[$viewFieldIndexes].push(index);
// Populate $fieldIndexesByViewTag: for a bitmask tag, register the field
// index under each individual set bit so that view.add(obj, Tag.ONE) finds
// fields tagged @view(Tag.ONE|Tag.TWO).
// Negative tags (i.e. DEFAULT_VIEW_TAG = -1) are stored as-is.
if (tag < 0) {
if (!metadata[$fieldIndexesByViewTag][tag]) {
metadata[$fieldIndexesByViewTag][tag] = [];
}
metadata[$fieldIndexesByViewTag][tag].push(index);
}
else {
for (let bits = tag; bits > 0; bits &= bits - 1) {
const bit = bits & (-bits); // isolate lowest set bit
if (!metadata[$fieldIndexesByViewTag][bit]) {
metadata[$fieldIndexesByViewTag][bit] = [];
}
metadata[$fieldIndexesByViewTag][bit].push(index);
}
}
},
setFields(target, fields) {
// for inheritance support
const constructor = target.prototype.constructor;
TypeContext.register(constructor);
const parentClass = Object.getPrototypeOf(constructor);
const parentMetadata = parentClass && parentClass[Symbol.metadata];
const metadata = Metadata.initialize(constructor);
// Use Schema's methods if not defined in the class
if (!constructor[$track]) {
constructor[$track] = Schema[$track];
}
if (!constructor[$encoder]) {
constructor[$encoder] = Schema[$encoder];
}
if (!constructor[$decoder]) {
constructor[$decoder] = Schema[$decoder];
}
if (!constructor.prototype.toJSON) {
constructor.prototype.toJSON = Schema.prototype.toJSON;
}
//
// detect index for this field, considering inheritance
//
let fieldIndex = metadata[$numFields] // current structure already has fields defined
?? (parentMetadata && parentMetadata[$numFields]) // parent structure has fields defined
?? -1; // no fields defined
fieldIndex++;
for (const field in fields) {
const type = getNormalizedType(fields[field]);
// FIXME: this code is duplicated from @type() annotation
const complexTypeKlass = typeof (Object.keys(type)[0]) === "string" && getType(Object.keys(type)[0]);
const childType = (complexTypeKlass)
? Object.values(type)[0]
: type;
Metadata.addField(metadata, fieldIndex, field, type, getPropertyDescriptor(`_${field}`, fieldIndex, childType, complexTypeKlass));
fieldIndex++;
}
return target;
},
isDeprecated(metadata, field) {
return metadata[field].deprecated === true;
},
init(klass) {
//
// Used only to initialize an empty Schema (Encoder#constructor)
// TODO: remove/refactor this...
//
const metadata = {};
klass[Symbol.metadata] = metadata;
Object.defineProperty(metadata, $numFields, {
value: 0,
enumerable: false,
configurable: true,
});
},
initialize(constructor) {
const parentClass = Object.getPrototypeOf(constructor);
const parentMetadata = parentClass[Symbol.metadata];
let metadata = constructor[Symbol.metadata] ?? Object.create(null);
// make sure inherited classes have their own metadata object.
if (parentClass !== Schema && metadata === parentMetadata) {
metadata = Object.create(null);
if (parentMetadata) {
//
// assign parent metadata to current
//
Object.setPrototypeOf(metadata, parentMetadata);
// $numFields
Object.defineProperty(metadata, $numFields, {
value: parentMetadata[$numFields],
enumerable: false,
configurable: true,
writable: true,
});
// $viewFieldIndexes / $fieldIndexesByViewTag
if (parentMetadata[$viewFieldIndexes] !== undefined) {
Object.defineProperty(metadata, $viewFieldIndexes, {
value: [...parentMetadata[$viewFieldIndexes]],
enumerable: false,
configurable: true,
writable: true,
});
Object.defineProperty(metadata, $fieldIndexesByViewTag, {
value: { ...parentMetadata[$fieldIndexesByViewTag] },
enumerable: false,
configurable: true,
writable: true,
});
}
// $refTypeFieldIndexes
if (parentMetadata[$refTypeFieldIndexes] !== undefined) {
Object.defineProperty(metadata, $refTypeFieldIndexes, {
value: [...parentMetadata[$refTypeFieldIndexes]],
enumerable: false,
configurable: true,
writable: true,
});
}
// $descriptors
Object.defineProperty(metadata, $descriptors, {
value: { ...parentMetadata[$descriptors] },
enumerable: false,
configurable: true,
writable: true,
});
}
}
Object.defineProperty(constructor, Symbol.metadata, {
value: metadata,
writable: false,
configurable: true
});
return metadata;
},
isValidInstance(klass) {
return (klass.constructor[Symbol.metadata] &&
Object.prototype.hasOwnProperty.call(klass.constructor[Symbol.metadata], $numFields));
},
getFields(klass) {
const metadata = klass[Symbol.metadata];
const fields = {};
for (let i = 0; i <= metadata[$numFields]; i++) {
fields[metadata[i].name] = metadata[i].type;
}
return fields;
},
hasViewTagAtIndex(metadata, index) {
return metadata?.[$viewFieldIndexes]?.includes(index);
}
};
function createChangeSet(queueRootNode) {
return { indexes: {}, operations: [], queueRootNode };
}
// Linked list helper functions
function createChangeTreeList() {
return { next: undefined, tail: undefined };
}
function setOperationAtIndex(changeSet, index) {
const operationsIndex = changeSet.indexes[index];
if (operationsIndex === undefined) {
changeSet.indexes[index] = changeSet.operations.push(index) - 1;
}
else {
changeSet.operations[operationsIndex] = index;
}
}
function deleteOperationAtIndex(changeSet, index) {
let operationsIndex = changeSet.indexes[index];
if (operationsIndex === undefined) {
//
// if index is not found, we need to find the last operation
// FIXME: this is not very efficient
//
// > See "should allow consecutive splices (same place)" tests
//
operationsIndex = Object.values(changeSet.indexes).at(-1);
index = Object.entries(changeSet.indexes).find(([_, value]) => value === operationsIndex)?.[0];
}
changeSet.operations[operationsIndex] = undefined;
delete changeSet.indexes[index];
}
class ChangeTree {
ref;
metadata;
root;
parentChain; // Linked list for tracking parents
/**
* Whether this structure is parent of a filtered structure.
*/
isFiltered = false;
isVisibilitySharedWithParent; // See test case: 'should not be required to manually call view.add() items to child arrays without @view() tag'
indexedOperations = {};
//
// TODO:
// try storing the index + operation per item.
// example: 1024 & 1025 => ADD, 1026 => DELETE
//
// => https://chatgpt.com/share/67107d0c-bc20-8004-8583-83b17dd7c196
//
changes = { indexes: {}, operations: [] };
allChanges = { indexes: {}, operations: [] };
filteredChanges;
allFilteredChanges;
indexes; // TODO: remove this, only used by MapSchema/SetSchema/CollectionSchema (`encodeKeyValueOperation`)
/**
* Is this a new instance? Used on ArraySchema to determine OPERATION.MOVE_AND_ADD operation.
*/
isNew = true;
constructor(ref) {
this.ref = ref;
this.metadata = ref.constructor[Symbol.metadata];
//
// Does this structure have "filters" declared?
//
if (this.metadata?.[$viewFieldIndexes]) {
this.allFilteredChanges = { indexes: {}, operations: [] };
this.filteredChanges = { indexes: {}, operations: [] };
}
}
setRoot(root) {
this.root = root;
const isNewChangeTree = this.root.add(this);
this.checkIsFiltered(this.parent, this.parentIndex, isNewChangeTree);
// Recursively set root on child structures
if (isNewChangeTree) {
this.forEachChild((child, _) => {
if (child.root !== root) {
child.setRoot(root);
}
else {
root.add(child); // increment refCount
}
});
}
}
setParent(parent, root, parentIndex) {
this.addParent(parent, parentIndex);
// avoid setting parents with empty `root`
if (!root) {
return;
}
const isNewChangeTree = root.add(this);
// skip if parent is already set
if (root !== this.root) {
this.root = root;
this.checkIsFiltered(parent, parentIndex, isNewChangeTree);
}
// assign same parent on child structures
if (isNewChangeTree) {
//
// assign same parent on child structures
//
this.forEachChild((child, index) => {
if (child.root === root) {
//
// re-assigning a child of the same root, move it next to parent
// so encoding order is preserved
//
root.add(child);
root.moveNextToParent(child);
return;
}
child.setParent(this.ref, root, index);
});
}
}
forEachChild(callback) {
//
// assign same parent on child structures
//
if (this.ref[$childType]) {
if (typeof (this.ref[$childType]) !== "string") {
// MapSchema / ArraySchema, etc.
for (const [key, value] of this.ref.entries()) {
if (!value) {
continue;
} // sparse arrays can have undefined values
callback(value[$changes], this.indexes?.[key] ?? key);
}
}
}
else {
for (const index of this.metadata?.[$refTypeFieldIndexes] ?? []) {
const field = this.metadata[index];
const value = this.ref[field.name];
if (!value) {
continue;
}
callback(value[$changes], index);
}
}
}
operation(op) {
// operations without index use negative values to represent them
// this is checked during .encode() time.
if (this.filteredChanges !== undefined) {
this.filteredChanges.operations.push(-op);
this.root?.enqueueChangeTree(this, 'filteredChanges');
}
else {
this.changes.operations.push(-op);
this.root?.enqueueChangeTree(this, 'changes');
}
}
change(index, operation = OPERATION.ADD) {
const isFiltered = this.isFiltered || (this.metadata?.[index]?.tag !== undefined);
const changeSet = (isFiltered)
? this.filteredChanges
: this.changes;
const previousOperation = this.indexedOperations[index];
if (!previousOperation || previousOperation === OPERATION.DELETE) {
const op = (!previousOperation)
? operation
: (previousOperation === OPERATION.DELETE)
? OPERATION.DELETE_AND_ADD
: operation;
//
// TODO: are DELETE operations being encoded as ADD here ??
//
this.indexedOperations[index] = op;
}
setOperationAtIndex(changeSet, index);
if (isFiltered) {
setOperationAtIndex(this.allFilteredChanges, index);
if (this.root) {
this.root.enqueueChangeTree(this, 'filteredChanges');
this.root.enqueueChangeTree(this, 'allFilteredChanges');
}
}
else {
setOperationAtIndex(this.allChanges, index);
this.root?.enqueueChangeTree(this, 'changes');
}
}
shiftChangeIndexes(shiftIndex) {
//
// Used only during:
//
// - ArraySchema#unshift()
//
const changeSet = (this.isFiltered)
? this.filteredChanges
: this.changes;
const newIndexedOperations = {};
const newIndexes = {};
for (const index in this.indexedOperations) {
newIndexedOperations[Number(index) + shiftIndex] = this.indexedOperations[index];
newIndexes[Number(index) + shiftIndex] = changeSet.indexes[index];
}
this.indexedOperations = newIndexedOperations;
changeSet.indexes = newIndexes;
changeSet.operations = changeSet.operations.map((index) => index + shiftIndex);
}
shiftAllChangeIndexes(shiftIndex, startIndex = 0) {
//
// Used only during:
//
// - ArraySchema#splice()
//
if (this.filteredChanges !== undefined) {
this._shiftAllChangeIndexes(shiftIndex, startIndex, this.allFilteredChanges);
this._shiftAllChangeIndexes(shiftIndex, startIndex, this.allChanges);
}
else {
this._shiftAllChangeIndexes(shiftIndex, startIndex, this.allChanges);
}
}
_shiftAllChangeIndexes(shiftIndex, startIndex = 0, changeSet) {
const newIndexes = {};
let newKey = 0;
for (const key in changeSet.indexes) {
newIndexes[newKey++] = changeSet.indexes[key];
}
changeSet.indexes = newIndexes;
for (let i = 0; i < changeSet.operations.length; i++) {
const index = changeSet.operations[i];
if (index > startIndex) {
changeSet.operations[i] = index + shiftIndex;
}
}
}
indexedOperation(index, operation, allChangesIndex = index) {
this.indexedOperations[index] = operation;
if (this.filteredChanges !== undefined) {
setOperationAtIndex(this.allFilteredChanges, allChangesIndex);
setOperationAtIndex(this.filteredChanges, index);
this.root?.enqueueChangeTree(this, 'filteredChanges');
}
else {
setOperationAtIndex(this.allChanges, allChangesIndex);
setOperationAtIndex(this.changes, index);
this.root?.enqueueChangeTree(this, 'changes');
}
}
getType(index) {
return (
//
// Get the child type from parent structure.
// - ["string"] => "string"
// - { map: "string" } => "string"
// - { set: "string" } => "string"
//
this.ref[$childType] || // ArraySchema | MapSchema | SetSchema | CollectionSchema
this.metadata[index].type // Schema
);
}
getChange(index) {
return this.indexedOperations[index];
}
//
// used during `.encode()`
//
getValue(index, isEncodeAll = false) {
//
// `isEncodeAll` param is only used by ArraySchema
//
return this.ref[$getByIndex](index, isEncodeAll);
}
delete(index, operation, allChangesIndex = index) {
if (index === undefined) {
try {
throw new Error(`@colyseus/schema ${this.ref.constructor.name}: trying to delete non-existing index '${index}'`);
}
catch (e) {
console.warn(e);
}
return;
}
// Mirror `change()`: a field's add/delete must target the same
// changeset family (filtered vs non-filtered). Otherwise
// `deleteOperationAtIndex` falls through to its "find last
// operation" branch and evicts an unrelated sibling field —
// surfaced as encodeAll() dropping a non-@view field after a
// sibling @view field is set to undefined on a Schema with
// mixed @view / non-@view fields (filteredChanges defined,
// isFiltered false).
const isFiltered = this.isFiltered || (this.metadata?.[index]?.tag !== undefined);
const changeSet = (isFiltered)
? this.filteredChanges
: this.changes;
this.indexedOperations[index] = operation ?? OPERATION.DELETE;
setOperationAtIndex(changeSet, index);
if (isFiltered) {
deleteOperationAtIndex(this.allFilteredChanges, allChangesIndex);
}
else {
deleteOperationAtIndex(this.allChanges, allChangesIndex);
}
const previousValue = this.getValue(index);
// remove `root` reference
if (previousValue && previousValue[$changes]) {
//
// FIXME: this.root is "undefined"
//
// This method is being called at decoding time when a DELETE operation is found.
//
// - This is due to using the concrete Schema class at decoding time.
// - "Reflected" structures do not have this problem.
//
// (The property descriptors should NOT be used at decoding time. only at encoding time.)
//
this.root?.remove(previousValue[$changes]);
}
if (isFiltered) {
this.root?.enqueueChangeTree(this, 'filteredChanges');
}
else {
this.root?.enqueueChangeTree(this, 'changes');
}
return previousValue;
}
endEncode(changeSetName) {
this.indexedOperations = {};
// clear changeset
this[changeSetName] = createChangeSet();
// ArraySchema and MapSchema have a custom "encode end" method
this.ref[$onEncodeEnd]?.();
// Not a new instance anymore
this.isNew = false;
}
discard(discardAll = false) {
//
// > MapSchema:
// Remove cached key to ensure ADD operations is unsed instead of
// REPLACE in case same key is used on next patches.
//
this.ref[$onEncodeEnd]?.();
this.indexedOperations = {};
this.changes = createChangeSet(this.changes.queueRootNode);
if (this.filteredChanges !== undefined) {
this.filteredChanges = createChangeSet(this.filteredChanges.queueRootNode);
}
if (discardAll) {
// preserve queueRootNode references
this.allChanges = createChangeSet(this.allChanges.queueRootNode);
if (this.allFilteredChanges !== undefined) {
this.allFilteredChanges = createChangeSet(this.allFilteredChanges.queueRootNode);
}
}
}
/**
* Recursively discard all changes from this, and child structures.
* (Used in tests only)
*/
discardAll() {
const keys = Object.keys(this.indexedOperations);
for (let i = 0, len = keys.length; i < len; i++) {
const value = this.getValue(Number(keys[i]));
if (value && value[$changes]) {
value[$changes].discardAll();
}
}
this.discard();
}
get changed() {
return (Object.entries(this.indexedOperations).length > 0);
}
checkIsFiltered(parent, parentIndex, isNewChangeTree) {
if (this.root.types.hasFilters) {
//
// At Schema initialization, the "root" structure might not be available
// yet, as it only does once the "Encoder" has been set up.
//
// So the "parent" may be already set without a "root".
//
this._checkFilteredByParent(parent, parentIndex);
if (this.filteredChanges !== undefined) {
this.root?.enqueueChangeTree(this, 'filteredChanges');
if (isNewChangeTree) {
this.root?.enqueueChangeTree(this, 'allFilteredChanges');
}
}
}
if (!this.isFiltered) {
this.root?.enqueueChangeTree(this, 'changes');
if (isNewChangeTree) {
this.root?.enqueueChangeTree(this, 'allChanges');
}
}
}
_checkFilteredByParent(parent, parentIndex) {
// skip if parent is not set
if (!parent) {
return;
}
//
// ArraySchema | MapSchema - get the child type
// (if refType is typeof string, the parentFiltered[key] below will always be invalid)
//
const refType = Metadata.isValidInstance(this.ref)
? this.ref.constructor
: this.ref[$childType];
let parentChangeTree;
let parentIsCollection = !Metadata.isValidInstance(parent);
if (parentIsCollection) {
parentChangeTree = parent[$changes];
parent = parentChangeTree.parent;
parentIndex = parentChangeTree.parentIndex;
}
else {
parentChangeTree = parent[$changes];
}
const parentConstructor = parent.constructor;
let key = `${this.root.types.getTypeId(refType)}`;
if (parentConstructor) {
key += `-${this.root.types.schemas.get(parentConstructor)}`;
}
key += `-${parentIndex}`;
const parentMetadata = parentConstructor?.[Symbol.metadata];
const fieldHasViewTag = Metadata.hasViewTagAtIndex(parentMetadata, parentIndex);
this.isFiltered = parent[$changes].isFiltered // in case parent is already filtered
|| this.root.types.parentFiltered[key]
|| fieldHasViewTag;
//
// "isFiltered" may not be immediately available during `change()` due to the instance not being attached to the root yet.
// when it's available, we need to enqueue the "changes" changeset into the "filteredChanges" changeset.
//
if (this.isFiltered) {
//
// Children of a `@view(N)` collection (non-default tag) inherit
// visibility from their parent, so items pushed/set after the
// initial `view.add(state, N)` show up automatically.
//
// Default-tag `@view()` collections deliberately keep per-item
// gating — `view.add(item)` is required to opt each one in.
//
// The `parentMetadata[parentIndex].tag` access is safe inside
// this branch: the OR's short-circuit means we only reach it
// when `fieldHasViewTag` is true, which guarantees the metadata
// entry and its `tag` property exist.
//
this.isVisibilitySharedWithParent = (parentChangeTree.isFiltered &&
typeof (refType) !== "string" &&
(!fieldHasViewTag || (parentIsCollection && parentMetadata[parentIndex].tag !== DEFAULT_VIEW_TAG)));
if (!this.filteredChanges) {
this.filteredChanges = createChangeSet();
this.allFilteredChanges = createChangeSet();
}
if (this.changes.operations.length > 0) {
this.changes.operations.forEach((index) => setOperationAtIndex(this.filteredChanges, index));
this.allChanges.operations.forEach((index) => setOperationAtIndex(this.allFilteredChanges, index));
this.changes = createChangeSet();
this.allChanges = createChangeSet();
}
}
}
/**
* Get the immediate parent
*/
get parent() {
return this.parentChain?.ref;
}
/**
* Get the immediate parent index
*/
get parentIndex() {
return this.parentChain?.index;
}
/**
* Add a parent to the chain
*/
addParent(parent, index) {
// Check if this parent already exists in the chain
if (this.hasParent((p, _) => p[$changes] === parent[$changes])) {
// if (this.hasParent((p, i) => p[$changes] === parent[$changes] && i === index)) {
this.parentChain.index = index;
return;
}
this.parentChain = {
ref: parent,
index,
next: this.parentChain
};
}
/**
* Move `parent`'s existing chain entry to `index`, skipping the work
* `addParent` does. `parent` must already be a parent of this tree.
*
* Called by collections whose wire slots shift (ArraySchema): StateView
* addresses per-view ADD/DELETE by that index, so it has to follow the
* element it names.
*/
setParentIndex(parent, index) {
const chain = this.parentChain;
if (chain === undefined) {
return;
}
if (chain.next === undefined) {
chain.index = index; // sole parent, so it is `parent`
return;
}
// Shared instance — move only the entry `parent` owns. Matching goes
// through `$changes` because ArraySchema arrives proxied (see
// removeParent below).
for (let entry = chain; entry !== undefined; entry = entry.next) {
if (entry.ref[$changes] === parent[$changes]) {
entry.index = index;
return;
}
}
}
/**
* Remove a parent from the chain
* @param parent - The parent to remove
* @returns true if parent was removed
*/
removeParent(parent = this.parent) {
let current = this.parentChain;
let previous = null;
while (current) {
//
// FIXME: it is required to check against `$changes` here because
// ArraySchema is instance of Proxy
//
if (current.ref[$changes] === parent[$changes]) {
if (previous) {
previous.next = current.next;
}
else {
this.parentChain = current.next;
}
return true;
}
previous = current;
current = current.next;
}
return this.parentChain === undefined;
}
/**
* Find a specific parent in the chain
*/
findParent(predicate) {
let current = this.parentChain;
while (current) {
if (predicate(current.ref, current.index)) {
return current;
}
current = current.next;
}
return undefined;
}
/**
* Check if this ChangeTree has a specific parent
*/
hasParent(predicate) {
return this.findParent(predicate) !== undefined;
}
/**
* Get all parents as an array (for debugging/testing)
*/
getAllParents() {
const parents = [];
let current = this.parentChain;
while (current) {
parents.push({ ref: current.ref, index: current.index });
current = current.next;
}
return parents;
}
}
function encodeValue(encoder, bytes, type, value, operation, it) {
if (typeof (type) === "string") {
encode[type]?.(bytes, value, it);
}
else if (type[Symbol.metadata] !== undefined) {
//
// Encode refId for this instance.
// The actual instance is going to be encoded on next `changeTree` iteration.
//
encode.number(bytes, value[$refId], it);
// Try to encode inherited TYPE_ID if it's an ADD operation.
if ((operation & OPERATION.ADD) === OPERATION.ADD) {
encoder.tryEncodeTypeId(bytes, type, value.constructor, it);
}
}
else {
//
// Encode refId for this instance.
// The actual instance is going to be encoded on next `changeTree` iteration.
//
encode.number(bytes, value[$refId], it);
}
}
/**
* Used for Schema instances.
* @private
*/
const encodeSchemaOperation = function (encoder, bytes, changeTree, index, operation, it, _, __, metadata) {
// "compress" field index + operation
bytes[it.offset++] = (index | operation) & 255;
// Do not encode value for DELETE operations
if (operation === OPERATION.DELETE) {
return;
}
const ref = changeTree.ref;
const field = metadata[index];
// TODO: inline this function call small performance gain
encodeValue(encoder, bytes, metadata[index].type, ref[field.name], operation, it);
};
/**
* Used for collections (MapSchema, CollectionSchema, SetSchema)
* @private
*/
const encodeKeyValueOperation = function (encoder, bytes, changeTree, index, operation, it) {
// encode operation
bytes[it.offset++] = operation & 255;
// encode index
encode.number(bytes, index, it);
// Do not encode value for DELETE operations
if (operation === OPERATION.DELETE) {
return;
}
const ref = changeTree.ref;
//
// encode "alias" for dynamic fields (maps)
//
if ((operation & OPERATION.ADD) === OPERATION.ADD) { // ADD or DELETE_AND_ADD
if (typeof (ref['set']) === "function") {
//
// MapSchema dynamic key
//
const dynamicIndex = changeTree.ref['$indexes'].get(index);
encode.string(bytes, dynamicIndex, it);
}
}
const type = ref[$childType];
const value = ref[$getByIndex](index);
// try { throw new Error(); } catch (e) {
// // only print if not coming from Reflection.ts
// if (!e.stack.includes("src/Reflection.ts")) {
// console.log("encodeKeyValueOperation -> ", {
// ref: changeTree.ref.constructor.name,
// field,
// operation: OPERATION[operation],
// value: value?.toJSON(),
// items: ref.toJSON(),
// });
// }
// }
// TODO: inline this function call small performance gain
encodeValue(encoder, bytes, type, value, operation, it);
};
/**
* Used for collections (MapSchema, ArraySchema, etc.)
* @private
*/
const encodeArray = function (encoder, bytes, changeTree, field, operation, it, isEncodeAll, hasView) {
const ref = changeTree.ref;
const useOperationByRefId = hasView && changeTree.isFiltered && (typeof (changeTree.getType(field)) !== "string");
let refOrIndex;
if (useOperationByRefId) {
const item = ref['tmpItems'][field];
// Skip encoding if item is undefined (e.g. when clear() is called)
if (!item) {
return;
}
refOrIndex = item[$refId];
if (operation === OPERATION.DELETE) {
operation = OPERATION.DELETE_BY_REFID;
}
else if (operation === OPERATION.ADD) {
operation = OPERATION.ADD_BY_REFID;
}
}
else {
refOrIndex = field;
}
// encode operation
bytes[it.offset++] = operation & 255;
// encode index
encode.number(bytes, refOrIndex, it);
// Do not encode value for DELETE operations
if (operation === OPERATION.DELETE || operation === OPERATION.DELETE_BY_REFID) {
return;
}
const type = changeTree.getType(field);
const value = changeTree.getValue(field, isEncodeAll);
// console.log({ type, field, value });
// console.log("encodeArray -> ", {
// ref: changeTree.ref.constructor.name,
// field,
// operation: OPERATION[operation],
// value: value?.toJSON(),
// items: ref.toJSON(),
// });
// TODO: inline this function call small performance gain
encodeValue(encoder, bytes, type, value, operation, it);
};
const DEFINITION_MISMATCH = -1;
function decodeValue(decoder, operation, ref, index, type, bytes, it, allChanges) {
const $root = decoder.root;
const previousValue = ref[$getByIndex](index);
let value;
if ((operation & OPERATION.DELETE) === OPERATION.DELETE) {
// Flag `refId` for garbage collection.
const previousRefId = previousValue?.[$refId];
if (previousRefId !== undefined) {
$root.removeRef(previousRefId);
}
//
// Delete operations
//
if (operation !== OPERATION.DELETE_AND_ADD) {
ref[$deleteByIndex](index);
}
value = undefined;
}
if (operation === OPERATION.DELETE) ;
else if (Schema.is(type)) {
const refId = decode.number(bytes, it);
value = $root.refs.get(refId);
if ((operation & OPERATION.ADD) === OPERATION.ADD) {
const childType = decoder.getInstanceType(bytes, it, type);
if (!value) {
value = decoder.createInstanceOfType(childType);
}
$root.addRef(refId, value, (value !== previousValue || // increment ref count if value has changed
(operation === OPERATION.DELETE_AND_ADD && value === previousValue) // increment ref count if the same instance is being added again
));
}
}
else if (typeof (type) === "string") {
//
// primitive value (number, string, boolean, etc)
//
value = decode[type](bytes, it);
}
else {
const typeDef = getType(Object.keys(type)[0]);
const refId = decode.number(bytes, it);
const valueRef = ($root.refs.has(refId))
? previousValue || $root.refs.get(refId)
: new typeDef.constructor();
value = valueRef.clone(true);
value[$childType] = Object.values(type)[0]; // cache childType for ArraySchema and MapSchema
if (previousValue) {
let previousRefId = previousValue[$refId];
if (previousRefId !== undefined && refId !== previousRefId) {
// Collection field replaced by a different instance.
//
// Don't decrement children here: GC (`garbageCollectDeletedRefs`)
// removes them once the previous collection's refId hits zero.
// Doing it here too would double-decrement a *shared* child and
// drop it while still referenced ("refId not found").
if ((operation & OPERATION.DELETE) !== OPERATION.DELETE) {
// Replacement not tagged DELETE (e.g. pending ADD not upgraded
// to DELETE_AND_ADD), so the previous refId wasn't decremented
// above. Release it here, else it never gets GC'd (leak).
$root.removeRef(previousRefId);
}
// enqueue onRemove callbacks for the previous collection's children.
const entries = previousValue.entries();
let iter;
while ((iter = entries.next()) && !iter.done) {
const [key, value] = iter.value;
if (typeof (value) === "object") {
previousRefId = value[$refId];
}
allChanges.push({
ref: previousValue,
refId: previousRefId,
op: OPERATION.DELETE,
field: key,
value: undefined,
previousValue: value,
});
}
}
}
$root.addRef(refId, value, (valueRef !== previousValue ||
(operation === OPERATION.DELETE_AND_ADD && valueRef === previousValue)));
}
return { value, previousValue };
}
const decodeSchemaOperation = function (decoder, bytes, it, ref, allChanges) {
const first_byte = bytes[it.offset++];
const metadata = ref.constructor[Symbol.metadata];
// "compressed" index + operation
const operation = (first_byte >> 6) << 6;
const index = first_byte % (operation || 255);
// skip early if field is not defined
const field = metadata[index];
if (field === undefined) {
console.warn("@colyseus/schema: field not defined at", { index, ref: ref.constructor.name, metadata });
return DEFINITION_MISMATCH;
}
const { value, previousValue } = decodeValue(decoder, operation, ref, index, field.type, bytes, it, allChanges);
if (value !== null && value !== undefined) {
ref[field.name] = value;
}
// add change
if (previousValue !== value) {
allChanges.push({
ref,
refId: decoder.currentRefId,
op: operation,
field: field.name,
value,
previousValue,
});
}
};
const decodeKeyValueOperation = function (decoder, bytes, it, ref, allChanges) {
// "uncompressed" index + operation (array/map items)
const operation = bytes[it.offset++];
if (operation === OPERATION.CLEAR) {
//
// When decoding:
// - enqueue items for DELETE callback.
// - flag child items for garbage collection.
//
decoder.removeChildRefs(ref, allChanges);
ref.clear();
return;
}
const index = decode.number(bytes, it);
const type = ref[$childType];
let dynamicIndex;
if ((operation & OPERATION.ADD) === OPERATION.ADD) { // ADD or DELETE_AND_ADD
if (typeof (ref['set']) === "function") {
dynamicIndex = decode.string(bytes, it); // MapSchema
ref['setIndex'](index, dynamicIndex);
}
else {
dynamicIndex = index; // ArraySchema
}
}
else {
// get dynamic index from "ref"
dynamicIndex = ref['getIndex'](index);
}
const { value, previousValue } = decodeValue(decoder, operation, ref, index, type, bytes, it, allChanges);
if (value !== null && value !== undefined) {
if (typeof (ref['set']) === "function") {
// MapSchema
ref['$items'].set(dynamicIndex, value);
}
else if (typeof (ref['$setAt']) === "function") {
// ArraySchema
ref['$setAt'](index, value, operation);
}
else if (typeof (ref['add']) === "function") {
// CollectionSchema && SetSchema
const index = ref.add(value);
if (typeof (index) === "number") {
ref['setIndex'](index, index);
}
}
}
// add change
if (previousValue !== value) {
allChanges.push({
ref,
refId: decoder.currentRefId,
op: operation,
field: "", // FIXME: remove this
dynamicIndex,
value,
previousValue,
});
}
};
const decodeArray = function (decoder, bytes, it, ref, allChanges) {
// "uncompressed" index + operation (array/map items)
let operation = bytes[it.offset++];
let index;
if (operation === OPERATION.CLEAR) {
//
// When decoding:
// - enqueue items for DELETE callback.
// - flag child items for garbage collection.
//
decoder.removeChildRefs(ref, allChanges);
ref.clear();
return;
}
else if (operation === OPERATION.REVERSE) {
ref.reverse();
return;
}
else if (operation === OPERATION.DELETE_BY_REFID) {
// TODO: refactor here, try to follow same flow as below
const refId = decode.number(bytes, it);
const previousValue = decoder.root.refs.get(refId);
index = ref.findIndex((value) => value === previousValue);
ref[$deleteByIndex](index);
allChanges.push({
ref,
refId: decoder.currentRefId,
op: OPERATION.DELETE,
field: "", // FIXME: remove this
dynamicIndex: index,
value: undefined,
previousValue,
});
return;
}
else if (operation === OPERATION.ADD_BY_REFID) {
const refId = decode.number(bytes, it);
const itemByRefId = decoder.root.refs.get(refId);
// if item already exists, use existing index
if (itemByRefId) {
index = ref.findIndex((value) => value === itemByRefId);
}
// fallback to use last index
if (index === -1 || index === undefined) {
index = ref.length;
}
}
else {
index = decode.number(bytes, it);
}
const type = ref[$childType];
let dynamicIndex = index;
const { value, previousValue } = decodeValue(decoder, operation, ref, index, type, bytes, it, allChanges);
if (value !== null && value !== undefined &&
value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
) {
// ArraySchema
ref['$setAt'](index, value, operation);
}
// add change
if (previousValue !== value) {
allChanges.push({
ref,
refId: decoder.currentRefId,
op: operation,
field: "", // FIXME: remove this
dynamicIndex,
value,
previousValue,
});
}
};
class EncodeSchemaError extends Error {
}
function assertType(value, type, klass, field) {
let typeofTarget;
let allowNull = false;
switch (type) {
case "number":
case "int8":
case "uint8":
case "int16":
case "uint16":
case "int32":
case "uint32":
case "int64":
case "uint64":
case "float32":
case "float64":
typeofTarget = "number";
if (isNaN(value)) {
console.log(`trying to encode "NaN" in ${klass.constructor.name}#${field}`);
}
break;
case "bigint64":
case "biguint64":
typeofTarget = "bigint";
break;
case "string":
typeofTarget = "string";
allowNull = true;
break;
case "boolean":
// boolean is always encoded as true/false based on truthiness
return;
default:
// skip assertion for custom types
// TODO: allow custom types to define their own assertions
return;
}
if (typeof (value) !== typeofTarget && (!allowNull || (allowNull && value !== null))) {
let foundValue = `'${JSON.stringify(value)}'${(value && value.constructor && ` (${value.constructor.name})`) || ''}`;
throw new EncodeSchemaError(`a '${typeofTarget}' was expected, but ${foundValue} was provided in ${klass.constructor.name}#${field}`);
}
}
function assertInstanceType(value, type, instance, field) {
if (!(value instanceof type)) {
throw new EncodeSchemaError(`a '${type.name}' was expected, but '${value && value.constructor.name}' was provided in ${instance.constructor.name}#${field}`);
}
}
const DEFAULT_SORT = (a, b) => {
const A = a.toString();
const B = b.toString();
if (A < B)
return -1;
else if (A > B)
return 1;
else
return 0;
};
class ArraySchema {
[$changes];
[$refId];
[$childType];
items = [];
tmpItems = [];
deletedIndexes = {};
isMovingItems = false;
static [$encoder] = encodeArray;
static [$decoder] = decodeArray;
/**
* Determine if a property must be filtered.
* - If returns false, the property is NOT going to be encoded.
* - If returns true, the property is going to be encoded.
*
* Encoding with "filters" happens in two steps:
* - First, the encoder iterates over all "not owned" properties and encodes them.
* - Then, the encoder iterates over all "owned" properties per instance and encodes them.
*/
static [$filter](ref, index, view) {
return (!view ||
typeof (ref[$childType]) === "string" ||
view.isChangeTreeVisible(ref['tmpItems'][index]?.[$changes]));
}
static is(type) {
return (
// type format: ["string"]
Array.isArray(type) ||
// type format: { array: "string" }
(type['array'] !== undefined));
}
static from(iterable) {
return new ArraySchema(...Array.from(iterable));
}
constructor(...items) {
Object.defineProperty(this, $childType, {
value: undefined,
enumerable: false,
writable: true,
configurable: true,
});
const proxy = new Proxy(this, {
get: (obj, prop) => {
if (typeof (prop) !== "symbol" &&
// FIXME: d8 accuses this as low performance
!isNaN(prop) // https://stackoverflow.com/a/175787/892698
) {
return this.items[prop];
}
else {
return Reflect.get(obj, prop);
}
},
set: (obj, key, setValue) => {
if (typeof (key) !== "symbol" && !isNaN(key)) {
if (setValue === undefined || setValue === null) {
obj.$deleteAt(key);
}
else {
if (setValue[$changes]) {
assertInstanceType(setValue, obj[$childType], obj, key);
const previousValue = obj.items[key];
if (!obj.isMovingItems) {
obj.$changeAt(Number(key), setValue);
}
else {
if (previousValue !== undefined) {
if (setValue[$changes].isNew) {
obj[$changes].indexedOperation(Number(key), OPERATION.MOVE_AND_ADD);
}
else {
if ((obj[$changes].getChange(Number(key)) & OPERATION.DELETE) === OPERATION.DELETE) {
obj[$changes].indexedOperation(Number(key), OPERATION.DELETE_AND_MOVE);
}
else {
obj[$changes].indexedOperation(Number(key), OPERATION.MOVE);
}
}
}
else if (setValue[$changes].isNew) {
obj[$changes].indexedOperation(Number(key), OPERATION.ADD);
}
setValue[$changes].setParent(this, obj[$changes].root, key);
}
if (previousValue !== undefined) {
// remove root reference from previous value
previousValue[$changes].root?.remove(previousValue[$changes]);
}
}
else {
obj.$changeAt(Number(key), setValue);
}
obj.items[key] = setValue;
obj.tmpItems[key] = setValue;
}
return true;
}
else {
return Reflect.set(obj, key, setValue);
}
},
deleteProperty: (obj, prop) => {
if (typeof (prop) === "number") {
obj.$deleteAt(prop);
}
else {
delete obj[prop];
}
return true;
},
has: (obj, key) => {
if (typeof (key) !== "symbol" && !isNaN(Number(key))) {
return Reflect.has(this.items, key);
}
return Reflect.has(obj, key);
}
});
Object.defineProperty(this, $changes, {
value: new ChangeTree(proxy),
enumerable: false,
writable: true,
});
if (items.length > 0) {
this.push(...items);
}
return proxy;
}
set length(newLength) {
if (newLength === 0) {
this.clear();
}
else if (newLength < this.items.length) {
this.splice(newLength, this.length - newLength);
}
else {
console.warn("ArraySchema: can't set .length to a higher value than its length.");
}
}
get length() {
return this.items.length;
}
/**
* Re-point children at their wire slot. `ChangeTree.parentIndex` caches
* the slot a child holds in `tmpItems`, and StateView addresses per-view
* ADD/DELETE with it — so a reorder that leaves it behind aims those ops
* at whichever element inherited the slot (issue #231).
*
* The filter check is a correctness boundary, not a tunable: StateView is
* the only reader, and an array without `filteredChanges` never has a slot
* read back. Everything else stops at that check instead of walking its
* children every tick.
*
* Callers name the lowest slot that moved as `from`. Compaction cannot, so
* it hands over the pre-compaction layout as `staged` and the unchanged
* prefix is skipped instead. Either way tail churn walks nothing.
*/
$reindexChildren(from, staged) {
if (this[$changes].filteredChanges === undefined) {
return;
} // nothing will read the cache
if (typeof this[$childType] === "string") {
return;
} // primitives have no child tree
const tmpItems = this.tmpItems;
const length = tmpItems.length;
if (staged !== undefined) {
while (from < length && tmpItems[from] === staged[from]) {
from++;
}
}
for (let i = from; i < length; i++) {
tmpItems[i]?.[$changes]?.setParentIndex(this, i);
}
}
push(...values) {
let length = this.tmpItems.length;
const changeTree = this[$changes];
for (let i = 0, l = values.length; i < l; i++, length++) {
const value = values[i];
if (value === undefined || value === null) {
// skip null values
return;
}
else if (typeof (value) === "object" && this[$childType]) {
assertInstanceType(value, this[$childType], this, i);
// TODO: move value[$changes]?.setParent() to this block.
}
changeTree.indexedOperation(length, OPERATION.ADD, this.items.length);
this.items.push(value);
this.tmpItems.push(value);
//
// set value's parent after the value is set
// (to avoid encoding "refId" operations before parent's "ADD" operation)
//
value[$changes]?.setParent(this, changeTree.root, length);
}
return length;
}
/**
* Removes the last element from an array and returns it.
*/
pop() {
let index = -1;
// find last non-undefined index
for (let i = this.tmpItems.length - 1; i >= 0; i--) {
// if (this.tmpItems[i] !== undefined) {
if (this.deletedIndexes[i] !== true) {
index = i;
break;
}
}
if (index < 0) {
return undefined;
}
this[$changes].delete(index, undefined, this.items.length - 1);
this.deletedIndexes[index] = true;
return this.items.pop();
}
at(index) {
// Allow negative indexing from the end
if (index < 0)
index += this.length;
return this.items[index];
}
// encoding only
$changeAt(index, value) {
if (value === undefined || value === null) {
console.error("ArraySchema items cannot be null nor undefined; Use `deleteAt(index)` instead.");
return;
}
// skip if the value is the same as cached.
if (this.items[index] === value) {
return;
}
const operation = (this.items[index] !== undefined)
? typeof (value) === "object"
? OPERATION.DELETE_AND_ADD // schema child
: OPERATION.REPLACE // primitive
: OPERATION.ADD;
const changeTree = this[$changes];
changeTree.change(index, operation);
//
// set value's parent after the value is set
// (to avoid encoding "refId" operations before parent's "ADD" operation)
//
value[$changes]?.setParent(this, changeTree.root, index);
}
// encoding only
$deleteAt(index, operation) {
this[$changes].delete(index, operation);
}
// decoding only
$setAt(index, value, operation) {
if (index === 0 &&
operation === OPERATION.ADD &&
this.items[index] !== undefined) {
// handle decoding unshift
this.items.unshift(value);
}
else if (operation === OPERATION.DELETE_AND_MOVE) {
this.items.splice(index, 1);
this.items[index] = value;
}
else {
this.items[index] = value;
}
}
clear() {
// skip if already clear
if (this.items.length === 0) {
return;
}
// discard previous operations.
const changeTree = this[$changes];
// remove children references
changeTree.forEachChild((childChangeTree, _) => {
changeTree.root?.remove(childChangeTree);
});
changeTree.discard(true);
changeTree.operation(OPERATION.CLEAR);
this.items.length = 0;
this.tmpItems.length = 0;
}
/**
* Combines two or more arrays.
* @param items Additional items to add to the end of array1.
*/
// @ts-ignore
concat(...items) {
return new ArraySchema(...this.items.concat(...items));
}
/**
* Adds all the elements of an array separated by the specified separator string.
* @param separator A string used to separate one element of an array from the next in the resulting String. If omitted, the array elements are separated with a comma.
*/
join(separator) {
return this.items.join(separator);
}
/**
* Reverses the elements in an Array.
*/
// @ts-ignore
reverse() {
this[$changes].operation(OPERATION.REVERSE);
this.items.reverse();
this.tmpItems.reverse();
this.$reindexChildren(0);
return this;
}
/**
* Removes the first element from an array and returns it.
*/
shift() {
if (this.items.length === 0) {
return undefined;
}
const changeTree = this[$changes];
const index = this.tmpItems.findIndex(item => item === this.items[0]);
const allChangesIndex = this.items.findIndex(item => item === this.items[0]);
changeTree.delete(index, OPERATION.DELETE, allChangesIndex);
changeTree.shiftAllChangeIndexes(-1, allChangesIndex);
this.deletedIndexes[index] = true;
return this.items.shift();
}
/**
* Returns a section of an array.
* @param start The beginning of the specified portion of the array.
* @param end The end of the specified portion of the array. This is exclusive of the element at the index 'end'.
*/
slice(start, end) {
const sliced = new ArraySchema();
sliced.push(...this.items.slice(start, end));
return sliced;
}
/**
* Sorts an array.
* @param compareFn Function used to determine the order of the elements. It is expected to return
* a negative value if first argument is less than second argument, zero if they're equal and a positive
* value otherwise. If omitted, the elements are sorted in ascending, ASCII character order.
* ```ts
* [11,2,22,1].sort((a, b) => a - b)
* ```
*/
sort(compareFn = DEFAULT_SORT) {
this.isMovingItems = true;
const changeTree = this[$changes];
const sortedItems = this.items.sort(compareFn);
// wouldn't OPERATION.MOVE make more sense here?
sortedItems.forEach((_, i) => changeTree.change(i, OPERATION.REPLACE));
this.tmpItems.sort(compareFn);
this.$reindexChildren(0);
this.isMovingItems = false;
return this;
}
/**
* Removes elements from an array and, if necessary, inserts new elements in their place, returning the deleted elements.
* @param start The zero-based location in the array from which to start removing elements.
* @param deleteCount The number of elements to remove.
* @param insertItems Elements to insert into the array in place of the deleted elements.
*/
splice(start, deleteCount, ...insertItems) {
const changeTree = this[$changes];
const itemsLength = this.items.length;
const tmpItemsLength = this.tmpItems.length;
const insertCount = insertItems.length;
// build up-to-date list of indexes, excluding removed values.
const indexes = [];
for (let i = 0; i < tmpItemsLength; i++) {
if (this.deletedIndexes[i] !== true) {
indexes.push(i);
}
}
if (itemsLength > start) {
// if deleteCount is not provided, delete all items from start to end
if (deleteCount === undefined) {
deleteCount = itemsLength - start;
}
//
// delete operations at correct index
//
for (let i = start; i < start + deleteCount; i++) {
const index = indexes[i];
changeTree.delete(index, OPERATION.DELETE);
this.deletedIndexes[index] = true;
}
}
else {
// not enough items to delete
deleteCount = 0;
}
// insert operations
if (insertCount > 0) {
if (insertCount > deleteCount) {
console.error("Inserting more elements than deleting during ArraySchema#splice()");
throw new Error("ArraySchema#splice(): insertCount must be equal or lower than deleteCount.");
}
for (let i = 0; i < insertCount; i++) {
const addIndex = (indexes[start] ?? itemsLength) + i;
changeTree.indexedOperation(addIndex, (this.deletedIndexes[addIndex])
? OPERATION.DELETE_AND_ADD
: OPERATION.ADD);
// set value's parent/root
insertItems[i][$changes]?.setParent(this, changeTree.root, addIndex);
}
}
//
// delete exceeding indexes from "allChanges"
// (prevent .encodeAll() from encoding non-existing items)
//
if (deleteCount > insertCount) {
changeTree.shiftAllChangeIndexes(-(deleteCount - insertCount), indexes[start + insertCount]);
// debugChangeSet("AFTER SHIFT indexes", changeTree.allChanges);
}
//
// FIXME: this code block is duplicated on ChangeTree
//
if (changeTree.filteredChanges !== undefined) {
changeTree.root?.enqueueChangeTree(changeTree, 'filteredChanges');
}
else {
changeTree.root?.enqueueChangeTree(changeTree, 'changes');
}
return this.items.splice(start, deleteCount, ...insertItems);
}
/**
* Inserts new elements at the start of an array.
* @param items Elements to insert at the start of the Array.
*/
unshift(...items) {
const changeTree = this[$changes];
// shift indexes
changeTree.shiftChangeIndexes(items.length);
// new index
if (changeTree.isFiltered) {
setOperationAtIndex(changeTree.filteredChanges, this.items.length);
// changeTree.filteredChanges[this.items.length] = OPERATION.ADD;
}
else {
setOperationAtIndex(changeTree.allChanges, this.items.length);
// changeTree.allChanges[this.items.length] = OPERATION.ADD;
}
// FIXME: should we use OPERATION.MOVE here instead?
items.forEach((_, index) => {
changeTree.change(index, OPERATION.ADD);
});
this.tmpItems.unshift(...items);
this.$reindexChildren(0); // from 0: nothing above placed the new items either
return this.items.unshift(...items);
}
/**
* Returns the index of the first occurrence of a value in an array.
* @param searchElement The value to locate in the array.
* @param fromIndex The array index at which to begin the search. If fromIndex is omitted, the search starts at index 0.
*/
indexOf(searchElement, fromIndex) {
return this.items.indexOf(searchElement, fromIndex);
}
/**
* Returns the index of the last occurrence of a specified value in an array.
* @param searchElement The value to locate in the array.
* @param fromIndex The array index at which to begin the search. If fromIndex is omitted, the search starts at the last index in the array.
*/
lastIndexOf(searchElement, fromIndex = this.length - 1) {
return this.items.lastIndexOf(searchElement, fromIndex);
}
every(callbackfn, thisArg) {
return this.items.every(callbackfn, thisArg);
}
/**
* Determines whether the specified callback function returns true for any element of an array.
* @param callbackfn A function that accepts up to three arguments. The some method calls
* the callbackfn function for each element in the array until the callbackfn returns a value
* which is coercible to the Boolean value true, or until the end of the array.
* @param thisArg An object to which the this keyword can refer in the callbackfn function.
* If thisArg is omitted, undefined is used as the this value.
*/
some(callbackfn, thisArg) {
return this.items.some(callbackfn, thisArg);
}
/**
* Performs the specified action for each element in an array.
* @param callbackfn A function that accepts up to three arguments. forEach calls the callbackfn function one time for each element in the array.
* @param thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
*/
forEach(callbackfn, thisArg) {
return this.items.forEach(callbackfn, thisArg);
}
/**
* Calls a defined callback function on each element of an array, and returns an array that contains the results.
* @param callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.
* @param thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
*/
map(callbackfn, thisArg) {
return this.items.map(callbackfn, thisArg);
}
filter(callbackfn, thisArg) {
return this.items.filter(callbackfn, thisArg);
}
/**
* Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.
* @param callbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.
* @param initialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.
*/
reduce(callbackfn, initialValue) {
return this.items.reduce(callbackfn, initialValue);
}
/**
* Calls the specified callback function for all the elements in an array, in descending order. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.
* @param callbackfn A function that accepts up to four arguments. The reduceRight method calls the callbackfn function one time for each element in the array.
* @param initialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.
*/
reduceRight(callbackfn, initialValue) {
return this.items.reduceRight(callbackfn, initialValue);
}
/**
* Returns the value of the first element in the array where predicate is true, and undefined
* otherwise.
* @param predicate find calls predicate once for each element of the array, in ascending
* order, until it finds one where predicate returns true. If such an element is found, find
* immediately returns that element value. Otherwise, find returns undefined.
* @param thisArg If provided, it will be used as the this value for each invocation of
* predicate. If it is not provided, undefined is used instead.
*/
find(predicate, thisArg) {
return this.items.find(predicate, thisArg);
}
/**
* Returns the index of the first element in the array where predicate is true, and -1
* otherwise.
* @param predicate find calls predicate once for each element of the array, in ascending
* order, until it finds one where predicate returns true. If such an element is found,
* findIndex immediately returns that element index. Otherwise, findIndex returns -1.
* @param thisArg If provided, it will be used as the this value for each invocation of
* predicate. If it is not provided, undefined is used instead.
*/
findIndex(predicate, thisArg) {
return this.items.findIndex(predicate, thisArg);
}
/**
* Returns the this object after filling the section identified by start and end with value
* @param value value to fill array section with
* @param start index to start filling the array at. If start is negative, it is treated as
* length+start where length is the length of the array.
* @param end index to stop filling the array at. If end is negative, it is treated as
* length+end.
*/
fill(value, start, end) {
//
// TODO
//
throw new Error("ArraySchema#fill() not implemented");
}
/**
* Returns the this object after copying a section of the array identified by start and end
* to the same array starting at position target
* @param target If target is negative, it is treated as length+target where length is the
* length of the array.
* @param start If start is negative, it is treated as length+start. If end is negative, it
* is treated as length+end.
* @param end If not specified, length of the this object is used as its default value.
*/
copyWithin(target, start, end) {
//
// TODO
//
throw new Error("ArraySchema#copyWithin() not implemented");
}
/**
* Returns a string representation of an array.
*/
toString() {
return this.items.toString();
}
/**
* Returns a string representation of an array. The elements are converted to string using their toLocalString methods.
*/
toLocaleString() {
return this.items.toLocaleString();
}
;
/** Iterator */
[Symbol.iterator]() {
return this.items[Symbol.iterator]();
}
static get [Symbol.species]() {
return ArraySchema;
}
// WORKAROUND for compatibility
// - TypeScript 4 defines @@unscopables as a function
// - TypeScript 5 defines @@unscopables as an object
[Symbol.unscopables];
/**
* Returns an iterable of key, value pairs for every entry in the array
*/
entries() { return this.items.entries(); }
/**
* Returns an iterable of keys in the array
*/
keys() { return this.items.keys(); }
/**
* Returns an iterable of values in the array
*/
values() { return this.items.values(); }
/**
* Determines whether an array includes a certain element, returning true or false as appropriate.
* @param searchElement The element to search for.
* @param fromIndex The position in this array at which to begin searching for searchElement.
*/
includes(searchElement, fromIndex) {
return this.items.includes(searchElement, fromIndex);
}
//
// ES2022
//
/**
* Calls a defined callback function on each element of an array. Then, flattens the result into
* a new array.
* This is identical to a map followed by flat with depth 1.
*
* @param callback A function that accepts up to three arguments. The flatMap method calls the
* callback function one time for each element in the array.
* @param thisArg An object to which the this keyword can refer in the callback function. If
* thisArg is omitted, undefined is used as the this value.
*/
// @ts-ignore
flatMap(callback, thisArg) {
// @ts-ignore
throw new Error("ArraySchema#flatMap() is not supported.");
}
/**
* Returns a new array with all sub-array elements concatenated into it recursively up to the
* specified depth.
*
* @param depth The maximum recursion depth
*/
// @ts-ignore
flat(depth) {
throw new Error("ArraySchema#flat() is not supported.");
}
findLast() {
// @ts-ignore
return this.items.findLast.apply(this.items, arguments);
}
findLastIndex(...args) {
// @ts-ignore
return this.items.findLastIndex.apply(this.items, arguments);
}
//
// ES2023
//
with(index, value) {
const copy = this.items.slice();
// Allow negative indexing from the end
if (index < 0)
index += this.length;
copy[index] = value;
return new ArraySchema(...copy);
}
toReversed() {
return this.items.slice().reverse();
}
toSorted(compareFn) {
return this.items.slice().sort(compareFn);
}
// @ts-ignore
toSpliced(start, deleteCount, ...items) {
// @ts-ignore
return this.items.toSpliced.apply(copy, arguments);
}
shuffle() {
return this.move((_) => {
let currentIndex = this.items.length;
while (currentIndex != 0) {
let randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[this[currentIndex], this[randomIndex]] = [this[randomIndex], this[currentIndex]];
}
});
}
/**
* Allows to move items around in the array.
*
* Example:
* state.cards.move((cards) => {
* [cards[4], cards[3]] = [cards[3], cards[4]];
* [cards[3], cards[2]] = [cards[2], cards[3]];
* [cards[2], cards[0]] = [cards[0], cards[2]];
* [cards[1], cards[1]] = [cards[1], cards[1]];
* [cards[0], cards[0]] = [cards[0], cards[0]];
* })
*
* @param cb
* @returns
*/
move(cb) {
this.isMovingItems = true;
cb(this);
this.isMovingItems = false;
return this;
}
[$getByIndex](index, isEncodeAll = false) {
//
// TODO: avoid unecessary `this.tmpItems` check during decoding.
//
// ENCODING uses `this.tmpItems` (or `this.items` if `isEncodeAll` is true)
// DECODING uses `this.items`
//
return (isEncodeAll)
? this.items[index]
: this.deletedIndexes[index]
? this.items[index]
: this.tmpItems[index] || this.items[index];
}
[$deleteByIndex](index) {
this.items[index] = undefined;
this.tmpItems[index] = undefined; // TODO: do not try to get "tmpItems" at decoding time.
}
[$onEncodeEnd]() {
const staged = this.tmpItems;
this.tmpItems = this.items.slice();
// Compaction just closed the staged holes — everything above the
// lowest one slid down a slot. There is no cheap "were there any"
// test to gate this on: `deletedIndexes` is an object here, and a
// `for...in` probe measured slower than the prefix scan it skips.
this.$reindexChildren(0, staged);
this.deletedIndexes = {};
}
[$onDecodeEnd]() {
this.items = this.items.filter((item) => item !== undefined);
this.tmpItems = this.items.slice(); // TODO: do no use "tmpItems" at decoding time.
}
toArray() {
return this.items.slice(0);
}
toJSON() {
return this.toArray().map((value) => {
return (typeof (value['toJSON']) === "function")
? value['toJSON']()
: value;
});
}
//
// Decoding utilities
//
clone(isDecoding) {
let cloned;
if (isDecoding) {
cloned = new ArraySchema();
cloned.push(...this.items);
}
else {
cloned = new ArraySchema(...this.map(item => ((item[$changes])
? item.clone()
: item)));
}
return cloned;
}
;
}
registerType("array", { constructor: ArraySchema });
class MapSchema {
[$changes];
[$refId];
childType;
[$childType];
$items = new Map();
$indexes = new Map();
deletedItems = {};
static [$encoder] = encodeKeyValueOperation;
static [$decoder] = decodeKeyValueOperation;
/**
* Determine if a property must be filtered.
* - If returns false, the property is NOT going to be encoded.
* - If returns true, the property is going to be encoded.
*
* Encoding with "filters" happens in two steps:
* - First, the encoder iterates over all "not owned" properties and encodes them.
* - Then, the encoder iterates over all "owned" properties per instance and encodes them.
*/
static [$filter](ref, index, view) {
return (!view ||
typeof (ref[$childType]) === "string" ||
view.isChangeTreeVisible((ref[$getByIndex](index) ?? ref.deletedItems[index])[$changes]));
}
static is(type) {
return type['map'] !== undefined;
}
constructor(initialValues) {
const changeTree = new ChangeTree(this);
changeTree.indexes = {};
Object.defineProperty(this, $changes, {
value: changeTree,
enumerable: false,
writable: true,
});
if (initialValues) {
if (initialValues instanceof Map ||
initialValues instanceof MapSchema) {
initialValues.forEach((v, k) => this.set(k, v));
}
else {
for (const k in initialValues) {
this.set(k, initialValues[k]);
}
}
}
Object.defineProperty(this, $childType, {
value: undefined,
enumerable: false,
writable: true,
configurable: true,
});
}
/** Iterator */
[Symbol.iterator]() { return this.$items[Symbol.iterator](); }
get [Symbol.toStringTag]() { return this.$items[Symbol.toStringTag]; }
static get [Symbol.species]() { return MapSchema; }
set(key, value) {
if (value === undefined || value === null) {
throw new Error(`MapSchema#set('${key}', ${value}): trying to set ${value} value on '${key}'.`);
}
else if (typeof (value) === "object" && this[$childType]) {
assertInstanceType(value, this[$childType], this, key);
}
// Force "key" as string
// See: https://github.com/colyseus/colyseus/issues/561#issuecomment-1646733468
key = key.toString();
const changeTree = this[$changes];
const isRef = (value[$changes]) !== undefined;
let index;
let operation;
// IS REPLACE?
if (typeof (changeTree.indexes[key]) !== "undefined") {
index = changeTree.indexes[key];
operation = OPERATION.REPLACE;
const previousValue = this.$items.get(key);
if (previousValue === value) {
// if value is the same, avoid re-encoding it.
return;
}
else if (isRef) {
// if is schema, force ADD operation if value differ from previous one.
operation = OPERATION.DELETE_AND_ADD;
// remove reference from previous value
if (previousValue !== undefined) {
previousValue[$changes].root?.remove(previousValue[$changes]);
}
}
if (this.deletedItems[index]) {
delete this.deletedItems[index];
}
}
else {
index = changeTree.indexes[$numFields] ?? 0;
operation = OPERATION.ADD;
this.$indexes.set(index, key);
changeTree.indexes[key] = index;
changeTree.indexes[$numFields] = index + 1;
}
this.$items.set(key, value);
changeTree.change(index, operation);
//
// set value's parent after the value is set
// (to avoid encoding "refId" operations before parent's "ADD" operation)
//
if (isRef) {
value[$changes].setParent(this, changeTree.root, index);
}
return this;
}
get(key) {
return this.$items.get(key);
}
/**
* Returns the value for `key` if present. Otherwise inserts `defaultValue`
* (tracked as an ADD change, like `set()`) and returns it.
*
* Mirrors `Map.prototype.getOrInsert` (TC39 "upsert" proposal, typed in
* TypeScript 6's standard library).
*/
getOrInsert(key, defaultValue) {
if (this.$items.has(key)) {
return this.$items.get(key);
}
this.set(key, defaultValue);
return defaultValue;
}
/**
* Returns the value for `key` if present. Otherwise computes a value via
* `callbackfn(key)`, inserts it (tracked as an ADD change, like `set()`)
* and returns it. The callback is only invoked when the key is missing.
*
* Mirrors `Map.prototype.getOrInsertComputed` (TC39 "upsert" proposal,
* typed in TypeScript 6's standard library).
*/
getOrInsertComputed(key, callbackfn) {
if (this.$items.has(key)) {
return this.$items.get(key);
}
const value = callbackfn(key);
// per spec: overwrites even if callbackfn itself inserted `key`
this.set(key, value);
return value;
}
delete(key) {
if (!this.$items.has(key)) {
return false;
}
const index = this[$changes].indexes[key];
this.deletedItems[index] = this[$changes].delete(index);
return this.$items.delete(key);
}
clear() {
const changeTree = this[$changes];
// discard previous operations.
changeTree.discard(true);
changeTree.indexes = {};
// remove children references
changeTree.forEachChild((childChangeTree, _) => {
changeTree.root?.remove(childChangeTree);
});
// clear previous indexes
this.$indexes.clear();
// clear items
this.$items.clear();
changeTree.operation(OPERATION.CLEAR);
}
has(key) {
return this.$items.has(key);
}
forEach(callbackfn) {
this.$items.forEach(callbackfn);
}
entries() {
return this.$items.entries();
}
keys() {
return this.$items.keys();
}
values() {
return this.$items.values();
}
get size() {
return this.$items.size;
}
setIndex(index, key) {
this.$indexes.set(index, key);
}
getIndex(index) {
return this.$indexes.get(index);
}
[$getByIndex](index) {
return this.$items.get(this.$indexes.get(index));
}
[$deleteByIndex](index) {
const key = this.$indexes.get(index);
this.$items.delete(key);
this.$indexes.delete(index);
}
[$onEncodeEnd]() {
const changeTree = this[$changes];
// - cleanup changeTree.indexes
// - cleanup $indexes
for (const indexStr in this.deletedItems) {
const index = parseInt(indexStr);
const key = this.$indexes.get(index);
// TODO: refactor this.
// it shouldn't be necessary to keep track of indexes both on changeTree and on $indexes
delete changeTree.indexes[key];
this.$indexes.delete(index);
}
this.deletedItems = {};
}
toJSON() {
const map = {};
this.forEach((value, key) => {
map[key] = (typeof (value['toJSON']) === "function")
? value['toJSON']()
: value;
});
return map;
}
//
// Decoding utilities
//
// @ts-ignore
clone(isDecoding) {
let cloned;
if (isDecoding) {
// client-side
cloned = Object.assign(new MapSchema(), this);
}
else {
// server-side
cloned = new MapSchema();
this.forEach((value, key) => {
if (value[$changes]) {
cloned.set(key, value['clone']());
}
else {
cloned.set(key, value);
}
});
}
return cloned;
}
}
registerType("map", { constructor: MapSchema });
class CollectionSchema {
[$changes];
[$refId];
[$childType];
$items = new Map();
$indexes = new Map();
deletedItems = {};
$refId = 0;
static [$encoder] = encodeKeyValueOperation;
static [$decoder] = decodeKeyValueOperation;
/**
* Determine if a property must be filtered.
* - If returns false, the property is NOT going to be encoded.
* - If returns true, the property is going to be encoded.
*
* Encoding with "filters" happens in two steps:
* - First, the encoder iterates over all "not owned" properties and encodes them.
* - Then, the encoder iterates over all "owned" properties per instance and encodes them.
*/
static [$filter](ref, index, view) {
return (!view ||
typeof (ref[$childType]) === "string" ||
view.isChangeTreeVisible((ref[$getByIndex](index) ?? ref.deletedItems[index])[$changes]));
}
static is(type) {
return type['collection'] !== undefined;
}
constructor(initialValues) {
this[$changes] = new ChangeTree(this);
this[$changes].indexes = {};
if (initialValues) {
initialValues.forEach((v) => this.add(v));
}
Object.defineProperty(this, $childType, {
value: undefined,
enumerable: false,
writable: true,
configurable: true,
});
}
add(value) {
// set "index" for reference.
const index = this.$refId++;
const isRef = (value[$changes]) !== undefined;
if (isRef) {
value[$changes].setParent(this, this[$changes].root, index);
}
this[$changes].indexes[index] = index;
this.$indexes.set(index, index);
this.$items.set(index, value);
this[$changes].change(index);
return index;
}
at(index) {
const key = Array.from(this.$items.keys())[index];
return this.$items.get(key);
}
entries() {
return this.$items.entries();
}
delete(item) {
const entries = this.$items.entries();
let index;
let entry;
while (entry = entries.next()) {
if (entry.done) {
break;
}
if (item === entry.value[1]) {
index = entry.value[0];
break;
}
}
if (index === undefined) {
return false;
}
this.deletedItems[index] = this[$changes].delete(index);
this.$indexes.delete(index);
return this.$items.delete(index);
}
clear() {
const changeTree = this[$changes];
// discard previous operations.
changeTree.discard(true);
changeTree.indexes = {};
// remove children references
changeTree.forEachChild((childChangeTree, _) => {
changeTree.root?.remove(childChangeTree);
});
// clear previous indexes
this.$indexes.clear();
// clear items
this.$items.clear();
changeTree.operation(OPERATION.CLEAR);
}
has(value) {
return Array.from(this.$items.values()).some((v) => v === value);
}
forEach(callbackfn) {
this.$items.forEach((value, key, _) => callbackfn(value, key, this));
}
values() {
return this.$items.values();
}
get size() {
return this.$items.size;
}
/** Iterator */
[Symbol.iterator]() {
return this.$items.values();
}
setIndex(index, key) {
this.$indexes.set(index, key);
}
getIndex(index) {
return this.$indexes.get(index);
}
[$getByIndex](index) {
return this.$items.get(this.$indexes.get(index));
}
[$deleteByIndex](index) {
const key = this.$indexes.get(index);
this.$items.delete(key);
this.$indexes.delete(index);
}
[$onEncodeEnd]() {
this.deletedItems = {};
}
toArray() {
return Array.from(this.$items.values());
}
toJSON() {
const values = [];
this.forEach((value, key) => {
values.push((typeof (value['toJSON']) === "function")
? value['toJSON']()
: value);
});
return values;
}
//
// Decoding utilities
//
clone(isDecoding) {
let cloned;
if (isDecoding) {
// client-side
cloned = Object.assign(new CollectionSchema(), this);
}
else {
// server-side
cloned = new CollectionSchema();
this.forEach((value) => {
if (value[$changes]) {
cloned.add(value['clone']());
}
else {
cloned.add(value);
}
});
}
return cloned;
}
}
registerType("collection", { constructor: CollectionSchema, });
class SetSchema {
[$changes];
[$refId];
[$childType];
$items = new Map();
$indexes = new Map();
deletedItems = {};
$refId = 0;
static [$encoder] = encodeKeyValueOperation;
static [$decoder] = decodeKeyValueOperation;
/**
* Determine if a property must be filtered.
* - If returns false, the property is NOT going to be encoded.
* - If returns true, the property is going to be encoded.
*
* Encoding with "filters" happens in two steps:
* - First, the encoder iterates over all "not owned" properties and encodes them.
* - Then, the encoder iterates over all "owned" properties per instance and encodes them.
*/
static [$filter](ref, index, view) {
return (!view ||
typeof (ref[$childType]) === "string" ||
view.visible.has((ref[$getByIndex](index) ?? ref.deletedItems[index])[$changes]));
}
static is(type) {
return type['set'] !== undefined;
}
constructor(initialValues) {
this[$changes] = new ChangeTree(this);
this[$changes].indexes = {};
if (initialValues) {
initialValues.forEach((v) => this.add(v));
}
Object.defineProperty(this, $childType, {
value: undefined,
enumerable: false,
writable: true,
configurable: true,
});
}
add(value) {
// immediatelly return false if value already added.
if (this.has(value)) {
return false;
}
// set "index" for reference.
const index = this.$refId++;
if ((value[$changes]) !== undefined) {
value[$changes].setParent(this, this[$changes].root, index);
}
const operation = this[$changes].indexes[index]?.op ?? OPERATION.ADD;
this[$changes].indexes[index] = index;
this.$indexes.set(index, index);
this.$items.set(index, value);
this[$changes].change(index, operation);
return index;
}
entries() {
return this.$items.entries();
}
delete(item) {
const entries = this.$items.entries();
let index;
let entry;
while (entry = entries.next()) {
if (entry.done) {
break;
}
if (item === entry.value[1]) {
index = entry.value[0];
break;
}
}
if (index === undefined) {
return false;
}
this.deletedItems[index] = this[$changes].delete(index);
this.$indexes.delete(index);
return this.$items.delete(index);
}
clear() {
const changeTree = this[$changes];
// discard previous operations.
changeTree.discard(true);
changeTree.indexes = {};
// clear previous indexes
this.$indexes.clear();
// clear items
this.$items.clear();
changeTree.operation(OPERATION.CLEAR);
}
has(value) {
const values = this.$items.values();
let has = false;
let entry;
while (entry = values.next()) {
if (entry.done) {
break;
}
if (value === entry.value) {
has = true;
break;
}
}
return has;
}
forEach(callbackfn) {
this.$items.forEach((value, key, _) => callbackfn(value, key, this));
}
values() {
return this.$items.values();
}
get size() {
return this.$items.size;
}
/** Iterator */
[Symbol.iterator]() {
return this.$items.values();
}
setIndex(index, key) {
this.$indexes.set(index, key);
}
getIndex(index) {
return this.$indexes.get(index);
}
[$getByIndex](index) {
return this.$items.get(this.$indexes.get(index));
}
[$deleteByIndex](index) {
const key = this.$indexes.get(index);
this.$items.delete(key);
this.$indexes.delete(index);
}
[$onEncodeEnd]() {
this.deletedItems = {};
}
toArray() {
return Array.from(this.$items.values());
}
toJSON() {
const values = [];
this.forEach((value, key) => {
values.push((typeof (value['toJSON']) === "function")
? value['toJSON']()
: value);
});
return values;
}
//
// Decoding utilities
//
clone(isDecoding) {
let cloned;
if (isDecoding) {
// client-side
cloned = Object.assign(new SetSchema(), this);
}
else {
// server-side
cloned = new SetSchema();
this.forEach((value) => {
if (value[$changes]) {
cloned.add(value['clone']());
}
else {
cloned.add(value);
}
});
}
return cloned;
}
}
registerType("set", { constructor: SetSchema });
const DEFAULT_VIEW_TAG = -1;
function entity(constructor) {
TypeContext.register(constructor);
return constructor;
}
/**
* [See documentation](https://docs.colyseus.io/state/schema/)
*
* Annotate a Schema property to be serializeable.
* \@type()'d fields are automatically flagged as "dirty" for the next patch.
*
* @example Standard usage, with automatic change tracking.
* ```
* \@type("string") propertyName: string;
* ```
*
* @example You can provide the "manual" option if you'd like to manually control your patches via .setDirty().
* ```
* \@type("string", { manual: true })
* ```
*/
// export function type(type: DefinitionType, options?: TypeOptions) {
// return function ({ get, set }, context: ClassAccessorDecoratorContext): ClassAccessorDecoratorResult<Schema, any> {
// if (context.kind !== "accessor") {
// throw new Error("@type() is only supported for class accessor properties");
// }
// const field = context.name.toString();
// //
// // detect index for this field, considering inheritance
// //
// const parent = Object.getPrototypeOf(context.metadata);
// let fieldIndex: number = context.metadata[$numFields] // current structure already has fields defined
// ?? (parent && parent[$numFields]) // parent structure has fields defined
// ?? -1; // no fields defined
// fieldIndex++;
// if (
// !parent && // the parent already initializes the `$changes` property
// !Metadata.hasFields(context.metadata)
// ) {
// context.addInitializer(function (this: Ref) {
// Object.defineProperty(this, $changes, {
// value: new ChangeTree(this),
// enumerable: false,
// writable: true
// });
// });
// }
// Metadata.addField(context.metadata, fieldIndex, field, type);
// const isArray = ArraySchema.is(type);
// const isMap = !isArray && MapSchema.is(type);
// // if (options && options.manual) {
// // // do not declare getter/setter descriptor
// // definition.descriptors[field] = {
// // enumerable: true,
// // configurable: true,
// // writable: true,
// // };
// // return;
// // }
// return {
// init(value) {
// // TODO: may need to convert ArraySchema/MapSchema here
// // do not flag change if value is undefined.
// if (value !== undefined) {
// this[$changes].change(fieldIndex);
// // automaticallty transform Array into ArraySchema
// if (isArray) {
// if (!(value instanceof ArraySchema)) {
// value = new ArraySchema(...value);
// }
// value[$childType] = Object.values(type)[0];
// }
// // automaticallty transform Map into MapSchema
// if (isMap) {
// if (!(value instanceof MapSchema)) {
// value = new MapSchema(value);
// }
// value[$childType] = Object.values(type)[0];
// }
// // try to turn provided structure into a Proxy
// if (value['$proxy'] === undefined) {
// if (isMap) {
// value = getMapProxy(value);
// }
// }
// }
// return value;
// },
// get() {
// return get.call(this);
// },
// set(value: any) {
// /**
// * Create Proxy for array or map items
// */
// // skip if value is the same as cached.
// if (value === get.call(this)) {
// return;
// }
// if (
// value !== undefined &&
// value !== null
// ) {
// // automaticallty transform Array into ArraySchema
// if (isArray) {
// if (!(value instanceof ArraySchema)) {
// value = new ArraySchema(...value);
// }
// value[$childType] = Object.values(type)[0];
// }
// // automaticallty transform Map into MapSchema
// if (isMap) {
// if (!(value instanceof MapSchema)) {
// value = new MapSchema(value);
// }
// value[$childType] = Object.values(type)[0];
// }
// // try to turn provided structure into a Proxy
// if (value['$proxy'] === undefined) {
// if (isMap) {
// value = getMapProxy(value);
// }
// }
// // flag the change for encoding.
// this[$changes].change(fieldIndex);
// //
// // call setParent() recursively for this and its child
// // structures.
// //
// if (value[$changes]) {
// value[$changes].setParent(
// this,
// this[$changes].root,
// Metadata.getIndex(context.metadata, field),
// );
// }
// } else if (get.call(this)) {
// //
// // Setting a field to `null` or `undefined` will delete it.
// //
// this[$changes].delete(field);
// }
// set.call(this, value);
// },
// };
// }
// }
function view(tag = DEFAULT_VIEW_TAG) {
return function (target, fieldName) {
const constructor = target.constructor;
const parentClass = Object.getPrototypeOf(constructor);
const parentMetadata = parentClass[Symbol.metadata];
// TODO: use Metadata.initialize()
const metadata = (constructor[Symbol.metadata] ??= Object.assign({}, constructor[Symbol.metadata], parentMetadata ?? Object.create(null)));
// const fieldIndex = metadata[fieldName];
// if (!metadata[fieldIndex]) {
// //
// // detect index for this field, considering inheritance
// //
// metadata[fieldIndex] = {
// type: undefined,
// index: (metadata[$numFields] // current structure already has fields defined
// ?? (parentMetadata && parentMetadata[$numFields]) // parent structure has fields defined
// ?? -1) + 1 // no fields defined
// }
// }
Metadata.setTag(metadata, fieldName, tag);
};
}
function type(type, options) {
return function (target, field) {
const constructor = target.constructor;
if (!type) {
throw new Error(`${constructor.name}: @type() reference provided for "${field}" is undefined. Make sure you don't have any circular dependencies.`);
}
// Normalize type (enum/collection/etc)
type = getNormalizedType(type);
// for inheritance support
TypeContext.register(constructor);
const parentClass = Object.getPrototypeOf(constructor);
const parentMetadata = parentClass[Symbol.metadata];
const metadata = Metadata.initialize(constructor);
let fieldIndex = metadata[field];
/**
* skip if descriptor already exists for this field (`@deprecated()`)
*/
if (metadata[fieldIndex] !== undefined) {
if (metadata[fieldIndex].deprecated) {
// do not create accessors for deprecated properties.
return;
}
else if (metadata[fieldIndex].type !== undefined) {
// trying to define same property multiple times across inheritance.
// https://github.com/colyseus/colyseus-unity3d/issues/131#issuecomment-814308572
try {
throw new Error(`@colyseus/schema: Duplicate '${field}' definition on '${constructor.name}'.\nCheck @type() annotation`);
}
catch (e) {
const definitionAtLine = e.stack.split("\n")[4].trim();
throw new Error(`${e.message} ${definitionAtLine}`);
}
}
}
else {
//
// detect index for this field, considering inheritance
//
fieldIndex = metadata[$numFields] // current structure already has fields defined
?? (parentMetadata && parentMetadata[$numFields]) // parent structure has fields defined
?? -1; // no fields defined
fieldIndex++;
}
if (options && options.manual) {
Metadata.addField(metadata, fieldIndex, field, type, {
// do not declare getter/setter descriptor
enumerable: true,
configurable: true,
writable: true,
});
}
else {
const complexTypeKlass = typeof (Object.keys(type)[0]) === "string" && getType(Object.keys(type)[0]);
const childType = (complexTypeKlass)
? Object.values(type)[0]
: type;
Metadata.addField(metadata, fieldIndex, field, type, getPropertyDescriptor(`_${field}`, fieldIndex, childType, complexTypeKlass));
}
};
}
function getPropertyDescriptor(fieldCached, fieldIndex, type, complexTypeKlass) {
return {
get: function () { return this[fieldCached]; },
set: function (value) {
const previousValue = this[fieldCached] ?? undefined;
// skip if value is the same as cached.
if (value === previousValue) {
return;
}
if (value !== undefined &&
value !== null) {
if (complexTypeKlass) {
// automaticallty transform Array into ArraySchema
if (complexTypeKlass.constructor === ArraySchema && !(value instanceof ArraySchema)) {
value = new ArraySchema(...value);
}
// automaticallty transform Map into MapSchema
if (complexTypeKlass.constructor === MapSchema && !(value instanceof MapSchema)) {
value = new MapSchema(value);
}
// // automaticallty transform Array into SetSchema
// if (complexTypeKlass.constructor === SetSchema && !(value instanceof SetSchema)) {
// value = new SetSchema(value);
// }
value[$childType] = type;
}
else if (typeof (type) !== "string") {
assertInstanceType(value, type, this, fieldCached.substring(1));
}
else {
assertType(value, type, this, fieldCached.substring(1));
}
const changeTree = this[$changes];
//
// Replacing existing "ref", remove it from root.
//
if (previousValue !== undefined && previousValue[$changes]) {
changeTree.root?.remove(previousValue[$changes]);
this.constructor[$track](changeTree, fieldIndex, OPERATION.DELETE_AND_ADD);
}
else {
this.constructor[$track](changeTree, fieldIndex, OPERATION.ADD);
}
//
// call setParent() recursively for this and its child
// structures.
//
value[$changes]?.setParent(this, changeTree.root, fieldIndex);
}
else if (previousValue !== undefined) {
//
// Setting a field to `null` or `undefined` will delete it.
//
this[$changes].delete(fieldIndex);
}
this[fieldCached] = value;
},
enumerable: true,
configurable: true
};
}
/**
* `@deprecated()` flag a field as deprecated.
* The previous `@type()` annotation should remain along with this one.
*/
function deprecated(throws = true) {
return function (klass, field) {
//
// FIXME: the following block of code is repeated across `@type()`, `@deprecated()` and `@unreliable()` decorators.
//
const constructor = klass.constructor;
const parentClass = Object.getPrototypeOf(constructor);
const parentMetadata = parentClass[Symbol.metadata];
const metadata = (constructor[Symbol.metadata] ??= Object.assign({}, constructor[Symbol.metadata], parentMetadata ?? Object.create(null)));
const fieldIndex = metadata[field];
// if (!metadata[field]) {
// //
// // detect index for this field, considering inheritance
// //
// metadata[field] = {
// type: undefined,
// index: (metadata[$numFields] // current structure already has fields defined
// ?? (parentMetadata && parentMetadata[$numFields]) // parent structure has fields defined
// ?? -1) + 1 // no fields defined
// }
// }
metadata[fieldIndex].deprecated = true;
if (throws) {
metadata[$descriptors] ??= {};
metadata[$descriptors][field] = {
get: function () { throw new Error(`${field} is deprecated.`); },
set: function (value) { },
enumerable: false,
configurable: true
};
}
// flag metadata[field] as non-enumerable
Object.defineProperty(metadata, fieldIndex, {
value: metadata[fieldIndex],
enumerable: false,
configurable: true
});
};
}
function defineTypes(target, fields, options) {
for (let field in fields) {
type(fields[field], options)(target.prototype, field);
}
return target;
}
function schema(fieldsAndMethods, name, inherits = Schema) {
const fields = {};
const methods = {};
const defaultValues = {};
const viewTagFields = {};
for (let fieldName in fieldsAndMethods) {
const value = fieldsAndMethods[fieldName];
if (typeof (value) === "object") {
if (value['view'] !== undefined) {
viewTagFields[fieldName] = (typeof (value['view']) === "boolean")
? DEFAULT_VIEW_TAG
: value['view'];
}
// allow to define a field as not synced
if (value['sync'] !== false) {
fields[fieldName] = getNormalizedType(value);
}
// If no explicit default provided, handle automatic instantiation for collection types
if (!Object.prototype.hasOwnProperty.call(value, 'default')) {
// TODO: remove Array.isArray() check. Use ['array'] !== undefined only.
if (Array.isArray(value) || value['array'] !== undefined) {
// Collection: Array → new ArraySchema()
defaultValues[fieldName] = new ArraySchema();
}
else if (value['map'] !== undefined) {
// Collection: Map → new MapSchema()
defaultValues[fieldName] = new MapSchema();
}
else if (value['collection'] !== undefined) {
// Collection: Collection → new CollectionSchema()
defaultValues[fieldName] = new CollectionSchema();
}
else if (value['set'] !== undefined) {
// Collection: Set → new SetSchema()
defaultValues[fieldName] = new SetSchema();
}
else if (value['type'] !== undefined && Schema.is(value['type'])) {
// Direct Schema type: Type → new Type()
if (!value['type'].prototype.initialize || value['type'].prototype.initialize.length === 0) {
// only auto-initialize Schema instances if:
// - they don't have an initialize method
// - or initialize method doesn't accept any parameters
defaultValues[fieldName] = new value['type']();
}
}
}
else {
defaultValues[fieldName] = value['default'];
}
}
else if (typeof (value) === "function") {
if (Schema.is(value)) {
// Direct Schema type: Type → new Type()
if (!value.prototype.initialize || value.prototype.initialize.length === 0) {
// only auto-initialize Schema instances if:
// - they don't have an initialize method
// - or initialize method doesn't accept any parameters
defaultValues[fieldName] = new value();
}
fields[fieldName] = getNormalizedType(value);
}
else {
methods[fieldName] = value;
}
}
else {
fields[fieldName] = getNormalizedType(value);
}
}
const getDefaultValues = () => {
const defaults = {};
// use current class default values
for (const fieldName in defaultValues) {
const defaultValue = defaultValues[fieldName];
if (defaultValue && typeof defaultValue.clone === 'function') {
// complex, cloneable values, e.g. Schema, ArraySchema, MapSchema, CollectionSchema, SetSchema
defaults[fieldName] = defaultValue.clone();
}
else {
// primitives and non-cloneable values
defaults[fieldName] = defaultValue;
}
}
return defaults;
};
const getParentProps = (props) => {
const fieldNames = Object.keys(fields);
const parentProps = {};
for (const key in props) {
if (!fieldNames.includes(key)) {
parentProps[key] = props[key];
}
}
return parentProps;
};
/** @codegen-ignore */
const klass = Metadata.setFields(class extends inherits {
constructor(...args) {
// call initialize method
if (methods.initialize && typeof methods.initialize === 'function') {
super(Object.assign({}, getDefaultValues(), getParentProps(args[0] || {})));
/**
* only call initialize() in the current class, not the parent ones.
* see "should not call initialize automatically when creating an instance of inherited Schema"
*/
if (new.target === klass) {
methods.initialize.apply(this, args);
}
}
else {
super(Object.assign({}, getDefaultValues(), args[0] || {}));
}
}
}, fields);
// Store the getDefaultValues function on the class for inheritance
klass._getDefaultValues = getDefaultValues;
// Add methods to the prototype
Object.assign(klass.prototype, methods);
for (let fieldName in viewTagFields) {
view(viewTagFields[fieldName])(klass.prototype, fieldName);
}
if (name) {
Object.defineProperty(klass, "name", { value: name });
}
klass.extends = (fields, name) => schema(fields, name, klass);
return klass;
}
function getIndent(level) {
return (new Array(level).fill(0)).map((_, i) => (i === level - 1) ? `└─ ` : ` `).join("");
}
function dumpChanges(schema) {
const $root = schema[$changes].root;
const dump = {
ops: {},
refs: []
};
// for (const refId in $root.changes) {
let current = $root.changes.next;
while (current) {
const changeTree = current.changeTree;
// skip if ChangeTree is undefined
if (changeTree === undefined) {
current = current.next;
continue;
}
const changes = changeTree.indexedOperations;
dump.refs.push(`refId#${changeTree.ref[$refId]}`);
for (const index in changes) {
const op = changes[index];
const opName = OPERATION[op];
if (!dump.ops[opName]) {
dump.ops[opName] = 0;
}
dump.ops[OPERATION[op]]++;
}
current = current.next;
}
return dump;
}
/**
* Schema encoder / decoder
*/
class Schema {
static [Symbol.metadata];
static [$encoder] = encodeSchemaOperation;
static [$decoder] = decodeSchemaOperation;
[$refId];
/**
* Assign the property descriptors required to track changes on this instance.
* @param instance
*/
static initialize(instance) {
Object.defineProperty(instance, $changes, {
value: new ChangeTree(instance),
enumerable: false,
writable: true
});
Object.defineProperties(instance, instance.constructor[Symbol.metadata]?.[$descriptors] || {});
}
static is(type) {
return typeof (type[Symbol.metadata]) === "object";
}
/**
* Check if a value is an instance of Schema.
* This method uses duck-typing to avoid issues with multiple @colyseus/schema versions.
* @param obj Value to check
* @returns true if the value is a Schema instance
*/
static isSchema(obj) {
return typeof obj?.assign === "function";
}
/**
* Track property changes
*/
static [$track](changeTree, index, operation = OPERATION.ADD) {
changeTree.change(index, operation);
}
/**
* Determine if a property must be filtered.
* - If returns false, the property is NOT going to be encoded.
* - If returns true, the property is going to be encoded.
*
* Encoding with "filters" happens in two steps:
* - First, the encoder iterates over all "not owned" properties and encodes them.
* - Then, the encoder iterates over all "owned" properties per instance and encodes them.
*/
static [$filter](ref, index, view) {
const metadata = ref.constructor[Symbol.metadata];
const tag = metadata[index]?.tag;
if (view === undefined) {
// shared pass/encode: encode if doesn't have a tag
return tag === undefined;
}
else if (tag === undefined) {
// view pass: no tag
return true;
}
else if (tag === DEFAULT_VIEW_TAG) {
// view pass: default tag
return view.isChangeTreeVisible(ref[$changes]);
}
else {
// view pass: custom tag (bitmask)
// tag is the field's stored bitmask; view.tags stores the accumulated bitmask of tags used in view.add().
const tags = view.tags?.get(ref[$changes]);
return tags != null && (tag & tags) !== 0;
}
}
// allow inherited classes to have a constructor
constructor(arg) {
//
// inline
// Schema.initialize(this);
//
Schema.initialize(this);
//
// Assign initial values
//
if (arg) {
Object.assign(this, arg);
}
}
/**
* Assign properties to the instance.
* @param props Properties to assign to the instance
* @returns
*/
assign(props) {
Object.assign(this, props);
return this;
}
/**
* Restore the instance from JSON data.
* @param jsonData JSON data to restore the instance from
* @returns
*/
restore(jsonData) {
const metadata = this.constructor[Symbol.metadata];
for (const fieldIndex in metadata) {
const field = metadata[fieldIndex];
const fieldName = field.name;
const fieldType = field.type;
const value = jsonData[fieldName];
if (value === undefined || value === null) {
continue;
}
if (typeof fieldType === "string") {
// Primitive type: assign directly
this[fieldName] = value;
}
else if (Schema.is(fieldType)) {
// Schema type: create instance and restore
const instance = new fieldType();
instance.restore(value);
this[fieldName] = instance;
}
else if (typeof fieldType === "object") {
// Collection types: { map: ... }, { array: ... }, etc.
const collectionType = Object.keys(fieldType)[0];
const childType = fieldType[collectionType];
if (collectionType === "map") {
const mapSchema = this[fieldName];
for (const key in value) {
if (Schema.is(childType)) {
const childInstance = new childType();
childInstance.restore(value[key]);
mapSchema.set(key, childInstance);
}
else {
mapSchema.set(key, value[key]);
}
}
}
else if (collectionType === "array") {
const arraySchema = this[fieldName];
for (let i = 0; i < value.length; i++) {
if (Schema.is(childType)) {
const childInstance = new childType();
childInstance.restore(value[i]);
arraySchema.push(childInstance);
}
else {
arraySchema.push(value[i]);
}
}
}
}
}
return this;
}
/**
* (Server-side): Flag a property to be encoded for the next patch.
* @param instance Schema instance
* @param property string representing the property name, or number representing the index of the property.
* @param operation OPERATION to perform (detected automatically)
*/
setDirty(property, operation) {
const metadata = this.constructor[Symbol.metadata];
this[$changes].change(metadata[metadata[property]].index, operation);
}
clone() {
// Create instance without calling custom constructor
const cloned = Object.create(this.constructor.prototype);
Schema.initialize(cloned);
const metadata = this.constructor[Symbol.metadata];
//
// TODO: clone all properties, not only annotated ones
//
// for (const field in this) {
for (const fieldIndex in metadata) {
const field = metadata[fieldIndex].name;
if (typeof (this[field]) === "object" &&
typeof (this[field]?.clone) === "function") {
// deep clone
cloned[field] = this[field].clone();
}
else {
// primitive values
cloned[field] = this[field];
}
}
return cloned;
}
toJSON() {
const obj = {};
const metadata = this.constructor[Symbol.metadata];
for (const index in metadata) {
const field = metadata[index];
const fieldName = field.name;
if (!field.deprecated && this[fieldName] !== null && typeof (this[fieldName]) !== "undefined") {
obj[fieldName] = (typeof (this[fieldName]['toJSON']) === "function")
? this[fieldName]['toJSON']()
: this[fieldName];
}
}
return obj;
}
/**
* Used in tests only
* @internal
*/
discardAllChanges() {
this[$changes].discardAll();
}
[$getByIndex](index) {
const metadata = this.constructor[Symbol.metadata];
return this[metadata[index].name];
}
[$deleteByIndex](index) {
const metadata = this.constructor[Symbol.metadata];
this[metadata[index].name] = undefined;
}
/**
* Inspect the `refId` of all Schema instances in the tree. Optionally display the contents of the instance.
*
* @param ref Schema instance
* @param showContents display JSON contents of the instance
* @returns
*/
static debugRefIds(ref, showContents = false, level = 0, decoder, keyPrefix = "") {
const contents = (showContents) ? ` - ${JSON.stringify(ref.toJSON())}` : "";
const changeTree = ref[$changes];
const refId = ref[$refId];
const root = (decoder) ? decoder.root : changeTree.root;
// log reference count if > 1
const refCount = (root?.refCount?.[refId] > 1)
? ` [×${root.refCount[refId]}]`
: '';
let output = `${getIndent(level)}${keyPrefix}${ref.constructor.name} (refId: ${refId})${refCount}${contents}\n`;
changeTree.forEachChild((childChangeTree, indexOrKey) => {
let key = indexOrKey;
if (typeof indexOrKey === 'number' && ref['$indexes']) {
// MapSchema
key = ref['$indexes'].get(indexOrKey) ?? indexOrKey;
}
const keyPrefix = (ref['forEach'] !== undefined && key !== undefined) ? `["${key}"]: ` : "";
output += this.debugRefIds(childChangeTree.ref, showContents, level + 1, decoder, keyPrefix);
});
return output;
}
static debugRefIdEncodingOrder(ref, changeSet = 'allChanges') {
let encodeOrder = [];
let current = ref[$changes].root[changeSet].next;
while (current) {
if (current.changeTree) {
encodeOrder.push(current.changeTree.ref[$refId]);
}
current = current.next;
}
return encodeOrder;
}
static debugRefIdsFromDecoder(decoder) {
return this.debugRefIds(decoder.state, false, 0, decoder);
}
/**
* Return a string representation of the changes on a Schema instance.
* The list of changes is cleared after each encode.
*
* @param instance Schema instance
* @param isEncodeAll Return "full encode" instead of current change set.
* @returns
*/
static debugChanges(instance, isEncodeAll = false) {
const changeTree = instance[$changes];
const changeSet = (isEncodeAll) ? changeTree.allChanges : changeTree.changes;
const changeSetName = (isEncodeAll) ? "allChanges" : "changes";
let output = `${instance.constructor.name} (${instance[$refId]}) -> .${changeSetName}:\n`;
function dumpChangeSet(changeSet) {
changeSet.operations
.filter(op => op)
.forEach((index) => {
const operation = changeTree.indexedOperations[index];
output += `- [${index}]: ${OPERATION[operation]} (${JSON.stringify(changeTree.getValue(Number(index), isEncodeAll))})\n`;
});
}
dumpChangeSet(changeSet);
// display filtered changes
if (!isEncodeAll &&
changeTree.filteredChanges &&
(changeTree.filteredChanges.operations).filter(op => op).length > 0) {
output += `${instance.constructor.name} (${instance[$refId]}) -> .filteredChanges:\n`;
dumpChangeSet(changeTree.filteredChanges);
}
// display filtered changes
if (isEncodeAll &&
changeTree.allFilteredChanges &&
(changeTree.allFilteredChanges.operations).filter(op => op).length > 0) {
output += `${instance.constructor.name} (${instance[$refId]}) -> .allFilteredChanges:\n`;
dumpChangeSet(changeTree.allFilteredChanges);
}
return output;
}
static debugChangesDeep(ref, changeSetName = "changes") {
let output = "";
const rootChangeTree = ref[$changes];
const root = rootChangeTree.root;
const changeTrees = new Map();
const instanceRefIds = [];
let totalOperations = 0;
// TODO: FIXME: this method is not working as expected
for (const [refId, changes] of Object.entries(root[changeSetName])) {
const changeTree = root.changeTrees[refId];
if (!changeTree) {
continue;
}
let includeChangeTree = false;
let parentChangeTrees = [];
let parentChangeTree = changeTree.parent?.[$changes];
if (changeTree === rootChangeTree) {
includeChangeTree = true;
}
else {
while (parentChangeTree !== undefined) {
parentChangeTrees.push(parentChangeTree);
if (parentChangeTree.ref === ref) {
includeChangeTree = true;
break;
}
parentChangeTree = parentChangeTree.parent?.[$changes];
}
}
if (includeChangeTree) {
instanceRefIds.push(changeTree.ref[$refId]);
totalOperations += Object.keys(changes).length;
changeTrees.set(changeTree, parentChangeTrees.reverse());
}
}
output += "---\n";
output += `root refId: ${rootChangeTree.ref[$refId]}\n`;
output += `Total instances: ${instanceRefIds.length} (refIds: ${instanceRefIds.join(", ")})\n`;
output += `Total changes: ${totalOperations}\n`;
output += "---\n";
// based on root.changes, display a tree of changes that has the "ref" instance as parent
const visitedParents = new WeakSet();
for (const [changeTree, parentChangeTrees] of changeTrees.entries()) {
parentChangeTrees.forEach((parentChangeTree, level) => {
if (!visitedParents.has(parentChangeTree)) {
output += `${getIndent(level)}${parentChangeTree.ref.constructor.name} (refId: ${parentChangeTree.ref[$refId]})\n`;
visitedParents.add(parentChangeTree);
}
});
const changes = changeTree.indexedOperations;
const level = parentChangeTrees.length;
const indent = getIndent(level);
const parentIndex = (level > 0) ? `(${changeTree.parentIndex}) ` : "";
output += `${indent}${parentIndex}${changeTree.ref.constructor.name} (refId: ${changeTree.ref[$refId]}) - changes: ${Object.keys(changes).length}\n`;
for (const index in changes) {
const operation = changes[index];
output += `${getIndent(level + 1)}${OPERATION[operation]}: ${index}\n`;
}
}
return `${output}`;
}
}
class Root {
types;
nextUniqueId = 0;
refCount = {};
changeTrees = {};
// all changes
allChanges = createChangeTreeList();
allFilteredChanges = createChangeTreeList(); // TODO: do not initialize it if filters are not used
// pending changes to be encoded
changes = createChangeTreeList();
filteredChanges = createChangeTreeList(); // TODO: do not initialize it if filters are not used
constructor(types) {
this.types = types;
}
getNextUniqueId() {
return this.nextUniqueId++;
}
add(changeTree) {
const ref = changeTree.ref;
// Assign unique `refId` to ref if it doesn't have one yet.
if (ref[$refId] === undefined) {
Object.defineProperty(ref, $refId, {
value: this.getNextUniqueId(),
enumerable: false,
writable: true
});
}
const refId = ref[$refId];
const isNewChangeTree = (this.changeTrees[refId] === undefined);
if (isNewChangeTree) {
this.changeTrees[refId] = changeTree;
}
const previousRefCount = this.refCount[refId];
if (previousRefCount === 0) {
//
// When a ChangeTree is re-added, it means that it was previously removed.
// We need to re-add all changes to the `changes` map.
//
const ops = changeTree.allChanges.operations;
let len = ops.length;
while (len--) {
changeTree.indexedOperations[ops[len]] = OPERATION.ADD;
setOperationAtIndex(changeTree.changes, len);
}
}
this.refCount[refId] = (previousRefCount || 0) + 1;
// console.log("ADD", { refId, ref: ref.constructor.name, refCount: this.refCount[refId], isNewChangeTree });
return isNewChangeTree;
}
remove(changeTree) {
const refId = changeTree.ref[$refId];
const refCount = (this.refCount[refId]) - 1;
// console.log("REMOVE", { refId, ref: changeTree.ref.constructor.name, refCount, needRemove: refCount <= 0 });
if (refCount <= 0) {
//
// Only remove "root" reference if it's the last reference
//
changeTree.root = undefined;
delete this.changeTrees[refId];
this.removeChangeFromChangeSet("allChanges", changeTree);
this.removeChangeFromChangeSet("changes", changeTree);
if (changeTree.filteredChanges) {
this.removeChangeFromChangeSet("allFilteredChanges", changeTree);
this.removeChangeFromChangeSet("filteredChanges", changeTree);
}
this.refCount[refId] = 0;
changeTree.forEachChild((child, _) => {
if (child.removeParent(changeTree.ref)) {
if ((child.parentChain === undefined || // no parent, remove it
(child.parentChain && this.refCount[child.ref[$refId]] > 0) // parent is still in use, but has more than one reference, remove it
)) {
this.remove(child);
}
else if (child.parentChain) {
// re-assigning a child of the same root, move it next to parent
this.moveNextToParent(child);
}
}
});
}
else {
this.refCount[refId] = refCount;
//
// When losing a reference to an instance, it is best to move the
// ChangeTree next to its parent in the encoding queue.
//
// This way, at decoding time, the instance that contains the
// ChangeTree will be available before the ChangeTree itself. If the
// containing instance is not available, the Decoder will throw
// "refId not found" error.
//
this.recursivelyMoveNextToParent(changeTree);
}
return refCount;
}
recursivelyMoveNextToParent(changeTree) {
this.moveNextToParent(changeTree);
changeTree.forEachChild((child, _) => this.recursivelyMoveNextToParent(child));
}
moveNextToParent(changeTree) {
if (changeTree.filteredChanges) {
this.moveNextToParentInChangeTreeList("filteredChanges", changeTree);
this.moveNextToParentInChangeTreeList("allFilteredChanges", changeTree);
}
else {
this.moveNextToParentInChangeTreeList("changes", changeTree);
this.moveNextToParentInChangeTreeList("allChanges", changeTree);
}
}
moveNextToParentInChangeTreeList(changeSetName, changeTree) {
const changeSet = this[changeSetName];
const node = changeTree[changeSetName].queueRootNode;
if (!node)
return;
// Find the parent in the linked list
const parent = changeTree.parent;
if (!parent || !parent[$changes])
return;
const parentNode = parent[$changes][changeSetName]?.queueRootNode;
if (!parentNode || parentNode === node)
return;
// Use cached positions - no iteration needed!
const parentPosition = parentNode.position;
const childPosition = node.position;
// If child is already after parent, no need to move
if (childPosition > parentPosition)
return;
// Child is before parent, so we need to move it after parent
// This maintains decoding order (parent before child)
// Remove node from current position
if (node.prev) {
node.prev.next = node.next;
}
else {
changeSet.next = node.next;
}
if (node.next) {
node.next.prev = node.prev;
}
else {
changeSet.tail = node.prev;
}
// Insert node right after parent
node.prev = parentNode;
node.next = parentNode.next;
if (parentNode.next) {
parentNode.next.prev = node;
}
else {
changeSet.tail = node;
}
parentNode.next = node;
// Update positions after the move
this.updatePositionsAfterMove(changeSet, node, parentPosition + 1);
}
enqueueChangeTree(changeTree, changeSet, queueRootNode = changeTree[changeSet].queueRootNode) {
// skip
if (queueRootNode) {
return;
}
// Add to linked list if not already present
changeTree[changeSet].queueRootNode = this.addToChangeTreeList(this[changeSet], changeTree);
}
addToChangeTreeList(list, changeTree) {
const node = {
changeTree,
next: undefined,
prev: undefined,
position: list.tail ? list.tail.position + 1 : 0
};
if (!list.next) {
list.next = node;
list.tail = node;
}
else {
node.prev = list.tail;
list.tail.next = node;
list.tail = node;
}
return node;
}
updatePositionsAfterRemoval(list, removedPosition) {
// Update positions for all nodes after the removed position
let current = list.next;
let position = 0;
while (current) {
if (position >= removedPosition) {
current.position = position;
}
current = current.next;
position++;
}
}
updatePositionsAfterMove(list, node, newPosition) {
// Recalculate all positions - this is more reliable than trying to be clever
let current = list.next;
let position = 0;
while (current) {
current.position = position;
current = current.next;
position++;
}
}
removeChangeFromChangeSet(changeSetName, changeTree) {
const changeSet = this[changeSetName];
const node = changeTree[changeSetName].queueRootNode;
if (node && node.changeTree === changeTree) {
const removedPosition = node.position;
// Remove the node from the linked list
if (node.prev) {
node.prev.next = node.next;
}
else {
changeSet.next = node.next;
}
if (node.next) {
node.next.prev = node.prev;
}
else {
changeSet.tail = node.prev;
}
// Update positions for nodes that came after the removed node
this.updatePositionsAfterRemoval(changeSet, removedPosition);
// Clear ChangeTree reference
changeTree[changeSetName].queueRootNode = undefined;
return true;
}
return false;
}
}
function concatBytes(a, b) {
const result = new Uint8Array(a.length + b.length);
result.set(a, 0);
result.set(b, a.length);
return result;
}
class Encoder {
static BUFFER_SIZE = 8 * 1024; // 8KB
sharedBuffer = new Uint8Array(Encoder.BUFFER_SIZE);
context;
state;
root;
constructor(state) {
//
// Use .cache() here to avoid re-creating a new context for every new room instance.
//
// We may need to make this optional in case of dynamically created
// schemas - which would lead to memory leaks
//
this.context = TypeContext.cache(state.constructor);
this.root = new Root(this.context);
this.setState(state);
// console.log(">>>>>>>>>>>>>>>> Encoder types");
// this.context.schemas.forEach((id, schema) => {
// console.log("type:", id, schema.name, Object.keys(schema[Symbol.metadata]));
// });
}
setState(state) {
this.state = state;
this.state[$changes].setRoot(this.root);
}
encode(it = { offset: 0 }, view, buffer = this.sharedBuffer, changeSetName = "changes", isEncodeAll = changeSetName === "allChanges", initialOffset = it.offset // cache current offset in case we need to resize the buffer
) {
const hasView = (view !== undefined);
const rootChangeTree = this.state[$changes];
let current = this.root[changeSetName];
while (current = current.next) {
const changeTree = current.changeTree;
if (hasView) {
if (!view.isChangeTreeVisible(changeTree)) {
// console.log("MARK AS INVISIBLE:", { ref: changeTree.ref.constructor.name, refId: changeTree.ref[$refId], raw: changeTree.ref.toJSON() });
view.invisible.add(changeTree);
continue; // skip this change tree
}
view.invisible.delete(changeTree); // remove from invisible list
}
const changeSet = changeTree[changeSetName];
const ref = changeTree.ref;
// TODO: avoid iterating over change tree if no changes were made
const numChanges = changeSet.operations.length;
if (numChanges === 0) {
continue;
}
const ctor = ref.constructor;
const encoder = ctor[$encoder];
const filter = ctor[$filter];
const metadata = ctor[Symbol.metadata];
// skip root `refId` if it's the first change tree
// (unless it "hasView", which will need to revisit the root)
if (hasView || it.offset > initialOffset || changeTree !== rootChangeTree) {
buffer[it.offset++] = SWITCH_TO_STRUCTURE & 255;
encode.number(buffer, ref[$refId], it);
}
for (let j = 0; j < numChanges; j++) {
const fieldIndex = changeSet.operations[j];
if (fieldIndex < 0) {
// "pure" operation without fieldIndex (e.g. CLEAR, REVERSE, etc.)
// encode and continue early - no need to reach $filter check
buffer[it.offset++] = Math.abs(fieldIndex) & 255;
continue;
}
const operation = (isEncodeAll)
? OPERATION.ADD
: changeTree.indexedOperations[fieldIndex];
//
// first pass (encodeAll), identify "filtered" operations without encoding them
// they will be encoded per client, based on their view.
//
// TODO: how can we optimize filtering out "encode all" operations?
// TODO: avoid checking if no view tags were defined
//
if (fieldIndex === undefined || operation === undefined || (filter && !filter(ref, fieldIndex, view))) {
// console.log("ADD AS INVISIBLE:", fieldIndex, changeTree.ref.constructor.name)
// view?.invisible.add(changeTree);
continue;
}
encoder(this, buffer, changeTree, fieldIndex, operation, it, isEncodeAll, hasView, metadata);
}
}
if (it.offset <= buffer.byteLength) {
return buffer.subarray(0, it.offset);
}
// Overflowed: grow and re-encode. Reuse the same iterator so `it.offset`
// ends accurate — a fresh one strands it at the overflow value and
// corrupts the next view's `viewOffset` in a multi-view encode.
buffer = this.ensureCapacity(buffer, it.offset);
console.warn(`@colyseus/schema buffer overflow. Encoded state is higher than default BUFFER_SIZE. Use the following to increase default BUFFER_SIZE:
import { Encoder } from "@colyseus/schema";
Encoder.BUFFER_SIZE = ${Math.round(buffer.byteLength / 1024)} * 1024; // ${Math.round(buffer.byteLength / 1024)} KB
`);
it.offset = initialOffset;
return this.encode(it, view, buffer, changeSetName, isEncodeAll);
}
encodeAll(it = { offset: 0 }, buffer = this.sharedBuffer) {
return this.encode(it, undefined, buffer, "allChanges", true);
}
encodeAllView(view, sharedOffset, it, bytes = this.sharedBuffer) {
const viewOffset = it.offset;
// encode() may reallocate the buffer — keep its return, not the stale `bytes`.
bytes = this.encode(it, view, bytes, "allFilteredChanges", true, viewOffset);
return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
}
/** Grow `buffer` to keep BUFFER_SIZE free bytes past `offset`, preserving `[0, offset)`. */
ensureCapacity(buffer, offset) {
if (offset + Encoder.BUFFER_SIZE <= buffer.byteLength) {
return buffer;
}
const size = Math.ceil((offset + Encoder.BUFFER_SIZE) / Encoder.BUFFER_SIZE) * Encoder.BUFFER_SIZE;
const grown = new Uint8Array(size);
grown.set(buffer.subarray(0, offset));
if (buffer === this.sharedBuffer) {
this.sharedBuffer = grown;
}
return grown;
}
encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
const viewOffset = it.offset;
//
// Iterate `view.changes` in topological order so a refId is never
// SWITCH_TO_STRUCTURE'd before an earlier op has introduced it on
// the decoder. Map insertion order alone isn't sufficient: a
// sequence like view.remove(child) → view.add(child) on a child
// whose ancestor wasn't yet visible can put the child entry into
// the Map before its newly-visible ancestor.
//
// Hot-path optimization: `view.add` preserves topo order by
// construction (addParentOf walks deepest-ancestor-first before
// touching the obj's own entry). Only `view.remove` can leave the
// Map dirty. `StateView.changesOutOfOrder` tracks this so most
// encodes can iterate `view.changes` directly, paying nothing.
//
const orderedRefIds = view.changesOutOfOrder
? this.topoOrderViewChanges(view)
: view.changes.keys();
for (const refId of orderedRefIds) {
const changes = view.changes.get(refId);
const changeTree = this.root.changeTrees[refId];
if (changeTree === undefined) {
// detached instance, remove from view and skip.
// console.log("detached instance, remove from view and skip.", refId);
view.changes.delete(refId);
continue;
}
const keys = Object.keys(changes);
if (keys.length === 0) {
// FIXME: avoid having empty changes if no changes were made
// console.log("changes.size === 0, skip", refId, changeTree.ref.constructor.name);
continue;
}
const ref = changeTree.ref;
const ctor = ref.constructor;
const encoder = ctor[$encoder];
const metadata = ctor[Symbol.metadata];
// These writes are unguarded and unrecoverable (view.changes is cleared
// below), so unlike encode() they can't re-encode on overflow — grow ahead.
bytes = this.ensureCapacity(bytes, it.offset);
bytes[it.offset++] = SWITCH_TO_STRUCTURE & 255;
encode.number(bytes, ref[$refId], it);
for (let i = 0, numChanges = keys.length; i < numChanges; i++) {
const index = Number(keys[i]);
// workaround when using view.add() on item that has been deleted from state (see test "adding to view item that has been removed from state")
const value = changeTree.ref[$getByIndex](index);
const operation = (value !== undefined && changes[index]) || OPERATION.DELETE;
// isEncodeAll = false
// hasView = true
encoder(this, bytes, changeTree, index, operation, it, false, true, metadata);
}
}
//
// TODO: only clear view changes after all views are encoded
// (to allow re-using StateView's for multiple clients)
//
// clear "view" changes after encoding
view.changes.clear();
view.changesOutOfOrder = false;
// encode() may reallocate the buffer — keep its return, not the stale `bytes`.
// Anchor the re-encode at the current offset (default), not `viewOffset`: a
// resize must not clobber the view.changes already written at [viewOffset, ).
bytes = this.encode(it, view, bytes, "filteredChanges", false);
return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
}
/**
* Produce a topological ordering of `view.changes` keys so each refId
* is preceded by any ancestor that's also in the same view's changeset.
*
* The wire stream uses SWITCH_TO_STRUCTURE pointers; if a child is
* encoded before any earlier op has introduced its refId on the
* decoder, decode fails with "refId not found". An entry's refId can
* only be introduced by an ADD on one of its ancestors — so any
* ancestor that itself appears in this view's pending changes must
* be encoded first.
*
* Implementation: DFS post-order over the parent chain. The `visited`
* Set guards against duplicates; cycles are not expected in a
* well-formed parent chain but the visited check is a cheap safety
* net. Cost is O(n × d) for n entries with parent-chain depth d.
*/
topoOrderViewChanges(view) {
const result = [];
const visited = new Set();
const visit = (refId) => {
if (visited.has(refId)) {
return;
}
visited.add(refId);
const changeTree = this.root.changeTrees[refId];
if (changeTree !== undefined) {
let chain = changeTree.parentChain;
while (chain) {
const parentRefId = chain.ref[$refId];
if (parentRefId !== undefined && view.changes.has(parentRefId)) {
visit(parentRefId);
}
chain = chain.next;
}
}
result.push(refId);
};
for (const refId of view.changes.keys()) {
visit(refId);
}
return result;
}
discardChanges() {
// discard shared changes
let current = this.root.changes.next;
while (current) {
current.changeTree.endEncode('changes');
current = current.next;
}
this.root.changes = createChangeTreeList();
// discard filtered changes
current = this.root.filteredChanges.next;
while (current) {
current.changeTree.endEncode('filteredChanges');
current = current.next;
}
this.root.filteredChanges = createChangeTreeList();
}
tryEncodeTypeId(bytes, baseType, targetType, it) {
const baseTypeId = this.context.getTypeId(baseType);
const targetTypeId = this.context.getTypeId(targetType);
if (targetTypeId === undefined) {
console.warn(`@colyseus/schema WARNING: Class "${targetType.name}" is not registered on TypeRegistry - Please either tag the class with @entity or define a @type() field.`);
return;
}
if (baseTypeId !== targetTypeId) {
bytes[it.offset++] = TYPE_ID & 255;
encode.number(bytes, targetTypeId, it);
}
}
get hasChanges() {
return (this.root.changes.next !== undefined ||
this.root.filteredChanges.next !== undefined);
}
}
function spliceOne(arr, index) {
// manually splice an array
if (index === -1 || index >= arr.length) {
return false;
}
const len = arr.length - 1;
for (let i = index; i < len; i++) {
arr[i] = arr[i + 1];
}
arr.length = len;
return true;
}
class DecodingWarning extends Error {
constructor(message) {
super(message);
this.name = "DecodingWarning";
}
}
class ReferenceTracker {
//
// Relation of refId => Schema structure
// For direct access of structures during decoding time.
//
refs = new Map();
refCount = {};
deletedRefs = new Set();
callbacks = {};
nextUniqueId = 0;
getNextUniqueId() {
return this.nextUniqueId++;
}
// for decoding
addRef(refId, ref, incrementCount = true) {
this.refs.set(refId, ref);
Object.defineProperty(ref, $refId, {
value: refId,
enumerable: false,
writable: true
});
if (incrementCount) {
this.refCount[refId] = (this.refCount[refId] || 0) + 1;
}
if (this.deletedRefs.has(refId)) {
this.deletedRefs.delete(refId);
}
}
// for decoding
removeRef(refId) {
const refCount = this.refCount[refId];
if (refCount === undefined) {
try {
throw new DecodingWarning("trying to remove refId that doesn't exist: " + refId);
}
catch (e) {
console.warn(e);
}
return;
}
if (refCount === 0) {
try {
const ref = this.refs.get(refId);
throw new DecodingWarning(`trying to remove refId '${refId}' with 0 refCount (${ref.constructor.name}: ${JSON.stringify(ref)})`);
}
catch (e) {
console.warn(e);
}
return;
}
if ((this.refCount[refId] = refCount - 1) <= 0) {
this.deletedRefs.add(refId);
}
}
clearRefs() {
this.refs.clear();
this.deletedRefs.clear();
this.callbacks = {};
this.refCount = {};
}
// for decoding
garbageCollectDeletedRefs() {
this.deletedRefs.forEach((refId) => {
//
// Skip active references.
//
if (this.refCount[refId] > 0) {
return;
}
const ref = this.refs.get(refId);
//
// Ensure child schema instances have their references removed as well.
//
if (ref.constructor[Symbol.metadata] !== undefined) {
const metadata = ref.constructor[Symbol.metadata];
for (const index in metadata) {
const field = metadata[index].name;
const child = ref[field];
if (typeof (child) === "object" && child) {
const childRefId = child[$refId];
if (childRefId !== undefined && !this.deletedRefs.has(childRefId)) {
this.removeRef(childRefId);
}
}
}
}
else {
if (typeof (ref[$childType]) === "function") {
Array.from(ref.values())
.forEach((child) => {
const childRefId = child[$refId];
if (childRefId !== undefined && !this.deletedRefs.has(childRefId)) {
this.removeRef(childRefId);
}
});
}
}
this.refs.delete(refId); // remove ref
delete this.refCount[refId]; // remove ref count
delete this.callbacks[refId]; // remove callbacks
});
// clear deleted refs.
this.deletedRefs.clear();
}
addCallback(refId, fieldOrOperation, callback) {
if (refId === undefined) {
const name = (typeof (fieldOrOperation) === "number")
? OPERATION[fieldOrOperation]
: fieldOrOperation;
throw new Error(`Can't addCallback on '${name}' (refId is undefined)`);
}
if (!this.callbacks[refId]) {
this.callbacks[refId] = {};
}
if (!this.callbacks[refId][fieldOrOperation]) {
this.callbacks[refId][fieldOrOperation] = [];
}
this.callbacks[refId][fieldOrOperation].push(callback);
return () => this.removeCallback(refId, fieldOrOperation, callback);
}
removeCallback(refId, field, callback) {
const index = this.callbacks?.[refId]?.[field]?.indexOf(callback);
if (index !== undefined && index !== -1) {
spliceOne(this.callbacks[refId][field], index);
}
}
}
class Decoder {
context;
state;
root;
currentRefId = 0;
triggerChanges;
constructor(root, context) {
this.setState(root);
this.context = context || new TypeContext(root.constructor);
// console.log(">>>>>>>>>>>>>>>> Decoder types");
// this.context.schemas.forEach((id, schema) => {
// console.log("type:", id, schema.name, Object.keys(schema[Symbol.metadata]));
// });
}
setState(root) {
this.state = root;
this.root = new ReferenceTracker();
this.root.addRef(0, root);
}
decode(bytes, it = { offset: 0 }, ref = this.state) {
const allChanges = [];
const $root = this.root;
const totalBytes = bytes.byteLength;
let decoder = ref['constructor'][$decoder];
this.currentRefId = 0;
while (it.offset < totalBytes) {
//
// Peek ahead, check if it's a switch to a different structure
//
if (bytes[it.offset] == SWITCH_TO_STRUCTURE) {
it.offset++;
ref[$onDecodeEnd]?.();
const nextRefId = decode.number(bytes, it);
const nextRef = $root.refs.get(nextRefId);
//
// Trying to access a reference that haven't been decoded yet.
//
if (!nextRef) {
// throw new Error(`"refId" not found: ${nextRefId}`);
console.error(`"refId" not found: ${nextRefId}`, { previousRef: ref, previousRefId: this.currentRefId });
console.warn("Please report this issue to the developers.");
this.skipCurrentStructure(bytes, it, totalBytes);
}
else {
ref = nextRef;
decoder = ref.constructor[$decoder];
this.currentRefId = nextRefId;
}
continue;
}
const result = decoder(this, bytes, it, ref, allChanges);
if (result === DEFINITION_MISMATCH) {
console.warn("@colyseus/schema: definition mismatch");
this.skipCurrentStructure(bytes, it, totalBytes);
continue;
}
}
// FIXME: DRY with SWITCH_TO_STRUCTURE block.
ref[$onDecodeEnd]?.();
// trigger changes
this.triggerChanges?.(allChanges);
// drop references of unused schemas
$root.garbageCollectDeletedRefs();
return allChanges;
}
skipCurrentStructure(bytes, it, totalBytes) {
//
// keep skipping next bytes until reaches a known structure
// by local decoder.
//
const nextIterator = { offset: it.offset };
while (it.offset < totalBytes) {
if (bytes[it.offset] === SWITCH_TO_STRUCTURE) {
nextIterator.offset = it.offset + 1;
if (this.root.refs.has(decode.number(bytes, nextIterator))) {
break;
}
}
it.offset++;
}
}
getInstanceType(bytes, it, defaultType) {
let type;
if (bytes[it.offset] === TYPE_ID) {
it.offset++;
const type_id = decode.number(bytes, it);
type = this.context.get(type_id);
}
return type || defaultType;
}
createInstanceOfType(type) {
return new type();
}
removeChildRefs(ref, allChanges) {
const needRemoveRef = typeof (ref[$childType]) !== "string";
const refId = ref[$refId];
ref.forEach((value, key) => {
allChanges.push({
ref: ref,
refId,
op: OPERATION.DELETE,
field: key,
value: undefined,
previousValue: value
});
if (needRemoveRef) {
this.root.removeRef(value[$refId]);
}
});
}
}
/**
* Reflection
*/
const ReflectionField = schema({
name: "string",
type: "string",
referencedType: "number",
});
const ReflectionType = schema({
id: "number",
extendsId: "number",
fields: [ReflectionField],
});
const Reflection = schema({
types: [ReflectionType],
rootType: "number",
});
Reflection.encode = function (encoder, it = { offset: 0 }) {
const context = encoder.context;
const reflection = new Reflection();
const reflectionEncoder = new Encoder(reflection);
// rootType is usually the first schema passed to the Encoder
// (unless it inherits from another schema)
const rootType = context.schemas.get(encoder.state.constructor);
if (rootType > 0) {
reflection.rootType = rootType;
}
const includedTypeIds = new Set();
const pendingReflectionTypes = {};
// add type to reflection in a way that respects inheritance
// (parent types should be added before their children)
const addType = (type) => {
if (type.extendsId === undefined || includedTypeIds.has(type.extendsId)) {
includedTypeIds.add(type.id);
reflection.types.push(type);
const deps = pendingReflectionTypes[type.id];
if (deps !== undefined) {
delete pendingReflectionTypes[type.id];
deps.forEach((childType) => addType(childType));
}
}
else {
if (pendingReflectionTypes[type.extendsId] === undefined) {
pendingReflectionTypes[type.extendsId] = [];
}
pendingReflectionTypes[type.extendsId].push(type);
}
};
context.schemas.forEach((typeid, klass) => {
const type = new ReflectionType();
type.id = Number(typeid);
// support inheritance
const inheritFrom = Object.getPrototypeOf(klass);
if (inheritFrom !== Schema) {
type.extendsId = context.schemas.get(inheritFrom);
}
const metadata = klass[Symbol.metadata];
//
// FIXME: this is a workaround for inherited types without additional fields
// if metadata is the same reference as the parent class - it means the class has no own metadata
//
if (metadata !== inheritFrom[Symbol.metadata]) {
for (const fieldIndex in metadata) {
const index = Number(fieldIndex);
const fieldName = metadata[index].name;
// skip fields from parent classes
if (!Object.prototype.hasOwnProperty.call(metadata, fieldName)) {
continue;
}
const reflectionField = new ReflectionField();
reflectionField.name = fieldName;
let fieldType;
const field = metadata[index];
if (typeof (field.type) === "string") {
fieldType = field.type;
}
else {
let childTypeSchema;
//
// TODO: refactor below.
//
if (Schema.is(field.type)) {
fieldType = "ref";
childTypeSchema = field.type;
}
else {
fieldType = Object.keys(field.type)[0];
if (typeof (field.type[fieldType]) === "string") {
fieldType += ":" + field.type[fieldType]; // array:string
}
else {
childTypeSchema = field.type[fieldType];
}
}
reflectionField.referencedType = (childTypeSchema)
? context.getTypeId(childTypeSchema)
: -1;
}
reflectionField.type = fieldType;
type.fields.push(reflectionField);
}
}
addType(type);
});
// in case there are types that were not added due to inheritance
for (const typeid in pendingReflectionTypes) {
pendingReflectionTypes[typeid].forEach((type) => reflection.types.push(type));
}
const buf = reflectionEncoder.encodeAll(it);
return buf.slice(0, it.offset);
};
Reflection.decode = function (bytes, it) {
const reflection = new Reflection();
const reflectionDecoder = new Decoder(reflection);
reflectionDecoder.decode(bytes, it);
const typeContext = new TypeContext();
// 1st pass, initialize metadata + inheritance
reflection.types.forEach((reflectionType) => {
const parentClass = typeContext.get(reflectionType.extendsId) ?? Schema;
const schema = class _ extends parentClass {
};
// register for inheritance support
TypeContext.register(schema);
typeContext.add(schema, reflectionType.id);
}, {});
// define fields
const addFields = (metadata, reflectionType, parentFieldIndex) => {
reflectionType.fields.forEach((field, i) => {
const fieldIndex = parentFieldIndex + i;
if (field.referencedType !== undefined) {
let fieldType = field.type;
let refType = typeContext.get(field.referencedType);
// map or array of primitive type (-1)
if (!refType) {
const typeInfo = field.type.split(":");
fieldType = typeInfo[0];
refType = typeInfo[1]; // string
}
if (fieldType === "ref") {
Metadata.addField(metadata, fieldIndex, field.name, refType);
}
else {
Metadata.addField(metadata, fieldIndex, field.name, { [fieldType]: refType });
}
}
else {
Metadata.addField(metadata, fieldIndex, field.name, field.type);
}
});
};
// 2nd pass, set fields
reflection.types.forEach((reflectionType) => {
const schema = typeContext.get(reflectionType.id);
// for inheritance support
const metadata = Metadata.initialize(schema);
const inheritedTypes = [];
let parentType = reflectionType;
do {
inheritedTypes.push(parentType);
parentType = reflection.types.find((t) => t.id === parentType.extendsId);
} while (parentType);
let parentFieldIndex = 0;
inheritedTypes.reverse().forEach((reflectionType) => {
// add fields from all inherited classes
// TODO: refactor this to avoid adding fields from parent classes
addFields(metadata, reflectionType, parentFieldIndex);
parentFieldIndex += reflectionType.fields.length;
});
});
const state = new (typeContext.get(reflection.rootType || 0))();
return new Decoder(state, typeContext);
};
/**
* Legacy callback system
*
* @param decoder
* @returns
*/
function getDecoderStateCallbacks(decoder) {
const $root = decoder.root;
const callbacks = $root.callbacks;
const onAddCalls = new WeakMap();
let currentOnAddCallback;
decoder.triggerChanges = function (allChanges) {
const uniqueRefIds = new Set();
for (let i = 0, l = allChanges.length; i < l; i++) {
const change = allChanges[i];
const refId = change.refId;
const ref = change.ref;
const $callbacks = callbacks[refId];
if (!$callbacks) {
continue;
}
//
// trigger onRemove on child structure.
//
if ((change.op & OPERATION.DELETE) === OPERATION.DELETE &&
Schema.isSchema(change.previousValue)) {
const deleteCallbacks = callbacks[change.previousValue[$refId]]?.[OPERATION.DELETE];
for (let i = deleteCallbacks?.length - 1; i >= 0; i--) {
deleteCallbacks[i]();
}
}
if (Schema.isSchema(ref)) {
//
// Handle schema instance
//
if (!uniqueRefIds.has(refId)) {
// trigger onChange
const replaceCallbacks = $callbacks?.[OPERATION.REPLACE];
for (let i = replaceCallbacks?.length - 1; i >= 0; i--) {
replaceCallbacks[i]();
// try {
// } catch (e) {
// console.error(e);
// }
}
}
if ($callbacks.hasOwnProperty(change.field)) {
const fieldCallbacks = $callbacks[change.field];
for (let i = fieldCallbacks?.length - 1; i >= 0; i--) {
fieldCallbacks[i](change.value, change.previousValue);
// try {
// } catch (e) {
// console.error(e);
// }
}
}
}
else {
//
// Handle collection of items
//
if ((change.op & OPERATION.DELETE) === OPERATION.DELETE) {
//
// FIXME: `previousValue` should always be available.
//
if (change.previousValue !== undefined) {
// triger onRemove
const deleteCallbacks = $callbacks[OPERATION.DELETE];
for (let i = deleteCallbacks?.length - 1; i >= 0; i--) {
deleteCallbacks[i](change.previousValue, change.dynamicIndex ?? change.field);
}
}
// Handle DELETE_AND_ADD operations
if ((change.op & OPERATION.ADD) === OPERATION.ADD) {
const addCallbacks = $callbacks[OPERATION.ADD];
for (let i = addCallbacks?.length - 1; i >= 0; i--) {
addCallbacks[i](change.value, change.dynamicIndex ?? change.field);
}
}
}
else if ((change.op & OPERATION.ADD) === OPERATION.ADD &&
change.previousValue !== change.value) {
// triger onAdd
const addCallbacks = $callbacks[OPERATION.ADD];
for (let i = addCallbacks?.length - 1; i >= 0; i--) {
addCallbacks[i](change.value, change.dynamicIndex ?? change.field);
}
}
// trigger onChange
if (change.value !== change.previousValue &&
// FIXME: see "should not encode item if added and removed at the same patch" test case.
// some "ADD" + "DELETE" operations on same patch are being encoded as "DELETE"
(change.value !== undefined || change.previousValue !== undefined)) {
const replaceCallbacks = $callbacks[OPERATION.REPLACE];
for (let i = replaceCallbacks?.length - 1; i >= 0; i--) {
replaceCallbacks[i](change.value, change.dynamicIndex ?? change.field);
}
}
}
uniqueRefIds.add(refId);
}
};
function getProxy(metadataOrType, context) {
let metadata = context.instance?.constructor[Symbol.metadata] || metadataOrType;
let isCollection = ((context.instance && typeof (context.instance['forEach']) === "function") ||
(metadataOrType && typeof (metadataOrType[Symbol.metadata]) === "undefined"));
if (metadata && !isCollection) {
const onAddListen = function (ref, prop, callback, immediate) {
// immediate trigger
if (immediate &&
context.instance[prop] !== undefined &&
!onAddCalls.has(currentOnAddCallback) // Workaround for https://github.com/colyseus/schema/issues/147
) {
callback(context.instance[prop], undefined);
}
return $root.addCallback(ref[$refId], prop, callback);
};
/**
* Schema instances
*/
return new Proxy({
listen: function listen(prop, callback, immediate = true) {
if (context.instance) {
return onAddListen(context.instance, prop, callback, immediate);
}
else {
// collection instance not received yet
let detachCallback = () => { };
context.onInstanceAvailable((ref, existing) => {
detachCallback = onAddListen(ref, prop, callback, immediate && existing && !onAddCalls.has(currentOnAddCallback));
});
return () => detachCallback();
}
},
onChange: function onChange(callback) {
return $root.addCallback(context.instance[$refId], OPERATION.REPLACE, callback);
},
//
// TODO: refactor `bindTo()` implementation.
// There is room for improvement.
//
bindTo: function bindTo(targetObject, properties) {
if (!properties) {
properties = Object.keys(metadata).map((index) => metadata[index].name);
}
return $root.addCallback(context.instance[$refId], OPERATION.REPLACE, () => {
properties.forEach((prop) => targetObject[prop] = context.instance[prop]);
});
}
}, {
get(target, prop) {
const metadataField = metadata[metadata[prop]];
if (metadataField) {
const instance = context.instance?.[prop];
const onInstanceAvailable = ((callback) => {
const unbind = $(context.instance).listen(prop, (value, _) => {
callback(value, false);
// FIXME: by "unbinding" the callback here,
// it will not support when the server
// re-instantiates the instance.
//
unbind?.();
}, false);
// has existing value
if (instance?.[$refId] !== undefined) {
callback(instance, true);
}
});
return getProxy(metadataField.type, {
// make sure refId is available, otherwise need to wait for the instance to be available.
instance: (instance?.[$refId] !== undefined && instance),
parentInstance: context.instance,
onInstanceAvailable,
});
}
else {
// accessing the function
return target[prop];
}
},
has(target, prop) { return metadata[prop] !== undefined; },
set(_, _1, _2) { throw new Error("not allowed"); },
deleteProperty(_, _1) { throw new Error("not allowed"); },
});
}
else {
/**
* Collection instances
*/
const onAdd = function (ref, callback, immediate) {
// Trigger callback on existing items
if (immediate) {
ref.forEach((v, k) => callback(v, k));
}
return $root.addCallback(ref[$refId], OPERATION.ADD, (value, key) => {
onAddCalls.set(callback, true);
currentOnAddCallback = callback;
callback(value, key);
onAddCalls.delete(callback);
currentOnAddCallback = undefined;
});
};
const onRemove = function (ref, callback) {
return $root.addCallback(ref[$refId], OPERATION.DELETE, callback);
};
const onChange = function (ref, callback) {
return $root.addCallback(ref[$refId], OPERATION.REPLACE, callback);
};
return new Proxy({
onAdd: function (callback, immediate = true) {
//
// https://github.com/colyseus/schema/issues/147
// If parent instance has "onAdd" registered, avoid triggering immediate callback.
//
if (context.instance) {
return onAdd(context.instance, callback, immediate && !onAddCalls.has(currentOnAddCallback));
}
else if (context.onInstanceAvailable) {
// collection instance not received yet
let detachCallback = () => { };
context.onInstanceAvailable((ref, existing) => {
detachCallback = onAdd(ref, callback, immediate && existing && !onAddCalls.has(currentOnAddCallback));
});
return () => detachCallback();
}
},
onRemove: function (callback) {
if (context.instance) {
return onRemove(context.instance, callback);
}
else if (context.onInstanceAvailable) {
// collection instance not received yet
let detachCallback = () => { };
context.onInstanceAvailable((ref) => {
detachCallback = onRemove(ref, callback);
});
return () => detachCallback();
}
},
onChange: function (callback) {
if (context.instance) {
return onChange(context.instance, callback);
}
else if (context.onInstanceAvailable) {
// collection instance not received yet
let detachCallback = () => { };
context.onInstanceAvailable((ref) => {
detachCallback = onChange(ref, callback);
});
return () => detachCallback();
}
},
}, {
get(target, prop) {
if (!target[prop]) {
throw new Error(`Can't access '${prop}' through callback proxy. access the instance directly.`);
}
return target[prop];
},
has(target, prop) { return target[prop] !== undefined; },
set(_, _1, _2) { throw new Error("not allowed"); },
deleteProperty(_, _1) { throw new Error("not allowed"); },
});
}
}
function $(instance) {
return getProxy(undefined, { instance });
}
return $;
}
function getRawChangesCallback(decoder, callback) {
decoder.triggerChanges = callback;
}
class StateCallbackStrategy {
decoder;
uniqueRefIds = new Set();
isTriggering = false;
constructor(decoder) {
this.decoder = decoder;
this.decoder.triggerChanges = this.triggerChanges.bind(this);
}
get callbacks() {
return this.decoder.root.callbacks;
}
get state() {
return this.decoder.state;
}
addCallback(refId, operationOrProperty, handler) {
const $root = this.decoder.root;
return $root.addCallback(refId, operationOrProperty, handler);
}
addCallbackOrWaitCollectionAvailable(instance, propertyName, operation, handler, immediate = true) {
let removeHandler = () => { };
const removeOnAdd = () => removeHandler();
const collection = instance[propertyName];
// Collection not available yet. Listen for its availability before attaching the handler.
if (!collection || collection[$refId] === undefined) {
let removePropertyCallback;
removePropertyCallback = this.addCallback(instance[$refId], propertyName, (value, _) => {
if (value !== null && value !== undefined) {
// Remove the property listener now that collection is available
removePropertyCallback();
removeHandler = this.addCallback(value[$refId], operation, handler);
}
});
removeHandler = removePropertyCallback;
return removeOnAdd;
}
else {
//
// Call immediately if collection is already available, if it's an ADD operation.
//
immediate = immediate && this.isTriggering === false;
if (operation === OPERATION.ADD && immediate) {
collection.forEach((value, key) => {
handler(value, key);
});
}
return this.addCallback(collection[$refId], operation, handler);
}
}
listen(...args) {
if (typeof args[0] === 'string') {
// listen(property, handler, immediate?)
return this.listenInstance(this.state, args[0], args[1], args[2]);
}
else {
// listen(instance, property, handler, immediate?)
return this.listenInstance(args[0], args[1], args[2], args[3]);
}
}
listenInstance(instance, propertyName, handler, immediate = true) {
immediate = immediate && this.isTriggering === false;
//
// Call handler immediately if property is already available.
//
const currentValue = instance[propertyName];
if (immediate && currentValue !== null && currentValue !== undefined) {
handler(currentValue, undefined);
}
return this.addCallback(instance[$refId], propertyName, handler);
}
onChange(...args) {
if (args.length === 2 && typeof args[0] !== 'string') {
// onChange(instance, handler) - instance change
const instance = args[0];
const handler = args[1];
return this.addCallback(instance[$refId], OPERATION.REPLACE, handler);
}
if (typeof args[0] === 'string') {
// onChange(property, handler) - collection on root state
return this.addCallbackOrWaitCollectionAvailable(this.state, args[0], OPERATION.REPLACE, args[1]);
}
else {
// onChange(instance, property, handler) - nested collection
return this.addCallbackOrWaitCollectionAvailable(args[0], args[1], OPERATION.REPLACE, args[2]);
}
}
onAdd(...args) {
if (typeof args[0] === 'string') {
// onAdd(property, handler, immediate?) - collection on root state
return this.addCallbackOrWaitCollectionAvailable(this.state, args[0], OPERATION.ADD, args[1], args[2] !== false);
}
else {
// onAdd(instance, property, handler, immediate?) - nested collection
return this.addCallbackOrWaitCollectionAvailable(args[0], args[1], OPERATION.ADD, args[2], args[3] !== false);
}
}
onRemove(...args) {
if (typeof args[0] === 'string') {
// onRemove(property, handler) - collection on root state
return this.addCallbackOrWaitCollectionAvailable(this.state, args[0], OPERATION.DELETE, args[1]);
}
else {
// onRemove(instance, property, handler) - nested collection
return this.addCallbackOrWaitCollectionAvailable(args[0], args[1], OPERATION.DELETE, args[2]);
}
}
/**
* Bind properties from a Schema instance to a target object.
* Changes will be automatically reflected on the target object.
*/
bindTo(from, to, properties, immediate = true) {
const metadata = from.constructor[Symbol.metadata];
// If no properties specified, bind all properties
if (!properties) {
properties = Object.keys(metadata)
.filter(key => !isNaN(Number(key)))
.map((index) => metadata[index].name);
}
const action = () => {
for (const prop of properties) {
const fromValue = from[prop];
if (fromValue !== undefined) {
to[prop] = fromValue;
}
}
};
if (immediate) {
action();
}
return this.addCallback(from[$refId], OPERATION.REPLACE, action);
}
triggerChanges(allChanges) {
this.uniqueRefIds.clear();
for (let i = 0, l = allChanges.length; i < l; i++) {
const change = allChanges[i];
const refId = change.refId;
const ref = change.ref;
const $callbacks = this.callbacks[refId];
if (!$callbacks) {
continue;
}
//
// trigger onRemove on child structure.
//
if ((change.op & OPERATION.DELETE) === OPERATION.DELETE &&
Schema.isSchema(change.previousValue)) {
const childRefId = change.previousValue[$refId];
const deleteCallbacks = this.callbacks[childRefId]?.[OPERATION.DELETE];
if (deleteCallbacks) {
for (let j = deleteCallbacks.length - 1; j >= 0; j--) {
deleteCallbacks[j]();
}
}
}
if (Schema.isSchema(ref)) {
//
// Handle Schema instance
//
if (!this.uniqueRefIds.has(refId)) {
// trigger onChange
const replaceCallbacks = $callbacks[OPERATION.REPLACE];
if (replaceCallbacks) {
for (let j = replaceCallbacks.length - 1; j >= 0; j--) {
try {
replaceCallbacks[j]();
}
catch (e) {
console.error(e);
}
}
}
}
// trigger field callbacks
const fieldCallbacks = $callbacks[change.field];
if (fieldCallbacks) {
for (let j = fieldCallbacks.length - 1; j >= 0; j--) {
try {
this.isTriggering = true;
fieldCallbacks[j](change.value, change.previousValue);
}
catch (e) {
console.error(e);
}
finally {
this.isTriggering = false;
}
}
}
}
else {
//
// Handle collection of items
//
const dynamicIndex = change.dynamicIndex ?? change.field;
if ((change.op & OPERATION.DELETE) === OPERATION.DELETE) {
//
// FIXME: `previousValue` should always be available.
//
if (change.previousValue !== undefined) {
// trigger onRemove (value, key)
const deleteCallbacks = $callbacks[OPERATION.DELETE];
if (deleteCallbacks) {
for (let j = deleteCallbacks.length - 1; j >= 0; j--) {
deleteCallbacks[j](change.previousValue, dynamicIndex);
}
}
}
// Handle DELETE_AND_ADD operation
if ((change.op & OPERATION.ADD) === OPERATION.ADD) {
const addCallbacks = $callbacks[OPERATION.ADD];
if (addCallbacks) {
this.isTriggering = true;
for (let j = addCallbacks.length - 1; j >= 0; j--) {
addCallbacks[j](change.value, dynamicIndex);
}
this.isTriggering = false;
}
}
}
else if ((change.op & OPERATION.ADD) === OPERATION.ADD &&
change.previousValue !== change.value) {
// trigger onAdd (value, key)
const addCallbacks = $callbacks[OPERATION.ADD];
if (addCallbacks) {
this.isTriggering = true;
for (let j = addCallbacks.length - 1; j >= 0; j--) {
addCallbacks[j](change.value, dynamicIndex);
}
this.isTriggering = false;
}
}
// trigger onChange (key, value)
if (change.value !== change.previousValue) {
const replaceCallbacks = $callbacks[OPERATION.REPLACE];
if (replaceCallbacks) {
for (let j = replaceCallbacks.length - 1; j >= 0; j--) {
replaceCallbacks[j](dynamicIndex, change.value);
}
}
}
}
this.uniqueRefIds.add(refId);
}
}
}
/**
* Factory class for retrieving the callbacks API.
*/
const Callbacks = {
/**
* Get the new callbacks standard API.
*
* Usage:
* ```ts
* const callbacks = Callbacks.get(roomOrDecoder);
*
* // Listen to property changes
* callbacks.listen("currentTurn", (currentValue, previousValue) => { ... });
*
* // Listen to collection additions
* callbacks.onAdd("entities", (entity, sessionId) => {
* // Nested property listening
* callbacks.listen(entity, "hp", (currentHp, previousHp) => { ... });
* });
*
* // Listen to collection removals
* callbacks.onRemove("entities", (entity, sessionId) => { ... });
*
* // Listen to any property change on an instance
* callbacks.onChange(entity, () => { ... });
*
* // Bind properties to another object
* callbacks.bindTo(player, playerVisual);
* ```
*
* @param roomOrDecoder - Room or Decoder instance to get the callbacks for.
* @returns the new callbacks standard API.
*/
get(roomOrDecoder) {
if (roomOrDecoder instanceof Decoder) {
return new StateCallbackStrategy(roomOrDecoder);
}
else if ('decoder' in roomOrDecoder.serializer) {
return new StateCallbackStrategy(roomOrDecoder.serializer.decoder);
}
else {
throw new Error('Invalid room or decoder');
}
},
/**
* Get the legacy callbacks API.
*
* We aim to deprecate this API on 1.0, and iterate on improving Callbacks.get() API.
*
* @param roomOrDecoder - Room or Decoder instance to get the legacy callbacks for.
* @returns the legacy callbacks API.
*/
getLegacy(roomOrDecoder) {
if (roomOrDecoder instanceof Decoder) {
return getDecoderStateCallbacks(roomOrDecoder);
}
else if ('decoder' in roomOrDecoder.serializer) {
return getDecoderStateCallbacks(roomOrDecoder.serializer.decoder);
}
throw new Error('Invalid room or decoder');
},
getRawChanges(decoder, callback) {
return getRawChangesCallback(decoder, callback);
}
};
class StateView {
iterable;
/**
* Iterable list of items that are visible to this view
* (Available only if constructed with `iterable: true`)
*/
items;
/**
* List of ChangeTree's that are visible to this view
*/
visible = new WeakSet();
/**
* List of ChangeTree's that are invisible to this view
*/
invisible = new WeakSet();
tags; // bitmask of tags used to add each ChangeTree
/**
* Manual "ADD" operations for changes per ChangeTree, specific to this view.
* (This is used to force encoding a property, even if it was not changed)
*/
changes = new Map();
/**
* Set when an operation may have left `changes` out of topological
* order (a parent that needs to be encoded before its descendants is
* positioned after them in the Map). `Encoder.encodeView` consults
* this flag and only runs the topo-ordering pass when it's true,
* skipping the work in the common case where insertion order already
* coincides with topo order.
*
* Only `remove()` can break the invariant: it writes entries that
* bypass `addParentOf`'s deepest-ancestor-first ordering. Everything
* else (including multi-parent re-adds) preserves order by
* construction. Reset to false at the end of each encodeView pass
* (when `changes` is cleared).
*/
changesOutOfOrder = false;
constructor(iterable = false) {
this.iterable = iterable;
if (iterable) {
this.items = [];
}
}
/**
* Get the IndexedOperations entry for `refId`, creating one if missing.
*
* Map insertion order alone doesn't guarantee parent-before-child
* iteration in all cases (a `view.remove()` followed by `view.add()`
* can put a child entry into the Map before its newly-visible
* ancestor). The wire-order invariant (parent SWITCH_TO_STRUCTURE
* before any of its children's) is enforced at encode time by
* `Encoder.encodeView` via a topological pass over `view.changes`.
*/
touchChanges(refId) {
let entry = this.changes.get(refId);
if (entry === undefined) {
entry = {};
this.changes.set(refId, entry);
}
return entry;
}
// TODO: allow to set multiple tags at once
add(obj, tag = DEFAULT_VIEW_TAG, checkIncludeParent = true) {
const changeTree = obj?.[$changes];
const parentChangeTree = changeTree.parent;
if (!changeTree) {
console.warn("StateView#add(), invalid object:", obj);
return false;
}
else if (!parentChangeTree &&
obj[$refId] !== 0 // allow root object
) {
/**
* TODO: can we avoid this?
*
* When the "parent" structure has the @view() tag, it is currently
* not possible to identify it has to be added to the view as well
* (this.addParentOf() is not called).
*/
throw new Error(`Cannot add a detached instance to the StateView. Make sure to assign the "${changeTree.ref.constructor.name}" instance to the state before calling view.add()`);
}
// FIXME: ArraySchema/MapSchema do not have metadata
const metadata = obj.constructor[Symbol.metadata];
this.visible.add(changeTree);
// add to iterable list (only the explicitly added items)
if (this.iterable && checkIncludeParent) {
this.items.push(obj);
}
// add parent ChangeTree's
// - if it was invisible to this view
// - if it were previously filtered out
if (checkIncludeParent && parentChangeTree) {
this.addParentOf(changeTree, tag);
}
// FIXME / OPTIMIZE: do not add if no changes are needed
const changes = this.touchChanges(obj[$refId]);
let isChildAdded = false;
//
// Add children of this ChangeTree first.
// If successful, we must link the current ChangeTree to the child.
//
changeTree.forEachChild((change, index) => {
// Do not ADD children that don't have the same tag
if (metadata &&
metadata[index].tag !== undefined) {
const fieldTag = metadata[index].tag;
// DEFAULT_VIEW_TAG fields are visible to all clients.
// Custom-tagged fields are only visible when bits overlap,
// and never to default-tag clients.
const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
(tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
if (!tagMatch) {
return;
}
}
if (this.add(change.ref, tag, false)) {
isChildAdded = true;
}
});
// set tag
if (tag !== DEFAULT_VIEW_TAG) {
if (!this.tags) {
this.tags = new WeakMap();
}
// Add tag bits into the bitmask stored for this ChangeTree.
const currentMask = this.tags.get(changeTree) ?? 0;
this.tags.set(changeTree, currentMask | tag);
// Ref: add tagged properties
metadata?.[$fieldIndexesByViewTag]?.[tag]?.forEach((index) => {
if (changeTree.getChange(index) !== OPERATION.DELETE) {
changes[index] = OPERATION.ADD;
}
});
}
else if (!changeTree.isNew || isChildAdded) {
// new structures will be added as part of .encode() call, no need to force it to .encodeView()
const changeSet = (changeTree.filteredChanges !== undefined)
? changeTree.allFilteredChanges
: changeTree.allChanges;
const isInvisible = this.invisible.has(changeTree);
for (let i = 0, len = changeSet.operations.length; i < len; i++) {
const index = changeSet.operations[i];
if (index === undefined) {
continue;
} // skip "undefined" indexes
const op = changeTree.indexedOperations[index] ?? OPERATION.ADD;
const tagAtIndex = metadata?.[index].tag;
if (op !== OPERATION.DELETE &&
(isInvisible || // if "invisible", include all
tagAtIndex === undefined || // "all change" with no tag
(tagAtIndex === DEFAULT_VIEW_TAG || (tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0)) // tagged property
)) {
changes[index] = op;
isChildAdded = true; // FIXME: assign only once
}
}
}
return isChildAdded;
}
addParentOf(childChangeTree, tag) {
const changeTree = childChangeTree.parent[$changes];
const parentIndex = childChangeTree.parentIndex;
if (!this.visible.has(changeTree)) {
// view must have all "changeTree" parent tree
this.visible.add(changeTree);
// add parent's parent
const parentChangeTree = changeTree.parent?.[$changes];
if (parentChangeTree && (parentChangeTree.filteredChanges !== undefined)) {
this.addParentOf(changeTree, tag);
}
// // parent is already available, no need to add it!
// if (!this.invisible.has(changeTree)) { return; }
}
// add parent's tag properties
if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
const changes = this.touchChanges(changeTree.ref[$refId]);
// Only accumulate positive (custom) tags in the bitmask.
// DEFAULT_VIEW_TAG = -1 has all bits set and must not be OR'd in,
// as it would make every custom-tagged field appear visible.
if (tag !== DEFAULT_VIEW_TAG) {
if (!this.tags) {
this.tags = new WeakMap();
}
const currentMask = this.tags.has(changeTree) ? this.tags.get(changeTree) : 0;
this.tags.set(changeTree, currentMask | tag);
}
changes[parentIndex] = OPERATION.ADD;
}
}
remove(obj, tag = DEFAULT_VIEW_TAG, _isClear = false) {
const changeTree = obj[$changes];
if (!changeTree) {
console.warn("StateView#remove(), invalid object:", obj);
return this;
}
// remove() bypasses addParentOf's ordering guarantee — flag the
// changeset as potentially out of topological order.
this.changesOutOfOrder = true;
this.visible.delete(changeTree);
// remove from iterable list
if (this.iterable &&
!_isClear // no need to remove during clear(), as it will be cleared entirely
) {
spliceOne(this.items, this.items.indexOf(obj));
}
const ref = changeTree.ref;
const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
const refId = ref[$refId];
if (tag === DEFAULT_VIEW_TAG) {
// parent is collection (Map/Array)
const parent = changeTree.parent;
if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) {
const parentChanges = this.touchChanges(parent[$refId]);
if (parentChanges[changeTree.parentIndex] === OPERATION.ADD) {
//
// SAME PATCH ADD + REMOVE:
// The 'changes' of deleted structure should be ignored.
//
this.changes.delete(refId);
}
// DELETE / DELETE BY REF ID
parentChanges[changeTree.parentIndex] = OPERATION.DELETE;
// Remove child schema from visible set
this._recursiveDeleteVisibleChangeTree(changeTree);
}
else {
// delete all "tagged" properties.
const changes = this.touchChanges(refId);
metadata?.[$viewFieldIndexes]?.forEach((index) => {
changes[index] = OPERATION.DELETE;
// Remove child structures of @view() fields from visible set.
// (They were added during view.add() via forEachChild)
const value = changeTree.ref[metadata[index].name];
if (value?.[$changes]) {
this.visible.delete(value[$changes]);
this._recursiveDeleteVisibleChangeTree(value[$changes]);
}
});
}
}
else {
// delete only tagged properties
const changes = this.touchChanges(refId);
metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
changes[index] = OPERATION.DELETE;
// Remove child structures from visible set
const value = changeTree.ref[metadata[index].name];
if (value?.[$changes]) {
this.visible.delete(value[$changes]);
this._recursiveDeleteVisibleChangeTree(value[$changes]);
}
});
}
// remove tag
if (this.tags && this.tags.has(changeTree)) {
if (tag === undefined) {
// delete all tags
this.tags.delete(changeTree);
}
else {
// clear the tag's bits from the bitmask
const newMask = this.tags.get(changeTree) & ~tag;
if (newMask === 0) {
this.tags.delete(changeTree);
}
else {
this.tags.set(changeTree, newMask);
}
}
}
return this;
}
has(obj) {
return this.visible.has(obj[$changes]);
}
hasTag(ob, tag = DEFAULT_VIEW_TAG) {
const tags = this.tags?.get(ob[$changes]);
return tags != null && (tags & tag) !== 0;
}
clear() {
if (!this.iterable) {
throw new Error("StateView#clear() is only available for iterable StateView's. Use StateView(iterable: true) constructor.");
}
for (let i = 0, l = this.items.length; i < l; i++) {
this.remove(this.items[i], DEFAULT_VIEW_TAG, true);
}
// clear items array
this.items.length = 0;
}
isChangeTreeVisible(changeTree) {
let isVisible = this.visible.has(changeTree);
//
// TODO: avoid checking for parent visibility, most of the time it's not needed
// See test case: 'should not be required to manually call view.add() items to child arrays without @view() tag'
//
if (!isVisible && changeTree.isVisibilitySharedWithParent) {
// console.log("CHECK AGAINST PARENT...", {
// ref: changeTree.ref.constructor.name,
// refId: changeTree.ref[$refId],
// parent: changeTree.parent.constructor.name,
// });
if (this.visible.has(changeTree.parent[$changes])) {
this.visible.add(changeTree);
isVisible = true;
}
}
return isVisible;
}
_recursiveDeleteVisibleChangeTree(changeTree) {
changeTree.forEachChild((childChangeTree) => {
this.visible.delete(childChangeTree);
this._recursiveDeleteVisibleChangeTree(childChangeTree);
});
}
}
registerType("map", { constructor: MapSchema });
registerType("array", { constructor: ArraySchema });
registerType("set", { constructor: SetSchema });
registerType("collection", { constructor: CollectionSchema, });
export { $changes, $childType, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $refId, $track, ArraySchema, Callbacks, ChangeTree, CollectionSchema, Decoder, Encoder, MapSchema, Metadata, OPERATION, Reflection, ReflectionField, ReflectionType, Schema, SetSchema, StateCallbackStrategy, StateView, TypeContext, decode, decodeKeyValueOperation, decodeSchemaOperation, defineCustomTypes, defineTypes, deprecated, dumpChanges, encode, encodeArray, encodeKeyValueOperation, encodeSchemaOperation, entity, getDecoderStateCallbacks, getRawChangesCallback, registerType, schema, type, view };
//# sourceMappingURL=index.mjs.map