@colyseus/schema 5.0.3 → 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.js CHANGED
@@ -2114,11 +2114,24 @@
2114
2114
  const refType = Metadata.isValidInstance(tree.ref)
2115
2115
  ? tree.ref.constructor
2116
2116
  : tree.ref[$childType];
2117
+ // #218: nested Schema fields inherit visibility from a @view-gated
2118
+ // parent regardless of whether the parent is a collection. The
2119
+ // `parentIsCollection` constraint that used to live here blocked
2120
+ // nested-Schema-field-of-@view-tagged-Schema from sharing visibility,
2121
+ // forcing users to wrap the child in an ArraySchema as a workaround.
2122
+ //
2123
+ // #226 (4.0.25): items inside a non-default-tag `@view(N)` collection
2124
+ // also inherit visibility from the parent collection, so items
2125
+ // pushed/set after `view.add(state, N)` show up automatically.
2126
+ // Default-tag `@view()` collections keep per-item gating —
2127
+ // `view.add(item)` is still required to opt each one in.
2128
+ // The `parentMetadata[parentIndex].tag` access is safe inside the
2129
+ // `fieldHasViewTag` short-circuit (the metadata entry and its `tag`
2130
+ // are guaranteed to exist when that flag is set).
2117
2131
  tree.isVisibilitySharedWithParent = (parentChangeTree.isFiltered
2118
2132
  && typeof refType !== "string"
2119
- && !fieldHasViewTag
2120
2133
  && !fieldHasStream
2121
- && parentIsCollection);
2134
+ && (!fieldHasViewTag || (parentIsCollection && parentMetadata[parentIndex].tag !== DEFAULT_VIEW_TAG)));
2122
2135
  }
2123
2136
  }
2124
2137
 
@@ -5451,8 +5464,10 @@
5451
5464
  */
5452
5465
  class FieldBuilder {
5453
5466
  [$builder] = true;
5454
- // Internal configuration. Public so schema() and tests can read it, but not
5455
- // meant to be mutated by users directly.
5467
+ // Internal configuration. Declared `private` (soft-private): hidden from
5468
+ // editor autocomplete and from normal external `.field` access, but still
5469
+ // reachable at runtime via element access (e.g. `builder['_noSync']`) for
5470
+ // internal tooling/tests. Not meant to be mutated by end users.
5456
5471
  _type;
5457
5472
  _default = undefined;
5458
5473
  _hasDefault = false;
@@ -5465,6 +5480,7 @@
5465
5480
  _static = false;
5466
5481
  _stream = false;
5467
5482
  _optional = false;
5483
+ _noSync = false;
5468
5484
  _streamPriority = undefined;
5469
5485
  constructor(type) {
5470
5486
  this._type = type;
@@ -5515,6 +5531,30 @@
5515
5531
  this._static = true;
5516
5532
  return this;
5517
5533
  }
5534
+ /**
5535
+ * Mark this field as **local-only** — it is typed and initialized on the
5536
+ * instance (so `.default()` and the inferred instance type still apply),
5537
+ * but is never registered for synchronization: it never enters change
5538
+ * tracking, never goes over the wire, and decoders never receive it.
5539
+ *
5540
+ * Useful for server-side scratch state, per-peer UI state, or values you
5541
+ * want on the class for typing convenience without paying any sync cost.
5542
+ *
5543
+ * Mutually exclusive with the sync-only modifiers (`.view()`, `.owned()`,
5544
+ * `.unreliable()`, `.transient()`, `.static()`, `.stream()`) — combining
5545
+ * them throws at `schema()` time.
5546
+ *
5547
+ * ```ts
5548
+ * const Player = schema({
5549
+ * hp: t.uint8().default(100), // synchronized
5550
+ * lastInputTick: t.number().noSync(), // local-only
5551
+ * }, 'Player');
5552
+ * ```
5553
+ */
5554
+ noSync() {
5555
+ this._noSync = true;
5556
+ return this;
5557
+ }
5518
5558
  /**
5519
5559
  * Opt a collection field into priority-batched streaming delivery —
5520
5560
  * ADDs drain at most `maxPerTick` per tick per view (or per broadcast
@@ -5569,6 +5609,11 @@
5569
5609
  this._optional = true;
5570
5610
  return this;
5571
5611
  }
5612
+ /**
5613
+ * @internal — snapshot of the builder's configuration consumed by
5614
+ * `schema()`. `private` keeps it out of autocomplete; internal callers
5615
+ * reach it via element access (`builder['toDefinition']()`).
5616
+ */
5572
5617
  toDefinition() {
5573
5618
  return {
5574
5619
  type: this._type,
@@ -5583,6 +5628,7 @@
5583
5628
  static: this._static,
5584
5629
  stream: this._stream,
5585
5630
  optional: this._optional,
5631
+ noSync: this._noSync,
5586
5632
  streamPriority: this._streamPriority,
5587
5633
  };
5588
5634
  }
@@ -5598,7 +5644,8 @@
5598
5644
  }
5599
5645
  function resolveChild(child) {
5600
5646
  if (isBuilder(child)) {
5601
- return child._type;
5647
+ // `_type` is private; element access bypasses the visibility check.
5648
+ return child['_type'];
5602
5649
  }
5603
5650
  return child;
5604
5651
  }
@@ -5608,7 +5655,7 @@
5608
5655
  const collectionFactory = ((child) => new FieldBuilder({ collection: resolveChild(child) }));
5609
5656
  const streamFactory = ((child) => {
5610
5657
  const b = new FieldBuilder({ stream: resolveChild(child) });
5611
- b._stream = true;
5658
+ b['_stream'] = true; // element access bypasses `private`
5612
5659
  return b;
5613
5660
  });
5614
5661
  const refFactory = ((ctor) => new FieldBuilder(ctor));
@@ -6037,6 +6084,36 @@
6037
6084
  });
6038
6085
  };
6039
6086
  }
6087
+ /**
6088
+ * Produce the auto-instantiated construction default for a builder type
6089
+ * (empty collection or zero-arg Schema ref), or `undefined` when the type
6090
+ * has no auto-default. Shared by synced and `.noSync()` field handling.
6091
+ */
6092
+ function autoInstantiateDefault(rawType) {
6093
+ if (rawType && typeof rawType === "object") {
6094
+ if (rawType.array !== undefined) {
6095
+ return new ArraySchema();
6096
+ }
6097
+ if (rawType.map !== undefined) {
6098
+ return new MapSchema();
6099
+ }
6100
+ if (rawType.set !== undefined) {
6101
+ return new SetSchema();
6102
+ }
6103
+ if (rawType.collection !== undefined) {
6104
+ return new CollectionSchema();
6105
+ }
6106
+ if (rawType.stream !== undefined) {
6107
+ return new StreamSchema();
6108
+ }
6109
+ }
6110
+ else if (typeof rawType === "function" && Schema.is(rawType)) {
6111
+ if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
6112
+ return new rawType();
6113
+ }
6114
+ }
6115
+ return undefined;
6116
+ }
6040
6117
  /**
6041
6118
  * Define a Schema class declaratively.
6042
6119
  *
@@ -6072,7 +6149,28 @@
6072
6149
  for (const fieldName in fieldsAndMethods) {
6073
6150
  const value = fieldsAndMethods[fieldName];
6074
6151
  if (isBuilder(value)) {
6075
- const def = value.toDefinition();
6152
+ const def = value['toDefinition'](); // private; element access bypasses visibility
6153
+ if (def.noSync) {
6154
+ // Local-only field: skip metadata registration entirely so it is
6155
+ // never encoded/decoded, but still seed its construction default
6156
+ // (honoring `.default()` and collection/ref auto-instantiation).
6157
+ if (def.view !== undefined || def.owned || def.unreliable ||
6158
+ def.transient || def.static || def.stream) {
6159
+ throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .noSync() ` +
6160
+ `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
6161
+ `A local-only field cannot be synchronized.`);
6162
+ }
6163
+ if (def.hasDefault) {
6164
+ defaultValues[fieldName] = def.default;
6165
+ }
6166
+ else if (!def.optional) {
6167
+ const autoDefault = autoInstantiateDefault(def.type);
6168
+ if (autoDefault !== undefined) {
6169
+ defaultValues[fieldName] = autoDefault;
6170
+ }
6171
+ }
6172
+ continue;
6173
+ }
6076
6174
  fields[fieldName] = getNormalizedType(def.type);
6077
6175
  if (def.view !== undefined) {
6078
6176
  viewTagFields[fieldName] = def.view;
@@ -6107,28 +6205,9 @@
6107
6205
  else if (!def.optional) {
6108
6206
  // Auto-instantiate collection/Schema defaults when none is provided.
6109
6207
  // `.optional()` opts out — field starts as undefined.
6110
- const rawType = def.type;
6111
- if (rawType && typeof rawType === "object") {
6112
- if (rawType.array !== undefined) {
6113
- defaultValues[fieldName] = new ArraySchema();
6114
- }
6115
- else if (rawType.map !== undefined) {
6116
- defaultValues[fieldName] = new MapSchema();
6117
- }
6118
- else if (rawType.set !== undefined) {
6119
- defaultValues[fieldName] = new SetSchema();
6120
- }
6121
- else if (rawType.collection !== undefined) {
6122
- defaultValues[fieldName] = new CollectionSchema();
6123
- }
6124
- else if (rawType.stream !== undefined) {
6125
- defaultValues[fieldName] = new StreamSchema();
6126
- }
6127
- }
6128
- else if (typeof rawType === "function" && Schema.is(rawType)) {
6129
- if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
6130
- defaultValues[fieldName] = new rawType();
6131
- }
6208
+ const autoDefault = autoInstantiateDefault(def.type);
6209
+ if (autoDefault !== undefined) {
6210
+ defaultValues[fieldName] = autoDefault;
6132
6211
  }
6133
6212
  }
6134
6213
  }
@@ -7367,8 +7446,19 @@
7367
7446
  // selected element is passed to `view.add()` which populates
7368
7447
  // view.changes with the stream-link ADD + element-field ADDs.
7369
7448
  this._emitStreamPriority(view);
7370
- // encode visibility-triggered changes collected by view.add()
7371
- for (const [refId, changes] of view.changes) {
7449
+ //
7450
+ // `view.changes` Map insertion order IS topological order:
7451
+ // - `view.add` walks the parent chain to root via `addParentOf`
7452
+ // (depth-first ancestor-first), inserting every ancestor's
7453
+ // entry before the descendant's.
7454
+ // - `view.remove` calls `_touchAncestorsOf` before its own
7455
+ // write to insert any missing ancestors at the front of the
7456
+ // chain — empty entries that get skipped by the size==0
7457
+ // check below but establish Map position.
7458
+ // No per-encode topo sort needed.
7459
+ //
7460
+ for (const refId of view.changes.keys()) {
7461
+ const changes = view.changes.get(refId);
7372
7462
  const changeTree = this.root.changeTrees[refId];
7373
7463
  if (changeTree === undefined) {
7374
7464
  // detached instance, remove from view and skip.
@@ -8770,6 +8860,7 @@
8770
8860
  else if ('decoder' in roomOrDecoder.serializer) {
8771
8861
  return getDecoderStateCallbacks(roomOrDecoder.serializer.decoder);
8772
8862
  }
8863
+ throw new Error('Invalid room or decoder');
8773
8864
  },
8774
8865
  getRawChanges(decoder, callback) {
8775
8866
  return getRawChangesCallback(decoder, callback);
@@ -9225,12 +9316,27 @@
9225
9316
  if (!this.isVisible(changeTree)) {
9226
9317
  // view must have all "changeTree" parent tree
9227
9318
  this.markVisible(changeTree);
9228
- // add parent's parent
9319
+ // Recurse all the way to the root regardless of whether the
9320
+ // parent is filtered. Walking the full chain is what makes
9321
+ // `view.changes` topologically ordered by construction — any
9322
+ // filtered ancestor up the chain is touched here, before the
9323
+ // descendant's entry. The actual entry-write below is gated
9324
+ // on `hasFilteredFields` so non-filtered ancestors don't
9325
+ // emit redundant wire bytes (the decoder already knows them
9326
+ // via the shared encode pass). Marking them visible is
9327
+ // still useful: it makes this short-circuit fire on the next
9328
+ // `view.add` instead of re-walking the chain.
9229
9329
  const parentChangeTree = changeTree.parent?.[$changes];
9230
- if (parentChangeTree && parentChangeTree.hasFilteredFields) {
9330
+ if (parentChangeTree) {
9231
9331
  this.addParentOf(changeTree, tag);
9232
9332
  }
9233
9333
  }
9334
+ // Skip the entry-write for non-filtered ancestors: their refIds
9335
+ // are already known to the decoder through the shared pass, and
9336
+ // an extra ADD on a non-filtered field's index would only emit
9337
+ // bytes for a no-op (`value === previousValue` on the decoder).
9338
+ if (!changeTree.hasFilteredFields)
9339
+ return;
9234
9340
  // add parent's tag properties
9235
9341
  if (changeTree.getChange(parentIndex) !== exports.OPERATION.DELETE) {
9236
9342
  let changes = this.changes.get(changeTree.ref[$refId]);
@@ -9242,6 +9348,45 @@
9242
9348
  changes.set(parentIndex, exports.OPERATION.ADD);
9243
9349
  }
9244
9350
  }
9351
+ /**
9352
+ * Walk `tree`'s parent chain to root and insert an empty entry into
9353
+ * `view.changes` for any ancestor not already present. Empty entries
9354
+ * are skipped by `encodeView` (`changes.size === 0` continue), so no
9355
+ * wire bytes are emitted — but the Map's insertion order now puts
9356
+ * each ancestor BEFORE the descendant entry that the caller is about
9357
+ * to write. Combined with `addParentOf`'s full-recursion walk on
9358
+ * `view.add`, this preserves the global invariant that
9359
+ * `view.changes` iteration order is topological.
9360
+ *
9361
+ * Iterative (not recursive) so the stack is bounded by tree depth
9362
+ * regardless of call patterns. Stops the walk as soon as it hits an
9363
+ * ancestor that's already in `view.changes` — at that point the
9364
+ * remainder of the chain is guaranteed to also be present (invariant
9365
+ * upheld by every prior caller).
9366
+ */
9367
+ _touchAncestorsOf(tree) {
9368
+ let cursor = tree.parent?.[$changes];
9369
+ if (cursor === undefined)
9370
+ return;
9371
+ // Collect the missing prefix of the chain, deepest-first. Only
9372
+ // FILTERED ancestors need entries — non-filtered ones never
9373
+ // appear in `view.changes` (mirrors the addParentOf gate), so
9374
+ // they don't need a Map slot reserved either.
9375
+ const stack = [];
9376
+ while (cursor !== undefined) {
9377
+ if (cursor.hasFilteredFields) {
9378
+ const refId = cursor.ref[$refId];
9379
+ if (this.changes.has(refId))
9380
+ break;
9381
+ stack.push(cursor);
9382
+ }
9383
+ cursor = cursor.parent?.[$changes];
9384
+ }
9385
+ // Insert root-first so Map order is topological.
9386
+ for (let i = stack.length - 1; i >= 0; i--) {
9387
+ this.changes.set(stack[i].ref[$refId], new Map());
9388
+ }
9389
+ }
9245
9390
  remove(obj, tag = DEFAULT_VIEW_TAG, _isClear = false) {
9246
9391
  const changeTree = obj[$changes];
9247
9392
  if (!changeTree) {
@@ -9302,6 +9447,11 @@
9302
9447
  const ref = changeTree.ref;
9303
9448
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
9304
9449
  const refId = ref[$refId];
9450
+ // Pre-insert any missing ancestors into view.changes so the Map's
9451
+ // iteration order stays topological — the entries we're about to
9452
+ // write (either on this obj, or on its parent collection below)
9453
+ // must come AFTER every ancestor in the chain on the wire.
9454
+ this._touchAncestorsOf(changeTree);
9305
9455
  let changes = this.changes.get(refId);
9306
9456
  if (changes === undefined) {
9307
9457
  changes = new Map();
package/build/index.mjs CHANGED
@@ -2108,11 +2108,24 @@ function checkInheritedFlags(tree, parent, parentIndex) {
2108
2108
  const refType = Metadata.isValidInstance(tree.ref)
2109
2109
  ? tree.ref.constructor
2110
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).
2111
2125
  tree.isVisibilitySharedWithParent = (parentChangeTree.isFiltered
2112
2126
  && typeof refType !== "string"
2113
- && !fieldHasViewTag
2114
2127
  && !fieldHasStream
2115
- && parentIsCollection);
2128
+ && (!fieldHasViewTag || (parentIsCollection && parentMetadata[parentIndex].tag !== DEFAULT_VIEW_TAG)));
2116
2129
  }
2117
2130
  }
2118
2131
 
@@ -5445,8 +5458,10 @@ registerType("stream", { constructor: StreamSchema });
5445
5458
  */
5446
5459
  class FieldBuilder {
5447
5460
  [$builder] = true;
5448
- // Internal configuration. Public so schema() and tests can read it, but not
5449
- // 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.
5450
5465
  _type;
5451
5466
  _default = undefined;
5452
5467
  _hasDefault = false;
@@ -5459,6 +5474,7 @@ class FieldBuilder {
5459
5474
  _static = false;
5460
5475
  _stream = false;
5461
5476
  _optional = false;
5477
+ _noSync = false;
5462
5478
  _streamPriority = undefined;
5463
5479
  constructor(type) {
5464
5480
  this._type = type;
@@ -5509,6 +5525,30 @@ class FieldBuilder {
5509
5525
  this._static = true;
5510
5526
  return this;
5511
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
+ }
5512
5552
  /**
5513
5553
  * Opt a collection field into priority-batched streaming delivery —
5514
5554
  * ADDs drain at most `maxPerTick` per tick per view (or per broadcast
@@ -5563,6 +5603,11 @@ class FieldBuilder {
5563
5603
  this._optional = true;
5564
5604
  return this;
5565
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
+ */
5566
5611
  toDefinition() {
5567
5612
  return {
5568
5613
  type: this._type,
@@ -5577,6 +5622,7 @@ class FieldBuilder {
5577
5622
  static: this._static,
5578
5623
  stream: this._stream,
5579
5624
  optional: this._optional,
5625
+ noSync: this._noSync,
5580
5626
  streamPriority: this._streamPriority,
5581
5627
  };
5582
5628
  }
@@ -5592,7 +5638,8 @@ function primitive(name) {
5592
5638
  }
5593
5639
  function resolveChild(child) {
5594
5640
  if (isBuilder(child)) {
5595
- return child._type;
5641
+ // `_type` is private; element access bypasses the visibility check.
5642
+ return child['_type'];
5596
5643
  }
5597
5644
  return child;
5598
5645
  }
@@ -5602,7 +5649,7 @@ const setFactory = ((child) => new FieldBuilder({ set: resolveChild(child) }));
5602
5649
  const collectionFactory = ((child) => new FieldBuilder({ collection: resolveChild(child) }));
5603
5650
  const streamFactory = ((child) => {
5604
5651
  const b = new FieldBuilder({ stream: resolveChild(child) });
5605
- b._stream = true;
5652
+ b['_stream'] = true; // element access bypasses `private`
5606
5653
  return b;
5607
5654
  });
5608
5655
  const refFactory = ((ctor) => new FieldBuilder(ctor));
@@ -6031,6 +6078,36 @@ function deprecated(throws = true) {
6031
6078
  });
6032
6079
  };
6033
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
+ }
6034
6111
  /**
6035
6112
  * Define a Schema class declaratively.
6036
6113
  *
@@ -6066,7 +6143,28 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6066
6143
  for (const fieldName in fieldsAndMethods) {
6067
6144
  const value = fieldsAndMethods[fieldName];
6068
6145
  if (isBuilder(value)) {
6069
- 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
+ }
6070
6168
  fields[fieldName] = getNormalizedType(def.type);
6071
6169
  if (def.view !== undefined) {
6072
6170
  viewTagFields[fieldName] = def.view;
@@ -6101,28 +6199,9 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6101
6199
  else if (!def.optional) {
6102
6200
  // Auto-instantiate collection/Schema defaults when none is provided.
6103
6201
  // `.optional()` opts out — field starts as undefined.
6104
- const rawType = def.type;
6105
- if (rawType && typeof rawType === "object") {
6106
- if (rawType.array !== undefined) {
6107
- defaultValues[fieldName] = new ArraySchema();
6108
- }
6109
- else if (rawType.map !== undefined) {
6110
- defaultValues[fieldName] = new MapSchema();
6111
- }
6112
- else if (rawType.set !== undefined) {
6113
- defaultValues[fieldName] = new SetSchema();
6114
- }
6115
- else if (rawType.collection !== undefined) {
6116
- defaultValues[fieldName] = new CollectionSchema();
6117
- }
6118
- else if (rawType.stream !== undefined) {
6119
- defaultValues[fieldName] = new StreamSchema();
6120
- }
6121
- }
6122
- else if (typeof rawType === "function" && Schema.is(rawType)) {
6123
- if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
6124
- defaultValues[fieldName] = new rawType();
6125
- }
6202
+ const autoDefault = autoInstantiateDefault(def.type);
6203
+ if (autoDefault !== undefined) {
6204
+ defaultValues[fieldName] = autoDefault;
6126
6205
  }
6127
6206
  }
6128
6207
  }
@@ -7361,8 +7440,19 @@ class Encoder {
7361
7440
  // selected element is passed to `view.add()` which populates
7362
7441
  // view.changes with the stream-link ADD + element-field ADDs.
7363
7442
  this._emitStreamPriority(view);
7364
- // encode visibility-triggered changes collected by view.add()
7365
- 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);
7366
7456
  const changeTree = this.root.changeTrees[refId];
7367
7457
  if (changeTree === undefined) {
7368
7458
  // detached instance, remove from view and skip.
@@ -8764,6 +8854,7 @@ const Callbacks = {
8764
8854
  else if ('decoder' in roomOrDecoder.serializer) {
8765
8855
  return getDecoderStateCallbacks(roomOrDecoder.serializer.decoder);
8766
8856
  }
8857
+ throw new Error('Invalid room or decoder');
8767
8858
  },
8768
8859
  getRawChanges(decoder, callback) {
8769
8860
  return getRawChangesCallback(decoder, callback);
@@ -9219,12 +9310,27 @@ class StateView {
9219
9310
  if (!this.isVisible(changeTree)) {
9220
9311
  // view must have all "changeTree" parent tree
9221
9312
  this.markVisible(changeTree);
9222
- // 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.
9223
9323
  const parentChangeTree = changeTree.parent?.[$changes];
9224
- if (parentChangeTree && parentChangeTree.hasFilteredFields) {
9324
+ if (parentChangeTree) {
9225
9325
  this.addParentOf(changeTree, tag);
9226
9326
  }
9227
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;
9228
9334
  // add parent's tag properties
9229
9335
  if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
9230
9336
  let changes = this.changes.get(changeTree.ref[$refId]);
@@ -9236,6 +9342,45 @@ class StateView {
9236
9342
  changes.set(parentIndex, OPERATION.ADD);
9237
9343
  }
9238
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
+ }
9239
9384
  remove(obj, tag = DEFAULT_VIEW_TAG, _isClear = false) {
9240
9385
  const changeTree = obj[$changes];
9241
9386
  if (!changeTree) {
@@ -9296,6 +9441,11 @@ class StateView {
9296
9441
  const ref = changeTree.ref;
9297
9442
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
9298
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);
9299
9449
  let changes = this.changes.get(refId);
9300
9450
  if (changes === undefined) {
9301
9451
  changes = new Map();