@colyseus/schema 4.0.20 → 5.0.1

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.
Files changed (96) hide show
  1. package/README.md +2 -0
  2. package/build/Metadata.d.ts +56 -2
  3. package/build/Reflection.d.ts +28 -34
  4. package/build/Schema.d.ts +70 -9
  5. package/build/annotations.d.ts +64 -17
  6. package/build/codegen/cli.cjs +84 -67
  7. package/build/codegen/cli.cjs.map +1 -1
  8. package/build/decoder/DecodeOperation.d.ts +48 -5
  9. package/build/decoder/Decoder.d.ts +2 -2
  10. package/build/decoder/strategy/Callbacks.d.ts +1 -1
  11. package/build/encoder/ChangeRecorder.d.ts +107 -0
  12. package/build/encoder/ChangeTree.d.ts +218 -69
  13. package/build/encoder/EncodeDescriptor.d.ts +63 -0
  14. package/build/encoder/EncodeOperation.d.ts +25 -2
  15. package/build/encoder/Encoder.d.ts +59 -3
  16. package/build/encoder/MapJournal.d.ts +62 -0
  17. package/build/encoder/RefIdAllocator.d.ts +35 -0
  18. package/build/encoder/Root.d.ts +94 -13
  19. package/build/encoder/StateView.d.ts +116 -8
  20. package/build/encoder/changeTree/inheritedFlags.d.ts +34 -0
  21. package/build/encoder/changeTree/liveIteration.d.ts +3 -0
  22. package/build/encoder/changeTree/parentChain.d.ts +24 -0
  23. package/build/encoder/changeTree/treeAttachment.d.ts +13 -0
  24. package/build/encoder/streaming.d.ts +73 -0
  25. package/build/encoder/subscriptions.d.ts +25 -0
  26. package/build/index.cjs +5258 -1549
  27. package/build/index.cjs.map +1 -1
  28. package/build/index.d.ts +7 -3
  29. package/build/index.js +5258 -1549
  30. package/build/index.mjs +5249 -1549
  31. package/build/index.mjs.map +1 -1
  32. package/build/input/InputDecoder.d.ts +32 -0
  33. package/build/input/InputEncoder.d.ts +117 -0
  34. package/build/input/index.cjs +7453 -0
  35. package/build/input/index.cjs.map +1 -0
  36. package/build/input/index.d.ts +3 -0
  37. package/build/input/index.mjs +7450 -0
  38. package/build/input/index.mjs.map +1 -0
  39. package/build/types/HelperTypes.d.ts +67 -9
  40. package/build/types/TypeContext.d.ts +9 -0
  41. package/build/types/builder.d.ts +192 -0
  42. package/build/types/custom/ArraySchema.d.ts +25 -4
  43. package/build/types/custom/CollectionSchema.d.ts +30 -2
  44. package/build/types/custom/MapSchema.d.ts +52 -3
  45. package/build/types/custom/SetSchema.d.ts +32 -2
  46. package/build/types/custom/StreamSchema.d.ts +114 -0
  47. package/build/types/symbols.d.ts +48 -5
  48. package/package.json +9 -3
  49. package/src/Metadata.ts +259 -31
  50. package/src/Reflection.ts +15 -13
  51. package/src/Schema.ts +176 -134
  52. package/src/annotations.ts +365 -252
  53. package/src/bench_bloat.ts +173 -0
  54. package/src/bench_decode.ts +221 -0
  55. package/src/bench_decode_mem.ts +165 -0
  56. package/src/bench_encode.ts +108 -0
  57. package/src/bench_init.ts +150 -0
  58. package/src/bench_static.ts +109 -0
  59. package/src/bench_stream.ts +295 -0
  60. package/src/bench_view_cmp.ts +142 -0
  61. package/src/codegen/languages/csharp.ts +0 -24
  62. package/src/codegen/parser.ts +83 -61
  63. package/src/decoder/DecodeOperation.ts +168 -63
  64. package/src/decoder/Decoder.ts +20 -10
  65. package/src/decoder/ReferenceTracker.ts +4 -0
  66. package/src/decoder/strategy/Callbacks.ts +30 -26
  67. package/src/decoder/strategy/getDecoderStateCallbacks.ts +16 -13
  68. package/src/encoder/ChangeRecorder.ts +276 -0
  69. package/src/encoder/ChangeTree.ts +674 -519
  70. package/src/encoder/EncodeDescriptor.ts +213 -0
  71. package/src/encoder/EncodeOperation.ts +107 -65
  72. package/src/encoder/Encoder.ts +630 -119
  73. package/src/encoder/MapJournal.ts +124 -0
  74. package/src/encoder/RefIdAllocator.ts +68 -0
  75. package/src/encoder/Root.ts +247 -120
  76. package/src/encoder/StateView.ts +592 -121
  77. package/src/encoder/changeTree/inheritedFlags.ts +217 -0
  78. package/src/encoder/changeTree/liveIteration.ts +74 -0
  79. package/src/encoder/changeTree/parentChain.ts +131 -0
  80. package/src/encoder/changeTree/treeAttachment.ts +171 -0
  81. package/src/encoder/streaming.ts +232 -0
  82. package/src/encoder/subscriptions.ts +71 -0
  83. package/src/index.ts +15 -3
  84. package/src/input/InputDecoder.ts +57 -0
  85. package/src/input/InputEncoder.ts +303 -0
  86. package/src/input/index.ts +3 -0
  87. package/src/types/HelperTypes.ts +121 -24
  88. package/src/types/TypeContext.ts +14 -2
  89. package/src/types/builder.ts +331 -0
  90. package/src/types/custom/ArraySchema.ts +210 -197
  91. package/src/types/custom/CollectionSchema.ts +115 -35
  92. package/src/types/custom/MapSchema.ts +162 -58
  93. package/src/types/custom/SetSchema.ts +128 -39
  94. package/src/types/custom/StreamSchema.ts +310 -0
  95. package/src/types/symbols.ts +93 -6
  96. package/src/utils.ts +4 -6
@@ -1,10 +1,9 @@
1
1
  import { TypeContext } from "../types/TypeContext.js";
2
- import { $changes, $childType, $decoder, $onDecodeEnd, $refId } from "../types/symbols.js";
2
+ import { $childType, $decoder, $onDecodeEnd, $refId } from "../types/symbols.js";
3
3
  import { Schema } from "../Schema.js";
4
-
5
4
  import { decode } from "../encoding/decode.js";
6
5
  import { OPERATION, SWITCH_TO_STRUCTURE, TYPE_ID } from '../encoding/spec.js';
7
- import type { IRef, Ref } from "../encoder/ChangeTree.js";
6
+ import { type IRef, type Ref } from "../encoder/ChangeTree.js";
8
7
  import type { Iterator } from "../encoding/decode.js";
9
8
  import { ReferenceTracker } from "./ReferenceTracker.js";
10
9
  import { DEFINITION_MISMATCH, type DataChange, type DecodeOperation } from "./DecodeOperation.js";
@@ -42,7 +41,13 @@ export class Decoder<T extends IRef = any> {
42
41
  it: Iterator = { offset: 0 },
43
42
  ref: IRef = this.state,
44
43
  ) {
45
- const allChanges: DataChange[] = [];
44
+ // Only allocate a collection array when there's a subscriber. Every
45
+ // decode-op push site uses `allChanges?.push(...)` — optional
46
+ // chaining short-circuits the object literal too, so a listener-
47
+ // free decoder does zero per-field allocation.
48
+ const allChanges: DataChange[] | null = (this.triggerChanges !== undefined)
49
+ ? []
50
+ : null;
46
51
 
47
52
  const $root = this.root;
48
53
  const totalBytes = bytes.byteLength;
@@ -90,11 +95,16 @@ export class Decoder<T extends IRef = any> {
90
95
  }
91
96
  }
92
97
 
93
- // FIXME: DRY with SWITCH_TO_STRUCTURE block.
98
+ // Close out the last ref's decode session — mirrors the
99
+ // SWITCH_TO_STRUCTURE block above, which fires it at every
100
+ // intra-loop structure transition. ArraySchema uses this to
101
+ // compact `items` after a tick's deletes. No other consumer
102
+ // currently hooks it; the dual call sites stay as-is rather
103
+ // than being extracted into a helper for a one-line body.
94
104
  (ref as any)[$onDecodeEnd]?.()
95
105
 
96
106
  // trigger changes
97
- this.triggerChanges?.(allChanges);
107
+ if (allChanges !== null) this.triggerChanges?.(allChanges);
98
108
 
99
109
  // drop references of unused schemas
100
110
  $root.garbageCollectDeletedRefs();
@@ -131,16 +141,16 @@ export class Decoder<T extends IRef = any> {
131
141
  return type || defaultType;
132
142
  }
133
143
 
134
- createInstanceOfType (type: typeof Schema): Schema {
135
- return new (type as any)();
144
+ createInstanceOfType(type: typeof Schema): Schema {
145
+ return type.initializeForDecoder();
136
146
  }
137
147
 
138
- removeChildRefs(ref: Collection, allChanges: DataChange[]) {
148
+ removeChildRefs(ref: Collection, allChanges: DataChange[] | null) {
139
149
  const needRemoveRef = typeof ((ref as any)[$childType]) !== "string";
140
150
  const refId = (ref as Ref)[$refId];
141
151
 
142
152
  ref.forEach((value: any, key: any) => {
143
- allChanges.push({
153
+ allChanges?.push({
144
154
  ref: ref as Ref,
145
155
  refId,
146
156
  op: OPERATION.DELETE,
@@ -41,6 +41,10 @@ export class ReferenceTracker {
41
41
  addRef(refId: number, ref: IRef, incrementCount: boolean = true) {
42
42
  this.refs.set(refId, ref);
43
43
 
44
+ // `enumerable: false` is load-bearing: tests use `deepStrictEqual`
45
+ // on decoded instances, which WOULD walk enumerable Symbol-keyed
46
+ // properties and include `$refId` in the comparison. Keep the
47
+ // descriptor dance for semantic compatibility.
44
48
  Object.defineProperty(ref, $refId, {
45
49
  value: refId,
46
50
  enumerable: false,
@@ -129,7 +129,7 @@ export class StateCallbackStrategy<TState extends IRef> {
129
129
  /**
130
130
  * Listen to property changes on a nested instance.
131
131
  */
132
- listen<TInstance, K extends PublicPropNames<TInstance>>(
132
+ listen<TInstance extends Schema, K extends PublicPropNames<TInstance>>(
133
133
  instance: TInstance,
134
134
  property: K,
135
135
  handler: PropertyChangeCallback<TInstance[K]>,
@@ -333,6 +333,13 @@ export class StateCallbackStrategy<TState extends IRef> {
333
333
  protected triggerChanges(allChanges: DataChange[]): void {
334
334
  this.uniqueRefIds.clear();
335
335
 
336
+ // Flag stays set for the whole dispatch pass — any `listen()` /
337
+ // `onAdd(...)` registered while a callback is firing needs to
338
+ // suppress its immediate-trigger. One toggle per pass is enough;
339
+ // every callback invocation below is wrapped in try/catch so
340
+ // nothing bubbles out to leave the flag stuck.
341
+ this.isTriggering = true;
342
+
336
343
  for (let i = 0, l = allChanges.length; i < l; i++) {
337
344
  const change = allChanges[i];
338
345
  const refId = change.refId;
@@ -354,7 +361,7 @@ export class StateCallbackStrategy<TState extends IRef> {
354
361
  const deleteCallbacks = this.callbacks[childRefId]?.[OPERATION.DELETE];
355
362
  if (deleteCallbacks) {
356
363
  for (let j = deleteCallbacks.length - 1; j >= 0; j--) {
357
- deleteCallbacks[j]();
364
+ try { deleteCallbacks[j](); } catch (e) { console.error(e); }
358
365
  }
359
366
  }
360
367
  }
@@ -369,11 +376,7 @@ export class StateCallbackStrategy<TState extends IRef> {
369
376
  const replaceCallbacks = $callbacks[OPERATION.REPLACE];
370
377
  if (replaceCallbacks) {
371
378
  for (let j = replaceCallbacks.length - 1; j >= 0; j--) {
372
- try {
373
- replaceCallbacks[j]();
374
- } catch (e) {
375
- console.error(e);
376
- }
379
+ try { replaceCallbacks[j](); } catch (e) { console.error(e); }
377
380
  }
378
381
  }
379
382
  }
@@ -382,14 +385,7 @@ export class StateCallbackStrategy<TState extends IRef> {
382
385
  const fieldCallbacks = $callbacks[change.field];
383
386
  if (fieldCallbacks) {
384
387
  for (let j = fieldCallbacks.length - 1; j >= 0; j--) {
385
- try {
386
- this.isTriggering = true;
387
- fieldCallbacks[j](change.value, change.previousValue);
388
- } catch (e) {
389
- console.error(e);
390
- } finally {
391
- this.isTriggering = false;
392
- }
388
+ try { fieldCallbacks[j](change.value, change.previousValue); } catch (e) { console.error(e); }
393
389
  }
394
390
  }
395
391
 
@@ -400,15 +396,25 @@ export class StateCallbackStrategy<TState extends IRef> {
400
396
  const dynamicIndex = change.dynamicIndex ?? change.field;
401
397
 
402
398
  if ((change.op & OPERATION.DELETE) === OPERATION.DELETE) {
403
- //
404
- // FIXME: `previousValue` should always be available.
405
- //
399
+ // DELETE can arrive with `previousValue === undefined` in two
400
+ // legitimate cases neither is fixable decoder-side:
401
+ // 1. DELETE_AND_ADD (op byte 192) for an index/refId the
402
+ // decoder never held — e.g. a client whose @view
403
+ // subscription just began sees the replacement op
404
+ // first. `previousValue` is undefined; `value` is the
405
+ // newly-decoded instance. The ADD half still fires below.
406
+ // 2. DELETE_BY_REFID (decodeArray, DecodeOperation.ts) for
407
+ // a filtered ArraySchema ref that was never ADDed on
408
+ // this decoder. That push site is unconditional because
409
+ // `removeRef` has already run; raw-change observers
410
+ // still want the event even with unknown previousValue.
411
+ // The guard prevents `onRemove(undefined, key)` from firing.
406
412
  if (change.previousValue !== undefined) {
407
413
  // trigger onRemove (value, key)
408
414
  const deleteCallbacks = $callbacks[OPERATION.DELETE];
409
415
  if (deleteCallbacks) {
410
416
  for (let j = deleteCallbacks.length - 1; j >= 0; j--) {
411
- deleteCallbacks[j](change.previousValue, dynamicIndex);
417
+ try { deleteCallbacks[j](change.previousValue, dynamicIndex); } catch (e) { console.error(e); }
412
418
  }
413
419
  }
414
420
  }
@@ -417,11 +423,9 @@ export class StateCallbackStrategy<TState extends IRef> {
417
423
  if ((change.op & OPERATION.ADD) === OPERATION.ADD) {
418
424
  const addCallbacks = $callbacks[OPERATION.ADD];
419
425
  if (addCallbacks) {
420
- this.isTriggering = true;
421
426
  for (let j = addCallbacks.length - 1; j >= 0; j--) {
422
- addCallbacks[j](change.value, dynamicIndex);
427
+ try { addCallbacks[j](change.value, dynamicIndex); } catch (e) { console.error(e); }
423
428
  }
424
- this.isTriggering = false;
425
429
  }
426
430
  }
427
431
 
@@ -432,11 +436,9 @@ export class StateCallbackStrategy<TState extends IRef> {
432
436
  // trigger onAdd (value, key)
433
437
  const addCallbacks = $callbacks[OPERATION.ADD];
434
438
  if (addCallbacks) {
435
- this.isTriggering = true;
436
439
  for (let j = addCallbacks.length - 1; j >= 0; j--) {
437
- addCallbacks[j](change.value, dynamicIndex);
440
+ try { addCallbacks[j](change.value, dynamicIndex); } catch (e) { console.error(e); }
438
441
  }
439
- this.isTriggering = false;
440
442
  }
441
443
  }
442
444
 
@@ -445,7 +447,7 @@ export class StateCallbackStrategy<TState extends IRef> {
445
447
  const replaceCallbacks = $callbacks[OPERATION.REPLACE];
446
448
  if (replaceCallbacks) {
447
449
  for (let j = replaceCallbacks.length - 1; j >= 0; j--) {
448
- replaceCallbacks[j](dynamicIndex, change.value);
450
+ try { replaceCallbacks[j](dynamicIndex, change.value); } catch (e) { console.error(e); }
449
451
  }
450
452
  }
451
453
  }
@@ -453,6 +455,8 @@ export class StateCallbackStrategy<TState extends IRef> {
453
455
 
454
456
  this.uniqueRefIds.add(refId);
455
457
  }
458
+
459
+ this.isTriggering = false;
456
460
  }
457
461
  }
458
462
 
@@ -155,10 +155,6 @@ export function getDecoderStateCallbacks<T extends Schema>(decoder: Decoder<T>):
155
155
  const replaceCallbacks = $callbacks?.[OPERATION.REPLACE];
156
156
  for (let i = replaceCallbacks?.length - 1; i >= 0; i--) {
157
157
  replaceCallbacks[i]();
158
- // try {
159
- // } catch (e) {
160
- // console.error(e);
161
- // }
162
158
  }
163
159
  }
164
160
 
@@ -166,10 +162,6 @@ export function getDecoderStateCallbacks<T extends Schema>(decoder: Decoder<T>):
166
162
  const fieldCallbacks = $callbacks[change.field];
167
163
  for (let i = fieldCallbacks?.length - 1; i >= 0; i--) {
168
164
  fieldCallbacks[i](change.value, change.previousValue);
169
- // try {
170
- // } catch (e) {
171
- // console.error(e);
172
- // }
173
165
  }
174
166
  }
175
167
 
@@ -180,9 +172,16 @@ export function getDecoderStateCallbacks<T extends Schema>(decoder: Decoder<T>):
180
172
  //
181
173
 
182
174
  if ((change.op & OPERATION.DELETE) === OPERATION.DELETE) {
183
- //
184
- // FIXME: `previousValue` should always be available.
185
- //
175
+ // DELETE can arrive with `previousValue === undefined` in
176
+ // two legitimate cases neither is fixable decoder-side:
177
+ // 1. DELETE_AND_ADD (op byte 192) for an index/refId the
178
+ // decoder never held (e.g. a client whose @view
179
+ // subscription just began sees the replacement op
180
+ // first). The ADD half still fires below.
181
+ // 2. DELETE_BY_REFID (decodeArray) for a filtered
182
+ // ArraySchema ref that was never ADDed on this
183
+ // decoder; that push site is unconditional.
184
+ // The guard prevents `onRemove(undefined, key)` from firing.
186
185
  if (change.previousValue !== undefined) {
187
186
  // triger onRemove
188
187
  const deleteCallbacks = $callbacks[OPERATION.DELETE];
@@ -211,10 +210,14 @@ export function getDecoderStateCallbacks<T extends Schema>(decoder: Decoder<T>):
211
210
  }
212
211
 
213
212
  // trigger onChange
213
+ // The `value !== undefined || previousValue !== undefined` half
214
+ // suppresses spurious onChange calls for DELETEs the decoder
215
+ // never had context for — same two cases documented above
216
+ // (DELETE_AND_ADD post-view-grant, DELETE_BY_REFID for unseen
217
+ // refIds), plus the historical "ADD+DELETE collapsed to DELETE
218
+ // on the wire" path now neutralized at the push site.
214
219
  if (
215
220
  change.value !== change.previousValue &&
216
- // FIXME: see "should not encode item if added and removed at the same patch" test case.
217
- // some "ADD" + "DELETE" operations on same patch are being encoded as "DELETE"
218
221
  (change.value !== undefined || change.previousValue !== undefined)
219
222
  ) {
220
223
  const replaceCallbacks = $callbacks[OPERATION.REPLACE];
@@ -0,0 +1,276 @@
1
+ import { OPERATION } from "../encoding/spec.js";
2
+
3
+ /**
4
+ * ChangeRecorder — "what changed this tick" for a single ref.
5
+ *
6
+ * This file holds the two standalone recorder classes used for the
7
+ * unreliable channel (lazy, opt-in). The reliable channel is inlined on
8
+ * `ChangeTree` for perf; see `ChangeTree._isSchema` dispatch.
9
+ *
10
+ * Interface design (ISP):
11
+ * - {@link ChangeRecorder}: common ops, implemented by both Schema and
12
+ * Collection recorders.
13
+ * - {@link ICollectionChangeRecorder}: extends with `recordPure` +
14
+ * `shift` — collection-only. Schema recorders do NOT carry these.
15
+ *
16
+ * Per-field filter/visibility is decided at encode time, not record time.
17
+ * Full-sync output is derived structurally via `ChangeTree.forEachLive`.
18
+ */
19
+ export interface ChangeRecorder {
20
+ /**
21
+ * Record a change at the given index. Handles op merge
22
+ * (DELETE followed by ADD becomes DELETE_AND_ADD).
23
+ */
24
+ record(index: number, op: OPERATION): void;
25
+
26
+ /** Record a DELETE at the given index. */
27
+ recordDelete(index: number, op: OPERATION): void;
28
+
29
+ /**
30
+ * Record an operation without op-merge semantics. Used by ArraySchema
31
+ * positional writes where DELETE→ADD merge is undesirable.
32
+ */
33
+ recordRaw(index: number, op: OPERATION): void;
34
+
35
+ /** Current operation at index, or undefined if none. */
36
+ operationAt(index: number): OPERATION | undefined;
37
+
38
+ /** Overwrite the operation at index. */
39
+ setOperationAt(index: number, op: OPERATION): void;
40
+
41
+ /**
42
+ * Iterate (index, op) pairs in record order.
43
+ * Pure operations emit with index = -op (wire convention).
44
+ */
45
+ forEach(cb: (index: number, op: OPERATION) => void): void;
46
+
47
+ /** Closure-free forEach variant for the hot encode path. */
48
+ forEachWithCtx<T>(ctx: T, cb: (ctx: T, index: number, op: OPERATION) => void): void;
49
+
50
+ size(): number;
51
+ has(): boolean;
52
+ reset(): void;
53
+ }
54
+
55
+ /**
56
+ * Extended recorder for collection types — adds `recordPure` (CLEAR /
57
+ * REVERSE) and `shift` (ArraySchema.unshift support).
58
+ */
59
+ export interface ICollectionChangeRecorder extends ChangeRecorder {
60
+ /**
61
+ * Record a pure operation (CLEAR / REVERSE) with no index.
62
+ * Interleaves with indexed ops at record order.
63
+ */
64
+ recordPure(op: OPERATION): void;
65
+
66
+ /** Shift current-tick dirty indexes by `shiftIndex`. */
67
+ shift(shiftIndex: number): void;
68
+ }
69
+
70
+ // Module-scope adapter: lets `forEach(cb)` delegate to `forEachWithCtx`
71
+ // by passing the user's callback as ctx. No per-call allocation.
72
+ const _invokeNoCtx = (
73
+ cb: (index: number, op: OPERATION) => void,
74
+ index: number,
75
+ op: OPERATION,
76
+ ) => cb(index, op);
77
+
78
+ // ──────────────────────────────────────────────────────────────────────────
79
+ // SchemaChangeRecorder — bitmask + Uint8Array, for Schema types (≤64 fields)
80
+ // ──────────────────────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Schema field operations are limited to ADD(128), DELETE(64), and
84
+ * DELETE_AND_ADD(192). REPLACE(0) is collection-only, so `ops[i] === 0`
85
+ * is a safe "no operation" sentinel.
86
+ */
87
+ export class SchemaChangeRecorder implements ChangeRecorder {
88
+ // Bitmask storage for fields 0-31 (low) and 32-63 (high).
89
+ private dirtyLow = 0;
90
+ private dirtyHigh = 0;
91
+
92
+ // ops[fieldIndex] = OPERATION value. Pre-sized to numFields+1.
93
+ private readonly ops: Uint8Array;
94
+
95
+ constructor(numFields: number) {
96
+ this.ops = new Uint8Array(Math.max(numFields + 1, 1));
97
+ }
98
+
99
+ record(index: number, op: OPERATION): void {
100
+ const prev = this.ops[index];
101
+ if (prev === 0) this.ops[index] = op;
102
+ else if (prev === OPERATION.DELETE) this.ops[index] = OPERATION.DELETE_AND_ADD;
103
+ // Promote ADD → DELETE_AND_ADD when a ref is replaced in the same
104
+ // tick. See `ChangeTree.record` for rationale — same logic, this
105
+ // interface implementation is kept in sync.
106
+ else if (prev === OPERATION.ADD && op === OPERATION.DELETE_AND_ADD) {
107
+ this.ops[index] = OPERATION.DELETE_AND_ADD;
108
+ }
109
+ // else preserve existing ADD / DELETE_AND_ADD.
110
+
111
+ if (index < 32) this.dirtyLow |= (1 << index);
112
+ else this.dirtyHigh |= (1 << (index - 32));
113
+ }
114
+
115
+ recordDelete(index: number, op: OPERATION): void {
116
+ this.ops[index] = op;
117
+ if (index < 32) this.dirtyLow |= (1 << index);
118
+ else this.dirtyHigh |= (1 << (index - 32));
119
+ }
120
+
121
+ recordRaw(index: number, op: OPERATION): void {
122
+ this.record(index, op);
123
+ }
124
+
125
+ operationAt(index: number): OPERATION | undefined {
126
+ const op = this.ops[index];
127
+ return op === 0 ? undefined : op;
128
+ }
129
+
130
+ setOperationAt(index: number, op: OPERATION): void {
131
+ this.ops[index] = op;
132
+ }
133
+
134
+ forEach(cb: (index: number, op: OPERATION) => void): void {
135
+ this.forEachWithCtx(cb, _invokeNoCtx);
136
+ }
137
+
138
+ forEachWithCtx<T>(ctx: T, cb: (ctx: T, index: number, op: OPERATION) => void): void {
139
+ let low = this.dirtyLow;
140
+ let high = this.dirtyHigh;
141
+ const ops = this.ops;
142
+ // Iterate set bits via clz32 (CPU-level bit scan).
143
+ while (low !== 0) {
144
+ const bit = low & -low;
145
+ const fieldIndex = 31 - Math.clz32(bit);
146
+ low ^= bit;
147
+ cb(ctx, fieldIndex, ops[fieldIndex]);
148
+ }
149
+ while (high !== 0) {
150
+ const bit = high & -high;
151
+ const fieldIndex = 31 - Math.clz32(bit) + 32;
152
+ high ^= bit;
153
+ cb(ctx, fieldIndex, ops[fieldIndex]);
154
+ }
155
+ }
156
+
157
+ size(): number {
158
+ return popcount32(this.dirtyLow) + popcount32(this.dirtyHigh);
159
+ }
160
+
161
+ has(): boolean {
162
+ return (this.dirtyLow | this.dirtyHigh) !== 0;
163
+ }
164
+
165
+ reset(): void {
166
+ this.dirtyLow = 0;
167
+ this.dirtyHigh = 0;
168
+ this.ops.fill(0);
169
+ }
170
+ }
171
+
172
+ // ──────────────────────────────────────────────────────────────────────────
173
+ // CollectionChangeRecorder — Map-based, for collections with sparse indexes
174
+ // ──────────────────────────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Collection items have sparse indexes (e.g. 0, 7, 1024) exceeding the
178
+ * 64-field cap Schema imposes. Map-based storage handles arbitrary
179
+ * indexes; the value at each entry is the OPERATION.
180
+ *
181
+ * Pure operations (CLEAR, REVERSE) live in `pureOps` as `[position, op]`
182
+ * entries where `position` is `dirty.size` at record time — preserves
183
+ * insertion-order interleaving with indexed ops (e.g. CLEAR must emit
184
+ * BEFORE subsequent ADDs).
185
+ */
186
+ export class CollectionChangeRecorder implements ICollectionChangeRecorder {
187
+ private dirty: Map<number, OPERATION> = new Map();
188
+ private pureOps: Array<[number, OPERATION]> = [];
189
+
190
+ record(index: number, op: OPERATION): void {
191
+ const prev = this.dirty.get(index);
192
+ if (prev === undefined) this.dirty.set(index, op);
193
+ else if (prev === OPERATION.DELETE) this.dirty.set(index, OPERATION.DELETE_AND_ADD);
194
+ // Promote ADD → DELETE_AND_ADD for same-tick replacement of a ref
195
+ // (see `SchemaChangeRecorder.record` for rationale).
196
+ else if (prev === OPERATION.ADD && op === OPERATION.DELETE_AND_ADD) {
197
+ this.dirty.set(index, OPERATION.DELETE_AND_ADD);
198
+ }
199
+ // else preserve existing op.
200
+ }
201
+
202
+ recordDelete(index: number, op: OPERATION): void {
203
+ this.dirty.set(index, op);
204
+ }
205
+
206
+ recordRaw(index: number, op: OPERATION): void {
207
+ this.dirty.set(index, op);
208
+ }
209
+
210
+ recordPure(op: OPERATION): void {
211
+ this.pureOps.push([this.dirty.size, op]);
212
+ }
213
+
214
+ operationAt(index: number): OPERATION | undefined {
215
+ return this.dirty.get(index);
216
+ }
217
+
218
+ setOperationAt(index: number, op: OPERATION): void {
219
+ if (this.dirty.has(index)) this.dirty.set(index, op);
220
+ }
221
+
222
+ forEach(cb: (index: number, op: OPERATION) => void): void {
223
+ this.forEachWithCtx(cb, _invokeNoCtx);
224
+ }
225
+
226
+ forEachWithCtx<T>(ctx: T, cb: (ctx: T, index: number, op: OPERATION) => void): void {
227
+ const pure = this.pureOps;
228
+ if (pure.length > 0) {
229
+ let pureIdx = 0, i = 0;
230
+ for (const [index, op] of this.dirty) {
231
+ while (pureIdx < pure.length && pure[pureIdx][0] <= i) {
232
+ const pureOp = pure[pureIdx++][1];
233
+ cb(ctx, -pureOp, pureOp);
234
+ }
235
+ cb(ctx, index, op);
236
+ i++;
237
+ }
238
+ while (pureIdx < pure.length) {
239
+ const pureOp = pure[pureIdx++][1];
240
+ cb(ctx, -pureOp, pureOp);
241
+ }
242
+ } else {
243
+ for (const [index, op] of this.dirty) cb(ctx, index, op);
244
+ }
245
+ }
246
+
247
+ size(): number {
248
+ return this.dirty.size + this.pureOps.length;
249
+ }
250
+
251
+ has(): boolean {
252
+ return this.dirty.size > 0 || this.pureOps.length > 0;
253
+ }
254
+
255
+ reset(): void {
256
+ this.dirty.clear();
257
+ this.pureOps.length = 0;
258
+ }
259
+
260
+ shift(shiftIndex: number): void {
261
+ const dst = new Map<number, OPERATION>();
262
+ for (const [idx, val] of this.dirty) dst.set(idx + shiftIndex, val);
263
+ this.dirty = dst;
264
+ }
265
+ }
266
+
267
+ // ──────────────────────────────────────────────────────────────────────────
268
+ // Helpers
269
+ // ──────────────────────────────────────────────────────────────────────────
270
+
271
+ /** 32-bit Hamming weight (popcount). */
272
+ export function popcount32(n: number): number {
273
+ n = n - ((n >>> 1) & 0x55555555);
274
+ n = (n & 0x33333333) + ((n >>> 2) & 0x33333333);
275
+ return (((n + (n >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24;
276
+ }