@colyseus/schema 5.0.10 → 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 -10
  9. package/build/encoder/EncodeDescriptor.d.ts +11 -12
  10. package/build/encoder/StateView.d.ts +26 -8
  11. package/build/encoder/changeTree/inheritedFlags.d.ts +1 -1
  12. package/build/encoder/streaming.d.ts +7 -0
  13. package/build/index.cjs +393 -287
  14. package/build/index.cjs.map +1 -1
  15. package/build/index.d.ts +1 -1
  16. package/build/index.js +393 -287
  17. package/build/index.mjs +392 -286
  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 -28
  37. package/src/encoder/EncodeDescriptor.ts +17 -38
  38. package/src/encoder/EncodeOperation.ts +3 -1
  39. package/src/encoder/Encoder.ts +100 -37
  40. package/src/encoder/Root.ts +18 -20
  41. package/src/encoder/StateView.ts +118 -47
  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
  */
@@ -2598,7 +2594,6 @@
2598
2594
  // per-view WeakSet lookups with direct bitwise ops.
2599
2595
  // Lazy: undefined until the tree participates in any view.
2600
2596
  visibleViews;
2601
- invisibleViews;
2602
2597
  // Per-(view, tag) bitmap, indexed by tag. Custom tags only —
2603
2598
  // DEFAULT_VIEW_TAG visibility lives in `visibleViews`.
2604
2599
  tagViews;
@@ -2621,10 +2616,10 @@
2621
2616
  set isNew(v) { this.flags = v ? (this.flags | IS_NEW) : (this.flags & ~IS_NEW); }
2622
2617
  get isUnreliable() { return (this.flags & IS_UNRELIABLE) !== 0; }
2623
2618
  set isUnreliable(v) { this.flags = v ? (this.flags | IS_UNRELIABLE) : (this.flags & ~IS_UNRELIABLE); }
2624
- get isTransient() { return (this.flags & IS_TRANSIENT) !== 0; }
2625
- set isTransient(v) { this.flags = v ? (this.flags | IS_TRANSIENT) : (this.flags & ~IS_TRANSIENT); }
2626
- get isStatic() { return (this.flags & IS_STATIC) !== 0; }
2627
- 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); }
2628
2623
  get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
2629
2624
  set isStreamCollection(v) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
2630
2625
  get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
@@ -2633,7 +2628,7 @@
2633
2628
  // @view-tagged fields. StateView.addParentOf uses this to decide whether
2634
2629
  // a parent must be included in a view's bootstrap. Reads the class-level
2635
2630
  // "any viewed field" flag that `EncodeDescriptor` precomputes — same
2636
- // pattern as `hasAnyStatic` / `hasAnyUnreliable` / `hasAnyStream`.
2631
+ // pattern as `hasAnyFullStateOnly` / `hasAnyUnreliable` / `hasAnyStream`.
2637
2632
  get hasFilteredFields() {
2638
2633
  return this.isFiltered || this.encDescriptor.hasAnyView;
2639
2634
  }
@@ -2657,7 +2652,7 @@
2657
2652
  // metadata lookup. For schemas that DO have unreliable fields, the
2658
2653
  // bitmask answers fields 0-31 in one bitwise op (no Array.includes
2659
2654
  // linear scan). Fields ≥32 always fall back to the metadata lookup
2660
- // (same limitation as filterBitmask bitmask only covers low 32).
2655
+ // (shift counts wrap at 32, so the bitmask only covers the low 32).
2661
2656
  const desc = this.encDescriptor;
2662
2657
  if (!desc.hasAnyUnreliable)
2663
2658
  return false;
@@ -2667,15 +2662,15 @@
2667
2662
  }
2668
2663
  // @static fields sync once via full-sync; post-init mutations are ignored
2669
2664
  // by the tracker (the value still lives on the instance).
2670
- isFieldStatic(index) {
2671
- if (this.isStatic)
2665
+ isFieldFullStateOnly(index) {
2666
+ if (this.isFullStateOnly)
2672
2667
  return true;
2673
2668
  const desc = this.encDescriptor;
2674
- if (!desc.hasAnyStatic)
2669
+ if (!desc.hasAnyFullStateOnly)
2675
2670
  return false;
2676
2671
  if (index < 32)
2677
- return (desc.staticBitmask & (1 << index)) !== 0;
2678
- return Metadata.hasStaticAtIndex(this.metadata, index);
2672
+ return (desc.fullStateOnlyBitmask & (1 << index)) !== 0;
2673
+ return Metadata.hasFullStateOnlyAtIndex(this.metadata, index);
2679
2674
  }
2680
2675
  // `t.stream(...)` collection fields — encoded via per-view priority/budget
2681
2676
  // gate instead of emitting all dirty ADDs in one tick. Class-level short
@@ -2934,7 +2929,7 @@
2934
2929
  // keep the recorder object allocated (re-alloc is the cost we avoid), clear contents
2935
2930
  this.unreliableRecorder?.reset();
2936
2931
  // back to a freshly-constructed tree: IS_NEW, no inherited flags
2937
- // (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
2932
+ // (FILTERED/PATCH_ONLY/STATIC/STREAM are re-derived on the next setParent).
2938
2933
  // NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
2939
2934
  this.flags = IS_NEW | NEEDS_RESTAGE;
2940
2935
  this._fullSyncGen = 0;
@@ -2950,7 +2945,6 @@
2950
2945
  // per-view visibility lives on the tree (NOT keyed by refId), so a
2951
2946
  // recycled tree must not inherit its previous life's view membership.
2952
2947
  this.visibleViews = undefined;
2953
- this.invisibleViews = undefined;
2954
2948
  this.tagViews = undefined;
2955
2949
  this.subscribedViews = undefined;
2956
2950
  }
@@ -2968,7 +2962,7 @@
2968
2962
  throw new Error("ChangeTree (Schema): unshift is not supported");
2969
2963
  const src = this.collDirty;
2970
2964
  const dst = new Map();
2971
- const track = !this.paused && !this.isStatic;
2965
+ const track = !this.paused && !this.isFullStateOnly;
2972
2966
  if (track) {
2973
2967
  for (let i = 0; i < count; i++)
2974
2968
  dst.set(i, exports.OPERATION.ADD);
@@ -2992,7 +2986,7 @@
2992
2986
  forEachLiveWithCtx(this, ctx, cb);
2993
2987
  }
2994
2988
  operation(op) {
2995
- if (this.paused || this.isStatic)
2989
+ if (this.paused || this.isFullStateOnly)
2996
2990
  return;
2997
2991
  // Pure ops (CLEAR/REVERSE) only emit from collection trees — the
2998
2992
  // recorder here is always a CollectionChangeRecorder by construction.
@@ -3022,11 +3016,23 @@
3022
3016
  * fields (see annotations.ts), so the per-field unreliable flag here
3023
3017
  * always means "primitive value updates" — the structural-ADD-routes-
3024
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`.
3025
3031
  */
3026
3032
  _routeAndRecord(index, op, raw) {
3027
- if (this.paused || this.isFieldStatic(index))
3033
+ if (this.paused || this.isFieldFullStateOnly(index))
3028
3034
  return;
3029
- if (this.isFieldUnreliable(index)) {
3035
+ if (this.isFieldUnreliable(index) && !this.isNew) {
3030
3036
  const r = this.ensureUnreliableRecorder();
3031
3037
  if (raw)
3032
3038
  r.recordRaw(index, op);
@@ -3093,9 +3099,11 @@
3093
3099
  }
3094
3100
  return;
3095
3101
  }
3096
- if (this.paused || this.isFieldStatic(index))
3102
+ if (this.paused || this.isFieldFullStateOnly(index))
3097
3103
  return this.getValue(index);
3098
- 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;
3099
3107
  if (unreliable)
3100
3108
  this.ensureUnreliableRecorder().recordDelete(index, operation ?? exports.OPERATION.DELETE);
3101
3109
  else
@@ -3301,7 +3309,9 @@
3301
3309
  * @private
3302
3310
  */
3303
3311
  const encodeSchemaOperation = function (encoder, bytes, changeTree, index, operation, it, _, __) {
3304
- // "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.
3305
3315
  bytes[it.offset++] = (index | operation) & 255;
3306
3316
  // Do not encode value for DELETE operations
3307
3317
  if (operation === exports.OPERATION.DELETE) {
@@ -3497,8 +3507,8 @@
3497
3507
  /**
3498
3508
  * Mark a collection as present in the payload — even with zero entries.
3499
3509
  * The sweep only prunes collections reported here: absence means "not part
3500
- * of full-sync" (@transient, view-invisible), where pruning would destroy
3501
- * 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
3502
3512
  * presence is the only reliable signal.
3503
3513
  */
3504
3514
  function resyncMarkPresent(decoder, refId) {
@@ -3512,7 +3522,7 @@
3512
3522
  * entry the snapshot did not visit.
3513
3523
  *
3514
3524
  * Walks the tree from the root — NOT `root.refs` — for three reasons:
3515
- * `@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;
3516
3526
  * entries of subtrees removed by the sweep itself are left to the GC's
3517
3527
  * transitive walk (sweeping them directly would double-decrement shared
3518
3528
  * children); and collections the snapshot never mentions (emptied
@@ -3537,11 +3547,11 @@
3537
3547
  if (refIndexes === undefined) {
3538
3548
  return;
3539
3549
  }
3540
- const transient = metadata[$transientFieldIndexes];
3550
+ const patchOnly = metadata[$patchOnlyFieldIndexes];
3541
3551
  for (let i = 0; i < refIndexes.length; i++) {
3542
3552
  const fieldIndex = refIndexes[i];
3543
- // @transient fields are never in a snapshot — leave them alone.
3544
- if (transient !== undefined && transient.includes(fieldIndex)) {
3553
+ // @patchOnly fields are never in a snapshot — leave them alone.
3554
+ if (patchOnly !== undefined && patchOnly.includes(fieldIndex)) {
3545
3555
  continue;
3546
3556
  }
3547
3557
  const field = metadata[fieldIndex];
@@ -3566,7 +3576,7 @@
3566
3576
  seen.add(refId);
3567
3577
  // `undefined` = the collection never appeared in the payload at all
3568
3578
  // (not even as its parent's field op) — it is not part of full-sync
3569
- // (@transient, view-invisible) and must be left alone. An empty Set
3579
+ // (@patchOnly, view-invisible) and must be left alone. An empty Set
3570
3580
  // means "present with zero entries" → prune everything.
3571
3581
  const visited = decoder.resyncVisited.get(refId);
3572
3582
  if (visited === undefined) {
@@ -5885,7 +5895,7 @@
5885
5895
  * per-client and drained in priority order (callback on StateView) up to
5886
5896
  * `maxPerTick` per encode pass. Field mutations on already-sent elements
5887
5897
  * propagate through the normal reliable channel without consuming the
5888
- * per-tick budget. Chain `.static()` on the field builder to suppress
5898
+ * per-tick budget. Chain `.fullStateOnly()` on the field builder to suppress
5889
5899
  * post-add mutation tracking entirely.
5890
5900
  */
5891
5901
  class StreamSchema {
@@ -6160,12 +6170,11 @@
6160
6170
  _default = undefined;
6161
6171
  _hasDefault = false;
6162
6172
  _view = undefined;
6163
- _owned = false;
6164
6173
  _unreliable = false;
6165
- _transient = false;
6174
+ _patchOnly = false;
6166
6175
  _deprecated = false;
6167
6176
  _deprecatedThrows = true;
6168
- _static = false;
6177
+ _fullStateOnly = false;
6169
6178
  _stream = false;
6170
6179
  _optional = false;
6171
6180
  _noSync = false;
@@ -6200,38 +6209,43 @@
6200
6209
  this._view = tag ?? -1;
6201
6210
  return this;
6202
6211
  }
6203
- /** Mark this field as owned (encoder-side ownership filtering). */
6204
- owned() {
6205
- this._owned = true;
6206
- return this;
6207
- }
6208
6212
  /**
6209
6213
  * Mark this field as unreliable — tick patches emit it on the unreliable
6210
6214
  * transport channel. Still persisted to full-sync snapshots unless also
6211
- * 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.
6212
6222
  */
6213
6223
  unreliable() {
6214
6224
  this._unreliable = true;
6215
6225
  return this;
6216
6226
  }
6217
6227
  /**
6218
- * Mark this field as transientNOT persisted to full-sync snapshots
6219
- * (`encodeAll` / `encodeAllView`). Late-joining clients see the field
6220
- * only after its next mutation is emitted on a tick patch. Orthogonal
6221
- * 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()`.
6222
6232
  */
6223
- transient() {
6224
- this._transient = true;
6233
+ patchOnly() {
6234
+ this._patchOnly = true;
6225
6235
  return this;
6226
6236
  }
6227
6237
  /**
6228
- * Mark this field as static.
6229
- * - Primitive / Schema fields: synchronized once, skips change tracking.
6230
- * - Stream fields (`t.stream(X).static()`): child elements are frozen
6231
- * 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.
6232
6246
  */
6233
- static() {
6234
- this._static = true;
6247
+ fullStateOnly() {
6248
+ this._fullStateOnly = true;
6235
6249
  return this;
6236
6250
  }
6237
6251
  /**
@@ -6243,8 +6257,8 @@
6243
6257
  * Useful for server-side scratch state, per-peer UI state, or values you
6244
6258
  * want on the class for typing convenience without paying any sync cost.
6245
6259
  *
6246
- * Mutually exclusive with the sync-only modifiers (`.view()`, `.owned()`,
6247
- * `.unreliable()`, `.transient()`, `.static()`, `.stream()`) — combining
6260
+ * Mutually exclusive with the sync-only modifiers (`.view()`,
6261
+ * `.unreliable()`, `.patchOnly()`, `.fullStateOnly()`, `.stream()`) — combining
6248
6262
  * them throws at `schema()` time.
6249
6263
  *
6250
6264
  * ```ts
@@ -6286,9 +6300,12 @@
6286
6300
  * higher return values emit first. Does nothing in broadcast mode
6287
6301
  * (shared `encode()` drains FIFO). Only meaningful on stream fields.
6288
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
+ *
6289
6306
  * ```ts
6290
6307
  * t.stream(Enemy).priority((view, enemy) =>
6291
- * -dist2(view.anchor, enemy)
6308
+ * -((enemy.x - view.x) ** 2 + (enemy.y - view.y) ** 2)
6292
6309
  * )
6293
6310
  * ```
6294
6311
  */
@@ -6323,12 +6340,11 @@
6323
6340
  default: this._default,
6324
6341
  hasDefault: this._hasDefault,
6325
6342
  view: this._view,
6326
- owned: this._owned,
6327
6343
  unreliable: this._unreliable,
6328
- transient: this._transient,
6344
+ patchOnly: this._patchOnly,
6329
6345
  deprecated: this._deprecated,
6330
6346
  deprecatedThrows: this._deprecatedThrows,
6331
- static: this._static,
6347
+ fullStateOnly: this._fullStateOnly,
6332
6348
  stream: this._stream,
6333
6349
  optional: this._optional,
6334
6350
  noSync: this._noSync,
@@ -6582,25 +6598,44 @@
6582
6598
  Metadata.setTag(metadata, fieldName, tag);
6583
6599
  };
6584
6600
  }
6585
- function owned(target, field) {
6586
- const metadata = Metadata.initialize(target.constructor);
6587
- metadata[metadata[field]].owned = true;
6588
- }
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
+ */
6589
6612
  function unreliable(target, field) {
6590
6613
  const metadata = Metadata.initialize(target.constructor);
6591
6614
  Metadata.setUnreliable(metadata, field);
6592
6615
  }
6593
6616
  /**
6594
- * @transient — mark a field as not persisted to snapshots (encodeAll /
6595
- * 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
6596
6619
  * (reliable or unreliable), but late-joining clients won't see them until
6597
6620
  * the next mutation.
6598
6621
  *
6599
6622
  * Orthogonal to @unreliable: a field can be either, both, or neither.
6600
6623
  */
6601
- 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) {
6602
6637
  const metadata = Metadata.initialize(target.constructor);
6603
- Metadata.setTransient(metadata, field);
6638
+ Metadata.setFullStateOnly(metadata, field);
6604
6639
  }
6605
6640
  function type(type, options) {
6606
6641
  return function (target, field) {
@@ -6972,11 +7007,10 @@
6972
7007
  }
6973
7008
  };
6974
7009
  const viewTagFields = {};
6975
- const ownedFields = [];
6976
7010
  const unreliableFields = [];
6977
- const transientFields = [];
7011
+ const patchOnlyFields = [];
6978
7012
  const deprecatedFields = {};
6979
- const staticFields = [];
7013
+ const fullStateOnlyFields = [];
6980
7014
  const streamFields = [];
6981
7015
  const streamPriorityFields = {};
6982
7016
  const optionalFields = [];
@@ -6988,15 +7022,22 @@
6988
7022
  // Local-only field: skip metadata registration entirely so it is
6989
7023
  // never encoded/decoded, but still seed its construction default
6990
7024
  // (honoring `.default()` and collection/ref auto-instantiation).
6991
- if (def.view !== undefined || def.owned || def.unreliable ||
6992
- def.transient || def.static || def.stream) {
7025
+ if (def.view !== undefined || def.unreliable ||
7026
+ def.patchOnly || def.fullStateOnly || def.stream) {
6993
7027
  throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .noSync() ` +
6994
- `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
7028
+ `together with a sync-only modifier (.view/.unreliable/.patchOnly/.fullStateOnly/.stream). ` +
6995
7029
  `A local-only field cannot be synchronized.`);
6996
7030
  }
6997
7031
  seedDefault(fieldName, def);
6998
7032
  continue;
6999
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
+ }
7000
7041
  const normalizedType = getNormalizedType(def.type);
7001
7042
  // A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
7002
7043
  if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
@@ -7007,20 +7048,17 @@
7007
7048
  if (def.view !== undefined) {
7008
7049
  viewTagFields[fieldName] = def.view;
7009
7050
  }
7010
- if (def.owned) {
7011
- ownedFields.push(fieldName);
7012
- }
7013
7051
  if (def.unreliable) {
7014
7052
  unreliableFields.push(fieldName);
7015
7053
  }
7016
- if (def.transient) {
7017
- transientFields.push(fieldName);
7054
+ if (def.patchOnly) {
7055
+ patchOnlyFields.push(fieldName);
7018
7056
  }
7019
7057
  if (def.deprecated) {
7020
7058
  deprecatedFields[fieldName] = def.deprecatedThrows;
7021
7059
  }
7022
- if (def.static) {
7023
- staticFields.push(fieldName);
7060
+ if (def.fullStateOnly) {
7061
+ fullStateOnlyFields.push(fieldName);
7024
7062
  }
7025
7063
  if (def.stream) {
7026
7064
  streamFields.push(fieldName);
@@ -7103,22 +7141,19 @@
7103
7141
  for (const fieldName in viewTagFields) {
7104
7142
  view(viewTagFields[fieldName])(klass.prototype, fieldName);
7105
7143
  }
7106
- for (const fieldName of ownedFields) {
7107
- owned(klass.prototype, fieldName);
7108
- }
7109
7144
  for (const fieldName of unreliableFields) {
7110
7145
  unreliable(klass.prototype, fieldName);
7111
7146
  }
7112
- for (const fieldName of transientFields) {
7113
- transient(klass.prototype, fieldName);
7147
+ for (const fieldName of patchOnlyFields) {
7148
+ patchOnly(klass.prototype, fieldName);
7114
7149
  }
7115
7150
  for (const fieldName in deprecatedFields) {
7116
7151
  deprecated(deprecatedFields[fieldName])(klass.prototype, fieldName);
7117
7152
  }
7118
- if (staticFields.length > 0 || streamFields.length > 0) {
7153
+ if (fullStateOnlyFields.length > 0 || streamFields.length > 0) {
7119
7154
  const metadata = klass[Symbol.metadata];
7120
- for (const fieldName of staticFields) {
7121
- Metadata.setStatic(metadata, fieldName);
7155
+ for (const fieldName of fullStateOnlyFields) {
7156
+ Metadata.setFullStateOnly(metadata, fieldName);
7122
7157
  }
7123
7158
  for (const fieldName of streamFields) {
7124
7159
  Metadata.setStream(metadata, fieldName);
@@ -7767,7 +7802,7 @@
7767
7802
  const previousRefCount = this.refCount[refId];
7768
7803
  if (previousRefCount === 0 || changeTree.needsRestage) {
7769
7804
  //
7770
- // Re-stage every currently-populated non-transient index as a
7805
+ // Re-stage every currently-populated non-patchOnly index as a
7771
7806
  // fresh ADD in the matching dirty bucket so the next encode
7772
7807
  // re-emits it on the correct channel. Two triggers:
7773
7808
  // - refCount 0: a previously-removed tree re-added under the
@@ -7857,14 +7892,10 @@
7857
7892
  const parentNode = parent[$changes][nodeField];
7858
7893
  if (!parentNode || parentNode === node)
7859
7894
  return;
7860
- // Check if child is already after parent by walking from parent
7861
- let cursor = parentNode.next;
7862
- while (cursor) {
7863
- if (cursor === node)
7864
- return; // already after parent
7865
- cursor = cursor.next;
7866
- }
7867
- // 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;
7868
7899
  // Remove node from current position
7869
7900
  if (node.prev) {
7870
7901
  node.prev.next = node.next;
@@ -7878,16 +7909,18 @@
7878
7909
  else {
7879
7910
  changeSet.tail = node.prev;
7880
7911
  }
7881
- // Insert node right after parent
7882
- node.prev = parentNode;
7883
- node.next = parentNode.next;
7884
- if (parentNode.next) {
7885
- parentNode.next.prev = node;
7886
- }
7887
- else {
7888
- changeSet.tail = node;
7889
- }
7890
- 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++;
7891
7924
  }
7892
7925
  enqueueChangeTree(changeTree, existingNode = changeTree.changesNode) {
7893
7926
  if (existingNode) {
@@ -7909,12 +7942,12 @@
7909
7942
  node.changeTree = changeTree;
7910
7943
  node.next = undefined;
7911
7944
  node.prev = undefined;
7912
- node.position = 0;
7913
7945
  }
7914
7946
  else {
7915
7947
  node = { changeTree, next: undefined, prev: undefined, position: 0 };
7916
7948
  }
7917
7949
  if (!list.next) {
7950
+ list.nextPosition = 0; // list drained — restart sequence (stays SMI)
7918
7951
  list.next = node;
7919
7952
  list.tail = node;
7920
7953
  }
@@ -7923,6 +7956,7 @@
7923
7956
  list.tail.next = node;
7924
7957
  list.tail = node;
7925
7958
  }
7959
+ node.position = list.nextPosition++;
7926
7960
  return node;
7927
7961
  }
7928
7962
  /**
@@ -8010,17 +8044,7 @@
8010
8044
  // Visibility gate: when a view is active, a non-visible tree contributes
8011
8045
  // nothing itself but we still recurse so descendants (possibly added to
8012
8046
  // the view explicitly) are reachable.
8013
- let visibleHere = true;
8014
- if (ctx.hasView) {
8015
- const view = ctx.view;
8016
- if (!view.isChangeTreeVisible(changeTree)) {
8017
- view.markInvisible(changeTree);
8018
- visibleHere = false;
8019
- }
8020
- else {
8021
- view.unmarkInvisible(changeTree);
8022
- }
8023
- }
8047
+ const visibleHere = !ctx.hasView || ctx.view.isChangeTreeVisible(changeTree);
8024
8048
  if (visibleHere) {
8025
8049
  const desc = changeTree.encDescriptor;
8026
8050
  ctx.changeTree = changeTree;
@@ -8031,6 +8055,7 @@
8031
8055
  ctx.treeIsFiltered = changeTree.isFiltered;
8032
8056
  ctx.isSchema = desc.isSchema;
8033
8057
  ctx.filterBitmask = desc.filterBitmask;
8058
+ ctx.tags = desc.tags;
8034
8059
  ctx.structSwitchEmitted = false;
8035
8060
  ctx.shouldEmitSwitch = (ctx.hasView || ctx.it.offset > ctx.initialOffset || changeTree !== ctx.rootChangeTree);
8036
8061
  // Call the module function directly — the `forEachLiveWithCtx`
@@ -8070,10 +8095,13 @@
8070
8095
  }
8071
8096
  // Per-field filter decision (same rule as ChangeTree.change()):
8072
8097
  // a field is filtered iff the tree inherits isFiltered OR the field
8073
- // itself carries a @view tag. Schema trees check via the precomputed
8074
- // 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.
8075
8101
  const fieldFiltered = ctx.isSchema
8076
- ? (ctx.treeIsFiltered || (ctx.filterBitmask & (1 << fieldIndex)) !== 0)
8102
+ ? (ctx.treeIsFiltered || (fieldIndex < 32
8103
+ ? (ctx.filterBitmask & (1 << fieldIndex)) !== 0
8104
+ : ctx.tags[fieldIndex] !== undefined))
8077
8105
  : ctx.treeIsFiltered;
8078
8106
  if (fieldFiltered !== ctx.emitFiltered)
8079
8107
  return;
@@ -8128,7 +8156,7 @@
8128
8156
  ref: undefined, encoder: undefined, filter: undefined, metadata: undefined,
8129
8157
  view: undefined, isEncodeAll: false, hasView: false,
8130
8158
  treeIsFiltered: false, isSchema: false, emitFiltered: false,
8131
- filterBitmask: 0,
8159
+ filterBitmask: 0, tags: undefined,
8132
8160
  structSwitchEmitted: false, isRootTree: false, shouldEmitSwitch: false,
8133
8161
  gen: 0, initialOffset: 0, rootChangeTree: undefined,
8134
8162
  };
@@ -8169,12 +8197,8 @@
8169
8197
  let current = queue;
8170
8198
  while (current = current.next) {
8171
8199
  const changeTree = current.changeTree;
8172
- if (hasView) {
8173
- if (!view.isChangeTreeVisible(changeTree)) {
8174
- view.markInvisible(changeTree);
8175
- continue;
8176
- }
8177
- view.unmarkInvisible(changeTree);
8200
+ if (hasView && !view.isChangeTreeVisible(changeTree)) {
8201
+ continue;
8178
8202
  }
8179
8203
  const recorder = unreliable ? changeTree.unreliableRecorder : changeTree;
8180
8204
  if (!recorder || !recorder.has()) {
@@ -8189,6 +8213,7 @@
8189
8213
  ctx.treeIsFiltered = changeTree.isFiltered;
8190
8214
  ctx.isSchema = desc.isSchema;
8191
8215
  ctx.filterBitmask = desc.filterBitmask;
8216
+ ctx.tags = desc.tags;
8192
8217
  ctx.structSwitchEmitted = false;
8193
8218
  ctx.isRootTree = (changeTree === rootChangeTree);
8194
8219
  // Root's struct switch is skipped at the very start of the shared
@@ -8440,7 +8465,7 @@
8440
8465
  // Emit each element's full state — forEachLive walks populated
8441
8466
  // fields structurally, mirroring encodeAllView's bootstrap.
8442
8467
  // Covers both static elements (dirty state was reset by
8443
- // inheritedFlags' becameStatic branch) and non-static (still
8468
+ // inheritedFlags' becameFullStateOnly branch) and non-static (still
8444
8469
  // has dirty state but the main loop skipped them because
8445
8470
  // they're filtered).
8446
8471
  for (const element of emittedElements) {
@@ -8519,20 +8544,81 @@
8519
8544
  // `t.stream(X).priority(fn)` or the decorator form) and seeded
8520
8545
  // into `_stream.priority` when the stream was attached. Users
8521
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;
8522
8552
  const priority = st.priority;
8523
- // Materialize pending into an array so we can sort + slice.
8524
- // Small sets (typical: tens to low hundreds) — allocation is
8525
- // 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.
8526
8566
  const positions = [];
8527
- for (const p of pending)
8528
- positions.push(p);
8529
- if (priority !== undefined) {
8530
- // Use the symbol-keyed accessor so Map/Set/Stream all route
8531
- // through the same lookup regardless of $items layout.
8532
- 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]);
8533
8610
  }
8534
- const max = st.maxPerTick;
8535
- 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;
8536
8622
  let sent = st.sentByView.get(viewId);
8537
8623
  if (sent === undefined) {
8538
8624
  sent = new Set();
@@ -9866,9 +9952,6 @@
9866
9952
  const v = tree.visibleViews;
9867
9953
  if (v !== undefined && slot < v.length)
9868
9954
  v[slot] &= clearMask;
9869
- const i = tree.invisibleViews;
9870
- if (i !== undefined && slot < i.length)
9871
- i[slot] &= clearMask;
9872
9955
  const s = tree.subscribedViews;
9873
9956
  if (s !== undefined && slot < s.length)
9874
9957
  s[slot] &= clearMask;
@@ -9890,6 +9973,31 @@
9890
9973
  _clearViewBitFromAllTrees(root, slot, bit);
9891
9974
  root.releaseViewId(id);
9892
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
+ }
9893
10001
  class StateView {
9894
10002
  iterable;
9895
10003
  /**
@@ -10020,32 +10128,6 @@
10020
10128
  if (slot < arr.length)
10021
10129
  arr[slot] &= ~this._bit;
10022
10130
  }
10023
- /** True iff this view has previously marked `tree` as invisible. */
10024
- isInvisible(tree) {
10025
- const arr = tree.invisibleViews;
10026
- const slot = this._slot;
10027
- return arr !== undefined && slot < arr.length && (arr[slot] & this._bit) !== 0;
10028
- }
10029
- /** Mark `tree` as invisible to this view (used by encode loop). */
10030
- markInvisible(tree) {
10031
- const slot = this._slot;
10032
- let arr = tree.invisibleViews;
10033
- if (arr === undefined) {
10034
- arr = tree.invisibleViews = [];
10035
- }
10036
- while (arr.length <= slot)
10037
- arr.push(0);
10038
- arr[slot] |= this._bit;
10039
- }
10040
- /** Clear invisible bit. */
10041
- unmarkInvisible(tree) {
10042
- const arr = tree.invisibleViews;
10043
- if (arr === undefined)
10044
- return;
10045
- const slot = this._slot;
10046
- if (slot < arr.length)
10047
- arr[slot] &= ~this._bit;
10048
- }
10049
10131
  // ──────────────────────────────────────────────────────────────────
10050
10132
  // Per-tag, per-view bitmap. Replaces the legacy
10051
10133
  // `tags: WeakMap<ChangeTree, Set<number>>` storage. Hot read site is
@@ -10140,12 +10222,12 @@
10140
10222
  }
10141
10223
  _add(obj, tag, checkIncludeParent, _skipStreamRouting) {
10142
10224
  const changeTree = obj?.[$changes];
10143
- const parentChangeTree = changeTree.parent;
10144
10225
  if (!changeTree) {
10145
- console.warn("StateView#add(), invalid object:", obj);
10226
+ console.warn(`StateView#add(): expected a Schema instance or collection, received ${describeArg(obj)}`);
10146
10227
  return false;
10147
10228
  }
10148
- else if (!parentChangeTree &&
10229
+ const parentChangeTree = changeTree.parent;
10230
+ if (!parentChangeTree &&
10149
10231
  obj[$refId] !== 0 // allow root object
10150
10232
  ) {
10151
10233
  /**
@@ -10189,11 +10271,18 @@
10189
10271
  // below use `metadata?.[...]` null-safe access. Only Schema
10190
10272
  // subclasses yield a real Metadata object.
10191
10273
  const metadata = obj.constructor[Symbol.metadata];
10192
- this.markVisible(changeTree);
10193
- // add to iterable list (only the explicitly added items)
10194
- if (this.iterable && checkIncludeParent) {
10274
+ // Add to iterable list (only the explicitly added items), deduping
10275
+ // re-adds of an already-visible instance. isVisible must be read
10276
+ // BEFORE markVisible; indexOf runs only on the re-add path.
10277
+ // NOTE: dedup applies to `items` only — a re-add still re-queues the
10278
+ // full snapshot on purpose (shared-view bootstrap re-add: a
10279
+ // late-attached client may not have consumed earlier drains).
10280
+ // Callers wanting cheap idempotence can guard with `view.has(obj)`.
10281
+ if (this.iterable && checkIncludeParent
10282
+ && (!this.isVisible(changeTree) || this.items.indexOf(obj) === -1)) {
10195
10283
  this.items.push(obj);
10196
10284
  }
10285
+ this.markVisible(changeTree);
10197
10286
  // add parent ChangeTree's
10198
10287
  // - if it was invisible to this view
10199
10288
  // - if it were previously filtered out
@@ -10276,18 +10365,20 @@
10276
10365
  }
10277
10366
  else if (!changeTree.isNew || isChildAdded) {
10278
10367
  // new structures will be added as part of .encode() call, no need to force it to .encodeView()
10279
- const isInvisible = this.isInvisible(changeTree);
10280
10368
  // Full-sync snapshot: walk the live ref structurally instead of
10281
10369
  // iterating a cumulative recorder bucket. Every populated index
10282
10370
  // is emitted as ADD (matching the op-coercion previously done
10283
10371
  // at encode time). Per-field tags come from the descriptor's
10284
10372
  // precomputed `tags[]` array — direct index vs a metadata[i].tag
10285
10373
  // object hop.
10374
+ //
10375
+ // Non-matching custom-tagged fields are NEVER included here —
10376
+ // `view.changes` is drained without a per-field tag re-check,
10377
+ // so anything added leaks straight to the wire.
10286
10378
  const tags = changeTree.encDescriptor.tags;
10287
10379
  changeTree.forEachLive((index) => {
10288
10380
  const tagAtIndex = tags[index];
10289
- if (isInvisible || // if "invisible", include all
10290
- tagAtIndex === undefined || // "all change" with no tag
10381
+ if (tagAtIndex === undefined || // "all change" with no tag
10291
10382
  tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
10292
10383
  (tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
10293
10384
  ) {
@@ -10417,9 +10508,9 @@
10417
10508
  }
10418
10509
  }
10419
10510
  remove(obj, tag = DEFAULT_VIEW_TAG, _isClear = false) {
10420
- const changeTree = obj[$changes];
10511
+ const changeTree = obj?.[$changes];
10421
10512
  if (!changeTree) {
10422
- console.warn("StateView#remove(), invalid object:", obj);
10513
+ console.warn(`StateView#remove(): expected a Schema instance or collection, received ${describeArg(obj)}`);
10423
10514
  return this;
10424
10515
  }
10425
10516
  // ── Streamable-element unsubscribe ─────────────────────────────
@@ -10439,7 +10530,7 @@
10439
10530
  }
10440
10531
  // ── Streamable-collection unsubscribe (the stream itself) ─────
10441
10532
  // Flush DELETE for every sent position and drop pending. After
10442
- // this, the stream is marked invisible to this view — any future
10533
+ // this, the stream is no longer visible to this view — any future
10443
10534
  // `stream.add()` would still seed broadcast pending (if no views)
10444
10535
  // but would NOT re-seed per-view pending (user must re-subscribe).
10445
10536
  if (changeTree.isStreamCollection) {
@@ -10557,32 +10648,47 @@
10557
10648
  hasTag(ob, tag = DEFAULT_VIEW_TAG) {
10558
10649
  return this.hasTagOnTree(ob[$changes], tag);
10559
10650
  }
10560
- /**
10561
- * Persistent subscription to a collection's contents. Unlike `add()`,
10562
- * which is a one-shot bootstrap, `subscribe()` enrolls this view in
10563
- * future content changes — every subsequent push / set / add to the
10564
- * collection automatically flows to this view, and every removal
10565
- * queues a DELETE op. Works on every collection type:
10566
- *
10567
- * - `ArraySchema` / `MapSchema` / `SetSchema` / `CollectionSchema`:
10568
- * new children are force-shipped immediately (equivalent to
10569
- * `view.add(child)` per item).
10570
- * - `StreamSchema` (or `.stream()` maps/sets): new positions are
10571
- * enqueued into `_pendingByView` so the priority pass drains them
10572
- * respecting `maxPerTick`.
10573
- *
10574
- * Idempotent on re-subscribe. Subscribing to an already-subscribed
10575
- * collection is a no-op.
10576
- */
10577
- subscribe(collection) {
10651
+ subscribe(collection, priority) {
10578
10652
  const tree = collection?.[$changes];
10579
10653
  if (!tree) {
10580
- console.warn("StateView#subscribe(), invalid collection:", collection);
10654
+ console.warn(`StateView#subscribe(): expected a Schema collection, received ${describeArg(collection)}`);
10581
10655
  return this;
10582
10656
  }
10583
10657
  if (this._root === undefined && tree.root !== undefined) {
10584
10658
  this._bindRoot(tree.root);
10585
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
+ }
10586
10692
  if (this.isSubscribed(tree))
10587
10693
  return this;
10588
10694
  // Mark collection visible so its own ADD/DELETE ops emit in the
@@ -10628,7 +10734,7 @@
10628
10734
  unsubscribe(collection) {
10629
10735
  const tree = collection?.[$changes];
10630
10736
  if (!tree) {
10631
- console.warn("StateView#unsubscribe(), invalid collection:", collection);
10737
+ console.warn(`StateView#unsubscribe(): expected a Schema collection, received ${describeArg(collection)}`);
10632
10738
  return this;
10633
10739
  }
10634
10740
  if (!this.isSubscribed(tree))
@@ -10757,15 +10863,15 @@
10757
10863
  exports.encodeMapEntry = encodeMapEntry;
10758
10864
  exports.encodeSchemaOperation = encodeSchemaOperation;
10759
10865
  exports.entity = entity;
10866
+ exports.fullStateOnly = fullStateOnly;
10760
10867
  exports.getDecoderStateCallbacks = getDecoderStateCallbacks;
10761
10868
  exports.getEncodeDescriptor = getEncodeDescriptor;
10762
10869
  exports.getRawChangesCallback = getRawChangesCallback;
10763
10870
  exports.isBuilder = isBuilder;
10764
- exports.owned = owned;
10871
+ exports.patchOnly = patchOnly;
10765
10872
  exports.registerType = registerType;
10766
10873
  exports.schema = schema;
10767
10874
  exports.t = t;
10768
- exports.transient = transient;
10769
10875
  exports.type = type;
10770
10876
  exports.unreliable = unreliable;
10771
10877
  exports.view = view;