@colyseus/schema 5.0.11 → 5.0.13

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 (62) 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 +25 -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/changeTree/parentChain.d.ts +9 -0
  13. package/build/encoder/streaming.d.ts +7 -0
  14. package/build/index.cjs +449 -233
  15. package/build/index.cjs.map +1 -1
  16. package/build/index.d.ts +1 -1
  17. package/build/index.js +449 -233
  18. package/build/index.mjs +448 -232
  19. package/build/index.mjs.map +1 -1
  20. package/build/types/builder.d.ts +31 -22
  21. package/build/types/custom/ArraySchema.d.ts +17 -0
  22. package/build/types/custom/StreamSchema.d.ts +1 -1
  23. package/build/types/symbols.d.ts +4 -10
  24. package/package.json +1 -1
  25. package/src/Metadata.ts +58 -31
  26. package/src/annotations.ts +56 -32
  27. package/src/codegen/api.ts +2 -1
  28. package/src/codegen/languages/c.ts +21 -3
  29. package/src/codegen/languages/csharp.ts +7 -1
  30. package/src/codegen/languages/dart.ts +274 -0
  31. package/src/codegen/languages/haxe.ts +7 -1
  32. package/src/codegen/languages/lua.ts +16 -4
  33. package/src/codegen/languages/ts.ts +5 -0
  34. package/src/codegen/parser.ts +97 -3
  35. package/src/codegen/types.ts +24 -0
  36. package/src/decoder/Resync.ts +8 -8
  37. package/src/encoder/ChangeRecorder.ts +1 -1
  38. package/src/encoder/ChangeTree.ts +46 -26
  39. package/src/encoder/EncodeDescriptor.ts +17 -38
  40. package/src/encoder/EncodeOperation.ts +3 -1
  41. package/src/encoder/Encoder.ts +97 -21
  42. package/src/encoder/Root.ts +18 -20
  43. package/src/encoder/StateView.ts +102 -12
  44. package/src/encoder/changeTree/inheritedFlags.ts +10 -10
  45. package/src/encoder/changeTree/liveIteration.ts +9 -9
  46. package/src/encoder/changeTree/parentChain.ts +29 -0
  47. package/src/encoder/streaming.ts +8 -0
  48. package/src/encoding/spec.ts +1 -1
  49. package/src/index.ts +2 -2
  50. package/src/types/builder.ts +35 -31
  51. package/src/types/custom/ArraySchema.ts +40 -1
  52. package/src/types/custom/StreamSchema.ts +1 -1
  53. package/src/types/symbols.ts +4 -11
  54. package/src/bench_bloat.ts +0 -173
  55. package/src/bench_churn.ts +0 -121
  56. package/src/bench_decode.ts +0 -221
  57. package/src/bench_decode_mem.ts +0 -165
  58. package/src/bench_encode.ts +0 -108
  59. package/src/bench_init.ts +0 -150
  60. package/src/bench_static.ts +0 -109
  61. package/src/bench_stream.ts +0 -295
  62. package/src/bench_view_cmp.ts +0 -142
@@ -4,7 +4,11 @@ import { DEFAULT_VIEW_TAG } from "../annotations.js";
4
4
  import { OPERATION } from "../encoding/spec.js";
5
5
  import { Metadata } from "../Metadata.js";
6
6
  import { spliceOne } from "../types/utils.js";
7
- import { streamDequeueForView, streamEnqueueForView } from "./streaming.js";
7
+ import { ensureStreamState, streamDequeueForView, streamEnqueueForView } from "./streaming.js";
8
+ import type { StreamSchema } from "../types/custom/StreamSchema.js";
9
+ import type { MapSchema } from "../types/custom/MapSchema.js";
10
+ import type { SetSchema } from "../types/custom/SetSchema.js";
11
+ import type { CollectionSchema } from "../types/custom/CollectionSchema.js";
8
12
  import type { Schema } from "../Schema.js";
9
13
  import type { Root, Streamable } from "./Root.js";
10
14
 
@@ -51,6 +55,24 @@ const _disposeRegistry = new FinalizationRegistry<{ root: Root; id: number; slot
51
55
  },
52
56
  );
53
57
 
58
+ /**
59
+ * Compact description of a rejected argument, for warning messages.
60
+ * Passing the value itself to `console.warn` is not an option — a
61
+ * populated collection inspects into dozens of lines of encoder
62
+ * internals and buries the message that matters.
63
+ */
64
+ function describeArg(value: any): string {
65
+ if (value === undefined) { return "undefined"; }
66
+ if (value === null) { return "null"; }
67
+ const type = typeof value;
68
+ if (type === "string") {
69
+ return JSON.stringify(value.length > 30 ? `${value.slice(0, 30)}…` : value);
70
+ }
71
+ if (type !== "object" && type !== "function") { return `${type} ${String(value)}`; }
72
+ if (Array.isArray(value)) { return `Array(${value.length})`; }
73
+ return value.constructor?.name ?? "Object";
74
+ }
75
+
54
76
  export class StateView {
55
77
  /**
56
78
  * Iterable list of items that are visible to this view
@@ -282,13 +304,16 @@ export class StateView {
282
304
 
283
305
  private _add(obj: Ref, tag: number, checkIncludeParent: boolean, _skipStreamRouting: boolean) {
284
306
  const changeTree: ChangeTree = obj?.[$changes];
285
- const parentChangeTree = changeTree.parent;
286
-
287
307
  if (!changeTree) {
288
- console.warn("StateView#add(), invalid object:", obj);
308
+ console.warn(
309
+ `StateView#add(): expected a Schema instance or collection, received ${describeArg(obj)}`,
310
+ );
289
311
  return false;
312
+ }
313
+
314
+ const parentChangeTree = changeTree.parent;
290
315
 
291
- } else if (
316
+ if (
292
317
  !parentChangeTree &&
293
318
  obj[$refId] !== 0 // allow root object
294
319
  ) {
@@ -603,9 +628,11 @@ export class StateView {
603
628
  remove(obj: Ref, tag?: number): this; // hide _isClear parameter from public API
604
629
  remove(obj: Ref, tag?: number, _isClear?: boolean): this;
605
630
  remove(obj: Ref, tag: number = DEFAULT_VIEW_TAG, _isClear: boolean = false): this {
606
- const changeTree: ChangeTree = obj[$changes];
631
+ const changeTree: ChangeTree = obj?.[$changes];
607
632
  if (!changeTree) {
608
- console.warn("StateView#remove(), invalid object:", obj);
633
+ console.warn(
634
+ `StateView#remove(): expected a Schema instance or collection, received ${describeArg(obj)}`,
635
+ );
609
636
  return this;
610
637
  }
611
638
 
@@ -780,18 +807,79 @@ export class StateView {
780
807
  * enqueued into `_pendingByView` so the priority pass drains them
781
808
  * respecting `maxPerTick`.
782
809
  *
783
- * Idempotent on re-subscribe. Subscribing to an already-subscribed
784
- * collection is a no-op.
810
+ * On a streaming collection, pass a `priority` callback to order THIS
811
+ * client's backlog. It receives only the element, so whatever the
812
+ * client sorts by is captured in the closure — nothing is attached to
813
+ * the view, and both the element and the captured entity stay typed:
814
+ *
815
+ * ```ts
816
+ * onJoin(client) {
817
+ * const player = this.state.players.get(client.sessionId);
818
+ * client.view.subscribe(this.state.enemies, (enemy) =>
819
+ * -((enemy.x - player.x) ** 2 + (enemy.y - player.y) ** 2));
820
+ * }
821
+ * ```
822
+ *
823
+ * A per-view callback overrides the collection's declaration-scope
824
+ * `.priority()` for this client only.
825
+ *
826
+ * Idempotent on re-subscribe: subscribing to an already-subscribed
827
+ * collection is a no-op, EXCEPT that a supplied `priority` always
828
+ * replaces the previous one — re-subscribe to retarget the ordering.
829
+ * Omitting the argument leaves any existing callback in place; pass
830
+ * `null` to drop it and fall back to the declaration-scope callback.
785
831
  */
786
- subscribe(collection: Ref): this {
832
+ subscribe<V>(
833
+ collection: StreamSchema<V> | MapSchema<V, any> | SetSchema<V> | CollectionSchema<V>,
834
+ priority?: ((element: V) => number) | null,
835
+ ): this;
836
+ subscribe(collection: Ref): this;
837
+ subscribe(collection: Ref, priority?: ((element: any) => number) | null): this {
787
838
  const tree: ChangeTree = collection?.[$changes];
788
839
  if (!tree) {
789
- console.warn("StateView#subscribe(), invalid collection:", collection);
840
+ console.warn(
841
+ `StateView#subscribe(): expected a Schema collection, received ${describeArg(collection)}`,
842
+ );
790
843
  return this;
791
844
  }
792
845
  if (this._root === undefined && tree.root !== undefined) {
793
846
  this._bindRoot(tree.root);
794
847
  }
848
+
849
+ if (priority !== undefined) {
850
+ if (!tree.isStreamCollection) {
851
+ // Name the field rather than dumping the collection — a
852
+ // populated MapSchema inspects into dozens of lines of
853
+ // internals and buries the message.
854
+ const kind = (collection as any)?.constructor?.name ?? "collection";
855
+ const parent: any = tree.parent;
856
+ if (parent === undefined) {
857
+ console.warn(
858
+ `StateView#subscribe(): \`priority\` ignored — this ${kind} is not ` +
859
+ `attached to a state yet, so it cannot be identified as a stream. ` +
860
+ `Subscribe after assigning it to the state.`,
861
+ );
862
+ } else {
863
+ const field = parent?.constructor?.[Symbol.metadata]?.[tree.parentIndex]?.name;
864
+ const where = field ? `${parent.constructor.name}#${field}` : kind;
865
+ console.warn(
866
+ `StateView#subscribe(): \`priority\` ignored — ${where} is a ${kind}, ` +
867
+ `not a streaming collection. Declare the field with .stream() ` +
868
+ `(e.g. t.map(X).stream()) or use t.stream(X) to enable priority batching.`,
869
+ );
870
+ }
871
+ } else {
872
+ // Set before the idempotency return below, so re-subscribing
873
+ // is the documented way to retarget this view's ordering.
874
+ const st = ensureStreamState(collection as unknown as Streamable);
875
+ if (priority === null) {
876
+ st.priorityByView?.delete(this.id);
877
+ } else {
878
+ (st.priorityByView ??= new Map()).set(this.id, priority);
879
+ }
880
+ }
881
+ }
882
+
795
883
  if (this.isSubscribed(tree)) return this;
796
884
 
797
885
  // Mark collection visible so its own ADD/DELETE ops emit in the
@@ -839,7 +927,9 @@ export class StateView {
839
927
  unsubscribe(collection: Ref): this {
840
928
  const tree: ChangeTree = collection?.[$changes];
841
929
  if (!tree) {
842
- console.warn("StateView#unsubscribe(), invalid collection:", collection);
930
+ console.warn(
931
+ `StateView#unsubscribe(): expected a Schema collection, received ${describeArg(collection)}`,
932
+ );
843
933
  return this;
844
934
  }
845
935
  if (!this.isSubscribed(tree)) return this;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Filter / unreliable / transient / static inheritance helpers for
2
+ * Filter / unreliable / patchOnly / static inheritance helpers for
3
3
  * ChangeTree. Called by setRoot / setParent to derive child flags from
4
4
  * the parent field's annotation + the parent tree's own state.
5
5
  */
@@ -7,15 +7,15 @@ import { Metadata } from "../../Metadata.js";
7
7
  import { DEFAULT_VIEW_TAG } from "../../annotations.js";
8
8
  import {
9
9
  $changes, $childType,
10
- $staticFieldIndexes, $streamFieldIndexes,
11
- $transientFieldIndexes, $viewFieldIndexes,
10
+ $fullStateOnlyFieldIndexes, $streamFieldIndexes,
11
+ $patchOnlyFieldIndexes, $viewFieldIndexes,
12
12
  // $unreliableFieldIndexes — tree-level unreliable currently disabled
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
16
  import type { Schema } from "../../Schema.js";
17
17
  import {
18
- INHERITABLE_FLAGS, IS_STATIC, IS_TRANSIENT,
18
+ INHERITABLE_FLAGS, IS_FULL_STATE_ONLY, IS_PATCH_ONLY,
19
19
  // IS_UNRELIABLE — tree-level unreliable currently disabled; see
20
20
  // INHERITABLE_FLAGS comment in ChangeTree.ts.
21
21
  type ChangeTree, type Ref,
@@ -39,7 +39,7 @@ export function checkIsFiltered(
39
39
 
40
40
  // Static trees never track per-tick changes — skip the queue entirely.
41
41
  // Full-sync reaches them via structural walk (forEachChild).
42
- if (tree.isStatic) return;
42
+ if (tree.isFullStateOnly) return;
43
43
 
44
44
  // Mutations that happened before setRoot (e.g. class-field initializers)
45
45
  // recorded into the appropriate recorder but couldn't enqueue yet.
@@ -66,7 +66,7 @@ export function checkIsFiltered(
66
66
  }
67
67
 
68
68
  /**
69
- * Inherit filter / unreliable / transient / static classification from
69
+ * Inherit filter / unreliable / patchOnly / static classification from
70
70
  * the parent field's annotation. Collections (MapSchema / ArraySchema /
71
71
  * etc.) inherit these from the Schema field that holds them.
72
72
  *
@@ -106,15 +106,15 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
106
106
 
107
107
  const parentMetadata: any = (parent as any)?.constructor?.[Symbol.metadata];
108
108
 
109
- // Flag inheritance — pack the transient/static annotation checks into
109
+ // Flag inheritance — pack the patchOnly/static annotation checks into
110
110
  // flag bits alongside the parent's own transitive flags, then OR onto
111
111
  // `tree.flags` in one write. The bit diff tells us which flag just
112
112
  // went from 0→1, cheaper than the prior `becameX = !tree.isX && (...)`
113
113
  // pairs. IS_UNRELIABLE is omitted from both sides — tree-level
114
114
  // unreliable is disabled (see INHERITABLE_FLAGS in ChangeTree.ts).
115
115
  const fieldBits =
116
- (parentMetadata?.[$transientFieldIndexes]?.includes(parentIndex) ? IS_TRANSIENT : 0)
117
- | (parentMetadata?.[$staticFieldIndexes]?.includes(parentIndex) ? IS_STATIC : 0);
116
+ (parentMetadata?.[$patchOnlyFieldIndexes]?.includes(parentIndex) ? IS_PATCH_ONLY : 0)
117
+ | (parentMetadata?.[$fullStateOnlyFieldIndexes]?.includes(parentIndex) ? IS_FULL_STATE_ONLY : 0);
118
118
  const inheritedBits = (parentChangeTree.flags & INHERITABLE_FLAGS) | fieldBits;
119
119
  const beforeFlags = tree.flags;
120
120
  tree.flags = beforeFlags | inheritedBits;
@@ -125,7 +125,7 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
125
125
  // `new Config().assign({...})` populates the recorder before the
126
126
  // Config instance is attached). Static trees ship state via structural
127
127
  // walk only; per-tick dirty entries would leak post-first-sync.
128
- if (gainedBits & IS_STATIC) {
128
+ if (gainedBits & IS_FULL_STATE_ONLY) {
129
129
  tree.reset();
130
130
  tree.unreliableRecorder?.reset();
131
131
  }
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Walk all currently-populated non-transient indexes on a tree, emitting
2
+ * Walk all currently-populated non-patchOnly indexes on a tree, emitting
3
3
  * each index once. Used by Root.add (re-stage), Encoder.encodeAll, and
4
4
  * StateView.add to derive full-sync output from the live structure.
5
5
  *
6
- * Transient fields (`@transient`) are skipped — they're delivered only on
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
- * field is @transient inherit the skip (`tree.isTransient`).
8
+ * field is @patchOnly inherit the skip (`tree.isPatchOnly`).
9
9
  */
10
- import { $childType, $numFields, $transientFieldIndexes } from "../../types/symbols.js";
10
+ import { $childType, $numFields, $patchOnlyFieldIndexes } from "../../types/symbols.js";
11
11
  import type { ChangeTree } from "../ChangeTree.js";
12
12
 
13
13
  // Adapter that lets `forEachLive(cb)` delegate to `forEachLiveWithCtx(cb, _invokeNoCtx)` —
@@ -29,10 +29,10 @@ export function forEachLiveWithCtx<C>(
29
29
  const ref = tree.refTarget as any;
30
30
 
31
31
  if (ref[$childType] !== undefined) {
32
- // Collection inheriting @transient from parent field: skip entirely.
32
+ // Collection inheriting @patchOnly from parent field: skip entirely.
33
33
  // The resync sweep (decoder/Resync.ts) relies on this: a collection
34
34
  // absent from full-sync output is never pruned client-side.
35
- if (tree.isTransient) return;
35
+ if (tree.isPatchOnly) return;
36
36
 
37
37
  // Collection types: dispatch by shape.
38
38
  if (Array.isArray(ref.items)) {
@@ -56,7 +56,7 @@ export function forEachLiveWithCtx<C>(
56
56
  // Schema: walk declared fields. `null` is treated as absent —
57
57
  // the setter records a DELETE when a field is set to null or
58
58
  // undefined, so it should not appear in full-sync output.
59
- // (@transient skips below matter to the resync sweep — see
59
+ // (@patchOnly skips below matter to the resync sweep — see
60
60
  // decoder/Resync.ts: absent-from-payload means never pruned.)
61
61
  //
62
62
  // Read names from the per-class descriptor's parallel array —
@@ -65,12 +65,12 @@ export function forEachLiveWithCtx<C>(
65
65
  const metadata = tree.metadata;
66
66
  if (!metadata) return;
67
67
  const numFields = (metadata[$numFields] ?? -1) as number;
68
- const transientIndexes = metadata[$transientFieldIndexes];
68
+ const patchOnlyIndexes = metadata[$patchOnlyFieldIndexes];
69
69
  const names = tree.encDescriptor.names;
70
70
  for (let i = 0; i <= numFields; i++) {
71
71
  const name = names[i];
72
72
  if (name === undefined) continue;
73
- if (transientIndexes && transientIndexes.includes(i)) continue;
73
+ if (patchOnlyIndexes && patchOnlyIndexes.includes(i)) continue;
74
74
  const value = ref[name];
75
75
  if (value !== undefined && value !== null) cb(ctx, i);
76
76
  }
@@ -45,6 +45,35 @@ export function addParent(tree: ChangeTree, parent: Ref, index: number): void {
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * Move `parent`'s existing chain entry to `index`, skipping the attachment
50
+ * work `addParent` does. `parent` must already be a parent of `tree`.
51
+ *
52
+ * Called by collections whose wire slots shift (ArraySchema): StateView
53
+ * addresses per-view ADD/DELETE by that index, so it has to follow the
54
+ * element it names.
55
+ */
56
+ export function setParentIndex(tree: ChangeTree, parent: Ref, index: number): void {
57
+ if (tree.extraParents === undefined) {
58
+ tree._parentIndex = index; // sole parent, so it is `parent`
59
+ return;
60
+ }
61
+ // Shared instance — move only the entry `parent` owns. Matching goes
62
+ // through `$changes` because ArraySchema arrives proxied (see removeParent
63
+ // below), and `extraParents` only ever fills by demoting `parentRef`, so
64
+ // the inline parent is set here.
65
+ if (tree.parentRef[$changes] === parent[$changes]) {
66
+ tree._parentIndex = index;
67
+ return;
68
+ }
69
+ for (let entry = tree.extraParents; entry !== undefined; entry = entry.next) {
70
+ if (entry.ref[$changes] === parent[$changes]) {
71
+ entry.index = index;
72
+ return;
73
+ }
74
+ }
75
+ }
76
+
48
77
  /**
49
78
  * Remove a parent from the chain.
50
79
  * @returns true if parent was found and removed (Root.remove relies on this).
@@ -53,6 +53,13 @@ export interface StreamableState {
53
53
  * Instance-level override: assign to `stream.priority`.
54
54
  */
55
55
  priority?: (view: any, element: any) => number;
56
+ /**
57
+ * Per-view priority registered by `StateView.subscribe(collection, fn)`.
58
+ * Takes precedence over the declaration-scope `priority` for that view.
59
+ * Receives only the element — the client's own entity is captured in
60
+ * the closure, so nothing has to be attached to the view.
61
+ */
62
+ priorityByView?: Map<number, (element: any) => number>;
56
63
  }
57
64
 
58
65
  export function createStreamableState(): StreamableState {
@@ -229,4 +236,5 @@ export function streamDropView(s: Streamable, viewId: number): void {
229
236
  if (st === undefined) return;
230
237
  st.pendingByView.delete(viewId);
231
238
  st.sentByView.delete(viewId);
239
+ st.priorityByView?.delete(viewId);
232
240
  }
@@ -1,4 +1,4 @@
1
- export const SWITCH_TO_STRUCTURE = 255; // (decoding collides with DELETE_AND_ADD + fieldIndex = 63)
1
+ export const SWITCH_TO_STRUCTURE = 255; // same byte as `DELETE_AND_ADD | 63`, which is why field index 63 is unassignable (Metadata.MAX_FIELDS)
2
2
  export const TYPE_ID = 213;
3
3
 
4
4
  /**
package/src/index.ts CHANGED
@@ -50,9 +50,9 @@ export {
50
50
  type,
51
51
  deprecated,
52
52
  defineTypes,
53
- owned,
54
53
  unreliable,
55
- transient,
54
+ patchOnly,
55
+ fullStateOnly,
56
56
  view,
57
57
  schema,
58
58
  entity,
@@ -20,12 +20,11 @@ export interface BuilderDefinition {
20
20
  default?: any;
21
21
  hasDefault: boolean;
22
22
  view?: number; // tag value; undefined = no view
23
- owned?: boolean;
24
23
  unreliable?: boolean;
25
- transient?: boolean;
24
+ patchOnly?: boolean;
26
25
  deprecated?: boolean;
27
26
  deprecatedThrows?: boolean;
28
- static?: boolean;
27
+ fullStateOnly?: boolean;
29
28
  stream?: boolean;
30
29
  optional?: boolean;
31
30
  /** Local-only field: typed + initialized, but never registered for sync. */
@@ -75,12 +74,11 @@ export class FieldBuilder<
75
74
  private _default: any = undefined;
76
75
  private _hasDefault = false;
77
76
  private _view: number | undefined = undefined;
78
- private _owned = false;
79
77
  private _unreliable = false;
80
- private _transient = false;
78
+ private _patchOnly = false;
81
79
  private _deprecated = false;
82
80
  private _deprecatedThrows = true;
83
- private _static = false;
81
+ private _fullStateOnly = false;
84
82
  private _stream = false;
85
83
  private _optional = false;
86
84
  private _noSync = false;
@@ -119,16 +117,16 @@ export class FieldBuilder<
119
117
  return this;
120
118
  }
121
119
 
122
- /** Mark this field as owned (encoder-side ownership filtering). */
123
- owned(): this {
124
- this._owned = true;
125
- return this;
126
- }
127
-
128
120
  /**
129
121
  * Mark this field as unreliable — tick patches emit it on the unreliable
130
122
  * transport channel. Still persisted to full-sync snapshots unless also
131
- * tagged with `.transient()`.
123
+ * tagged with `.patchOnly()`. Primitive fields only.
124
+ *
125
+ * The field's FIRST value still travels the reliable channel, as part of
126
+ * the owning instance's ADD; only later mutations become unreliable. A
127
+ * decoder cannot apply a write to a ref it has not been told about, so a
128
+ * value emitted ahead of that ADD would be dropped — and lost for good if
129
+ * the field is never written again.
132
130
  */
133
131
  unreliable(): this {
134
132
  this._unreliable = true;
@@ -136,24 +134,28 @@ export class FieldBuilder<
136
134
  }
137
135
 
138
136
  /**
139
- * Mark this field as transientNOT persisted to full-sync snapshots
140
- * (`encodeAll` / `encodeAllView`). Late-joining clients see the field
141
- * only after its next mutation is emitted on a tick patch. Orthogonal
142
- * to `.unreliable()`.
137
+ * Deliver this field on tick patches ONLY it is never written to a
138
+ * full-state sync (`encodeAll` / `encodeAllView`). Late-joining clients
139
+ * see the field only after its next mutation is emitted on a patch.
140
+ * The mirror of `.fullStateOnly()`, and orthogonal to `.unreliable()`.
143
141
  */
144
- transient(): this {
145
- this._transient = true;
142
+ patchOnly(): this {
143
+ this._patchOnly = true;
146
144
  return this;
147
145
  }
148
146
 
149
147
  /**
150
- * Mark this field as static.
151
- * - Primitive / Schema fields: synchronized once, skips change tracking.
152
- * - Stream fields (`t.stream(X).static()`): child elements are frozen
153
- * after add post-add field mutations on elements become no-ops.
148
+ * Deliver this field in the full state sync ONLY (`encodeAll` /
149
+ * `encodeAllView`) it never enters a tick patch. A client receives it
150
+ * on join (and again on a resync); writes after that are not tracked.
151
+ * The mirror of `.patchOnly()`.
152
+ *
153
+ * The field itself is NOT frozen — it stays mutable server-side, only
154
+ * its propagation stops. On a stream field (`t.stream(X).fullStateOnly()`)
155
+ * the same rule applies per element: post-add mutations are no-ops.
154
156
  */
155
- static(): this {
156
- this._static = true;
157
+ fullStateOnly(): this {
158
+ this._fullStateOnly = true;
157
159
  return this;
158
160
  }
159
161
 
@@ -166,8 +168,8 @@ export class FieldBuilder<
166
168
  * Useful for server-side scratch state, per-peer UI state, or values you
167
169
  * want on the class for typing convenience without paying any sync cost.
168
170
  *
169
- * Mutually exclusive with the sync-only modifiers (`.view()`, `.owned()`,
170
- * `.unreliable()`, `.transient()`, `.static()`, `.stream()`) — combining
171
+ * Mutually exclusive with the sync-only modifiers (`.view()`,
172
+ * `.unreliable()`, `.patchOnly()`, `.fullStateOnly()`, `.stream()`) — combining
171
173
  * them throws at `schema()` time.
172
174
  *
173
175
  * ```ts
@@ -211,9 +213,12 @@ export class FieldBuilder<
211
213
  * higher return values emit first. Does nothing in broadcast mode
212
214
  * (shared `encode()` drains FIFO). Only meaningful on stream fields.
213
215
  *
216
+ * `StateView` carries no position of its own — attach whatever the
217
+ * callback needs to sort by (`view` is loosely typed for this).
218
+ *
214
219
  * ```ts
215
220
  * t.stream(Enemy).priority((view, enemy) =>
216
- * -dist2(view.anchor, enemy)
221
+ * -((enemy.x - view.x) ** 2 + (enemy.y - view.y) ** 2)
217
222
  * )
218
223
  * ```
219
224
  */
@@ -251,12 +256,11 @@ export class FieldBuilder<
251
256
  default: this._default,
252
257
  hasDefault: this._hasDefault,
253
258
  view: this._view,
254
- owned: this._owned,
255
259
  unreliable: this._unreliable,
256
- transient: this._transient,
260
+ patchOnly: this._patchOnly,
257
261
  deprecated: this._deprecated,
258
262
  deprecatedThrows: this._deprecatedThrows,
259
- static: this._static,
263
+ fullStateOnly: this._fullStateOnly,
260
264
  stream: this._stream,
261
265
  optional: this._optional,
262
266
  noSync: this._noSync,
@@ -331,6 +331,35 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
331
331
  return tmpItems.length + (index - live);
332
332
  }
333
333
 
334
+ /**
335
+ * Re-point children at their wire slot. `ChangeTree._parentIndex` caches
336
+ * the slot a child holds in `tmpItems`, and StateView addresses per-view
337
+ * ADD/DELETE with it — so a reorder that leaves it behind aims those ops
338
+ * at whichever element inherited the slot (issue #231).
339
+ *
340
+ * The filter check is a correctness boundary, not a tunable: StateView is
341
+ * the only reader and reaches the index only through a filtered array
342
+ * (`addParentOf` bails on `hasFilteredFields`, `remove` on the child's
343
+ * `isFiltered`). Everything else stops at the flag read instead of walking
344
+ * its children every tick.
345
+ *
346
+ * Callers name the lowest slot that moved as `from`. Compaction cannot, so
347
+ * it hands over the pre-compaction layout as `staged` and the unchanged
348
+ * prefix is skipped instead. Either way tail churn walks nothing.
349
+ */
350
+ protected $reindexChildren(from: number, staged?: V[]) {
351
+ if (!this[$changes].hasFilteredFields) { return; } // nothing will read the cache
352
+ if (typeof this[$childType] === "string") { return; } // primitives have no child tree
353
+ const tmpItems = this.tmpItems;
354
+ const length = tmpItems.length;
355
+ if (staged !== undefined) {
356
+ while (from < length && tmpItems[from] === staged[from]) { from++; }
357
+ }
358
+ for (let i = from; i < length; i++) {
359
+ tmpItems[i]?.[$changes]?.setParentIndex(this, i);
360
+ }
361
+ }
362
+
334
363
  // encoding only. Returns the wire index the change was recorded at
335
364
  // (undefined when nothing was recorded).
336
365
  protected $changeAt(index: number, value: V): number | undefined {
@@ -460,6 +489,7 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
460
489
  self[$changes].operation(OPERATION.REVERSE);
461
490
  self.items.reverse();
462
491
  self.tmpItems.reverse();
492
+ self.$reindexChildren(0);
463
493
  return this;
464
494
  }
465
495
 
@@ -515,6 +545,7 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
515
545
  sortedItems.forEach((_, i) => changeTree.change(i, OPERATION.REPLACE));
516
546
 
517
547
  self.tmpItems.sort(compareFn);
548
+ self.$reindexChildren(0);
518
549
 
519
550
  self.isMovingItems = false;
520
551
  return this;
@@ -620,6 +651,7 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
620
651
  }
621
652
 
622
653
  self.tmpItems.unshift(...items);
654
+ self.$reindexChildren(items.length); // survivors only — the loop above placed the new items
623
655
 
624
656
  return self.items.unshift(...items);
625
657
  }
@@ -938,8 +970,15 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
938
970
 
939
971
  protected [$onEncodeEnd]() {
940
972
  const self = this[$proxyTarget] ?? this;
973
+ const staged = self.tmpItems;
941
974
  self.tmpItems = self.items.slice();
942
- self.deletedIndexes.length = 0;
975
+
976
+ if (self.deletedIndexes.length > 0) {
977
+ // compaction just closed the staged holes — everything above the
978
+ // lowest one slid down a slot
979
+ self.$reindexChildren(0, staged);
980
+ self.deletedIndexes.length = 0;
981
+ }
943
982
  }
944
983
 
945
984
  protected [$onDecodeEnd]() {
@@ -34,7 +34,7 @@ import type { Schema } from "../../Schema.js";
34
34
  * per-client and drained in priority order (callback on StateView) up to
35
35
  * `maxPerTick` per encode pass. Field mutations on already-sent elements
36
36
  * propagate through the normal reliable channel without consuming the
37
- * per-tick budget. Chain `.static()` on the field builder to suppress
37
+ * per-tick budget. Chain `.fullStateOnly()` on the field builder to suppress
38
38
  * post-add mutation tracking entirely.
39
39
  */
40
40
  export class StreamSchema<V = any> implements IRef {
@@ -117,17 +117,10 @@ export const $builder = "~builder";
117
117
  */
118
118
  export const $descriptors = "~descriptors";
119
119
 
120
- /**
121
- * Per-class bitmask: bit i set iff field i carries a @view tag.
122
- * Lazily computed from $viewFieldIndexes on first encode pass.
123
- * Skips the per-field metadata[i].tag property chase in the hot encode loop.
124
- */
125
- export const $filterBitmask = "~__filterBitmask";
126
-
127
120
  /**
128
121
  * Cached per-class encode descriptor: bundles encoder fn, filter fn,
129
- * metadata, isSchema flag, and filterBitmask into one object stashed on
130
- * the constructor. Replaces 5 separate per-tree property chases /
122
+ * metadata, isSchema flag and the per-field arrays into one object stashed
123
+ * on the constructor. Replaces several separate per-tree property chases /
131
124
  * function calls in the encode loop with a single property load.
132
125
  */
133
126
  export const $encodeDescriptor = "~__encodeDescriptor";
@@ -137,7 +130,7 @@ export const $refTypeFieldIndexes = "~__refTypeFieldIndexes";
137
130
  export const $viewFieldIndexes = "~__viewFieldIndexes";
138
131
  export const $fieldIndexesByViewTag = "$__fieldIndexesByViewTag";
139
132
  export const $unreliableFieldIndexes = "~__unreliableFieldIndexes";
140
- export const $transientFieldIndexes = "~__transientFieldIndexes";
141
- export const $staticFieldIndexes = "~__staticFieldIndexes";
133
+ export const $patchOnlyFieldIndexes = "~__patchOnlyFieldIndexes";
134
+ export const $fullStateOnlyFieldIndexes = "~__fullStateOnlyFieldIndexes";
142
135
  export const $streamFieldIndexes = "~__streamFieldIndexes";
143
136
  export const $streamPriorities = "~__streamPriorities";