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