@colyseus/schema 4.0.21 → 4.0.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/schema",
3
- "version": "4.0.21",
3
+ "version": "4.0.23",
4
4
  "description": "Binary state serializer with delta encoding for games",
5
5
  "type": "module",
6
6
  "bin": {
@@ -88,13 +88,13 @@ export class StateCallbackStrategy<TState extends IRef> {
88
88
  if (!collection || collection[$refId] === undefined) {
89
89
  let removePropertyCallback: () => void;
90
90
  removePropertyCallback = this.addCallback(
91
- instance[$refId],
91
+ instance[$refId]!,
92
92
  propertyName,
93
93
  (value: TReturn, _: TReturn) => {
94
94
  if (value !== null && value !== undefined) {
95
95
  // Remove the property listener now that collection is available
96
96
  removePropertyCallback();
97
- removeHandler = this.addCallback(value[$refId], operation, handler);
97
+ removeHandler = this.addCallback(value[$refId]!, operation, handler);
98
98
  }
99
99
  }
100
100
  );
@@ -113,7 +113,7 @@ export class StateCallbackStrategy<TState extends IRef> {
113
113
  });
114
114
  }
115
115
 
116
- return this.addCallback(collection[$refId], operation, handler);
116
+ return this.addCallback(collection[$refId]!, operation, handler);
117
117
  }
118
118
  }
119
119
 
@@ -162,13 +162,13 @@ export class StateCallbackStrategy<TState extends IRef> {
162
162
  handler(currentValue, undefined as any);
163
163
  }
164
164
 
165
- return this.addCallback(instance[$refId], propertyName, handler);
165
+ return this.addCallback(instance[$refId]!, propertyName, handler);
166
166
  }
167
167
 
168
168
  /**
169
169
  * Listen to any property change on an instance.
170
170
  */
171
- onChange<TInstance extends Schema>(
171
+ onChange<TInstance extends object>(
172
172
  instance: TInstance,
173
173
  handler: InstanceChangeCallback
174
174
  ): () => void;
@@ -184,7 +184,7 @@ export class StateCallbackStrategy<TState extends IRef> {
184
184
  /**
185
185
  * Listen to item changes in a nested collection.
186
186
  */
187
- onChange<TInstance extends Schema, K extends CollectionPropNames<TInstance>>(
187
+ onChange<TInstance extends object, K extends CollectionPropNames<TInstance>>(
188
188
  instance: TInstance,
189
189
  property: K,
190
190
  handler: KeyValueCallback<CollectionKeyType<TInstance, K>, CollectionValueType<TInstance, K>>
@@ -195,7 +195,7 @@ export class StateCallbackStrategy<TState extends IRef> {
195
195
  // onChange(instance, handler) - instance change
196
196
  const instance = args[0] as Schema;
197
197
  const handler = args[1] as InstanceChangeCallback;
198
- return this.addCallback(instance[$refId], OPERATION.REPLACE, handler);
198
+ return this.addCallback(instance[$refId]!, OPERATION.REPLACE, handler);
199
199
  }
200
200
 
201
201
  if (typeof args[0] === 'string') {
@@ -229,7 +229,7 @@ export class StateCallbackStrategy<TState extends IRef> {
229
229
  /**
230
230
  * Listen to items added to a nested collection.
231
231
  */
232
- onAdd<TInstance extends Schema, K extends CollectionPropNames<TInstance>>(
232
+ onAdd<TInstance, K extends CollectionPropNames<TInstance>>(
233
233
  instance: TInstance,
234
234
  property: K,
235
235
  handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>,
@@ -269,7 +269,7 @@ export class StateCallbackStrategy<TState extends IRef> {
269
269
  /**
270
270
  * Listen to items removed from a nested collection.
271
271
  */
272
- onRemove<TInstance extends Schema, K extends CollectionPropNames<TInstance>>(
272
+ onRemove<TInstance, K extends CollectionPropNames<TInstance>>(
273
273
  instance: TInstance,
274
274
  property: K,
275
275
  handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>
@@ -299,7 +299,7 @@ export class StateCallbackStrategy<TState extends IRef> {
299
299
  * Bind properties from a Schema instance to a target object.
300
300
  * Changes will be automatically reflected on the target object.
301
301
  */
302
- bindTo<TInstance extends Schema, TTarget>(
302
+ bindTo<TInstance, TTarget>(
303
303
  from: TInstance,
304
304
  to: TTarget,
305
305
  properties?: string[],
@@ -327,7 +327,7 @@ export class StateCallbackStrategy<TState extends IRef> {
327
327
  action();
328
328
  }
329
329
 
330
- return this.addCallback(from[$refId], OPERATION.REPLACE, action);
330
+ return this.addCallback((from as IRef)[$refId]!, OPERATION.REPLACE, action);
331
331
  }
332
332
 
333
333
  protected triggerChanges(allChanges: DataChange[]): void {
@@ -350,7 +350,7 @@ export class StateCallbackStrategy<TState extends IRef> {
350
350
  (change.op & OPERATION.DELETE) === OPERATION.DELETE &&
351
351
  Schema.isSchema(change.previousValue)
352
352
  ) {
353
- const childRefId = (change.previousValue as Ref)[$refId];
353
+ const childRefId = (change.previousValue as Ref)[$refId]!;
354
354
  const deleteCallbacks = this.callbacks[childRefId]?.[OPERATION.DELETE];
355
355
  if (deleteCallbacks) {
356
356
  for (let j = deleteCallbacks.length - 1; j >= 0; j--) {
@@ -518,8 +518,10 @@ export const Callbacks = {
518
518
  return getDecoderStateCallbacks(roomOrDecoder);
519
519
 
520
520
  } else if ('decoder' in roomOrDecoder.serializer) {
521
- return getDecoderStateCallbacks(roomOrDecoder.serializer.decoder);
521
+ return getDecoderStateCallbacks((roomOrDecoder.serializer as { decoder: Decoder<T> }).decoder);
522
522
  }
523
+
524
+ throw new Error('Invalid room or decoder');
523
525
  },
524
526
 
525
527
  getRawChanges(decoder: Decoder, callback: (changes: DataChange[]) => void) {
@@ -190,8 +190,26 @@ export class Encoder<T extends Schema = any> {
190
190
  ) {
191
191
  const viewOffset = it.offset;
192
192
 
193
- // encode visibility changes (add/remove for this view)
194
- for (const [refId, changes] of view.changes) {
193
+ //
194
+ // Iterate `view.changes` in topological order so a refId is never
195
+ // SWITCH_TO_STRUCTURE'd before an earlier op has introduced it on
196
+ // the decoder. Map insertion order alone isn't sufficient: a
197
+ // sequence like view.remove(child) → view.add(child) on a child
198
+ // whose ancestor wasn't yet visible can put the child entry into
199
+ // the Map before its newly-visible ancestor.
200
+ //
201
+ // Hot-path optimization: `view.add` preserves topo order by
202
+ // construction (addParentOf walks deepest-ancestor-first before
203
+ // touching the obj's own entry). Only `view.remove` can leave the
204
+ // Map dirty. `StateView.changesOutOfOrder` tracks this so most
205
+ // encodes can iterate `view.changes` directly, paying nothing.
206
+ //
207
+ const orderedRefIds: Iterable<number> = view.changesOutOfOrder
208
+ ? this.topoOrderViewChanges(view)
209
+ : view.changes.keys();
210
+
211
+ for (const refId of orderedRefIds) {
212
+ const changes = view.changes.get(refId);
195
213
  const changeTree: ChangeTree = this.root.changeTrees[refId];
196
214
 
197
215
  if (changeTree === undefined) {
@@ -235,6 +253,7 @@ export class Encoder<T extends Schema = any> {
235
253
  //
236
254
  // clear "view" changes after encoding
237
255
  view.changes.clear();
256
+ view.changesOutOfOrder = false;
238
257
 
239
258
  // try to encode "filtered" changes
240
259
  this.encode(it, view, bytes, "filteredChanges", false, viewOffset);
@@ -245,6 +264,52 @@ export class Encoder<T extends Schema = any> {
245
264
  );
246
265
  }
247
266
 
267
+ /**
268
+ * Produce a topological ordering of `view.changes` keys so each refId
269
+ * is preceded by any ancestor that's also in the same view's changeset.
270
+ *
271
+ * The wire stream uses SWITCH_TO_STRUCTURE pointers; if a child is
272
+ * encoded before any earlier op has introduced its refId on the
273
+ * decoder, decode fails with "refId not found". An entry's refId can
274
+ * only be introduced by an ADD on one of its ancestors — so any
275
+ * ancestor that itself appears in this view's pending changes must
276
+ * be encoded first.
277
+ *
278
+ * Implementation: DFS post-order over the parent chain. The `visited`
279
+ * Set guards against duplicates; cycles are not expected in a
280
+ * well-formed parent chain but the visited check is a cheap safety
281
+ * net. Cost is O(n × d) for n entries with parent-chain depth d.
282
+ */
283
+ protected topoOrderViewChanges(view: StateView): number[] {
284
+ const result: number[] = [];
285
+ const visited = new Set<number>();
286
+
287
+ const visit = (refId: number) => {
288
+ if (visited.has(refId)) { return; }
289
+ visited.add(refId);
290
+
291
+ const changeTree = this.root.changeTrees[refId];
292
+ if (changeTree !== undefined) {
293
+ let chain = changeTree.parentChain;
294
+ while (chain) {
295
+ const parentRefId = chain.ref[$refId];
296
+ if (parentRefId !== undefined && view.changes.has(parentRefId)) {
297
+ visit(parentRefId);
298
+ }
299
+ chain = chain.next;
300
+ }
301
+ }
302
+
303
+ result.push(refId);
304
+ };
305
+
306
+ for (const refId of view.changes.keys()) {
307
+ visit(refId);
308
+ }
309
+
310
+ return result;
311
+ }
312
+
248
313
  discardChanges() {
249
314
  // discard shared changes
250
315
  let current = this.root.changes.next;
@@ -35,12 +35,47 @@ export class StateView {
35
35
  */
36
36
  changes = new Map<number, IndexedOperations>();
37
37
 
38
+ /**
39
+ * Set when an operation may have left `changes` out of topological
40
+ * order (a parent that needs to be encoded before its descendants is
41
+ * positioned after them in the Map). `Encoder.encodeView` consults
42
+ * this flag and only runs the topo-ordering pass when it's true,
43
+ * skipping the work in the common case where insertion order already
44
+ * coincides with topo order.
45
+ *
46
+ * Only `remove()` can break the invariant: it writes entries that
47
+ * bypass `addParentOf`'s deepest-ancestor-first ordering. Everything
48
+ * else (including multi-parent re-adds) preserves order by
49
+ * construction. Reset to false at the end of each encodeView pass
50
+ * (when `changes` is cleared).
51
+ */
52
+ changesOutOfOrder: boolean = false;
53
+
38
54
  constructor(public iterable: boolean = false) {
39
55
  if (iterable) {
40
56
  this.items = [];
41
57
  }
42
58
  }
43
59
 
60
+ /**
61
+ * Get the IndexedOperations entry for `refId`, creating one if missing.
62
+ *
63
+ * Map insertion order alone doesn't guarantee parent-before-child
64
+ * iteration in all cases (a `view.remove()` followed by `view.add()`
65
+ * can put a child entry into the Map before its newly-visible
66
+ * ancestor). The wire-order invariant (parent SWITCH_TO_STRUCTURE
67
+ * before any of its children's) is enforced at encode time by
68
+ * `Encoder.encodeView` via a topological pass over `view.changes`.
69
+ */
70
+ protected touchChanges(refId: number): IndexedOperations {
71
+ let entry = this.changes.get(refId);
72
+ if (entry === undefined) {
73
+ entry = {};
74
+ this.changes.set(refId, entry);
75
+ }
76
+ return entry;
77
+ }
78
+
44
79
  // TODO: allow to set multiple tags at once
45
80
  add(obj: Ref, tag: number = DEFAULT_VIEW_TAG, checkIncludeParent: boolean = true) {
46
81
  const changeTree: ChangeTree = obj?.[$changes];
@@ -82,12 +117,8 @@ export class StateView {
82
117
  this.addParentOf(changeTree, tag);
83
118
  }
84
119
 
85
- let changes = this.changes.get(obj[$refId]);
86
- if (changes === undefined) {
87
- changes = {};
88
- // FIXME / OPTIMIZE: do not add if no changes are needed
89
- this.changes.set(obj[$refId], changes);
90
- }
120
+ // FIXME / OPTIMIZE: do not add if no changes are needed
121
+ const changes = this.touchChanges(obj[$refId]);
91
122
 
92
123
  let isChildAdded = false;
93
124
 
@@ -182,11 +213,7 @@ export class StateView {
182
213
 
183
214
  // add parent's tag properties
184
215
  if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
185
- let changes = this.changes.get(changeTree.ref[$refId]);
186
- if (changes === undefined) {
187
- changes = {};
188
- this.changes.set(changeTree.ref[$refId], changes);
189
- }
216
+ const changes = this.touchChanges(changeTree.ref[$refId]);
190
217
 
191
218
  if (!this.tags) {
192
219
  this.tags = new WeakMap<ChangeTree, Set<number>>();
@@ -214,6 +241,10 @@ export class StateView {
214
241
  return this;
215
242
  }
216
243
 
244
+ // remove() bypasses addParentOf's ordering guarantee — flag the
245
+ // changeset as potentially out of topological order.
246
+ this.changesOutOfOrder = true;
247
+
217
248
  this.visible.delete(changeTree);
218
249
 
219
250
  // remove from iterable list
@@ -229,23 +260,13 @@ export class StateView {
229
260
 
230
261
  const refId = ref[$refId];
231
262
 
232
- let changes = this.changes.get(refId);
233
- if (changes === undefined) {
234
- changes = {};
235
- this.changes.set(refId, changes);
236
- }
237
-
238
263
  if (tag === DEFAULT_VIEW_TAG) {
239
264
  // parent is collection (Map/Array)
240
265
  const parent = changeTree.parent;
241
266
  if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) {
242
- const parentRefId = parent[$refId];
243
- let changes = this.changes.get(parentRefId);
244
- if (changes === undefined) {
245
- changes = {};
246
- this.changes.set(parentRefId, changes);
267
+ const parentChanges = this.touchChanges(parent[$refId]);
247
268
 
248
- } else if (changes[changeTree.parentIndex] === OPERATION.ADD) {
269
+ if (parentChanges[changeTree.parentIndex] === OPERATION.ADD) {
249
270
  //
250
271
  // SAME PATCH ADD + REMOVE:
251
272
  // The 'changes' of deleted structure should be ignored.
@@ -254,13 +275,14 @@ export class StateView {
254
275
  }
255
276
 
256
277
  // DELETE / DELETE BY REF ID
257
- changes[changeTree.parentIndex] = OPERATION.DELETE;
278
+ parentChanges[changeTree.parentIndex] = OPERATION.DELETE;
258
279
 
259
280
  // Remove child schema from visible set
260
281
  this._recursiveDeleteVisibleChangeTree(changeTree);
261
282
 
262
283
  } else {
263
284
  // delete all "tagged" properties.
285
+ const changes = this.touchChanges(refId);
264
286
  metadata?.[$viewFieldIndexes]?.forEach((index) => {
265
287
  changes[index] = OPERATION.DELETE;
266
288
 
@@ -276,6 +298,7 @@ export class StateView {
276
298
 
277
299
  } else {
278
300
  // delete only tagged properties
301
+ const changes = this.touchChanges(refId);
279
302
  metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
280
303
  changes[index] = OPERATION.DELETE;
281
304