@colyseus/schema 4.0.20 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +2 -0
  2. package/build/Metadata.d.ts +56 -2
  3. package/build/Reflection.d.ts +28 -34
  4. package/build/Schema.d.ts +70 -9
  5. package/build/annotations.d.ts +64 -17
  6. package/build/codegen/cli.cjs +84 -67
  7. package/build/codegen/cli.cjs.map +1 -1
  8. package/build/decoder/DecodeOperation.d.ts +48 -5
  9. package/build/decoder/Decoder.d.ts +2 -2
  10. package/build/decoder/strategy/Callbacks.d.ts +1 -1
  11. package/build/encoder/ChangeRecorder.d.ts +107 -0
  12. package/build/encoder/ChangeTree.d.ts +218 -69
  13. package/build/encoder/EncodeDescriptor.d.ts +63 -0
  14. package/build/encoder/EncodeOperation.d.ts +25 -2
  15. package/build/encoder/Encoder.d.ts +59 -3
  16. package/build/encoder/MapJournal.d.ts +62 -0
  17. package/build/encoder/RefIdAllocator.d.ts +35 -0
  18. package/build/encoder/Root.d.ts +94 -13
  19. package/build/encoder/StateView.d.ts +116 -8
  20. package/build/encoder/changeTree/inheritedFlags.d.ts +34 -0
  21. package/build/encoder/changeTree/liveIteration.d.ts +3 -0
  22. package/build/encoder/changeTree/parentChain.d.ts +24 -0
  23. package/build/encoder/changeTree/treeAttachment.d.ts +13 -0
  24. package/build/encoder/streaming.d.ts +73 -0
  25. package/build/encoder/subscriptions.d.ts +25 -0
  26. package/build/index.cjs +5258 -1549
  27. package/build/index.cjs.map +1 -1
  28. package/build/index.d.ts +7 -3
  29. package/build/index.js +5258 -1549
  30. package/build/index.mjs +5249 -1549
  31. package/build/index.mjs.map +1 -1
  32. package/build/input/InputDecoder.d.ts +32 -0
  33. package/build/input/InputEncoder.d.ts +117 -0
  34. package/build/input/index.cjs +7453 -0
  35. package/build/input/index.cjs.map +1 -0
  36. package/build/input/index.d.ts +3 -0
  37. package/build/input/index.mjs +7450 -0
  38. package/build/input/index.mjs.map +1 -0
  39. package/build/types/HelperTypes.d.ts +67 -9
  40. package/build/types/TypeContext.d.ts +9 -0
  41. package/build/types/builder.d.ts +192 -0
  42. package/build/types/custom/ArraySchema.d.ts +25 -4
  43. package/build/types/custom/CollectionSchema.d.ts +30 -2
  44. package/build/types/custom/MapSchema.d.ts +52 -3
  45. package/build/types/custom/SetSchema.d.ts +32 -2
  46. package/build/types/custom/StreamSchema.d.ts +114 -0
  47. package/build/types/symbols.d.ts +48 -5
  48. package/package.json +9 -3
  49. package/src/Metadata.ts +259 -31
  50. package/src/Reflection.ts +15 -13
  51. package/src/Schema.ts +176 -134
  52. package/src/annotations.ts +365 -252
  53. package/src/bench_bloat.ts +173 -0
  54. package/src/bench_decode.ts +221 -0
  55. package/src/bench_decode_mem.ts +165 -0
  56. package/src/bench_encode.ts +108 -0
  57. package/src/bench_init.ts +150 -0
  58. package/src/bench_static.ts +109 -0
  59. package/src/bench_stream.ts +295 -0
  60. package/src/bench_view_cmp.ts +142 -0
  61. package/src/codegen/languages/csharp.ts +0 -24
  62. package/src/codegen/parser.ts +83 -61
  63. package/src/decoder/DecodeOperation.ts +168 -63
  64. package/src/decoder/Decoder.ts +20 -10
  65. package/src/decoder/ReferenceTracker.ts +4 -0
  66. package/src/decoder/strategy/Callbacks.ts +30 -26
  67. package/src/decoder/strategy/getDecoderStateCallbacks.ts +16 -13
  68. package/src/encoder/ChangeRecorder.ts +276 -0
  69. package/src/encoder/ChangeTree.ts +674 -519
  70. package/src/encoder/EncodeDescriptor.ts +213 -0
  71. package/src/encoder/EncodeOperation.ts +107 -65
  72. package/src/encoder/Encoder.ts +630 -119
  73. package/src/encoder/MapJournal.ts +124 -0
  74. package/src/encoder/RefIdAllocator.ts +68 -0
  75. package/src/encoder/Root.ts +247 -120
  76. package/src/encoder/StateView.ts +592 -121
  77. package/src/encoder/changeTree/inheritedFlags.ts +217 -0
  78. package/src/encoder/changeTree/liveIteration.ts +74 -0
  79. package/src/encoder/changeTree/parentChain.ts +131 -0
  80. package/src/encoder/changeTree/treeAttachment.ts +171 -0
  81. package/src/encoder/streaming.ts +232 -0
  82. package/src/encoder/subscriptions.ts +71 -0
  83. package/src/index.ts +15 -3
  84. package/src/input/InputDecoder.ts +57 -0
  85. package/src/input/InputEncoder.ts +303 -0
  86. package/src/input/index.ts +3 -0
  87. package/src/types/HelperTypes.ts +121 -24
  88. package/src/types/TypeContext.ts +14 -2
  89. package/src/types/builder.ts +331 -0
  90. package/src/types/custom/ArraySchema.ts +210 -197
  91. package/src/types/custom/CollectionSchema.ts +115 -35
  92. package/src/types/custom/MapSchema.ts +162 -58
  93. package/src/types/custom/SetSchema.ts +128 -39
  94. package/src/types/custom/StreamSchema.ts +310 -0
  95. package/src/types/symbols.ts +93 -6
  96. package/src/utils.ts +4 -6
@@ -122,34 +122,10 @@ ${namespace ? "}" : ""}
122
122
  `;
123
123
  }
124
124
 
125
- /**
126
- * Check if all enum members resolve to non-negative integers,
127
- * allowing emission as a native C# `enum` (which only supports integral types).
128
- */
129
- function canUseNativeEnum(_enum: Enum): boolean {
130
- return _enum.properties.every((prop) => {
131
- if (!prop.type) return true;
132
- const n = Number(prop.type);
133
- return Number.isInteger(n) && n >= 0;
134
- });
135
- }
136
-
137
125
  /**
138
126
  * Generate just the enum body (without imports/namespace) for bundling
139
127
  */
140
128
  function generateEnumBody(_enum: Enum, indent: string = ""): string {
141
- if (canUseNativeEnum(_enum)) {
142
- const members = _enum.properties
143
- .map((prop, i) => {
144
- const value = prop.type ? Number(prop.type) : i;
145
- return `${indent}\t${prop.name} = ${value},`;
146
- })
147
- .join("\n");
148
- return `${indent}public enum ${_enum.name} : int {
149
- ${members}
150
- ${indent}}`;
151
- }
152
-
153
129
  return `${indent}public struct ${_enum.name} {
154
130
 
155
131
  ${_enum.properties
@@ -8,7 +8,54 @@ let currentProperty: Property;
8
8
 
9
9
  let globalContext: Context;
10
10
 
11
+ const BUILDER_COLLECTION_KINDS = new Set(["array", "map", "set", "collection"]);
12
+
13
+ /**
14
+ * For a t.*().chain().calls() expression, walk down to the base `t.X(...)`
15
+ * call and return its method name and first argument. Returns null if the
16
+ * node does not look like a builder chain.
17
+ */
18
+ function extractBuilderBase(node: ts.CallExpression): { methodName: string, firstArg?: ts.Expression } | null {
19
+ let current: ts.CallExpression = node;
20
+ while (true) {
21
+ const expr = current.expression;
22
+ if (!ts.isPropertyAccessExpression(expr)) {
23
+ return null;
24
+ }
25
+ if (ts.isCallExpression(expr.expression)) {
26
+ // Chained modifier, e.g. .default() / .view() — walk deeper.
27
+ current = expr.expression;
28
+ continue;
29
+ }
30
+ return {
31
+ methodName: expr.name.text,
32
+ firstArg: current.arguments[0],
33
+ };
34
+ }
35
+ }
36
+
11
37
  function defineProperty(property: Property, initializer: any) {
38
+ // Builder-style: t.number(), t.array(Item), t.map(Item).view(), etc.
39
+ if (ts.isCallExpression(initializer)) {
40
+ const base = extractBuilderBase(initializer);
41
+ if (base) {
42
+ if (BUILDER_COLLECTION_KINDS.has(base.methodName)) {
43
+ property.type = base.methodName;
44
+ if (base.firstArg) {
45
+ property.childType = (base.firstArg as any).text ?? base.firstArg.getText();
46
+ }
47
+ } else if (base.methodName === "ref") {
48
+ property.type = "ref";
49
+ if (base.firstArg) {
50
+ property.childType = (base.firstArg as any).text ?? base.firstArg.getText();
51
+ }
52
+ } else {
53
+ property.type = base.methodName;
54
+ }
55
+ return;
56
+ }
57
+ }
58
+
12
59
  if (ts.isIdentifier(initializer)) {
13
60
  property.type = "ref";
14
61
  property.childType = initializer.text;
@@ -207,39 +254,6 @@ function inspectNode(node: ts.Node, context: Context, decoratorName: string) {
207
254
  defineProperty(property, prop.initializer);
208
255
  }
209
256
 
210
- } else if (
211
- node.getText() === "defineTypes" &&
212
- (
213
- node.parent.kind === ts.SyntaxKind.CallExpression ||
214
- node.parent.kind === ts.SyntaxKind.PropertyAccessExpression
215
- )
216
- ) {
217
- /**
218
- * JavaScript source file (`.js`)
219
- * Using `defineTypes()`
220
- */
221
- const callExpression = (node.parent.kind === ts.SyntaxKind.PropertyAccessExpression)
222
- ? node.parent.parent as ts.CallExpression
223
- : node.parent as ts.CallExpression;
224
-
225
- if (callExpression.kind !== ts.SyntaxKind.CallExpression) {
226
- break;
227
- }
228
-
229
- const className = callExpression.arguments[0].getText()
230
- currentStructure.name = className;
231
-
232
- const types = callExpression.arguments[1] as any;
233
- for (let i = 0; i < types.properties.length; i++) {
234
- const prop = types.properties[i];
235
-
236
- const property = currentProperty || new Property();
237
- property.name = prop.name.escapedText;
238
- currentStructure.addProperty(property);
239
-
240
- defineProperty(property, prop.initializer);
241
- }
242
-
243
257
  }
244
258
 
245
259
  if (node.parent.kind === ts.SyntaxKind.ClassDeclaration) {
@@ -252,58 +266,66 @@ function inspectNode(node: ts.Node, context: Context, decoratorName: string) {
252
266
 
253
267
  case ts.SyntaxKind.CallExpression:
254
268
  /**
255
- * Defining schema via `schema.schema({ ... })`
256
- * - schema.schema({})
257
- * - schema({})
258
- * - ClassName.extends({})
269
+ * Defining schema via:
270
+ * - schema({ ... })
271
+ * - schema({ ... }, 'Name')
272
+ * - schema.schema({ ... }, 'Name')
273
+ * - ParentClass.extend({ ... }, 'Name')
259
274
  */
260
- if (
261
- (
262
- (
263
- (node as ts.CallExpression).expression?.getText() === "schema.schema" ||
264
- (node as ts.CallExpression).expression?.getText() === "schema"
265
- ) ||
266
- (
267
- (node as ts.CallExpression).expression?.getText().indexOf(".extends") !== -1
268
- )
269
- ) &&
270
- (node as ts.CallExpression).arguments[0].kind === ts.SyntaxKind.ObjectLiteralExpression
271
- ) {
275
+ {
272
276
  const callExpression = node as ts.CallExpression;
277
+ const callee = callExpression.expression?.getText?.();
278
+ if (!callee) break;
279
+
280
+ const isSchemaCall = callee === "schema" || callee === "schema.schema";
281
+ const isExtendCall = callee.indexOf(".extend") !== -1 && !callee.endsWith(".extends");
282
+ if (!isSchemaCall && !isExtendCall) break;
283
+
284
+ // Signature: (fields, name?)
285
+ const fieldsArg = callExpression.arguments[0];
286
+ const nameArg = callExpression.arguments[1];
287
+ if (!fieldsArg || fieldsArg.kind !== ts.SyntaxKind.ObjectLiteralExpression) {
288
+ break;
289
+ }
273
290
 
274
- let className = callExpression.arguments[1]?.getText();
291
+ let className: string | undefined;
292
+ if (nameArg) {
293
+ if (nameArg.kind === ts.SyntaxKind.StringLiteral) {
294
+ className = (nameArg as ts.StringLiteral).text;
295
+ } else {
296
+ className = nameArg.getText();
297
+ }
298
+ }
275
299
 
276
300
  if (!className && callExpression.parent.kind === ts.SyntaxKind.VariableDeclaration) {
277
301
  className = (callExpression.parent as ts.VariableDeclaration).name?.getText();
278
302
  }
279
303
 
280
- // skip if no className is provided
281
- if (!className) { break; }
304
+ if (!className) break;
282
305
 
283
306
  if (currentStructure?.name !== className) {
284
307
  currentStructure = new Class();
285
308
  context.addStructure(currentStructure);
286
309
  }
287
310
 
288
- if ((node as ts.CallExpression).expression?.getText().indexOf(".extends") !== -1) {
289
- // if it's using `.extends({})`
311
+ if (isExtendCall) {
290
312
  const extendsClass = (node as any).expression?.expression?.escapedText;
291
-
292
- // skip if no extendsClass is provided
293
- if (!extendsClass) { break; }
313
+ if (!extendsClass) break;
294
314
  (currentStructure as Class).extends = extendsClass;
295
-
296
315
  } else {
297
- // if it's using `schema({})`
298
- (currentStructure as Class).extends = "Schema"; // force extends to Schema
316
+ (currentStructure as Class).extends = "Schema";
299
317
  }
300
318
 
301
319
  currentStructure.name = className;
302
320
 
303
- const types = callExpression.arguments[0] as any;
321
+ const types = fieldsArg as any;
304
322
  for (let i = 0; i < types.properties.length; i++) {
305
323
  const prop = types.properties[i];
306
324
 
325
+ // Skip methods declared inside the fields object.
326
+ if (prop.kind === ts.SyntaxKind.MethodDeclaration) continue;
327
+ if (!prop.initializer) continue;
328
+
307
329
  const property = currentProperty || new Property();
308
330
  property.name = prop.name.escapedText;
309
331
 
@@ -4,7 +4,7 @@ import { Schema } from "../Schema.js";
4
4
  import type { IRef, Ref } from "../encoder/ChangeTree.js";
5
5
  import type { Decoder } from "./Decoder.js";
6
6
  import { Iterator, decode } from "../encoding/decode.js";
7
- import { $childType, $deleteByIndex, $getByIndex, $refId } from "../types/symbols.js";
7
+ import { $childType, $deleteByIndex, $getByIndex, $proxyTarget, $refId } from "../types/symbols.js";
8
8
 
9
9
  import type { ArraySchema } from "../types/custom/ArraySchema.js";
10
10
 
@@ -15,7 +15,8 @@ export interface DataChange<T = any, F = string> {
15
15
  ref: IRef,
16
16
  refId: number,
17
17
  op: OPERATION,
18
- field: F;
18
+ /** Set for Schema field changes; omitted for collection item changes (which carry a `dynamicIndex` instead). */
19
+ field?: F;
19
20
  dynamicIndex?: number | string;
20
21
  value: T;
21
22
  previousValue: T;
@@ -23,26 +24,73 @@ export interface DataChange<T = any, F = string> {
23
24
 
24
25
  export const DEFINITION_MISMATCH = -1;
25
26
 
27
+ /**
28
+ * When no `triggerChanges` subscriber is attached, `Decoder.decode` passes
29
+ * `null` so the per-field change objects are never allocated. Every push
30
+ * site uses `allChanges?.push(...)` — optional chaining also short-circuits
31
+ * the object literal, so there's nothing to collect and nothing to throw
32
+ * away.
33
+ */
26
34
  export type DecodeOperation<T extends Schema = any> = (
27
35
  decoder: Decoder<T>,
28
36
  bytes: Uint8Array,
29
37
  it: Iterator,
30
38
  ref: IRef,
31
- allChanges: DataChange[],
39
+ allChanges: DataChange[] | null,
32
40
  ) => number | void;
33
41
 
42
+ /**
43
+ * Collection-kind discriminator declared on each collection class as
44
+ * `static COLLECTION_KIND = CollectionKind.X`. The decoder's key/value
45
+ * dispatch used to make three back-to-back `typeof(ref.method) ===
46
+ * "function"` checks per entry; those collapse into one switch on the
47
+ * target's class tag. Missing / `undefined` on a ref hits the switch's
48
+ * `default` branch and logs a warning — a guard for future collection
49
+ * types that land without a tag.
50
+ *
51
+ * Declared as a `const` object (not a TS `enum`) so the codegen parser —
52
+ * which picks up every `EnumDeclaration` in the lib source via transitive
53
+ * imports — doesn't emit a generated .cs file for it.
54
+ */
55
+ export const CollectionKind = {
56
+ Map: 1,
57
+ Array: 2,
58
+ Set: 3,
59
+ Collection: 4,
60
+ Stream: 5,
61
+ } as const;
62
+ export type CollectionKind = typeof CollectionKind[keyof typeof CollectionKind];
63
+
64
+ /**
65
+ * Structural type for any class that participates in the `decodeKeyValue-
66
+ * Operation` dispatch. Lets the hot-path read `tgt.constructor.COLLECTION_KIND`
67
+ * without an `any` cast.
68
+ */
69
+ export interface CollectionCtor {
70
+ readonly COLLECTION_KIND: CollectionKind;
71
+ }
72
+
73
+ /**
74
+ * Decode the next wire value for `ref[index]`. Returns the decoded value.
75
+ *
76
+ * Callers pass `previousValue` explicitly — it's the current value at the
77
+ * slot before decoding and is needed for ref-count bookkeeping (on DELETE)
78
+ * and for the DELETE_AND_ADD self-reassign case. Keeping it as a parameter
79
+ * lets this function return a single primitive instead of a pair, so the
80
+ * hot call path allocates nothing.
81
+ */
34
82
  export function decodeValue<T extends Ref>(
35
83
  decoder: Decoder,
36
84
  operation: OPERATION,
37
85
  ref: T,
38
86
  index: number,
87
+ previousValue: any,
39
88
  type: any,
40
89
  bytes: Uint8Array,
41
90
  it: Iterator,
42
- allChanges: DataChange[],
43
- ) {
91
+ allChanges: DataChange[] | null,
92
+ ): any {
44
93
  const $root = decoder.root;
45
- const previousValue = (ref as any)[$getByIndex](index) as T;
46
94
 
47
95
  let value: any;
48
96
 
@@ -56,7 +104,7 @@ export function decodeValue<T extends Ref>(
56
104
  // Delete operations
57
105
  //
58
106
  if (operation !== OPERATION.DELETE_AND_ADD) {
59
- (ref as any)[$deleteByIndex](index);
107
+ ref[$deleteByIndex](index);
60
108
  }
61
109
 
62
110
  value = undefined;
@@ -67,6 +115,15 @@ export function decodeValue<T extends Ref>(
67
115
  // Don't do anything
68
116
  //
69
117
 
118
+ } else if (typeof (type) === "string") {
119
+ //
120
+ // Primitive value (number, string, boolean, …). Hot-path first
121
+ // because steady-state ticks are dominated by primitive field
122
+ // updates — moves us past a cheap typeof check instead of a
123
+ // Symbol-metadata lookup via `Schema.is`.
124
+ //
125
+ value = (decode as any)[type](bytes, it);
126
+
70
127
  } else if (Schema.is(type)) {
71
128
  const refId = decode.number(bytes, it);
72
129
  value = $root.refs.get(refId);
@@ -87,19 +144,17 @@ export function decodeValue<T extends Ref>(
87
144
  );
88
145
  }
89
146
 
90
- } else if (typeof(type) === "string") {
91
- //
92
- // primitive value (number, string, boolean, etc)
93
- //
94
- value = (decode as any)[type](bytes, it);
95
-
96
147
  } else {
97
148
  const typeDef = getType(Object.keys(type)[0]);
98
149
  const refId = decode.number(bytes, it);
99
150
 
151
+ // `initializeForDecoder` is a static on every registered collection
152
+ // class — it does `Object.create(Class.prototype)` + the class-
153
+ // field init + assigns an untracked `$changes` directly. Keeps
154
+ // the decoder free of collection-type internals.
100
155
  const valueRef: Ref = ($root.refs.has(refId))
101
156
  ? previousValue || $root.refs.get(refId)
102
- : new typeDef.constructor();
157
+ : (typeDef.constructor as any).initializeForDecoder();
103
158
 
104
159
  value = valueRef.clone(true);
105
160
  value[$childType] = Object.values(type)[0]; // cache childType for ArraySchema and MapSchema
@@ -122,7 +177,7 @@ export function decodeValue<T extends Ref>(
122
177
  $root.removeRef(previousRefId);
123
178
  }
124
179
 
125
- allChanges.push({
180
+ allChanges?.push({
126
181
  ref: previousValue,
127
182
  refId: previousRefId,
128
183
  op: OPERATION.DELETE,
@@ -141,7 +196,7 @@ export function decodeValue<T extends Ref>(
141
196
  ));
142
197
  }
143
198
 
144
- return { value, previousValue };
199
+ return value;
145
200
  }
146
201
 
147
202
  export const decodeSchemaOperation: DecodeOperation = function <T extends Schema>(
@@ -149,7 +204,7 @@ export const decodeSchemaOperation: DecodeOperation = function <T extends Schema
149
204
  bytes: Uint8Array,
150
205
  it: Iterator,
151
206
  ref: T,
152
- allChanges: DataChange[],
207
+ allChanges: DataChange[] | null,
153
208
  ) {
154
209
  const first_byte = bytes[it.offset++];
155
210
  const metadata: Metadata = (ref.constructor as typeof Schema)[Symbol.metadata];
@@ -165,11 +220,13 @@ export const decodeSchemaOperation: DecodeOperation = function <T extends Schema
165
220
  return DEFINITION_MISMATCH;
166
221
  }
167
222
 
168
- const { value, previousValue } = decodeValue(
223
+ const previousValue = ref[$getByIndex](index);
224
+ const value = decodeValue(
169
225
  decoder,
170
226
  operation,
171
227
  ref,
172
228
  index,
229
+ previousValue,
173
230
  field.type,
174
231
  bytes,
175
232
  it,
@@ -177,12 +234,19 @@ export const decodeSchemaOperation: DecodeOperation = function <T extends Schema
177
234
  );
178
235
 
179
236
  if (value !== null && value !== undefined) {
237
+ // Write via the generated setter. Bypass to `(ref as any)[$values][index]`
238
+ // was attempted but only works for @type-decorated classes (which
239
+ // install accessor descriptors reading from `$values`). Reflection-
240
+ // decoded classes install a plain data-property descriptor instead,
241
+ // so their value lives as an own property on the instance — direct
242
+ // `$values[index]` writes are invisible to the getter on that path.
243
+ // Two-mode dispatch would cost more than the ~3% it'd save.
180
244
  ref[field.name as keyof T] = value;
181
245
  }
182
246
 
183
247
  // add change
184
248
  if (previousValue !== value) {
185
- allChanges.push({
249
+ allChanges?.push({
186
250
  ref,
187
251
  refId: decoder.currentRefId,
188
252
  op: operation,
@@ -198,8 +262,14 @@ export const decodeKeyValueOperation: DecodeOperation = function (
198
262
  bytes: Uint8Array,
199
263
  it: Iterator,
200
264
  ref: Ref,
201
- allChanges: DataChange[]
265
+ allChanges: DataChange[] | null,
202
266
  ) {
267
+ // Unwrap ArraySchema Proxy once so subsequent property reads skip the
268
+ // `get` trap. `$proxyTarget` is a self-reference on the target; on
269
+ // non-proxied collections (Map/Set/Collection/Stream) the lookup is
270
+ // undefined and we fall back to `ref`.
271
+ const tgt: any = (ref as any)[$proxyTarget] ?? ref;
272
+
203
273
  // "uncompressed" index + operation (array/map items)
204
274
  const operation = bytes[it.offset++];
205
275
 
@@ -209,34 +279,38 @@ export const decodeKeyValueOperation: DecodeOperation = function (
209
279
  // - enqueue items for DELETE callback.
210
280
  // - flag child items for garbage collection.
211
281
  //
212
- decoder.removeChildRefs(ref as unknown as Collection, allChanges);
282
+ decoder.removeChildRefs(tgt as Collection, allChanges);
213
283
 
214
- (ref as any).clear();
284
+ tgt.clear();
215
285
  return;
216
286
  }
217
287
 
218
288
  const index = decode.number(bytes, it);
219
- const type = (ref as any)[$childType];
289
+ const type = tgt[$childType];
290
+ // One constructor lookup, one integer read → switch. Replaces three
291
+ // `typeof(ref.method) === "function"` dispatches per entry.
292
+ const kind: CollectionKind = (tgt.constructor as CollectionCtor).COLLECTION_KIND;
220
293
 
221
294
  let dynamicIndex: number | string;
222
295
 
223
296
  if ((operation & OPERATION.ADD) === OPERATION.ADD) { // ADD or DELETE_AND_ADD
224
- if (typeof((ref as any)['set']) === "function") {
225
- dynamicIndex = decode.string(bytes, it); // MapSchema
226
- (ref as any)['setIndex'](index, dynamicIndex);
297
+ if (kind === CollectionKind.Map) {
298
+ dynamicIndex = decode.string(bytes, it); // MapSchema uses a wire-delivered string key
299
+ tgt.setIndex(index, dynamicIndex);
227
300
  } else {
228
- dynamicIndex = index; // ArraySchema
301
+ dynamicIndex = index;
229
302
  }
230
303
  } else {
231
- // get dynamic index from "ref"
232
- dynamicIndex = (ref as any)['getIndex'](index);
304
+ dynamicIndex = tgt.getIndex(index);
233
305
  }
234
306
 
235
- const { value, previousValue } = decodeValue(
307
+ const previousValue = tgt[$getByIndex](index);
308
+ const value = decodeValue(
236
309
  decoder,
237
310
  operation,
238
311
  ref,
239
312
  index,
313
+ previousValue,
240
314
  type,
241
315
  bytes,
242
316
  it,
@@ -244,31 +318,57 @@ export const decodeKeyValueOperation: DecodeOperation = function (
244
318
  );
245
319
 
246
320
  if (value !== null && value !== undefined) {
247
- if (typeof((ref as any)['set']) === "function") {
248
- // MapSchema
249
- (ref as any)['$items'].set(dynamicIndex as string, value);
250
-
251
- } else if (typeof((ref as any)['$setAt']) === "function") {
252
- // ArraySchema
253
- (ref as any)['$setAt'](index, value, operation);
254
-
255
- } else if (typeof((ref as any)['add']) === "function") {
256
- // CollectionSchema && SetSchema
257
- const index = (ref as any).add(value);
258
-
259
- if (typeof(index) === "number") {
260
- (ref as any)['setIndex'](index, index);
261
- }
321
+ switch (kind) {
322
+ case CollectionKind.Map:
323
+ tgt.$items.set(dynamicIndex as string, value);
324
+ break;
325
+
326
+ case CollectionKind.Array:
327
+ tgt.$setAt(index, value, operation);
328
+ break;
329
+
330
+ // SetSchema / CollectionSchema / StreamSchema — use the wire-
331
+ // index we decoded above so server/client `$items` stay in sync
332
+ // regardless of duplicate emission (e.g. a bootstrap that walks
333
+ // both `encodeAll` and the shared recorder emits the same ADD
334
+ // op twice). Previous implementation called `ref.add(value)`
335
+ // and let the decoder-side `$refId++` allocate a new index per
336
+ // call — which for CollectionSchema (no value-dedup) turned
337
+ // duplicate wire ADDs into duplicate client-side entries.
338
+ case CollectionKind.Set:
339
+ case CollectionKind.Collection:
340
+ case CollectionKind.Stream:
341
+ if (!tgt.$items.has(index)) {
342
+ tgt.$items.set(index, value);
343
+ // Keep the decoder's monotonic counter ahead of any
344
+ // wire-index we've seen so future server-side `.add()`
345
+ // allocations don't collide with ones already decoded.
346
+ // (StreamSchema has no `$refId` counter — `typeof`
347
+ // guards the Set/Collection path.)
348
+ if (typeof tgt.$refId === "number" && index >= tgt.$refId) {
349
+ tgt.$refId = index + 1;
350
+ }
351
+ }
352
+ break;
353
+
354
+ default:
355
+ // A future collection type landed without a COLLECTION_KIND
356
+ // tag. Surface it loudly instead of silently dropping the
357
+ // value — the missing entry here is the only place the new
358
+ // type's item-storage semantics need to be wired up.
359
+ console.warn(
360
+ `@colyseus/schema: missing COLLECTION_KIND on ${tgt.constructor?.name} — item at index ${index} was not stored.`
361
+ );
362
+ break;
262
363
  }
263
364
  }
264
365
 
265
366
  // add change
266
367
  if (previousValue !== value) {
267
- allChanges.push({
368
+ allChanges?.push({
268
369
  ref,
269
370
  refId: decoder.currentRefId,
270
371
  op: operation,
271
- field: "", // FIXME: remove this
272
372
  dynamicIndex,
273
373
  value,
274
374
  previousValue,
@@ -281,8 +381,11 @@ export const decodeArray: DecodeOperation = function (
281
381
  bytes: Uint8Array,
282
382
  it: Iterator,
283
383
  ref: ArraySchema,
284
- allChanges: DataChange[]
384
+ allChanges: DataChange[] | null,
285
385
  ) {
386
+ // Unwrap the Proxy once — ref is always an ArraySchema here.
387
+ const tgt: any = (ref as any)[$proxyTarget] ?? ref;
388
+
286
389
  // "uncompressed" index + operation (array/map items)
287
390
  let operation = bytes[it.offset++];
288
391
  let index: number;
@@ -293,25 +396,24 @@ export const decodeArray: DecodeOperation = function (
293
396
  // - enqueue items for DELETE callback.
294
397
  // - flag child items for garbage collection.
295
398
  //
296
- decoder.removeChildRefs(ref as unknown as Collection, allChanges);
297
- (ref as ArraySchema).clear();
399
+ decoder.removeChildRefs(tgt as Collection, allChanges);
400
+ tgt.clear();
298
401
  return;
299
402
 
300
403
  } else if (operation === OPERATION.REVERSE) {
301
- (ref as ArraySchema).reverse();
404
+ tgt.reverse();
302
405
  return;
303
406
 
304
407
  } else if (operation === OPERATION.DELETE_BY_REFID) {
305
408
  // TODO: refactor here, try to follow same flow as below
306
409
  const refId = decode.number(bytes, it);
307
410
  const previousValue = decoder.root.refs.get(refId);
308
- index = ref.findIndex((value) => value === previousValue);
309
- ref[$deleteByIndex](index);
310
- allChanges.push({
411
+ index = tgt.findIndex((value: any) => value === previousValue);
412
+ tgt[$deleteByIndex](index);
413
+ allChanges?.push({
311
414
  ref,
312
415
  refId: decoder.currentRefId,
313
416
  op: OPERATION.DELETE,
314
- field: "", // FIXME: remove this
315
417
  dynamicIndex: index,
316
418
  value: undefined,
317
419
  previousValue,
@@ -325,27 +427,32 @@ export const decodeArray: DecodeOperation = function (
325
427
 
326
428
  // if item already exists, use existing index
327
429
  if (itemByRefId) {
328
- index = ref.findIndex((value) => value === itemByRefId);
430
+ index = tgt.findIndex((value: any) => value === itemByRefId);
329
431
  }
330
432
 
331
433
  // fallback to use last index
332
434
  if (index === -1 || index === undefined) {
333
- index = ref.length;
435
+ index = tgt.length;
334
436
  }
335
437
 
336
438
  } else {
337
439
  index = decode.number(bytes, it);
338
440
  }
339
441
 
340
- const type = ref[$childType];
442
+ const type = tgt[$childType];
341
443
 
342
444
  let dynamicIndex: number | string = index;
343
445
 
344
- const { value, previousValue } = decodeValue(
446
+ // Direct `items[index]` read ArraySchema's `$getByIndex` is encoder-only
447
+ // (it consults `tmpItems`/`deletedIndexes`, which the decoder doesn't
448
+ // maintain). The decoder's authoritative state is `items`.
449
+ const previousValue = tgt.items[index];
450
+ const value = decodeValue(
345
451
  decoder,
346
452
  operation,
347
453
  ref,
348
454
  index,
455
+ previousValue,
349
456
  type,
350
457
  bytes,
351
458
  it,
@@ -356,17 +463,15 @@ export const decodeArray: DecodeOperation = function (
356
463
  value !== null && value !== undefined &&
357
464
  value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
358
465
  ) {
359
- // ArraySchema
360
- (ref as ArraySchema)['$setAt'](index, value, operation);
466
+ tgt.$setAt(index, value, operation);
361
467
  }
362
468
 
363
469
  // add change
364
470
  if (previousValue !== value) {
365
- allChanges.push({
471
+ allChanges?.push({
366
472
  ref,
367
473
  refId: decoder.currentRefId,
368
474
  op: operation,
369
- field: "", // FIXME: remove this
370
475
  dynamicIndex,
371
476
  value,
372
477
  previousValue,