@colyseus/schema 5.0.9 → 5.0.11

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.11",
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
@@ -232,7 +232,6 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
232
232
  // per-view WeakSet lookups with direct bitwise ops.
233
233
  // Lazy: undefined until the tree participates in any view.
234
234
  visibleViews?: number[];
235
- invisibleViews?: number[];
236
235
 
237
236
  // Per-(view, tag) bitmap, indexed by tag. Custom tags only —
238
237
  // DEFAULT_VIEW_TAG visibility lives in `visibleViews`.
@@ -586,17 +585,31 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
586
585
  // per-view visibility lives on the tree (NOT keyed by refId), so a
587
586
  // recycled tree must not inherit its previous life's view membership.
588
587
  this.visibleViews = undefined;
589
- this.invisibleViews = undefined;
590
588
  this.tagViews = undefined;
591
589
  this.subscribedViews = undefined;
592
590
  }
593
591
 
594
- shift(shiftIndex: number): void {
595
- if (this._isSchema) throw new Error("ChangeTree (Schema): shift is not supported");
592
+ /**
593
+ * ArraySchema#unshift(): re-key pending ops on both channels by
594
+ * `+count`, then record ADDs for the new items at indexes 0..count-1.
595
+ *
596
+ * The rebuilt map's insertion order IS the wire order: new ADDs first
597
+ * (ascending — the decoder splice-inserts each one, which only works
598
+ * lowest-index-first), then prior ops in their original relative order
599
+ * at their shifted positions. See ArraySchema#$setAt.
600
+ */
601
+ unshift(count: number): void {
602
+ if (this._isSchema) throw new Error("ChangeTree (Schema): unshift is not supported");
596
603
  const src = this.collDirty!;
597
604
  const dst = new Map<number, OPERATION>();
598
- for (const [idx, val] of src) dst.set(idx + shiftIndex, val);
605
+ const track = !this.paused && !this.isStatic;
606
+ if (track) {
607
+ for (let i = 0; i < count; i++) dst.set(i, OPERATION.ADD);
608
+ }
609
+ for (const [idx, val] of src) dst.set(idx + count, val);
599
610
  this.collDirty = dst;
611
+ (this.unreliableRecorder as ICollectionChangeRecorder | undefined)?.shift(count);
612
+ if (track) this.root?.enqueueChangeTree(this);
600
613
  }
601
614
 
602
615
  // Tree attachment + child iteration — see ./changeTree/treeAttachment.ts.
@@ -665,13 +678,6 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
665
678
  this._routeAndRecord(index, operation, true);
666
679
  }
667
680
 
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
681
  getChange(index: number) {
676
682
  return this.operationAt(index);
677
683
  }
@@ -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
  }
@@ -119,16 +119,7 @@ function _fullSyncWalk(ctx: EncodeCtx, changeTree: ChangeTree): void {
119
119
  // Visibility gate: when a view is active, a non-visible tree contributes
120
120
  // nothing itself but we still recurse so descendants (possibly added to
121
121
  // the view explicitly) are reachable.
122
- let visibleHere = true;
123
- if (ctx.hasView) {
124
- const view = ctx.view!;
125
- if (!view.isChangeTreeVisible(changeTree)) {
126
- view.markInvisible(changeTree);
127
- visibleHere = false;
128
- } else {
129
- view.unmarkInvisible(changeTree);
130
- }
131
- }
122
+ const visibleHere = !ctx.hasView || ctx.view!.isChangeTreeVisible(changeTree);
132
123
 
133
124
  if (visibleHere) {
134
125
  const desc = changeTree.encDescriptor;
@@ -313,12 +304,8 @@ export class Encoder<T extends Schema = any> {
313
304
  while (current = current.next) {
314
305
  const changeTree = (current as ChangeTreeNode).changeTree;
315
306
 
316
- if (hasView) {
317
- if (!view.isChangeTreeVisible(changeTree)) {
318
- view.markInvisible(changeTree);
319
- continue;
320
- }
321
- view.unmarkInvisible(changeTree);
307
+ if (hasView && !view.isChangeTreeVisible(changeTree)) {
308
+ continue;
322
309
  }
323
310
 
324
311
  const recorder = unreliable ? changeTree.unreliableRecorder : changeTree;
@@ -28,8 +28,6 @@ function _clearViewBitFromAllTrees(root: Root, slot: number, bit: number): void
28
28
  const tree = trees[refId];
29
29
  const v = tree.visibleViews;
30
30
  if (v !== undefined && slot < v.length) v[slot] &= clearMask;
31
- const i = tree.invisibleViews;
32
- if (i !== undefined && slot < i.length) i[slot] &= clearMask;
33
31
  const s = tree.subscribedViews;
34
32
  if (s !== undefined && slot < s.length) s[slot] &= clearMask;
35
33
  const t = tree.tagViews;
@@ -192,32 +190,6 @@ export class StateView {
192
190
  if (slot < arr.length) arr[slot] &= ~this._bit;
193
191
  }
194
192
 
195
- /** True iff this view has previously marked `tree` as invisible. */
196
- public isInvisible(tree: ChangeTree): boolean {
197
- const arr = tree.invisibleViews;
198
- const slot = this._slot;
199
- return arr !== undefined && slot < arr.length && (arr[slot] & this._bit) !== 0;
200
- }
201
-
202
- /** Mark `tree` as invisible to this view (used by encode loop). */
203
- public markInvisible(tree: ChangeTree): void {
204
- const slot = this._slot;
205
- let arr = tree.invisibleViews;
206
- if (arr === undefined) {
207
- arr = tree.invisibleViews = [];
208
- }
209
- while (arr.length <= slot) arr.push(0);
210
- arr[slot] |= this._bit;
211
- }
212
-
213
- /** Clear invisible bit. */
214
- public unmarkInvisible(tree: ChangeTree): void {
215
- const arr = tree.invisibleViews;
216
- if (arr === undefined) return;
217
- const slot = this._slot;
218
- if (slot < arr.length) arr[slot] &= ~this._bit;
219
- }
220
-
221
193
  // ──────────────────────────────────────────────────────────────────
222
194
  // Per-tag, per-view bitmap. Replaces the legacy
223
195
  // `tags: WeakMap<ChangeTree, Set<number>>` storage. Hot read site is
@@ -371,13 +343,20 @@ export class StateView {
371
343
  // subclasses yield a real Metadata object.
372
344
  const metadata: Metadata = (obj.constructor as typeof Schema)[Symbol.metadata];
373
345
 
374
- this.markVisible(changeTree);
375
-
376
- // add to iterable list (only the explicitly added items)
377
- if (this.iterable && checkIncludeParent) {
346
+ // Add to iterable list (only the explicitly added items), deduping
347
+ // re-adds of an already-visible instance. isVisible must be read
348
+ // BEFORE markVisible; indexOf runs only on the re-add path.
349
+ // NOTE: dedup applies to `items` only — a re-add still re-queues the
350
+ // full snapshot on purpose (shared-view bootstrap re-add: a
351
+ // late-attached client may not have consumed earlier drains).
352
+ // Callers wanting cheap idempotence can guard with `view.has(obj)`.
353
+ if (this.iterable && checkIncludeParent
354
+ && (!this.isVisible(changeTree) || this.items.indexOf(obj) === -1)) {
378
355
  this.items.push(obj);
379
356
  }
380
357
 
358
+ this.markVisible(changeTree);
359
+
381
360
  // add parent ChangeTree's
382
361
  // - if it was invisible to this view
383
362
  // - if it were previously filtered out
@@ -468,7 +447,6 @@ export class StateView {
468
447
 
469
448
  } else if (!changeTree.isNew || isChildAdded) {
470
449
  // new structures will be added as part of .encode() call, no need to force it to .encodeView()
471
- const isInvisible = this.isInvisible(changeTree);
472
450
 
473
451
  // Full-sync snapshot: walk the live ref structurally instead of
474
452
  // iterating a cumulative recorder bucket. Every populated index
@@ -476,11 +454,14 @@ export class StateView {
476
454
  // at encode time). Per-field tags come from the descriptor's
477
455
  // precomputed `tags[]` array — direct index vs a metadata[i].tag
478
456
  // object hop.
457
+ //
458
+ // Non-matching custom-tagged fields are NEVER included here —
459
+ // `view.changes` is drained without a per-field tag re-check,
460
+ // so anything added leaks straight to the wire.
479
461
  const tags = changeTree.encDescriptor.tags;
480
462
  changeTree.forEachLive((index) => {
481
463
  const tagAtIndex = tags[index];
482
464
  if (
483
- isInvisible || // if "invisible", include all
484
465
  tagAtIndex === undefined || // "all change" with no tag
485
466
  tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
486
467
  (tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
@@ -652,7 +633,7 @@ export class StateView {
652
633
 
653
634
  // ── Streamable-collection unsubscribe (the stream itself) ─────
654
635
  // Flush DELETE for every sent position and drop pending. After
655
- // this, the stream is marked invisible to this view — any future
636
+ // this, the stream is no longer visible to this view — any future
656
637
  // `stream.add()` would still seed broadcast pending (if no views)
657
638
  // but would NOT re-seed per-view pending (user must re-subscribe).
658
639
  if (changeTree.isStreamCollection) {
@@ -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