@colyseus/schema 5.0.8 → 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.
@@ -189,7 +189,16 @@ export declare class ChangeTree<T extends Ref = any> implements ChangeRecorder {
189
189
  * collection/field, which `Root.remove` does before this runs.
190
190
  */
191
191
  recycle(): void;
192
- shift(shiftIndex: number): void;
192
+ /**
193
+ * ArraySchema#unshift(): re-key pending ops on both channels by
194
+ * `+count`, then record ADDs for the new items at indexes 0..count-1.
195
+ *
196
+ * The rebuilt map's insertion order IS the wire order: new ADDs first
197
+ * (ascending — the decoder splice-inserts each one, which only works
198
+ * lowest-index-first), then prior ops in their original relative order
199
+ * at their shifted positions. See ArraySchema#$setAt.
200
+ */
201
+ unshift(count: number): void;
193
202
  setRoot(root: Root): void;
194
203
  setParent(parent: Ref, root?: Root, parentIndex?: number): void;
195
204
  forEachChild(cb: (change: ChangeTree, at: any) => void): void;
@@ -214,7 +223,6 @@ export declare class ChangeTree<T extends Ref = any> implements ChangeRecorder {
214
223
  private _routeAndRecord;
215
224
  change(index: number, operation?: OPERATION): void;
216
225
  indexedOperation(index: number, operation: OPERATION): void;
217
- shiftChangeIndexes(shiftIndex: number): void;
218
226
  getChange(index: number): OPERATION;
219
227
  pause(): void;
220
228
  resume(): void;
package/build/index.cjs CHANGED
@@ -2950,14 +2950,31 @@ class ChangeTree {
2950
2950
  this.tagViews = undefined;
2951
2951
  this.subscribedViews = undefined;
2952
2952
  }
2953
- shift(shiftIndex) {
2953
+ /**
2954
+ * ArraySchema#unshift(): re-key pending ops on both channels by
2955
+ * `+count`, then record ADDs for the new items at indexes 0..count-1.
2956
+ *
2957
+ * The rebuilt map's insertion order IS the wire order: new ADDs first
2958
+ * (ascending — the decoder splice-inserts each one, which only works
2959
+ * lowest-index-first), then prior ops in their original relative order
2960
+ * at their shifted positions. See ArraySchema#$setAt.
2961
+ */
2962
+ unshift(count) {
2954
2963
  if (this._isSchema)
2955
- throw new Error("ChangeTree (Schema): shift is not supported");
2964
+ throw new Error("ChangeTree (Schema): unshift is not supported");
2956
2965
  const src = this.collDirty;
2957
2966
  const dst = new Map();
2967
+ const track = !this.paused && !this.isStatic;
2968
+ if (track) {
2969
+ for (let i = 0; i < count; i++)
2970
+ dst.set(i, exports.OPERATION.ADD);
2971
+ }
2958
2972
  for (const [idx, val] of src)
2959
- dst.set(idx + shiftIndex, val);
2973
+ dst.set(idx + count, val);
2960
2974
  this.collDirty = dst;
2975
+ this.unreliableRecorder?.shift(count);
2976
+ if (track)
2977
+ this.root?.enqueueChangeTree(this);
2961
2978
  }
2962
2979
  // Tree attachment + child iteration — see ./changeTree/treeAttachment.ts.
2963
2980
  setRoot(root) { setRoot(this, root); }
@@ -3026,12 +3043,6 @@ class ChangeTree {
3026
3043
  indexedOperation(index, operation) {
3027
3044
  this._routeAndRecord(index, operation, true);
3028
3045
  }
3029
- // ArraySchema#unshift(): apply shift to both channels.
3030
- // Unreliable recorder on an array is always a CollectionChangeRecorder.
3031
- shiftChangeIndexes(shiftIndex) {
3032
- this.shift(shiftIndex);
3033
- this.unreliableRecorder?.shift(shiftIndex);
3034
- }
3035
3046
  getChange(index) {
3036
3047
  return this.operationAt(index);
3037
3048
  }
@@ -3372,7 +3383,8 @@ const encodeArray = function (encoder, bytes, changeTree, field, operation, it,
3372
3383
  // ArraySchema stores its per-instance child type at `$childType`.
3373
3384
  // This encoder is array-only — there's no Schema fallback to consider.
3374
3385
  const type = ref[$childType];
3375
- const useOperationByRefId = hasView && changeTree.isFiltered && typeof type !== "string";
3386
+ const isSchemaChild = typeof type !== "string";
3387
+ const useOperationByRefId = hasView && changeTree.isFiltered && isSchemaChild;
3376
3388
  let refOrIndex;
3377
3389
  if (useOperationByRefId) {
3378
3390
  const item = ref.tmpItems[field];
@@ -3388,6 +3400,20 @@ const encodeArray = function (encoder, bytes, changeTree, field, operation, it,
3388
3400
  operation = exports.OPERATION.ADD_BY_REFID;
3389
3401
  }
3390
3402
  }
3403
+ else if (operation === exports.OPERATION.DELETE && isSchemaChild) {
3404
+ //
3405
+ // DELETE by identity: idempotent, so a stale positional DELETE
3406
+ // (pending in the shared queue when a client bootstraps via
3407
+ // encodeAll) can't corrupt that client — its snapshot no longer
3408
+ // holds the item, the refId is unknown, and the decoder skips.
3409
+ //
3410
+ const item = ref.tmpItems[field];
3411
+ if (!item) {
3412
+ return;
3413
+ }
3414
+ refOrIndex = item[$refId];
3415
+ operation = exports.OPERATION.DELETE_BY_REFID;
3416
+ }
3391
3417
  else {
3392
3418
  refOrIndex = field;
3393
3419
  }
@@ -3779,7 +3805,10 @@ const decodeKeyValueOperation = function (decoder, bytes, it, ref, allChanges) {
3779
3805
  tgt.$items.set(dynamicIndex, value);
3780
3806
  break;
3781
3807
  case CollectionKind.Array:
3782
- tgt.$setAt(index, value, operation);
3808
+ // resync snapshot ADDs are positional overwrites, not inserts
3809
+ tgt.$setAt(index, value, (decoder.resyncVisited !== null && operation === exports.OPERATION.ADD)
3810
+ ? exports.OPERATION.REPLACE
3811
+ : operation);
3783
3812
  break;
3784
3813
  // SetSchema / CollectionSchema / StreamSchema — use the wire-
3785
3814
  // index we decoded above so server/client `$items` stay in sync
@@ -3846,9 +3875,13 @@ const decodeArray = function (decoder, bytes, it, ref, allChanges) {
3846
3875
  return;
3847
3876
  }
3848
3877
  else if (operation === exports.OPERATION.DELETE_BY_REFID) {
3849
- // TODO: refactor here, try to follow same flow as below
3850
3878
  const refId = decode.number(bytes, it);
3851
3879
  const previousValue = decoder.root.refs.get(refId);
3880
+ // Stale DELETE — refId unknown to this decoder (e.g. it
3881
+ // bootstrapped via encodeAll after the item was already removed).
3882
+ if (previousValue === undefined) {
3883
+ return;
3884
+ }
3852
3885
  // Decrement the removed child's ref-count so it can be garbage
3853
3886
  // collected — this refId-based branch returns early and never reaches
3854
3887
  // decodeValue(), so it must do the same DELETE bookkeeping itself.
@@ -3856,10 +3889,13 @@ const decodeArray = function (decoder, bytes, it, ref, allChanges) {
3856
3889
  // different type → "field not defined" / "definition mismatch"
3857
3890
  // (surfaces under StateView when a filtered ArraySchema element is
3858
3891
  // spliced while its parent moves through view membership churn).
3859
- if (previousValue !== undefined) {
3860
- decoder.root.removeRef(refId);
3861
- }
3892
+ // Must run even when the item is absent from THIS array (view churn).
3893
+ decoder.root.removeRef(refId);
3862
3894
  index = tgt.findIndex((value) => value === previousValue);
3895
+ // Item not present in this decoder's array — nothing to remove locally.
3896
+ if (index === -1) {
3897
+ return;
3898
+ }
3863
3899
  tgt[$deleteByIndex](index);
3864
3900
  allChanges?.push({
3865
3901
  ref,
@@ -3900,9 +3936,12 @@ const decodeArray = function (decoder, bytes, it, ref, allChanges) {
3900
3936
  resyncTouchEntry(decoder, ref, operation, index, previousValue, value, allChanges);
3901
3937
  }
3902
3938
  if (value !== null && value !== undefined &&
3903
- value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
3939
+ value !== previousValue // avoid setting same value twice (an ADD at an occupied index would splice-insert)
3904
3940
  ) {
3905
- tgt.$setAt(index, value, operation);
3941
+ // resync snapshot ADDs are positional overwrites, not inserts
3942
+ tgt.$setAt(index, value, (decoder.resyncVisited !== null && operation === exports.OPERATION.ADD)
3943
+ ? exports.OPERATION.REPLACE
3944
+ : operation);
3906
3945
  }
3907
3946
  // add change
3908
3947
  if (previousValue !== value) {
@@ -3959,30 +3998,35 @@ const ARRAY_PROXY_HANDLER = {
3959
3998
  obj.$deleteAt(key);
3960
3999
  }
3961
4000
  else {
4001
+ // wire slot the write was recorded at; undefined = nothing
4002
+ // recorded (same value / skipped) — must NOT touch tmpItems
4003
+ // then, or a same-tick shifted layout gets clobbered.
4004
+ let wireIndex;
3962
4005
  if (setValue[$changes]) {
3963
4006
  assertInstanceType(setValue, obj[$childType], obj, key);
3964
4007
  const previousValue = obj.items[key];
3965
4008
  if (!obj.isMovingItems) {
3966
- obj.$changeAt(Number(key), setValue);
4009
+ wireIndex = obj.$changeAt(Number(key), setValue);
3967
4010
  }
3968
4011
  else {
4012
+ wireIndex = obj.$wireIndex(Number(key));
3969
4013
  if (previousValue !== undefined) {
3970
4014
  if (setValue[$changes].isNew) {
3971
- obj[$changes].indexedOperation(Number(key), exports.OPERATION.MOVE_AND_ADD);
4015
+ obj[$changes].indexedOperation(wireIndex, exports.OPERATION.MOVE_AND_ADD);
3972
4016
  }
3973
4017
  else {
3974
- if ((obj[$changes].getChange(Number(key)) & exports.OPERATION.DELETE) === exports.OPERATION.DELETE) {
3975
- obj[$changes].indexedOperation(Number(key), exports.OPERATION.DELETE_AND_MOVE);
4018
+ if ((obj[$changes].getChange(wireIndex) & exports.OPERATION.DELETE) === exports.OPERATION.DELETE) {
4019
+ obj[$changes].indexedOperation(wireIndex, exports.OPERATION.DELETE_AND_MOVE);
3976
4020
  }
3977
4021
  else {
3978
- obj[$changes].indexedOperation(Number(key), exports.OPERATION.MOVE);
4022
+ obj[$changes].indexedOperation(wireIndex, exports.OPERATION.MOVE);
3979
4023
  }
3980
4024
  }
3981
4025
  }
3982
4026
  else if (setValue[$changes].isNew) {
3983
- obj[$changes].indexedOperation(Number(key), exports.OPERATION.ADD);
4027
+ obj[$changes].indexedOperation(wireIndex, exports.OPERATION.ADD);
3984
4028
  }
3985
- setValue[$changes].setParent(obj, obj[$changes].root, key);
4029
+ setValue[$changes].setParent(obj, obj[$changes].root, wireIndex);
3986
4030
  }
3987
4031
  if (previousValue !== undefined) {
3988
4032
  // remove root reference from previous value
@@ -3990,10 +4034,12 @@ const ARRAY_PROXY_HANDLER = {
3990
4034
  }
3991
4035
  }
3992
4036
  else {
3993
- obj.$changeAt(Number(key), setValue);
4037
+ wireIndex = obj.$changeAt(Number(key), setValue);
3994
4038
  }
3995
4039
  obj.items[key] = setValue;
3996
- obj.tmpItems[key] = setValue;
4040
+ if (wireIndex !== undefined) {
4041
+ obj.tmpItems[wireIndex] = setValue;
4042
+ }
3997
4043
  }
3998
4044
  return true;
3999
4045
  }
@@ -4176,40 +4222,68 @@ class ArraySchema {
4176
4222
  index += this.length;
4177
4223
  return this.items[index];
4178
4224
  }
4179
- // encoding only
4225
+ /**
4226
+ * items-index → wire (tmpItems) index. Identity while no deletions are
4227
+ * staged this tick; otherwise maps to the index-th live (non-deleted)
4228
+ * tmpItems slot — the same live-index walk `splice()` uses. Without the
4229
+ * translation, index writes recorded after a same-tick `shift()`/`splice()`
4230
+ * land on the wrong wire slots.
4231
+ */
4232
+ $wireIndex(index) {
4233
+ const deletedIndexes = this.deletedIndexes;
4234
+ if (deletedIndexes.length === 0) {
4235
+ return index;
4236
+ }
4237
+ const tmpItems = this.tmpItems;
4238
+ let live = 0;
4239
+ for (let i = 0; i < tmpItems.length; i++) {
4240
+ if (deletedIndexes[i] !== true) {
4241
+ if (live === index) {
4242
+ return i;
4243
+ }
4244
+ live++;
4245
+ }
4246
+ }
4247
+ // beyond the live range: appends land after the staged tmpItems tail
4248
+ return tmpItems.length + (index - live);
4249
+ }
4250
+ // encoding only. Returns the wire index the change was recorded at
4251
+ // (undefined when nothing was recorded).
4180
4252
  $changeAt(index, value) {
4181
4253
  if (value === undefined || value === null) {
4182
- console.error("ArraySchema items cannot be null nor undefined; Use `deleteAt(index)` instead.");
4183
- return;
4254
+ console.error("ArraySchema items cannot be null nor undefined; Use `splice(index, 1)` instead.");
4255
+ return undefined;
4184
4256
  }
4185
4257
  // skip if the value is the same as cached.
4186
4258
  if (this.items[index] === value) {
4187
- return;
4259
+ return undefined;
4188
4260
  }
4189
4261
  const operation = (this.items[index] !== undefined)
4190
4262
  ? typeof (value) === "object"
4191
4263
  ? exports.OPERATION.DELETE_AND_ADD // schema child
4192
4264
  : exports.OPERATION.REPLACE // primitive
4193
4265
  : exports.OPERATION.ADD;
4266
+ const wireIndex = this.$wireIndex(index);
4194
4267
  const changeTree = this[$changes];
4195
- changeTree.change(index, operation);
4268
+ changeTree.change(wireIndex, operation);
4196
4269
  //
4197
4270
  // set value's parent after the value is set
4198
4271
  // (to avoid encoding "refId" operations before parent's "ADD" operation)
4199
4272
  //
4200
- value[$changes]?.setParent(this, changeTree.root, index);
4273
+ value[$changes]?.setParent(this, changeTree.root, wireIndex);
4274
+ return wireIndex;
4201
4275
  }
4202
4276
  // encoding only
4203
4277
  $deleteAt(index, operation) {
4204
- this[$changes].delete(index, operation);
4278
+ this[$changes].delete(this.$wireIndex(index), operation);
4205
4279
  }
4206
4280
  // decoding only
4207
4281
  $setAt(index, value, operation) {
4208
- if (index === 0 &&
4209
- operation === exports.OPERATION.ADD &&
4282
+ if (operation === exports.OPERATION.ADD &&
4210
4283
  this.items[index] !== undefined) {
4211
- // handle decoding unshift
4212
- this.items.unshift(value);
4284
+ // ADD at an occupied index = insert (unshift / splice-insert):
4285
+ // shift existing items up instead of overwriting.
4286
+ this.items.splice(index, 0, value);
4213
4287
  }
4214
4288
  else if (operation === exports.OPERATION.DELETE_AND_MOVE) {
4215
4289
  this.items.splice(index, 1);
@@ -4296,10 +4370,16 @@ class ArraySchema {
4296
4370
  return undefined;
4297
4371
  }
4298
4372
  const changeTree = self[$changes];
4299
- const first = items[0];
4300
- const index = self.tmpItems.findIndex(item => item === first);
4373
+ // items[0] ≡ first live (non-deleted) tmpItems slot. Value-based
4374
+ // findIndex is unsafe here: same-tick index writes can duplicate a
4375
+ // value across tmp slots and resolve the wrong one.
4376
+ const deletedIndexes = self.deletedIndexes;
4377
+ let index = 0;
4378
+ while (deletedIndexes[index] === true) {
4379
+ index++;
4380
+ }
4301
4381
  changeTree.delete(index, exports.OPERATION.DELETE);
4302
- self.deletedIndexes[index] = true;
4382
+ deletedIndexes[index] = true;
4303
4383
  return items.shift();
4304
4384
  }
4305
4385
  /**
@@ -4397,13 +4477,18 @@ class ArraySchema {
4397
4477
  unshift(...items) {
4398
4478
  const self = this[$proxyTarget];
4399
4479
  const changeTree = self[$changes];
4400
- // Existing items shift up `shiftChangeIndexes` handles their
4401
- // relocation bookkeeping. The prepended `items` are genuinely new
4402
- // (no prior existence to MOVE), so each records an ADD.
4403
- changeTree.shiftChangeIndexes(items.length);
4404
- items.forEach((_, index) => {
4405
- changeTree.change(index, exports.OPERATION.ADD);
4406
- });
4480
+ // single recorder op: shifts pending indexes up and records the new
4481
+ // ADDs lowest-first (the decoder splice-inserts in ascending order).
4482
+ changeTree.unshift(items.length);
4483
+ // attach ref-type items — parent set AFTER recording, as in $changeAt
4484
+ for (let i = 0; i < items.length; i++) {
4485
+ items[i]?.[$changes]?.setParent(this, changeTree.root, i);
4486
+ }
4487
+ // keep staged-delete flags aligned with the prepended tmp slots
4488
+ const deletedIndexes = self.deletedIndexes;
4489
+ if (deletedIndexes.length > 0) {
4490
+ deletedIndexes.unshift(...new Array(items.length).fill(false));
4491
+ }
4407
4492
  self.tmpItems.unshift(...items);
4408
4493
  return self.items.unshift(...items);
4409
4494
  }
@@ -5022,6 +5107,37 @@ class MapSchema {
5022
5107
  get(key) {
5023
5108
  return this.$items.get(key);
5024
5109
  }
5110
+ /**
5111
+ * Returns the value for `key` if present. Otherwise inserts `defaultValue`
5112
+ * (tracked as an ADD change, like `set()`) and returns it.
5113
+ *
5114
+ * Mirrors `Map.prototype.getOrInsert` (TC39 "upsert" proposal, typed in
5115
+ * TypeScript 6's standard library).
5116
+ */
5117
+ getOrInsert(key, defaultValue) {
5118
+ if (this.$items.has(key)) {
5119
+ return this.$items.get(key);
5120
+ }
5121
+ this.set(key, defaultValue);
5122
+ return defaultValue;
5123
+ }
5124
+ /**
5125
+ * Returns the value for `key` if present. Otherwise computes a value via
5126
+ * `callbackfn(key)`, inserts it (tracked as an ADD change, like `set()`)
5127
+ * and returns it. The callback is only invoked when the key is missing.
5128
+ *
5129
+ * Mirrors `Map.prototype.getOrInsertComputed` (TC39 "upsert" proposal,
5130
+ * typed in TypeScript 6's standard library).
5131
+ */
5132
+ getOrInsertComputed(key, callbackfn) {
5133
+ if (this.$items.has(key)) {
5134
+ return this.$items.get(key);
5135
+ }
5136
+ const value = callbackfn(key);
5137
+ // per spec: overwrites even if callbackfn itself inserted `key`
5138
+ this.set(key, value);
5139
+ return value;
5140
+ }
5025
5141
  delete(key) {
5026
5142
  if (!this.$items.has(key)) {
5027
5143
  return false;
@@ -6747,6 +6863,24 @@ function deprecated(throws = true) {
6747
6863
  });
6748
6864
  };
6749
6865
  }
6866
+ let defineTypesWarned = false;
6867
+ /**
6868
+ * Adds synchronizable fields to an existing `Schema` subclass — the pre-5.0
6869
+ * helper for plain JavaScript users.
6870
+ *
6871
+ * @deprecated Use `schema()` with `t.*` field builders instead:
6872
+ * https://docs.colyseus.io/state/schema
6873
+ */
6874
+ function defineTypes(target, fields, options) {
6875
+ if (!defineTypesWarned) {
6876
+ defineTypesWarned = true;
6877
+ console.warn("@colyseus/schema: defineTypes() is deprecated and will be removed in a future release. Use schema() with t.* field builders instead → https://docs.colyseus.io/state/schema");
6878
+ }
6879
+ for (let field in fields) {
6880
+ type(fields[field], options)(target.prototype, field);
6881
+ }
6882
+ return target;
6883
+ }
6750
6884
  /**
6751
6885
  * Build a per-construction factory for a builder type's auto-instantiated
6752
6886
  * default (empty collection or zero-arg Schema ref), or `undefined` when the
@@ -10609,6 +10743,7 @@ exports.decode = decode;
10609
10743
  exports.decodeKeyValueOperation = decodeKeyValueOperation;
10610
10744
  exports.decodeSchemaOperation = decodeSchemaOperation;
10611
10745
  exports.defineCustomTypes = defineCustomTypes;
10746
+ exports.defineTypes = defineTypes;
10612
10747
  exports.deprecated = deprecated;
10613
10748
  exports.dumpChanges = dumpChanges;
10614
10749
  exports.encode = encode;