@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.
Files changed (54) hide show
  1. package/README.md +11 -5
  2. package/build/Metadata.d.ts +10 -1
  3. package/build/annotations.d.ts +6 -5
  4. package/build/codegen/api.d.ts +2 -0
  5. package/build/codegen/cli.cjs +322 -31
  6. package/build/codegen/cli.cjs.map +1 -1
  7. package/build/codegen/parser.d.ts +6 -1
  8. package/build/codegen/resolve.d.ts +25 -0
  9. package/build/codegen/types.d.ts +2 -0
  10. package/build/decoder/strategy/Callbacks.d.ts +7 -8
  11. package/build/decoder/strategy/getDecoderStateCallbacks.d.ts +2 -2
  12. package/build/encoder/ChangeTree.d.ts +40 -12
  13. package/build/encoder/Encoder.d.ts +1 -1
  14. package/build/encoder/Root.d.ts +9 -0
  15. package/build/encoder/StateView.d.ts +38 -1
  16. package/build/encoder/changeTree/inheritedFlags.d.ts +13 -19
  17. package/build/encoder/changeTree/liveIteration.d.ts +8 -0
  18. package/build/encoder/changeTree/parentChain.d.ts +30 -8
  19. package/build/encoder/streaming.d.ts +1 -1
  20. package/build/index.cjs +3232 -2830
  21. package/build/index.cjs.map +1 -1
  22. package/build/index.js +3228 -2826
  23. package/build/index.mjs +3232 -2830
  24. package/build/index.mjs.map +1 -1
  25. package/build/types/HelperTypes.d.ts +24 -14
  26. package/build/types/TypeContext.d.ts +0 -17
  27. package/build/types/builder.d.ts +1 -5
  28. package/build/types/symbols.d.ts +1 -0
  29. package/package.json +9 -8
  30. package/src/Metadata.ts +59 -77
  31. package/src/Reflection.ts +9 -5
  32. package/src/annotations.ts +28 -18
  33. package/src/codegen/api.ts +3 -1
  34. package/src/codegen/cli.ts +5 -2
  35. package/src/codegen/parser.ts +69 -31
  36. package/src/codegen/resolve.ts +322 -0
  37. package/src/codegen/types.ts +4 -1
  38. package/src/decoder/DecodeOperation.ts +13 -2
  39. package/src/decoder/strategy/Callbacks.ts +7 -8
  40. package/src/decoder/strategy/getDecoderStateCallbacks.ts +2 -2
  41. package/src/encoder/ChangeTree.ts +76 -25
  42. package/src/encoder/EncodeOperation.ts +10 -1
  43. package/src/encoder/Encoder.ts +52 -2
  44. package/src/encoder/Root.ts +28 -8
  45. package/src/encoder/StateView.ts +150 -66
  46. package/src/encoder/changeTree/inheritedFlags.ts +164 -45
  47. package/src/encoder/changeTree/liveIteration.ts +24 -3
  48. package/src/encoder/changeTree/parentChain.ts +72 -15
  49. package/src/encoder/streaming.ts +2 -1
  50. package/src/types/HelperTypes.ts +43 -34
  51. package/src/types/TypeContext.ts +5 -52
  52. package/src/types/builder.ts +14 -10
  53. package/src/types/custom/ArraySchema.ts +57 -14
  54. package/src/types/symbols.ts +3 -0
@@ -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. isVisible must be read
373
- // BEFORE markVisible; indexOf runs only on the re-add path.
374
- // NOTE: dedup applies to `items` only — a re-add still re-queues the
375
- // full snapshot on purpose (shared-view bootstrap re-add: a
376
- // late-attached client may not have consumed earlier drains).
377
- // Callers wanting cheap idempotence can guard with `view.has(obj)`.
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
- && (!this.isVisible(changeTree) || this.items.indexOf(obj) === -1)) {
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
- } else if (!changeTree.isNew || isChildAdded) {
474
- // new structures will be added as part of .encode() call, no need to force it to .encodeView()
475
-
476
- // Full-sync snapshot: walk the live ref structurally instead of
477
- // iterating a cumulative recorder bucket. Every populated index
478
- // is emitted as ADD (matching the op-coercion previously done
479
- // at encode time). Per-field tags come from the descriptor's
480
- // precomputed `tags[]` array — direct index vs a metadata[i].tag
481
- // object hop.
482
- //
483
- // Non-matching custom-tagged fields are NEVER included here —
484
- // `view.changes` is drained without a per-field tag re-check,
485
- // so anything added leaks straight to the wire.
486
- const tags = changeTree.encDescriptor.tags;
487
- changeTree.forEachLive((index) => {
488
- const tagAtIndex = tags[index];
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
- changes.set(parentIndex, OPERATION.ADD);
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 parentStreamTree = changeTree.parent?.[$changes];
645
- if (parentStreamTree?.isStreamCollection) {
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(changeTree.parentIndex) === OPERATION.ADD) {
780
+ } else if (changes.get(key) === OPERATION.ADD) {
728
781
  //
729
782
  // SAME PATCH ADD + REMOVE:
730
- // The 'changes' of deleted structure should be ignored.
783
+ // cancel the structure's pending ops and its descendants'
784
+ // their introduction never reaches this client.
731
785
  //
732
- this.changes.delete(refId);
786
+ this._dropPendingEntries(changeTree);
733
787
  }
734
788
 
735
789
  // DELETE / DELETE BY REF ID
736
- changes.set(changeTree.parentIndex, OPERATION.DELETE);
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
- const names = changeTree.encDescriptor.names;
744
- metadata?.[$viewFieldIndexes]?.forEach((index) => {
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
- changes.set(index, OPERATION.DELETE);
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, no
75
- * queue update, and no `parentFiltered` hit. Two small structural
76
- * choices keep that case cheap without any precomputed descriptor
77
- * bitmask:
78
- *
79
- * 1) Flag inheritance is a single bitwise OR onto `tree.flags`. The
80
- * three per-annotation reads pack into `fieldBits`, the parent's
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
- let parentChangeTree: ChangeTree = parent[$changes];
101
- const parentIsCollection = !Metadata.isValidInstance(parent);
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
- // Skip the `parentFiltered` string-key lookup when no class has
163
- // actually registered filter inheritance via ancestry. The lookup
164
- // cannot hit in that state, so the string concat + hash lookup would
165
- // be wasted work every attach.
166
- let parentFiltered = false;
167
- const parentConstructor = (parent as any)?.constructor as typeof Schema | undefined;
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 refType = Metadata.isValidInstance(tree.ref)
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
- && typeof refType !== "string"
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 { $childType, $numFields, $patchOnlyFieldIndexes } from "../../types/symbols.js";
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 patchOnlyIndexes = metadata[$patchOnlyFieldIndexes];
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 (patchOnlyIndexes && patchOnlyIndexes.includes(i)) continue;
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
  }