@colyseus/schema 5.0.10 → 5.0.12

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 (58) hide show
  1. package/build/Metadata.d.ts +20 -12
  2. package/build/annotations.d.ts +23 -10
  3. package/build/codegen/cli.cjs +615 -204
  4. package/build/codegen/cli.cjs.map +1 -1
  5. package/build/codegen/languages/dart.d.ts +20 -0
  6. package/build/codegen/types.d.ts +20 -0
  7. package/build/decoder/Resync.d.ts +3 -3
  8. package/build/encoder/ChangeTree.d.ts +22 -10
  9. package/build/encoder/EncodeDescriptor.d.ts +11 -12
  10. package/build/encoder/StateView.d.ts +26 -8
  11. package/build/encoder/changeTree/inheritedFlags.d.ts +1 -1
  12. package/build/encoder/streaming.d.ts +7 -0
  13. package/build/index.cjs +393 -287
  14. package/build/index.cjs.map +1 -1
  15. package/build/index.d.ts +1 -1
  16. package/build/index.js +393 -287
  17. package/build/index.mjs +392 -286
  18. package/build/index.mjs.map +1 -1
  19. package/build/types/builder.d.ts +31 -22
  20. package/build/types/custom/StreamSchema.d.ts +1 -1
  21. package/build/types/symbols.d.ts +4 -10
  22. package/package.json +1 -1
  23. package/src/Metadata.ts +58 -31
  24. package/src/annotations.ts +56 -32
  25. package/src/codegen/api.ts +2 -1
  26. package/src/codegen/languages/c.ts +21 -3
  27. package/src/codegen/languages/csharp.ts +7 -1
  28. package/src/codegen/languages/dart.ts +274 -0
  29. package/src/codegen/languages/haxe.ts +7 -1
  30. package/src/codegen/languages/lua.ts +16 -4
  31. package/src/codegen/languages/ts.ts +5 -0
  32. package/src/codegen/parser.ts +97 -3
  33. package/src/codegen/types.ts +24 -0
  34. package/src/decoder/Resync.ts +8 -8
  35. package/src/encoder/ChangeRecorder.ts +1 -1
  36. package/src/encoder/ChangeTree.ts +41 -28
  37. package/src/encoder/EncodeDescriptor.ts +17 -38
  38. package/src/encoder/EncodeOperation.ts +3 -1
  39. package/src/encoder/Encoder.ts +100 -37
  40. package/src/encoder/Root.ts +18 -20
  41. package/src/encoder/StateView.ts +118 -47
  42. package/src/encoder/changeTree/inheritedFlags.ts +10 -10
  43. package/src/encoder/changeTree/liveIteration.ts +9 -9
  44. package/src/encoder/streaming.ts +8 -0
  45. package/src/encoding/spec.ts +1 -1
  46. package/src/index.ts +2 -2
  47. package/src/types/builder.ts +35 -31
  48. package/src/types/custom/StreamSchema.ts +1 -1
  49. package/src/types/symbols.ts +4 -11
  50. package/src/bench_bloat.ts +0 -173
  51. package/src/bench_churn.ts +0 -121
  52. package/src/bench_decode.ts +0 -221
  53. package/src/bench_decode_mem.ts +0 -165
  54. package/src/bench_encode.ts +0 -108
  55. package/src/bench_init.ts +0 -150
  56. package/src/bench_static.ts +0 -109
  57. package/src/bench_stream.ts +0 -295
  58. package/src/bench_view_cmp.ts +0 -142
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * - parentChain.ts addParent / removeParent / find / has / getAll
10
10
  * - liveIteration.ts forEachLive
11
- * - inheritedFlags.ts filter / unreliable / transient / static inheritance
11
+ * - inheritedFlags.ts filter / unreliable / patchOnly / static inheritance
12
12
  * - treeAttachment.ts setRoot / setParent / forEachChild(+WithCtx)
13
13
  *
14
14
  * Public surface on ChangeTree is unchanged — methods are thin pass-throughs
@@ -97,18 +97,19 @@ export interface ChangeTreeNode {
97
97
  changeTree: ChangeTree;
98
98
  next?: ChangeTreeNode;
99
99
  prev?: ChangeTreeNode;
100
- position: number; // Cached position in the linked list for O(1) lookup
100
+ position: number; // strictly increasing along the list O(1) order test
101
101
  }
102
102
 
103
103
  // Linked list for change trees
104
104
  export interface ChangeTreeList {
105
105
  next?: ChangeTreeNode;
106
106
  tail?: ChangeTreeNode;
107
+ nextPosition: number; // monotonic per drain cycle (resets when list empties)
107
108
  }
108
109
 
109
110
  // Linked list helper functions
110
111
  export function createChangeTreeList(): ChangeTreeList {
111
- return { next: undefined, tail: undefined };
112
+ return { next: undefined, tail: undefined, nextPosition: 0 };
112
113
  }
113
114
 
114
115
  export interface ParentChain {
@@ -117,10 +118,10 @@ export interface ParentChain {
117
118
  next?: ParentChain;
118
119
  }
119
120
 
120
- // Flags bitfield. *_UNRELIABLE / _TRANSIENT / _STATIC mirror the parent
121
+ // Flags bitfield. *_UNRELIABLE / _PATCH_ONLY / _STATIC mirror the parent
121
122
  // field's annotation — inherited at setParent/setRoot time.
122
123
  export const IS_FILTERED = 1, IS_VISIBILITY_SHARED = 2, IS_NEW = 4;
123
- export const IS_UNRELIABLE = 8, IS_TRANSIENT = 16, IS_STATIC = 32;
124
+ export const IS_UNRELIABLE = 8, IS_PATCH_ONLY = 16, IS_FULL_STATE_ONLY = 32;
124
125
  // Collection tree attached to a parent field annotated `.stream()` —
125
126
  // drives the encoder's priority/broadcast pass. Set in inheritedFlags
126
127
  // so both `t.stream(X)` (via StreamSchema's `$isStream` brand) and
@@ -147,7 +148,7 @@ export const NEEDS_RESTAGE = 128;
147
148
  * reconsidered if a safe semantics (e.g. reliable ADD + unreliable
148
149
  * field mutations only) is designed later.
149
150
  */
150
- export const INHERITABLE_FLAGS = IS_TRANSIENT | IS_STATIC;
151
+ export const INHERITABLE_FLAGS = IS_PATCH_ONLY | IS_FULL_STATE_ONLY;
151
152
 
152
153
  export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
153
154
  ref: T;
@@ -170,8 +171,8 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
170
171
  metadata: Metadata;
171
172
 
172
173
  /**
173
- * Per-class cache of encoder fn / filter fn / isSchema / filterBitmask /
174
- * metadata, looked up once at construction. The encode loop reads
174
+ * Per-class cache of encoder fn / filter fn / isSchema / metadata /
175
+ * per-field arrays, looked up once at construction. The encode loop reads
175
176
  * `tree.encDescriptor` and never touches `ref.constructor` again. See
176
177
  * EncodeDescriptor.ts.
177
178
  */
@@ -232,7 +233,6 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
232
233
  // per-view WeakSet lookups with direct bitwise ops.
233
234
  // Lazy: undefined until the tree participates in any view.
234
235
  visibleViews?: number[];
235
- invisibleViews?: number[];
236
236
 
237
237
  // Per-(view, tag) bitmap, indexed by tag. Custom tags only —
238
238
  // DEFAULT_VIEW_TAG visibility lives in `visibleViews`.
@@ -258,10 +258,10 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
258
258
  set isNew(v: boolean) { this.flags = v ? (this.flags | IS_NEW) : (this.flags & ~IS_NEW); }
259
259
  get isUnreliable() { return (this.flags & IS_UNRELIABLE) !== 0; }
260
260
  set isUnreliable(v: boolean) { this.flags = v ? (this.flags | IS_UNRELIABLE) : (this.flags & ~IS_UNRELIABLE); }
261
- get isTransient() { return (this.flags & IS_TRANSIENT) !== 0; }
262
- set isTransient(v: boolean) { this.flags = v ? (this.flags | IS_TRANSIENT) : (this.flags & ~IS_TRANSIENT); }
263
- get isStatic() { return (this.flags & IS_STATIC) !== 0; }
264
- set isStatic(v: boolean) { this.flags = v ? (this.flags | IS_STATIC) : (this.flags & ~IS_STATIC); }
261
+ get isPatchOnly() { return (this.flags & IS_PATCH_ONLY) !== 0; }
262
+ set isPatchOnly(v: boolean) { this.flags = v ? (this.flags | IS_PATCH_ONLY) : (this.flags & ~IS_PATCH_ONLY); }
263
+ get isFullStateOnly() { return (this.flags & IS_FULL_STATE_ONLY) !== 0; }
264
+ set isFullStateOnly(v: boolean) { this.flags = v ? (this.flags | IS_FULL_STATE_ONLY) : (this.flags & ~IS_FULL_STATE_ONLY); }
265
265
  get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
266
266
  set isStreamCollection(v: boolean) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
267
267
  get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
@@ -271,7 +271,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
271
271
  // @view-tagged fields. StateView.addParentOf uses this to decide whether
272
272
  // a parent must be included in a view's bootstrap. Reads the class-level
273
273
  // "any viewed field" flag that `EncodeDescriptor` precomputes — same
274
- // pattern as `hasAnyStatic` / `hasAnyUnreliable` / `hasAnyStream`.
274
+ // pattern as `hasAnyFullStateOnly` / `hasAnyUnreliable` / `hasAnyStream`.
275
275
  get hasFilteredFields(): boolean {
276
276
  return this.isFiltered || this.encDescriptor.hasAnyView;
277
277
  }
@@ -297,7 +297,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
297
297
  // metadata lookup. For schemas that DO have unreliable fields, the
298
298
  // bitmask answers fields 0-31 in one bitwise op (no Array.includes
299
299
  // linear scan). Fields ≥32 always fall back to the metadata lookup
300
- // (same limitation as filterBitmask bitmask only covers low 32).
300
+ // (shift counts wrap at 32, so the bitmask only covers the low 32).
301
301
  const desc = this.encDescriptor;
302
302
  if (!desc.hasAnyUnreliable) return false;
303
303
  if (index < 32) return (desc.unreliableBitmask & (1 << index)) !== 0;
@@ -306,12 +306,12 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
306
306
 
307
307
  // @static fields sync once via full-sync; post-init mutations are ignored
308
308
  // by the tracker (the value still lives on the instance).
309
- isFieldStatic(index: number): boolean {
310
- if (this.isStatic) return true;
309
+ isFieldFullStateOnly(index: number): boolean {
310
+ if (this.isFullStateOnly) return true;
311
311
  const desc = this.encDescriptor;
312
- if (!desc.hasAnyStatic) return false;
313
- if (index < 32) return (desc.staticBitmask & (1 << index)) !== 0;
314
- return Metadata.hasStaticAtIndex(this.metadata, index);
312
+ if (!desc.hasAnyFullStateOnly) return false;
313
+ if (index < 32) return (desc.fullStateOnlyBitmask & (1 << index)) !== 0;
314
+ return Metadata.hasFullStateOnlyAtIndex(this.metadata, index);
315
315
  }
316
316
 
317
317
  // `t.stream(...)` collection fields — encoded via per-view priority/budget
@@ -566,7 +566,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
566
566
  this.unreliableRecorder?.reset();
567
567
 
568
568
  // back to a freshly-constructed tree: IS_NEW, no inherited flags
569
- // (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
569
+ // (FILTERED/PATCH_ONLY/STATIC/STREAM are re-derived on the next setParent).
570
570
  // NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
571
571
  this.flags = IS_NEW | NEEDS_RESTAGE;
572
572
  this._fullSyncGen = 0;
@@ -586,7 +586,6 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
586
586
  // per-view visibility lives on the tree (NOT keyed by refId), so a
587
587
  // recycled tree must not inherit its previous life's view membership.
588
588
  this.visibleViews = undefined;
589
- this.invisibleViews = undefined;
590
589
  this.tagViews = undefined;
591
590
  this.subscribedViews = undefined;
592
591
  }
@@ -604,7 +603,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
604
603
  if (this._isSchema) throw new Error("ChangeTree (Schema): unshift is not supported");
605
604
  const src = this.collDirty!;
606
605
  const dst = new Map<number, OPERATION>();
607
- const track = !this.paused && !this.isStatic;
606
+ const track = !this.paused && !this.isFullStateOnly;
608
607
  if (track) {
609
608
  for (let i = 0; i < count; i++) dst.set(i, OPERATION.ADD);
610
609
  }
@@ -627,7 +626,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
627
626
  }
628
627
 
629
628
  operation(op: OPERATION) {
630
- if (this.paused || this.isStatic) return;
629
+ if (this.paused || this.isFullStateOnly) return;
631
630
  // Pure ops (CLEAR/REVERSE) only emit from collection trees — the
632
631
  // recorder here is always a CollectionChangeRecorder by construction.
633
632
  //
@@ -657,10 +656,22 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
657
656
  * fields (see annotations.ts), so the per-field unreliable flag here
658
657
  * always means "primitive value updates" — the structural-ADD-routes-
659
658
  * reliable footgun for ref-type fields can't reach this code path.
659
+ *
660
+ * `!isNew` holds an `@unreliable` field on the RELIABLE channel until this
661
+ * tree's own ADD has shipped there. A decoder can only apply a field write
662
+ * to a ref it already knows, so a value emitted before the ADD is dropped —
663
+ * permanently, if the field is never written again. `isNew` clears in
664
+ * `endEncode()`, i.e. after a reliable pass, and recording reliably is
665
+ * itself what enqueues the tree for that pass; the state is self-clearing
666
+ * and no tree can be stranded on the wrong channel. Mirrors `encodeAll`,
667
+ * which has always seeded these fields for late joiners.
668
+ *
669
+ * Ordering matters: `isFieldUnreliable` short-circuits on the class-level
670
+ * `hasAnyUnreliable`, so schemas without the modifier never read `flags`.
660
671
  */
661
672
  private _routeAndRecord(index: number, op: OPERATION, raw: boolean): void {
662
- if (this.paused || this.isFieldStatic(index)) return;
663
- if (this.isFieldUnreliable(index)) {
673
+ if (this.paused || this.isFieldFullStateOnly(index)) return;
674
+ if (this.isFieldUnreliable(index) && !this.isNew) {
664
675
  const r = this.ensureUnreliableRecorder();
665
676
  if (raw) r.recordRaw(index, op);
666
677
  else r.record(index, op);
@@ -724,9 +735,11 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
724
735
  return;
725
736
  }
726
737
 
727
- if (this.paused || this.isFieldStatic(index)) return this.getValue(index);
738
+ if (this.paused || this.isFieldFullStateOnly(index)) return this.getValue(index);
728
739
 
729
- const unreliable = this.isFieldUnreliable(index);
740
+ // Same pre-ADD hold as `_routeAndRecord` — a DELETE naming a ref the
741
+ // decoder hasn't seen is dropped just like a field write.
742
+ const unreliable = this.isFieldUnreliable(index) && !this.isNew;
730
743
  if (unreliable) this.ensureUnreliableRecorder().recordDelete(index, operation ?? OPERATION.DELETE);
731
744
  else this.recordDelete(index, operation ?? OPERATION.DELETE);
732
745
 
@@ -10,14 +10,13 @@
10
10
  * ctor[$filter]
11
11
  * ctor[Symbol.metadata]
12
12
  * Metadata.isValidInstance(ref)
13
- * getFilterBitmask(metadata)
14
13
  *
15
14
  * Lives in its own file to break the Encoder.ts ↔ ChangeTree.ts import
16
15
  * cycle (ChangeTree caches descriptors at construction; Encoder reads them
17
16
  * during encode).
18
17
  */
19
18
  import { Metadata } from "../Metadata.js";
20
- import { $encodeDescriptor, $encoder, $encoders, $filter, $filterBitmask, $numFields, $staticFieldIndexes, $streamFieldIndexes, $unreliableFieldIndexes, $viewFieldIndexes } from "../types/symbols.js";
19
+ import { $encodeDescriptor, $encoder, $encoders, $filter, $numFields, $fullStateOnlyFieldIndexes, $streamFieldIndexes, $unreliableFieldIndexes, $viewFieldIndexes } from "../types/symbols.js";
21
20
  import type { StateView } from "./StateView.js";
22
21
  import type { EncodeOperation } from "./EncodeOperation.js";
23
22
 
@@ -28,35 +27,34 @@ export interface EncodeDescriptor {
28
27
  isSchema: boolean;
29
28
  /**
30
29
  * Bit i set iff field i has a @view tag. 0 for collection trees.
31
- * Lets `encodeChangeCb` do a single bitwise op instead of a
32
- * per-field metadata[i]?.tag chase.
30
+ * Lets `encodeChangeCb` do a single bitwise op instead of a per-field
31
+ * metadata[i]?.tag chase. Fields 0–31 only, like the bitmasks below —
32
+ * `encodeChangeCb` reads `tags` past that.
33
33
  */
34
34
  filterBitmask: number;
35
35
 
36
36
  /**
37
37
  * Class-level "any field has the flag" booleans + per-field bitmasks.
38
- * Hot path: per-mutation `_routeAndRecord` calls `isFieldStatic` and
38
+ * Hot path: per-mutation `_routeAndRecord` calls `isFieldFullStateOnly` and
39
39
  * `isFieldUnreliable`. The common case is "no static/unreliable fields
40
40
  * anywhere on this class" (booleans short-circuit before the symbol-keyed
41
41
  * metadata lookup); the secondary common case is "this class has some
42
42
  * such fields and we need to know if THIS field is one" — the bitmask
43
43
  * answers in one bitwise op instead of an `Array.includes` linear scan.
44
44
  *
45
- * Bitmasks cover fields 0–31 only (matches the `filterBitmask` limitation).
46
- * Fields ≥32 fall back to `Metadata.hasXAtIndex` — same handling as the
47
- * filter-bitmask path.
45
+ * Bitmasks cover fields 0–31 only shift counts wrap at 32. Fields ≥32
46
+ * fall back to `Metadata.hasXAtIndex`.
48
47
  */
49
- hasAnyStatic: boolean;
48
+ hasAnyFullStateOnly: boolean;
50
49
  hasAnyUnreliable: boolean;
51
50
  hasAnyStream: boolean;
52
51
  /**
53
- * Class-level "any field carries a `@view` tag" covers fields both
54
- * within and beyond index 31 (unlike `filterBitmask`, which only
55
- * captures the low 32). Read by `ChangeTree.hasFilteredFields` to
56
- * decide whether a parent tree must be included in a view's bootstrap.
52
+ * Class-level "any field carries a `@view` tag". Read by
53
+ * `ChangeTree.hasFilteredFields` to decide whether a parent tree must
54
+ * be included in a view's bootstrap.
57
55
  */
58
56
  hasAnyView: boolean;
59
- staticBitmask: number;
57
+ fullStateOnlyBitmask: number;
60
58
  unreliableBitmask: number;
61
59
  /**
62
60
  * Bit i set iff field i holds a `t.stream(...)` collection. Hot encode
@@ -84,29 +82,10 @@ export interface EncodeDescriptor {
84
82
  encoders: (((bytes: Uint8Array, value: any, it: any) => void) | undefined)[];
85
83
  }
86
84
 
87
- function computeFilterBitmask(metadata: any): number {
88
- if (metadata === undefined) return 0;
89
- let bm: number | undefined = metadata[$filterBitmask];
90
- if (bm !== undefined) return bm;
91
- bm = 0;
92
- const tagged = metadata[$viewFieldIndexes];
93
- if (tagged !== undefined) {
94
- for (let i = 0, len = tagged.length; i < len; i++) bm |= (1 << tagged[i]);
95
- }
96
- // Non-enumerable so `for (const k in metadata)` iteration in TypeContext
97
- // and elsewhere doesn't mistake this cache for a real field index.
98
- Object.defineProperty(metadata, $filterBitmask, {
99
- value: bm,
100
- enumerable: false,
101
- writable: true,
102
- configurable: true,
103
- });
104
- return bm;
105
- }
106
-
107
85
  /**
108
86
  * Bitmask of field indexes 0–31 in `indexes`. For fields ≥32 callers must
109
- * fall back to the array lookup (same as `filterBitmask`).
87
+ * fall back to the array lookup shift counts wrap at 32, so an unguarded
88
+ * `1 << 40` would set bit 8 and misclassify field 8.
110
89
  */
111
90
  function indexesToBitmask(indexes: number[] | undefined): number {
112
91
  if (indexes === undefined) return 0;
@@ -190,12 +169,12 @@ export function getEncodeDescriptor(ref: any): EncodeDescriptor {
190
169
  filter,
191
170
  metadata,
192
171
  isSchema,
193
- filterBitmask: isSchema ? computeFilterBitmask(metadata) : 0,
194
- hasAnyStatic: (metadata?.[$staticFieldIndexes]?.length ?? 0) > 0,
172
+ filterBitmask: isSchema ? indexesToBitmask(metadata?.[$viewFieldIndexes]) : 0,
173
+ hasAnyFullStateOnly: (metadata?.[$fullStateOnlyFieldIndexes]?.length ?? 0) > 0,
195
174
  hasAnyUnreliable: (metadata?.[$unreliableFieldIndexes]?.length ?? 0) > 0,
196
175
  hasAnyStream: (metadata?.[$streamFieldIndexes]?.length ?? 0) > 0,
197
176
  hasAnyView,
198
- staticBitmask: indexesToBitmask(metadata?.[$staticFieldIndexes]),
177
+ fullStateOnlyBitmask: indexesToBitmask(metadata?.[$fullStateOnlyFieldIndexes]),
199
178
  unreliableBitmask: indexesToBitmask(metadata?.[$unreliableFieldIndexes]),
200
179
  streamBitmask: indexesToBitmask(metadata?.[$streamFieldIndexes]),
201
180
  names: arrays.names,
@@ -75,7 +75,9 @@ export const encodeSchemaOperation: EncodeOperation = function <T extends Schema
75
75
  _: any,
76
76
  __: any,
77
77
  ) {
78
- // "compress" field index + operation
78
+ // "compress" field index + operation. Can't collide with
79
+ // SWITCH_TO_STRUCTURE (255): that needs `DELETE_AND_ADD | 63`, and
80
+ // `Metadata.MAX_FIELDS` keeps index 63 unassignable.
79
81
  bytes[it.offset++] = (index | operation) & 255;
80
82
 
81
83
  // Do not encode value for DELETE operations
@@ -43,12 +43,21 @@ interface EncodeCtx {
43
43
  emitFiltered: boolean;
44
44
 
45
45
  /**
46
- * Bitmask: bit i set iff field i has a @view tag. Lets the per-field
47
- * filter check be a single bitwise op instead of a metadata[i]?.tag chase.
48
- * Always 0 for collection trees.
46
+ * Bitmask: bit i set iff field i has a @view tag, for i < 32. Lets the
47
+ * per-field filter check be a single bitwise op instead of a
48
+ * metadata[i]?.tag chase. Always 0 for collection trees.
49
49
  */
50
50
  filterBitmask: number;
51
51
 
52
+ /**
53
+ * Per-field @view tags of the current tree (`undefined` where untagged),
54
+ * covering the fields the bitmask can't reach. Empty for collections.
55
+ * Read through the ctx rather than `changeTree.encDescriptor`: a
56
+ * multi-hop chain here costs ~2% on full-sync even though only Schemas
57
+ * with 32+ fields ever evaluate it.
58
+ */
59
+ tags: (number | undefined)[];
60
+
52
61
  /**
53
62
  * Current walk's visit stamp. `_fullSyncWalk` compares it against each
54
63
  * tree's `_fullSyncGen` on entry: match means "already visited by
@@ -119,16 +128,7 @@ function _fullSyncWalk(ctx: EncodeCtx, changeTree: ChangeTree): void {
119
128
  // Visibility gate: when a view is active, a non-visible tree contributes
120
129
  // nothing itself but we still recurse so descendants (possibly added to
121
130
  // the view explicitly) are reachable.
122
- let visibleHere = true;
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
- }
131
+ const visibleHere = !ctx.hasView || ctx.view!.isChangeTreeVisible(changeTree);
132
132
 
133
133
  if (visibleHere) {
134
134
  const desc = changeTree.encDescriptor;
@@ -140,6 +140,7 @@ function _fullSyncWalk(ctx: EncodeCtx, changeTree: ChangeTree): void {
140
140
  ctx.treeIsFiltered = changeTree.isFiltered;
141
141
  ctx.isSchema = desc.isSchema;
142
142
  ctx.filterBitmask = desc.filterBitmask;
143
+ ctx.tags = desc.tags;
143
144
  ctx.structSwitchEmitted = false;
144
145
  ctx.shouldEmitSwitch = (ctx.hasView || ctx.it.offset > ctx.initialOffset || changeTree !== ctx.rootChangeTree);
145
146
 
@@ -183,10 +184,13 @@ function encodeChangeCb(ctx: EncodeCtx, fieldIndex: number, op: OPERATION): void
183
184
 
184
185
  // Per-field filter decision (same rule as ChangeTree.change()):
185
186
  // a field is filtered iff the tree inherits isFiltered OR the field
186
- // itself carries a @view tag. Schema trees check via the precomputed
187
- // bitmask; collection trees inherit tree-level (bitmask is 0).
187
+ // itself carries a @view tag. The bitmask only spans 0–31 — `1 << 40`
188
+ // wraps onto bit 8 so fields past it read their tag directly. Reaching
189
+ // that arm needs a Schema with more than 32 fields.
188
190
  const fieldFiltered = ctx.isSchema
189
- ? (ctx.treeIsFiltered || (ctx.filterBitmask & (1 << fieldIndex)) !== 0)
191
+ ? (ctx.treeIsFiltered || (fieldIndex < 32
192
+ ? (ctx.filterBitmask & (1 << fieldIndex)) !== 0
193
+ : ctx.tags[fieldIndex] !== undefined))
190
194
  : ctx.treeIsFiltered;
191
195
  if (fieldFiltered !== ctx.emitFiltered) return;
192
196
 
@@ -248,7 +252,7 @@ export class Encoder<T extends Schema = any> {
248
252
  ref: undefined, encoder: undefined!, filter: undefined, metadata: undefined,
249
253
  view: undefined, isEncodeAll: false, hasView: false,
250
254
  treeIsFiltered: false, isSchema: false, emitFiltered: false,
251
- filterBitmask: 0,
255
+ filterBitmask: 0, tags: undefined!,
252
256
  structSwitchEmitted: false, isRootTree: false, shouldEmitSwitch: false,
253
257
  gen: 0, initialOffset: 0, rootChangeTree: undefined!,
254
258
  };
@@ -313,12 +317,8 @@ export class Encoder<T extends Schema = any> {
313
317
  while (current = current.next) {
314
318
  const changeTree = (current as ChangeTreeNode).changeTree;
315
319
 
316
- if (hasView) {
317
- if (!view.isChangeTreeVisible(changeTree)) {
318
- view.markInvisible(changeTree);
319
- continue;
320
- }
321
- view.unmarkInvisible(changeTree);
320
+ if (hasView && !view.isChangeTreeVisible(changeTree)) {
321
+ continue;
322
322
  }
323
323
 
324
324
  const recorder = unreliable ? changeTree.unreliableRecorder : changeTree;
@@ -333,6 +333,7 @@ export class Encoder<T extends Schema = any> {
333
333
  ctx.treeIsFiltered = changeTree.isFiltered;
334
334
  ctx.isSchema = desc.isSchema;
335
335
  ctx.filterBitmask = desc.filterBitmask;
336
+ ctx.tags = desc.tags;
336
337
  ctx.structSwitchEmitted = false;
337
338
  ctx.isRootTree = (changeTree === rootChangeTree);
338
339
  // Root's struct switch is skipped at the very start of the shared
@@ -652,7 +653,7 @@ export class Encoder<T extends Schema = any> {
652
653
  // Emit each element's full state — forEachLive walks populated
653
654
  // fields structurally, mirroring encodeAllView's bootstrap.
654
655
  // Covers both static elements (dirty state was reset by
655
- // inheritedFlags' becameStatic branch) and non-static (still
656
+ // inheritedFlags' becameFullStateOnly branch) and non-static (still
656
657
  // has dirty state but the main loop skipped them because
657
658
  // they're filtered).
658
659
  for (const element of emittedElements) {
@@ -731,24 +732,86 @@ export class Encoder<T extends Schema = any> {
731
732
  // `t.stream(X).priority(fn)` or the decorator form) and seeded
732
733
  // into `_stream.priority` when the stream was attached. Users
733
734
  // can also override per-instance by assigning to the setter.
735
+ // A per-view callback (registered by `subscribe(coll, fn)`)
736
+ // wins over the declaration-scope one: it closes over the
737
+ // client's own entity, so it needs no view-carried anchor.
738
+ const perView = st.priorityByView?.get(viewId);
739
+ const usePerView = perView !== undefined;
734
740
  const priority = st.priority;
741
+ const max = st.maxPerTick;
735
742
 
736
- // Materialize pending into an array so we can sort + slice.
737
- // Small sets (typical: tens to low hundreds) — allocation is
738
- // negligible compared to the priority sort and element walk.
743
+ // Select the `max` highest-priority candidates.
744
+ //
745
+ // A comparator-based sort invokes the callback twice per
746
+ // comparison, each with its own `$getByIndex` lookup — ~2·n·log n
747
+ // of each to pick `max` entries (38k calls to select 8 out of a
748
+ // 2000-entry backlog). Scoring every candidate once and keeping a
749
+ // bounded top-`max` window costs n invocations instead, and sizes
750
+ // the scratch by `max` rather than by the backlog.
751
+ //
752
+ // Ties keep the earlier position (both comparisons below are
753
+ // strict), so equal-priority entries still drain in insertion
754
+ // order.
739
755
  const positions: number[] = [];
740
- for (const p of pending) positions.push(p);
741
-
742
- if (priority !== undefined) {
743
- // Use the symbol-keyed accessor so Map/Set/Stream all route
744
- // through the same lookup regardless of $items layout.
745
- positions.sort(
746
- (a: number, b: number) => priority(view, s[$getByIndex](b)) - priority(view, s[$getByIndex](a)),
747
- );
756
+ const stale: number[] = [];
757
+
758
+ if (usePerView || priority !== undefined) {
759
+ const bestPos: number[] = [];
760
+ const bestScore: number[] = [];
761
+ let filled = 0;
762
+
763
+ for (const pos of pending) {
764
+ // Symbol-keyed accessor so Map/Set/Stream all route
765
+ // through the same lookup regardless of $items layout.
766
+ const element = s[$getByIndex](pos);
767
+ if (element === undefined) {
768
+ // Removed after being queued — drop it below without
769
+ // spending budget on it.
770
+ stale.push(pos);
771
+ continue;
772
+ }
773
+
774
+ const score = usePerView
775
+ ? perView!(element)
776
+ : priority!(view, element);
777
+
778
+ // Window not yet full: always insert.
779
+ if (filled < max) {
780
+ let j = filled++;
781
+ while (j > 0 && bestScore[j - 1] < score) {
782
+ bestScore[j] = bestScore[j - 1];
783
+ bestPos[j] = bestPos[j - 1];
784
+ j--;
785
+ }
786
+ bestScore[j] = score;
787
+ bestPos[j] = pos;
788
+
789
+ // Otherwise only a strictly better score displaces the tail.
790
+ } else if (score > bestScore[max - 1]) {
791
+ let j = max - 1;
792
+ while (j > 0 && bestScore[j - 1] < score) {
793
+ bestScore[j] = bestScore[j - 1];
794
+ bestPos[j] = bestPos[j - 1];
795
+ j--;
796
+ }
797
+ bestScore[j] = score;
798
+ bestPos[j] = pos;
799
+ }
800
+ }
801
+
802
+ for (let i = 0; i < filled; i++) positions.push(bestPos[i]);
803
+
804
+ } else {
805
+ // FIFO — take the head of the backlog, no scoring needed.
806
+ for (const pos of pending) {
807
+ if (positions.length >= max) break;
808
+ positions.push(pos);
809
+ }
748
810
  }
749
811
 
750
- const max = st.maxPerTick;
751
- const count = Math.min(positions.length, max);
812
+ for (const pos of stale) pending.delete(pos);
813
+
814
+ const count = positions.length;
752
815
 
753
816
  let sent: Set<number> | undefined = st.sentByView.get(viewId);
754
817
  if (sent === undefined) {
@@ -162,7 +162,7 @@ export class Root {
162
162
  const previousRefCount = this.refCount[refId];
163
163
  if (previousRefCount === 0 || changeTree.needsRestage) {
164
164
  //
165
- // Re-stage every currently-populated non-transient index as a
165
+ // Re-stage every currently-populated non-patchOnly index as a
166
166
  // fresh ADD in the matching dirty bucket so the next encode
167
167
  // re-emits it on the correct channel. Two triggers:
168
168
  // - refCount 0: a previously-removed tree re-added under the
@@ -270,13 +270,9 @@ export class Root {
270
270
  const parentNode = parent[$changes][nodeField];
271
271
  if (!parentNode || parentNode === node) return;
272
272
 
273
- // Check if child is already after parent by walking from parent
274
- let cursor = parentNode.next;
275
- while (cursor) {
276
- if (cursor === node) return; // already after parent
277
- cursor = cursor.next;
278
- }
279
- // If we reach here, node is before parent — need to move
273
+ // Positions are strictly increasing along the list, so this is an
274
+ // exact O(1) "is child already after parent" test — no queue scan.
275
+ if (node.position > parentNode.position) return;
280
276
 
281
277
  // Remove node from current position
282
278
  if (node.prev) {
@@ -291,17 +287,18 @@ export class Root {
291
287
  changeSet.tail = node.prev;
292
288
  }
293
289
 
294
- // Insert node right after parent
295
- node.prev = parentNode;
296
- node.next = parentNode.next;
297
-
298
- if (parentNode.next) {
299
- parentNode.next.prev = node;
300
- } else {
301
- changeSet.tail = node;
302
- }
303
-
304
- parentNode.next = node;
290
+ // Re-append at the tail: after `parentNode` AND after every other
291
+ // queued parent of a multi-referenced instance — relinking next to
292
+ // the *primary* parent could jump the child ahead of a 2nd/3rd
293
+ // parent whose ADD the decoder must see first. Tail placement gets
294
+ // a fresh max position, keeping the invariant append-only.
295
+ // (`recursivelyMoveNextToParent` visits pre-order, so a moved
296
+ // subtree re-serializes parent-first behind it.)
297
+ node.prev = changeSet.tail;
298
+ node.next = undefined;
299
+ changeSet.tail!.next = node; // parentNode remains in the list — never empty here
300
+ changeSet.tail = node;
301
+ node.position = changeSet.nextPosition++;
305
302
  }
306
303
 
307
304
  public enqueueChangeTree(
@@ -328,11 +325,11 @@ export class Root {
328
325
  node.changeTree = changeTree;
329
326
  node.next = undefined;
330
327
  node.prev = undefined;
331
- node.position = 0;
332
328
  } else {
333
329
  node = { changeTree, next: undefined, prev: undefined, position: 0 };
334
330
  }
335
331
  if (!list.next) {
332
+ list.nextPosition = 0; // list drained — restart sequence (stays SMI)
336
333
  list.next = node;
337
334
  list.tail = node;
338
335
  } else {
@@ -340,6 +337,7 @@ export class Root {
340
337
  list.tail!.next = node;
341
338
  list.tail = node;
342
339
  }
340
+ node.position = list.nextPosition++;
343
341
  return node;
344
342
  }
345
343