@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
package/build/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- const SWITCH_TO_STRUCTURE = 255; // (decoding collides with DELETE_AND_ADD + fieldIndex = 63)
1
+ 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
  const TYPE_ID = 213;
3
3
  /**
4
4
  * Encoding Schema field operations.
@@ -118,16 +118,10 @@ const $builder = "~builder";
118
118
  * Metadata
119
119
  */
120
120
  const $descriptors = "~descriptors";
121
- /**
122
- * Per-class bitmask: bit i set iff field i carries a @view tag.
123
- * Lazily computed from $viewFieldIndexes on first encode pass.
124
- * Skips the per-field metadata[i].tag property chase in the hot encode loop.
125
- */
126
- const $filterBitmask = "~__filterBitmask";
127
121
  /**
128
122
  * 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 /
123
+ * metadata, isSchema flag and the per-field arrays into one object stashed
124
+ * on the constructor. Replaces several separate per-tree property chases /
131
125
  * function calls in the encode loop with a single property load.
132
126
  */
133
127
  const $encodeDescriptor = "~__encodeDescriptor";
@@ -137,8 +131,8 @@ const $refTypeFieldIndexes = "~__refTypeFieldIndexes";
137
131
  const $viewFieldIndexes = "~__viewFieldIndexes";
138
132
  const $fieldIndexesByViewTag = "$__fieldIndexesByViewTag";
139
133
  const $unreliableFieldIndexes = "~__unreliableFieldIndexes";
140
- const $transientFieldIndexes = "~__transientFieldIndexes";
141
- const $staticFieldIndexes = "~__staticFieldIndexes";
134
+ const $patchOnlyFieldIndexes = "~__patchOnlyFieldIndexes";
135
+ const $fullStateOnlyFieldIndexes = "~__fullStateOnlyFieldIndexes";
142
136
  const $streamFieldIndexes = "~__streamFieldIndexes";
143
137
  const $streamPriorities = "~__streamPriorities";
144
138
 
@@ -819,6 +813,7 @@ function streamDropView(s, viewId) {
819
813
  return;
820
814
  st.pendingByView.delete(viewId);
821
815
  st.sentByView.delete(viewId);
816
+ st.priorityByView?.delete(viewId);
822
817
  }
823
818
 
824
819
  const WIRE_BY_BITS = {
@@ -1064,6 +1059,15 @@ class TypeContext {
1064
1059
  }
1065
1060
  }
1066
1061
 
1062
+ /**
1063
+ * Field indexes ride in the low 6 bits of the operation byte
1064
+ * (`(index | operation) & 255`), which leaves room for 0..63. Index 63 is
1065
+ * given up: `DELETE_AND_ADD | 63` is 255, the same byte the decoder claims
1066
+ * as SWITCH_TO_STRUCTURE before any field decoder sees it. Every nullable
1067
+ * field can produce that operation (delete-then-set in one tick merges to
1068
+ * DELETE_AND_ADD), so the slot is unusable rather than partly usable.
1069
+ */
1070
+ const MAX_FIELDS = 63;
1067
1071
  /**
1068
1072
  * Given a normalized field type (`"number"`, `{ map: Foo }`, `Player`,
1069
1073
  * etc.), split into the collection-type descriptor (`{ constructor:
@@ -1127,10 +1131,12 @@ function isTSEnum(_enum) {
1127
1131
  }
1128
1132
  const Metadata = {
1129
1133
  addField(metadata, index, name, type, descriptor) {
1130
- if (index > 64) {
1131
- throw new Error(`Can't define field '${name}'.\nSchema instances may only have up to 64 fields.`);
1134
+ // `index` is 0-based, so 62 is the last usable slot — see MAX_FIELDS
1135
+ // for why 63 is off limits.
1136
+ if (index >= MAX_FIELDS) {
1137
+ throw new Error(`Can't define field '${name}'.\nSchema instances may only have up to ${MAX_FIELDS} fields.`);
1132
1138
  }
1133
- metadata[index] = Object.assign(metadata[index] || {}, // avoid overwriting previous field metadata (@owned / @deprecated)
1139
+ metadata[index] = Object.assign(metadata[index] || {}, // avoid overwriting previous field metadata (@deprecated / @unreliable)
1134
1140
  {
1135
1141
  type: getNormalizedType(type),
1136
1142
  index,
@@ -1280,31 +1286,43 @@ const Metadata = {
1280
1286
  }
1281
1287
  metadata[$unreliableFieldIndexes].push(index);
1282
1288
  },
1283
- setTransient(metadata, fieldName) {
1289
+ setPatchOnly(metadata, fieldName) {
1284
1290
  const index = metadata[fieldName];
1285
- metadata[index].transient = true;
1286
- if (!metadata[$transientFieldIndexes]) {
1287
- Object.defineProperty(metadata, $transientFieldIndexes, {
1291
+ // patchOnly + fullStateOnly are the only two delivery channels —
1292
+ // excluding a field from both would silently never reach a client.
1293
+ // (The builder validates earlier; this guards the decorator path.)
1294
+ if (metadata[index].fullStateOnly) {
1295
+ throw new Error(`field "${fieldName}" cannot be both patchOnly and fullStateOnly — ` +
1296
+ `those are the only two delivery channels, so the field would never reach a client.`);
1297
+ }
1298
+ metadata[index].patchOnly = true;
1299
+ if (!metadata[$patchOnlyFieldIndexes]) {
1300
+ Object.defineProperty(metadata, $patchOnlyFieldIndexes, {
1288
1301
  value: [],
1289
1302
  enumerable: false,
1290
1303
  configurable: true,
1291
1304
  writable: true,
1292
1305
  });
1293
1306
  }
1294
- metadata[$transientFieldIndexes].push(index);
1307
+ metadata[$patchOnlyFieldIndexes].push(index);
1295
1308
  },
1296
- setStatic(metadata, fieldName) {
1309
+ setFullStateOnly(metadata, fieldName) {
1297
1310
  const index = metadata[fieldName];
1298
- metadata[index].static = true;
1299
- if (!metadata[$staticFieldIndexes]) {
1300
- Object.defineProperty(metadata, $staticFieldIndexes, {
1311
+ // Mirror of the guard in setPatchOnly — covers both decorator orders.
1312
+ if (metadata[index].patchOnly) {
1313
+ throw new Error(`field "${fieldName}" cannot be both patchOnly and fullStateOnly — ` +
1314
+ `those are the only two delivery channels, so the field would never reach a client.`);
1315
+ }
1316
+ metadata[index].fullStateOnly = true;
1317
+ if (!metadata[$fullStateOnlyFieldIndexes]) {
1318
+ Object.defineProperty(metadata, $fullStateOnlyFieldIndexes, {
1301
1319
  value: [],
1302
1320
  enumerable: false,
1303
1321
  configurable: true,
1304
1322
  writable: true,
1305
1323
  });
1306
1324
  }
1307
- metadata[$staticFieldIndexes].push(index);
1325
+ metadata[$fullStateOnlyFieldIndexes].push(index);
1308
1326
  },
1309
1327
  setStream(metadata, fieldName) {
1310
1328
  const index = metadata[fieldName];
@@ -1468,19 +1486,19 @@ const Metadata = {
1468
1486
  writable: true,
1469
1487
  });
1470
1488
  }
1471
- // $transientFieldIndexes
1472
- if (parentMetadata[$transientFieldIndexes] !== undefined) {
1473
- Object.defineProperty(metadata, $transientFieldIndexes, {
1474
- value: [...parentMetadata[$transientFieldIndexes]],
1489
+ // $patchOnlyFieldIndexes
1490
+ if (parentMetadata[$patchOnlyFieldIndexes] !== undefined) {
1491
+ Object.defineProperty(metadata, $patchOnlyFieldIndexes, {
1492
+ value: [...parentMetadata[$patchOnlyFieldIndexes]],
1475
1493
  enumerable: false,
1476
1494
  configurable: true,
1477
1495
  writable: true,
1478
1496
  });
1479
1497
  }
1480
- // $staticFieldIndexes
1481
- if (parentMetadata[$staticFieldIndexes] !== undefined) {
1482
- Object.defineProperty(metadata, $staticFieldIndexes, {
1483
- value: [...parentMetadata[$staticFieldIndexes]],
1498
+ // $fullStateOnlyFieldIndexes
1499
+ if (parentMetadata[$fullStateOnlyFieldIndexes] !== undefined) {
1500
+ Object.defineProperty(metadata, $fullStateOnlyFieldIndexes, {
1501
+ value: [...parentMetadata[$fullStateOnlyFieldIndexes]],
1484
1502
  enumerable: false,
1485
1503
  configurable: true,
1486
1504
  writable: true,
@@ -1538,11 +1556,11 @@ const Metadata = {
1538
1556
  hasUnreliableAtIndex(metadata, index) {
1539
1557
  return metadata?.[$unreliableFieldIndexes]?.includes(index);
1540
1558
  },
1541
- hasTransientAtIndex(metadata, index) {
1542
- return metadata?.[$transientFieldIndexes]?.includes(index);
1559
+ hasPatchOnlyAtIndex(metadata, index) {
1560
+ return metadata?.[$patchOnlyFieldIndexes]?.includes(index);
1543
1561
  },
1544
- hasStaticAtIndex(metadata, index) {
1545
- return metadata?.[$staticFieldIndexes]?.includes(index);
1562
+ hasFullStateOnlyAtIndex(metadata, index) {
1563
+ return metadata?.[$fullStateOnlyFieldIndexes]?.includes(index);
1546
1564
  },
1547
1565
  hasStreamAtIndex(metadata, index) {
1548
1566
  return metadata?.[$streamFieldIndexes]?.includes(index);
@@ -1553,7 +1571,7 @@ const Metadata = {
1553
1571
  // by passing the user's callback as ctx. No per-call allocation.
1554
1572
  const _invokeNoCtx$2 = (cb, index, op) => cb(index, op);
1555
1573
  // ──────────────────────────────────────────────────────────────────────────
1556
- // SchemaChangeRecorder — bitmask + Uint8Array, for Schema types (≤64 fields)
1574
+ // SchemaChangeRecorder — bitmask + Uint8Array, for Schema types (≤63 fields)
1557
1575
  // ──────────────────────────────────────────────────────────────────────────
1558
1576
  /**
1559
1577
  * Schema field operations are limited to ADD(128), DELETE(64), and
@@ -1746,37 +1764,15 @@ function popcount32(n) {
1746
1764
  * ctor[$filter]
1747
1765
  * ctor[Symbol.metadata]
1748
1766
  * Metadata.isValidInstance(ref)
1749
- * getFilterBitmask(metadata)
1750
1767
  *
1751
1768
  * Lives in its own file to break the Encoder.ts ↔ ChangeTree.ts import
1752
1769
  * cycle (ChangeTree caches descriptors at construction; Encoder reads them
1753
1770
  * during encode).
1754
1771
  */
1755
- function computeFilterBitmask(metadata) {
1756
- if (metadata === undefined)
1757
- return 0;
1758
- let bm = metadata[$filterBitmask];
1759
- if (bm !== undefined)
1760
- return bm;
1761
- bm = 0;
1762
- const tagged = metadata[$viewFieldIndexes];
1763
- if (tagged !== undefined) {
1764
- for (let i = 0, len = tagged.length; i < len; i++)
1765
- bm |= (1 << tagged[i]);
1766
- }
1767
- // Non-enumerable so `for (const k in metadata)` iteration in TypeContext
1768
- // and elsewhere doesn't mistake this cache for a real field index.
1769
- Object.defineProperty(metadata, $filterBitmask, {
1770
- value: bm,
1771
- enumerable: false,
1772
- writable: true,
1773
- configurable: true,
1774
- });
1775
- return bm;
1776
- }
1777
1772
  /**
1778
1773
  * Bitmask of field indexes 0–31 in `indexes`. For fields ≥32 callers must
1779
- * fall back to the array lookup (same as `filterBitmask`).
1774
+ * fall back to the array lookup shift counts wrap at 32, so an unguarded
1775
+ * `1 << 40` would set bit 8 and misclassify field 8.
1780
1776
  */
1781
1777
  function indexesToBitmask(indexes) {
1782
1778
  if (indexes === undefined)
@@ -1852,12 +1848,12 @@ function getEncodeDescriptor(ref) {
1852
1848
  filter,
1853
1849
  metadata,
1854
1850
  isSchema,
1855
- filterBitmask: isSchema ? computeFilterBitmask(metadata) : 0,
1856
- hasAnyStatic: (metadata?.[$staticFieldIndexes]?.length ?? 0) > 0,
1851
+ filterBitmask: isSchema ? indexesToBitmask(metadata?.[$viewFieldIndexes]) : 0,
1852
+ hasAnyFullStateOnly: (metadata?.[$fullStateOnlyFieldIndexes]?.length ?? 0) > 0,
1857
1853
  hasAnyUnreliable: (metadata?.[$unreliableFieldIndexes]?.length ?? 0) > 0,
1858
1854
  hasAnyStream: (metadata?.[$streamFieldIndexes]?.length ?? 0) > 0,
1859
1855
  hasAnyView,
1860
- staticBitmask: indexesToBitmask(metadata?.[$staticFieldIndexes]),
1856
+ fullStateOnlyBitmask: indexesToBitmask(metadata?.[$fullStateOnlyFieldIndexes]),
1861
1857
  unreliableBitmask: indexesToBitmask(metadata?.[$unreliableFieldIndexes]),
1862
1858
  streamBitmask: indexesToBitmask(metadata?.[$streamFieldIndexes]),
1863
1859
  names: arrays.names,
@@ -1993,13 +1989,13 @@ function getAllParents(tree) {
1993
1989
  }
1994
1990
 
1995
1991
  /**
1996
- * Walk all currently-populated non-transient indexes on a tree, emitting
1992
+ * Walk all currently-populated non-patchOnly indexes on a tree, emitting
1997
1993
  * each index once. Used by Root.add (re-stage), Encoder.encodeAll, and
1998
1994
  * StateView.add to derive full-sync output from the live structure.
1999
1995
  *
2000
- * Transient fields (`@transient`) are skipped — they're delivered only on
1996
+ * Patch-only fields (`@patchOnly`) are skipped — they're delivered only on
2001
1997
  * tick patches and not persisted to snapshots. Collections whose parent
2002
- * field is @transient inherit the skip (`tree.isTransient`).
1998
+ * field is @patchOnly inherit the skip (`tree.isPatchOnly`).
2003
1999
  */
2004
2000
  // Adapter that lets `forEachLive(cb)` delegate to `forEachLiveWithCtx(cb, _invokeNoCtx)` —
2005
2001
  // keeps the no-ctx path closure-free and shares one walker implementation.
@@ -2013,10 +2009,10 @@ function forEachLiveWithCtx(tree, ctx, cb) {
2013
2009
  // types. See `ChangeTree.refTarget` doc.
2014
2010
  const ref = tree.refTarget;
2015
2011
  if (ref[$childType] !== undefined) {
2016
- // Collection inheriting @transient from parent field: skip entirely.
2012
+ // Collection inheriting @patchOnly from parent field: skip entirely.
2017
2013
  // The resync sweep (decoder/Resync.ts) relies on this: a collection
2018
2014
  // absent from full-sync output is never pruned client-side.
2019
- if (tree.isTransient)
2015
+ if (tree.isPatchOnly)
2020
2016
  return;
2021
2017
  // Collection types: dispatch by shape.
2022
2018
  if (Array.isArray(ref.items)) {
@@ -2045,7 +2041,7 @@ function forEachLiveWithCtx(tree, ctx, cb) {
2045
2041
  // Schema: walk declared fields. `null` is treated as absent —
2046
2042
  // the setter records a DELETE when a field is set to null or
2047
2043
  // undefined, so it should not appear in full-sync output.
2048
- // (@transient skips below matter to the resync sweep — see
2044
+ // (@patchOnly skips below matter to the resync sweep — see
2049
2045
  // decoder/Resync.ts: absent-from-payload means never pruned.)
2050
2046
  //
2051
2047
  // Read names from the per-class descriptor's parallel array —
@@ -2055,13 +2051,13 @@ function forEachLiveWithCtx(tree, ctx, cb) {
2055
2051
  if (!metadata)
2056
2052
  return;
2057
2053
  const numFields = (metadata[$numFields] ?? -1);
2058
- const transientIndexes = metadata[$transientFieldIndexes];
2054
+ const patchOnlyIndexes = metadata[$patchOnlyFieldIndexes];
2059
2055
  const names = tree.encDescriptor.names;
2060
2056
  for (let i = 0; i <= numFields; i++) {
2061
2057
  const name = names[i];
2062
2058
  if (name === undefined)
2063
2059
  continue;
2064
- if (transientIndexes && transientIndexes.includes(i))
2060
+ if (patchOnlyIndexes && patchOnlyIndexes.includes(i))
2065
2061
  continue;
2066
2062
  const value = ref[name];
2067
2063
  if (value !== undefined && value !== null)
@@ -2071,7 +2067,7 @@ function forEachLiveWithCtx(tree, ctx, cb) {
2071
2067
  }
2072
2068
 
2073
2069
  /**
2074
- * Filter / unreliable / transient / static inheritance helpers for
2070
+ * Filter / unreliable / patchOnly / static inheritance helpers for
2075
2071
  * ChangeTree. Called by setRoot / setParent to derive child flags from
2076
2072
  * the parent field's annotation + the parent tree's own state.
2077
2073
  */
@@ -2084,7 +2080,7 @@ function checkIsFiltered(tree, parent, parentIndex, _isNewChangeTree) {
2084
2080
  checkInheritedFlags(tree, parent, parentIndex);
2085
2081
  // Static trees never track per-tick changes — skip the queue entirely.
2086
2082
  // Full-sync reaches them via structural walk (forEachChild).
2087
- if (tree.isStatic)
2083
+ if (tree.isFullStateOnly)
2088
2084
  return;
2089
2085
  // Mutations that happened before setRoot (e.g. class-field initializers)
2090
2086
  // recorded into the appropriate recorder but couldn't enqueue yet.
@@ -2110,7 +2106,7 @@ function checkIsFiltered(tree, parent, parentIndex, _isNewChangeTree) {
2110
2106
  }
2111
2107
  }
2112
2108
  /**
2113
- * Inherit filter / unreliable / transient / static classification from
2109
+ * Inherit filter / unreliable / patchOnly / static classification from
2114
2110
  * the parent field's annotation. Collections (MapSchema / ArraySchema /
2115
2111
  * etc.) inherit these from the Schema field that holds them.
2116
2112
  *
@@ -2149,14 +2145,14 @@ function checkInheritedFlags(tree, parent, parentIndex) {
2149
2145
  parentIndex = parentChangeTree.parentIndex;
2150
2146
  }
2151
2147
  const parentMetadata = parent?.constructor?.[Symbol.metadata];
2152
- // Flag inheritance — pack the transient/static annotation checks into
2148
+ // Flag inheritance — pack the patchOnly/static annotation checks into
2153
2149
  // flag bits alongside the parent's own transitive flags, then OR onto
2154
2150
  // `tree.flags` in one write. The bit diff tells us which flag just
2155
2151
  // went from 0→1, cheaper than the prior `becameX = !tree.isX && (...)`
2156
2152
  // pairs. IS_UNRELIABLE is omitted from both sides — tree-level
2157
2153
  // unreliable is disabled (see INHERITABLE_FLAGS in ChangeTree.ts).
2158
- const fieldBits = (parentMetadata?.[$transientFieldIndexes]?.includes(parentIndex) ? IS_TRANSIENT : 0)
2159
- | (parentMetadata?.[$staticFieldIndexes]?.includes(parentIndex) ? IS_STATIC : 0);
2154
+ const fieldBits = (parentMetadata?.[$patchOnlyFieldIndexes]?.includes(parentIndex) ? IS_PATCH_ONLY : 0)
2155
+ | (parentMetadata?.[$fullStateOnlyFieldIndexes]?.includes(parentIndex) ? IS_FULL_STATE_ONLY : 0);
2160
2156
  const inheritedBits = (parentChangeTree.flags & INHERITABLE_FLAGS) | fieldBits;
2161
2157
  const beforeFlags = tree.flags;
2162
2158
  tree.flags = beforeFlags | inheritedBits;
@@ -2166,7 +2162,7 @@ function checkInheritedFlags(tree, parent, parentIndex) {
2166
2162
  // `new Config().assign({...})` populates the recorder before the
2167
2163
  // Config instance is attached). Static trees ship state via structural
2168
2164
  // walk only; per-tick dirty entries would leak post-first-sync.
2169
- if (gainedBits & IS_STATIC) {
2165
+ if (gainedBits & IS_FULL_STATE_ONLY) {
2170
2166
  tree.reset();
2171
2167
  tree.unreliableRecorder?.reset();
2172
2168
  }
@@ -2471,7 +2467,7 @@ function _setParentChildCb(ctx, child, index) {
2471
2467
  *
2472
2468
  * - parentChain.ts addParent / removeParent / find / has / getAll
2473
2469
  * - liveIteration.ts forEachLive
2474
- * - inheritedFlags.ts filter / unreliable / transient / static inheritance
2470
+ * - inheritedFlags.ts filter / unreliable / patchOnly / static inheritance
2475
2471
  * - treeAttachment.ts setRoot / setParent / forEachChild(+WithCtx)
2476
2472
  *
2477
2473
  * Public surface on ChangeTree is unchanged — methods are thin pass-throughs
@@ -2491,12 +2487,12 @@ function readInlineOpByte(low, high, index) {
2491
2487
  const _invokeNoCtx = (cb, index, op) => cb(index, op);
2492
2488
  // Linked list helper functions
2493
2489
  function createChangeTreeList() {
2494
- return { next: undefined, tail: undefined };
2490
+ return { next: undefined, tail: undefined, nextPosition: 0 };
2495
2491
  }
2496
- // Flags bitfield. *_UNRELIABLE / _TRANSIENT / _STATIC mirror the parent
2492
+ // Flags bitfield. *_UNRELIABLE / _PATCH_ONLY / _STATIC mirror the parent
2497
2493
  // field's annotation — inherited at setParent/setRoot time.
2498
2494
  const IS_FILTERED = 1, IS_VISIBILITY_SHARED = 2, IS_NEW = 4;
2499
- const IS_UNRELIABLE = 8, IS_TRANSIENT = 16, IS_STATIC = 32;
2495
+ const IS_UNRELIABLE = 8, IS_PATCH_ONLY = 16, IS_FULL_STATE_ONLY = 32;
2500
2496
  // Collection tree attached to a parent field annotated `.stream()` —
2501
2497
  // drives the encoder's priority/broadcast pass. Set in inheritedFlags
2502
2498
  // so both `t.stream(X)` (via StreamSchema's `$isStream` brand) and
@@ -2523,7 +2519,7 @@ const NEEDS_RESTAGE = 128;
2523
2519
  * reconsidered if a safe semantics (e.g. reliable ADD + unreliable
2524
2520
  * field mutations only) is designed later.
2525
2521
  */
2526
- const INHERITABLE_FLAGS = IS_TRANSIENT | IS_STATIC;
2522
+ const INHERITABLE_FLAGS = IS_PATCH_ONLY | IS_FULL_STATE_ONLY;
2527
2523
  class ChangeTree {
2528
2524
  ref;
2529
2525
  /**
@@ -2542,8 +2538,8 @@ class ChangeTree {
2542
2538
  refTarget;
2543
2539
  metadata;
2544
2540
  /**
2545
- * Per-class cache of encoder fn / filter fn / isSchema / filterBitmask /
2546
- * metadata, looked up once at construction. The encode loop reads
2541
+ * Per-class cache of encoder fn / filter fn / isSchema / metadata /
2542
+ * per-field arrays, looked up once at construction. The encode loop reads
2547
2543
  * `tree.encDescriptor` and never touches `ref.constructor` again. See
2548
2544
  * EncodeDescriptor.ts.
2549
2545
  */
@@ -2614,10 +2610,10 @@ class ChangeTree {
2614
2610
  set isNew(v) { this.flags = v ? (this.flags | IS_NEW) : (this.flags & ~IS_NEW); }
2615
2611
  get isUnreliable() { return (this.flags & IS_UNRELIABLE) !== 0; }
2616
2612
  set isUnreliable(v) { this.flags = v ? (this.flags | IS_UNRELIABLE) : (this.flags & ~IS_UNRELIABLE); }
2617
- get isTransient() { return (this.flags & IS_TRANSIENT) !== 0; }
2618
- set isTransient(v) { this.flags = v ? (this.flags | IS_TRANSIENT) : (this.flags & ~IS_TRANSIENT); }
2619
- get isStatic() { return (this.flags & IS_STATIC) !== 0; }
2620
- set isStatic(v) { this.flags = v ? (this.flags | IS_STATIC) : (this.flags & ~IS_STATIC); }
2613
+ get isPatchOnly() { return (this.flags & IS_PATCH_ONLY) !== 0; }
2614
+ set isPatchOnly(v) { this.flags = v ? (this.flags | IS_PATCH_ONLY) : (this.flags & ~IS_PATCH_ONLY); }
2615
+ get isFullStateOnly() { return (this.flags & IS_FULL_STATE_ONLY) !== 0; }
2616
+ set isFullStateOnly(v) { this.flags = v ? (this.flags | IS_FULL_STATE_ONLY) : (this.flags & ~IS_FULL_STATE_ONLY); }
2621
2617
  get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
2622
2618
  set isStreamCollection(v) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
2623
2619
  get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
@@ -2626,7 +2622,7 @@ class ChangeTree {
2626
2622
  // @view-tagged fields. StateView.addParentOf uses this to decide whether
2627
2623
  // a parent must be included in a view's bootstrap. Reads the class-level
2628
2624
  // "any viewed field" flag that `EncodeDescriptor` precomputes — same
2629
- // pattern as `hasAnyStatic` / `hasAnyUnreliable` / `hasAnyStream`.
2625
+ // pattern as `hasAnyFullStateOnly` / `hasAnyUnreliable` / `hasAnyStream`.
2630
2626
  get hasFilteredFields() {
2631
2627
  return this.isFiltered || this.encDescriptor.hasAnyView;
2632
2628
  }
@@ -2650,7 +2646,7 @@ class ChangeTree {
2650
2646
  // metadata lookup. For schemas that DO have unreliable fields, the
2651
2647
  // bitmask answers fields 0-31 in one bitwise op (no Array.includes
2652
2648
  // linear scan). Fields ≥32 always fall back to the metadata lookup
2653
- // (same limitation as filterBitmask bitmask only covers low 32).
2649
+ // (shift counts wrap at 32, so the bitmask only covers the low 32).
2654
2650
  const desc = this.encDescriptor;
2655
2651
  if (!desc.hasAnyUnreliable)
2656
2652
  return false;
@@ -2660,15 +2656,15 @@ class ChangeTree {
2660
2656
  }
2661
2657
  // @static fields sync once via full-sync; post-init mutations are ignored
2662
2658
  // by the tracker (the value still lives on the instance).
2663
- isFieldStatic(index) {
2664
- if (this.isStatic)
2659
+ isFieldFullStateOnly(index) {
2660
+ if (this.isFullStateOnly)
2665
2661
  return true;
2666
2662
  const desc = this.encDescriptor;
2667
- if (!desc.hasAnyStatic)
2663
+ if (!desc.hasAnyFullStateOnly)
2668
2664
  return false;
2669
2665
  if (index < 32)
2670
- return (desc.staticBitmask & (1 << index)) !== 0;
2671
- return Metadata.hasStaticAtIndex(this.metadata, index);
2666
+ return (desc.fullStateOnlyBitmask & (1 << index)) !== 0;
2667
+ return Metadata.hasFullStateOnlyAtIndex(this.metadata, index);
2672
2668
  }
2673
2669
  // `t.stream(...)` collection fields — encoded via per-view priority/budget
2674
2670
  // gate instead of emitting all dirty ADDs in one tick. Class-level short
@@ -2927,7 +2923,7 @@ class ChangeTree {
2927
2923
  // keep the recorder object allocated (re-alloc is the cost we avoid), clear contents
2928
2924
  this.unreliableRecorder?.reset();
2929
2925
  // back to a freshly-constructed tree: IS_NEW, no inherited flags
2930
- // (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
2926
+ // (FILTERED/PATCH_ONLY/STATIC/STREAM are re-derived on the next setParent).
2931
2927
  // NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
2932
2928
  this.flags = IS_NEW | NEEDS_RESTAGE;
2933
2929
  this._fullSyncGen = 0;
@@ -2960,7 +2956,7 @@ class ChangeTree {
2960
2956
  throw new Error("ChangeTree (Schema): unshift is not supported");
2961
2957
  const src = this.collDirty;
2962
2958
  const dst = new Map();
2963
- const track = !this.paused && !this.isStatic;
2959
+ const track = !this.paused && !this.isFullStateOnly;
2964
2960
  if (track) {
2965
2961
  for (let i = 0; i < count; i++)
2966
2962
  dst.set(i, OPERATION.ADD);
@@ -2984,7 +2980,7 @@ class ChangeTree {
2984
2980
  forEachLiveWithCtx(this, ctx, cb);
2985
2981
  }
2986
2982
  operation(op) {
2987
- if (this.paused || this.isStatic)
2983
+ if (this.paused || this.isFullStateOnly)
2988
2984
  return;
2989
2985
  // Pure ops (CLEAR/REVERSE) only emit from collection trees — the
2990
2986
  // recorder here is always a CollectionChangeRecorder by construction.
@@ -3014,11 +3010,23 @@ class ChangeTree {
3014
3010
  * fields (see annotations.ts), so the per-field unreliable flag here
3015
3011
  * always means "primitive value updates" — the structural-ADD-routes-
3016
3012
  * reliable footgun for ref-type fields can't reach this code path.
3013
+ *
3014
+ * `!isNew` holds an `@unreliable` field on the RELIABLE channel until this
3015
+ * tree's own ADD has shipped there. A decoder can only apply a field write
3016
+ * to a ref it already knows, so a value emitted before the ADD is dropped —
3017
+ * permanently, if the field is never written again. `isNew` clears in
3018
+ * `endEncode()`, i.e. after a reliable pass, and recording reliably is
3019
+ * itself what enqueues the tree for that pass; the state is self-clearing
3020
+ * and no tree can be stranded on the wrong channel. Mirrors `encodeAll`,
3021
+ * which has always seeded these fields for late joiners.
3022
+ *
3023
+ * Ordering matters: `isFieldUnreliable` short-circuits on the class-level
3024
+ * `hasAnyUnreliable`, so schemas without the modifier never read `flags`.
3017
3025
  */
3018
3026
  _routeAndRecord(index, op, raw) {
3019
- if (this.paused || this.isFieldStatic(index))
3027
+ if (this.paused || this.isFieldFullStateOnly(index))
3020
3028
  return;
3021
- if (this.isFieldUnreliable(index)) {
3029
+ if (this.isFieldUnreliable(index) && !this.isNew) {
3022
3030
  const r = this.ensureUnreliableRecorder();
3023
3031
  if (raw)
3024
3032
  r.recordRaw(index, op);
@@ -3085,9 +3093,11 @@ class ChangeTree {
3085
3093
  }
3086
3094
  return;
3087
3095
  }
3088
- if (this.paused || this.isFieldStatic(index))
3096
+ if (this.paused || this.isFieldFullStateOnly(index))
3089
3097
  return this.getValue(index);
3090
- const unreliable = this.isFieldUnreliable(index);
3098
+ // Same pre-ADD hold as `_routeAndRecord` — a DELETE naming a ref the
3099
+ // decoder hasn't seen is dropped just like a field write.
3100
+ const unreliable = this.isFieldUnreliable(index) && !this.isNew;
3091
3101
  if (unreliable)
3092
3102
  this.ensureUnreliableRecorder().recordDelete(index, operation ?? OPERATION.DELETE);
3093
3103
  else
@@ -3293,7 +3303,9 @@ function encodeValue(encoder, bytes, type, value, operation, it, encoderFn) {
3293
3303
  * @private
3294
3304
  */
3295
3305
  const encodeSchemaOperation = function (encoder, bytes, changeTree, index, operation, it, _, __) {
3296
- // "compress" field index + operation
3306
+ // "compress" field index + operation. Can't collide with
3307
+ // SWITCH_TO_STRUCTURE (255): that needs `DELETE_AND_ADD | 63`, and
3308
+ // `Metadata.MAX_FIELDS` keeps index 63 unassignable.
3297
3309
  bytes[it.offset++] = (index | operation) & 255;
3298
3310
  // Do not encode value for DELETE operations
3299
3311
  if (operation === OPERATION.DELETE) {
@@ -3489,8 +3501,8 @@ function resyncTouchEntry(decoder, ref, operation, identity, previousValue, valu
3489
3501
  /**
3490
3502
  * Mark a collection as present in the payload — even with zero entries.
3491
3503
  * The sweep only prunes collections reported here: absence means "not part
3492
- * of full-sync" (@transient, view-invisible), where pruning would destroy
3493
- * live data. Reflected clients have no @transient metadata, so payload
3504
+ * of full-sync" (@patchOnly, view-invisible), where pruning would destroy
3505
+ * live data. Reflected clients have no @patchOnly metadata, so payload
3494
3506
  * presence is the only reliable signal.
3495
3507
  */
3496
3508
  function resyncMarkPresent(decoder, refId) {
@@ -3504,7 +3516,7 @@ function resyncMarkPresent(decoder, refId) {
3504
3516
  * entry the snapshot did not visit.
3505
3517
  *
3506
3518
  * Walks the tree from the root — NOT `root.refs` — for three reasons:
3507
- * `@transient` fields are never part of a snapshot and must be left alone;
3519
+ * `@patchOnly` fields are never part of a snapshot and must be left alone;
3508
3520
  * entries of subtrees removed by the sweep itself are left to the GC's
3509
3521
  * transitive walk (sweeping them directly would double-decrement shared
3510
3522
  * children); and collections the snapshot never mentions (emptied
@@ -3529,11 +3541,11 @@ function sweepSchema(decoder, ref, seen, allChanges) {
3529
3541
  if (refIndexes === undefined) {
3530
3542
  return;
3531
3543
  }
3532
- const transient = metadata[$transientFieldIndexes];
3544
+ const patchOnly = metadata[$patchOnlyFieldIndexes];
3533
3545
  for (let i = 0; i < refIndexes.length; i++) {
3534
3546
  const fieldIndex = refIndexes[i];
3535
- // @transient fields are never in a snapshot — leave them alone.
3536
- if (transient !== undefined && transient.includes(fieldIndex)) {
3547
+ // @patchOnly fields are never in a snapshot — leave them alone.
3548
+ if (patchOnly !== undefined && patchOnly.includes(fieldIndex)) {
3537
3549
  continue;
3538
3550
  }
3539
3551
  const field = metadata[fieldIndex];
@@ -3558,7 +3570,7 @@ function sweepCollection(decoder, coll, seen, allChanges) {
3558
3570
  seen.add(refId);
3559
3571
  // `undefined` = the collection never appeared in the payload at all
3560
3572
  // (not even as its parent's field op) — it is not part of full-sync
3561
- // (@transient, view-invisible) and must be left alone. An empty Set
3573
+ // (@patchOnly, view-invisible) and must be left alone. An empty Set
3562
3574
  // means "present with zero entries" → prune everything.
3563
3575
  const visited = decoder.resyncVisited.get(refId);
3564
3576
  if (visited === undefined) {
@@ -5877,7 +5889,7 @@ registerType("set", { constructor: SetSchema });
5877
5889
  * per-client and drained in priority order (callback on StateView) up to
5878
5890
  * `maxPerTick` per encode pass. Field mutations on already-sent elements
5879
5891
  * propagate through the normal reliable channel without consuming the
5880
- * per-tick budget. Chain `.static()` on the field builder to suppress
5892
+ * per-tick budget. Chain `.fullStateOnly()` on the field builder to suppress
5881
5893
  * post-add mutation tracking entirely.
5882
5894
  */
5883
5895
  class StreamSchema {
@@ -6152,12 +6164,11 @@ class FieldBuilder {
6152
6164
  _default = undefined;
6153
6165
  _hasDefault = false;
6154
6166
  _view = undefined;
6155
- _owned = false;
6156
6167
  _unreliable = false;
6157
- _transient = false;
6168
+ _patchOnly = false;
6158
6169
  _deprecated = false;
6159
6170
  _deprecatedThrows = true;
6160
- _static = false;
6171
+ _fullStateOnly = false;
6161
6172
  _stream = false;
6162
6173
  _optional = false;
6163
6174
  _noSync = false;
@@ -6192,38 +6203,43 @@ class FieldBuilder {
6192
6203
  this._view = tag ?? -1;
6193
6204
  return this;
6194
6205
  }
6195
- /** Mark this field as owned (encoder-side ownership filtering). */
6196
- owned() {
6197
- this._owned = true;
6198
- return this;
6199
- }
6200
6206
  /**
6201
6207
  * Mark this field as unreliable — tick patches emit it on the unreliable
6202
6208
  * transport channel. Still persisted to full-sync snapshots unless also
6203
- * tagged with `.transient()`.
6209
+ * tagged with `.patchOnly()`. Primitive fields only.
6210
+ *
6211
+ * The field's FIRST value still travels the reliable channel, as part of
6212
+ * the owning instance's ADD; only later mutations become unreliable. A
6213
+ * decoder cannot apply a write to a ref it has not been told about, so a
6214
+ * value emitted ahead of that ADD would be dropped — and lost for good if
6215
+ * the field is never written again.
6204
6216
  */
6205
6217
  unreliable() {
6206
6218
  this._unreliable = true;
6207
6219
  return this;
6208
6220
  }
6209
6221
  /**
6210
- * Mark this field as transientNOT persisted to full-sync snapshots
6211
- * (`encodeAll` / `encodeAllView`). Late-joining clients see the field
6212
- * only after its next mutation is emitted on a tick patch. Orthogonal
6213
- * to `.unreliable()`.
6222
+ * Deliver this field on tick patches ONLY it is never written to a
6223
+ * full-state sync (`encodeAll` / `encodeAllView`). Late-joining clients
6224
+ * see the field only after its next mutation is emitted on a patch.
6225
+ * The mirror of `.fullStateOnly()`, and orthogonal to `.unreliable()`.
6214
6226
  */
6215
- transient() {
6216
- this._transient = true;
6227
+ patchOnly() {
6228
+ this._patchOnly = true;
6217
6229
  return this;
6218
6230
  }
6219
6231
  /**
6220
- * Mark this field as static.
6221
- * - Primitive / Schema fields: synchronized once, skips change tracking.
6222
- * - Stream fields (`t.stream(X).static()`): child elements are frozen
6223
- * after add post-add field mutations on elements become no-ops.
6232
+ * Deliver this field in the full state sync ONLY (`encodeAll` /
6233
+ * `encodeAllView`) it never enters a tick patch. A client receives it
6234
+ * on join (and again on a resync); writes after that are not tracked.
6235
+ * The mirror of `.patchOnly()`.
6236
+ *
6237
+ * The field itself is NOT frozen — it stays mutable server-side, only
6238
+ * its propagation stops. On a stream field (`t.stream(X).fullStateOnly()`)
6239
+ * the same rule applies per element: post-add mutations are no-ops.
6224
6240
  */
6225
- static() {
6226
- this._static = true;
6241
+ fullStateOnly() {
6242
+ this._fullStateOnly = true;
6227
6243
  return this;
6228
6244
  }
6229
6245
  /**
@@ -6235,8 +6251,8 @@ class FieldBuilder {
6235
6251
  * Useful for server-side scratch state, per-peer UI state, or values you
6236
6252
  * want on the class for typing convenience without paying any sync cost.
6237
6253
  *
6238
- * Mutually exclusive with the sync-only modifiers (`.view()`, `.owned()`,
6239
- * `.unreliable()`, `.transient()`, `.static()`, `.stream()`) — combining
6254
+ * Mutually exclusive with the sync-only modifiers (`.view()`,
6255
+ * `.unreliable()`, `.patchOnly()`, `.fullStateOnly()`, `.stream()`) — combining
6240
6256
  * them throws at `schema()` time.
6241
6257
  *
6242
6258
  * ```ts
@@ -6278,9 +6294,12 @@ class FieldBuilder {
6278
6294
  * higher return values emit first. Does nothing in broadcast mode
6279
6295
  * (shared `encode()` drains FIFO). Only meaningful on stream fields.
6280
6296
  *
6297
+ * `StateView` carries no position of its own — attach whatever the
6298
+ * callback needs to sort by (`view` is loosely typed for this).
6299
+ *
6281
6300
  * ```ts
6282
6301
  * t.stream(Enemy).priority((view, enemy) =>
6283
- * -dist2(view.anchor, enemy)
6302
+ * -((enemy.x - view.x) ** 2 + (enemy.y - view.y) ** 2)
6284
6303
  * )
6285
6304
  * ```
6286
6305
  */
@@ -6315,12 +6334,11 @@ class FieldBuilder {
6315
6334
  default: this._default,
6316
6335
  hasDefault: this._hasDefault,
6317
6336
  view: this._view,
6318
- owned: this._owned,
6319
6337
  unreliable: this._unreliable,
6320
- transient: this._transient,
6338
+ patchOnly: this._patchOnly,
6321
6339
  deprecated: this._deprecated,
6322
6340
  deprecatedThrows: this._deprecatedThrows,
6323
- static: this._static,
6341
+ fullStateOnly: this._fullStateOnly,
6324
6342
  stream: this._stream,
6325
6343
  optional: this._optional,
6326
6344
  noSync: this._noSync,
@@ -6574,25 +6592,44 @@ function view(tag = DEFAULT_VIEW_TAG) {
6574
6592
  Metadata.setTag(metadata, fieldName, tag);
6575
6593
  };
6576
6594
  }
6577
- function owned(target, field) {
6578
- const metadata = Metadata.initialize(target.constructor);
6579
- metadata[metadata[field]].owned = true;
6580
- }
6595
+ /**
6596
+ * `@unreliable` — route a field onto the unreliable transport channel, so a
6597
+ * dropped update costs one stale value instead of stalling the ordered stream
6598
+ * behind a retransmit. Primitive fields only (see `Metadata.setUnreliable`).
6599
+ *
6600
+ * The field's FIRST value still travels the reliable channel, as part of the
6601
+ * owning instance's ADD; only later mutations become unreliable. A decoder
6602
+ * cannot apply a write to a ref it has not been told about, so a value emitted
6603
+ * ahead of that ADD would be dropped — and lost for good if the field is never
6604
+ * written again.
6605
+ */
6581
6606
  function unreliable(target, field) {
6582
6607
  const metadata = Metadata.initialize(target.constructor);
6583
6608
  Metadata.setUnreliable(metadata, field);
6584
6609
  }
6585
6610
  /**
6586
- * @transient — mark a field as not persisted to snapshots (encodeAll /
6587
- * encodeAllView). Transient fields are still emitted on per-tick patches
6611
+ * @patchOnly — mark a field as not persisted to snapshots (encodeAll /
6612
+ * encodeAllView). PatchOnly fields are still emitted on per-tick patches
6588
6613
  * (reliable or unreliable), but late-joining clients won't see them until
6589
6614
  * the next mutation.
6590
6615
  *
6591
6616
  * Orthogonal to @unreliable: a field can be either, both, or neither.
6592
6617
  */
6593
- function transient(target, field) {
6618
+ function patchOnly(target, field) {
6619
+ const metadata = Metadata.initialize(target.constructor);
6620
+ Metadata.setPatchOnly(metadata, field);
6621
+ }
6622
+ /**
6623
+ * @fullStateOnly — mark a field as delivered in the full state sync only
6624
+ * (encodeAll / encodeAllView), never on per-tick patches. Writes after a
6625
+ * client has joined are not propagated to it — populate these fields
6626
+ * before clients connect (e.g. during onCreate).
6627
+ *
6628
+ * The exact mirror of @patchOnly — the two are mutually exclusive.
6629
+ */
6630
+ function fullStateOnly(target, field) {
6594
6631
  const metadata = Metadata.initialize(target.constructor);
6595
- Metadata.setTransient(metadata, field);
6632
+ Metadata.setFullStateOnly(metadata, field);
6596
6633
  }
6597
6634
  function type(type, options) {
6598
6635
  return function (target, field) {
@@ -6964,11 +7001,10 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6964
7001
  }
6965
7002
  };
6966
7003
  const viewTagFields = {};
6967
- const ownedFields = [];
6968
7004
  const unreliableFields = [];
6969
- const transientFields = [];
7005
+ const patchOnlyFields = [];
6970
7006
  const deprecatedFields = {};
6971
- const staticFields = [];
7007
+ const fullStateOnlyFields = [];
6972
7008
  const streamFields = [];
6973
7009
  const streamPriorityFields = {};
6974
7010
  const optionalFields = [];
@@ -6980,15 +7016,22 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6980
7016
  // Local-only field: skip metadata registration entirely so it is
6981
7017
  // never encoded/decoded, but still seed its construction default
6982
7018
  // (honoring `.default()` and collection/ref auto-instantiation).
6983
- if (def.view !== undefined || def.owned || def.unreliable ||
6984
- def.transient || def.static || def.stream) {
7019
+ if (def.view !== undefined || def.unreliable ||
7020
+ def.patchOnly || def.fullStateOnly || def.stream) {
6985
7021
  throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .noSync() ` +
6986
- `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
7022
+ `together with a sync-only modifier (.view/.unreliable/.patchOnly/.fullStateOnly/.stream). ` +
6987
7023
  `A local-only field cannot be synchronized.`);
6988
7024
  }
6989
7025
  seedDefault(fieldName, def);
6990
7026
  continue;
6991
7027
  }
7028
+ // The two delivery channels are exhaustive: excluding a field from
7029
+ // both leaves it with nowhere to go — a silent .noSync().
7030
+ if (def.patchOnly && def.fullStateOnly) {
7031
+ throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .patchOnly() ` +
7032
+ `together with .fullStateOnly(). Those are the only two delivery channels, ` +
7033
+ `so the field would never reach a client — use .noSync() if that is intended.`);
7034
+ }
6992
7035
  const normalizedType = getNormalizedType(def.type);
6993
7036
  // A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
6994
7037
  if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
@@ -6999,20 +7042,17 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6999
7042
  if (def.view !== undefined) {
7000
7043
  viewTagFields[fieldName] = def.view;
7001
7044
  }
7002
- if (def.owned) {
7003
- ownedFields.push(fieldName);
7004
- }
7005
7045
  if (def.unreliable) {
7006
7046
  unreliableFields.push(fieldName);
7007
7047
  }
7008
- if (def.transient) {
7009
- transientFields.push(fieldName);
7048
+ if (def.patchOnly) {
7049
+ patchOnlyFields.push(fieldName);
7010
7050
  }
7011
7051
  if (def.deprecated) {
7012
7052
  deprecatedFields[fieldName] = def.deprecatedThrows;
7013
7053
  }
7014
- if (def.static) {
7015
- staticFields.push(fieldName);
7054
+ if (def.fullStateOnly) {
7055
+ fullStateOnlyFields.push(fieldName);
7016
7056
  }
7017
7057
  if (def.stream) {
7018
7058
  streamFields.push(fieldName);
@@ -7095,22 +7135,19 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
7095
7135
  for (const fieldName in viewTagFields) {
7096
7136
  view(viewTagFields[fieldName])(klass.prototype, fieldName);
7097
7137
  }
7098
- for (const fieldName of ownedFields) {
7099
- owned(klass.prototype, fieldName);
7100
- }
7101
7138
  for (const fieldName of unreliableFields) {
7102
7139
  unreliable(klass.prototype, fieldName);
7103
7140
  }
7104
- for (const fieldName of transientFields) {
7105
- transient(klass.prototype, fieldName);
7141
+ for (const fieldName of patchOnlyFields) {
7142
+ patchOnly(klass.prototype, fieldName);
7106
7143
  }
7107
7144
  for (const fieldName in deprecatedFields) {
7108
7145
  deprecated(deprecatedFields[fieldName])(klass.prototype, fieldName);
7109
7146
  }
7110
- if (staticFields.length > 0 || streamFields.length > 0) {
7147
+ if (fullStateOnlyFields.length > 0 || streamFields.length > 0) {
7111
7148
  const metadata = klass[Symbol.metadata];
7112
- for (const fieldName of staticFields) {
7113
- Metadata.setStatic(metadata, fieldName);
7149
+ for (const fieldName of fullStateOnlyFields) {
7150
+ Metadata.setFullStateOnly(metadata, fieldName);
7114
7151
  }
7115
7152
  for (const fieldName of streamFields) {
7116
7153
  Metadata.setStream(metadata, fieldName);
@@ -7759,7 +7796,7 @@ class Root {
7759
7796
  const previousRefCount = this.refCount[refId];
7760
7797
  if (previousRefCount === 0 || changeTree.needsRestage) {
7761
7798
  //
7762
- // Re-stage every currently-populated non-transient index as a
7799
+ // Re-stage every currently-populated non-patchOnly index as a
7763
7800
  // fresh ADD in the matching dirty bucket so the next encode
7764
7801
  // re-emits it on the correct channel. Two triggers:
7765
7802
  // - refCount 0: a previously-removed tree re-added under the
@@ -7849,14 +7886,10 @@ class Root {
7849
7886
  const parentNode = parent[$changes][nodeField];
7850
7887
  if (!parentNode || parentNode === node)
7851
7888
  return;
7852
- // Check if child is already after parent by walking from parent
7853
- let cursor = parentNode.next;
7854
- while (cursor) {
7855
- if (cursor === node)
7856
- return; // already after parent
7857
- cursor = cursor.next;
7858
- }
7859
- // If we reach here, node is before parent — need to move
7889
+ // Positions are strictly increasing along the list, so this is an
7890
+ // exact O(1) "is child already after parent" test — no queue scan.
7891
+ if (node.position > parentNode.position)
7892
+ return;
7860
7893
  // Remove node from current position
7861
7894
  if (node.prev) {
7862
7895
  node.prev.next = node.next;
@@ -7870,16 +7903,18 @@ class Root {
7870
7903
  else {
7871
7904
  changeSet.tail = node.prev;
7872
7905
  }
7873
- // Insert node right after parent
7874
- node.prev = parentNode;
7875
- node.next = parentNode.next;
7876
- if (parentNode.next) {
7877
- parentNode.next.prev = node;
7878
- }
7879
- else {
7880
- changeSet.tail = node;
7881
- }
7882
- parentNode.next = node;
7906
+ // Re-append at the tail: after `parentNode` AND after every other
7907
+ // queued parent of a multi-referenced instance — relinking next to
7908
+ // the *primary* parent could jump the child ahead of a 2nd/3rd
7909
+ // parent whose ADD the decoder must see first. Tail placement gets
7910
+ // a fresh max position, keeping the invariant append-only.
7911
+ // (`recursivelyMoveNextToParent` visits pre-order, so a moved
7912
+ // subtree re-serializes parent-first behind it.)
7913
+ node.prev = changeSet.tail;
7914
+ node.next = undefined;
7915
+ changeSet.tail.next = node; // parentNode remains in the list — never empty here
7916
+ changeSet.tail = node;
7917
+ node.position = changeSet.nextPosition++;
7883
7918
  }
7884
7919
  enqueueChangeTree(changeTree, existingNode = changeTree.changesNode) {
7885
7920
  if (existingNode) {
@@ -7901,12 +7936,12 @@ class Root {
7901
7936
  node.changeTree = changeTree;
7902
7937
  node.next = undefined;
7903
7938
  node.prev = undefined;
7904
- node.position = 0;
7905
7939
  }
7906
7940
  else {
7907
7941
  node = { changeTree, next: undefined, prev: undefined, position: 0 };
7908
7942
  }
7909
7943
  if (!list.next) {
7944
+ list.nextPosition = 0; // list drained — restart sequence (stays SMI)
7910
7945
  list.next = node;
7911
7946
  list.tail = node;
7912
7947
  }
@@ -7915,6 +7950,7 @@ class Root {
7915
7950
  list.tail.next = node;
7916
7951
  list.tail = node;
7917
7952
  }
7953
+ node.position = list.nextPosition++;
7918
7954
  return node;
7919
7955
  }
7920
7956
  /**
@@ -8013,6 +8049,7 @@ function _fullSyncWalk(ctx, changeTree) {
8013
8049
  ctx.treeIsFiltered = changeTree.isFiltered;
8014
8050
  ctx.isSchema = desc.isSchema;
8015
8051
  ctx.filterBitmask = desc.filterBitmask;
8052
+ ctx.tags = desc.tags;
8016
8053
  ctx.structSwitchEmitted = false;
8017
8054
  ctx.shouldEmitSwitch = (ctx.hasView || ctx.it.offset > ctx.initialOffset || changeTree !== ctx.rootChangeTree);
8018
8055
  // Call the module function directly — the `forEachLiveWithCtx`
@@ -8052,10 +8089,13 @@ function encodeChangeCb(ctx, fieldIndex, op) {
8052
8089
  }
8053
8090
  // Per-field filter decision (same rule as ChangeTree.change()):
8054
8091
  // a field is filtered iff the tree inherits isFiltered OR the field
8055
- // itself carries a @view tag. Schema trees check via the precomputed
8056
- // bitmask; collection trees inherit tree-level (bitmask is 0).
8092
+ // itself carries a @view tag. The bitmask only spans 0–31 — `1 << 40`
8093
+ // wraps onto bit 8 so fields past it read their tag directly. Reaching
8094
+ // that arm needs a Schema with more than 32 fields.
8057
8095
  const fieldFiltered = ctx.isSchema
8058
- ? (ctx.treeIsFiltered || (ctx.filterBitmask & (1 << fieldIndex)) !== 0)
8096
+ ? (ctx.treeIsFiltered || (fieldIndex < 32
8097
+ ? (ctx.filterBitmask & (1 << fieldIndex)) !== 0
8098
+ : ctx.tags[fieldIndex] !== undefined))
8059
8099
  : ctx.treeIsFiltered;
8060
8100
  if (fieldFiltered !== ctx.emitFiltered)
8061
8101
  return;
@@ -8110,7 +8150,7 @@ class Encoder {
8110
8150
  ref: undefined, encoder: undefined, filter: undefined, metadata: undefined,
8111
8151
  view: undefined, isEncodeAll: false, hasView: false,
8112
8152
  treeIsFiltered: false, isSchema: false, emitFiltered: false,
8113
- filterBitmask: 0,
8153
+ filterBitmask: 0, tags: undefined,
8114
8154
  structSwitchEmitted: false, isRootTree: false, shouldEmitSwitch: false,
8115
8155
  gen: 0, initialOffset: 0, rootChangeTree: undefined,
8116
8156
  };
@@ -8167,6 +8207,7 @@ class Encoder {
8167
8207
  ctx.treeIsFiltered = changeTree.isFiltered;
8168
8208
  ctx.isSchema = desc.isSchema;
8169
8209
  ctx.filterBitmask = desc.filterBitmask;
8210
+ ctx.tags = desc.tags;
8170
8211
  ctx.structSwitchEmitted = false;
8171
8212
  ctx.isRootTree = (changeTree === rootChangeTree);
8172
8213
  // Root's struct switch is skipped at the very start of the shared
@@ -8418,7 +8459,7 @@ class Encoder {
8418
8459
  // Emit each element's full state — forEachLive walks populated
8419
8460
  // fields structurally, mirroring encodeAllView's bootstrap.
8420
8461
  // Covers both static elements (dirty state was reset by
8421
- // inheritedFlags' becameStatic branch) and non-static (still
8462
+ // inheritedFlags' becameFullStateOnly branch) and non-static (still
8422
8463
  // has dirty state but the main loop skipped them because
8423
8464
  // they're filtered).
8424
8465
  for (const element of emittedElements) {
@@ -8497,20 +8538,81 @@ class Encoder {
8497
8538
  // `t.stream(X).priority(fn)` or the decorator form) and seeded
8498
8539
  // into `_stream.priority` when the stream was attached. Users
8499
8540
  // can also override per-instance by assigning to the setter.
8541
+ // A per-view callback (registered by `subscribe(coll, fn)`)
8542
+ // wins over the declaration-scope one: it closes over the
8543
+ // client's own entity, so it needs no view-carried anchor.
8544
+ const perView = st.priorityByView?.get(viewId);
8545
+ const usePerView = perView !== undefined;
8500
8546
  const priority = st.priority;
8501
- // Materialize pending into an array so we can sort + slice.
8502
- // Small sets (typical: tens to low hundreds) — allocation is
8503
- // negligible compared to the priority sort and element walk.
8547
+ const max = st.maxPerTick;
8548
+ // Select the `max` highest-priority candidates.
8549
+ //
8550
+ // A comparator-based sort invokes the callback twice per
8551
+ // comparison, each with its own `$getByIndex` lookup — ~2·n·log n
8552
+ // of each to pick `max` entries (38k calls to select 8 out of a
8553
+ // 2000-entry backlog). Scoring every candidate once and keeping a
8554
+ // bounded top-`max` window costs n invocations instead, and sizes
8555
+ // the scratch by `max` rather than by the backlog.
8556
+ //
8557
+ // Ties keep the earlier position (both comparisons below are
8558
+ // strict), so equal-priority entries still drain in insertion
8559
+ // order.
8504
8560
  const positions = [];
8505
- for (const p of pending)
8506
- positions.push(p);
8507
- if (priority !== undefined) {
8508
- // Use the symbol-keyed accessor so Map/Set/Stream all route
8509
- // through the same lookup regardless of $items layout.
8510
- positions.sort((a, b) => priority(view, s[$getByIndex](b)) - priority(view, s[$getByIndex](a)));
8561
+ const stale = [];
8562
+ if (usePerView || priority !== undefined) {
8563
+ const bestPos = [];
8564
+ const bestScore = [];
8565
+ let filled = 0;
8566
+ for (const pos of pending) {
8567
+ // Symbol-keyed accessor so Map/Set/Stream all route
8568
+ // through the same lookup regardless of $items layout.
8569
+ const element = s[$getByIndex](pos);
8570
+ if (element === undefined) {
8571
+ // Removed after being queued — drop it below without
8572
+ // spending budget on it.
8573
+ stale.push(pos);
8574
+ continue;
8575
+ }
8576
+ const score = usePerView
8577
+ ? perView(element)
8578
+ : priority(view, element);
8579
+ // Window not yet full: always insert.
8580
+ if (filled < max) {
8581
+ let j = filled++;
8582
+ while (j > 0 && bestScore[j - 1] < score) {
8583
+ bestScore[j] = bestScore[j - 1];
8584
+ bestPos[j] = bestPos[j - 1];
8585
+ j--;
8586
+ }
8587
+ bestScore[j] = score;
8588
+ bestPos[j] = pos;
8589
+ // Otherwise only a strictly better score displaces the tail.
8590
+ }
8591
+ else if (score > bestScore[max - 1]) {
8592
+ let j = max - 1;
8593
+ while (j > 0 && bestScore[j - 1] < score) {
8594
+ bestScore[j] = bestScore[j - 1];
8595
+ bestPos[j] = bestPos[j - 1];
8596
+ j--;
8597
+ }
8598
+ bestScore[j] = score;
8599
+ bestPos[j] = pos;
8600
+ }
8601
+ }
8602
+ for (let i = 0; i < filled; i++)
8603
+ positions.push(bestPos[i]);
8511
8604
  }
8512
- const max = st.maxPerTick;
8513
- const count = Math.min(positions.length, max);
8605
+ else {
8606
+ // FIFO take the head of the backlog, no scoring needed.
8607
+ for (const pos of pending) {
8608
+ if (positions.length >= max)
8609
+ break;
8610
+ positions.push(pos);
8611
+ }
8612
+ }
8613
+ for (const pos of stale)
8614
+ pending.delete(pos);
8615
+ const count = positions.length;
8514
8616
  let sent = st.sentByView.get(viewId);
8515
8617
  if (sent === undefined) {
8516
8618
  sent = new Set();
@@ -9865,6 +9967,31 @@ const _disposeRegistry = new FinalizationRegistry(({ root, id, slot, bit }) => {
9865
9967
  _clearViewBitFromAllTrees(root, slot, bit);
9866
9968
  root.releaseViewId(id);
9867
9969
  });
9970
+ /**
9971
+ * Compact description of a rejected argument, for warning messages.
9972
+ * Passing the value itself to `console.warn` is not an option — a
9973
+ * populated collection inspects into dozens of lines of encoder
9974
+ * internals and buries the message that matters.
9975
+ */
9976
+ function describeArg(value) {
9977
+ if (value === undefined) {
9978
+ return "undefined";
9979
+ }
9980
+ if (value === null) {
9981
+ return "null";
9982
+ }
9983
+ const type = typeof value;
9984
+ if (type === "string") {
9985
+ return JSON.stringify(value.length > 30 ? `${value.slice(0, 30)}…` : value);
9986
+ }
9987
+ if (type !== "object" && type !== "function") {
9988
+ return `${type} ${String(value)}`;
9989
+ }
9990
+ if (Array.isArray(value)) {
9991
+ return `Array(${value.length})`;
9992
+ }
9993
+ return value.constructor?.name ?? "Object";
9994
+ }
9868
9995
  class StateView {
9869
9996
  iterable;
9870
9997
  /**
@@ -10089,12 +10216,12 @@ class StateView {
10089
10216
  }
10090
10217
  _add(obj, tag, checkIncludeParent, _skipStreamRouting) {
10091
10218
  const changeTree = obj?.[$changes];
10092
- const parentChangeTree = changeTree.parent;
10093
10219
  if (!changeTree) {
10094
- console.warn("StateView#add(), invalid object:", obj);
10220
+ console.warn(`StateView#add(): expected a Schema instance or collection, received ${describeArg(obj)}`);
10095
10221
  return false;
10096
10222
  }
10097
- else if (!parentChangeTree &&
10223
+ const parentChangeTree = changeTree.parent;
10224
+ if (!parentChangeTree &&
10098
10225
  obj[$refId] !== 0 // allow root object
10099
10226
  ) {
10100
10227
  /**
@@ -10375,9 +10502,9 @@ class StateView {
10375
10502
  }
10376
10503
  }
10377
10504
  remove(obj, tag = DEFAULT_VIEW_TAG, _isClear = false) {
10378
- const changeTree = obj[$changes];
10505
+ const changeTree = obj?.[$changes];
10379
10506
  if (!changeTree) {
10380
- console.warn("StateView#remove(), invalid object:", obj);
10507
+ console.warn(`StateView#remove(): expected a Schema instance or collection, received ${describeArg(obj)}`);
10381
10508
  return this;
10382
10509
  }
10383
10510
  // ── Streamable-element unsubscribe ─────────────────────────────
@@ -10515,32 +10642,47 @@ class StateView {
10515
10642
  hasTag(ob, tag = DEFAULT_VIEW_TAG) {
10516
10643
  return this.hasTagOnTree(ob[$changes], tag);
10517
10644
  }
10518
- /**
10519
- * Persistent subscription to a collection's contents. Unlike `add()`,
10520
- * which is a one-shot bootstrap, `subscribe()` enrolls this view in
10521
- * future content changes — every subsequent push / set / add to the
10522
- * collection automatically flows to this view, and every removal
10523
- * queues a DELETE op. Works on every collection type:
10524
- *
10525
- * - `ArraySchema` / `MapSchema` / `SetSchema` / `CollectionSchema`:
10526
- * new children are force-shipped immediately (equivalent to
10527
- * `view.add(child)` per item).
10528
- * - `StreamSchema` (or `.stream()` maps/sets): new positions are
10529
- * enqueued into `_pendingByView` so the priority pass drains them
10530
- * respecting `maxPerTick`.
10531
- *
10532
- * Idempotent on re-subscribe. Subscribing to an already-subscribed
10533
- * collection is a no-op.
10534
- */
10535
- subscribe(collection) {
10645
+ subscribe(collection, priority) {
10536
10646
  const tree = collection?.[$changes];
10537
10647
  if (!tree) {
10538
- console.warn("StateView#subscribe(), invalid collection:", collection);
10648
+ console.warn(`StateView#subscribe(): expected a Schema collection, received ${describeArg(collection)}`);
10539
10649
  return this;
10540
10650
  }
10541
10651
  if (this._root === undefined && tree.root !== undefined) {
10542
10652
  this._bindRoot(tree.root);
10543
10653
  }
10654
+ if (priority !== undefined) {
10655
+ if (!tree.isStreamCollection) {
10656
+ // Name the field rather than dumping the collection — a
10657
+ // populated MapSchema inspects into dozens of lines of
10658
+ // internals and buries the message.
10659
+ const kind = collection?.constructor?.name ?? "collection";
10660
+ const parent = tree.parent;
10661
+ if (parent === undefined) {
10662
+ console.warn(`StateView#subscribe(): \`priority\` ignored — this ${kind} is not ` +
10663
+ `attached to a state yet, so it cannot be identified as a stream. ` +
10664
+ `Subscribe after assigning it to the state.`);
10665
+ }
10666
+ else {
10667
+ const field = parent?.constructor?.[Symbol.metadata]?.[tree.parentIndex]?.name;
10668
+ const where = field ? `${parent.constructor.name}#${field}` : kind;
10669
+ console.warn(`StateView#subscribe(): \`priority\` ignored — ${where} is a ${kind}, ` +
10670
+ `not a streaming collection. Declare the field with .stream() ` +
10671
+ `(e.g. t.map(X).stream()) or use t.stream(X) to enable priority batching.`);
10672
+ }
10673
+ }
10674
+ else {
10675
+ // Set before the idempotency return below, so re-subscribing
10676
+ // is the documented way to retarget this view's ordering.
10677
+ const st = ensureStreamState(collection);
10678
+ if (priority === null) {
10679
+ st.priorityByView?.delete(this.id);
10680
+ }
10681
+ else {
10682
+ (st.priorityByView ??= new Map()).set(this.id, priority);
10683
+ }
10684
+ }
10685
+ }
10544
10686
  if (this.isSubscribed(tree))
10545
10687
  return this;
10546
10688
  // Mark collection visible so its own ADD/DELETE ops emit in the
@@ -10586,7 +10728,7 @@ class StateView {
10586
10728
  unsubscribe(collection) {
10587
10729
  const tree = collection?.[$changes];
10588
10730
  if (!tree) {
10589
- console.warn("StateView#unsubscribe(), invalid collection:", collection);
10731
+ console.warn(`StateView#unsubscribe(): expected a Schema collection, received ${describeArg(collection)}`);
10590
10732
  return this;
10591
10733
  }
10592
10734
  if (!this.isSubscribed(tree))
@@ -10670,5 +10812,5 @@ registerType("array", { constructor: ArraySchema });
10670
10812
  registerType("set", { constructor: SetSchema });
10671
10813
  registerType("collection", { constructor: CollectionSchema, });
10672
10814
 
10673
- export { $changes, $childType, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $numFields, $refId, $track, $values, ArraySchema, Callbacks, ChangeTree, CollectionSchema, Decoder, Encoder, FieldBuilder, MapSchema, Metadata, OPERATION, Reflection, ReflectionField, ReflectionType, Root, Schema, SetSchema, StateCallbackStrategy, StateView, StreamSchema, TypeContext, createPool, decode, decodeKeyValueOperation, decodeSchemaOperation, defineCustomTypes, defineTypes, deprecated, dumpChanges, encode, encodeArray, encodeIndexedEntry, encodeKeyValueOperation, encodeMapEntry, encodeSchemaOperation, entity, getDecoderStateCallbacks, getEncodeDescriptor, getRawChangesCallback, isBuilder, owned, registerType, schema, t, transient, type, unreliable, view };
10815
+ export { $changes, $childType, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $numFields, $refId, $track, $values, ArraySchema, Callbacks, ChangeTree, CollectionSchema, Decoder, Encoder, FieldBuilder, MapSchema, Metadata, OPERATION, Reflection, ReflectionField, ReflectionType, Root, Schema, SetSchema, StateCallbackStrategy, StateView, StreamSchema, TypeContext, createPool, decode, decodeKeyValueOperation, decodeSchemaOperation, defineCustomTypes, defineTypes, deprecated, dumpChanges, encode, encodeArray, encodeIndexedEntry, encodeKeyValueOperation, encodeMapEntry, encodeSchemaOperation, entity, fullStateOnly, getDecoderStateCallbacks, getEncodeDescriptor, getRawChangesCallback, isBuilder, patchOnly, registerType, schema, t, type, unreliable, view };
10674
10816
  //# sourceMappingURL=index.mjs.map