@woltz/rich-domain 1.2.0 → 1.2.2

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 (143) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/aggregate-changes.d.ts +164 -0
  3. package/dist/aggregate-changes.d.ts.map +1 -0
  4. package/dist/aggregate-changes.js +281 -0
  5. package/dist/aggregate-changes.js.map +1 -0
  6. package/dist/base-entity.d.ts +32 -8
  7. package/dist/base-entity.d.ts.map +1 -1
  8. package/dist/base-entity.js +86 -93
  9. package/dist/base-entity.js.map +1 -1
  10. package/dist/change-tracker.d.ts +97 -0
  11. package/dist/change-tracker.d.ts.map +1 -0
  12. package/dist/change-tracker.js +758 -0
  13. package/dist/change-tracker.js.map +1 -0
  14. package/dist/constants.d.ts +7 -1
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +65 -0
  17. package/dist/constants.js.map +1 -1
  18. package/dist/criteria.d.ts +3 -3
  19. package/dist/criteria.d.ts.map +1 -1
  20. package/dist/criteria.js +6 -4
  21. package/dist/criteria.js.map +1 -1
  22. package/dist/crypto.d.ts +3 -0
  23. package/dist/crypto.d.ts.map +1 -0
  24. package/dist/crypto.js +29 -0
  25. package/dist/crypto.js.map +1 -0
  26. package/dist/domain-event.d.ts.map +1 -1
  27. package/dist/domain-event.js +0 -3
  28. package/dist/domain-event.js.map +1 -1
  29. package/dist/entity-changes.d.ts +84 -0
  30. package/dist/entity-changes.d.ts.map +1 -0
  31. package/dist/entity-changes.js +131 -0
  32. package/dist/entity-changes.js.map +1 -0
  33. package/dist/entity-schema-registry.d.ts +148 -0
  34. package/dist/entity-schema-registry.d.ts.map +1 -0
  35. package/dist/entity-schema-registry.js +213 -0
  36. package/dist/entity-schema-registry.js.map +1 -0
  37. package/dist/entity.d.ts +0 -6
  38. package/dist/entity.d.ts.map +1 -1
  39. package/dist/entity.js +0 -9
  40. package/dist/entity.js.map +1 -1
  41. package/dist/id.d.ts +11 -10
  42. package/dist/id.d.ts.map +1 -1
  43. package/dist/id.js +4 -28
  44. package/dist/id.js.map +1 -1
  45. package/dist/index.d.ts +9 -5
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +8 -11
  48. package/dist/index.js.map +1 -1
  49. package/dist/mapper.d.ts +1 -1
  50. package/dist/mapper.d.ts.map +1 -1
  51. package/dist/mapper.js.map +1 -1
  52. package/dist/paginated-result.d.ts.map +1 -1
  53. package/dist/paginated-result.js +0 -15
  54. package/dist/paginated-result.js.map +1 -1
  55. package/dist/repository/base-repository.d.ts +7 -33
  56. package/dist/repository/base-repository.d.ts.map +1 -1
  57. package/dist/repository/base-repository.js +0 -27
  58. package/dist/repository/base-repository.js.map +1 -1
  59. package/dist/repository/index.d.ts.map +1 -1
  60. package/dist/repository/index.js +0 -6
  61. package/dist/repository/index.js.map +1 -1
  62. package/dist/repository/unit-of-work.d.ts +0 -25
  63. package/dist/repository/unit-of-work.d.ts.map +1 -1
  64. package/dist/repository/unit-of-work.js +0 -28
  65. package/dist/repository/unit-of-work.js.map +1 -1
  66. package/dist/types/change-tracker.d.ts +196 -0
  67. package/dist/types/change-tracker.d.ts.map +1 -0
  68. package/dist/types/change-tracker.js +2 -0
  69. package/dist/types/change-tracker.js.map +1 -0
  70. package/dist/types/criteria.d.ts +5 -1
  71. package/dist/types/criteria.d.ts.map +1 -1
  72. package/dist/types/domain.d.ts +4 -6
  73. package/dist/types/domain.d.ts.map +1 -1
  74. package/dist/types/index.d.ts +1 -1
  75. package/dist/types/index.d.ts.map +1 -1
  76. package/dist/types/index.js +1 -1
  77. package/dist/types/index.js.map +1 -1
  78. package/dist/types/utils.d.ts +0 -1
  79. package/dist/types/utils.d.ts.map +1 -1
  80. package/dist/utils/criteria-operator-validation.d.ts +1 -0
  81. package/dist/utils/criteria-operator-validation.d.ts.map +1 -1
  82. package/dist/utils/criteria-operator-validation.js +39 -17
  83. package/dist/utils/criteria-operator-validation.js.map +1 -1
  84. package/dist/validation-error.d.ts.map +1 -1
  85. package/dist/validation-error.js +1 -6
  86. package/dist/validation-error.js.map +1 -1
  87. package/dist/value-object.d.ts +57 -8
  88. package/dist/value-object.d.ts.map +1 -1
  89. package/dist/value-object.js +49 -22
  90. package/dist/value-object.js.map +1 -1
  91. package/package.json +2 -1
  92. package/src/aggregate-changes.ts +335 -0
  93. package/src/base-entity.ts +102 -109
  94. package/src/change-tracker.ts +1062 -0
  95. package/src/constants.ts +75 -1
  96. package/src/criteria.ts +11 -4
  97. package/src/crypto.ts +31 -0
  98. package/src/domain-event.ts +0 -4
  99. package/src/entity-changes.ts +146 -0
  100. package/src/entity-schema-registry.ts +255 -0
  101. package/src/entity.ts +0 -11
  102. package/src/id.ts +17 -26
  103. package/src/index.ts +15 -19
  104. package/src/mapper.ts +4 -1
  105. package/src/paginated-result.ts +0 -21
  106. package/src/repository/base-repository.ts +7 -38
  107. package/src/repository/index.ts +0 -9
  108. package/src/repository/unit-of-work.ts +0 -29
  109. package/src/types/change-tracker.ts +233 -0
  110. package/src/types/criteria.ts +6 -1
  111. package/src/types/domain.ts +4 -8
  112. package/src/types/index.ts +1 -1
  113. package/src/types/utils.ts +0 -9
  114. package/src/utils/criteria-operator-validation.ts +57 -19
  115. package/src/validation-error.ts +1 -7
  116. package/src/value-object.ts +84 -24
  117. package/tests/aggregate-changes.test.ts +284 -0
  118. package/tests/criteria.test.ts +122 -161
  119. package/tests/entity-equality.test.ts +38 -61
  120. package/tests/entity-schema-registry.test.ts +382 -0
  121. package/tests/entity-validation.test.ts +7 -94
  122. package/tests/history-tracker.spec.ts +349 -617
  123. package/tests/id.test.ts +41 -44
  124. package/tests/load-test/data.json +346041 -0
  125. package/tests/load-test/entities.ts +97 -0
  126. package/tests/load-test/generate-data.ts +81 -0
  127. package/tests/load-test/lead-to-domain.mapper.ts +24 -0
  128. package/tests/load-test/load.test.ts +38 -0
  129. package/tests/repository.test.ts +30 -54
  130. package/tests/to-json.test.ts +14 -18
  131. package/tests/utils.ts +138 -102
  132. package/tests/value-objects.test.ts +57 -29
  133. package/dist/deep-proxy.d.ts +0 -36
  134. package/dist/deep-proxy.d.ts.map +0 -1
  135. package/dist/deep-proxy.js +0 -384
  136. package/dist/deep-proxy.js.map +0 -1
  137. package/dist/types/history-tracker.d.ts +0 -36
  138. package/dist/types/history-tracker.d.ts.map +0 -1
  139. package/dist/types/history-tracker.js +0 -2
  140. package/dist/types/history-tracker.js.map +0 -1
  141. package/src/deep-proxy.ts +0 -447
  142. package/src/types/history-tracker.ts +0 -45
  143. package/tests/entity.test.ts +0 -33
@@ -0,0 +1,1062 @@
1
+ import { Id } from "./id";
2
+ import { Entity } from "./entity";
3
+ import { ValueObject } from "./value-object";
4
+ import { ArrayState, HistoryEntry, TrackedItem } from "./types";
5
+ import { EntityChangeState } from "./types/change-tracker";
6
+ import { AggregateChanges } from "./aggregate-changes";
7
+
8
+ /**
9
+ * Callback for validation on property change.
10
+ * Return false to reject the change, or throw an error.
11
+ */
12
+ export type OnChangeValidator = (
13
+ path: string,
14
+ oldValue: any,
15
+ newValue: any
16
+ ) => boolean | void;
17
+
18
+ /**
19
+ * Tracks changes in Aggregates using Proxy.
20
+ *
21
+ * Features:
22
+ * - Tracks changes in primitive properties
23
+ * - Tracks changes in nested entities (1:1)
24
+ * - Tracks changes in collections (1:N)
25
+ * - Supports Value Objects with identityKey
26
+ * - Calculates depth automatically
27
+ * - Generates AggregateChanges for persistence
28
+ * - Supports validation on change via onChangeValidator
29
+ */
30
+ export class ChangeTracker {
31
+ private history: HistoryEntry[] = [];
32
+ private originalValues: Map<string, any> = new Map();
33
+ private trackedArrays: Map<string, ArrayState> = new Map();
34
+ private trackedEntities: Map<string, TrackedItem> = new Map();
35
+ private onChangeValidator?: OnChangeValidator;
36
+
37
+ constructor(
38
+ private target: any,
39
+ private rootEntityName: string,
40
+ private path: string = "",
41
+ private depth: number = 0,
42
+ private parentId?: string,
43
+ private parentEntity?: string,
44
+ private rootTracker?: ChangeTracker
45
+ ) {
46
+ if (!rootTracker) {
47
+ this.rootTracker = this;
48
+ }
49
+ this.captureInitialState();
50
+ }
51
+
52
+ /**
53
+ * Sets a validator callback that will be called on every property change.
54
+ * The validator can:
55
+ * - Return false to reject the change (value will be reverted)
56
+ * - Throw an error to reject the change with an error
57
+ * - Return true/undefined to accept the change
58
+ */
59
+ setOnChangeValidator(validator: OnChangeValidator): void {
60
+ this.getRootTracker().onChangeValidator = validator;
61
+ }
62
+
63
+ private captureInitialState(): void {
64
+ if (this.depth > 0) return;
65
+ this.captureEntityState(this.target, this.rootEntityName, "", 0);
66
+ }
67
+
68
+ private captureEntityState(
69
+ obj: any,
70
+ entityName: string,
71
+ path: string,
72
+ depth: number,
73
+ parentId?: string,
74
+ parentEntity?: string
75
+ ): void {
76
+ if (!obj || typeof obj !== "object") return;
77
+
78
+ const id = this.getEntityId(obj);
79
+ const key = path || "root";
80
+
81
+ this.trackedEntities.set(key, {
82
+ entity: obj,
83
+ metadata: {
84
+ entityName,
85
+ depth,
86
+ parentId,
87
+ parentEntity,
88
+ path,
89
+ },
90
+ originalState: this.deepClone(obj),
91
+ });
92
+
93
+ const propsToScan = obj.props || obj;
94
+
95
+ for (const [propName, value] of Object.entries(propsToScan)) {
96
+ if (propName === "id") continue;
97
+
98
+ const propPath = path ? `${path}.${propName}` : propName;
99
+
100
+ if (Array.isArray(value)) {
101
+ this.captureArrayState(value, propPath, depth + 1, id, entityName);
102
+ } else if (this.isEntityOrVO(value)) {
103
+ const nestedName = this.getEntityName(value);
104
+ this.captureEntityState(
105
+ value,
106
+ nestedName,
107
+ propPath,
108
+ depth + 1,
109
+ id,
110
+ entityName
111
+ );
112
+ }
113
+ }
114
+ }
115
+
116
+ private captureArrayState(
117
+ arr: any[],
118
+ path: string,
119
+ depth: number,
120
+ parentId?: string,
121
+ parentEntity?: string
122
+ ): void {
123
+ const entityName = arr.length > 0 ? this.getEntityName(arr[0]) : "Unknown";
124
+
125
+ this.trackedArrays.set(path, {
126
+ cloned: this.cloneArray(arr),
127
+ original: arr.slice(),
128
+ metadata: {
129
+ entityName,
130
+ depth,
131
+ parentId,
132
+ parentEntity,
133
+ path,
134
+ },
135
+ });
136
+
137
+ arr.forEach((item, index) => {
138
+ if (this.isEntityOrVO(item)) {
139
+ const itemPath = `${path}[${index}]`;
140
+ this.captureEntityState(
141
+ item,
142
+ this.getEntityName(item),
143
+ itemPath,
144
+ depth,
145
+ parentId,
146
+ parentEntity
147
+ );
148
+ }
149
+ });
150
+ }
151
+
152
+ createProxy(): any {
153
+ const handler: ProxyHandler<any> = {
154
+ get: (target, prop, receiver) => {
155
+ const value = Reflect.get(target, prop, receiver);
156
+
157
+ if (this.shouldSkipProperty(prop)) {
158
+ return value;
159
+ }
160
+
161
+ if (typeof value === "function") {
162
+ return value.bind(target);
163
+ }
164
+
165
+ const currentPath = this.buildPath(String(prop));
166
+
167
+ if (Array.isArray(value)) {
168
+ return this.createArrayProxy(value, currentPath);
169
+ }
170
+
171
+ if (this.isEntityOrVO(value)) {
172
+ const nestedTracker = new ChangeTracker(
173
+ value,
174
+ this.getEntityName(value),
175
+ currentPath,
176
+ this.depth + 1,
177
+ this.getEntityId(this.target),
178
+ this.rootEntityName,
179
+ this.rootTracker
180
+ );
181
+ return nestedTracker.createProxy();
182
+ }
183
+
184
+ return value;
185
+ },
186
+
187
+ set: (target, prop, newValue, receiver) => {
188
+ const currentPath = this.buildPath(String(prop));
189
+ const oldValue = Reflect.get(target, prop, receiver);
190
+
191
+ if (!Array.isArray(newValue) && oldValue === newValue) {
192
+ return true;
193
+ }
194
+
195
+ const rootTracker = this.getRootTracker();
196
+ if (rootTracker.onChangeValidator) {
197
+ try {
198
+ const result = rootTracker.onChangeValidator(
199
+ currentPath,
200
+ oldValue,
201
+ newValue
202
+ );
203
+ if (result === false) {
204
+ return true;
205
+ }
206
+ } catch (error) {
207
+ throw error;
208
+ }
209
+ }
210
+
211
+ if (!rootTracker.originalValues.has(currentPath)) {
212
+ rootTracker.originalValues.set(currentPath, oldValue);
213
+ }
214
+
215
+ rootTracker.history.push({
216
+ path: currentPath,
217
+ previousValue: oldValue,
218
+ currentValue: newValue,
219
+ timestamp: Date.now(),
220
+ });
221
+
222
+ const result = Reflect.set(target, prop, newValue, receiver);
223
+
224
+ if (Array.isArray(newValue)) {
225
+ this.handleArrayAssignment(currentPath, oldValue);
226
+ } else if (this.isEntityOrVO(newValue) || this.isEntityOrVO(oldValue)) {
227
+ this.handleEntityChange(currentPath, oldValue, newValue);
228
+ }
229
+
230
+ return result;
231
+ },
232
+ };
233
+
234
+ const proxy = new Proxy(this.target, handler);
235
+ Object.defineProperty(proxy, "__isProxy", { value: true, writable: false });
236
+ return proxy;
237
+ }
238
+
239
+ private createArrayProxy(array: any[], path: string): any[] {
240
+ const tracker = this;
241
+ const rootTracker = this.getRootTracker();
242
+
243
+ if (!rootTracker.trackedArrays.has(path)) {
244
+ const parentId = this.getEntityId(this.target);
245
+ rootTracker.captureArrayState(
246
+ array,
247
+ path,
248
+ this.depth + 1,
249
+ parentId,
250
+ this.rootEntityName
251
+ );
252
+ }
253
+
254
+ return new Proxy(array, {
255
+ get(target, prop, receiver) {
256
+ const value = Reflect.get(target, prop, receiver);
257
+
258
+ if (typeof value === "function") {
259
+ const mutatingMethods = [
260
+ "push",
261
+ "pop",
262
+ "shift",
263
+ "unshift",
264
+ "splice",
265
+ "sort",
266
+ "reverse",
267
+ ];
268
+
269
+ if (mutatingMethods.includes(String(prop))) {
270
+ return function (...args: any[]) {
271
+ const oldArray = target.slice();
272
+
273
+ if (rootTracker.onChangeValidator) {
274
+ try {
275
+ const result = rootTracker.onChangeValidator(path, oldArray, [
276
+ ...oldArray,
277
+ ...args,
278
+ ]);
279
+ if (result === false) {
280
+ return undefined;
281
+ }
282
+ } catch (error) {
283
+ throw error;
284
+ }
285
+ }
286
+
287
+ const result = value.apply(target, args);
288
+
289
+ rootTracker.history.push({
290
+ path,
291
+ previousValue: oldArray,
292
+ currentValue: target.slice(),
293
+ timestamp: Date.now(),
294
+ });
295
+
296
+ return result;
297
+ };
298
+ }
299
+ return value.bind(target);
300
+ }
301
+
302
+ if (!isNaN(Number(prop)) && tracker.isEntityOrVO(value)) {
303
+ const nestedPath = `${path}[${String(prop)}]`;
304
+ const nestedTracker = new ChangeTracker(
305
+ value,
306
+ tracker.getEntityName(value),
307
+ nestedPath,
308
+ tracker.depth + 1,
309
+ tracker.getEntityId(tracker.target),
310
+ tracker.rootEntityName,
311
+ rootTracker
312
+ );
313
+ return nestedTracker.createProxy();
314
+ }
315
+
316
+ return value;
317
+ },
318
+
319
+ set(target, prop, newValue, receiver) {
320
+ if (!isNaN(Number(prop))) {
321
+ const oldArray = target.slice();
322
+
323
+ if (rootTracker.onChangeValidator) {
324
+ try {
325
+ const result = rootTracker.onChangeValidator(
326
+ path,
327
+ oldArray,
328
+ newValue
329
+ );
330
+ if (result === false) {
331
+ return true;
332
+ }
333
+ } catch (error) {
334
+ throw error;
335
+ }
336
+ }
337
+
338
+ const result = Reflect.set(target, prop, newValue, receiver);
339
+
340
+ rootTracker.history.push({
341
+ path,
342
+ previousValue: oldArray,
343
+ currentValue: target.slice(),
344
+ timestamp: Date.now(),
345
+ });
346
+
347
+ return result;
348
+ }
349
+ return Reflect.set(target, prop, newValue, receiver);
350
+ },
351
+ });
352
+ }
353
+
354
+ /**
355
+ * Returns all detected changes as AggregateChanges.
356
+ */
357
+ getChanges<TEntityMap = Record<string, any>>(): AggregateChanges<TEntityMap> {
358
+ const changes = new AggregateChanges<TEntityMap>();
359
+ const rootTracker = this.getRootTracker();
360
+
361
+ this.analyzeRootChanges(changes, rootTracker);
362
+ this.analyzeCollectionChanges(changes, rootTracker);
363
+ this.analyzeEntityChanges(changes, rootTracker);
364
+
365
+ return changes;
366
+ }
367
+
368
+ private analyzeRootChanges(
369
+ changes: AggregateChanges<any>,
370
+ rootTracker: ChangeTracker
371
+ ): void {
372
+ const changedFields: Record<string, any> = {};
373
+ let hasChanges = false;
374
+
375
+ for (const [path, originalValue] of rootTracker.originalValues) {
376
+ if (path.includes(".") || path.includes("[")) continue;
377
+
378
+ const currentValue = this.target[path];
379
+
380
+ if (!this.isEqual(originalValue, currentValue)) {
381
+ changedFields[path] = currentValue;
382
+ hasChanges = true;
383
+ }
384
+ }
385
+
386
+ if (hasChanges) {
387
+ const id = this.getEntityId(this.target);
388
+ if (id) {
389
+ changes.addUpdate(
390
+ this.rootEntityName,
391
+ id,
392
+ this.target,
393
+ changedFields,
394
+ 0
395
+ );
396
+ }
397
+ }
398
+ }
399
+
400
+ private analyzeCollectionChanges(
401
+ changes: AggregateChanges<any>,
402
+ rootTracker: ChangeTracker
403
+ ): void {
404
+ const allTrackedArrays = new Map<string, ArrayState>();
405
+ const processedArrays = new Set<any>();
406
+
407
+ for (const [path, arrayState] of rootTracker.trackedArrays) {
408
+ const currentArray = this.getValueAtPath(this.target, path);
409
+ if (Array.isArray(currentArray) && !processedArrays.has(currentArray)) {
410
+ allTrackedArrays.set(path, arrayState);
411
+ processedArrays.add(currentArray);
412
+ }
413
+ }
414
+
415
+ this.collectNestedArrays(
416
+ this.target,
417
+ "",
418
+ allTrackedArrays,
419
+ processedArrays
420
+ );
421
+
422
+ for (const [path, arrayState] of allTrackedArrays) {
423
+ const currentArray = this.getValueAtPath(this.target, path);
424
+ if (!Array.isArray(currentArray)) continue;
425
+
426
+ const { created, updated, deleted } = this.detectArrayChanges(
427
+ arrayState.cloned,
428
+ arrayState.original,
429
+ currentArray
430
+ );
431
+
432
+ const { depth, parentId, parentEntity } = arrayState.metadata;
433
+
434
+ for (const item of created) {
435
+ const itemEntityName = this.getEntityName(item);
436
+ changes.addCreate(itemEntityName, item, depth, parentId, parentEntity);
437
+
438
+ this.markNestedItemsAsCreated(item, depth, changes);
439
+ }
440
+
441
+ for (const item of updated) {
442
+ const id = this.getEntityId(item);
443
+ if (id) {
444
+ const original = arrayState.cloned.find(
445
+ (o) => this.getEntityId(o) === id
446
+ );
447
+ const changedFields = this.detectChangedFields(original, item);
448
+ if (Object.keys(changedFields).length > 0) {
449
+ const itemEntityName = this.getEntityName(item);
450
+ changes.addUpdate(itemEntityName, id, item, changedFields, depth);
451
+ }
452
+ }
453
+ }
454
+
455
+ for (const item of deleted) {
456
+ const id = this.getEntityId(item);
457
+ const key = this.getItemKey(item);
458
+ if (id || key) {
459
+ const itemEntityName = this.getEntityName(item);
460
+ const deleteId = id || key!;
461
+ changes.addDelete(itemEntityName, deleteId, item, depth);
462
+
463
+ this.markNestedItemsAsDeleted(item, depth, changes, rootTracker);
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Recursively marks all nested items as created when a parent is created.
471
+ */
472
+ private markNestedItemsAsCreated(
473
+ item: any,
474
+ parentDepth: number,
475
+ changes: AggregateChanges<any>
476
+ ): void {
477
+ if (!item || typeof item !== "object") return;
478
+
479
+ const itemId = this.getEntityId(item);
480
+ if (!itemId) return;
481
+
482
+ const props = item.props || item;
483
+
484
+ for (const [propName, value] of Object.entries(props)) {
485
+ if (propName === "id") continue;
486
+
487
+ if (Array.isArray(value)) {
488
+ for (const nestedItem of value) {
489
+ if (this.isEntityOrVO(nestedItem)) {
490
+ const nestedId = this.getEntityId(nestedItem);
491
+ const nestedKey = this.getItemKey(nestedItem);
492
+ if (nestedId || nestedKey) {
493
+ const entityName = this.getEntityName(nestedItem);
494
+ changes.addCreate(
495
+ entityName,
496
+ nestedItem,
497
+ parentDepth + 1,
498
+ itemId,
499
+ this.getEntityName(item)
500
+ );
501
+
502
+ this.markNestedItemsAsCreated(
503
+ nestedItem,
504
+ parentDepth + 1,
505
+ changes
506
+ );
507
+ }
508
+ }
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Recursively marks all nested items as deleted when a parent is deleted.
516
+ * Uses the original captured state to find nested items.
517
+ */
518
+ private markNestedItemsAsDeleted(
519
+ item: any,
520
+ parentDepth: number,
521
+ changes: AggregateChanges<any>,
522
+ rootTracker: ChangeTracker
523
+ ): void {
524
+ if (!item || typeof item !== "object") return;
525
+
526
+ const itemId = this.getEntityId(item);
527
+ if (!itemId) return;
528
+
529
+ for (const [, arrayState] of rootTracker.trackedArrays) {
530
+ if (arrayState.metadata.parentId === itemId) {
531
+ for (const nestedItem of arrayState.cloned) {
532
+ const id =
533
+ typeof nestedItem === "object" && nestedItem !== null
534
+ ? nestedItem.id
535
+ : undefined;
536
+ if (id) {
537
+ const entityName = arrayState.metadata.entityName;
538
+ changes.addDelete(entityName, id, nestedItem, parentDepth + 1);
539
+
540
+ this.markNestedJsonItemAsDeleted(
541
+ id,
542
+ parentDepth + 1,
543
+ changes,
544
+ rootTracker
545
+ );
546
+ }
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Recursively marks nested items as deleted from a JSON object.
554
+ * This is used when processing cloned (JSON) state.
555
+ */
556
+ private markNestedJsonItemAsDeleted(
557
+ itemId: string,
558
+ parentDepth: number,
559
+ changes: AggregateChanges<any>,
560
+ rootTracker: ChangeTracker
561
+ ): void {
562
+ for (const [, arrayState] of rootTracker.trackedArrays) {
563
+ if (arrayState.metadata.parentId === itemId) {
564
+ for (const nestedJsonItem of arrayState.cloned) {
565
+ if (typeof nestedJsonItem !== "object" || nestedJsonItem === null)
566
+ continue;
567
+
568
+ const nestedId = nestedJsonItem.id;
569
+ const entityName = arrayState.metadata.entityName;
570
+
571
+ if (nestedId) {
572
+ changes.addDelete(
573
+ entityName,
574
+ nestedId,
575
+ nestedJsonItem,
576
+ parentDepth + 1
577
+ );
578
+
579
+ this.markNestedJsonItemAsDeleted(
580
+ nestedId,
581
+ parentDepth + 1,
582
+ changes,
583
+ rootTracker
584
+ );
585
+ } else {
586
+ const key = this.extractIdentityKeyFromJson(
587
+ nestedJsonItem,
588
+ arrayState.original
589
+ );
590
+ if (key) {
591
+ changes.addDelete(
592
+ entityName,
593
+ key,
594
+ nestedJsonItem,
595
+ parentDepth + 1
596
+ );
597
+ }
598
+ }
599
+ }
600
+ }
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Extracts identity key from a JSON object by looking at the original ValueObject instances.
606
+ */
607
+ private extractIdentityKeyFromJson(
608
+ jsonItem: any,
609
+ originalArray: any[]
610
+ ): string | undefined {
611
+ for (const originalItem of originalArray) {
612
+ if (this.isEntityOrVO(originalItem)) {
613
+ const originalJson = this.deepClone(originalItem);
614
+ if (JSON.stringify(originalJson) === JSON.stringify(jsonItem)) {
615
+ const key = this.getItemKey(originalItem);
616
+ if (key) return key;
617
+ }
618
+ }
619
+ }
620
+
621
+ if (jsonItem.id) return jsonItem.id;
622
+
623
+ return undefined;
624
+ }
625
+
626
+ private collectNestedArrays(
627
+ obj: any,
628
+ basePath: string,
629
+ allArrays: Map<string, ArrayState>,
630
+ processedArrays: Set<any>
631
+ ): void {
632
+ if (!obj || typeof obj !== "object") return;
633
+
634
+ for (const [propName, value] of Object.entries(obj)) {
635
+ if (propName === "id" || propName === "proxy" || propName === "_props")
636
+ continue;
637
+
638
+ const propPath = basePath ? `${basePath}.${propName}` : propName;
639
+
640
+ if (Array.isArray(value)) {
641
+ value.forEach((item, index) => {
642
+ if (this.isEntityOrVO(item)) {
643
+ this.collectNestedArrays(
644
+ item,
645
+ `${propPath}[${index}]`,
646
+ allArrays,
647
+ processedArrays
648
+ );
649
+ }
650
+ });
651
+ } else if (this.isEntityOrVO(value)) {
652
+ this.collectNestedArrays(value, propPath, allArrays, processedArrays);
653
+ }
654
+ }
655
+ }
656
+
657
+ private analyzeEntityChanges(
658
+ changes: AggregateChanges<any>,
659
+ rootTracker: ChangeTracker
660
+ ): void {
661
+ for (const [path, trackedItem] of rootTracker.trackedEntities) {
662
+ if (path === "root") continue;
663
+ if (path.includes("[")) continue;
664
+
665
+ const currentValue = this.getValueAtPath(this.target, path);
666
+ const originalValue = trackedItem.originalState;
667
+ const originalEntity = trackedItem.entity;
668
+ const { entityName, depth, parentId, parentEntity } =
669
+ trackedItem.metadata;
670
+
671
+ const state = this.detectEntityChangeState(originalValue, currentValue);
672
+
673
+ switch (state) {
674
+ case "created":
675
+ changes.addCreate(
676
+ entityName,
677
+ currentValue,
678
+ depth,
679
+ parentId,
680
+ parentEntity
681
+ );
682
+ break;
683
+
684
+ case "deleted":
685
+ const id = this.getEntityId(originalValue);
686
+ if (id) {
687
+ changes.addDelete(entityName, id, originalEntity, depth);
688
+ }
689
+ break;
690
+
691
+ case "replaced":
692
+ const oldId = this.getEntityId(originalValue);
693
+ if (oldId) {
694
+ changes.addDelete(entityName, oldId, originalEntity, depth);
695
+ }
696
+ changes.addCreate(
697
+ entityName,
698
+ currentValue,
699
+ depth,
700
+ parentId,
701
+ parentEntity
702
+ );
703
+ break;
704
+
705
+ case "updated":
706
+ const updateId = this.getEntityId(currentValue);
707
+ if (updateId) {
708
+ const changedFields = this.detectChangedFields(
709
+ originalValue,
710
+ currentValue
711
+ );
712
+ if (Object.keys(changedFields).length > 0) {
713
+ changes.addUpdate(
714
+ entityName,
715
+ updateId,
716
+ currentValue,
717
+ changedFields,
718
+ depth
719
+ );
720
+ }
721
+ }
722
+ break;
723
+ }
724
+ }
725
+ }
726
+
727
+ private detectEntityChangeState(
728
+ previous: any,
729
+ current: any
730
+ ): EntityChangeState {
731
+ if (previous === null && current !== null) {
732
+ return "created";
733
+ }
734
+
735
+ if (previous !== null && current === null) {
736
+ return "deleted";
737
+ }
738
+
739
+ if (previous !== null && current !== null) {
740
+ const prevId = this.getEntityId(previous);
741
+ const currId = this.getEntityId(current);
742
+
743
+ if (prevId && currId && prevId === currId) {
744
+ return this.hasChanged(previous, current) ? "updated" : "unchanged";
745
+ } else {
746
+ return "replaced";
747
+ }
748
+ }
749
+
750
+ return "unchanged";
751
+ }
752
+
753
+ private detectArrayChanges(
754
+ oldCloned: any[],
755
+ oldOriginal: any[],
756
+ newArray: any[]
757
+ ): { created: any[]; updated: any[]; deleted: any[] } {
758
+ const created: any[] = [];
759
+ const updated: any[] = [];
760
+ const deleted: any[] = [];
761
+
762
+ const oldMap = new Map<string, any>();
763
+ const newMap = new Map<string, any>();
764
+
765
+ oldCloned.forEach((item) => {
766
+ const key = this.getItemKey(item);
767
+ if (key) oldMap.set(key, item);
768
+ });
769
+
770
+ newArray.forEach((item) => {
771
+ const key = this.getItemKey(item);
772
+ if (key) newMap.set(key, item);
773
+ });
774
+
775
+ newArray.forEach((item) => {
776
+ const key = this.getItemKey(item);
777
+ if (!key) {
778
+ created.push(item);
779
+ } else if (!oldMap.has(key)) {
780
+ created.push(item);
781
+ } else if (this.hasChanged(oldMap.get(key), item)) {
782
+ updated.push(item);
783
+ }
784
+ });
785
+
786
+ oldOriginal.forEach((item) => {
787
+ const key = this.getItemKey(item);
788
+ if (key && !newMap.has(key)) {
789
+ deleted.push(item);
790
+ }
791
+ });
792
+
793
+ return { created, updated, deleted };
794
+ }
795
+
796
+ private detectChangedFields(
797
+ original: any,
798
+ current: any
799
+ ): Record<string, any> {
800
+ const changes: Record<string, any> = {};
801
+
802
+ if (!original || !current) return changes;
803
+
804
+ const origProps = original.props || original;
805
+ const currProps = current.props || current;
806
+
807
+ for (const key of Object.keys(currProps)) {
808
+ if (key === "id") continue;
809
+
810
+ const origValue = origProps[key];
811
+ const currValue = currProps[key];
812
+
813
+ if (Array.isArray(currValue) || this.isEntityOrVO(currValue)) {
814
+ continue;
815
+ }
816
+
817
+ if (!this.isEqual(origValue, currValue)) {
818
+ changes[key] = currValue;
819
+ }
820
+ }
821
+
822
+ return changes;
823
+ }
824
+
825
+ private handleArrayAssignment(path: string, oldValue: any): void {
826
+ const rootTracker = this.getRootTracker();
827
+
828
+ if (!rootTracker.trackedArrays.has(path)) {
829
+ const parentId = this.getEntityId(this.target);
830
+ rootTracker.captureArrayState(
831
+ Array.isArray(oldValue) ? oldValue : [],
832
+ path,
833
+ this.depth + 1,
834
+ parentId,
835
+ this.rootEntityName
836
+ );
837
+ }
838
+ }
839
+
840
+ private handleEntityChange(path: string, oldValue: any, newValue: any): void {
841
+ const rootTracker = this.getRootTracker();
842
+ const entityName = newValue
843
+ ? this.getEntityName(newValue)
844
+ : this.getEntityName(oldValue);
845
+
846
+ const existingTracked = rootTracker.trackedEntities.get(path);
847
+
848
+ rootTracker.trackedEntities.set(path, {
849
+ entity: existingTracked?.entity || oldValue,
850
+ metadata: {
851
+ entityName,
852
+ depth: this.depth + 1,
853
+ parentId: this.getEntityId(this.target),
854
+ parentEntity: this.rootEntityName,
855
+ path,
856
+ },
857
+ originalState: existingTracked?.originalState,
858
+ });
859
+ }
860
+
861
+ private getRootTracker(): ChangeTracker {
862
+ return this.rootTracker || this;
863
+ }
864
+
865
+ private buildPath(prop: string): string {
866
+ return this.path ? `${this.path}.${prop}` : prop;
867
+ }
868
+
869
+ private shouldSkipProperty(prop: string | symbol): boolean {
870
+ const skipProps = [
871
+ "__isProxy",
872
+ "__tracker",
873
+ "__originalTarget",
874
+ "__path",
875
+ "constructor",
876
+ "prototype",
877
+ ];
878
+ return skipProps.includes(String(prop));
879
+ }
880
+
881
+ private getValueAtPath(obj: any, path: string): any {
882
+ if (!path) return obj;
883
+
884
+ const parts = path.split(/[.\[\]]+/).filter(Boolean);
885
+ let current = obj;
886
+
887
+ for (const part of parts) {
888
+ if (current === null || current === undefined) return undefined;
889
+ current = current[part];
890
+ }
891
+
892
+ return current;
893
+ }
894
+
895
+ private getItemKey(item: any): string | undefined {
896
+ const id = this.getEntityId(item);
897
+ if (id) return id;
898
+
899
+ if (item instanceof ValueObject && item.hasIdentityKey()) {
900
+ return item.getIdentityKey() || undefined;
901
+ }
902
+
903
+ return undefined;
904
+ }
905
+
906
+ private getEntityId(item: any): string | undefined {
907
+ if (!item) return undefined;
908
+ if (item.id instanceof Id) return item.id.value;
909
+ if (item.id !== undefined) return String(item.id);
910
+ return undefined;
911
+ }
912
+
913
+ private getEntityName(item: any): string {
914
+ if (!item) return "Unknown";
915
+ return item.constructor?.name || "Unknown";
916
+ }
917
+
918
+ private isEntityOrVO(value: any): boolean {
919
+ if (value === null || value === undefined) return false;
920
+ return value instanceof Entity || value instanceof ValueObject;
921
+ }
922
+
923
+ private isEqual(a: any, b: any): boolean {
924
+ if (a === b) return true;
925
+ if (a instanceof Id && b instanceof Id) return a.value === b.value;
926
+ if (a instanceof Date && b instanceof Date)
927
+ return a.getTime() === b.getTime();
928
+
929
+ try {
930
+ return this.hasChanged(a, b) === false;
931
+ } catch {
932
+ return this.deepEqual(a, b);
933
+ }
934
+ }
935
+
936
+ private deepEqual(a: any, b: any): boolean {
937
+ if (a === b) return true;
938
+ if (a == null || b == null) return a === b;
939
+ if (typeof a !== typeof b) return false;
940
+ if (typeof a !== "object") return a === b;
941
+
942
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
943
+ if (Array.isArray(a)) {
944
+ if (a.length !== b.length) return false;
945
+ for (let i = 0; i < a.length; i++) {
946
+ if (!this.deepEqual(a[i], b[i])) return false;
947
+ }
948
+ return true;
949
+ }
950
+
951
+ const keysA = Object.keys(a).filter((key) => {
952
+ const value = a[key];
953
+ return (
954
+ typeof value !== "object" ||
955
+ value instanceof Date ||
956
+ value instanceof Id ||
957
+ value === null
958
+ );
959
+ });
960
+ const keysB = Object.keys(b).filter((key) => {
961
+ const value = b[key];
962
+ return (
963
+ typeof value !== "object" ||
964
+ value instanceof Date ||
965
+ value instanceof Id ||
966
+ value === null
967
+ );
968
+ });
969
+
970
+ if (keysA.length !== keysB.length) return false;
971
+
972
+ for (const key of keysA) {
973
+ if (!keysB.includes(key)) return false;
974
+ if (!this.isEqual(a[key], b[key])) return false;
975
+ }
976
+
977
+ return true;
978
+ }
979
+
980
+ private hasChanged(obj1: any, obj2: any): boolean {
981
+ const json1 = this.normalizeAndStringify(this.deepClone(obj1));
982
+ const json2 = this.normalizeAndStringify(this.deepClone(obj2));
983
+ return json1 !== json2;
984
+ }
985
+
986
+ private cloneArray(arr: any[]): any[] {
987
+ return arr.map((item) => this.deepClone(item));
988
+ }
989
+
990
+ private deepClone(obj: any): any {
991
+ if (obj === null || obj === undefined || typeof obj !== "object") {
992
+ return obj;
993
+ }
994
+
995
+ if (obj instanceof Id) {
996
+ return obj.value;
997
+ }
998
+
999
+ if (typeof obj.toJson === "function") {
1000
+ return obj.toJson();
1001
+ }
1002
+
1003
+ if (Array.isArray(obj)) {
1004
+ return obj.map((item) => this.deepClone(item));
1005
+ }
1006
+
1007
+ if (obj instanceof Date) {
1008
+ return new Date(obj.getTime());
1009
+ }
1010
+
1011
+ try {
1012
+ return structuredClone(obj);
1013
+ } catch {
1014
+ const cloned: any = {};
1015
+ for (const key in obj) {
1016
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
1017
+ cloned[key] = this.deepClone(obj[key]);
1018
+ }
1019
+ }
1020
+ return cloned;
1021
+ }
1022
+ }
1023
+
1024
+ private normalizeAndStringify(obj: any): string {
1025
+ if (obj === null || typeof obj !== "object") {
1026
+ return JSON.stringify(obj);
1027
+ }
1028
+
1029
+ if (Array.isArray(obj)) {
1030
+ return `[${obj
1031
+ .map((item) => this.normalizeAndStringify(item))
1032
+ .join(",")}]`;
1033
+ }
1034
+
1035
+ const keys = Object.keys(obj).sort();
1036
+ const parts = keys.map(
1037
+ (key) => `"${key}":${this.normalizeAndStringify(obj[key])}`
1038
+ );
1039
+ return `{${parts.join(",")}}`;
1040
+ }
1041
+
1042
+ getHistory(): HistoryEntry[] {
1043
+ return [...this.getRootTracker().history];
1044
+ }
1045
+
1046
+ clearHistory(): void {
1047
+ const rootTracker = this.getRootTracker();
1048
+ rootTracker.history = [];
1049
+ rootTracker.originalValues.clear();
1050
+ rootTracker.trackedArrays.clear();
1051
+ rootTracker.trackedEntities.clear();
1052
+ this.captureInitialState();
1053
+ }
1054
+
1055
+ markAsClean(): void {
1056
+ this.clearHistory();
1057
+ }
1058
+
1059
+ getTarget(): any {
1060
+ return this.target;
1061
+ }
1062
+ }