@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.
package/build/index.js CHANGED
@@ -2954,14 +2954,31 @@
2954
2954
  this.tagViews = undefined;
2955
2955
  this.subscribedViews = undefined;
2956
2956
  }
2957
- shift(shiftIndex) {
2957
+ /**
2958
+ * ArraySchema#unshift(): re-key pending ops on both channels by
2959
+ * `+count`, then record ADDs for the new items at indexes 0..count-1.
2960
+ *
2961
+ * The rebuilt map's insertion order IS the wire order: new ADDs first
2962
+ * (ascending — the decoder splice-inserts each one, which only works
2963
+ * lowest-index-first), then prior ops in their original relative order
2964
+ * at their shifted positions. See ArraySchema#$setAt.
2965
+ */
2966
+ unshift(count) {
2958
2967
  if (this._isSchema)
2959
- throw new Error("ChangeTree (Schema): shift is not supported");
2968
+ throw new Error("ChangeTree (Schema): unshift is not supported");
2960
2969
  const src = this.collDirty;
2961
2970
  const dst = new Map();
2971
+ const track = !this.paused && !this.isStatic;
2972
+ if (track) {
2973
+ for (let i = 0; i < count; i++)
2974
+ dst.set(i, exports.OPERATION.ADD);
2975
+ }
2962
2976
  for (const [idx, val] of src)
2963
- dst.set(idx + shiftIndex, val);
2977
+ dst.set(idx + count, val);
2964
2978
  this.collDirty = dst;
2979
+ this.unreliableRecorder?.shift(count);
2980
+ if (track)
2981
+ this.root?.enqueueChangeTree(this);
2965
2982
  }
2966
2983
  // Tree attachment + child iteration — see ./changeTree/treeAttachment.ts.
2967
2984
  setRoot(root) { setRoot(this, root); }
@@ -3030,12 +3047,6 @@
3030
3047
  indexedOperation(index, operation) {
3031
3048
  this._routeAndRecord(index, operation, true);
3032
3049
  }
3033
- // ArraySchema#unshift(): apply shift to both channels.
3034
- // Unreliable recorder on an array is always a CollectionChangeRecorder.
3035
- shiftChangeIndexes(shiftIndex) {
3036
- this.shift(shiftIndex);
3037
- this.unreliableRecorder?.shift(shiftIndex);
3038
- }
3039
3050
  getChange(index) {
3040
3051
  return this.operationAt(index);
3041
3052
  }
@@ -3376,7 +3387,8 @@
3376
3387
  // ArraySchema stores its per-instance child type at `$childType`.
3377
3388
  // This encoder is array-only — there's no Schema fallback to consider.
3378
3389
  const type = ref[$childType];
3379
- const useOperationByRefId = hasView && changeTree.isFiltered && typeof type !== "string";
3390
+ const isSchemaChild = typeof type !== "string";
3391
+ const useOperationByRefId = hasView && changeTree.isFiltered && isSchemaChild;
3380
3392
  let refOrIndex;
3381
3393
  if (useOperationByRefId) {
3382
3394
  const item = ref.tmpItems[field];
@@ -3392,6 +3404,20 @@
3392
3404
  operation = exports.OPERATION.ADD_BY_REFID;
3393
3405
  }
3394
3406
  }
3407
+ else if (operation === exports.OPERATION.DELETE && isSchemaChild) {
3408
+ //
3409
+ // DELETE by identity: idempotent, so a stale positional DELETE
3410
+ // (pending in the shared queue when a client bootstraps via
3411
+ // encodeAll) can't corrupt that client — its snapshot no longer
3412
+ // holds the item, the refId is unknown, and the decoder skips.
3413
+ //
3414
+ const item = ref.tmpItems[field];
3415
+ if (!item) {
3416
+ return;
3417
+ }
3418
+ refOrIndex = item[$refId];
3419
+ operation = exports.OPERATION.DELETE_BY_REFID;
3420
+ }
3395
3421
  else {
3396
3422
  refOrIndex = field;
3397
3423
  }
@@ -3783,7 +3809,10 @@
3783
3809
  tgt.$items.set(dynamicIndex, value);
3784
3810
  break;
3785
3811
  case CollectionKind.Array:
3786
- tgt.$setAt(index, value, operation);
3812
+ // resync snapshot ADDs are positional overwrites, not inserts
3813
+ tgt.$setAt(index, value, (decoder.resyncVisited !== null && operation === exports.OPERATION.ADD)
3814
+ ? exports.OPERATION.REPLACE
3815
+ : operation);
3787
3816
  break;
3788
3817
  // SetSchema / CollectionSchema / StreamSchema — use the wire-
3789
3818
  // index we decoded above so server/client `$items` stay in sync
@@ -3850,9 +3879,13 @@
3850
3879
  return;
3851
3880
  }
3852
3881
  else if (operation === exports.OPERATION.DELETE_BY_REFID) {
3853
- // TODO: refactor here, try to follow same flow as below
3854
3882
  const refId = decode.number(bytes, it);
3855
3883
  const previousValue = decoder.root.refs.get(refId);
3884
+ // Stale DELETE — refId unknown to this decoder (e.g. it
3885
+ // bootstrapped via encodeAll after the item was already removed).
3886
+ if (previousValue === undefined) {
3887
+ return;
3888
+ }
3856
3889
  // Decrement the removed child's ref-count so it can be garbage
3857
3890
  // collected — this refId-based branch returns early and never reaches
3858
3891
  // decodeValue(), so it must do the same DELETE bookkeeping itself.
@@ -3860,10 +3893,13 @@
3860
3893
  // different type → "field not defined" / "definition mismatch"
3861
3894
  // (surfaces under StateView when a filtered ArraySchema element is
3862
3895
  // spliced while its parent moves through view membership churn).
3863
- if (previousValue !== undefined) {
3864
- decoder.root.removeRef(refId);
3865
- }
3896
+ // Must run even when the item is absent from THIS array (view churn).
3897
+ decoder.root.removeRef(refId);
3866
3898
  index = tgt.findIndex((value) => value === previousValue);
3899
+ // Item not present in this decoder's array — nothing to remove locally.
3900
+ if (index === -1) {
3901
+ return;
3902
+ }
3867
3903
  tgt[$deleteByIndex](index);
3868
3904
  allChanges?.push({
3869
3905
  ref,
@@ -3904,9 +3940,12 @@
3904
3940
  resyncTouchEntry(decoder, ref, operation, index, previousValue, value, allChanges);
3905
3941
  }
3906
3942
  if (value !== null && value !== undefined &&
3907
- value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
3943
+ value !== previousValue // avoid setting same value twice (an ADD at an occupied index would splice-insert)
3908
3944
  ) {
3909
- tgt.$setAt(index, value, operation);
3945
+ // resync snapshot ADDs are positional overwrites, not inserts
3946
+ tgt.$setAt(index, value, (decoder.resyncVisited !== null && operation === exports.OPERATION.ADD)
3947
+ ? exports.OPERATION.REPLACE
3948
+ : operation);
3910
3949
  }
3911
3950
  // add change
3912
3951
  if (previousValue !== value) {
@@ -3963,30 +4002,35 @@
3963
4002
  obj.$deleteAt(key);
3964
4003
  }
3965
4004
  else {
4005
+ // wire slot the write was recorded at; undefined = nothing
4006
+ // recorded (same value / skipped) — must NOT touch tmpItems
4007
+ // then, or a same-tick shifted layout gets clobbered.
4008
+ let wireIndex;
3966
4009
  if (setValue[$changes]) {
3967
4010
  assertInstanceType(setValue, obj[$childType], obj, key);
3968
4011
  const previousValue = obj.items[key];
3969
4012
  if (!obj.isMovingItems) {
3970
- obj.$changeAt(Number(key), setValue);
4013
+ wireIndex = obj.$changeAt(Number(key), setValue);
3971
4014
  }
3972
4015
  else {
4016
+ wireIndex = obj.$wireIndex(Number(key));
3973
4017
  if (previousValue !== undefined) {
3974
4018
  if (setValue[$changes].isNew) {
3975
- obj[$changes].indexedOperation(Number(key), exports.OPERATION.MOVE_AND_ADD);
4019
+ obj[$changes].indexedOperation(wireIndex, exports.OPERATION.MOVE_AND_ADD);
3976
4020
  }
3977
4021
  else {
3978
- if ((obj[$changes].getChange(Number(key)) & exports.OPERATION.DELETE) === exports.OPERATION.DELETE) {
3979
- obj[$changes].indexedOperation(Number(key), exports.OPERATION.DELETE_AND_MOVE);
4022
+ if ((obj[$changes].getChange(wireIndex) & exports.OPERATION.DELETE) === exports.OPERATION.DELETE) {
4023
+ obj[$changes].indexedOperation(wireIndex, exports.OPERATION.DELETE_AND_MOVE);
3980
4024
  }
3981
4025
  else {
3982
- obj[$changes].indexedOperation(Number(key), exports.OPERATION.MOVE);
4026
+ obj[$changes].indexedOperation(wireIndex, exports.OPERATION.MOVE);
3983
4027
  }
3984
4028
  }
3985
4029
  }
3986
4030
  else if (setValue[$changes].isNew) {
3987
- obj[$changes].indexedOperation(Number(key), exports.OPERATION.ADD);
4031
+ obj[$changes].indexedOperation(wireIndex, exports.OPERATION.ADD);
3988
4032
  }
3989
- setValue[$changes].setParent(obj, obj[$changes].root, key);
4033
+ setValue[$changes].setParent(obj, obj[$changes].root, wireIndex);
3990
4034
  }
3991
4035
  if (previousValue !== undefined) {
3992
4036
  // remove root reference from previous value
@@ -3994,10 +4038,12 @@
3994
4038
  }
3995
4039
  }
3996
4040
  else {
3997
- obj.$changeAt(Number(key), setValue);
4041
+ wireIndex = obj.$changeAt(Number(key), setValue);
3998
4042
  }
3999
4043
  obj.items[key] = setValue;
4000
- obj.tmpItems[key] = setValue;
4044
+ if (wireIndex !== undefined) {
4045
+ obj.tmpItems[wireIndex] = setValue;
4046
+ }
4001
4047
  }
4002
4048
  return true;
4003
4049
  }
@@ -4180,40 +4226,68 @@
4180
4226
  index += this.length;
4181
4227
  return this.items[index];
4182
4228
  }
4183
- // encoding only
4229
+ /**
4230
+ * items-index → wire (tmpItems) index. Identity while no deletions are
4231
+ * staged this tick; otherwise maps to the index-th live (non-deleted)
4232
+ * tmpItems slot — the same live-index walk `splice()` uses. Without the
4233
+ * translation, index writes recorded after a same-tick `shift()`/`splice()`
4234
+ * land on the wrong wire slots.
4235
+ */
4236
+ $wireIndex(index) {
4237
+ const deletedIndexes = this.deletedIndexes;
4238
+ if (deletedIndexes.length === 0) {
4239
+ return index;
4240
+ }
4241
+ const tmpItems = this.tmpItems;
4242
+ let live = 0;
4243
+ for (let i = 0; i < tmpItems.length; i++) {
4244
+ if (deletedIndexes[i] !== true) {
4245
+ if (live === index) {
4246
+ return i;
4247
+ }
4248
+ live++;
4249
+ }
4250
+ }
4251
+ // beyond the live range: appends land after the staged tmpItems tail
4252
+ return tmpItems.length + (index - live);
4253
+ }
4254
+ // encoding only. Returns the wire index the change was recorded at
4255
+ // (undefined when nothing was recorded).
4184
4256
  $changeAt(index, value) {
4185
4257
  if (value === undefined || value === null) {
4186
- console.error("ArraySchema items cannot be null nor undefined; Use `deleteAt(index)` instead.");
4187
- return;
4258
+ console.error("ArraySchema items cannot be null nor undefined; Use `splice(index, 1)` instead.");
4259
+ return undefined;
4188
4260
  }
4189
4261
  // skip if the value is the same as cached.
4190
4262
  if (this.items[index] === value) {
4191
- return;
4263
+ return undefined;
4192
4264
  }
4193
4265
  const operation = (this.items[index] !== undefined)
4194
4266
  ? typeof (value) === "object"
4195
4267
  ? exports.OPERATION.DELETE_AND_ADD // schema child
4196
4268
  : exports.OPERATION.REPLACE // primitive
4197
4269
  : exports.OPERATION.ADD;
4270
+ const wireIndex = this.$wireIndex(index);
4198
4271
  const changeTree = this[$changes];
4199
- changeTree.change(index, operation);
4272
+ changeTree.change(wireIndex, operation);
4200
4273
  //
4201
4274
  // set value's parent after the value is set
4202
4275
  // (to avoid encoding "refId" operations before parent's "ADD" operation)
4203
4276
  //
4204
- value[$changes]?.setParent(this, changeTree.root, index);
4277
+ value[$changes]?.setParent(this, changeTree.root, wireIndex);
4278
+ return wireIndex;
4205
4279
  }
4206
4280
  // encoding only
4207
4281
  $deleteAt(index, operation) {
4208
- this[$changes].delete(index, operation);
4282
+ this[$changes].delete(this.$wireIndex(index), operation);
4209
4283
  }
4210
4284
  // decoding only
4211
4285
  $setAt(index, value, operation) {
4212
- if (index === 0 &&
4213
- operation === exports.OPERATION.ADD &&
4286
+ if (operation === exports.OPERATION.ADD &&
4214
4287
  this.items[index] !== undefined) {
4215
- // handle decoding unshift
4216
- this.items.unshift(value);
4288
+ // ADD at an occupied index = insert (unshift / splice-insert):
4289
+ // shift existing items up instead of overwriting.
4290
+ this.items.splice(index, 0, value);
4217
4291
  }
4218
4292
  else if (operation === exports.OPERATION.DELETE_AND_MOVE) {
4219
4293
  this.items.splice(index, 1);
@@ -4300,10 +4374,16 @@
4300
4374
  return undefined;
4301
4375
  }
4302
4376
  const changeTree = self[$changes];
4303
- const first = items[0];
4304
- const index = self.tmpItems.findIndex(item => item === first);
4377
+ // items[0] ≡ first live (non-deleted) tmpItems slot. Value-based
4378
+ // findIndex is unsafe here: same-tick index writes can duplicate a
4379
+ // value across tmp slots and resolve the wrong one.
4380
+ const deletedIndexes = self.deletedIndexes;
4381
+ let index = 0;
4382
+ while (deletedIndexes[index] === true) {
4383
+ index++;
4384
+ }
4305
4385
  changeTree.delete(index, exports.OPERATION.DELETE);
4306
- self.deletedIndexes[index] = true;
4386
+ deletedIndexes[index] = true;
4307
4387
  return items.shift();
4308
4388
  }
4309
4389
  /**
@@ -4401,13 +4481,18 @@
4401
4481
  unshift(...items) {
4402
4482
  const self = this[$proxyTarget];
4403
4483
  const changeTree = self[$changes];
4404
- // Existing items shift up `shiftChangeIndexes` handles their
4405
- // relocation bookkeeping. The prepended `items` are genuinely new
4406
- // (no prior existence to MOVE), so each records an ADD.
4407
- changeTree.shiftChangeIndexes(items.length);
4408
- items.forEach((_, index) => {
4409
- changeTree.change(index, exports.OPERATION.ADD);
4410
- });
4484
+ // single recorder op: shifts pending indexes up and records the new
4485
+ // ADDs lowest-first (the decoder splice-inserts in ascending order).
4486
+ changeTree.unshift(items.length);
4487
+ // attach ref-type items — parent set AFTER recording, as in $changeAt
4488
+ for (let i = 0; i < items.length; i++) {
4489
+ items[i]?.[$changes]?.setParent(this, changeTree.root, i);
4490
+ }
4491
+ // keep staged-delete flags aligned with the prepended tmp slots
4492
+ const deletedIndexes = self.deletedIndexes;
4493
+ if (deletedIndexes.length > 0) {
4494
+ deletedIndexes.unshift(...new Array(items.length).fill(false));
4495
+ }
4411
4496
  self.tmpItems.unshift(...items);
4412
4497
  return self.items.unshift(...items);
4413
4498
  }
@@ -5026,6 +5111,37 @@
5026
5111
  get(key) {
5027
5112
  return this.$items.get(key);
5028
5113
  }
5114
+ /**
5115
+ * Returns the value for `key` if present. Otherwise inserts `defaultValue`
5116
+ * (tracked as an ADD change, like `set()`) and returns it.
5117
+ *
5118
+ * Mirrors `Map.prototype.getOrInsert` (TC39 "upsert" proposal, typed in
5119
+ * TypeScript 6's standard library).
5120
+ */
5121
+ getOrInsert(key, defaultValue) {
5122
+ if (this.$items.has(key)) {
5123
+ return this.$items.get(key);
5124
+ }
5125
+ this.set(key, defaultValue);
5126
+ return defaultValue;
5127
+ }
5128
+ /**
5129
+ * Returns the value for `key` if present. Otherwise computes a value via
5130
+ * `callbackfn(key)`, inserts it (tracked as an ADD change, like `set()`)
5131
+ * and returns it. The callback is only invoked when the key is missing.
5132
+ *
5133
+ * Mirrors `Map.prototype.getOrInsertComputed` (TC39 "upsert" proposal,
5134
+ * typed in TypeScript 6's standard library).
5135
+ */
5136
+ getOrInsertComputed(key, callbackfn) {
5137
+ if (this.$items.has(key)) {
5138
+ return this.$items.get(key);
5139
+ }
5140
+ const value = callbackfn(key);
5141
+ // per spec: overwrites even if callbackfn itself inserted `key`
5142
+ this.set(key, value);
5143
+ return value;
5144
+ }
5029
5145
  delete(key) {
5030
5146
  if (!this.$items.has(key)) {
5031
5147
  return false;