@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.
- package/README.md +2 -0
- package/build/Metadata.d.ts +56 -2
- package/build/Reflection.d.ts +28 -34
- package/build/Schema.d.ts +70 -9
- package/build/annotations.d.ts +64 -17
- package/build/codegen/cli.cjs +84 -67
- package/build/codegen/cli.cjs.map +1 -1
- package/build/decoder/DecodeOperation.d.ts +48 -5
- package/build/decoder/Decoder.d.ts +2 -2
- package/build/decoder/strategy/Callbacks.d.ts +1 -1
- package/build/encoder/ChangeRecorder.d.ts +107 -0
- package/build/encoder/ChangeTree.d.ts +218 -69
- package/build/encoder/EncodeDescriptor.d.ts +63 -0
- package/build/encoder/EncodeOperation.d.ts +25 -2
- package/build/encoder/Encoder.d.ts +59 -3
- package/build/encoder/MapJournal.d.ts +62 -0
- package/build/encoder/RefIdAllocator.d.ts +35 -0
- package/build/encoder/Root.d.ts +94 -13
- package/build/encoder/StateView.d.ts +116 -8
- package/build/encoder/changeTree/inheritedFlags.d.ts +34 -0
- package/build/encoder/changeTree/liveIteration.d.ts +3 -0
- package/build/encoder/changeTree/parentChain.d.ts +24 -0
- package/build/encoder/changeTree/treeAttachment.d.ts +13 -0
- package/build/encoder/streaming.d.ts +73 -0
- package/build/encoder/subscriptions.d.ts +25 -0
- package/build/index.cjs +5258 -1549
- package/build/index.cjs.map +1 -1
- package/build/index.d.ts +7 -3
- package/build/index.js +5258 -1549
- package/build/index.mjs +5249 -1549
- package/build/index.mjs.map +1 -1
- package/build/input/InputDecoder.d.ts +32 -0
- package/build/input/InputEncoder.d.ts +117 -0
- package/build/input/index.cjs +7453 -0
- package/build/input/index.cjs.map +1 -0
- package/build/input/index.d.ts +3 -0
- package/build/input/index.mjs +7450 -0
- package/build/input/index.mjs.map +1 -0
- package/build/types/HelperTypes.d.ts +67 -9
- package/build/types/TypeContext.d.ts +9 -0
- package/build/types/builder.d.ts +192 -0
- package/build/types/custom/ArraySchema.d.ts +25 -4
- package/build/types/custom/CollectionSchema.d.ts +30 -2
- package/build/types/custom/MapSchema.d.ts +52 -3
- package/build/types/custom/SetSchema.d.ts +32 -2
- package/build/types/custom/StreamSchema.d.ts +114 -0
- package/build/types/symbols.d.ts +48 -5
- package/package.json +9 -3
- package/src/Metadata.ts +259 -31
- package/src/Reflection.ts +15 -13
- package/src/Schema.ts +176 -134
- package/src/annotations.ts +365 -252
- package/src/bench_bloat.ts +173 -0
- package/src/bench_decode.ts +221 -0
- package/src/bench_decode_mem.ts +165 -0
- package/src/bench_encode.ts +108 -0
- package/src/bench_init.ts +150 -0
- package/src/bench_static.ts +109 -0
- package/src/bench_stream.ts +295 -0
- package/src/bench_view_cmp.ts +142 -0
- package/src/codegen/languages/csharp.ts +0 -24
- package/src/codegen/parser.ts +83 -61
- package/src/decoder/DecodeOperation.ts +168 -63
- package/src/decoder/Decoder.ts +20 -10
- package/src/decoder/ReferenceTracker.ts +4 -0
- package/src/decoder/strategy/Callbacks.ts +30 -26
- package/src/decoder/strategy/getDecoderStateCallbacks.ts +16 -13
- package/src/encoder/ChangeRecorder.ts +276 -0
- package/src/encoder/ChangeTree.ts +674 -519
- package/src/encoder/EncodeDescriptor.ts +213 -0
- package/src/encoder/EncodeOperation.ts +107 -65
- package/src/encoder/Encoder.ts +630 -119
- package/src/encoder/MapJournal.ts +124 -0
- package/src/encoder/RefIdAllocator.ts +68 -0
- package/src/encoder/Root.ts +247 -120
- package/src/encoder/StateView.ts +592 -121
- package/src/encoder/changeTree/inheritedFlags.ts +217 -0
- package/src/encoder/changeTree/liveIteration.ts +74 -0
- package/src/encoder/changeTree/parentChain.ts +131 -0
- package/src/encoder/changeTree/treeAttachment.ts +171 -0
- package/src/encoder/streaming.ts +232 -0
- package/src/encoder/subscriptions.ts +71 -0
- package/src/index.ts +15 -3
- package/src/input/InputDecoder.ts +57 -0
- package/src/input/InputEncoder.ts +303 -0
- package/src/input/index.ts +3 -0
- package/src/types/HelperTypes.ts +121 -24
- package/src/types/TypeContext.ts +14 -2
- package/src/types/builder.ts +331 -0
- package/src/types/custom/ArraySchema.ts +210 -197
- package/src/types/custom/CollectionSchema.ts +115 -35
- package/src/types/custom/MapSchema.ts +162 -58
- package/src/types/custom/SetSchema.ts +128 -39
- package/src/types/custom/StreamSchema.ts +310 -0
- package/src/types/symbols.ts +93 -6
- package/src/utils.ts +4 -6
package/src/decoder/Decoder.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { TypeContext } from "../types/TypeContext.js";
|
|
2
|
-
import { $
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
135
|
-
return
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|