@woltz/rich-domain 1.2.0 → 1.2.1

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