@colyseus/schema 5.0.14 → 5.0.20
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 +11 -5
- package/build/Metadata.d.ts +10 -1
- package/build/annotations.d.ts +6 -5
- package/build/codegen/api.d.ts +2 -0
- package/build/codegen/cli.cjs +322 -31
- package/build/codegen/cli.cjs.map +1 -1
- package/build/codegen/parser.d.ts +6 -1
- package/build/codegen/resolve.d.ts +25 -0
- package/build/codegen/types.d.ts +2 -0
- package/build/decoder/strategy/Callbacks.d.ts +7 -8
- package/build/decoder/strategy/getDecoderStateCallbacks.d.ts +2 -2
- package/build/encoder/ChangeTree.d.ts +40 -12
- package/build/encoder/Encoder.d.ts +1 -1
- package/build/encoder/Root.d.ts +9 -0
- package/build/encoder/StateView.d.ts +38 -1
- package/build/encoder/changeTree/inheritedFlags.d.ts +13 -19
- package/build/encoder/changeTree/liveIteration.d.ts +8 -0
- package/build/encoder/changeTree/parentChain.d.ts +30 -8
- package/build/encoder/streaming.d.ts +1 -1
- package/build/index.cjs +3232 -2830
- package/build/index.cjs.map +1 -1
- package/build/index.js +3228 -2826
- package/build/index.mjs +3232 -2830
- package/build/index.mjs.map +1 -1
- package/build/types/HelperTypes.d.ts +24 -14
- package/build/types/TypeContext.d.ts +0 -17
- package/build/types/builder.d.ts +1 -5
- package/build/types/symbols.d.ts +1 -0
- package/package.json +9 -8
- package/src/Metadata.ts +59 -77
- package/src/Reflection.ts +9 -5
- package/src/annotations.ts +28 -18
- package/src/codegen/api.ts +3 -1
- package/src/codegen/cli.ts +5 -2
- package/src/codegen/parser.ts +69 -31
- package/src/codegen/resolve.ts +322 -0
- package/src/codegen/types.ts +4 -1
- package/src/decoder/DecodeOperation.ts +13 -2
- package/src/decoder/strategy/Callbacks.ts +7 -8
- package/src/decoder/strategy/getDecoderStateCallbacks.ts +2 -2
- package/src/encoder/ChangeTree.ts +76 -25
- package/src/encoder/EncodeOperation.ts +10 -1
- package/src/encoder/Encoder.ts +52 -2
- package/src/encoder/Root.ts +28 -8
- package/src/encoder/StateView.ts +150 -66
- package/src/encoder/changeTree/inheritedFlags.ts +164 -45
- package/src/encoder/changeTree/liveIteration.ts +24 -3
- package/src/encoder/changeTree/parentChain.ts +72 -15
- package/src/encoder/streaming.ts +2 -1
- package/src/types/HelperTypes.ts +43 -34
- package/src/types/TypeContext.ts +5 -52
- package/src/types/builder.ts +14 -10
- package/src/types/custom/ArraySchema.ts +57 -14
- package/src/types/symbols.ts +3 -0
package/src/encoder/StateView.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChangeTree, Ref } from "./ChangeTree.js";
|
|
2
|
-
import { $changes, $fieldIndexesByViewTag, $refId, $viewFieldIndexes } from "../types/symbols.js";
|
|
2
|
+
import { $changes, $childType, $fieldIndexesByViewTag, $refId, $viewFieldIndexes } from "../types/symbols.js";
|
|
3
3
|
import { DEFAULT_VIEW_TAG } from "../annotations.js";
|
|
4
4
|
import { OPERATION } from "../encoding/spec.js";
|
|
5
5
|
import { Metadata } from "../Metadata.js";
|
|
@@ -61,6 +61,16 @@ const _disposeRegistry = new FinalizationRegistry<{ root: Root; id: number; slot
|
|
|
61
61
|
* populated collection inspects into dozens of lines of encoder
|
|
62
62
|
* internals and buries the message that matters.
|
|
63
63
|
*/
|
|
64
|
+
/**
|
|
65
|
+
* Sentinel inner-map key: "snapshot every live element of this ref-typed
|
|
66
|
+
* ArraySchema". Written by `_add`'s bulk path instead of one entry per
|
|
67
|
+
* element; `encodeView` expands it structurally at drain time, so the
|
|
68
|
+
* emitted slots reflect any reindex that happened after `view.add()` —
|
|
69
|
+
* and a whole-array snapshot costs one Map insert instead of N.
|
|
70
|
+
* Real slots are never negative, so -1 cannot collide.
|
|
71
|
+
*/
|
|
72
|
+
export const ARRAY_SNAPSHOT = -1;
|
|
73
|
+
|
|
64
74
|
function describeArg(value: any): string {
|
|
65
75
|
if (value === undefined) { return "undefined"; }
|
|
66
76
|
if (value === null) { return "null"; }
|
|
@@ -107,8 +117,16 @@ export class StateView {
|
|
|
107
117
|
* Inner storage is a Map so the encode loop in `encodeView` can iterate
|
|
108
118
|
* directly with numeric keys — the legacy `{[index]: OPERATION}` shape
|
|
109
119
|
* forced an `Object.keys(...)` allocation + `Number(key)` parse per ref.
|
|
120
|
+
*
|
|
121
|
+
* Inner keys are numbers (Schema field indexes, MapSchema journal
|
|
122
|
+
* indexes, Set/Collection indexes, stream positions — all stable within
|
|
123
|
+
* a tick), EXCEPT element bindings under a ref-typed ArraySchema parent,
|
|
124
|
+
* which are keyed by the child's ChangeTree. An array wire slot captured
|
|
125
|
+
* at `view.add()` time goes stale if the array reindexes (unshift /
|
|
126
|
+
* reverse / move) later in the same tick — identity keys let
|
|
127
|
+
* `encodeView` resolve the CURRENT slot at drain time instead.
|
|
110
128
|
*/
|
|
111
|
-
changes = new Map<number, Map<number, OPERATION>>();
|
|
129
|
+
changes = new Map<number, Map<number | ChangeTree, OPERATION>>();
|
|
112
130
|
|
|
113
131
|
constructor(public iterable: boolean = false) {
|
|
114
132
|
if (iterable) {
|
|
@@ -368,15 +386,18 @@ export class StateView {
|
|
|
368
386
|
// subclasses yield a real Metadata object.
|
|
369
387
|
const metadata: Metadata = (obj.constructor as typeof Schema)[Symbol.metadata];
|
|
370
388
|
|
|
389
|
+
const wasVisible = this.isVisible(changeTree);
|
|
390
|
+
|
|
371
391
|
// Add to iterable list (only the explicitly added items), deduping
|
|
372
|
-
// re-adds of an already-visible instance
|
|
373
|
-
//
|
|
374
|
-
// NOTE: dedup applies to `items` only — a re-add still
|
|
375
|
-
// full snapshot on purpose (shared-view bootstrap
|
|
376
|
-
// late-attached client may not have consumed earlier
|
|
377
|
-
// Callers wanting cheap idempotence can guard with
|
|
392
|
+
// re-adds of an already-visible instance; indexOf runs only on the
|
|
393
|
+
// re-add path.
|
|
394
|
+
// NOTE: dedup applies to `items` only — a default-tag re-add still
|
|
395
|
+
// re-queues the full snapshot on purpose (shared-view bootstrap
|
|
396
|
+
// re-add: a late-attached client may not have consumed earlier
|
|
397
|
+
// drains). Callers wanting cheap idempotence can guard with
|
|
398
|
+
// `view.has(obj)`.
|
|
378
399
|
if (this.iterable && checkIncludeParent
|
|
379
|
-
&& (!
|
|
400
|
+
&& (!wasVisible || this.items.indexOf(obj) === -1)) {
|
|
380
401
|
this.items.push(obj);
|
|
381
402
|
}
|
|
382
403
|
|
|
@@ -469,32 +490,51 @@ export class StateView {
|
|
|
469
490
|
});
|
|
470
491
|
}
|
|
471
492
|
}
|
|
493
|
+
}
|
|
472
494
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if (
|
|
490
|
-
tagAtIndex === undefined || // "all change" with no tag
|
|
491
|
-
tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
|
|
492
|
-
(tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
|
|
493
|
-
) {
|
|
494
|
-
changes.set(index, OPERATION.ADD);
|
|
495
|
+
// Full-sync snapshot of a non-new tree (fresh ones ship via .encode()).
|
|
496
|
+
// Also runs for custom tags when bootstrapping the tree for this view
|
|
497
|
+
// (!wasVisible) — the per-field filter admits untagged fields, and
|
|
498
|
+
// collections behind tagged fields have no `byTag`: without the
|
|
499
|
+
// snapshot their elements are never introduced ("refId" not found).
|
|
500
|
+
// A tagged add on an already-visible tree stays incremental (byTag
|
|
501
|
+
// only); default-tag re-adds re-snapshot on purpose (see `items`
|
|
502
|
+
// dedup note above).
|
|
503
|
+
if ((tag === DEFAULT_VIEW_TAG || !wasVisible) && (!changeTree.isNew || isChildAdded)) {
|
|
504
|
+
if (changeTree.isArray && typeof (changeTree.refTarget as any)[$childType] !== "string") {
|
|
505
|
+
// Ref-typed ArraySchema (the only proxied collection): one
|
|
506
|
+
// sentinel entry — encodeView snapshots the live elements at
|
|
507
|
+
// drain time, so the slots survive a same-tick reindex (see
|
|
508
|
+
// `changes` field docs) and the write stays O(1).
|
|
509
|
+
if ((changeTree.refTarget as any).items.length > 0) {
|
|
510
|
+
changes.set(ARRAY_SNAPSHOT, OPERATION.ADD);
|
|
495
511
|
isChildAdded = true;
|
|
496
512
|
}
|
|
497
|
-
|
|
513
|
+
|
|
514
|
+
} else {
|
|
515
|
+
// Full-sync snapshot: walk the live ref structurally instead of
|
|
516
|
+
// iterating a cumulative recorder bucket. Every populated index
|
|
517
|
+
// is emitted as ADD (matching the op-coercion previously done
|
|
518
|
+
// at encode time). Per-field tags come from the descriptor's
|
|
519
|
+
// precomputed `tags[]` array — direct index vs a metadata[i].tag
|
|
520
|
+
// object hop.
|
|
521
|
+
//
|
|
522
|
+
// Non-matching custom-tagged fields are NEVER included here —
|
|
523
|
+
// `view.changes` is drained without a per-field tag re-check,
|
|
524
|
+
// so anything added leaks straight to the wire.
|
|
525
|
+
const tags = changeTree.encDescriptor.tags;
|
|
526
|
+
changeTree.forEachLive((index) => {
|
|
527
|
+
const tagAtIndex = tags[index];
|
|
528
|
+
if (
|
|
529
|
+
tagAtIndex === undefined || // "all change" with no tag
|
|
530
|
+
tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
|
|
531
|
+
(tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
|
|
532
|
+
) {
|
|
533
|
+
changes.set(index, OPERATION.ADD);
|
|
534
|
+
isChildAdded = true;
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
498
538
|
}
|
|
499
539
|
|
|
500
540
|
return isChildAdded;
|
|
@@ -575,13 +615,21 @@ export class StateView {
|
|
|
575
615
|
if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
|
|
576
616
|
let changes = this.changes.get(changeTree.ref[$refId]);
|
|
577
617
|
if (changes === undefined) {
|
|
578
|
-
changes = new Map<number, OPERATION>();
|
|
618
|
+
changes = new Map<number | ChangeTree, OPERATION>();
|
|
579
619
|
this.changes.set(changeTree.ref[$refId], changes);
|
|
580
620
|
}
|
|
581
621
|
|
|
582
622
|
this.addTag(changeTree, tag);
|
|
583
623
|
|
|
584
|
-
|
|
624
|
+
// ArraySchema parents: key by the child's identity, not the wire
|
|
625
|
+
// slot it holds right now — a same-tick unshift()/reverse()/move()
|
|
626
|
+
// would shift the slot before encodeView drains this entry. Other
|
|
627
|
+
// parents keep numeric keys (Schema fields, MapSchema journal
|
|
628
|
+
// indexes and Set/Collection indexes are stable within a tick).
|
|
629
|
+
changes.set(
|
|
630
|
+
changeTree.isArray ? childChangeTree : parentIndex,
|
|
631
|
+
OPERATION.ADD,
|
|
632
|
+
);
|
|
585
633
|
}
|
|
586
634
|
}
|
|
587
635
|
|
|
@@ -641,8 +689,8 @@ export class StateView {
|
|
|
641
689
|
// out of the stream's per-view state. If it never made it to the
|
|
642
690
|
// wire (still in pending), silent drop; if already sent, queue
|
|
643
691
|
// DELETE via `view.changes` for the next encodeView drain.
|
|
644
|
-
const
|
|
645
|
-
if (
|
|
692
|
+
const parentTree = changeTree.parent?.[$changes];
|
|
693
|
+
if (parentTree?.isStreamCollection) {
|
|
646
694
|
this.unmarkVisible(changeTree);
|
|
647
695
|
if (this.iterable && !_isClear) {
|
|
648
696
|
spliceOne(this.items, this.items.indexOf(obj));
|
|
@@ -718,59 +766,46 @@ export class StateView {
|
|
|
718
766
|
// parent is collection (Map/Array)
|
|
719
767
|
const parent = changeTree.parent;
|
|
720
768
|
if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) {
|
|
769
|
+
// ArraySchema parents use identity keys (see `changes` field
|
|
770
|
+
// docs); Map parents keep the (stable) journal index.
|
|
771
|
+
const key = parentTree!.isArray
|
|
772
|
+
? changeTree
|
|
773
|
+
: changeTree.parentIndex;
|
|
721
774
|
const parentRefId = parent[$refId];
|
|
722
775
|
let changes = this.changes.get(parentRefId);
|
|
723
776
|
if (changes === undefined) {
|
|
724
|
-
changes = new Map<number, OPERATION>();
|
|
777
|
+
changes = new Map<number | ChangeTree, OPERATION>();
|
|
725
778
|
this.changes.set(parentRefId, changes);
|
|
726
779
|
|
|
727
|
-
} else if (changes.get(
|
|
780
|
+
} else if (changes.get(key) === OPERATION.ADD) {
|
|
728
781
|
//
|
|
729
782
|
// SAME PATCH ADD + REMOVE:
|
|
730
|
-
//
|
|
783
|
+
// cancel the structure's pending ops and its descendants' —
|
|
784
|
+
// their introduction never reaches this client.
|
|
731
785
|
//
|
|
732
|
-
this.
|
|
786
|
+
this._dropPendingEntries(changeTree);
|
|
733
787
|
}
|
|
734
788
|
|
|
735
789
|
// DELETE / DELETE BY REF ID
|
|
736
|
-
changes.set(
|
|
790
|
+
changes.set(key, OPERATION.DELETE);
|
|
737
791
|
|
|
738
792
|
// Remove child schema from visible set
|
|
739
793
|
this._recursiveDeleteVisibleChangeTree(changeTree);
|
|
740
794
|
|
|
741
795
|
} else {
|
|
742
796
|
// delete all "tagged" properties.
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
changes.set(index, OPERATION.DELETE);
|
|
746
|
-
|
|
747
|
-
// Remove child structures of @view() fields from visible set.
|
|
748
|
-
// (They were added during view.add() via forEachChild)
|
|
749
|
-
const value = changeTree.ref[names[index] as keyof Ref];
|
|
750
|
-
if (value?.[$changes]) {
|
|
751
|
-
this.unmarkVisible(value[$changes]);
|
|
752
|
-
this._recursiveDeleteVisibleChangeTree(value[$changes]);
|
|
753
|
-
}
|
|
754
|
-
});
|
|
797
|
+
metadata?.[$viewFieldIndexes]?.forEach((index) =>
|
|
798
|
+
this._removeViewField(changeTree, changes, index));
|
|
755
799
|
}
|
|
756
800
|
|
|
757
801
|
} else {
|
|
758
802
|
// delete only tagged properties. `$fieldIndexesByViewTag` is
|
|
759
803
|
// keyed per-bit, so a combined tag iterates each set bit.
|
|
760
|
-
const names = changeTree.encDescriptor.names;
|
|
761
804
|
const byTag = metadata?.[$fieldIndexesByViewTag];
|
|
762
805
|
if (byTag !== undefined) {
|
|
763
806
|
for (let bits = tag; bits > 0; bits &= bits - 1) {
|
|
764
|
-
byTag[bits & -bits]?.forEach((index) =>
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
// Remove child structures from visible set
|
|
768
|
-
const value = changeTree.ref[names[index] as keyof Ref];
|
|
769
|
-
if (value?.[$changes]) {
|
|
770
|
-
this.unmarkVisible(value[$changes]);
|
|
771
|
-
this._recursiveDeleteVisibleChangeTree(value[$changes]);
|
|
772
|
-
}
|
|
773
|
-
});
|
|
807
|
+
byTag[bits & -bits]?.forEach((index) =>
|
|
808
|
+
this._removeViewField(changeTree, changes, index));
|
|
774
809
|
}
|
|
775
810
|
}
|
|
776
811
|
}
|
|
@@ -956,14 +991,16 @@ export class StateView {
|
|
|
956
991
|
} else {
|
|
957
992
|
// Non-streams: queue DELETE for every current child and
|
|
958
993
|
// unmark their visibility so subsequent mutations stop
|
|
959
|
-
// reaching this view.
|
|
994
|
+
// reaching this view. ArraySchema children are keyed by identity
|
|
995
|
+
// (see `changes` field docs); others by their stable index.
|
|
996
|
+
const isArray = tree.isArray;
|
|
960
997
|
let changes = this.changes.get(collectionRefId);
|
|
961
998
|
tree.forEachChild((childTree, index) => {
|
|
962
999
|
if (changes === undefined) {
|
|
963
1000
|
changes = new Map();
|
|
964
1001
|
this.changes.set(collectionRefId, changes);
|
|
965
1002
|
}
|
|
966
|
-
changes.set(index, OPERATION.DELETE);
|
|
1003
|
+
changes.set(isArray ? childTree : index, OPERATION.DELETE);
|
|
967
1004
|
this.unmarkVisible(childTree);
|
|
968
1005
|
});
|
|
969
1006
|
}
|
|
@@ -999,9 +1036,24 @@ export class StateView {
|
|
|
999
1036
|
// `markVisible` memoizes so the branch fires at most once per
|
|
1000
1037
|
// (tree, view) pair.
|
|
1001
1038
|
if (!isVisible && changeTree.isVisibilitySharedWithParent){
|
|
1039
|
+
// Primary grant is intentionally unguarded — pre-existing
|
|
1040
|
+
// semantics; the extras walk below is stricter on purpose.
|
|
1002
1041
|
if (this.isVisible(changeTree.parent[$changes])) {
|
|
1003
1042
|
this.markVisible(changeTree);
|
|
1004
1043
|
isVisible = true;
|
|
1044
|
+
} else {
|
|
1045
|
+
// Shared instance: the sharing parent may sit anywhere in the
|
|
1046
|
+
// chain — addParent promotes the LAST container to primary.
|
|
1047
|
+
// Only filtered parents can grant (public ones never share
|
|
1048
|
+
// visibility downward).
|
|
1049
|
+
for (let e = changeTree.extraParents; e !== undefined; e = e.next) {
|
|
1050
|
+
const parentTree = e.ref[$changes];
|
|
1051
|
+
if (parentTree.isFiltered && this.isVisible(parentTree)) {
|
|
1052
|
+
this.markVisible(changeTree);
|
|
1053
|
+
isVisible = true;
|
|
1054
|
+
break;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1005
1057
|
}
|
|
1006
1058
|
}
|
|
1007
1059
|
|
|
@@ -1014,4 +1066,36 @@ export class StateView {
|
|
|
1014
1066
|
this._recursiveDeleteVisibleChangeTree(childChangeTree);
|
|
1015
1067
|
});
|
|
1016
1068
|
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Drop the pending `view.changes` entries of `tree` and every descendant.
|
|
1072
|
+
* Called when a same-patch pending ADD is cancelled: the subtree's
|
|
1073
|
+
* introduction never reaches this client, so its entries would emit
|
|
1074
|
+
* refIds the decoder cannot resolve ("refId" not found).
|
|
1075
|
+
*/
|
|
1076
|
+
private _dropPendingEntries(tree: ChangeTree): void {
|
|
1077
|
+
this.changes.delete(tree.ref[$refId]);
|
|
1078
|
+
tree.forEachChild((child) => this._dropPendingEntries(child));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Queue DELETE for a @view field on `changes` and hide the field
|
|
1083
|
+
* value's subtree from this view. When the field's ADD is still
|
|
1084
|
+
* pending (same-patch add + remove), the value's introduction never
|
|
1085
|
+
* ships — its pending subtree entries are dropped along with it.
|
|
1086
|
+
*/
|
|
1087
|
+
private _removeViewField(changeTree: ChangeTree, changes: Map<number | ChangeTree, OPERATION>, index: number): void {
|
|
1088
|
+
const wasPendingAdd = changes.get(index) === OPERATION.ADD;
|
|
1089
|
+
changes.set(index, OPERATION.DELETE);
|
|
1090
|
+
|
|
1091
|
+
const value = changeTree.ref[changeTree.encDescriptor.names[index] as keyof Ref];
|
|
1092
|
+
const valueTree: ChangeTree = value?.[$changes];
|
|
1093
|
+
if (valueTree) {
|
|
1094
|
+
this.unmarkVisible(valueTree);
|
|
1095
|
+
this._recursiveDeleteVisibleChangeTree(valueTree);
|
|
1096
|
+
if (wasPendingAdd) {
|
|
1097
|
+
this._dropPendingEntries(valueTree);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1017
1101
|
}
|
|
@@ -13,16 +13,17 @@ import {
|
|
|
13
13
|
// (see INHERITABLE_FLAGS comment in ChangeTree.ts). Per-field unreliable
|
|
14
14
|
// routing on primitive fields still uses it via `isFieldUnreliable()`.
|
|
15
15
|
} from "../../types/symbols.js";
|
|
16
|
-
import type { Schema } from "../../Schema.js";
|
|
17
16
|
import {
|
|
18
|
-
INHERITABLE_FLAGS, IS_FULL_STATE_ONLY, IS_PATCH_ONLY,
|
|
17
|
+
INHERITABLE_FLAGS, IS_FULL_STATE_ONLY, IS_PATCH_ONLY, PENDING_FILTER_REFRESH,
|
|
19
18
|
// IS_UNRELIABLE — tree-level unreliable currently disabled; see
|
|
20
19
|
// INHERITABLE_FLAGS comment in ChangeTree.ts.
|
|
21
20
|
type ChangeTree, type Ref,
|
|
22
21
|
} from "../ChangeTree.js";
|
|
23
22
|
import type { ICollectionChangeRecorder } from "../ChangeRecorder.js";
|
|
24
|
-
import type { Streamable } from "../Root.js";
|
|
23
|
+
import type { Root, Streamable } from "../Root.js";
|
|
25
24
|
import { ensureStreamState } from "../streaming.js";
|
|
25
|
+
import { restageLiveCb } from "./liveIteration.js";
|
|
26
|
+
import { isEdgeLive } from "./parentChain.js";
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Reconcile queue membership + inherited flags for a tree that just had
|
|
@@ -71,25 +72,13 @@ export function checkIsFiltered(
|
|
|
71
72
|
* etc.) inherit these from the Schema field that holds them.
|
|
72
73
|
*
|
|
73
74
|
* The common case — fresh tree attached to a parent field that carries
|
|
74
|
-
* none of the inheritable annotations — produces no flag change
|
|
75
|
-
* queue update
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* inherited bits come from `parentChangeTree.flags` directly; one
|
|
82
|
-
* read-modify-write replaces three getter/setter cycles, and the
|
|
83
|
-
* bit diff against `beforeFlags` gives us the "just became static /
|
|
84
|
-
* unreliable" signal for the side-effect branches.
|
|
85
|
-
*
|
|
86
|
-
* 2) The `parentFiltered` string-key lookup is gated on
|
|
87
|
-
* `types.hasParentFilteredEntries`, which is only flipped true when
|
|
88
|
-
* `registerFilteredByParent` actually records an entry — i.e. when
|
|
89
|
-
* some @view-tagged field reaches this (child, parent, index)
|
|
90
|
-
* triple through the ancestry walk. Schemas with @view tags only on
|
|
91
|
-
* sibling fields (not along any attachment chain) skip the string
|
|
92
|
-
* concat + hash lookup entirely.
|
|
75
|
+
* none of the inheritable annotations — produces no flag change and no
|
|
76
|
+
* queue update. Flag inheritance is a single bitwise OR onto
|
|
77
|
+
* `tree.flags`: the per-annotation reads pack into `fieldBits`, the
|
|
78
|
+
* parent's inherited bits come from `parentChangeTree.flags` directly,
|
|
79
|
+
* and one read-modify-write replaces three getter/setter cycles. The bit
|
|
80
|
+
* diff against `beforeFlags` gives the "just became static / unreliable"
|
|
81
|
+
* signal for the side-effect branches.
|
|
93
82
|
*/
|
|
94
83
|
export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex: number): void {
|
|
95
84
|
if (!parent) { return; }
|
|
@@ -97,15 +86,17 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
|
|
|
97
86
|
// Walk up a collection level so `parent` lands on the Schema that
|
|
98
87
|
// owns the field at `parentIndex`. Field annotations live on Schema
|
|
99
88
|
// metadata; collections have none.
|
|
100
|
-
|
|
101
|
-
const parentIsCollection = !
|
|
89
|
+
const parentChangeTree: ChangeTree = parent[$changes];
|
|
90
|
+
const parentIsCollection = !parentChangeTree._isSchema;
|
|
91
|
+
let parentMetadata: any;
|
|
102
92
|
if (parentIsCollection) {
|
|
103
93
|
parent = parentChangeTree.parent;
|
|
104
94
|
parentIndex = parentChangeTree.parentIndex;
|
|
95
|
+
parentMetadata = parent?.[$changes].metadata;
|
|
96
|
+
} else {
|
|
97
|
+
parentMetadata = parentChangeTree.metadata;
|
|
105
98
|
}
|
|
106
99
|
|
|
107
|
-
const parentMetadata: any = (parent as any)?.constructor?.[Symbol.metadata];
|
|
108
|
-
|
|
109
100
|
// Flag inheritance — pack the patchOnly/static annotation checks into
|
|
110
101
|
// flag bits alongside the parent's own transitive flags, then OR onto
|
|
111
102
|
// `tree.flags` in one write. The bit diff tells us which flag just
|
|
@@ -159,21 +150,12 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
|
|
|
159
150
|
// pass is the only way elements become visible to a view.
|
|
160
151
|
const fieldHasStream = parentMetadata?.[$streamFieldIndexes]?.includes(parentIndex) ?? false;
|
|
161
152
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
if (types.hasParentFilteredEntries && parentConstructor !== undefined) {
|
|
169
|
-
const refType = Metadata.isValidInstance(tree.ref)
|
|
170
|
-
? tree.ref.constructor
|
|
171
|
-
: (tree.ref as any)[$childType];
|
|
172
|
-
const key = `${types.getTypeId(refType as typeof Schema)}-${types.schemas.get(parentConstructor)}-${parentIndex}`;
|
|
173
|
-
parentFiltered = types.parentFiltered[key] ?? false;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const newFiltered = parentChangeTree.isFiltered || parentFiltered || fieldHasViewTag || fieldHasStream;
|
|
153
|
+
// Filtering is a property of the *attachment*, never of the child class:
|
|
154
|
+
// the same Schema class may sit under a @view field here and under a
|
|
155
|
+
// public field there (#204). `parentChangeTree.isFiltered` carries the
|
|
156
|
+
// ancestry — `setRoot` derives it parent-first before recursing — so the
|
|
157
|
+
// field annotation only has to answer for this one edge.
|
|
158
|
+
const newFiltered = parentChangeTree.isFiltered || fieldHasViewTag || fieldHasStream;
|
|
177
159
|
tree.isFiltered = newFiltered;
|
|
178
160
|
|
|
179
161
|
// Flag collection trees attached to a `.stream()` field so the encoder
|
|
@@ -204,9 +186,7 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
|
|
|
204
186
|
}
|
|
205
187
|
|
|
206
188
|
if (newFiltered) {
|
|
207
|
-
const
|
|
208
|
-
? tree.ref.constructor
|
|
209
|
-
: (tree.ref as any)[$childType];
|
|
189
|
+
const sharesEligible = _sharesEligible(tree);
|
|
210
190
|
// #218: nested Schema fields inherit visibility from a @view-gated
|
|
211
191
|
// parent regardless of whether the parent is a collection. The
|
|
212
192
|
// `parentIsCollection` constraint that used to live here blocked
|
|
@@ -223,9 +203,148 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
|
|
|
223
203
|
// are guaranteed to exist when that flag is set).
|
|
224
204
|
tree.isVisibilitySharedWithParent = (
|
|
225
205
|
parentChangeTree.isFiltered
|
|
226
|
-
&&
|
|
206
|
+
&& sharesEligible
|
|
227
207
|
&& !fieldHasStream
|
|
228
208
|
&& (!fieldHasViewTag || (parentIsCollection && parentMetadata[parentIndex].tag !== DEFAULT_VIEW_TAG))
|
|
229
209
|
);
|
|
230
210
|
}
|
|
231
211
|
}
|
|
212
|
+
|
|
213
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
214
|
+
// Per-edge filter refresh — instance sharing across a @view boundary.
|
|
215
|
+
//
|
|
216
|
+
// `checkIsFiltered` classifies a tree from the edge it was FIRST attached
|
|
217
|
+
// through. A shared instance has N parent edges with different visibility,
|
|
218
|
+
// and the wire emits field data per-refId per-channel — so the tree-level
|
|
219
|
+
// invariant is:
|
|
220
|
+
//
|
|
221
|
+
// isFiltered ⇔ no fully-public root path reaches this tree
|
|
222
|
+
//
|
|
223
|
+
// Rather than reconciling eagerly at every attach/detach (whose ordering
|
|
224
|
+
// against the container's own storage mutation is fragile), edge events
|
|
225
|
+
// call `Root.enqueueFilterRefresh` and the encoder re-derives the flags at
|
|
226
|
+
// the top of the next encode — after every container mutation of the tick
|
|
227
|
+
// has settled — via `drainFilterRefresh`. `isFiltered` is only CONSUMED at
|
|
228
|
+
// encode time (recording is channel-agnostic), so the deferral is safe for
|
|
229
|
+
// wire routing; only same-tick StateView bootstrap reads see the stale
|
|
230
|
+
// flags, which at worst emits redundant (deduped) entries.
|
|
231
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Drain `root.pendingFilterRefresh`. Called by the encoder before any
|
|
235
|
+
* emission (per-tick channels and full-sync).
|
|
236
|
+
*/
|
|
237
|
+
export function drainFilterRefresh(root: Root): void {
|
|
238
|
+
const list = root.pendingFilterRefresh;
|
|
239
|
+
for (let i = 0; i < list.length; i++) {
|
|
240
|
+
const tree = list[i];
|
|
241
|
+
// Already settled as another entry's parent, or detached/recycled
|
|
242
|
+
// since it was queued.
|
|
243
|
+
if ((tree.flags & PENDING_FILTER_REFRESH) === 0) continue;
|
|
244
|
+
refreshFilterState(tree);
|
|
245
|
+
}
|
|
246
|
+
list.length = 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Primitive-element collections never share visibility downward. One
|
|
251
|
+
* predicate for both derivations (`checkInheritedFlags` and
|
|
252
|
+
* `refreshFilterState`) — the InstanceSharing invariant test pins them
|
|
253
|
+
* together. `_isSchema` short-circuits the `$childType` probe for Schema
|
|
254
|
+
* trees (whose `$childType` is undefined and would pass anyway).
|
|
255
|
+
*/
|
|
256
|
+
function _sharesEligible(tree: ChangeTree): boolean {
|
|
257
|
+
return tree._isSchema || typeof (tree.refTarget as any)[$childType] !== "string";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Re-derive `isFiltered` (AND over live edges) and
|
|
262
|
+
* `isVisibilitySharedWithParent` (OR over live edges) from the parent
|
|
263
|
+
* chain. On a filtered→public flip, live state is re-staged — it may have
|
|
264
|
+
* already drained to view channels only, and clients that hold it decode
|
|
265
|
+
* the duplicate ADDs as no-ops (StateView bootstrap re-adds rely on the
|
|
266
|
+
* same property). The public→filtered flip needs no re-stage: the public
|
|
267
|
+
* container's DELETE already ships on the shared channel.
|
|
268
|
+
*
|
|
269
|
+
* A flip cascades into children so classifications inherited through this
|
|
270
|
+
* tree follow it; re-derivation is idempotent and a child that does not
|
|
271
|
+
* flip does not recurse, so the walk terminates on cyclic instance graphs.
|
|
272
|
+
*/
|
|
273
|
+
function refreshFilterState(tree: ChangeTree): void {
|
|
274
|
+
tree.flags &= ~PENDING_FILTER_REFRESH;
|
|
275
|
+
const root = tree.root;
|
|
276
|
+
if (root === undefined || tree.parentRef === undefined) return;
|
|
277
|
+
|
|
278
|
+
const sharesEligible = _sharesEligible(tree);
|
|
279
|
+
|
|
280
|
+
let bits = _edgeBits(tree, tree.parentRef, tree._parentIndex, sharesEligible);
|
|
281
|
+
// Saturated means no further edge can change the outcome.
|
|
282
|
+
for (let e = tree.extraParents; e !== undefined && bits !== EDGE_SATURATED; e = e.next) {
|
|
283
|
+
bits |= _edgeBits(tree, e.ref, e.index, sharesEligible);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// No live edge resolved (mid-detach churn) — keep the current
|
|
287
|
+
// classification rather than guess.
|
|
288
|
+
if (bits === 0) return;
|
|
289
|
+
|
|
290
|
+
tree.isVisibilitySharedWithParent = (bits & EDGE_SHARES) !== 0;
|
|
291
|
+
|
|
292
|
+
const newFiltered = (bits & EDGE_PUBLIC) === 0;
|
|
293
|
+
if (newFiltered === tree.isFiltered) return;
|
|
294
|
+
tree.isFiltered = newFiltered;
|
|
295
|
+
|
|
296
|
+
// Became public: clients that only ever had the view channel never saw
|
|
297
|
+
// this state. Static trees ship via structural walk instead.
|
|
298
|
+
if (!newFiltered && !tree.isFullStateOnly) {
|
|
299
|
+
tree.forEachLiveWithCtx(tree, restageLiveCb);
|
|
300
|
+
if (tree.has()) root.enqueueChangeTree(tree);
|
|
301
|
+
if (tree.unreliableRecorder?.has()) root.enqueueUnreliable(tree);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
tree.forEachChildWithCtx(tree, _cascadeRefreshCb);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const EDGE_LIVE = 1, EDGE_PUBLIC = 2, EDGE_SHARES = 4;
|
|
308
|
+
const EDGE_SATURATED = EDGE_LIVE | EDGE_PUBLIC | EDGE_SHARES;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Classify one parent edge: is it live, does it make the tree publicly
|
|
312
|
+
* reachable, does view visibility flow through it.
|
|
313
|
+
*/
|
|
314
|
+
function _edgeBits(tree: ChangeTree, parentRef: Ref, index: number, sharesEligible: boolean): number {
|
|
315
|
+
const parentTree: ChangeTree = parentRef[$changes];
|
|
316
|
+
if (parentTree.root !== tree.root || !isEdgeLive(tree, parentTree, index)) return 0;
|
|
317
|
+
|
|
318
|
+
// A queued parent must settle first — this edge reads its `isFiltered`.
|
|
319
|
+
// The flag-clear on entry terminates cycles, and a cascade re-entering
|
|
320
|
+
// `tree` is idempotent (same edges, same result — the outer pass then
|
|
321
|
+
// sees "no change").
|
|
322
|
+
if (parentTree.flags & PENDING_FILTER_REFRESH) refreshFilterState(parentTree);
|
|
323
|
+
|
|
324
|
+
let bits = EDGE_LIVE;
|
|
325
|
+
if (parentTree._isSchema) {
|
|
326
|
+
// A @view/stream-marked field stays filtered even under a public
|
|
327
|
+
// parent, and never shares visibility downward.
|
|
328
|
+
const marked = parentTree.encDescriptor.tags[index] !== undefined
|
|
329
|
+
|| parentTree.isFieldStream(index);
|
|
330
|
+
if (!marked) {
|
|
331
|
+
if (!parentTree.isFiltered) bits |= EDGE_PUBLIC;
|
|
332
|
+
else if (sharesEligible) bits |= EDGE_SHARES;
|
|
333
|
+
}
|
|
334
|
+
} else if (!parentTree.isFiltered) {
|
|
335
|
+
// Collection edge: the collection's own classification already
|
|
336
|
+
// folds in the field that holds it.
|
|
337
|
+
bits |= EDGE_PUBLIC;
|
|
338
|
+
} else if (sharesEligible && !parentTree.isStreamCollection) {
|
|
339
|
+
// #226: default-tag @view() collections keep per-item gating;
|
|
340
|
+
// untagged and non-default-tag @view(N) ones share.
|
|
341
|
+
const gp = parentTree.parent?.[$changes];
|
|
342
|
+
const tag = gp?._isSchema ? gp.encDescriptor.tags[parentTree.parentIndex] : undefined;
|
|
343
|
+
if (tag !== DEFAULT_VIEW_TAG) bits |= EDGE_SHARES;
|
|
344
|
+
}
|
|
345
|
+
return bits;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const _cascadeRefreshCb = (_parentTree: ChangeTree, child: ChangeTree, _index: any): void => {
|
|
349
|
+
refreshFilterState(child);
|
|
350
|
+
};
|
|
@@ -6,10 +6,31 @@
|
|
|
6
6
|
* Patch-only fields (`@patchOnly`) are skipped — they're delivered only on
|
|
7
7
|
* tick patches and not persisted to snapshots. Collections whose parent
|
|
8
8
|
* field is @patchOnly inherit the skip (`tree.isPatchOnly`).
|
|
9
|
+
*
|
|
10
|
+
* `@deprecated()` fields are skipped too: the decorator swaps the field's
|
|
11
|
+
* prototype accessor for a throwing getter, so `ref[name]` below would blow
|
|
12
|
+
* up full sync for the whole state. Both skips ride one decoration-time
|
|
13
|
+
* list (`$fullSyncSkipIndexes`) so the walk pays a single metadata lookup.
|
|
9
14
|
*/
|
|
10
|
-
import {
|
|
15
|
+
import { OPERATION } from "../../encoding/spec.js";
|
|
16
|
+
import { $childType, $numFields, $fullSyncSkipIndexes } from "../../types/symbols.js";
|
|
11
17
|
import type { ChangeTree } from "../ChangeTree.js";
|
|
12
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Re-stage one live index as a fresh ADD on its channel. Shared by
|
|
21
|
+
* `Root.add` (refCount-0 / NEEDS_RESTAGE re-adds) and
|
|
22
|
+
* `inheritedFlags.refreshFilterState` (filtered→public flip) via
|
|
23
|
+
* `forEachLiveWithCtx(tree, restageLiveCb)` — one home for the
|
|
24
|
+
* unreliable-routing rule.
|
|
25
|
+
*/
|
|
26
|
+
export const restageLiveCb = (tree: ChangeTree, fieldIndex: number): void => {
|
|
27
|
+
if (tree.isFieldUnreliable(fieldIndex)) {
|
|
28
|
+
tree.ensureUnreliableRecorder().record(fieldIndex, OPERATION.ADD);
|
|
29
|
+
} else {
|
|
30
|
+
tree.record(fieldIndex, OPERATION.ADD);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
13
34
|
// Adapter that lets `forEachLive(cb)` delegate to `forEachLiveWithCtx(cb, _invokeNoCtx)` —
|
|
14
35
|
// keeps the no-ctx path closure-free and shares one walker implementation.
|
|
15
36
|
const _invokeNoCtx = (cb: (index: number) => void, index: number) => cb(index);
|
|
@@ -65,12 +86,12 @@ export function forEachLiveWithCtx<C>(
|
|
|
65
86
|
const metadata = tree.metadata;
|
|
66
87
|
if (!metadata) return;
|
|
67
88
|
const numFields = (metadata[$numFields] ?? -1) as number;
|
|
68
|
-
const
|
|
89
|
+
const skipIndexes = metadata[$fullSyncSkipIndexes] as number[] | undefined;
|
|
69
90
|
const names = tree.encDescriptor.names;
|
|
70
91
|
for (let i = 0; i <= numFields; i++) {
|
|
71
92
|
const name = names[i];
|
|
72
93
|
if (name === undefined) continue;
|
|
73
|
-
if (
|
|
94
|
+
if (skipIndexes && skipIndexes.includes(i)) continue;
|
|
74
95
|
const value = ref[name];
|
|
75
96
|
if (value !== undefined && value !== null) cb(ctx, i);
|
|
76
97
|
}
|