@colyseus/schema 5.0.9 → 5.0.10

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.
@@ -55,7 +55,15 @@ export declare class ArraySchema<V = any> implements Array<V>, Collection<number
55
55
  */
56
56
  pop(): V | undefined;
57
57
  at(index: number): V;
58
- protected $changeAt(index: number, value: V): void;
58
+ /**
59
+ * items-index → wire (tmpItems) index. Identity while no deletions are
60
+ * staged this tick; otherwise maps to the index-th live (non-deleted)
61
+ * tmpItems slot — the same live-index walk `splice()` uses. Without the
62
+ * translation, index writes recorded after a same-tick `shift()`/`splice()`
63
+ * land on the wrong wire slots.
64
+ */
65
+ protected $wireIndex(index: number): number;
66
+ protected $changeAt(index: number, value: V): number | undefined;
59
67
  protected $deleteAt(index: number, operation?: OPERATION): void;
60
68
  protected $setAt(index: number, value: V, operation: OPERATION): void;
61
69
  clear(): void;
@@ -71,11 +71,28 @@ export declare class MapSchema<V = any, K extends string = string> implements Ma
71
71
  */
72
72
  static initializeForDecoder<V = any, K extends string = string>(): MapSchema<V, K>;
73
73
  /** Iterator */
74
- [Symbol.iterator](): IterableIterator<[K, V]>;
74
+ [Symbol.iterator](): ReturnType<Map<K, V>[typeof Symbol.iterator]>;
75
75
  get [Symbol.toStringTag](): string;
76
76
  static get [Symbol.species](): typeof MapSchema;
77
77
  set(key: K, value: V): this;
78
78
  get(key: K): V | undefined;
79
+ /**
80
+ * Returns the value for `key` if present. Otherwise inserts `defaultValue`
81
+ * (tracked as an ADD change, like `set()`) and returns it.
82
+ *
83
+ * Mirrors `Map.prototype.getOrInsert` (TC39 "upsert" proposal, typed in
84
+ * TypeScript 6's standard library).
85
+ */
86
+ getOrInsert(key: K, defaultValue: V): V;
87
+ /**
88
+ * Returns the value for `key` if present. Otherwise computes a value via
89
+ * `callbackfn(key)`, inserts it (tracked as an ADD change, like `set()`)
90
+ * and returns it. The callback is only invoked when the key is missing.
91
+ *
92
+ * Mirrors `Map.prototype.getOrInsertComputed` (TC39 "upsert" proposal,
93
+ * typed in TypeScript 6's standard library).
94
+ */
95
+ getOrInsertComputed(key: K, callbackfn: (key: K) => V): V;
79
96
  delete(key: K): boolean;
80
97
  clear(): void;
81
98
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/schema",
3
- "version": "5.0.9",
3
+ "version": "5.0.10",
4
4
  "description": "Binary state serializer with delta encoding for games",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,8 @@
17
17
  "build": "tsc -p tsconfig.build.json && rollup -c rollup.config.mjs",
18
18
  "watch": "tsc -p tsconfig.build.json -w",
19
19
  "typecheck:build": "tsc -p tsconfig.build.json --noEmit",
20
- "test": "tsx --tsconfig tsconfig.test.json ./node_modules/mocha/bin/mocha.js \"test/*.test.ts\" \"test/**/*.test.ts\"",
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
22
  "test:exports": "node test/verify-exports.mjs",
22
23
  "typecheck": "tsc -p tsconfig.test.json",
23
24
  "coverage": "c8 npm run test",
@@ -96,7 +97,12 @@
96
97
  "typescript": "^5.9.3"
97
98
  },
98
99
  "peerDependencies": {
99
- "typescript": "^5.0.0 || ^6.0.0"
100
+ "typescript": ">=5.0.0"
101
+ },
102
+ "peerDependenciesMeta": {
103
+ "typescript": {
104
+ "optional": true
105
+ }
100
106
  },
101
107
  "c8": {
102
108
  "include": [
@@ -1,7 +1,7 @@
1
1
  import argv from "./argv.js";
2
2
  import { generate, generators } from "./api.js";
3
3
 
4
- function displayHelp() {
4
+ function displayHelp(exitCode: number = 0) {
5
5
  console.log(`\nschema-codegen [path/to/Schema.ts]
6
6
 
7
7
  Usage (C#/Unity)
@@ -22,7 +22,7 @@ ${Object.
22
22
  Optional:
23
23
  --namespace: generate namespace on output code
24
24
  --decorator: custom name for @type decorator to scan for`);
25
- process.exit();
25
+ process.exit(exitCode);
26
26
  }
27
27
 
28
28
  const args = argv(process.argv.slice(2));
@@ -39,7 +39,7 @@ for (let target in generators) {
39
39
 
40
40
  if (!args.output) {
41
41
  console.error("You must provide a valid --output directory.");
42
- displayHelp();
42
+ displayHelp(1);
43
43
  }
44
44
 
45
45
  try {
@@ -55,5 +55,5 @@ try {
55
55
  } catch (e) {
56
56
  console.error(e.message);
57
57
  console.error(e.stack);
58
- displayHelp();
58
+ displayHelp(1);
59
59
  }
@@ -419,6 +419,14 @@ export function parseFiles(
419
419
  decoratorName: string = "type",
420
420
  context: Context = new Context()
421
421
  ) {
422
+ if (typeof ts.createSourceFile !== "function") {
423
+ // typescript@7+ (native) no longer ships the JS compiler API
424
+ throw new Error(
425
+ `schema-codegen requires the TypeScript compiler API, which the installed "typescript@${(ts as any).version}" package does not provide.\n` +
426
+ `TypeScript 7+ no longer ships the JS compiler API — install typescript 5.x or 6.x (e.g. \`npm install --save-dev typescript@6\`) to use schema-codegen.`
427
+ );
428
+ }
429
+
422
430
  /**
423
431
  * Re-set globalContext for each test case
424
432
  */
@@ -469,7 +477,8 @@ export function parseFiles(
469
477
 
470
478
  break;
471
479
  } catch (e) {
472
- // console.log(`${fileNameAlternatives[i]} => ${e.message}`);
480
+ // only swallow fs errors (ENOENT/EISDIR) while probing alternatives
481
+ if (!e?.code) { throw e; }
473
482
  }
474
483
  }
475
484
 
@@ -349,7 +349,11 @@ export const decodeKeyValueOperation: DecodeOperation = function (
349
349
  break;
350
350
 
351
351
  case CollectionKind.Array:
352
- tgt.$setAt(index, value, operation);
352
+ // resync snapshot ADDs are positional overwrites, not inserts
353
+ tgt.$setAt(index, value,
354
+ (decoder.resyncVisited !== null && operation === OPERATION.ADD)
355
+ ? OPERATION.REPLACE
356
+ : operation);
353
357
  break;
354
358
 
355
359
  // SetSchema / CollectionSchema / StreamSchema — use the wire-
@@ -430,9 +434,13 @@ export const decodeArray: DecodeOperation = function (
430
434
  return;
431
435
 
432
436
  } else if (operation === OPERATION.DELETE_BY_REFID) {
433
- // TODO: refactor here, try to follow same flow as below
434
437
  const refId = decode.number(bytes, it);
435
438
  const previousValue = decoder.root.refs.get(refId);
439
+
440
+ // Stale DELETE — refId unknown to this decoder (e.g. it
441
+ // bootstrapped via encodeAll after the item was already removed).
442
+ if (previousValue === undefined) { return; }
443
+
436
444
  // Decrement the removed child's ref-count so it can be garbage
437
445
  // collected — this refId-based branch returns early and never reaches
438
446
  // decodeValue(), so it must do the same DELETE bookkeeping itself.
@@ -440,10 +448,14 @@ export const decodeArray: DecodeOperation = function (
440
448
  // different type → "field not defined" / "definition mismatch"
441
449
  // (surfaces under StateView when a filtered ArraySchema element is
442
450
  // spliced while its parent moves through view membership churn).
443
- if (previousValue !== undefined) {
444
- decoder.root.removeRef(refId);
445
- }
451
+ // Must run even when the item is absent from THIS array (view churn).
452
+ decoder.root.removeRef(refId);
453
+
446
454
  index = tgt.findIndex((value: any) => value === previousValue);
455
+
456
+ // Item not present in this decoder's array — nothing to remove locally.
457
+ if (index === -1) { return; }
458
+
447
459
  tgt[$deleteByIndex](index);
448
460
  allChanges?.push({
449
461
  ref,
@@ -503,9 +515,13 @@ export const decodeArray: DecodeOperation = function (
503
515
 
504
516
  if (
505
517
  value !== null && value !== undefined &&
506
- value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
518
+ value !== previousValue // avoid setting same value twice (an ADD at an occupied index would splice-insert)
507
519
  ) {
508
- tgt.$setAt(index, value, operation);
520
+ // resync snapshot ADDs are positional overwrites, not inserts
521
+ tgt.$setAt(index, value,
522
+ (decoder.resyncVisited !== null && operation === OPERATION.ADD)
523
+ ? OPERATION.REPLACE
524
+ : operation);
509
525
  }
510
526
 
511
527
  // add change
@@ -591,12 +591,27 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
591
591
  this.subscribedViews = undefined;
592
592
  }
593
593
 
594
- shift(shiftIndex: number): void {
595
- if (this._isSchema) throw new Error("ChangeTree (Schema): shift is not supported");
594
+ /**
595
+ * ArraySchema#unshift(): re-key pending ops on both channels by
596
+ * `+count`, then record ADDs for the new items at indexes 0..count-1.
597
+ *
598
+ * The rebuilt map's insertion order IS the wire order: new ADDs first
599
+ * (ascending — the decoder splice-inserts each one, which only works
600
+ * lowest-index-first), then prior ops in their original relative order
601
+ * at their shifted positions. See ArraySchema#$setAt.
602
+ */
603
+ unshift(count: number): void {
604
+ if (this._isSchema) throw new Error("ChangeTree (Schema): unshift is not supported");
596
605
  const src = this.collDirty!;
597
606
  const dst = new Map<number, OPERATION>();
598
- for (const [idx, val] of src) dst.set(idx + shiftIndex, val);
607
+ const track = !this.paused && !this.isStatic;
608
+ if (track) {
609
+ for (let i = 0; i < count; i++) dst.set(i, OPERATION.ADD);
610
+ }
611
+ for (const [idx, val] of src) dst.set(idx + count, val);
599
612
  this.collDirty = dst;
613
+ (this.unreliableRecorder as ICollectionChangeRecorder | undefined)?.shift(count);
614
+ if (track) this.root?.enqueueChangeTree(this);
600
615
  }
601
616
 
602
617
  // Tree attachment + child iteration — see ./changeTree/treeAttachment.ts.
@@ -665,13 +680,6 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
665
680
  this._routeAndRecord(index, operation, true);
666
681
  }
667
682
 
668
- // ArraySchema#unshift(): apply shift to both channels.
669
- // Unreliable recorder on an array is always a CollectionChangeRecorder.
670
- shiftChangeIndexes(shiftIndex: number) {
671
- this.shift(shiftIndex);
672
- (this.unreliableRecorder as ICollectionChangeRecorder | undefined)?.shift(shiftIndex);
673
- }
674
-
675
683
  getChange(index: number) {
676
684
  return this.operationAt(index);
677
685
  }
@@ -224,7 +224,8 @@ export const encodeArray: EncodeOperation = function (
224
224
  // ArraySchema stores its per-instance child type at `$childType`.
225
225
  // This encoder is array-only — there's no Schema fallback to consider.
226
226
  const type = ref[$childType];
227
- const useOperationByRefId = hasView && changeTree.isFiltered && typeof type !== "string";
227
+ const isSchemaChild = typeof type !== "string";
228
+ const useOperationByRefId = hasView && changeTree.isFiltered && isSchemaChild;
228
229
 
229
230
  let refOrIndex: number;
230
231
 
@@ -243,6 +244,18 @@ export const encodeArray: EncodeOperation = function (
243
244
  operation = OPERATION.ADD_BY_REFID;
244
245
  }
245
246
 
247
+ } else if (operation === OPERATION.DELETE && isSchemaChild) {
248
+ //
249
+ // DELETE by identity: idempotent, so a stale positional DELETE
250
+ // (pending in the shared queue when a client bootstraps via
251
+ // encodeAll) can't corrupt that client — its snapshot no longer
252
+ // holds the item, the refId is unknown, and the decoder skips.
253
+ //
254
+ const item = ref.tmpItems[field];
255
+ if (!item) { return; }
256
+ refOrIndex = item[$refId];
257
+ operation = OPERATION.DELETE_BY_REFID;
258
+
246
259
  } else {
247
260
  refOrIndex = field;
248
261
  }
@@ -44,33 +44,40 @@ const ARRAY_PROXY_HANDLER: ProxyHandler<any> = {
44
44
  obj.$deleteAt(key as unknown as number);
45
45
 
46
46
  } else {
47
+ // wire slot the write was recorded at; undefined = nothing
48
+ // recorded (same value / skipped) — must NOT touch tmpItems
49
+ // then, or a same-tick shifted layout gets clobbered.
50
+ let wireIndex: number | undefined;
51
+
47
52
  if (setValue[$changes]) {
48
53
  assertInstanceType(setValue, obj[$childType] as typeof Schema, obj, key);
49
54
 
50
55
  const previousValue = obj.items[key as unknown as number];
51
56
 
52
57
  if (!obj.isMovingItems) {
53
- obj.$changeAt(Number(key), setValue);
58
+ wireIndex = obj.$changeAt(Number(key), setValue);
54
59
 
55
60
  } else {
61
+ wireIndex = obj.$wireIndex(Number(key));
62
+
56
63
  if (previousValue !== undefined) {
57
64
  if (setValue[$changes].isNew) {
58
- obj[$changes].indexedOperation(Number(key), OPERATION.MOVE_AND_ADD);
65
+ obj[$changes].indexedOperation(wireIndex, OPERATION.MOVE_AND_ADD);
59
66
 
60
67
  } else {
61
- if ((obj[$changes].getChange(Number(key)) & OPERATION.DELETE) === OPERATION.DELETE) {
62
- obj[$changes].indexedOperation(Number(key), OPERATION.DELETE_AND_MOVE);
68
+ if ((obj[$changes].getChange(wireIndex) & OPERATION.DELETE) === OPERATION.DELETE) {
69
+ obj[$changes].indexedOperation(wireIndex, OPERATION.DELETE_AND_MOVE);
63
70
 
64
71
  } else {
65
- obj[$changes].indexedOperation(Number(key), OPERATION.MOVE);
72
+ obj[$changes].indexedOperation(wireIndex, OPERATION.MOVE);
66
73
  }
67
74
  }
68
75
 
69
76
  } else if (setValue[$changes].isNew) {
70
- obj[$changes].indexedOperation(Number(key), OPERATION.ADD);
77
+ obj[$changes].indexedOperation(wireIndex, OPERATION.ADD);
71
78
  }
72
79
 
73
- setValue[$changes].setParent(obj, obj[$changes].root, key);
80
+ setValue[$changes].setParent(obj, obj[$changes].root, wireIndex);
74
81
  }
75
82
 
76
83
  if (previousValue !== undefined) {
@@ -79,11 +86,13 @@ const ARRAY_PROXY_HANDLER: ProxyHandler<any> = {
79
86
  }
80
87
 
81
88
  } else {
82
- obj.$changeAt(Number(key), setValue);
89
+ wireIndex = obj.$changeAt(Number(key), setValue);
83
90
  }
84
91
 
85
92
  obj.items[key as unknown as number] = setValue;
86
- obj.tmpItems[key as unknown as number] = setValue;
93
+ if (wireIndex !== undefined) {
94
+ obj.tmpItems[wireIndex] = setValue;
95
+ }
87
96
  }
88
97
 
89
98
  return true;
@@ -300,16 +309,39 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
300
309
  return this.items[index];
301
310
  }
302
311
 
303
- // encoding only
304
- protected $changeAt(index: number, value: V) {
312
+ /**
313
+ * items-index wire (tmpItems) index. Identity while no deletions are
314
+ * staged this tick; otherwise maps to the index-th live (non-deleted)
315
+ * tmpItems slot — the same live-index walk `splice()` uses. Without the
316
+ * translation, index writes recorded after a same-tick `shift()`/`splice()`
317
+ * land on the wrong wire slots.
318
+ */
319
+ protected $wireIndex(index: number): number {
320
+ const deletedIndexes = this.deletedIndexes;
321
+ if (deletedIndexes.length === 0) { return index; }
322
+ const tmpItems = this.tmpItems;
323
+ let live = 0;
324
+ for (let i = 0; i < tmpItems.length; i++) {
325
+ if (deletedIndexes[i] !== true) {
326
+ if (live === index) { return i; }
327
+ live++;
328
+ }
329
+ }
330
+ // beyond the live range: appends land after the staged tmpItems tail
331
+ return tmpItems.length + (index - live);
332
+ }
333
+
334
+ // encoding only. Returns the wire index the change was recorded at
335
+ // (undefined when nothing was recorded).
336
+ protected $changeAt(index: number, value: V): number | undefined {
305
337
  if (value === undefined || value === null) {
306
- console.error("ArraySchema items cannot be null nor undefined; Use `deleteAt(index)` instead.");
307
- return;
338
+ console.error("ArraySchema items cannot be null nor undefined; Use `splice(index, 1)` instead.");
339
+ return undefined;
308
340
  }
309
341
 
310
342
  // skip if the value is the same as cached.
311
343
  if (this.items[index] === value) {
312
- return;
344
+ return undefined;
313
345
  }
314
346
 
315
347
  const operation = (this.items[index] !== undefined)
@@ -318,30 +350,34 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
318
350
  : OPERATION.REPLACE // primitive
319
351
  : OPERATION.ADD;
320
352
 
353
+ const wireIndex = this.$wireIndex(index);
354
+
321
355
  const changeTree = this[$changes];
322
- changeTree.change(index, operation);
356
+ changeTree.change(wireIndex, operation);
323
357
 
324
358
  //
325
359
  // set value's parent after the value is set
326
360
  // (to avoid encoding "refId" operations before parent's "ADD" operation)
327
361
  //
328
- value[$changes]?.setParent(this, changeTree.root, index);
362
+ value[$changes]?.setParent(this, changeTree.root, wireIndex);
363
+
364
+ return wireIndex;
329
365
  }
330
366
 
331
367
  // encoding only
332
368
  protected $deleteAt(index: number, operation?: OPERATION) {
333
- this[$changes].delete(index, operation);
369
+ this[$changes].delete(this.$wireIndex(index), operation);
334
370
  }
335
371
 
336
372
  // decoding only
337
373
  protected $setAt(index: number, value: V, operation: OPERATION) {
338
374
  if (
339
- index === 0 &&
340
375
  operation === OPERATION.ADD &&
341
376
  this.items[index] !== undefined
342
377
  ) {
343
- // handle decoding unshift
344
- this.items.unshift(value);
378
+ // ADD at an occupied index = insert (unshift / splice-insert):
379
+ // shift existing items up instead of overwriting.
380
+ this.items.splice(index, 0, value);
345
381
 
346
382
  } else if (operation === OPERATION.DELETE_AND_MOVE) {
347
383
  this.items.splice(index, 1);
@@ -436,10 +472,14 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
436
472
  if (items.length === 0) { return undefined; }
437
473
 
438
474
  const changeTree = self[$changes];
439
- const first = items[0];
440
- const index = self.tmpItems.findIndex(item => item === first);
475
+ // items[0] ≡ first live (non-deleted) tmpItems slot. Value-based
476
+ // findIndex is unsafe here: same-tick index writes can duplicate a
477
+ // value across tmp slots and resolve the wrong one.
478
+ const deletedIndexes = self.deletedIndexes;
479
+ let index = 0;
480
+ while (deletedIndexes[index] === true) { index++; }
441
481
  changeTree.delete(index, OPERATION.DELETE);
442
- self.deletedIndexes[index] = true;
482
+ deletedIndexes[index] = true;
443
483
 
444
484
  return items.shift();
445
485
  }
@@ -564,13 +604,20 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
564
604
  const self = this[$proxyTarget];
565
605
  const changeTree = self[$changes];
566
606
 
567
- // Existing items shift up `shiftChangeIndexes` handles their
568
- // relocation bookkeeping. The prepended `items` are genuinely new
569
- // (no prior existence to MOVE), so each records an ADD.
570
- changeTree.shiftChangeIndexes(items.length);
571
- items.forEach((_, index) => {
572
- changeTree.change(index, OPERATION.ADD)
573
- });
607
+ // single recorder op: shifts pending indexes up and records the new
608
+ // ADDs lowest-first (the decoder splice-inserts in ascending order).
609
+ changeTree.unshift(items.length);
610
+
611
+ // attach ref-type items — parent set AFTER recording, as in $changeAt
612
+ for (let i = 0; i < items.length; i++) {
613
+ items[i]?.[$changes]?.setParent(this, changeTree.root, i);
614
+ }
615
+
616
+ // keep staged-delete flags aligned with the prepended tmp slots
617
+ const deletedIndexes = self.deletedIndexes;
618
+ if (deletedIndexes.length > 0) {
619
+ deletedIndexes.unshift(...new Array(items.length).fill(false));
620
+ }
574
621
 
575
622
  self.tmpItems.unshift(...items);
576
623
 
@@ -140,7 +140,7 @@ export class MapSchema<V=any, K extends string = string> implements Map<K, V>, C
140
140
  }
141
141
 
142
142
  /** Iterator */
143
- [Symbol.iterator](): IterableIterator<[K, V]> { return this.$items[Symbol.iterator](); }
143
+ [Symbol.iterator](): ReturnType<Map<K, V>[typeof Symbol.iterator]> { return this.$items[Symbol.iterator](); }
144
144
  get [Symbol.toStringTag]() { return this.$items[Symbol.toStringTag] }
145
145
 
146
146
  static get [Symbol.species]() { return MapSchema; }
@@ -224,6 +224,39 @@ export class MapSchema<V=any, K extends string = string> implements Map<K, V>, C
224
224
  return this.$items.get(key);
225
225
  }
226
226
 
227
+ /**
228
+ * Returns the value for `key` if present. Otherwise inserts `defaultValue`
229
+ * (tracked as an ADD change, like `set()`) and returns it.
230
+ *
231
+ * Mirrors `Map.prototype.getOrInsert` (TC39 "upsert" proposal, typed in
232
+ * TypeScript 6's standard library).
233
+ */
234
+ getOrInsert(key: K, defaultValue: V): V {
235
+ if (this.$items.has(key)) {
236
+ return this.$items.get(key);
237
+ }
238
+ this.set(key, defaultValue);
239
+ return defaultValue;
240
+ }
241
+
242
+ /**
243
+ * Returns the value for `key` if present. Otherwise computes a value via
244
+ * `callbackfn(key)`, inserts it (tracked as an ADD change, like `set()`)
245
+ * and returns it. The callback is only invoked when the key is missing.
246
+ *
247
+ * Mirrors `Map.prototype.getOrInsertComputed` (TC39 "upsert" proposal,
248
+ * typed in TypeScript 6's standard library).
249
+ */
250
+ getOrInsertComputed(key: K, callbackfn: (key: K) => V): V {
251
+ if (this.$items.has(key)) {
252
+ return this.$items.get(key);
253
+ }
254
+ const value = callbackfn(key);
255
+ // per spec: overwrites even if callbackfn itself inserted `key`
256
+ this.set(key, value);
257
+ return value;
258
+ }
259
+
227
260
  delete(key: K) {
228
261
  if (!this.$items.has(key)) {
229
262
  return false;