@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.
- package/README.md +87 -51
- package/build/codegen/cli.cjs +13 -5
- package/build/codegen/cli.cjs.map +1 -1
- package/build/encoder/ChangeTree.d.ts +10 -3
- package/build/encoder/StateView.d.ts +0 -6
- package/build/index.cjs +182 -102
- package/build/index.cjs.map +1 -1
- package/build/index.js +182 -102
- package/build/index.mjs +182 -102
- package/build/index.mjs.map +1 -1
- package/build/types/custom/ArraySchema.d.ts +9 -1
- package/build/types/custom/MapSchema.d.ts +18 -1
- package/package.json +9 -3
- package/src/codegen/cli.ts +4 -4
- package/src/codegen/parser.ts +10 -1
- package/src/decoder/DecodeOperation.ts +23 -7
- package/src/encoder/ChangeTree.ts +18 -12
- package/src/encoder/EncodeOperation.ts +14 -1
- package/src/encoder/Encoder.ts +3 -16
- package/src/encoder/StateView.ts +16 -35
- package/src/types/custom/ArraySchema.ts +77 -30
- package/src/types/custom/MapSchema.ts +34 -1
|
@@ -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
|
-
|
|
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]():
|
|
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.
|
|
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": "
|
|
100
|
+
"typescript": ">=5.0.0"
|
|
101
|
+
},
|
|
102
|
+
"peerDependenciesMeta": {
|
|
103
|
+
"typescript": {
|
|
104
|
+
"optional": true
|
|
105
|
+
}
|
|
100
106
|
},
|
|
101
107
|
"c8": {
|
|
102
108
|
"include": [
|
package/src/codegen/cli.ts
CHANGED
|
@@ -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
|
}
|
package/src/codegen/parser.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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 (
|
|
518
|
+
value !== previousValue // avoid setting same value twice (an ADD at an occupied index would splice-insert)
|
|
507
519
|
) {
|
|
508
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/encoder/Encoder.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/src/encoder/StateView.ts
CHANGED
|
@@ -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
|
-
|
|
375
|
-
|
|
376
|
-
//
|
|
377
|
-
|
|
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
|
|
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(
|
|
65
|
+
obj[$changes].indexedOperation(wireIndex, OPERATION.MOVE_AND_ADD);
|
|
59
66
|
|
|
60
67
|
} else {
|
|
61
|
-
if ((obj[$changes].getChange(
|
|
62
|
-
obj[$changes].indexedOperation(
|
|
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(
|
|
72
|
+
obj[$changes].indexedOperation(wireIndex, OPERATION.MOVE);
|
|
66
73
|
}
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
} else if (setValue[$changes].isNew) {
|
|
70
|
-
obj[$changes].indexedOperation(
|
|
77
|
+
obj[$changes].indexedOperation(wireIndex, OPERATION.ADD);
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
setValue[$changes].setParent(obj, obj[$changes].root,
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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 `
|
|
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(
|
|
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,
|
|
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
|
-
//
|
|
344
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
items
|
|
572
|
-
|
|
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
|
|