@colyseus/schema 5.0.12 → 5.0.14

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.
@@ -70,7 +70,7 @@ export type InferValueType<T> = T extends FieldBuilder<infer V> ? V : T extends
70
70
  stream: infer ChildType;
71
71
  } ? StreamSchema<ChildType> : T extends Constructor ? InstanceType<T> : T extends Record<string | number, string | number> ? T[keyof T] : T extends PrimitiveType ? T : never;
72
72
  type OptionalBuilderKeys<T> = {
73
- [K in keyof T]: T[K] extends FieldBuilder<infer V> ? (undefined extends V ? K : never) : never;
73
+ [K in keyof T]: T[K] extends FieldBuilder<unknown, boolean, infer O extends boolean> ? (O extends true ? K : never) : never;
74
74
  }[keyof T];
75
75
  type RequiredBuilderKeys<T> = Exclude<keyof T, OptionalBuilderKeys<T>>;
76
76
  export type InferSchemaInstanceType<T> = {
@@ -89,12 +89,10 @@ export type NonFunctionNonPrimitivePropNames<T> = {
89
89
  }[keyof T];
90
90
  type ToJSONValue<U> = U extends Schema ? ToJSON<U> : PrimitiveStringToType<U>;
91
91
  type ToJSONField<X> = X extends MapSchema<infer U> ? Record<string, ToJSONValue<U>> : X extends Map<string, infer U> ? Record<string, ToJSONValue<U>> : X extends ArraySchema<infer U> ? ToJSONValue<U>[] : X extends SetSchema<infer U> ? ToJSONValue<U>[] : X extends CollectionSchema<infer U> ? ToJSONValue<U>[] : X extends Schema ? ToJSON<X> : X;
92
- type ToJSONRequiredKeys<T> = {
93
- [K in keyof T]-?: undefined extends T[K] ? never : K;
94
- }[keyof T];
95
92
  type ToJSONOptionalKeys<T> = {
96
- [K in keyof T]-?: undefined extends T[K] ? K : never;
93
+ [K in keyof T]-?: undefined extends {} ? ({} extends Pick<T, K> ? K : never) : (undefined extends T[K] ? K : never);
97
94
  }[keyof T];
95
+ type ToJSONRequiredKeys<T> = Exclude<keyof T, ToJSONOptionalKeys<T>>;
98
96
  export type ToJSON<T> = NonFunctionProps<{
99
97
  [K in ToJSONRequiredKeys<T>]: ToJSONField<T[K]>;
100
98
  } & {
@@ -45,10 +45,11 @@ export type BuilderOf<T> = FieldBuilder<T>;
45
45
  * Schema ref whose `initialize` takes zero args.
46
46
  * - `IsOptional` is a compile-time brand for `.optional()`. Both
47
47
  * `HasDefault` and `IsOptional` make the field omittable in
48
- * `BuilderInitProps<T>`. A separate brand (rather than reading
49
- * `undefined extends V`) sidesteps a TypeScript quirk where
50
- * class-generic-inferred `V` resolves `undefined extends V` as `true`
51
- * even for non-undefined types.
48
+ * `BuilderInitProps<T>`; `IsOptional` alone marks the instance property
49
+ * `?:`. A separate brand (rather than reading `undefined extends V`)
50
+ * keeps both correct for consumers compiling with
51
+ * `strictNullChecks: false`, where `undefined extends V` is true for
52
+ * every V.
52
53
  *
53
54
  * schema() reads the internal configuration via `toDefinition()` and wires
54
55
  * up metadata through the existing pipeline.
@@ -199,8 +200,8 @@ export declare function isBuilder(value: any): value is FieldBuilder<any>;
199
200
  * Two call signatures, NOT a defaulted generic `<T extends TBase = TBase>`: the
200
201
  * bare form must return a CONCRETE `FieldBuilder<TBase>` so `schema({ x:
201
202
  * t.number() })` still infers `x: number`. A defaulted free type parameter gets
202
- * captured as `any` during `schema()`'s self-referential field inference (and
203
- * `undefined extends any` then flips every field optional).
203
+ * captured as `any` during `schema()`'s self-referential field inference,
204
+ * degrading every field's value type to `any`.
204
205
  *
205
206
  * NOTE: the refinement is a TYPE-LEVEL assertion, not a runtime guarantee — the
206
207
  * wire still carries the codec's full range and the DECODER writes whatever
@@ -63,6 +63,23 @@ export declare class ArraySchema<V = any> implements Array<V>, Collection<number
63
63
  * land on the wrong wire slots.
64
64
  */
65
65
  protected $wireIndex(index: number): number;
66
+ /**
67
+ * Re-point children at their wire slot. `ChangeTree._parentIndex` caches
68
+ * the slot a child holds in `tmpItems`, and StateView addresses per-view
69
+ * ADD/DELETE with it — so a reorder that leaves it behind aims those ops
70
+ * at whichever element inherited the slot (issue #231).
71
+ *
72
+ * The filter check is a correctness boundary, not a tunable: StateView is
73
+ * the only reader and reaches the index only through a filtered array
74
+ * (`addParentOf` bails on `hasFilteredFields`, `remove` on the child's
75
+ * `isFiltered`). Everything else stops at the flag read instead of walking
76
+ * its children every tick.
77
+ *
78
+ * Callers name the lowest slot that moved as `from`. Compaction cannot, so
79
+ * it hands over the pre-compaction layout as `staged` and the unchanged
80
+ * prefix is skipped instead. Either way tail churn walks nothing.
81
+ */
82
+ protected $reindexChildren(from: number, staged?: V[]): void;
66
83
  protected $changeAt(index: number, value: V): number | undefined;
67
84
  protected $deleteAt(index: number, operation?: OPERATION): void;
68
85
  protected $setAt(index: number, value: V, operation: OPERATION): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/schema",
3
- "version": "5.0.12",
3
+ "version": "5.0.14",
4
4
  "description": "Binary state serializer with delta encoding for games",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  "watch": "tsc -p tsconfig.build.json -w",
19
19
  "typecheck:build": "tsc -p tsconfig.build.json --noEmit",
20
20
  "test": "npm run test:types && tsx --tsconfig tsconfig.test.json ./node_modules/mocha/bin/mocha.js \"test/*.test.ts\" \"test/**/*.test.ts\"",
21
- "test:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.types.json",
21
+ "test:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.types.json && tsc -p tsconfig.types-nostrict.json",
22
22
  "test:exports": "node test/verify-exports.mjs",
23
23
  "typecheck": "tsc -p tsconfig.test.json",
24
24
  "coverage": "c8 npm run test",
@@ -34,6 +34,7 @@ import type { DecodeOperation } from "../decoder/DecodeOperation.js";
34
34
 
35
35
  import {
36
36
  addParent as _addParent, removeParent as _removeParent,
37
+ setParentIndex as _setParentIndex,
37
38
  findParent as _findParent, hasParent as _hasParent,
38
39
  getAllParents as _getAllParents,
39
40
  } from "./changeTree/parentChain.js";
@@ -804,6 +805,9 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
804
805
 
805
806
  addParent(parent: Ref, index: number): void { _addParent(this, parent, index); }
806
807
 
808
+ /** Re-point an existing parent's cached index after the parent reindexed. */
809
+ setParentIndex(parent: Ref, index: number): void { _setParentIndex(this, parent, index); }
810
+
807
811
  /** @returns true if parent was found and removed */
808
812
  removeParent(parent: Ref = this.parent): boolean { return _removeParent(this, parent); }
809
813
 
@@ -856,6 +860,7 @@ export class UntrackedChangeTree {
856
860
  operation(): void {}
857
861
  setParent(): void {}
858
862
  addParent(): void {}
863
+ setParentIndex(): void {}
859
864
  removeParent(): boolean { return false; }
860
865
  getChange(): number { return 0; }
861
866
  discard(): void {}
@@ -45,6 +45,35 @@ export function addParent(tree: ChangeTree, parent: Ref, index: number): void {
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * Move `parent`'s existing chain entry to `index`, skipping the attachment
50
+ * work `addParent` does. `parent` must already be a parent of `tree`.
51
+ *
52
+ * Called by collections whose wire slots shift (ArraySchema): StateView
53
+ * addresses per-view ADD/DELETE by that index, so it has to follow the
54
+ * element it names.
55
+ */
56
+ export function setParentIndex(tree: ChangeTree, parent: Ref, index: number): void {
57
+ if (tree.extraParents === undefined) {
58
+ tree._parentIndex = index; // sole parent, so it is `parent`
59
+ return;
60
+ }
61
+ // Shared instance — move only the entry `parent` owns. Matching goes
62
+ // through `$changes` because ArraySchema arrives proxied (see removeParent
63
+ // below), and `extraParents` only ever fills by demoting `parentRef`, so
64
+ // the inline parent is set here.
65
+ if (tree.parentRef[$changes] === parent[$changes]) {
66
+ tree._parentIndex = index;
67
+ return;
68
+ }
69
+ for (let entry = tree.extraParents; entry !== undefined; entry = entry.next) {
70
+ if (entry.ref[$changes] === parent[$changes]) {
71
+ entry.index = index;
72
+ return;
73
+ }
74
+ }
75
+ }
76
+
48
77
  /**
49
78
  * Remove a parent from the chain.
50
79
  * @returns true if parent was found and removed (Root.remove relies on this).
@@ -87,10 +87,12 @@ export type InferValueType<T> =
87
87
 
88
88
  : never;
89
89
 
90
- // Keys whose FieldBuilder generic admits `undefined` (i.e. `.optional()` was chained).
90
+ // Keys whose builder carries the `.optional()` brand. Reads the brand rather
91
+ // than `undefined extends V`: the latter is true for EVERY V when the consumer
92
+ // compiles with `strictNullChecks: false`, flipping all fields optional.
91
93
  type OptionalBuilderKeys<T> = {
92
- [K in keyof T]: T[K] extends FieldBuilder<infer V>
93
- ? (undefined extends V ? K : never)
94
+ [K in keyof T]: T[K] extends FieldBuilder<unknown, boolean, infer O extends boolean>
95
+ ? (O extends true ? K : never)
94
96
  : never
95
97
  }[keyof T];
96
98
 
@@ -136,14 +138,16 @@ type ToJSONField<X> =
136
138
  : X extends Schema ? ToJSON<X>
137
139
  : X;
138
140
 
139
- // Keys whose value type admits `undefined` — runtime `toJSON()` omits those,
140
- // so they surface as `?:` on the JSON shape.
141
- type ToJSONRequiredKeys<T> = {
142
- [K in keyof T]-?: undefined extends T[K] ? never : K
143
- }[keyof T];
141
+ // Keys whose value admits `undefined` — runtime `toJSON()` omits those, so
142
+ // they surface as `?:` on the JSON shape. Under `strictNullChecks: false`
143
+ // (`undefined extends {}` detects it) `undefined extends T[K]` is true for
144
+ // every key, so only the `?` modifier can signal optionality there.
144
145
  type ToJSONOptionalKeys<T> = {
145
- [K in keyof T]-?: undefined extends T[K] ? K : never
146
+ [K in keyof T]-?: undefined extends {}
147
+ ? ({} extends Pick<T, K> ? K : never)
148
+ : (undefined extends T[K] ? K : never)
146
149
  }[keyof T];
150
+ type ToJSONRequiredKeys<T> = Exclude<keyof T, ToJSONOptionalKeys<T>>;
147
151
 
148
152
  export type ToJSON<T> = NonFunctionProps<
149
153
  & { [K in ToJSONRequiredKeys<T>]: ToJSONField<T[K]> }
@@ -226,10 +230,9 @@ type FieldValue<F> =
226
230
 
227
231
  // Classify each key of a fields map as "required" / "optional" / "none"
228
232
  // (methods). Both `HasDefault = true` and the explicit `.optional()` brand
229
- // `IsOptional = true` mark the field omittable at construction. The brand
230
- // sidesteps a TypeScript quirk where `undefined extends V` returned `true`
231
- // for non-undefined V when V was inferred from a class with T in
232
- // contravariant + covariant positions.
233
+ // `IsOptional = true` mark the field omittable at construction. Reading the
234
+ // brands (never `undefined extends V`) keeps this correct for consumers on
235
+ // `strictNullChecks: false`, where `undefined extends V` is true for every V.
233
236
  type KeyClass<T, K extends keyof T> =
234
237
  T[K] extends FieldBuilder<unknown, infer D extends boolean, infer O extends boolean>
235
238
  ? (D extends true
@@ -51,10 +51,11 @@ export type BuilderOf<T> = FieldBuilder<T>;
51
51
  * Schema ref whose `initialize` takes zero args.
52
52
  * - `IsOptional` is a compile-time brand for `.optional()`. Both
53
53
  * `HasDefault` and `IsOptional` make the field omittable in
54
- * `BuilderInitProps<T>`. A separate brand (rather than reading
55
- * `undefined extends V`) sidesteps a TypeScript quirk where
56
- * class-generic-inferred `V` resolves `undefined extends V` as `true`
57
- * even for non-undefined types.
54
+ * `BuilderInitProps<T>`; `IsOptional` alone marks the instance property
55
+ * `?:`. A separate brand (rather than reading `undefined extends V`)
56
+ * keeps both correct for consumers compiling with
57
+ * `strictNullChecks: false`, where `undefined extends V` is true for
58
+ * every V.
58
59
  *
59
60
  * schema() reads the internal configuration via `toDefinition()` and wires
60
61
  * up metadata through the existing pipeline.
@@ -289,8 +290,8 @@ export function isBuilder(value: any): value is FieldBuilder<any> {
289
290
  * Two call signatures, NOT a defaulted generic `<T extends TBase = TBase>`: the
290
291
  * bare form must return a CONCRETE `FieldBuilder<TBase>` so `schema({ x:
291
292
  * t.number() })` still infers `x: number`. A defaulted free type parameter gets
292
- * captured as `any` during `schema()`'s self-referential field inference (and
293
- * `undefined extends any` then flips every field optional).
293
+ * captured as `any` during `schema()`'s self-referential field inference,
294
+ * degrading every field's value type to `any`.
294
295
  *
295
296
  * NOTE: the refinement is a TYPE-LEVEL assertion, not a runtime guarantee — the
296
297
  * wire still carries the codec's full range and the DECODER writes whatever
@@ -331,6 +331,35 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
331
331
  return tmpItems.length + (index - live);
332
332
  }
333
333
 
334
+ /**
335
+ * Re-point children at their wire slot. `ChangeTree._parentIndex` caches
336
+ * the slot a child holds in `tmpItems`, and StateView addresses per-view
337
+ * ADD/DELETE with it — so a reorder that leaves it behind aims those ops
338
+ * at whichever element inherited the slot (issue #231).
339
+ *
340
+ * The filter check is a correctness boundary, not a tunable: StateView is
341
+ * the only reader and reaches the index only through a filtered array
342
+ * (`addParentOf` bails on `hasFilteredFields`, `remove` on the child's
343
+ * `isFiltered`). Everything else stops at the flag read instead of walking
344
+ * its children every tick.
345
+ *
346
+ * Callers name the lowest slot that moved as `from`. Compaction cannot, so
347
+ * it hands over the pre-compaction layout as `staged` and the unchanged
348
+ * prefix is skipped instead. Either way tail churn walks nothing.
349
+ */
350
+ protected $reindexChildren(from: number, staged?: V[]) {
351
+ if (!this[$changes].hasFilteredFields) { return; } // nothing will read the cache
352
+ if (typeof this[$childType] === "string") { return; } // primitives have no child tree
353
+ const tmpItems = this.tmpItems;
354
+ const length = tmpItems.length;
355
+ if (staged !== undefined) {
356
+ while (from < length && tmpItems[from] === staged[from]) { from++; }
357
+ }
358
+ for (let i = from; i < length; i++) {
359
+ tmpItems[i]?.[$changes]?.setParentIndex(this, i);
360
+ }
361
+ }
362
+
334
363
  // encoding only. Returns the wire index the change was recorded at
335
364
  // (undefined when nothing was recorded).
336
365
  protected $changeAt(index: number, value: V): number | undefined {
@@ -460,6 +489,7 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
460
489
  self[$changes].operation(OPERATION.REVERSE);
461
490
  self.items.reverse();
462
491
  self.tmpItems.reverse();
492
+ self.$reindexChildren(0);
463
493
  return this;
464
494
  }
465
495
 
@@ -515,6 +545,7 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
515
545
  sortedItems.forEach((_, i) => changeTree.change(i, OPERATION.REPLACE));
516
546
 
517
547
  self.tmpItems.sort(compareFn);
548
+ self.$reindexChildren(0);
518
549
 
519
550
  self.isMovingItems = false;
520
551
  return this;
@@ -620,6 +651,7 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
620
651
  }
621
652
 
622
653
  self.tmpItems.unshift(...items);
654
+ self.$reindexChildren(items.length); // survivors only — the loop above placed the new items
623
655
 
624
656
  return self.items.unshift(...items);
625
657
  }
@@ -938,8 +970,15 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
938
970
 
939
971
  protected [$onEncodeEnd]() {
940
972
  const self = this[$proxyTarget] ?? this;
973
+ const staged = self.tmpItems;
941
974
  self.tmpItems = self.items.slice();
942
- self.deletedIndexes.length = 0;
975
+
976
+ if (self.deletedIndexes.length > 0) {
977
+ // compaction just closed the staged holes — everything above the
978
+ // lowest one slid down a slot
979
+ self.$reindexChildren(0, staged);
980
+ self.deletedIndexes.length = 0;
981
+ }
943
982
  }
944
983
 
945
984
  protected [$onDecodeEnd]() {