@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.js CHANGED
@@ -4,7 +4,7 @@
4
4
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.schema = {}));
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
- const SWITCH_TO_STRUCTURE = 255; // (decoding collides with DELETE_AND_ADD + fieldIndex = 63)
7
+ const SWITCH_TO_STRUCTURE = 255; // same byte as `DELETE_AND_ADD | 63`, which is why field index 63 is unassignable (Metadata.MAX_FIELDS)
8
8
  const TYPE_ID = 213;
9
9
  /**
10
10
  * Encoding Schema field operations.
@@ -124,16 +124,10 @@
124
124
  * Metadata
125
125
  */
126
126
  const $descriptors = "~descriptors";
127
- /**
128
- * Per-class bitmask: bit i set iff field i carries a @view tag.
129
- * Lazily computed from $viewFieldIndexes on first encode pass.
130
- * Skips the per-field metadata[i].tag property chase in the hot encode loop.
131
- */
132
- const $filterBitmask = "~__filterBitmask";
133
127
  /**
134
128
  * Cached per-class encode descriptor: bundles encoder fn, filter fn,
135
- * metadata, isSchema flag, and filterBitmask into one object stashed on
136
- * the constructor. Replaces 5 separate per-tree property chases /
129
+ * metadata, isSchema flag and the per-field arrays into one object stashed
130
+ * on the constructor. Replaces several separate per-tree property chases /
137
131
  * function calls in the encode loop with a single property load.
138
132
  */
139
133
  const $encodeDescriptor = "~__encodeDescriptor";
@@ -143,8 +137,8 @@
143
137
  const $viewFieldIndexes = "~__viewFieldIndexes";
144
138
  const $fieldIndexesByViewTag = "$__fieldIndexesByViewTag";
145
139
  const $unreliableFieldIndexes = "~__unreliableFieldIndexes";
146
- const $transientFieldIndexes = "~__transientFieldIndexes";
147
- const $staticFieldIndexes = "~__staticFieldIndexes";
140
+ const $patchOnlyFieldIndexes = "~__patchOnlyFieldIndexes";
141
+ const $fullStateOnlyFieldIndexes = "~__fullStateOnlyFieldIndexes";
148
142
  const $streamFieldIndexes = "~__streamFieldIndexes";
149
143
  const $streamPriorities = "~__streamPriorities";
150
144
 
@@ -825,6 +819,7 @@
825
819
  return;
826
820
  st.pendingByView.delete(viewId);
827
821
  st.sentByView.delete(viewId);
822
+ st.priorityByView?.delete(viewId);
828
823
  }
829
824
 
830
825
  const WIRE_BY_BITS = {
@@ -1070,6 +1065,15 @@
1070
1065
  }
1071
1066
  }
1072
1067
 
1068
+ /**
1069
+ * Field indexes ride in the low 6 bits of the operation byte
1070
+ * (`(index | operation) & 255`), which leaves room for 0..63. Index 63 is
1071
+ * given up: `DELETE_AND_ADD | 63` is 255, the same byte the decoder claims
1072
+ * as SWITCH_TO_STRUCTURE before any field decoder sees it. Every nullable
1073
+ * field can produce that operation (delete-then-set in one tick merges to
1074
+ * DELETE_AND_ADD), so the slot is unusable rather than partly usable.
1075
+ */
1076
+ const MAX_FIELDS = 63;
1073
1077
  /**
1074
1078
  * Given a normalized field type (`"number"`, `{ map: Foo }`, `Player`,
1075
1079
  * etc.), split into the collection-type descriptor (`{ constructor:
@@ -1133,10 +1137,12 @@
1133
1137
  }
1134
1138
  const Metadata = {
1135
1139
  addField(metadata, index, name, type, descriptor) {
1136
- if (index > 64) {
1137
- throw new Error(`Can't define field '${name}'.\nSchema instances may only have up to 64 fields.`);
1140
+ // `index` is 0-based, so 62 is the last usable slot — see MAX_FIELDS
1141
+ // for why 63 is off limits.
1142
+ if (index >= MAX_FIELDS) {
1143
+ throw new Error(`Can't define field '${name}'.\nSchema instances may only have up to ${MAX_FIELDS} fields.`);
1138
1144
  }
1139
- metadata[index] = Object.assign(metadata[index] || {}, // avoid overwriting previous field metadata (@owned / @deprecated)
1145
+ metadata[index] = Object.assign(metadata[index] || {}, // avoid overwriting previous field metadata (@deprecated / @unreliable)
1140
1146
  {
1141
1147
  type: getNormalizedType(type),
1142
1148
  index,
@@ -1286,31 +1292,43 @@
1286
1292
  }
1287
1293
  metadata[$unreliableFieldIndexes].push(index);
1288
1294
  },
1289
- setTransient(metadata, fieldName) {
1295
+ setPatchOnly(metadata, fieldName) {
1290
1296
  const index = metadata[fieldName];
1291
- metadata[index].transient = true;
1292
- if (!metadata[$transientFieldIndexes]) {
1293
- Object.defineProperty(metadata, $transientFieldIndexes, {
1297
+ // patchOnly + fullStateOnly are the only two delivery channels —
1298
+ // excluding a field from both would silently never reach a client.
1299
+ // (The builder validates earlier; this guards the decorator path.)
1300
+ if (metadata[index].fullStateOnly) {
1301
+ throw new Error(`field "${fieldName}" cannot be both patchOnly and fullStateOnly — ` +
1302
+ `those are the only two delivery channels, so the field would never reach a client.`);
1303
+ }
1304
+ metadata[index].patchOnly = true;
1305
+ if (!metadata[$patchOnlyFieldIndexes]) {
1306
+ Object.defineProperty(metadata, $patchOnlyFieldIndexes, {
1294
1307
  value: [],
1295
1308
  enumerable: false,
1296
1309
  configurable: true,
1297
1310
  writable: true,
1298
1311
  });
1299
1312
  }
1300
- metadata[$transientFieldIndexes].push(index);
1313
+ metadata[$patchOnlyFieldIndexes].push(index);
1301
1314
  },
1302
- setStatic(metadata, fieldName) {
1315
+ setFullStateOnly(metadata, fieldName) {
1303
1316
  const index = metadata[fieldName];
1304
- metadata[index].static = true;
1305
- if (!metadata[$staticFieldIndexes]) {
1306
- Object.defineProperty(metadata, $staticFieldIndexes, {
1317
+ // Mirror of the guard in setPatchOnly — covers both decorator orders.
1318
+ if (metadata[index].patchOnly) {
1319
+ throw new Error(`field "${fieldName}" cannot be both patchOnly and fullStateOnly — ` +
1320
+ `those are the only two delivery channels, so the field would never reach a client.`);
1321
+ }
1322
+ metadata[index].fullStateOnly = true;
1323
+ if (!metadata[$fullStateOnlyFieldIndexes]) {
1324
+ Object.defineProperty(metadata, $fullStateOnlyFieldIndexes, {
1307
1325
  value: [],
1308
1326
  enumerable: false,
1309
1327
  configurable: true,
1310
1328
  writable: true,
1311
1329
  });
1312
1330
  }
1313
- metadata[$staticFieldIndexes].push(index);
1331
+ metadata[$fullStateOnlyFieldIndexes].push(index);
1314
1332
  },
1315
1333
  setStream(metadata, fieldName) {
1316
1334
  const index = metadata[fieldName];
@@ -1474,19 +1492,19 @@
1474
1492
  writable: true,
1475
1493
  });
1476
1494
  }
1477
- // $transientFieldIndexes
1478
- if (parentMetadata[$transientFieldIndexes] !== undefined) {
1479
- Object.defineProperty(metadata, $transientFieldIndexes, {
1480
- value: [...parentMetadata[$transientFieldIndexes]],
1495
+ // $patchOnlyFieldIndexes
1496
+ if (parentMetadata[$patchOnlyFieldIndexes] !== undefined) {
1497
+ Object.defineProperty(metadata, $patchOnlyFieldIndexes, {
1498
+ value: [...parentMetadata[$patchOnlyFieldIndexes]],
1481
1499
  enumerable: false,
1482
1500
  configurable: true,
1483
1501
  writable: true,
1484
1502
  });
1485
1503
  }
1486
- // $staticFieldIndexes
1487
- if (parentMetadata[$staticFieldIndexes] !== undefined) {
1488
- Object.defineProperty(metadata, $staticFieldIndexes, {
1489
- value: [...parentMetadata[$staticFieldIndexes]],
1504
+ // $fullStateOnlyFieldIndexes
1505
+ if (parentMetadata[$fullStateOnlyFieldIndexes] !== undefined) {
1506
+ Object.defineProperty(metadata, $fullStateOnlyFieldIndexes, {
1507
+ value: [...parentMetadata[$fullStateOnlyFieldIndexes]],
1490
1508
  enumerable: false,
1491
1509
  configurable: true,
1492
1510
  writable: true,
@@ -1544,11 +1562,11 @@
1544
1562
  hasUnreliableAtIndex(metadata, index) {
1545
1563
  return metadata?.[$unreliableFieldIndexes]?.includes(index);
1546
1564
  },
1547
- hasTransientAtIndex(metadata, index) {
1548
- return metadata?.[$transientFieldIndexes]?.includes(index);
1565
+ hasPatchOnlyAtIndex(metadata, index) {
1566
+ return metadata?.[$patchOnlyFieldIndexes]?.includes(index);
1549
1567
  },
1550
- hasStaticAtIndex(metadata, index) {
1551
- return metadata?.[$staticFieldIndexes]?.includes(index);
1568
+ hasFullStateOnlyAtIndex(metadata, index) {
1569
+ return metadata?.[$fullStateOnlyFieldIndexes]?.includes(index);
1552
1570
  },
1553
1571
  hasStreamAtIndex(metadata, index) {
1554
1572
  return metadata?.[$streamFieldIndexes]?.includes(index);
@@ -1559,7 +1577,7 @@
1559
1577
  // by passing the user's callback as ctx. No per-call allocation.
1560
1578
  const _invokeNoCtx$2 = (cb, index, op) => cb(index, op);
1561
1579
  // ──────────────────────────────────────────────────────────────────────────
1562
- // SchemaChangeRecorder — bitmask + Uint8Array, for Schema types (≤64 fields)
1580
+ // SchemaChangeRecorder — bitmask + Uint8Array, for Schema types (≤63 fields)
1563
1581
  // ──────────────────────────────────────────────────────────────────────────
1564
1582
  /**
1565
1583
  * Schema field operations are limited to ADD(128), DELETE(64), and
@@ -1752,37 +1770,15 @@
1752
1770
  * ctor[$filter]
1753
1771
  * ctor[Symbol.metadata]
1754
1772
  * Metadata.isValidInstance(ref)
1755
- * getFilterBitmask(metadata)
1756
1773
  *
1757
1774
  * Lives in its own file to break the Encoder.ts ↔ ChangeTree.ts import
1758
1775
  * cycle (ChangeTree caches descriptors at construction; Encoder reads them
1759
1776
  * during encode).
1760
1777
  */
1761
- function computeFilterBitmask(metadata) {
1762
- if (metadata === undefined)
1763
- return 0;
1764
- let bm = metadata[$filterBitmask];
1765
- if (bm !== undefined)
1766
- return bm;
1767
- bm = 0;
1768
- const tagged = metadata[$viewFieldIndexes];
1769
- if (tagged !== undefined) {
1770
- for (let i = 0, len = tagged.length; i < len; i++)
1771
- bm |= (1 << tagged[i]);
1772
- }
1773
- // Non-enumerable so `for (const k in metadata)` iteration in TypeContext
1774
- // and elsewhere doesn't mistake this cache for a real field index.
1775
- Object.defineProperty(metadata, $filterBitmask, {
1776
- value: bm,
1777
- enumerable: false,
1778
- writable: true,
1779
- configurable: true,
1780
- });
1781
- return bm;
1782
- }
1783
1778
  /**
1784
1779
  * Bitmask of field indexes 0–31 in `indexes`. For fields ≥32 callers must
1785
- * fall back to the array lookup (same as `filterBitmask`).
1780
+ * fall back to the array lookup shift counts wrap at 32, so an unguarded
1781
+ * `1 << 40` would set bit 8 and misclassify field 8.
1786
1782
  */
1787
1783
  function indexesToBitmask(indexes) {
1788
1784
  if (indexes === undefined)
@@ -1858,12 +1854,12 @@
1858
1854
  filter,
1859
1855
  metadata,
1860
1856
  isSchema,
1861
- filterBitmask: isSchema ? computeFilterBitmask(metadata) : 0,
1862
- hasAnyStatic: (metadata?.[$staticFieldIndexes]?.length ?? 0) > 0,
1857
+ filterBitmask: isSchema ? indexesToBitmask(metadata?.[$viewFieldIndexes]) : 0,
1858
+ hasAnyFullStateOnly: (metadata?.[$fullStateOnlyFieldIndexes]?.length ?? 0) > 0,
1863
1859
  hasAnyUnreliable: (metadata?.[$unreliableFieldIndexes]?.length ?? 0) > 0,
1864
1860
  hasAnyStream: (metadata?.[$streamFieldIndexes]?.length ?? 0) > 0,
1865
1861
  hasAnyView,
1866
- staticBitmask: indexesToBitmask(metadata?.[$staticFieldIndexes]),
1862
+ fullStateOnlyBitmask: indexesToBitmask(metadata?.[$fullStateOnlyFieldIndexes]),
1867
1863
  unreliableBitmask: indexesToBitmask(metadata?.[$unreliableFieldIndexes]),
1868
1864
  streamBitmask: indexesToBitmask(metadata?.[$streamFieldIndexes]),
1869
1865
  names: arrays.names,
@@ -1999,13 +1995,13 @@
1999
1995
  }
2000
1996
 
2001
1997
  /**
2002
- * Walk all currently-populated non-transient indexes on a tree, emitting
1998
+ * Walk all currently-populated non-patchOnly indexes on a tree, emitting
2003
1999
  * each index once. Used by Root.add (re-stage), Encoder.encodeAll, and
2004
2000
  * StateView.add to derive full-sync output from the live structure.
2005
2001
  *
2006
- * Transient fields (`@transient`) are skipped — they're delivered only on
2002
+ * Patch-only fields (`@patchOnly`) are skipped — they're delivered only on
2007
2003
  * tick patches and not persisted to snapshots. Collections whose parent
2008
- * field is @transient inherit the skip (`tree.isTransient`).
2004
+ * field is @patchOnly inherit the skip (`tree.isPatchOnly`).
2009
2005
  */
2010
2006
  // Adapter that lets `forEachLive(cb)` delegate to `forEachLiveWithCtx(cb, _invokeNoCtx)` —
2011
2007
  // keeps the no-ctx path closure-free and shares one walker implementation.
@@ -2019,10 +2015,10 @@
2019
2015
  // types. See `ChangeTree.refTarget` doc.
2020
2016
  const ref = tree.refTarget;
2021
2017
  if (ref[$childType] !== undefined) {
2022
- // Collection inheriting @transient from parent field: skip entirely.
2018
+ // Collection inheriting @patchOnly from parent field: skip entirely.
2023
2019
  // The resync sweep (decoder/Resync.ts) relies on this: a collection
2024
2020
  // absent from full-sync output is never pruned client-side.
2025
- if (tree.isTransient)
2021
+ if (tree.isPatchOnly)
2026
2022
  return;
2027
2023
  // Collection types: dispatch by shape.
2028
2024
  if (Array.isArray(ref.items)) {
@@ -2051,7 +2047,7 @@
2051
2047
  // Schema: walk declared fields. `null` is treated as absent —
2052
2048
  // the setter records a DELETE when a field is set to null or
2053
2049
  // undefined, so it should not appear in full-sync output.
2054
- // (@transient skips below matter to the resync sweep — see
2050
+ // (@patchOnly skips below matter to the resync sweep — see
2055
2051
  // decoder/Resync.ts: absent-from-payload means never pruned.)
2056
2052
  //
2057
2053
  // Read names from the per-class descriptor's parallel array —
@@ -2061,13 +2057,13 @@
2061
2057
  if (!metadata)
2062
2058
  return;
2063
2059
  const numFields = (metadata[$numFields] ?? -1);
2064
- const transientIndexes = metadata[$transientFieldIndexes];
2060
+ const patchOnlyIndexes = metadata[$patchOnlyFieldIndexes];
2065
2061
  const names = tree.encDescriptor.names;
2066
2062
  for (let i = 0; i <= numFields; i++) {
2067
2063
  const name = names[i];
2068
2064
  if (name === undefined)
2069
2065
  continue;
2070
- if (transientIndexes && transientIndexes.includes(i))
2066
+ if (patchOnlyIndexes && patchOnlyIndexes.includes(i))
2071
2067
  continue;
2072
2068
  const value = ref[name];
2073
2069
  if (value !== undefined && value !== null)
@@ -2077,7 +2073,7 @@
2077
2073
  }
2078
2074
 
2079
2075
  /**
2080
- * Filter / unreliable / transient / static inheritance helpers for
2076
+ * Filter / unreliable / patchOnly / static inheritance helpers for
2081
2077
  * ChangeTree. Called by setRoot / setParent to derive child flags from
2082
2078
  * the parent field's annotation + the parent tree's own state.
2083
2079
  */
@@ -2090,7 +2086,7 @@
2090
2086
  checkInheritedFlags(tree, parent, parentIndex);
2091
2087
  // Static trees never track per-tick changes — skip the queue entirely.
2092
2088
  // Full-sync reaches them via structural walk (forEachChild).
2093
- if (tree.isStatic)
2089
+ if (tree.isFullStateOnly)
2094
2090
  return;
2095
2091
  // Mutations that happened before setRoot (e.g. class-field initializers)
2096
2092
  // recorded into the appropriate recorder but couldn't enqueue yet.
@@ -2116,7 +2112,7 @@
2116
2112
  }
2117
2113
  }
2118
2114
  /**
2119
- * Inherit filter / unreliable / transient / static classification from
2115
+ * Inherit filter / unreliable / patchOnly / static classification from
2120
2116
  * the parent field's annotation. Collections (MapSchema / ArraySchema /
2121
2117
  * etc.) inherit these from the Schema field that holds them.
2122
2118
  *
@@ -2155,14 +2151,14 @@
2155
2151
  parentIndex = parentChangeTree.parentIndex;
2156
2152
  }
2157
2153
  const parentMetadata = parent?.constructor?.[Symbol.metadata];
2158
- // Flag inheritance — pack the transient/static annotation checks into
2154
+ // Flag inheritance — pack the patchOnly/static annotation checks into
2159
2155
  // flag bits alongside the parent's own transitive flags, then OR onto
2160
2156
  // `tree.flags` in one write. The bit diff tells us which flag just
2161
2157
  // went from 0→1, cheaper than the prior `becameX = !tree.isX && (...)`
2162
2158
  // pairs. IS_UNRELIABLE is omitted from both sides — tree-level
2163
2159
  // unreliable is disabled (see INHERITABLE_FLAGS in ChangeTree.ts).
2164
- const fieldBits = (parentMetadata?.[$transientFieldIndexes]?.includes(parentIndex) ? IS_TRANSIENT : 0)
2165
- | (parentMetadata?.[$staticFieldIndexes]?.includes(parentIndex) ? IS_STATIC : 0);
2160
+ const fieldBits = (parentMetadata?.[$patchOnlyFieldIndexes]?.includes(parentIndex) ? IS_PATCH_ONLY : 0)
2161
+ | (parentMetadata?.[$fullStateOnlyFieldIndexes]?.includes(parentIndex) ? IS_FULL_STATE_ONLY : 0);
2166
2162
  const inheritedBits = (parentChangeTree.flags & INHERITABLE_FLAGS) | fieldBits;
2167
2163
  const beforeFlags = tree.flags;
2168
2164
  tree.flags = beforeFlags | inheritedBits;
@@ -2172,7 +2168,7 @@
2172
2168
  // `new Config().assign({...})` populates the recorder before the
2173
2169
  // Config instance is attached). Static trees ship state via structural
2174
2170
  // walk only; per-tick dirty entries would leak post-first-sync.
2175
- if (gainedBits & IS_STATIC) {
2171
+ if (gainedBits & IS_FULL_STATE_ONLY) {
2176
2172
  tree.reset();
2177
2173
  tree.unreliableRecorder?.reset();
2178
2174
  }
@@ -2477,7 +2473,7 @@
2477
2473
  *
2478
2474
  * - parentChain.ts addParent / removeParent / find / has / getAll
2479
2475
  * - liveIteration.ts forEachLive
2480
- * - inheritedFlags.ts filter / unreliable / transient / static inheritance
2476
+ * - inheritedFlags.ts filter / unreliable / patchOnly / static inheritance
2481
2477
  * - treeAttachment.ts setRoot / setParent / forEachChild(+WithCtx)
2482
2478
  *
2483
2479
  * Public surface on ChangeTree is unchanged — methods are thin pass-throughs
@@ -2497,12 +2493,12 @@
2497
2493
  const _invokeNoCtx = (cb, index, op) => cb(index, op);
2498
2494
  // Linked list helper functions
2499
2495
  function createChangeTreeList() {
2500
- return { next: undefined, tail: undefined };
2496
+ return { next: undefined, tail: undefined, nextPosition: 0 };
2501
2497
  }
2502
- // Flags bitfield. *_UNRELIABLE / _TRANSIENT / _STATIC mirror the parent
2498
+ // Flags bitfield. *_UNRELIABLE / _PATCH_ONLY / _STATIC mirror the parent
2503
2499
  // field's annotation — inherited at setParent/setRoot time.
2504
2500
  const IS_FILTERED = 1, IS_VISIBILITY_SHARED = 2, IS_NEW = 4;
2505
- const IS_UNRELIABLE = 8, IS_TRANSIENT = 16, IS_STATIC = 32;
2501
+ const IS_UNRELIABLE = 8, IS_PATCH_ONLY = 16, IS_FULL_STATE_ONLY = 32;
2506
2502
  // Collection tree attached to a parent field annotated `.stream()` —
2507
2503
  // drives the encoder's priority/broadcast pass. Set in inheritedFlags
2508
2504
  // so both `t.stream(X)` (via StreamSchema's `$isStream` brand) and
@@ -2529,7 +2525,7 @@
2529
2525
  * reconsidered if a safe semantics (e.g. reliable ADD + unreliable
2530
2526
  * field mutations only) is designed later.
2531
2527
  */
2532
- const INHERITABLE_FLAGS = IS_TRANSIENT | IS_STATIC;
2528
+ const INHERITABLE_FLAGS = IS_PATCH_ONLY | IS_FULL_STATE_ONLY;
2533
2529
  class ChangeTree {
2534
2530
  ref;
2535
2531
  /**
@@ -2548,8 +2544,8 @@
2548
2544
  refTarget;
2549
2545
  metadata;
2550
2546
  /**
2551
- * Per-class cache of encoder fn / filter fn / isSchema / filterBitmask /
2552
- * metadata, looked up once at construction. The encode loop reads
2547
+ * Per-class cache of encoder fn / filter fn / isSchema / metadata /
2548
+ * per-field arrays, looked up once at construction. The encode loop reads
2553
2549
  * `tree.encDescriptor` and never touches `ref.constructor` again. See
2554
2550
  * EncodeDescriptor.ts.
2555
2551
  */
@@ -2620,10 +2616,10 @@
2620
2616
  set isNew(v) { this.flags = v ? (this.flags | IS_NEW) : (this.flags & ~IS_NEW); }
2621
2617
  get isUnreliable() { return (this.flags & IS_UNRELIABLE) !== 0; }
2622
2618
  set isUnreliable(v) { this.flags = v ? (this.flags | IS_UNRELIABLE) : (this.flags & ~IS_UNRELIABLE); }
2623
- get isTransient() { return (this.flags & IS_TRANSIENT) !== 0; }
2624
- set isTransient(v) { this.flags = v ? (this.flags | IS_TRANSIENT) : (this.flags & ~IS_TRANSIENT); }
2625
- get isStatic() { return (this.flags & IS_STATIC) !== 0; }
2626
- set isStatic(v) { this.flags = v ? (this.flags | IS_STATIC) : (this.flags & ~IS_STATIC); }
2619
+ get isPatchOnly() { return (this.flags & IS_PATCH_ONLY) !== 0; }
2620
+ set isPatchOnly(v) { this.flags = v ? (this.flags | IS_PATCH_ONLY) : (this.flags & ~IS_PATCH_ONLY); }
2621
+ get isFullStateOnly() { return (this.flags & IS_FULL_STATE_ONLY) !== 0; }
2622
+ set isFullStateOnly(v) { this.flags = v ? (this.flags | IS_FULL_STATE_ONLY) : (this.flags & ~IS_FULL_STATE_ONLY); }
2627
2623
  get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
2628
2624
  set isStreamCollection(v) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
2629
2625
  get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
@@ -2632,7 +2628,7 @@
2632
2628
  // @view-tagged fields. StateView.addParentOf uses this to decide whether
2633
2629
  // a parent must be included in a view's bootstrap. Reads the class-level
2634
2630
  // "any viewed field" flag that `EncodeDescriptor` precomputes — same
2635
- // pattern as `hasAnyStatic` / `hasAnyUnreliable` / `hasAnyStream`.
2631
+ // pattern as `hasAnyFullStateOnly` / `hasAnyUnreliable` / `hasAnyStream`.
2636
2632
  get hasFilteredFields() {
2637
2633
  return this.isFiltered || this.encDescriptor.hasAnyView;
2638
2634
  }
@@ -2656,7 +2652,7 @@
2656
2652
  // metadata lookup. For schemas that DO have unreliable fields, the
2657
2653
  // bitmask answers fields 0-31 in one bitwise op (no Array.includes
2658
2654
  // linear scan). Fields ≥32 always fall back to the metadata lookup
2659
- // (same limitation as filterBitmask bitmask only covers low 32).
2655
+ // (shift counts wrap at 32, so the bitmask only covers the low 32).
2660
2656
  const desc = this.encDescriptor;
2661
2657
  if (!desc.hasAnyUnreliable)
2662
2658
  return false;
@@ -2666,15 +2662,15 @@
2666
2662
  }
2667
2663
  // @static fields sync once via full-sync; post-init mutations are ignored
2668
2664
  // by the tracker (the value still lives on the instance).
2669
- isFieldStatic(index) {
2670
- if (this.isStatic)
2665
+ isFieldFullStateOnly(index) {
2666
+ if (this.isFullStateOnly)
2671
2667
  return true;
2672
2668
  const desc = this.encDescriptor;
2673
- if (!desc.hasAnyStatic)
2669
+ if (!desc.hasAnyFullStateOnly)
2674
2670
  return false;
2675
2671
  if (index < 32)
2676
- return (desc.staticBitmask & (1 << index)) !== 0;
2677
- return Metadata.hasStaticAtIndex(this.metadata, index);
2672
+ return (desc.fullStateOnlyBitmask & (1 << index)) !== 0;
2673
+ return Metadata.hasFullStateOnlyAtIndex(this.metadata, index);
2678
2674
  }
2679
2675
  // `t.stream(...)` collection fields — encoded via per-view priority/budget
2680
2676
  // gate instead of emitting all dirty ADDs in one tick. Class-level short
@@ -2933,7 +2929,7 @@
2933
2929
  // keep the recorder object allocated (re-alloc is the cost we avoid), clear contents
2934
2930
  this.unreliableRecorder?.reset();
2935
2931
  // back to a freshly-constructed tree: IS_NEW, no inherited flags
2936
- // (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
2932
+ // (FILTERED/PATCH_ONLY/STATIC/STREAM are re-derived on the next setParent).
2937
2933
  // NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
2938
2934
  this.flags = IS_NEW | NEEDS_RESTAGE;
2939
2935
  this._fullSyncGen = 0;
@@ -2966,7 +2962,7 @@
2966
2962
  throw new Error("ChangeTree (Schema): unshift is not supported");
2967
2963
  const src = this.collDirty;
2968
2964
  const dst = new Map();
2969
- const track = !this.paused && !this.isStatic;
2965
+ const track = !this.paused && !this.isFullStateOnly;
2970
2966
  if (track) {
2971
2967
  for (let i = 0; i < count; i++)
2972
2968
  dst.set(i, exports.OPERATION.ADD);
@@ -2990,7 +2986,7 @@
2990
2986
  forEachLiveWithCtx(this, ctx, cb);
2991
2987
  }
2992
2988
  operation(op) {
2993
- if (this.paused || this.isStatic)
2989
+ if (this.paused || this.isFullStateOnly)
2994
2990
  return;
2995
2991
  // Pure ops (CLEAR/REVERSE) only emit from collection trees — the
2996
2992
  // recorder here is always a CollectionChangeRecorder by construction.
@@ -3020,11 +3016,23 @@
3020
3016
  * fields (see annotations.ts), so the per-field unreliable flag here
3021
3017
  * always means "primitive value updates" — the structural-ADD-routes-
3022
3018
  * reliable footgun for ref-type fields can't reach this code path.
3019
+ *
3020
+ * `!isNew` holds an `@unreliable` field on the RELIABLE channel until this
3021
+ * tree's own ADD has shipped there. A decoder can only apply a field write
3022
+ * to a ref it already knows, so a value emitted before the ADD is dropped —
3023
+ * permanently, if the field is never written again. `isNew` clears in
3024
+ * `endEncode()`, i.e. after a reliable pass, and recording reliably is
3025
+ * itself what enqueues the tree for that pass; the state is self-clearing
3026
+ * and no tree can be stranded on the wrong channel. Mirrors `encodeAll`,
3027
+ * which has always seeded these fields for late joiners.
3028
+ *
3029
+ * Ordering matters: `isFieldUnreliable` short-circuits on the class-level
3030
+ * `hasAnyUnreliable`, so schemas without the modifier never read `flags`.
3023
3031
  */
3024
3032
  _routeAndRecord(index, op, raw) {
3025
- if (this.paused || this.isFieldStatic(index))
3033
+ if (this.paused || this.isFieldFullStateOnly(index))
3026
3034
  return;
3027
- if (this.isFieldUnreliable(index)) {
3035
+ if (this.isFieldUnreliable(index) && !this.isNew) {
3028
3036
  const r = this.ensureUnreliableRecorder();
3029
3037
  if (raw)
3030
3038
  r.recordRaw(index, op);
@@ -3091,9 +3099,11 @@
3091
3099
  }
3092
3100
  return;
3093
3101
  }
3094
- if (this.paused || this.isFieldStatic(index))
3102
+ if (this.paused || this.isFieldFullStateOnly(index))
3095
3103
  return this.getValue(index);
3096
- const unreliable = this.isFieldUnreliable(index);
3104
+ // Same pre-ADD hold as `_routeAndRecord` — a DELETE naming a ref the
3105
+ // decoder hasn't seen is dropped just like a field write.
3106
+ const unreliable = this.isFieldUnreliable(index) && !this.isNew;
3097
3107
  if (unreliable)
3098
3108
  this.ensureUnreliableRecorder().recordDelete(index, operation ?? exports.OPERATION.DELETE);
3099
3109
  else
@@ -3299,7 +3309,9 @@
3299
3309
  * @private
3300
3310
  */
3301
3311
  const encodeSchemaOperation = function (encoder, bytes, changeTree, index, operation, it, _, __) {
3302
- // "compress" field index + operation
3312
+ // "compress" field index + operation. Can't collide with
3313
+ // SWITCH_TO_STRUCTURE (255): that needs `DELETE_AND_ADD | 63`, and
3314
+ // `Metadata.MAX_FIELDS` keeps index 63 unassignable.
3303
3315
  bytes[it.offset++] = (index | operation) & 255;
3304
3316
  // Do not encode value for DELETE operations
3305
3317
  if (operation === exports.OPERATION.DELETE) {
@@ -3495,8 +3507,8 @@
3495
3507
  /**
3496
3508
  * Mark a collection as present in the payload — even with zero entries.
3497
3509
  * The sweep only prunes collections reported here: absence means "not part
3498
- * of full-sync" (@transient, view-invisible), where pruning would destroy
3499
- * live data. Reflected clients have no @transient metadata, so payload
3510
+ * of full-sync" (@patchOnly, view-invisible), where pruning would destroy
3511
+ * live data. Reflected clients have no @patchOnly metadata, so payload
3500
3512
  * presence is the only reliable signal.
3501
3513
  */
3502
3514
  function resyncMarkPresent(decoder, refId) {
@@ -3510,7 +3522,7 @@
3510
3522
  * entry the snapshot did not visit.
3511
3523
  *
3512
3524
  * Walks the tree from the root — NOT `root.refs` — for three reasons:
3513
- * `@transient` fields are never part of a snapshot and must be left alone;
3525
+ * `@patchOnly` fields are never part of a snapshot and must be left alone;
3514
3526
  * entries of subtrees removed by the sweep itself are left to the GC's
3515
3527
  * transitive walk (sweeping them directly would double-decrement shared
3516
3528
  * children); and collections the snapshot never mentions (emptied
@@ -3535,11 +3547,11 @@
3535
3547
  if (refIndexes === undefined) {
3536
3548
  return;
3537
3549
  }
3538
- const transient = metadata[$transientFieldIndexes];
3550
+ const patchOnly = metadata[$patchOnlyFieldIndexes];
3539
3551
  for (let i = 0; i < refIndexes.length; i++) {
3540
3552
  const fieldIndex = refIndexes[i];
3541
- // @transient fields are never in a snapshot — leave them alone.
3542
- if (transient !== undefined && transient.includes(fieldIndex)) {
3553
+ // @patchOnly fields are never in a snapshot — leave them alone.
3554
+ if (patchOnly !== undefined && patchOnly.includes(fieldIndex)) {
3543
3555
  continue;
3544
3556
  }
3545
3557
  const field = metadata[fieldIndex];
@@ -3564,7 +3576,7 @@
3564
3576
  seen.add(refId);
3565
3577
  // `undefined` = the collection never appeared in the payload at all
3566
3578
  // (not even as its parent's field op) — it is not part of full-sync
3567
- // (@transient, view-invisible) and must be left alone. An empty Set
3579
+ // (@patchOnly, view-invisible) and must be left alone. An empty Set
3568
3580
  // means "present with zero entries" → prune everything.
3569
3581
  const visited = decoder.resyncVisited.get(refId);
3570
3582
  if (visited === undefined) {
@@ -5883,7 +5895,7 @@
5883
5895
  * per-client and drained in priority order (callback on StateView) up to
5884
5896
  * `maxPerTick` per encode pass. Field mutations on already-sent elements
5885
5897
  * propagate through the normal reliable channel without consuming the
5886
- * per-tick budget. Chain `.static()` on the field builder to suppress
5898
+ * per-tick budget. Chain `.fullStateOnly()` on the field builder to suppress
5887
5899
  * post-add mutation tracking entirely.
5888
5900
  */
5889
5901
  class StreamSchema {
@@ -6158,12 +6170,11 @@
6158
6170
  _default = undefined;
6159
6171
  _hasDefault = false;
6160
6172
  _view = undefined;
6161
- _owned = false;
6162
6173
  _unreliable = false;
6163
- _transient = false;
6174
+ _patchOnly = false;
6164
6175
  _deprecated = false;
6165
6176
  _deprecatedThrows = true;
6166
- _static = false;
6177
+ _fullStateOnly = false;
6167
6178
  _stream = false;
6168
6179
  _optional = false;
6169
6180
  _noSync = false;
@@ -6198,38 +6209,43 @@
6198
6209
  this._view = tag ?? -1;
6199
6210
  return this;
6200
6211
  }
6201
- /** Mark this field as owned (encoder-side ownership filtering). */
6202
- owned() {
6203
- this._owned = true;
6204
- return this;
6205
- }
6206
6212
  /**
6207
6213
  * Mark this field as unreliable — tick patches emit it on the unreliable
6208
6214
  * transport channel. Still persisted to full-sync snapshots unless also
6209
- * tagged with `.transient()`.
6215
+ * tagged with `.patchOnly()`. Primitive fields only.
6216
+ *
6217
+ * The field's FIRST value still travels the reliable channel, as part of
6218
+ * the owning instance's ADD; only later mutations become unreliable. A
6219
+ * decoder cannot apply a write to a ref it has not been told about, so a
6220
+ * value emitted ahead of that ADD would be dropped — and lost for good if
6221
+ * the field is never written again.
6210
6222
  */
6211
6223
  unreliable() {
6212
6224
  this._unreliable = true;
6213
6225
  return this;
6214
6226
  }
6215
6227
  /**
6216
- * Mark this field as transientNOT persisted to full-sync snapshots
6217
- * (`encodeAll` / `encodeAllView`). Late-joining clients see the field
6218
- * only after its next mutation is emitted on a tick patch. Orthogonal
6219
- * to `.unreliable()`.
6228
+ * Deliver this field on tick patches ONLY it is never written to a
6229
+ * full-state sync (`encodeAll` / `encodeAllView`). Late-joining clients
6230
+ * see the field only after its next mutation is emitted on a patch.
6231
+ * The mirror of `.fullStateOnly()`, and orthogonal to `.unreliable()`.
6220
6232
  */
6221
- transient() {
6222
- this._transient = true;
6233
+ patchOnly() {
6234
+ this._patchOnly = true;
6223
6235
  return this;
6224
6236
  }
6225
6237
  /**
6226
- * Mark this field as static.
6227
- * - Primitive / Schema fields: synchronized once, skips change tracking.
6228
- * - Stream fields (`t.stream(X).static()`): child elements are frozen
6229
- * after add post-add field mutations on elements become no-ops.
6238
+ * Deliver this field in the full state sync ONLY (`encodeAll` /
6239
+ * `encodeAllView`) it never enters a tick patch. A client receives it
6240
+ * on join (and again on a resync); writes after that are not tracked.
6241
+ * The mirror of `.patchOnly()`.
6242
+ *
6243
+ * The field itself is NOT frozen — it stays mutable server-side, only
6244
+ * its propagation stops. On a stream field (`t.stream(X).fullStateOnly()`)
6245
+ * the same rule applies per element: post-add mutations are no-ops.
6230
6246
  */
6231
- static() {
6232
- this._static = true;
6247
+ fullStateOnly() {
6248
+ this._fullStateOnly = true;
6233
6249
  return this;
6234
6250
  }
6235
6251
  /**
@@ -6241,8 +6257,8 @@
6241
6257
  * Useful for server-side scratch state, per-peer UI state, or values you
6242
6258
  * want on the class for typing convenience without paying any sync cost.
6243
6259
  *
6244
- * Mutually exclusive with the sync-only modifiers (`.view()`, `.owned()`,
6245
- * `.unreliable()`, `.transient()`, `.static()`, `.stream()`) — combining
6260
+ * Mutually exclusive with the sync-only modifiers (`.view()`,
6261
+ * `.unreliable()`, `.patchOnly()`, `.fullStateOnly()`, `.stream()`) — combining
6246
6262
  * them throws at `schema()` time.
6247
6263
  *
6248
6264
  * ```ts
@@ -6284,9 +6300,12 @@
6284
6300
  * higher return values emit first. Does nothing in broadcast mode
6285
6301
  * (shared `encode()` drains FIFO). Only meaningful on stream fields.
6286
6302
  *
6303
+ * `StateView` carries no position of its own — attach whatever the
6304
+ * callback needs to sort by (`view` is loosely typed for this).
6305
+ *
6287
6306
  * ```ts
6288
6307
  * t.stream(Enemy).priority((view, enemy) =>
6289
- * -dist2(view.anchor, enemy)
6308
+ * -((enemy.x - view.x) ** 2 + (enemy.y - view.y) ** 2)
6290
6309
  * )
6291
6310
  * ```
6292
6311
  */
@@ -6321,12 +6340,11 @@
6321
6340
  default: this._default,
6322
6341
  hasDefault: this._hasDefault,
6323
6342
  view: this._view,
6324
- owned: this._owned,
6325
6343
  unreliable: this._unreliable,
6326
- transient: this._transient,
6344
+ patchOnly: this._patchOnly,
6327
6345
  deprecated: this._deprecated,
6328
6346
  deprecatedThrows: this._deprecatedThrows,
6329
- static: this._static,
6347
+ fullStateOnly: this._fullStateOnly,
6330
6348
  stream: this._stream,
6331
6349
  optional: this._optional,
6332
6350
  noSync: this._noSync,
@@ -6580,25 +6598,44 @@
6580
6598
  Metadata.setTag(metadata, fieldName, tag);
6581
6599
  };
6582
6600
  }
6583
- function owned(target, field) {
6584
- const metadata = Metadata.initialize(target.constructor);
6585
- metadata[metadata[field]].owned = true;
6586
- }
6601
+ /**
6602
+ * `@unreliable` route a field onto the unreliable transport channel, so a
6603
+ * dropped update costs one stale value instead of stalling the ordered stream
6604
+ * behind a retransmit. Primitive fields only (see `Metadata.setUnreliable`).
6605
+ *
6606
+ * The field's FIRST value still travels the reliable channel, as part of the
6607
+ * owning instance's ADD; only later mutations become unreliable. A decoder
6608
+ * cannot apply a write to a ref it has not been told about, so a value emitted
6609
+ * ahead of that ADD would be dropped — and lost for good if the field is never
6610
+ * written again.
6611
+ */
6587
6612
  function unreliable(target, field) {
6588
6613
  const metadata = Metadata.initialize(target.constructor);
6589
6614
  Metadata.setUnreliable(metadata, field);
6590
6615
  }
6591
6616
  /**
6592
- * @transient — mark a field as not persisted to snapshots (encodeAll /
6593
- * encodeAllView). Transient fields are still emitted on per-tick patches
6617
+ * @patchOnly — mark a field as not persisted to snapshots (encodeAll /
6618
+ * encodeAllView). PatchOnly fields are still emitted on per-tick patches
6594
6619
  * (reliable or unreliable), but late-joining clients won't see them until
6595
6620
  * the next mutation.
6596
6621
  *
6597
6622
  * Orthogonal to @unreliable: a field can be either, both, or neither.
6598
6623
  */
6599
- function transient(target, field) {
6624
+ function patchOnly(target, field) {
6625
+ const metadata = Metadata.initialize(target.constructor);
6626
+ Metadata.setPatchOnly(metadata, field);
6627
+ }
6628
+ /**
6629
+ * @fullStateOnly — mark a field as delivered in the full state sync only
6630
+ * (encodeAll / encodeAllView), never on per-tick patches. Writes after a
6631
+ * client has joined are not propagated to it — populate these fields
6632
+ * before clients connect (e.g. during onCreate).
6633
+ *
6634
+ * The exact mirror of @patchOnly — the two are mutually exclusive.
6635
+ */
6636
+ function fullStateOnly(target, field) {
6600
6637
  const metadata = Metadata.initialize(target.constructor);
6601
- Metadata.setTransient(metadata, field);
6638
+ Metadata.setFullStateOnly(metadata, field);
6602
6639
  }
6603
6640
  function type(type, options) {
6604
6641
  return function (target, field) {
@@ -6970,11 +7007,10 @@
6970
7007
  }
6971
7008
  };
6972
7009
  const viewTagFields = {};
6973
- const ownedFields = [];
6974
7010
  const unreliableFields = [];
6975
- const transientFields = [];
7011
+ const patchOnlyFields = [];
6976
7012
  const deprecatedFields = {};
6977
- const staticFields = [];
7013
+ const fullStateOnlyFields = [];
6978
7014
  const streamFields = [];
6979
7015
  const streamPriorityFields = {};
6980
7016
  const optionalFields = [];
@@ -6986,15 +7022,22 @@
6986
7022
  // Local-only field: skip metadata registration entirely so it is
6987
7023
  // never encoded/decoded, but still seed its construction default
6988
7024
  // (honoring `.default()` and collection/ref auto-instantiation).
6989
- if (def.view !== undefined || def.owned || def.unreliable ||
6990
- def.transient || def.static || def.stream) {
7025
+ if (def.view !== undefined || def.unreliable ||
7026
+ def.patchOnly || def.fullStateOnly || def.stream) {
6991
7027
  throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .noSync() ` +
6992
- `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
7028
+ `together with a sync-only modifier (.view/.unreliable/.patchOnly/.fullStateOnly/.stream). ` +
6993
7029
  `A local-only field cannot be synchronized.`);
6994
7030
  }
6995
7031
  seedDefault(fieldName, def);
6996
7032
  continue;
6997
7033
  }
7034
+ // The two delivery channels are exhaustive: excluding a field from
7035
+ // both leaves it with nowhere to go — a silent .noSync().
7036
+ if (def.patchOnly && def.fullStateOnly) {
7037
+ throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .patchOnly() ` +
7038
+ `together with .fullStateOnly(). Those are the only two delivery channels, ` +
7039
+ `so the field would never reach a client — use .noSync() if that is intended.`);
7040
+ }
6998
7041
  const normalizedType = getNormalizedType(def.type);
6999
7042
  // A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
7000
7043
  if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
@@ -7005,20 +7048,17 @@
7005
7048
  if (def.view !== undefined) {
7006
7049
  viewTagFields[fieldName] = def.view;
7007
7050
  }
7008
- if (def.owned) {
7009
- ownedFields.push(fieldName);
7010
- }
7011
7051
  if (def.unreliable) {
7012
7052
  unreliableFields.push(fieldName);
7013
7053
  }
7014
- if (def.transient) {
7015
- transientFields.push(fieldName);
7054
+ if (def.patchOnly) {
7055
+ patchOnlyFields.push(fieldName);
7016
7056
  }
7017
7057
  if (def.deprecated) {
7018
7058
  deprecatedFields[fieldName] = def.deprecatedThrows;
7019
7059
  }
7020
- if (def.static) {
7021
- staticFields.push(fieldName);
7060
+ if (def.fullStateOnly) {
7061
+ fullStateOnlyFields.push(fieldName);
7022
7062
  }
7023
7063
  if (def.stream) {
7024
7064
  streamFields.push(fieldName);
@@ -7101,22 +7141,19 @@
7101
7141
  for (const fieldName in viewTagFields) {
7102
7142
  view(viewTagFields[fieldName])(klass.prototype, fieldName);
7103
7143
  }
7104
- for (const fieldName of ownedFields) {
7105
- owned(klass.prototype, fieldName);
7106
- }
7107
7144
  for (const fieldName of unreliableFields) {
7108
7145
  unreliable(klass.prototype, fieldName);
7109
7146
  }
7110
- for (const fieldName of transientFields) {
7111
- transient(klass.prototype, fieldName);
7147
+ for (const fieldName of patchOnlyFields) {
7148
+ patchOnly(klass.prototype, fieldName);
7112
7149
  }
7113
7150
  for (const fieldName in deprecatedFields) {
7114
7151
  deprecated(deprecatedFields[fieldName])(klass.prototype, fieldName);
7115
7152
  }
7116
- if (staticFields.length > 0 || streamFields.length > 0) {
7153
+ if (fullStateOnlyFields.length > 0 || streamFields.length > 0) {
7117
7154
  const metadata = klass[Symbol.metadata];
7118
- for (const fieldName of staticFields) {
7119
- Metadata.setStatic(metadata, fieldName);
7155
+ for (const fieldName of fullStateOnlyFields) {
7156
+ Metadata.setFullStateOnly(metadata, fieldName);
7120
7157
  }
7121
7158
  for (const fieldName of streamFields) {
7122
7159
  Metadata.setStream(metadata, fieldName);
@@ -7765,7 +7802,7 @@
7765
7802
  const previousRefCount = this.refCount[refId];
7766
7803
  if (previousRefCount === 0 || changeTree.needsRestage) {
7767
7804
  //
7768
- // Re-stage every currently-populated non-transient index as a
7805
+ // Re-stage every currently-populated non-patchOnly index as a
7769
7806
  // fresh ADD in the matching dirty bucket so the next encode
7770
7807
  // re-emits it on the correct channel. Two triggers:
7771
7808
  // - refCount 0: a previously-removed tree re-added under the
@@ -7855,14 +7892,10 @@
7855
7892
  const parentNode = parent[$changes][nodeField];
7856
7893
  if (!parentNode || parentNode === node)
7857
7894
  return;
7858
- // Check if child is already after parent by walking from parent
7859
- let cursor = parentNode.next;
7860
- while (cursor) {
7861
- if (cursor === node)
7862
- return; // already after parent
7863
- cursor = cursor.next;
7864
- }
7865
- // If we reach here, node is before parent — need to move
7895
+ // Positions are strictly increasing along the list, so this is an
7896
+ // exact O(1) "is child already after parent" test — no queue scan.
7897
+ if (node.position > parentNode.position)
7898
+ return;
7866
7899
  // Remove node from current position
7867
7900
  if (node.prev) {
7868
7901
  node.prev.next = node.next;
@@ -7876,16 +7909,18 @@
7876
7909
  else {
7877
7910
  changeSet.tail = node.prev;
7878
7911
  }
7879
- // Insert node right after parent
7880
- node.prev = parentNode;
7881
- node.next = parentNode.next;
7882
- if (parentNode.next) {
7883
- parentNode.next.prev = node;
7884
- }
7885
- else {
7886
- changeSet.tail = node;
7887
- }
7888
- parentNode.next = node;
7912
+ // Re-append at the tail: after `parentNode` AND after every other
7913
+ // queued parent of a multi-referenced instance — relinking next to
7914
+ // the *primary* parent could jump the child ahead of a 2nd/3rd
7915
+ // parent whose ADD the decoder must see first. Tail placement gets
7916
+ // a fresh max position, keeping the invariant append-only.
7917
+ // (`recursivelyMoveNextToParent` visits pre-order, so a moved
7918
+ // subtree re-serializes parent-first behind it.)
7919
+ node.prev = changeSet.tail;
7920
+ node.next = undefined;
7921
+ changeSet.tail.next = node; // parentNode remains in the list — never empty here
7922
+ changeSet.tail = node;
7923
+ node.position = changeSet.nextPosition++;
7889
7924
  }
7890
7925
  enqueueChangeTree(changeTree, existingNode = changeTree.changesNode) {
7891
7926
  if (existingNode) {
@@ -7907,12 +7942,12 @@
7907
7942
  node.changeTree = changeTree;
7908
7943
  node.next = undefined;
7909
7944
  node.prev = undefined;
7910
- node.position = 0;
7911
7945
  }
7912
7946
  else {
7913
7947
  node = { changeTree, next: undefined, prev: undefined, position: 0 };
7914
7948
  }
7915
7949
  if (!list.next) {
7950
+ list.nextPosition = 0; // list drained — restart sequence (stays SMI)
7916
7951
  list.next = node;
7917
7952
  list.tail = node;
7918
7953
  }
@@ -7921,6 +7956,7 @@
7921
7956
  list.tail.next = node;
7922
7957
  list.tail = node;
7923
7958
  }
7959
+ node.position = list.nextPosition++;
7924
7960
  return node;
7925
7961
  }
7926
7962
  /**
@@ -8019,6 +8055,7 @@
8019
8055
  ctx.treeIsFiltered = changeTree.isFiltered;
8020
8056
  ctx.isSchema = desc.isSchema;
8021
8057
  ctx.filterBitmask = desc.filterBitmask;
8058
+ ctx.tags = desc.tags;
8022
8059
  ctx.structSwitchEmitted = false;
8023
8060
  ctx.shouldEmitSwitch = (ctx.hasView || ctx.it.offset > ctx.initialOffset || changeTree !== ctx.rootChangeTree);
8024
8061
  // Call the module function directly — the `forEachLiveWithCtx`
@@ -8058,10 +8095,13 @@
8058
8095
  }
8059
8096
  // Per-field filter decision (same rule as ChangeTree.change()):
8060
8097
  // a field is filtered iff the tree inherits isFiltered OR the field
8061
- // itself carries a @view tag. Schema trees check via the precomputed
8062
- // bitmask; collection trees inherit tree-level (bitmask is 0).
8098
+ // itself carries a @view tag. The bitmask only spans 0–31 — `1 << 40`
8099
+ // wraps onto bit 8 so fields past it read their tag directly. Reaching
8100
+ // that arm needs a Schema with more than 32 fields.
8063
8101
  const fieldFiltered = ctx.isSchema
8064
- ? (ctx.treeIsFiltered || (ctx.filterBitmask & (1 << fieldIndex)) !== 0)
8102
+ ? (ctx.treeIsFiltered || (fieldIndex < 32
8103
+ ? (ctx.filterBitmask & (1 << fieldIndex)) !== 0
8104
+ : ctx.tags[fieldIndex] !== undefined))
8065
8105
  : ctx.treeIsFiltered;
8066
8106
  if (fieldFiltered !== ctx.emitFiltered)
8067
8107
  return;
@@ -8116,7 +8156,7 @@
8116
8156
  ref: undefined, encoder: undefined, filter: undefined, metadata: undefined,
8117
8157
  view: undefined, isEncodeAll: false, hasView: false,
8118
8158
  treeIsFiltered: false, isSchema: false, emitFiltered: false,
8119
- filterBitmask: 0,
8159
+ filterBitmask: 0, tags: undefined,
8120
8160
  structSwitchEmitted: false, isRootTree: false, shouldEmitSwitch: false,
8121
8161
  gen: 0, initialOffset: 0, rootChangeTree: undefined,
8122
8162
  };
@@ -8173,6 +8213,7 @@
8173
8213
  ctx.treeIsFiltered = changeTree.isFiltered;
8174
8214
  ctx.isSchema = desc.isSchema;
8175
8215
  ctx.filterBitmask = desc.filterBitmask;
8216
+ ctx.tags = desc.tags;
8176
8217
  ctx.structSwitchEmitted = false;
8177
8218
  ctx.isRootTree = (changeTree === rootChangeTree);
8178
8219
  // Root's struct switch is skipped at the very start of the shared
@@ -8424,7 +8465,7 @@
8424
8465
  // Emit each element's full state — forEachLive walks populated
8425
8466
  // fields structurally, mirroring encodeAllView's bootstrap.
8426
8467
  // Covers both static elements (dirty state was reset by
8427
- // inheritedFlags' becameStatic branch) and non-static (still
8468
+ // inheritedFlags' becameFullStateOnly branch) and non-static (still
8428
8469
  // has dirty state but the main loop skipped them because
8429
8470
  // they're filtered).
8430
8471
  for (const element of emittedElements) {
@@ -8503,20 +8544,81 @@
8503
8544
  // `t.stream(X).priority(fn)` or the decorator form) and seeded
8504
8545
  // into `_stream.priority` when the stream was attached. Users
8505
8546
  // can also override per-instance by assigning to the setter.
8547
+ // A per-view callback (registered by `subscribe(coll, fn)`)
8548
+ // wins over the declaration-scope one: it closes over the
8549
+ // client's own entity, so it needs no view-carried anchor.
8550
+ const perView = st.priorityByView?.get(viewId);
8551
+ const usePerView = perView !== undefined;
8506
8552
  const priority = st.priority;
8507
- // Materialize pending into an array so we can sort + slice.
8508
- // Small sets (typical: tens to low hundreds) — allocation is
8509
- // negligible compared to the priority sort and element walk.
8553
+ const max = st.maxPerTick;
8554
+ // Select the `max` highest-priority candidates.
8555
+ //
8556
+ // A comparator-based sort invokes the callback twice per
8557
+ // comparison, each with its own `$getByIndex` lookup — ~2·n·log n
8558
+ // of each to pick `max` entries (38k calls to select 8 out of a
8559
+ // 2000-entry backlog). Scoring every candidate once and keeping a
8560
+ // bounded top-`max` window costs n invocations instead, and sizes
8561
+ // the scratch by `max` rather than by the backlog.
8562
+ //
8563
+ // Ties keep the earlier position (both comparisons below are
8564
+ // strict), so equal-priority entries still drain in insertion
8565
+ // order.
8510
8566
  const positions = [];
8511
- for (const p of pending)
8512
- positions.push(p);
8513
- if (priority !== undefined) {
8514
- // Use the symbol-keyed accessor so Map/Set/Stream all route
8515
- // through the same lookup regardless of $items layout.
8516
- positions.sort((a, b) => priority(view, s[$getByIndex](b)) - priority(view, s[$getByIndex](a)));
8567
+ const stale = [];
8568
+ if (usePerView || priority !== undefined) {
8569
+ const bestPos = [];
8570
+ const bestScore = [];
8571
+ let filled = 0;
8572
+ for (const pos of pending) {
8573
+ // Symbol-keyed accessor so Map/Set/Stream all route
8574
+ // through the same lookup regardless of $items layout.
8575
+ const element = s[$getByIndex](pos);
8576
+ if (element === undefined) {
8577
+ // Removed after being queued — drop it below without
8578
+ // spending budget on it.
8579
+ stale.push(pos);
8580
+ continue;
8581
+ }
8582
+ const score = usePerView
8583
+ ? perView(element)
8584
+ : priority(view, element);
8585
+ // Window not yet full: always insert.
8586
+ if (filled < max) {
8587
+ let j = filled++;
8588
+ while (j > 0 && bestScore[j - 1] < score) {
8589
+ bestScore[j] = bestScore[j - 1];
8590
+ bestPos[j] = bestPos[j - 1];
8591
+ j--;
8592
+ }
8593
+ bestScore[j] = score;
8594
+ bestPos[j] = pos;
8595
+ // Otherwise only a strictly better score displaces the tail.
8596
+ }
8597
+ else if (score > bestScore[max - 1]) {
8598
+ let j = max - 1;
8599
+ while (j > 0 && bestScore[j - 1] < score) {
8600
+ bestScore[j] = bestScore[j - 1];
8601
+ bestPos[j] = bestPos[j - 1];
8602
+ j--;
8603
+ }
8604
+ bestScore[j] = score;
8605
+ bestPos[j] = pos;
8606
+ }
8607
+ }
8608
+ for (let i = 0; i < filled; i++)
8609
+ positions.push(bestPos[i]);
8517
8610
  }
8518
- const max = st.maxPerTick;
8519
- const count = Math.min(positions.length, max);
8611
+ else {
8612
+ // FIFO take the head of the backlog, no scoring needed.
8613
+ for (const pos of pending) {
8614
+ if (positions.length >= max)
8615
+ break;
8616
+ positions.push(pos);
8617
+ }
8618
+ }
8619
+ for (const pos of stale)
8620
+ pending.delete(pos);
8621
+ const count = positions.length;
8520
8622
  let sent = st.sentByView.get(viewId);
8521
8623
  if (sent === undefined) {
8522
8624
  sent = new Set();
@@ -9871,6 +9973,31 @@
9871
9973
  _clearViewBitFromAllTrees(root, slot, bit);
9872
9974
  root.releaseViewId(id);
9873
9975
  });
9976
+ /**
9977
+ * Compact description of a rejected argument, for warning messages.
9978
+ * Passing the value itself to `console.warn` is not an option — a
9979
+ * populated collection inspects into dozens of lines of encoder
9980
+ * internals and buries the message that matters.
9981
+ */
9982
+ function describeArg(value) {
9983
+ if (value === undefined) {
9984
+ return "undefined";
9985
+ }
9986
+ if (value === null) {
9987
+ return "null";
9988
+ }
9989
+ const type = typeof value;
9990
+ if (type === "string") {
9991
+ return JSON.stringify(value.length > 30 ? `${value.slice(0, 30)}…` : value);
9992
+ }
9993
+ if (type !== "object" && type !== "function") {
9994
+ return `${type} ${String(value)}`;
9995
+ }
9996
+ if (Array.isArray(value)) {
9997
+ return `Array(${value.length})`;
9998
+ }
9999
+ return value.constructor?.name ?? "Object";
10000
+ }
9874
10001
  class StateView {
9875
10002
  iterable;
9876
10003
  /**
@@ -10095,12 +10222,12 @@
10095
10222
  }
10096
10223
  _add(obj, tag, checkIncludeParent, _skipStreamRouting) {
10097
10224
  const changeTree = obj?.[$changes];
10098
- const parentChangeTree = changeTree.parent;
10099
10225
  if (!changeTree) {
10100
- console.warn("StateView#add(), invalid object:", obj);
10226
+ console.warn(`StateView#add(): expected a Schema instance or collection, received ${describeArg(obj)}`);
10101
10227
  return false;
10102
10228
  }
10103
- else if (!parentChangeTree &&
10229
+ const parentChangeTree = changeTree.parent;
10230
+ if (!parentChangeTree &&
10104
10231
  obj[$refId] !== 0 // allow root object
10105
10232
  ) {
10106
10233
  /**
@@ -10381,9 +10508,9 @@
10381
10508
  }
10382
10509
  }
10383
10510
  remove(obj, tag = DEFAULT_VIEW_TAG, _isClear = false) {
10384
- const changeTree = obj[$changes];
10511
+ const changeTree = obj?.[$changes];
10385
10512
  if (!changeTree) {
10386
- console.warn("StateView#remove(), invalid object:", obj);
10513
+ console.warn(`StateView#remove(): expected a Schema instance or collection, received ${describeArg(obj)}`);
10387
10514
  return this;
10388
10515
  }
10389
10516
  // ── Streamable-element unsubscribe ─────────────────────────────
@@ -10521,32 +10648,47 @@
10521
10648
  hasTag(ob, tag = DEFAULT_VIEW_TAG) {
10522
10649
  return this.hasTagOnTree(ob[$changes], tag);
10523
10650
  }
10524
- /**
10525
- * Persistent subscription to a collection's contents. Unlike `add()`,
10526
- * which is a one-shot bootstrap, `subscribe()` enrolls this view in
10527
- * future content changes — every subsequent push / set / add to the
10528
- * collection automatically flows to this view, and every removal
10529
- * queues a DELETE op. Works on every collection type:
10530
- *
10531
- * - `ArraySchema` / `MapSchema` / `SetSchema` / `CollectionSchema`:
10532
- * new children are force-shipped immediately (equivalent to
10533
- * `view.add(child)` per item).
10534
- * - `StreamSchema` (or `.stream()` maps/sets): new positions are
10535
- * enqueued into `_pendingByView` so the priority pass drains them
10536
- * respecting `maxPerTick`.
10537
- *
10538
- * Idempotent on re-subscribe. Subscribing to an already-subscribed
10539
- * collection is a no-op.
10540
- */
10541
- subscribe(collection) {
10651
+ subscribe(collection, priority) {
10542
10652
  const tree = collection?.[$changes];
10543
10653
  if (!tree) {
10544
- console.warn("StateView#subscribe(), invalid collection:", collection);
10654
+ console.warn(`StateView#subscribe(): expected a Schema collection, received ${describeArg(collection)}`);
10545
10655
  return this;
10546
10656
  }
10547
10657
  if (this._root === undefined && tree.root !== undefined) {
10548
10658
  this._bindRoot(tree.root);
10549
10659
  }
10660
+ if (priority !== undefined) {
10661
+ if (!tree.isStreamCollection) {
10662
+ // Name the field rather than dumping the collection — a
10663
+ // populated MapSchema inspects into dozens of lines of
10664
+ // internals and buries the message.
10665
+ const kind = collection?.constructor?.name ?? "collection";
10666
+ const parent = tree.parent;
10667
+ if (parent === undefined) {
10668
+ console.warn(`StateView#subscribe(): \`priority\` ignored — this ${kind} is not ` +
10669
+ `attached to a state yet, so it cannot be identified as a stream. ` +
10670
+ `Subscribe after assigning it to the state.`);
10671
+ }
10672
+ else {
10673
+ const field = parent?.constructor?.[Symbol.metadata]?.[tree.parentIndex]?.name;
10674
+ const where = field ? `${parent.constructor.name}#${field}` : kind;
10675
+ console.warn(`StateView#subscribe(): \`priority\` ignored — ${where} is a ${kind}, ` +
10676
+ `not a streaming collection. Declare the field with .stream() ` +
10677
+ `(e.g. t.map(X).stream()) or use t.stream(X) to enable priority batching.`);
10678
+ }
10679
+ }
10680
+ else {
10681
+ // Set before the idempotency return below, so re-subscribing
10682
+ // is the documented way to retarget this view's ordering.
10683
+ const st = ensureStreamState(collection);
10684
+ if (priority === null) {
10685
+ st.priorityByView?.delete(this.id);
10686
+ }
10687
+ else {
10688
+ (st.priorityByView ??= new Map()).set(this.id, priority);
10689
+ }
10690
+ }
10691
+ }
10550
10692
  if (this.isSubscribed(tree))
10551
10693
  return this;
10552
10694
  // Mark collection visible so its own ADD/DELETE ops emit in the
@@ -10592,7 +10734,7 @@
10592
10734
  unsubscribe(collection) {
10593
10735
  const tree = collection?.[$changes];
10594
10736
  if (!tree) {
10595
- console.warn("StateView#unsubscribe(), invalid collection:", collection);
10737
+ console.warn(`StateView#unsubscribe(): expected a Schema collection, received ${describeArg(collection)}`);
10596
10738
  return this;
10597
10739
  }
10598
10740
  if (!this.isSubscribed(tree))
@@ -10721,15 +10863,15 @@
10721
10863
  exports.encodeMapEntry = encodeMapEntry;
10722
10864
  exports.encodeSchemaOperation = encodeSchemaOperation;
10723
10865
  exports.entity = entity;
10866
+ exports.fullStateOnly = fullStateOnly;
10724
10867
  exports.getDecoderStateCallbacks = getDecoderStateCallbacks;
10725
10868
  exports.getEncodeDescriptor = getEncodeDescriptor;
10726
10869
  exports.getRawChangesCallback = getRawChangesCallback;
10727
10870
  exports.isBuilder = isBuilder;
10728
- exports.owned = owned;
10871
+ exports.patchOnly = patchOnly;
10729
10872
  exports.registerType = registerType;
10730
10873
  exports.schema = schema;
10731
10874
  exports.t = t;
10732
- exports.transient = transient;
10733
10875
  exports.type = type;
10734
10876
  exports.unreliable = unreliable;
10735
10877
  exports.view = view;