@colyseus/schema 5.0.2 → 5.0.4

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.
package/build/index.mjs CHANGED
@@ -1212,6 +1212,33 @@ const Metadata = {
1212
1212
  getStreamPriority(metadata, index) {
1213
1213
  return metadata?.[$streamPriorities]?.[index];
1214
1214
  },
1215
+ /**
1216
+ * Install a single field with full encoder wiring: accessor descriptor
1217
+ * on the prototype + `metadata[$encoders]` slot for primitives. Shared
1218
+ * between `Metadata.setFields` (build path) and
1219
+ * `Reflection.makeEncodable` (Reflection upgrade path).
1220
+ */
1221
+ defineField(target, metadata, fieldIndex, fieldName, type) {
1222
+ const normalized = getNormalizedType(type);
1223
+ const { complexTypeKlass, childType } = resolveFieldType(normalized);
1224
+ Metadata.addField(metadata, fieldIndex, fieldName, normalized, getPropertyDescriptor(fieldName, fieldIndex, childType, complexTypeKlass));
1225
+ // Install accessor descriptor on the prototype (once per class field).
1226
+ if (metadata[$descriptors][fieldName]) {
1227
+ Object.defineProperty(target.prototype, fieldName, metadata[$descriptors][fieldName]);
1228
+ }
1229
+ // Pre-compute encoder function for primitive types.
1230
+ if (typeof normalized === "string") {
1231
+ if (!metadata[$encoders]) {
1232
+ Object.defineProperty(metadata, $encoders, {
1233
+ value: [],
1234
+ enumerable: false,
1235
+ configurable: true,
1236
+ writable: true,
1237
+ });
1238
+ }
1239
+ metadata[$encoders][fieldIndex] = encode[normalized];
1240
+ }
1241
+ },
1215
1242
  setFields(target, fields) {
1216
1243
  // for inheritance support
1217
1244
  const constructor = target.prototype.constructor;
@@ -1249,17 +1276,7 @@ const Metadata = {
1249
1276
  });
1250
1277
  }
1251
1278
  for (const field in fields) {
1252
- const type = getNormalizedType(fields[field]);
1253
- const { complexTypeKlass, childType } = resolveFieldType(type);
1254
- Metadata.addField(metadata, fieldIndex, field, type, getPropertyDescriptor(field, fieldIndex, childType, complexTypeKlass));
1255
- // Install accessor descriptor on the prototype (once per class field).
1256
- if (metadata[$descriptors][field]) {
1257
- Object.defineProperty(target.prototype, field, metadata[$descriptors][field]);
1258
- }
1259
- // Pre-compute encoder function for primitive types.
1260
- if (typeof type === "string") {
1261
- metadata[$encoders][fieldIndex] = encode[type];
1262
- }
1279
+ Metadata.defineField(constructor, metadata, fieldIndex, field, fields[field]);
1263
1280
  fieldIndex++;
1264
1281
  }
1265
1282
  return target;
@@ -2091,11 +2108,24 @@ function checkInheritedFlags(tree, parent, parentIndex) {
2091
2108
  const refType = Metadata.isValidInstance(tree.ref)
2092
2109
  ? tree.ref.constructor
2093
2110
  : tree.ref[$childType];
2111
+ // #218: nested Schema fields inherit visibility from a @view-gated
2112
+ // parent regardless of whether the parent is a collection. The
2113
+ // `parentIsCollection` constraint that used to live here blocked
2114
+ // nested-Schema-field-of-@view-tagged-Schema from sharing visibility,
2115
+ // forcing users to wrap the child in an ArraySchema as a workaround.
2116
+ //
2117
+ // #226 (4.0.25): items inside a non-default-tag `@view(N)` collection
2118
+ // also inherit visibility from the parent collection, so items
2119
+ // pushed/set after `view.add(state, N)` show up automatically.
2120
+ // Default-tag `@view()` collections keep per-item gating —
2121
+ // `view.add(item)` is still required to opt each one in.
2122
+ // The `parentMetadata[parentIndex].tag` access is safe inside the
2123
+ // `fieldHasViewTag` short-circuit (the metadata entry and its `tag`
2124
+ // are guaranteed to exist when that flag is set).
2094
2125
  tree.isVisibilitySharedWithParent = (parentChangeTree.isFiltered
2095
2126
  && typeof refType !== "string"
2096
- && !fieldHasViewTag
2097
2127
  && !fieldHasStream
2098
- && parentIsCollection);
2128
+ && (!fieldHasViewTag || (parentIsCollection && parentMetadata[parentIndex].tag !== DEFAULT_VIEW_TAG)));
2099
2129
  }
2100
2130
  }
2101
2131
 
@@ -5428,8 +5458,10 @@ registerType("stream", { constructor: StreamSchema });
5428
5458
  */
5429
5459
  class FieldBuilder {
5430
5460
  [$builder] = true;
5431
- // Internal configuration. Public so schema() and tests can read it, but not
5432
- // meant to be mutated by users directly.
5461
+ // Internal configuration. Declared `private` (soft-private): hidden from
5462
+ // editor autocomplete and from normal external `.field` access, but still
5463
+ // reachable at runtime via element access (e.g. `builder['_noSync']`) for
5464
+ // internal tooling/tests. Not meant to be mutated by end users.
5433
5465
  _type;
5434
5466
  _default = undefined;
5435
5467
  _hasDefault = false;
@@ -5442,6 +5474,7 @@ class FieldBuilder {
5442
5474
  _static = false;
5443
5475
  _stream = false;
5444
5476
  _optional = false;
5477
+ _noSync = false;
5445
5478
  _streamPriority = undefined;
5446
5479
  constructor(type) {
5447
5480
  this._type = type;
@@ -5492,6 +5525,30 @@ class FieldBuilder {
5492
5525
  this._static = true;
5493
5526
  return this;
5494
5527
  }
5528
+ /**
5529
+ * Mark this field as **local-only** — it is typed and initialized on the
5530
+ * instance (so `.default()` and the inferred instance type still apply),
5531
+ * but is never registered for synchronization: it never enters change
5532
+ * tracking, never goes over the wire, and decoders never receive it.
5533
+ *
5534
+ * Useful for server-side scratch state, per-peer UI state, or values you
5535
+ * want on the class for typing convenience without paying any sync cost.
5536
+ *
5537
+ * Mutually exclusive with the sync-only modifiers (`.view()`, `.owned()`,
5538
+ * `.unreliable()`, `.transient()`, `.static()`, `.stream()`) — combining
5539
+ * them throws at `schema()` time.
5540
+ *
5541
+ * ```ts
5542
+ * const Player = schema({
5543
+ * hp: t.uint8().default(100), // synchronized
5544
+ * lastInputTick: t.number().noSync(), // local-only
5545
+ * }, 'Player');
5546
+ * ```
5547
+ */
5548
+ noSync() {
5549
+ this._noSync = true;
5550
+ return this;
5551
+ }
5495
5552
  /**
5496
5553
  * Opt a collection field into priority-batched streaming delivery —
5497
5554
  * ADDs drain at most `maxPerTick` per tick per view (or per broadcast
@@ -5546,6 +5603,11 @@ class FieldBuilder {
5546
5603
  this._optional = true;
5547
5604
  return this;
5548
5605
  }
5606
+ /**
5607
+ * @internal — snapshot of the builder's configuration consumed by
5608
+ * `schema()`. `private` keeps it out of autocomplete; internal callers
5609
+ * reach it via element access (`builder['toDefinition']()`).
5610
+ */
5549
5611
  toDefinition() {
5550
5612
  return {
5551
5613
  type: this._type,
@@ -5560,6 +5622,7 @@ class FieldBuilder {
5560
5622
  static: this._static,
5561
5623
  stream: this._stream,
5562
5624
  optional: this._optional,
5625
+ noSync: this._noSync,
5563
5626
  streamPriority: this._streamPriority,
5564
5627
  };
5565
5628
  }
@@ -5575,7 +5638,8 @@ function primitive(name) {
5575
5638
  }
5576
5639
  function resolveChild(child) {
5577
5640
  if (isBuilder(child)) {
5578
- return child._type;
5641
+ // `_type` is private; element access bypasses the visibility check.
5642
+ return child['_type'];
5579
5643
  }
5580
5644
  return child;
5581
5645
  }
@@ -5585,7 +5649,7 @@ const setFactory = ((child) => new FieldBuilder({ set: resolveChild(child) }));
5585
5649
  const collectionFactory = ((child) => new FieldBuilder({ collection: resolveChild(child) }));
5586
5650
  const streamFactory = ((child) => {
5587
5651
  const b = new FieldBuilder({ stream: resolveChild(child) });
5588
- b._stream = true;
5652
+ b['_stream'] = true; // element access bypasses `private`
5589
5653
  return b;
5590
5654
  });
5591
5655
  const refFactory = ((ctor) => new FieldBuilder(ctor));
@@ -6014,6 +6078,36 @@ function deprecated(throws = true) {
6014
6078
  });
6015
6079
  };
6016
6080
  }
6081
+ /**
6082
+ * Produce the auto-instantiated construction default for a builder type
6083
+ * (empty collection or zero-arg Schema ref), or `undefined` when the type
6084
+ * has no auto-default. Shared by synced and `.noSync()` field handling.
6085
+ */
6086
+ function autoInstantiateDefault(rawType) {
6087
+ if (rawType && typeof rawType === "object") {
6088
+ if (rawType.array !== undefined) {
6089
+ return new ArraySchema();
6090
+ }
6091
+ if (rawType.map !== undefined) {
6092
+ return new MapSchema();
6093
+ }
6094
+ if (rawType.set !== undefined) {
6095
+ return new SetSchema();
6096
+ }
6097
+ if (rawType.collection !== undefined) {
6098
+ return new CollectionSchema();
6099
+ }
6100
+ if (rawType.stream !== undefined) {
6101
+ return new StreamSchema();
6102
+ }
6103
+ }
6104
+ else if (typeof rawType === "function" && Schema.is(rawType)) {
6105
+ if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
6106
+ return new rawType();
6107
+ }
6108
+ }
6109
+ return undefined;
6110
+ }
6017
6111
  /**
6018
6112
  * Define a Schema class declaratively.
6019
6113
  *
@@ -6049,7 +6143,28 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6049
6143
  for (const fieldName in fieldsAndMethods) {
6050
6144
  const value = fieldsAndMethods[fieldName];
6051
6145
  if (isBuilder(value)) {
6052
- const def = value.toDefinition();
6146
+ const def = value['toDefinition'](); // private; element access bypasses visibility
6147
+ if (def.noSync) {
6148
+ // Local-only field: skip metadata registration entirely so it is
6149
+ // never encoded/decoded, but still seed its construction default
6150
+ // (honoring `.default()` and collection/ref auto-instantiation).
6151
+ if (def.view !== undefined || def.owned || def.unreliable ||
6152
+ def.transient || def.static || def.stream) {
6153
+ throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .noSync() ` +
6154
+ `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
6155
+ `A local-only field cannot be synchronized.`);
6156
+ }
6157
+ if (def.hasDefault) {
6158
+ defaultValues[fieldName] = def.default;
6159
+ }
6160
+ else if (!def.optional) {
6161
+ const autoDefault = autoInstantiateDefault(def.type);
6162
+ if (autoDefault !== undefined) {
6163
+ defaultValues[fieldName] = autoDefault;
6164
+ }
6165
+ }
6166
+ continue;
6167
+ }
6053
6168
  fields[fieldName] = getNormalizedType(def.type);
6054
6169
  if (def.view !== undefined) {
6055
6170
  viewTagFields[fieldName] = def.view;
@@ -6084,28 +6199,9 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6084
6199
  else if (!def.optional) {
6085
6200
  // Auto-instantiate collection/Schema defaults when none is provided.
6086
6201
  // `.optional()` opts out — field starts as undefined.
6087
- const rawType = def.type;
6088
- if (rawType && typeof rawType === "object") {
6089
- if (rawType.array !== undefined) {
6090
- defaultValues[fieldName] = new ArraySchema();
6091
- }
6092
- else if (rawType.map !== undefined) {
6093
- defaultValues[fieldName] = new MapSchema();
6094
- }
6095
- else if (rawType.set !== undefined) {
6096
- defaultValues[fieldName] = new SetSchema();
6097
- }
6098
- else if (rawType.collection !== undefined) {
6099
- defaultValues[fieldName] = new CollectionSchema();
6100
- }
6101
- else if (rawType.stream !== undefined) {
6102
- defaultValues[fieldName] = new StreamSchema();
6103
- }
6104
- }
6105
- else if (typeof rawType === "function" && Schema.is(rawType)) {
6106
- if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
6107
- defaultValues[fieldName] = new rawType();
6108
- }
6202
+ const autoDefault = autoInstantiateDefault(def.type);
6203
+ if (autoDefault !== undefined) {
6204
+ defaultValues[fieldName] = autoDefault;
6109
6205
  }
6110
6206
  }
6111
6207
  }
@@ -7344,8 +7440,19 @@ class Encoder {
7344
7440
  // selected element is passed to `view.add()` which populates
7345
7441
  // view.changes with the stream-link ADD + element-field ADDs.
7346
7442
  this._emitStreamPriority(view);
7347
- // encode visibility-triggered changes collected by view.add()
7348
- for (const [refId, changes] of view.changes) {
7443
+ //
7444
+ // `view.changes` Map insertion order IS topological order:
7445
+ // - `view.add` walks the parent chain to root via `addParentOf`
7446
+ // (depth-first ancestor-first), inserting every ancestor's
7447
+ // entry before the descendant's.
7448
+ // - `view.remove` calls `_touchAncestorsOf` before its own
7449
+ // write to insert any missing ancestors at the front of the
7450
+ // chain — empty entries that get skipped by the size==0
7451
+ // check below but establish Map position.
7452
+ // No per-encode topo sort needed.
7453
+ //
7454
+ for (const refId of view.changes.keys()) {
7455
+ const changes = view.changes.get(refId);
7349
7456
  const changeTree = this.root.changeTrees[refId];
7350
7457
  if (changeTree === undefined) {
7351
7458
  // detached instance, remove from view and skip.
@@ -8106,6 +8213,31 @@ Reflection.decode = function (bytes, it) {
8106
8213
  const state = new (typeContext.get(reflection.rootType || 0))();
8107
8214
  return new Decoder(state, typeContext);
8108
8215
  };
8216
+ Reflection.makeEncodable = function (ctor) {
8217
+ const metadata = ctor[Symbol.metadata];
8218
+ if (!metadata)
8219
+ return ctor;
8220
+ const numFields = metadata[$numFields];
8221
+ if (numFields === undefined)
8222
+ return ctor;
8223
+ // Walk every field index across the inheritance chain. Repeat calls
8224
+ // are cheap: defineField overwrites the same descriptor and re-stamps
8225
+ // the same `metadata[$encoders]` slot (idempotent).
8226
+ for (let i = 0; i <= numFields; i++) {
8227
+ const field = metadata[i];
8228
+ if (!field)
8229
+ continue;
8230
+ Metadata.defineField(ctor, metadata, i, field.name, field.type);
8231
+ }
8232
+ // Invalidate any cached encode descriptor — `getEncodeDescriptor`
8233
+ // memoizes on the constructor. If something already constructed it
8234
+ // (e.g. a prior `InputEncoder(...)` call that threw), drop the stale
8235
+ // entry so the next read sees the upgraded metadata.
8236
+ if (Object.prototype.hasOwnProperty.call(ctor, $encodeDescriptor)) {
8237
+ delete ctor[$encodeDescriptor];
8238
+ }
8239
+ return ctor;
8240
+ };
8109
8241
 
8110
8242
  /**
8111
8243
  * Legacy callback system
@@ -8722,6 +8854,7 @@ const Callbacks = {
8722
8854
  else if ('decoder' in roomOrDecoder.serializer) {
8723
8855
  return getDecoderStateCallbacks(roomOrDecoder.serializer.decoder);
8724
8856
  }
8857
+ throw new Error('Invalid room or decoder');
8725
8858
  },
8726
8859
  getRawChanges(decoder, callback) {
8727
8860
  return getRawChangesCallback(decoder, callback);
@@ -9177,12 +9310,27 @@ class StateView {
9177
9310
  if (!this.isVisible(changeTree)) {
9178
9311
  // view must have all "changeTree" parent tree
9179
9312
  this.markVisible(changeTree);
9180
- // add parent's parent
9313
+ // Recurse all the way to the root regardless of whether the
9314
+ // parent is filtered. Walking the full chain is what makes
9315
+ // `view.changes` topologically ordered by construction — any
9316
+ // filtered ancestor up the chain is touched here, before the
9317
+ // descendant's entry. The actual entry-write below is gated
9318
+ // on `hasFilteredFields` so non-filtered ancestors don't
9319
+ // emit redundant wire bytes (the decoder already knows them
9320
+ // via the shared encode pass). Marking them visible is
9321
+ // still useful: it makes this short-circuit fire on the next
9322
+ // `view.add` instead of re-walking the chain.
9181
9323
  const parentChangeTree = changeTree.parent?.[$changes];
9182
- if (parentChangeTree && parentChangeTree.hasFilteredFields) {
9324
+ if (parentChangeTree) {
9183
9325
  this.addParentOf(changeTree, tag);
9184
9326
  }
9185
9327
  }
9328
+ // Skip the entry-write for non-filtered ancestors: their refIds
9329
+ // are already known to the decoder through the shared pass, and
9330
+ // an extra ADD on a non-filtered field's index would only emit
9331
+ // bytes for a no-op (`value === previousValue` on the decoder).
9332
+ if (!changeTree.hasFilteredFields)
9333
+ return;
9186
9334
  // add parent's tag properties
9187
9335
  if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
9188
9336
  let changes = this.changes.get(changeTree.ref[$refId]);
@@ -9194,6 +9342,45 @@ class StateView {
9194
9342
  changes.set(parentIndex, OPERATION.ADD);
9195
9343
  }
9196
9344
  }
9345
+ /**
9346
+ * Walk `tree`'s parent chain to root and insert an empty entry into
9347
+ * `view.changes` for any ancestor not already present. Empty entries
9348
+ * are skipped by `encodeView` (`changes.size === 0` continue), so no
9349
+ * wire bytes are emitted — but the Map's insertion order now puts
9350
+ * each ancestor BEFORE the descendant entry that the caller is about
9351
+ * to write. Combined with `addParentOf`'s full-recursion walk on
9352
+ * `view.add`, this preserves the global invariant that
9353
+ * `view.changes` iteration order is topological.
9354
+ *
9355
+ * Iterative (not recursive) so the stack is bounded by tree depth
9356
+ * regardless of call patterns. Stops the walk as soon as it hits an
9357
+ * ancestor that's already in `view.changes` — at that point the
9358
+ * remainder of the chain is guaranteed to also be present (invariant
9359
+ * upheld by every prior caller).
9360
+ */
9361
+ _touchAncestorsOf(tree) {
9362
+ let cursor = tree.parent?.[$changes];
9363
+ if (cursor === undefined)
9364
+ return;
9365
+ // Collect the missing prefix of the chain, deepest-first. Only
9366
+ // FILTERED ancestors need entries — non-filtered ones never
9367
+ // appear in `view.changes` (mirrors the addParentOf gate), so
9368
+ // they don't need a Map slot reserved either.
9369
+ const stack = [];
9370
+ while (cursor !== undefined) {
9371
+ if (cursor.hasFilteredFields) {
9372
+ const refId = cursor.ref[$refId];
9373
+ if (this.changes.has(refId))
9374
+ break;
9375
+ stack.push(cursor);
9376
+ }
9377
+ cursor = cursor.parent?.[$changes];
9378
+ }
9379
+ // Insert root-first so Map order is topological.
9380
+ for (let i = stack.length - 1; i >= 0; i--) {
9381
+ this.changes.set(stack[i].ref[$refId], new Map());
9382
+ }
9383
+ }
9197
9384
  remove(obj, tag = DEFAULT_VIEW_TAG, _isClear = false) {
9198
9385
  const changeTree = obj[$changes];
9199
9386
  if (!changeTree) {
@@ -9254,6 +9441,11 @@ class StateView {
9254
9441
  const ref = changeTree.ref;
9255
9442
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
9256
9443
  const refId = ref[$refId];
9444
+ // Pre-insert any missing ancestors into view.changes so the Map's
9445
+ // iteration order stays topological — the entries we're about to
9446
+ // write (either on this obj, or on its parent collection below)
9447
+ // must come AFTER every ancestor in the chain on the wire.
9448
+ this._touchAncestorsOf(changeTree);
9257
9449
  let changes = this.changes.get(refId);
9258
9450
  if (changes === undefined) {
9259
9451
  changes = new Map();