@colyseus/schema 3.0.42 → 3.0.43

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 (45) hide show
  1. package/build/cjs/index.js +327 -185
  2. package/build/cjs/index.js.map +1 -1
  3. package/build/esm/index.mjs +327 -185
  4. package/build/esm/index.mjs.map +1 -1
  5. package/build/umd/index.js +327 -185
  6. package/lib/Schema.d.ts +2 -1
  7. package/lib/Schema.js +21 -3
  8. package/lib/Schema.js.map +1 -1
  9. package/lib/bench_encode.d.ts +1 -0
  10. package/lib/bench_encode.js +130 -0
  11. package/lib/bench_encode.js.map +1 -0
  12. package/lib/debug.d.ts +1 -0
  13. package/lib/debug.js +51 -0
  14. package/lib/debug.js.map +1 -0
  15. package/lib/decoder/Decoder.js +7 -8
  16. package/lib/decoder/Decoder.js.map +1 -1
  17. package/lib/encoder/ChangeTree.d.ts +57 -7
  18. package/lib/encoder/ChangeTree.js +171 -106
  19. package/lib/encoder/ChangeTree.js.map +1 -1
  20. package/lib/encoder/Encoder.js +19 -20
  21. package/lib/encoder/Encoder.js.map +1 -1
  22. package/lib/encoder/Root.d.ts +8 -7
  23. package/lib/encoder/Root.js +81 -26
  24. package/lib/encoder/Root.js.map +1 -1
  25. package/lib/types/custom/ArraySchema.js +5 -3
  26. package/lib/types/custom/ArraySchema.js.map +1 -1
  27. package/lib/types/custom/MapSchema.js +7 -2
  28. package/lib/types/custom/MapSchema.js.map +1 -1
  29. package/lib/types/symbols.d.ts +14 -14
  30. package/lib/types/symbols.js +14 -14
  31. package/lib/types/symbols.js.map +1 -1
  32. package/lib/utils.js +7 -3
  33. package/lib/utils.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/Schema.ts +21 -5
  36. package/src/bench_encode.ts +108 -0
  37. package/src/debug.ts +55 -0
  38. package/src/decoder/Decoder.ts +8 -12
  39. package/src/encoder/ChangeTree.ts +201 -115
  40. package/src/encoder/Encoder.ts +21 -19
  41. package/src/encoder/Root.ts +87 -28
  42. package/src/types/custom/ArraySchema.ts +6 -4
  43. package/src/types/custom/MapSchema.ts +8 -2
  44. package/src/types/symbols.ts +14 -14
  45. package/src/utils.ts +9 -3
@@ -0,0 +1,108 @@
1
+ import { nanoid } from "nanoid";
2
+ import { Schema, type, MapSchema, ArraySchema, Encoder } from ".";
3
+
4
+ class Attribute extends Schema {
5
+ @type("string") name: string;
6
+ @type("number") value: number;
7
+ }
8
+
9
+ class Item extends Schema {
10
+ @type("number") price: number;
11
+ @type([ Attribute ]) attributes = new ArraySchema<Attribute>();
12
+ }
13
+
14
+ class Position extends Schema {
15
+ @type("number") x: number;
16
+ @type("number") y: number;
17
+ }
18
+
19
+ class Player extends Schema {
20
+ @type(Position) position = new Position();
21
+ @type({ map: Item }) items = new MapSchema<Item>();
22
+ }
23
+
24
+ class State extends Schema {
25
+ @type({ map: Player }) players = new MapSchema<Player>();
26
+ @type("string") currentTurn;
27
+ }
28
+
29
+ const state = new State();
30
+
31
+ Encoder.BUFFER_SIZE = 4096 * 4096;
32
+ const encoder = new Encoder(state);
33
+
34
+
35
+ let now = Date.now();
36
+
37
+ // for (let i = 0; i < 10000; i++) {
38
+ // const player = new Player();
39
+ // state.players.set(`p-${nanoid()}`, player);
40
+ //
41
+ // player.position.x = (i + 1) * 100;
42
+ // player.position.y = (i + 1) * 100;
43
+ // for (let j = 0; j < 10; j++) {
44
+ // const item = new Item();
45
+ // player.items.set(`item-${j}`, item);
46
+ // item.price = (i + 1) * 50;
47
+ // for (let k = 0; k < 5; k++) {
48
+ // const attr = new Attribute();
49
+ // attr.name = `Attribute ${k}`;
50
+ // attr.value = k;
51
+ // item.attributes.push(attr);
52
+ // }
53
+ // }
54
+ // }
55
+ // console.log("time to make changes:", Date.now() - now);
56
+
57
+
58
+ // measure time to .encodeAll()
59
+
60
+ now = Date.now();
61
+ // for (let i = 0; i < 1000; i++) {
62
+ // encoder.encodeAll();
63
+ // }
64
+ // console.log(Date.now() - now);
65
+
66
+ const total = 100;
67
+ const allEncodes = Date.now();
68
+
69
+ let avgTimeToEncode = 0;
70
+ let avgTimeToMakeChanges = 0;
71
+
72
+ for (let i = 0; i < total; i++) {
73
+ now = Date.now();
74
+ for (let j = 0; j < 50; j++) {
75
+ const player = new Player();
76
+ state.players.set(`p-${nanoid()}`, player);
77
+
78
+ player.position.x = (j + 1) * 100;
79
+ player.position.y = (j + 1) * 100;
80
+ for (let k = 0; k < 10; k++) {
81
+ const item = new Item();
82
+ item.price = (j + 1) * 50;
83
+ for (let l = 0; l < 5; l++) {
84
+ const attr = new Attribute();
85
+ attr.name = `Attribute ${l}`;
86
+ attr.value = l;
87
+ item.attributes.push(attr);
88
+ }
89
+ player.items.set(`item-${k}`, item);
90
+ }
91
+ }
92
+ const timeToMakeChanges = Date.now() - now;
93
+ console.log("time to make changes:", timeToMakeChanges);
94
+ avgTimeToMakeChanges += timeToMakeChanges;
95
+
96
+ now = Date.now();
97
+ encoder.encode();
98
+ encoder.discardChanges();
99
+
100
+ const timeToEncode = Date.now() - now;
101
+ console.log("time to encode:", timeToEncode);
102
+ avgTimeToEncode += timeToEncode;
103
+ }
104
+ console.log("avg time to encode:", (avgTimeToEncode) / total);
105
+ console.log("avg time to make changes:", (avgTimeToMakeChanges) / total);
106
+ console.log("time for all encodes:", Date.now() - allEncodes);
107
+
108
+ console.log(Array.from(encoder.encodeAll()).length, "bytes");
package/src/debug.ts ADDED
@@ -0,0 +1,55 @@
1
+ import * as fs from "fs";
2
+ import { Reflection, Decoder } from "./index";
3
+
4
+ const contents = fs.readFileSync("/Users/endel/Projects/colyseus/clients/bubbits/project/@bubbits/backend/schema-debug.txt", { encoding: "utf8" }).toString();
5
+
6
+ let isCommentBlock = false;
7
+ let lastComment = "";
8
+
9
+ let decoder: Decoder;
10
+
11
+ function getBuffer(line: string) {
12
+ const start = line.lastIndexOf(":");
13
+ const buffer = Buffer.from(new Uint8Array(line.substring(start + 1).split(",").map(n => Number(n))));
14
+ console.log(`(${buffer.byteLength}) ${Array.from(buffer).join(",")}`)
15
+ // console.log("");
16
+ // console.log("");
17
+ // console.log("> ", line);
18
+ // console.log("> substring:", line.substring(start + 1))
19
+ return buffer;
20
+ }
21
+
22
+ function decode(buffer: Buffer) {
23
+ try {
24
+ decoder.decode(buffer);
25
+ } catch (e) {
26
+ console.error(e);
27
+ console.log("Last log:\n\n")
28
+ console.log(lastComment);
29
+ }
30
+ }
31
+
32
+ contents.split("\n").forEach((line) => {
33
+ if (line.startsWith("#")) {
34
+ // reset last comment.
35
+ if (isCommentBlock === false) { lastComment = ""; }
36
+
37
+ isCommentBlock = true;
38
+ lastComment += line.substring(line.indexOf(":") + 1) + "\n";
39
+ return;
40
+ }
41
+
42
+ isCommentBlock = false;
43
+
44
+ if (line.startsWith("handshake:") && !decoder) {
45
+ decoder = Reflection.decode(getBuffer(line));
46
+
47
+ } else if (line.startsWith("state:")) {
48
+ decode(getBuffer(line));
49
+
50
+ } else if (line.startsWith("patch:")) {
51
+ decode(getBuffer(line));
52
+ }
53
+ });
54
+
55
+ console.log(decoder.state.toJSON());
@@ -58,6 +58,8 @@ export class Decoder<T extends Schema = any> {
58
58
  if (bytes[it.offset] == SWITCH_TO_STRUCTURE) {
59
59
  it.offset++;
60
60
 
61
+ ref[$onDecodeEnd]?.()
62
+
61
63
  const nextRefId = decode.number(bytes, it);
62
64
  const nextRef = $root.refs.get(nextRefId);
63
65
 
@@ -65,17 +67,17 @@ export class Decoder<T extends Schema = any> {
65
67
  // Trying to access a reference that haven't been decoded yet.
66
68
  //
67
69
  if (!nextRef) {
70
+ // throw new Error(`"refId" not found: ${nextRefId}`);
68
71
  console.error(`"refId" not found: ${nextRefId}`, { previousRef: ref, previousRefId: this.currentRefId });
69
72
  console.warn("Please report this to the developers. All refIds =>");
70
73
  console.warn(Schema.debugRefIdsDecoder(this));
71
74
  this.skipCurrentStructure(bytes, it, totalBytes);
72
- }
73
- ref[$onDecodeEnd]?.()
74
75
 
75
- this.currentRefId = nextRefId;
76
-
77
- ref = nextRef;
78
- decoder = ref.constructor[$decoder];
76
+ } else {
77
+ ref = nextRef;
78
+ decoder = ref.constructor[$decoder];
79
+ this.currentRefId = nextRefId;
80
+ }
79
81
 
80
82
  continue;
81
83
  }
@@ -131,12 +133,6 @@ export class Decoder<T extends Schema = any> {
131
133
  }
132
134
 
133
135
  createInstanceOfType (type: typeof Schema): Schema {
134
- // let instance: Schema = new (type as any)();
135
-
136
- // // assign root on $changes
137
- // instance[$changes].root = this.root[$changes].root;
138
-
139
- // return instance;
140
136
  return new (type as any)();
141
137
  }
142
138
 
@@ -36,17 +36,53 @@ export interface IndexedOperations {
36
36
  [index: number]: OPERATION;
37
37
  }
38
38
 
39
+ // Linked list node for change trees
40
+ export interface ChangeTreeNode {
41
+ changeTree: ChangeTree;
42
+ next?: ChangeTreeNode;
43
+ prev?: ChangeTreeNode;
44
+ }
45
+
46
+ // Linked list for change trees
47
+ export interface ChangeTreeList {
48
+ next?: ChangeTreeNode;
49
+ tail?: ChangeTreeNode;
50
+ length: number;
51
+ }
52
+
39
53
  export interface ChangeSet {
40
54
  // field index -> operation index
41
55
  indexes: { [index: number]: number };
42
56
  operations: number[];
43
- queueRootIndex?: number; // index of ChangeTree structure in `root.changes` or `root.filteredChanges`
57
+ queueRootNode?: ChangeTreeNode; // direct reference to ChangeTreeNode in the linked list
44
58
  }
45
59
 
46
60
  function createChangeSet(): ChangeSet {
47
61
  return { indexes: {}, operations: [] };
48
62
  }
49
63
 
64
+ // Linked list helper functions
65
+ export function createChangeTreeList(): ChangeTreeList {
66
+ return { next: undefined, tail: undefined, length: 0 };
67
+ }
68
+
69
+ export function addToChangeTreeList(list: ChangeTreeList, changeTree: ChangeTree): ChangeTreeNode {
70
+ const node: ChangeTreeNode = { changeTree, next: undefined, prev: undefined };
71
+
72
+ if (!list.next) {
73
+ list.next = node;
74
+ list.tail = node;
75
+ } else {
76
+ node.prev = list.tail;
77
+ list.tail!.next = node;
78
+ list.tail = node;
79
+ }
80
+
81
+ list.length++;
82
+
83
+ return node;
84
+ }
85
+
50
86
  export function setOperationAtIndex(changeSet: ChangeSet, index: number) {
51
87
  const operationsIndex = changeSet.indexes[index];
52
88
  if (operationsIndex === undefined) {
@@ -96,25 +132,32 @@ export function debugChangeSet(label: string, changeSet: ChangeSet) {
96
132
  export function enqueueChangeTree(
97
133
  root: Root,
98
134
  changeTree: ChangeTree,
99
- changeSet: 'changes' | 'filteredChanges' | 'allFilteredChanges',
100
- queueRootIndex = changeTree[changeSet].queueRootIndex
135
+ changeSet: 'changes' | 'filteredChanges' | 'allFilteredChanges' | 'allChanges',
136
+ queueRootNode = changeTree[changeSet].queueRootNode
101
137
  ) {
102
- if (!root) {
103
- // skip
104
- return;
138
+ // skip
139
+ if (!root) { return; }
105
140
 
106
- } else if (root[changeSet][queueRootIndex] !== changeTree) {
107
- changeTree[changeSet].queueRootIndex = root[changeSet].push(changeTree) - 1;
141
+ if (queueRootNode) {
142
+ } else {
143
+ // Add to linked list if not already present
144
+ changeTree[changeSet].queueRootNode = addToChangeTreeList(root[changeSet], changeTree);
108
145
  }
109
146
  }
110
147
 
148
+ export interface ParentChain {
149
+ ref: Ref;
150
+ index: number;
151
+ next?: ParentChain;
152
+ }
153
+
111
154
  export class ChangeTree<T extends Ref=any> {
112
155
  ref: T;
113
156
  refId: number;
157
+ metadata: Metadata;
114
158
 
115
159
  root?: Root;
116
- parent?: Ref;
117
- parentIndex?: number;
160
+ parentChain?: ParentChain; // Linked list for tracking parents
118
161
 
119
162
  /**
120
163
  * Whether this structure is parent of a filtered structure.
@@ -145,12 +188,12 @@ export class ChangeTree<T extends Ref=any> {
145
188
 
146
189
  constructor(ref: T) {
147
190
  this.ref = ref;
191
+ this.metadata = ref.constructor[Symbol.metadata];
148
192
 
149
193
  //
150
194
  // Does this structure have "filters" declared?
151
195
  //
152
- const metadata = ref.constructor[Symbol.metadata];
153
- if (metadata?.[$viewFieldIndexes]) {
196
+ if (this.metadata?.[$viewFieldIndexes]) {
154
197
  this.allFilteredChanges = { indexes: {}, operations: [] };
155
198
  this.filteredChanges = { indexes: {}, operations: [] };
156
199
  }
@@ -158,35 +201,18 @@ export class ChangeTree<T extends Ref=any> {
158
201
 
159
202
  setRoot(root: Root) {
160
203
  this.root = root;
161
- this.checkIsFiltered(this.parent, this.parentIndex);
162
204
 
163
- //
164
- // TODO: refactor and possibly unify .setRoot() and .setParent()
165
- //
205
+ const isNewChangeTree = this.root.add(this);
166
206
 
167
- // Recursively set root on child structures
168
- const metadata: Metadata = this.ref.constructor[Symbol.metadata];
169
- if (metadata) {
170
- metadata[$refTypeFieldIndexes]?.forEach((index) => {
171
- const field = metadata[index as any as number];
172
- const changeTree: ChangeTree = this.ref[field.name]?.[$changes];
173
- if (changeTree) {
174
- if (changeTree.root !== root) {
175
- changeTree.setRoot(root);
176
- } else {
177
- root.add(changeTree); // increment refCount
178
- }
179
- }
180
- });
207
+ this.checkIsFiltered(this.parent, this.parentIndex, isNewChangeTree);
181
208
 
182
- } else if (this.ref[$childType] && typeof(this.ref[$childType]) !== "string") {
183
- // MapSchema / ArraySchema, etc.
184
- (this.ref as MapSchema).forEach((value, key) => {
185
- const changeTree: ChangeTree = value[$changes];
186
- if (changeTree.root !== root) {
187
- changeTree.setRoot(root);
209
+ // Recursively set root on child structures
210
+ if (isNewChangeTree) {
211
+ this.forEachChild((child, _) => {
212
+ if (child.root !== root) {
213
+ child.setRoot(root);
188
214
  } else {
189
- root.add(changeTree); // increment refCount
215
+ root.add(child); // increment refCount
190
216
  }
191
217
  });
192
218
  }
@@ -197,63 +223,57 @@ export class ChangeTree<T extends Ref=any> {
197
223
  root?: Root,
198
224
  parentIndex?: number,
199
225
  ) {
200
- this.parent = parent;
201
- this.parentIndex = parentIndex;
226
+ this.addParent(parent, parentIndex);
202
227
 
203
228
  // avoid setting parents with empty `root`
204
229
  if (!root) { return; }
205
230
 
231
+ const isNewChangeTree = root.add(this);
232
+
206
233
  // skip if parent is already set
207
234
  if (root !== this.root) {
208
235
  this.root = root;
209
- this.checkIsFiltered(parent, parentIndex);
210
-
211
- } else {
212
- root.add(this);
236
+ this.checkIsFiltered(parent, parentIndex, isNewChangeTree);
213
237
  }
214
238
 
215
239
  // assign same parent on child structures
216
- const metadata: Metadata = this.ref.constructor[Symbol.metadata];
217
- if (metadata) {
218
- metadata[$refTypeFieldIndexes]?.forEach((index) => {
219
- const field = metadata[index as any as number];
220
- const changeTree: ChangeTree = this.ref[field.name]?.[$changes];
221
- if (changeTree && changeTree.root !== root) {
222
- changeTree.setParent(this.ref, root, index);
223
- }
224
- });
225
-
226
- } else if (this.ref[$childType] && typeof(this.ref[$childType]) !== "string") {
227
- // MapSchema / ArraySchema, etc.
228
- (this.ref as MapSchema).forEach((value, key) => {
229
- const changeTree: ChangeTree = value[$changes];
230
- if (changeTree.root !== root) {
231
- changeTree.setParent(this.ref, root, this.indexes[key] ?? key);
240
+ if (isNewChangeTree) {
241
+ //
242
+ // assign same parent on child structures
243
+ //
244
+ this.forEachChild((child, index) => {
245
+ if (child.root === root) {
246
+ //
247
+ // re-assigning a child of the same root, move it to the end
248
+ // of the changes queue so encoding order is preserved
249
+ //
250
+ root.moveToEndOfChanges(child);
251
+ return;
232
252
  }
253
+ child.setParent(this.ref, root, index);
233
254
  });
234
255
  }
235
-
236
256
  }
237
257
 
238
- forEachChild(callback: (change: ChangeTree, atIndex: number) => void) {
258
+ forEachChild(callback: (change: ChangeTree, at: any) => void) {
239
259
  //
240
260
  // assign same parent on child structures
241
261
  //
242
- const metadata: Metadata = this.ref.constructor[Symbol.metadata];
243
- if (metadata) {
244
- metadata[$refTypeFieldIndexes]?.forEach((index) => {
245
- const field = metadata[index as any as number];
246
- const value = this.ref[field.name];
247
- if (value) {
248
- callback(value[$changes], index);
249
- }
250
- });
262
+ if (this.ref[$childType]) {
263
+ if (typeof(this.ref[$childType]) !== "string") {
264
+ // MapSchema / ArraySchema, etc.
265
+ for (const [key, value] of (this.ref as MapSchema).entries()) {
266
+ callback(value[$changes], key);
267
+ };
268
+ }
251
269
 
252
- } else if (this.ref[$childType] && typeof(this.ref[$childType]) !== "string") {
253
- // MapSchema / ArraySchema, etc.
254
- (this.ref as MapSchema).forEach((value, key) => {
255
- callback(value[$changes], this.indexes[key] ?? key);
256
- });
270
+ } else {
271
+ for (const index of this.metadata?.[$refTypeFieldIndexes] ?? []) {
272
+ const field = this.metadata[index as any as number];
273
+ const value = this.ref[field.name];
274
+ if (!value) { continue; }
275
+ callback(value[$changes], index);
276
+ }
257
277
  }
258
278
  }
259
279
 
@@ -271,9 +291,7 @@ export class ChangeTree<T extends Ref=any> {
271
291
  }
272
292
 
273
293
  change(index: number, operation: OPERATION = OPERATION.ADD) {
274
- const metadata = this.ref.constructor[Symbol.metadata] as Metadata;
275
-
276
- const isFiltered = this.isFiltered || (metadata?.[index]?.tag !== undefined);
294
+ const isFiltered = this.isFiltered || (this.metadata?.[index]?.tag !== undefined);
277
295
  const changeSet = (isFiltered)
278
296
  ? this.filteredChanges
279
297
  : this.changes;
@@ -376,19 +394,16 @@ export class ChangeTree<T extends Ref=any> {
376
394
  }
377
395
 
378
396
  getType(index?: number) {
379
- if (Metadata.isValidInstance(this.ref)) {
380
- const metadata = this.ref.constructor[Symbol.metadata] as Metadata;
381
- return metadata[index].type;
382
-
383
- } else {
397
+ return (
384
398
  //
385
399
  // Get the child type from parent structure.
386
400
  // - ["string"] => "string"
387
401
  // - { map: "string" } => "string"
388
402
  // - { set: "string" } => "string"
389
403
  //
390
- return this.ref[$childType];
391
- }
404
+ this.ref[$childType] || // ArraySchema | MapSchema | SetSchema | CollectionSchema
405
+ this.metadata[index].type // Schema
406
+ );
392
407
  }
393
408
 
394
409
  getChange(index: number) {
@@ -458,9 +473,7 @@ export class ChangeTree<T extends Ref=any> {
458
473
  this.indexedOperations = {};
459
474
 
460
475
  // clear changeset
461
- this[changeSetName].indexes = {};
462
- this[changeSetName].operations.length = 0;
463
- this[changeSetName].queueRootIndex = undefined;
476
+ this[changeSetName] = createChangeSet();
464
477
 
465
478
  // ArraySchema and MapSchema have a custom "encode end" method
466
479
  this.ref[$onEncodeEnd]?.();
@@ -478,24 +491,17 @@ export class ChangeTree<T extends Ref=any> {
478
491
  this.ref[$onEncodeEnd]?.();
479
492
 
480
493
  this.indexedOperations = {};
481
-
482
- this.changes.indexes = {};
483
- this.changes.operations.length = 0;
484
- this.changes.queueRootIndex = undefined;
494
+ this.changes = createChangeSet();
485
495
 
486
496
  if (this.filteredChanges !== undefined) {
487
- this.filteredChanges.indexes = {};
488
- this.filteredChanges.operations.length = 0;
489
- this.filteredChanges.queueRootIndex = undefined;
497
+ this.filteredChanges = createChangeSet();
490
498
  }
491
499
 
492
500
  if (discardAll) {
493
- this.allChanges.indexes = {};
494
- this.allChanges.operations.length = 0;
501
+ this.allChanges = createChangeSet();
495
502
 
496
503
  if (this.allFilteredChanges !== undefined) {
497
- this.allFilteredChanges.indexes = {};
498
- this.allFilteredChanges.operations.length = 0;
504
+ this.allFilteredChanges = createChangeSet();
499
505
  }
500
506
  }
501
507
  }
@@ -517,22 +523,11 @@ export class ChangeTree<T extends Ref=any> {
517
523
  this.discard();
518
524
  }
519
525
 
520
- ensureRefId() {
521
- // skip if refId is already set.
522
- if (this.refId !== undefined) {
523
- return;
524
- }
525
-
526
- this.refId = this.root.getNextUniqueId();
527
- }
528
-
529
526
  get changed() {
530
527
  return (Object.entries(this.indexedOperations).length > 0);
531
528
  }
532
529
 
533
- protected checkIsFiltered(parent: Ref, parentIndex: number) {
534
- const isNewChangeTree = this.root.add(this);
535
-
530
+ protected checkIsFiltered(parent: Ref, parentIndex: number, isNewChangeTree: boolean) {
536
531
  if (this.root.types.hasFilters) {
537
532
  //
538
533
  // At Schema initialization, the "root" structure might not be available
@@ -545,7 +540,7 @@ export class ChangeTree<T extends Ref=any> {
545
540
  if (this.filteredChanges !== undefined) {
546
541
  enqueueChangeTree(this.root, this, 'filteredChanges');
547
542
  if (isNewChangeTree) {
548
- this.root.allFilteredChanges.push(this);
543
+ enqueueChangeTree(this.root, this, 'allFilteredChanges');
549
544
  }
550
545
  }
551
546
  }
@@ -553,7 +548,7 @@ export class ChangeTree<T extends Ref=any> {
553
548
  if (!this.isFiltered) {
554
549
  enqueueChangeTree(this.root, this, 'changes');
555
550
  if (isNewChangeTree) {
556
- this.root.allChanges.push(this);
551
+ enqueueChangeTree(this.root, this, 'allChanges');
557
552
  }
558
553
  }
559
554
  }
@@ -568,7 +563,7 @@ export class ChangeTree<T extends Ref=any> {
568
563
  //
569
564
  const refType = Metadata.isValidInstance(this.ref)
570
565
  ? this.ref.constructor
571
- : this.ref[$childType];
566
+ : this.ref[$childType];
572
567
 
573
568
  let parentChangeTree: ChangeTree;
574
569
 
@@ -627,4 +622,95 @@ export class ChangeTree<T extends Ref=any> {
627
622
  }
628
623
  }
629
624
 
630
- }
625
+ /**
626
+ * Get the immediate parent
627
+ */
628
+ get parent(): Ref | undefined {
629
+ return this.parentChain?.ref;
630
+ }
631
+
632
+ /**
633
+ * Get the immediate parent index
634
+ */
635
+ get parentIndex(): number | undefined {
636
+ return this.parentChain?.index;
637
+ }
638
+
639
+ /**
640
+ * Add a parent to the chain
641
+ */
642
+ addParent(parent: Ref, index: number) {
643
+ // Check if this parent already exists in the chain
644
+ if (this.hasParent((p, i) => p === parent && i === index)) {
645
+ return;
646
+ }
647
+
648
+ this.parentChain = {
649
+ ref: parent,
650
+ index,
651
+ next: this.parentChain
652
+ };
653
+ }
654
+
655
+ /**
656
+ * Remove a parent from the chain
657
+ * @param parent - The parent to remove
658
+ * @returns true if parent was removed
659
+ */
660
+ removeParent(parent: Ref): boolean {
661
+ let current = this.parentChain;
662
+ let previous = null;
663
+ while (current) {
664
+ //
665
+ // FIXME: it is required to check against `$changes` here because
666
+ // ArraySchema is instance of Proxy
667
+ //
668
+ if (current.ref[$changes] === parent[$changes]) {
669
+ if (previous) {
670
+ previous.next = current.next;
671
+ } else {
672
+ this.parentChain = current.next;
673
+ }
674
+ return true;
675
+ }
676
+ previous = current;
677
+ current = current.next;
678
+ }
679
+ return this.parentChain === undefined;
680
+ }
681
+
682
+ /**
683
+ * Find a specific parent in the chain
684
+ */
685
+ findParent(predicate: (parent: Ref, index: number) => boolean): ParentChain | undefined {
686
+ let current = this.parentChain;
687
+ while (current) {
688
+ if (predicate(current.ref, current.index)) {
689
+ return current;
690
+ }
691
+ current = current.next;
692
+ }
693
+ return undefined;
694
+ }
695
+
696
+ /**
697
+ * Check if this ChangeTree has a specific parent
698
+ */
699
+ hasParent(predicate: (parent: Ref, index: number) => boolean): boolean {
700
+ return this.findParent(predicate) !== undefined;
701
+ }
702
+
703
+ /**
704
+ * Get all parents as an array (for debugging/testing)
705
+ */
706
+ getAllParents(): Array<{ref: Ref, index: number}> {
707
+ const parents: Array<{ref: Ref, index: number}> = [];
708
+ let current = this.parentChain;
709
+ while (current) {
710
+ parents.push({ref: current.ref, index: current.index});
711
+ current = current.next;
712
+ }
713
+ return parents;
714
+ }
715
+
716
+ }