@colyseus/schema 5.0.11 → 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 -9
  9. package/build/encoder/EncodeDescriptor.d.ts +11 -12
  10. package/build/encoder/StateView.d.ts +26 -2
  11. package/build/encoder/changeTree/inheritedFlags.d.ts +1 -1
  12. package/build/encoder/streaming.d.ts +7 -0
  13. package/build/index.cjs +374 -232
  14. package/build/index.cjs.map +1 -1
  15. package/build/index.d.ts +1 -1
  16. package/build/index.js +374 -232
  17. package/build/index.mjs +373 -231
  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 -26
  37. package/src/encoder/EncodeDescriptor.ts +17 -38
  38. package/src/encoder/EncodeOperation.ts +3 -1
  39. package/src/encoder/Encoder.ts +97 -21
  40. package/src/encoder/Root.ts +18 -20
  41. package/src/encoder/StateView.ts +102 -12
  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
  */
@@ -257,10 +258,10 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
257
258
  set isNew(v: boolean) { this.flags = v ? (this.flags | IS_NEW) : (this.flags & ~IS_NEW); }
258
259
  get isUnreliable() { return (this.flags & IS_UNRELIABLE) !== 0; }
259
260
  set isUnreliable(v: boolean) { this.flags = v ? (this.flags | IS_UNRELIABLE) : (this.flags & ~IS_UNRELIABLE); }
260
- get isTransient() { return (this.flags & IS_TRANSIENT) !== 0; }
261
- set isTransient(v: boolean) { this.flags = v ? (this.flags | IS_TRANSIENT) : (this.flags & ~IS_TRANSIENT); }
262
- get isStatic() { return (this.flags & IS_STATIC) !== 0; }
263
- 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); }
264
265
  get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
265
266
  set isStreamCollection(v: boolean) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
266
267
  get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
@@ -270,7 +271,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
270
271
  // @view-tagged fields. StateView.addParentOf uses this to decide whether
271
272
  // a parent must be included in a view's bootstrap. Reads the class-level
272
273
  // "any viewed field" flag that `EncodeDescriptor` precomputes — same
273
- // pattern as `hasAnyStatic` / `hasAnyUnreliable` / `hasAnyStream`.
274
+ // pattern as `hasAnyFullStateOnly` / `hasAnyUnreliable` / `hasAnyStream`.
274
275
  get hasFilteredFields(): boolean {
275
276
  return this.isFiltered || this.encDescriptor.hasAnyView;
276
277
  }
@@ -296,7 +297,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
296
297
  // metadata lookup. For schemas that DO have unreliable fields, the
297
298
  // bitmask answers fields 0-31 in one bitwise op (no Array.includes
298
299
  // linear scan). Fields ≥32 always fall back to the metadata lookup
299
- // (same limitation as filterBitmask bitmask only covers low 32).
300
+ // (shift counts wrap at 32, so the bitmask only covers the low 32).
300
301
  const desc = this.encDescriptor;
301
302
  if (!desc.hasAnyUnreliable) return false;
302
303
  if (index < 32) return (desc.unreliableBitmask & (1 << index)) !== 0;
@@ -305,12 +306,12 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
305
306
 
306
307
  // @static fields sync once via full-sync; post-init mutations are ignored
307
308
  // by the tracker (the value still lives on the instance).
308
- isFieldStatic(index: number): boolean {
309
- if (this.isStatic) return true;
309
+ isFieldFullStateOnly(index: number): boolean {
310
+ if (this.isFullStateOnly) return true;
310
311
  const desc = this.encDescriptor;
311
- if (!desc.hasAnyStatic) return false;
312
- if (index < 32) return (desc.staticBitmask & (1 << index)) !== 0;
313
- 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);
314
315
  }
315
316
 
316
317
  // `t.stream(...)` collection fields — encoded via per-view priority/budget
@@ -565,7 +566,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
565
566
  this.unreliableRecorder?.reset();
566
567
 
567
568
  // back to a freshly-constructed tree: IS_NEW, no inherited flags
568
- // (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
569
+ // (FILTERED/PATCH_ONLY/STATIC/STREAM are re-derived on the next setParent).
569
570
  // NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
570
571
  this.flags = IS_NEW | NEEDS_RESTAGE;
571
572
  this._fullSyncGen = 0;
@@ -602,7 +603,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
602
603
  if (this._isSchema) throw new Error("ChangeTree (Schema): unshift is not supported");
603
604
  const src = this.collDirty!;
604
605
  const dst = new Map<number, OPERATION>();
605
- const track = !this.paused && !this.isStatic;
606
+ const track = !this.paused && !this.isFullStateOnly;
606
607
  if (track) {
607
608
  for (let i = 0; i < count; i++) dst.set(i, OPERATION.ADD);
608
609
  }
@@ -625,7 +626,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
625
626
  }
626
627
 
627
628
  operation(op: OPERATION) {
628
- if (this.paused || this.isStatic) return;
629
+ if (this.paused || this.isFullStateOnly) return;
629
630
  // Pure ops (CLEAR/REVERSE) only emit from collection trees — the
630
631
  // recorder here is always a CollectionChangeRecorder by construction.
631
632
  //
@@ -655,10 +656,22 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
655
656
  * fields (see annotations.ts), so the per-field unreliable flag here
656
657
  * always means "primitive value updates" — the structural-ADD-routes-
657
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`.
658
671
  */
659
672
  private _routeAndRecord(index: number, op: OPERATION, raw: boolean): void {
660
- if (this.paused || this.isFieldStatic(index)) return;
661
- if (this.isFieldUnreliable(index)) {
673
+ if (this.paused || this.isFieldFullStateOnly(index)) return;
674
+ if (this.isFieldUnreliable(index) && !this.isNew) {
662
675
  const r = this.ensureUnreliableRecorder();
663
676
  if (raw) r.recordRaw(index, op);
664
677
  else r.record(index, op);
@@ -722,9 +735,11 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
722
735
  return;
723
736
  }
724
737
 
725
- if (this.paused || this.isFieldStatic(index)) return this.getValue(index);
738
+ if (this.paused || this.isFieldFullStateOnly(index)) return this.getValue(index);
726
739
 
727
- 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;
728
743
  if (unreliable) this.ensureUnreliableRecorder().recordDelete(index, operation ?? OPERATION.DELETE);
729
744
  else this.recordDelete(index, operation ?? OPERATION.DELETE);
730
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
@@ -131,6 +140,7 @@ function _fullSyncWalk(ctx: EncodeCtx, changeTree: ChangeTree): void {
131
140
  ctx.treeIsFiltered = changeTree.isFiltered;
132
141
  ctx.isSchema = desc.isSchema;
133
142
  ctx.filterBitmask = desc.filterBitmask;
143
+ ctx.tags = desc.tags;
134
144
  ctx.structSwitchEmitted = false;
135
145
  ctx.shouldEmitSwitch = (ctx.hasView || ctx.it.offset > ctx.initialOffset || changeTree !== ctx.rootChangeTree);
136
146
 
@@ -174,10 +184,13 @@ function encodeChangeCb(ctx: EncodeCtx, fieldIndex: number, op: OPERATION): void
174
184
 
175
185
  // Per-field filter decision (same rule as ChangeTree.change()):
176
186
  // a field is filtered iff the tree inherits isFiltered OR the field
177
- // itself carries a @view tag. Schema trees check via the precomputed
178
- // 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.
179
190
  const fieldFiltered = ctx.isSchema
180
- ? (ctx.treeIsFiltered || (ctx.filterBitmask & (1 << fieldIndex)) !== 0)
191
+ ? (ctx.treeIsFiltered || (fieldIndex < 32
192
+ ? (ctx.filterBitmask & (1 << fieldIndex)) !== 0
193
+ : ctx.tags[fieldIndex] !== undefined))
181
194
  : ctx.treeIsFiltered;
182
195
  if (fieldFiltered !== ctx.emitFiltered) return;
183
196
 
@@ -239,7 +252,7 @@ export class Encoder<T extends Schema = any> {
239
252
  ref: undefined, encoder: undefined!, filter: undefined, metadata: undefined,
240
253
  view: undefined, isEncodeAll: false, hasView: false,
241
254
  treeIsFiltered: false, isSchema: false, emitFiltered: false,
242
- filterBitmask: 0,
255
+ filterBitmask: 0, tags: undefined!,
243
256
  structSwitchEmitted: false, isRootTree: false, shouldEmitSwitch: false,
244
257
  gen: 0, initialOffset: 0, rootChangeTree: undefined!,
245
258
  };
@@ -320,6 +333,7 @@ export class Encoder<T extends Schema = any> {
320
333
  ctx.treeIsFiltered = changeTree.isFiltered;
321
334
  ctx.isSchema = desc.isSchema;
322
335
  ctx.filterBitmask = desc.filterBitmask;
336
+ ctx.tags = desc.tags;
323
337
  ctx.structSwitchEmitted = false;
324
338
  ctx.isRootTree = (changeTree === rootChangeTree);
325
339
  // Root's struct switch is skipped at the very start of the shared
@@ -639,7 +653,7 @@ export class Encoder<T extends Schema = any> {
639
653
  // Emit each element's full state — forEachLive walks populated
640
654
  // fields structurally, mirroring encodeAllView's bootstrap.
641
655
  // Covers both static elements (dirty state was reset by
642
- // inheritedFlags' becameStatic branch) and non-static (still
656
+ // inheritedFlags' becameFullStateOnly branch) and non-static (still
643
657
  // has dirty state but the main loop skipped them because
644
658
  // they're filtered).
645
659
  for (const element of emittedElements) {
@@ -718,24 +732,86 @@ export class Encoder<T extends Schema = any> {
718
732
  // `t.stream(X).priority(fn)` or the decorator form) and seeded
719
733
  // into `_stream.priority` when the stream was attached. Users
720
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;
721
740
  const priority = st.priority;
741
+ const max = st.maxPerTick;
722
742
 
723
- // Materialize pending into an array so we can sort + slice.
724
- // Small sets (typical: tens to low hundreds) — allocation is
725
- // 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.
726
755
  const positions: number[] = [];
727
- for (const p of pending) positions.push(p);
728
-
729
- if (priority !== undefined) {
730
- // Use the symbol-keyed accessor so Map/Set/Stream all route
731
- // through the same lookup regardless of $items layout.
732
- positions.sort(
733
- (a: number, b: number) => priority(view, s[$getByIndex](b)) - priority(view, s[$getByIndex](a)),
734
- );
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
+ }
735
810
  }
736
811
 
737
- const max = st.maxPerTick;
738
- const count = Math.min(positions.length, max);
812
+ for (const pos of stale) pending.delete(pos);
813
+
814
+ const count = positions.length;
739
815
 
740
816
  let sent: Set<number> | undefined = st.sentByView.get(viewId);
741
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