@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
@@ -1,707 +1,439 @@
1
- import { Id } from "../src";
2
- import { Post, User, Address, Comment } from "./utils";
1
+ import { Id } from "../src/id";
2
+ import { Post, TagReference, User, Like, Address, Comment } from "./utils";
3
+
4
+ function createUser(
5
+ overrides: Partial<{
6
+ name: string;
7
+ email: string;
8
+ address: Address | null;
9
+ posts: Post[];
10
+ tags: TagReference[];
11
+ }> = {}
12
+ ) {
13
+ const user = new User({
14
+ id: new Id("user-1"),
15
+ name: overrides.name ?? "Test User",
16
+ email: overrides.email ?? "test@test.com",
17
+ address: overrides.address ?? null,
18
+ posts: overrides.posts ?? [],
19
+ tags: overrides.tags ?? [],
20
+ });
3
21
 
4
- describe("History Tracker Tests", () => {
5
- describe("Simple Property Changes", () => {
6
- it("should track simple property changes", (done) => {
7
- const user = new User({
8
- id: new Id("1"),
9
- name: "John Doe",
10
- email: "john@example.com",
11
- posts: [
12
- new Post({
13
- id: new Id("1"),
14
- title: "First Post",
15
- content: "Hello World",
16
- likes: 0,
17
- }),
18
- ],
19
- address: new Address({
20
- street: "Main St",
21
- city: "NYC",
22
- zipCode: "10001",
23
- }),
24
- comments: [],
25
- });
22
+ return user;
23
+ }
24
+
25
+ function createPost(
26
+ overrides: Partial<{
27
+ title: string;
28
+ content: string;
29
+ published: boolean;
30
+ comments: Comment[];
31
+ }> = {}
32
+ ): Post {
33
+ return new Post({
34
+ id: new Id(),
35
+ title: overrides.title ?? "Test Post",
36
+ content: overrides.content ?? "Test content",
37
+ published: overrides.published ?? false,
38
+ comments: overrides.comments ?? [],
39
+ });
40
+ }
41
+
42
+ function createComment(
43
+ overrides: Partial<{
44
+ text: string;
45
+ authorId: string;
46
+ likes: Like[];
47
+ }> = {}
48
+ ): Comment {
49
+ return new Comment({
50
+ id: new Id(),
51
+ text: overrides.text ?? "Test comment",
52
+ authorId: overrides.authorId ?? "author-1",
53
+ likes: overrides.likes ?? [],
54
+ });
55
+ }
56
+
57
+ function createAddress(street = "123 Main St", city = "Test City"): Address {
58
+ return new Address({
59
+ id: new Id(),
60
+ street,
61
+ city,
62
+ });
63
+ }
64
+
65
+ describe("ChangeTracker.getChanges()", () => {
66
+ describe("no changes", () => {
67
+ it("should return empty changes when nothing modified", () => {
68
+ const user = createUser();
26
69
 
27
- user.changeEmail("new@example.com");
28
- user.name = "New Name";
29
- user.addPost(
30
- new Post({
31
- id: new Id("2"),
32
- title: "Second Post",
33
- content: "Hello World 2",
34
- likes: 0,
35
- })
36
- );
37
-
38
- function dispatch(entity: User) {
39
- entity.subscribe({
40
- email: {
41
- onChange: ({ previous, current, path }) => {
42
- expect(previous).toBe("john@example.com");
43
- expect(current).toBe("new@example.com");
44
- expect(path).toBe("email");
45
- },
46
- },
47
- posts: {
48
- onChange: ({ toCreate, toUpdate, toDelete }) => {
49
- expect(toCreate).toHaveLength(1);
50
- expect(toUpdate).toHaveLength(1);
51
- expect(toDelete).toHaveLength(0);
52
- },
53
- },
54
- name: {
55
- onChange: ({ previous, current, path }) => {
56
- expect(previous).toBe("John Doe");
57
- expect(current).toBe("New Name");
58
- expect(path).toBe("name");
59
- },
60
- },
61
- });
62
- }
63
- user.posts[0].title = "Updated Title";
64
- dispatch(user);
65
-
66
- setTimeout(() => {
67
- done();
68
- }, 100);
70
+ const changes = user.getChanges();
71
+
72
+ expect(changes.isEmpty()).toBe(true);
73
+ expect(changes.hasCreates()).toBe(false);
74
+ expect(changes.hasUpdates()).toBe(false);
75
+ expect(changes.hasDeletes()).toBe(false);
69
76
  });
77
+ });
70
78
 
71
- it("should track multiple property changes", () => {
72
- const post = new Post({
73
- id: new Id("1"),
74
- title: "First Post",
75
- content: "Hello World",
76
- likes: 0,
77
- });
79
+ describe("root property changes", () => {
80
+ it("should detect primitive property changes", () => {
81
+ const user = createUser();
78
82
 
79
- const changes: any[] = [];
83
+ user.changeName("New Name");
84
+ user.changeEmail("new@email.com");
80
85
 
81
- post.subscribe({
82
- title: {
83
- onChange: (event) => changes.push({ property: "title", ...event }),
84
- },
85
- likes: {
86
- onChange: (event) => changes.push({ property: "likes", ...event }),
87
- },
88
- });
86
+ const changes = user.getTypedChanges();
89
87
 
90
- post.title = "New Title";
91
- post.likes = 10;
88
+ expect(changes.hasUpdates()).toBe(true);
92
89
 
93
- expect(changes).toHaveLength(2);
94
- expect(changes[0].property).toBe("title");
95
- expect(changes[1].property).toBe("likes");
90
+ const userUpdates = changes.for("User");
91
+ expect(userUpdates.hasUpdates()).toBe(true);
92
+ expect(userUpdates.updates[0].changed).toMatchObject({
93
+ name: "New Name",
94
+ email: "new@email.com",
95
+ });
96
96
  });
97
97
  });
98
98
 
99
- // ==========================================================================
100
- // Array Changes - Create Tests
101
- // ==========================================================================
99
+ describe("collection changes (1:N)", () => {
100
+ it("should detect added items", () => {
101
+ const user = createUser();
102
+ const newPost = createPost({ title: "New Post" });
102
103
 
103
- describe("Array Changes - Create", () => {
104
- it("should detect new items added to array", (done) => {
105
- const user = new User({
106
- id: new Id("1"),
107
- name: "John Doe",
108
- email: "john@example.com",
109
- posts: [],
110
- address: new Address({
111
- street: "Main St",
112
- city: "NYC",
113
- zipCode: "10001",
114
- }),
115
- comments: [],
116
- });
104
+ user.addPost(newPost);
117
105
 
118
- user.subscribe({
119
- posts: {
120
- onChange: ({ toCreate, toUpdate, toDelete }) => {
121
- expect(toCreate).toHaveLength(2);
122
- expect(toUpdate).toHaveLength(0);
123
- expect(toDelete).toHaveLength(0);
124
- expect(toCreate[0].title).toBe("Post 1");
125
- done();
126
- },
127
- },
128
- });
106
+ const changes = user.getTypedChanges();
129
107
 
130
- user.addManyPosts([
131
- new Post({
132
- id: new Id("1"),
133
- title: "Post 1",
134
- content: "Content 1",
135
- likes: 0,
136
- }),
137
- new Post({
138
- id: new Id("2"),
139
- title: "Post 2",
140
- content: "Content 2",
141
- likes: 0,
142
- }),
143
- ]);
108
+ expect(changes.hasCreates()).toBe(true);
109
+
110
+ const postChanges = changes.for("Post");
111
+
112
+ expect(postChanges.hasCreates()).toBe(true);
113
+ expect(postChanges.creates).toHaveLength(1);
114
+ expect(postChanges.creates[0].title).toBe("New Post");
144
115
  });
145
116
 
146
- it("should detect items pushed to array", (done) => {
147
- const user = new User({
148
- id: new Id("1"),
149
- name: "John Doe",
150
- email: "john@example.com",
151
- posts: [
152
- new Post({
153
- id: new Id("1"),
154
- title: "Post 1",
155
- content: "Content 1",
156
- likes: 0,
157
- }),
158
- ],
159
- address: new Address({
160
- street: "Main St",
161
- city: "NYC",
162
- zipCode: "10001",
163
- }),
117
+ it("should detect removed items", () => {
118
+ const existingPost = new Post({
119
+ id: new Id(),
120
+ title: "Existing Post",
121
+ content: "Existing content",
122
+ published: false,
164
123
  comments: [],
165
124
  });
166
-
167
- user.subscribe({
168
- posts: {
169
- onChange: ({ toCreate }) => {
170
- expect(toCreate).toHaveLength(1);
171
- expect(toCreate[0].title).toBe("Post 2");
172
- done();
173
- },
174
- },
125
+ const user = new User({
126
+ id: new Id(),
127
+ name: "Test User",
128
+ email: "test@test.com",
129
+ address: null,
130
+ posts: [existingPost],
131
+ tags: [],
175
132
  });
176
133
 
177
- user.posts.push(
178
- new Post({
179
- id: new Id("2"),
180
- title: "Post 2",
181
- content: "Content 2",
182
- likes: 0,
183
- })
184
- );
134
+ user.removePost(existingPost.id);
135
+
136
+ const changes = user.getChanges();
137
+
138
+ expect(changes.hasDeletes()).toBe(true);
139
+
140
+ const postChanges = changes.for("Post");
141
+ expect(postChanges.hasDeletes()).toBe(true);
142
+ expect(postChanges.deletes.length).toBe(1);
185
143
  });
186
- });
187
144
 
188
- // ==========================================================================
189
- // Array Changes - Update Tests
190
- // ==========================================================================
191
-
192
- describe("Array Changes - Update", () => {
193
- it("should detect updated items in array", (done) => {
194
- const id1 = new Id("1");
195
- const post1 = new Post({
196
- id: id1,
197
- title: "Post 1",
198
- content: "Content 1",
199
- likes: 0,
200
- });
201
- const post2 = new Post({
202
- id: new Id("2"),
203
- title: "Post 2",
204
- content: "Content 2",
205
- likes: 0,
206
- });
145
+ it("should detect updated items", () => {
146
+ const existingPost = createPost({ title: "Original Title" });
147
+ const user = createUser({ posts: [existingPost] });
207
148
 
208
- const user = new User({
209
- id: new Id("1"),
210
- name: "John Doe",
211
- email: "john@example.com",
212
- posts: [post1, post2],
213
- address: new Address({
214
- street: "Main St",
215
- city: "NYC",
216
- zipCode: "10001",
217
- }),
218
- comments: [],
219
- });
149
+ user.posts[0].changeTitle("Updated Title");
220
150
 
221
- user.subscribe({
222
- posts: {
223
- onChange: ({ toCreate, toUpdate, toDelete }) => {
224
- expect(toCreate).toHaveLength(0);
225
- expect(toUpdate).toHaveLength(1);
226
- expect(toDelete).toHaveLength(0);
227
- expect(toUpdate[0].id).toBe(id1);
228
- done();
229
- },
230
- },
231
- });
151
+ const changes = user.getTypedChanges();
232
152
 
233
- // Modify existing post
234
- post1.title = "Updated Post 1";
235
- user.changeEmail("new@example.com");
236
- user.posts = [...user.posts]; // Trigger change detection
153
+ const postChanges = changes.for("Post");
154
+ expect(postChanges.hasUpdates()).toBe(true);
155
+ expect(postChanges.updates[0].changed).toMatchObject({
156
+ title: "Updated Title",
157
+ });
237
158
  });
238
159
 
239
- it("should detect multiple updates in array", (done) => {
240
- const post1 = new Post({
241
- id: new Id("1"),
242
- title: "Post 1",
243
- content: "Content 1",
244
- likes: 0,
245
- });
246
- const post2 = new Post({
247
- id: new Id("2"),
248
- title: "Post 2",
249
- content: "Content 2",
250
- likes: 0,
251
- });
160
+ it("should detect multiple operations", () => {
161
+ const post1 = createPost({ title: "Post 1" });
162
+ const post2 = createPost({ title: "Post 2" });
163
+ const user = createUser({ posts: [post1, post2] });
252
164
 
253
- const user = new User({
254
- id: new Id("1"),
255
- name: "John Doe",
256
- email: "john@example.com",
257
- posts: [post1, post2],
258
- address: new Address({
259
- street: "Main St",
260
- city: "NYC",
261
- zipCode: "10001",
262
- }),
263
- comments: [],
264
- });
165
+ // Remove post1
166
+ user.removePost(post1.id);
265
167
 
266
- user.subscribe({
267
- posts: {
268
- onChange: ({ toUpdate }) => {
269
- expect(toUpdate).toHaveLength(2);
270
- done();
271
- },
272
- },
273
- });
168
+ // Add new post
169
+ const post3 = createPost({ title: "Post 3" });
170
+ user.addPost(post3);
274
171
 
275
- post1.title = "Updated Post 1";
276
- post2.likes = 100;
277
- user.posts = [...user.posts];
172
+ // Update post2
173
+ user.posts[0].changeTitle("Post 2 Updated");
174
+
175
+ const changes = user.getTypedChanges();
176
+ const postChanges = changes.for("Post");
177
+
178
+ expect(postChanges.hasCreates()).toBe(true);
179
+ expect(postChanges.hasUpdates()).toBe(true);
180
+ expect(postChanges.hasDeletes()).toBe(true);
181
+ expect(postChanges.creates).toHaveLength(1);
182
+ expect(postChanges.updates).toHaveLength(1);
183
+ expect(postChanges.deletes).toHaveLength(1);
278
184
  });
279
185
  });
280
186
 
281
- // ==========================================================================
282
- // Array Changes - Delete Tests
283
- // ==========================================================================
284
-
285
- describe("Array Changes - Delete", () => {
286
- it("should detect deleted items from array", (done) => {
287
- const post1 = new Post({
288
- id: new Id("1"),
289
- title: "Post 1",
290
- content: "Content 1",
291
- likes: 0,
292
- });
293
- const post2 = new Post({
294
- id: new Id("2"),
295
- title: "Post 2",
296
- content: "Content 2",
297
- likes: 0,
298
- });
187
+ describe("nested collections", () => {
188
+ it("should detect changes in deeply nested collections", () => {
189
+ const comment = createComment({ text: "Original comment" });
190
+ const post = createPost({ comments: [comment] });
191
+ const user = createUser({ posts: [post] });
299
192
 
300
- const user = new User({
301
- id: new Id("1"),
302
- name: "John Doe",
303
- email: "john@example.com",
304
- posts: [post1, post2],
305
- address: new Address({
306
- street: "Main St",
307
- city: "NYC",
308
- zipCode: "10001",
309
- }),
310
- comments: [],
311
- });
193
+ // Add new comment
194
+ const newComment = createComment({ text: "New comment" });
195
+ user.posts[0].addComment(newComment);
312
196
 
313
- user.subscribe({
314
- posts: {
315
- onChange: ({ toCreate, toUpdate, toDelete }) => {
316
- expect(toCreate).toHaveLength(0);
317
- expect(toUpdate).toHaveLength(0);
318
- expect(toDelete).toHaveLength(1);
319
- done();
320
- },
321
- },
322
- });
197
+ const changes = user.getTypedChanges();
198
+ const commentChanges = changes.for("Comment");
323
199
 
324
- user.posts = [post2];
200
+ expect(commentChanges.hasCreates()).toBe(true);
201
+ expect(commentChanges.creates[0].text).toBe("New comment");
202
+ expect(commentChanges.updates).toHaveLength(0);
203
+ expect(commentChanges.deletes).toHaveLength(0);
204
+ expect(commentChanges.creates).toHaveLength(1);
325
205
  });
326
206
 
327
- it("should detect items removed with splice", (done) => {
328
- const id1 = new Id("1");
329
- const user = new User({
330
- id: new Id("1"),
331
- name: "John Doe",
332
- email: "john@example.com",
333
- posts: [
334
- new Post({
335
- id: id1,
336
- title: "Post 1",
337
- content: "Content 1",
338
- likes: 0,
339
- }),
340
- new Post({
341
- id: new Id("2"),
342
- title: "Post 2",
343
- content: "Content 2",
344
- likes: 0,
345
- }),
346
- ],
347
- address: new Address({
348
- street: "Main St",
349
- city: "NYC",
350
- zipCode: "10001",
351
- }),
352
- comments: [],
353
- });
207
+ it("should handle 3+ levels of nesting (User > Post > Comment > Like)", () => {
208
+ const comment = createComment({ likes: [] });
209
+ const post = createPost({ comments: [comment] });
210
+ const user = createUser({ posts: [post] });
354
211
 
355
- user.subscribe({
356
- posts: {
357
- onChange: ({ toDelete }) => {
358
- expect(toDelete).toHaveLength(1);
359
- expect(toDelete[0].id.value).toBe(id1.value);
360
- done();
361
- },
362
- },
212
+ const newLike = new Like({
213
+ postId: "post-1",
214
+ userId: "user-2",
215
+ createdAt: new Date(),
363
216
  });
217
+ user.posts[0].comments[0].addLike(newLike);
218
+
219
+ const changes = user.getTypedChanges();
220
+ const likeChanges = changes.for("Like");
364
221
 
365
- user.posts.splice(0, 1);
222
+ expect(likeChanges.hasCreates()).toBe(true);
223
+ expect(likeChanges.creates).toHaveLength(1);
224
+ expect(likeChanges.updates).toHaveLength(0);
225
+ expect(likeChanges.deletes).toHaveLength(0);
366
226
  });
367
227
  });
368
228
 
369
- // ==========================================================================
370
- // Array Changes - Mixed Operations Tests
371
- // ==========================================================================
372
-
373
- describe("Array Changes - Mixed Operations", () => {
374
- it("should detect mixed create and update operations", (done) => {
375
- const post1 = new Post({
376
- id: new Id("1"),
377
- title: "Post 1",
378
- content: "Content 1",
379
- likes: 0,
380
- });
229
+ describe("entity relations (1:1)", () => {
230
+ it("should detect created entity (null Entity)", () => {
231
+ const user = createUser({ address: null });
381
232
 
382
- const user = new User({
383
- id: new Id("1"),
384
- name: "John Doe",
385
- email: "john@example.com",
386
- posts: [post1],
387
- address: new Address({
388
- street: "Main St",
389
- city: "NYC",
390
- zipCode: "10001",
391
- }),
392
- comments: [],
393
- });
233
+ user.setAddress(createAddress("New Street", "New City"));
394
234
 
395
- user.subscribe({
396
- posts: {
397
- onChange: ({ toCreate, toUpdate, toDelete }) => {
398
- expect(toCreate).toHaveLength(2);
399
- expect(toUpdate).toHaveLength(1);
400
- expect(toDelete).toHaveLength(0);
401
- done();
402
- },
403
- },
404
- });
235
+ const changes = user.getTypedChanges();
236
+ const addressChanges = changes.for("Address");
405
237
 
406
- post1.title = "Updated Post 1";
407
- user.posts = [
408
- post1,
409
- new Post({
410
- id: new Id("2"),
411
- title: "Post 2",
412
- content: "Content 2",
413
- likes: 0,
414
- }),
415
- new Post({
416
- id: new Id("3"),
417
- title: "Post 3",
418
- content: "Content 3",
419
- likes: 0,
420
- }),
421
- ];
238
+ expect(addressChanges.hasCreates()).toBe(true);
239
+ expect(addressChanges.creates[0].street).toBe("New Street");
422
240
  });
423
241
 
424
- it("should detect mixed create, update, and delete operations", (done) => {
425
- const post1 = new Post({
426
- id: new Id("1"),
427
- title: "Post 1",
428
- content: "Content 1",
429
- likes: 0,
430
- });
431
- const post2 = new Post({
432
- id: new Id("2"),
433
- title: "Post 2",
434
- content: "Content 2",
435
- likes: 0,
436
- });
437
- const post3 = new Post({
438
- id: new Id("3"),
439
- title: "Post 3",
440
- content: "Content 3",
441
- likes: 0,
442
- });
242
+ it("should detect deleted entity (Entity null)", () => {
243
+ const address = createAddress();
244
+ const user = createUser({ address });
443
245
 
444
- const user = new User({
445
- id: new Id("1"),
446
- name: "John Doe",
447
- email: "john@example.com",
448
- posts: [post1, post2, post3],
449
- address: new Address({
450
- street: "Main St",
451
- city: "NYC",
452
- zipCode: "10001",
453
- }),
454
- comments: [],
455
- });
246
+ user.removeAddress();
456
247
 
457
- user.subscribe({
458
- posts: {
459
- onChange: ({ toCreate, toUpdate, toDelete }) => {
460
- expect(toCreate).toHaveLength(1); // post4
461
- expect(toUpdate).toHaveLength(1); // post2 modified
462
- expect(toDelete).toHaveLength(2); // post1 and post3 removed
463
- done();
464
- },
465
- },
466
- });
248
+ const changes = user.getTypedChanges();
249
+ const addressChanges = changes.for("Address");
467
250
 
468
- post2.likes = 50;
469
- user.posts = [
470
- post2,
471
- new Post({
472
- id: new Id("4"),
473
- title: "Post 4",
474
- content: "Content 4",
475
- likes: 0,
476
- }),
477
- ];
251
+ expect(addressChanges.hasDeletes()).toBe(true);
252
+ expect(addressChanges.deletes).toHaveLength(1);
478
253
  });
479
- });
480
254
 
481
- // ==========================================================================
482
- // Nested Entity Tests
483
- // ==========================================================================
255
+ it("should detect updated entity (same ID with changes)", () => {
256
+ const address = createAddress("Old Street", "Old City");
257
+ const user = createUser({ address });
484
258
 
485
- describe("Nested Entity Changes", () => {
486
- it("should track changes in nested value objects", (done) => {
487
- const user = new User({
488
- id: new Id("1"),
489
- name: "John Doe",
490
- email: "john@example.com",
491
- posts: [],
492
- address: new Address({
493
- street: "Main St",
494
- city: "NYC",
495
- zipCode: "10001",
496
- }),
497
- comments: [],
498
- });
259
+ user.address?.changeStreet("New Street");
499
260
 
500
- user.subscribe({
501
- address: {
502
- onChange: ({ previous, current }) => {
503
- expect(previous).toBeInstanceOf(Address);
504
- expect(current).toBeInstanceOf(Address);
505
- expect(current.city).toBe("LA");
506
- done();
507
- },
508
- },
509
- });
261
+ const changes = user.getTypedChanges();
262
+ const addressChanges = changes.for("Address");
510
263
 
511
- user.address = new Address({
512
- street: "Broadway",
513
- city: "LA",
514
- zipCode: "90001",
264
+ expect(addressChanges.hasUpdates()).toBe(true);
265
+ expect(addressChanges.updates[0].changed).toMatchObject({
266
+ street: "New Street",
515
267
  });
516
268
  });
517
269
 
518
- it("should track correct changes when assign all value", (done) => {
519
- const user = new User({
520
- id: new Id("1"),
521
- name: "John Doe",
522
- email: "john@example.com",
523
- posts: [],
524
- address: new Address({
525
- street: "Main St",
526
- city: "NYC",
527
- zipCode: "10001",
528
- }),
529
- comments: [
530
- new Comment({
531
- text: "Nice post!",
532
- author: "Alice",
533
- }),
534
- ],
535
- });
270
+ it("should detect replaced entity (different ID)", () => {
271
+ const oldAddress = createAddress("Old Street", "Old City");
272
+ const user = createUser({ address: oldAddress });
536
273
 
537
- let count = 0;
538
- const MAX_COUNT_TO_EXPECT = 3;
539
- user.subscribe({
540
- comments: {
541
- onChange: ({ toCreate, toDelete, toUpdate }) => {
542
- count++;
543
- if (count === MAX_COUNT_TO_EXPECT) {
544
- expect(toCreate).toHaveLength(2);
545
- expect(toDelete).toHaveLength(1);
546
- expect(toUpdate).toHaveLength(0);
547
- done();
548
- }
549
- },
550
- },
551
- });
274
+ const newAddress = createAddress("New Street", "New City");
275
+ user.setAddress(newAddress);
276
+
277
+ const changes = user.getTypedChanges();
278
+ const addressChanges = changes.for("Address");
552
279
 
553
- user.comments.push(
554
- new Comment({
555
- text: "Nice post2!",
556
- author: "Alice2",
557
- })
558
- );
559
- user.comments.push(
560
- new Comment({
561
- text: "Nice post3!",
562
- author: "Alice3",
563
- })
564
- );
565
-
566
- user.comments = [
567
- new Comment({
568
- text: "Nice post2!",
569
- author: "Alice2",
570
- }),
571
- new Comment({
572
- text: "Nice post3!",
573
- author: "Alice3",
574
- }),
575
- ];
280
+ expect(addressChanges.hasDeletes()).toBe(true);
281
+ expect(addressChanges.hasCreates()).toBe(true);
282
+ expect(addressChanges.deletes).toHaveLength(1);
283
+ expect(addressChanges.deletes[0].id.value).toBe(oldAddress.id.value);
284
+ expect(addressChanges.creates).toHaveLength(1);
285
+ expect(addressChanges.creates[0].id.value).toBe(newAddress.id.value);
576
286
  });
577
287
  });
578
288
 
579
- // ==========================================================================
580
- // History Tracking Tests
581
- // ==========================================================================
582
-
583
- describe("History Tracking", () => {
584
- it("should record history of changes", () => {
585
- const post = new Post({
586
- id: new Id("1"),
587
- title: "First Post",
588
- content: "Hello World",
589
- likes: 0,
590
- });
289
+ describe("Value Objects with identityKey", () => {
290
+ it("should detect added VOs using identityKey", () => {
291
+ const user = createUser({ tags: [] });
292
+
293
+ const tag = new TagReference({ tagId: "tag-1", name: "JavaScript" });
294
+ user.addTag(tag);
295
+
296
+ const changes = user.getTypedChanges();
297
+ const tagChanges = changes.for("TagReference");
298
+
299
+ expect(tagChanges.hasCreates()).toBe(true);
300
+ expect(tagChanges.creates).toHaveLength(1);
301
+ expect(tagChanges.creates[0].tagId).toBe("tag-1");
302
+ });
303
+
304
+ it("should detect removed VOs using identityKey", () => {
305
+ const tag = new TagReference({ tagId: "tag-1", name: "JavaScript" });
306
+ const user = createUser({ tags: [tag] });
307
+
308
+ user.removeTag(tag.tagId);
591
309
 
592
- post.title = "Second Title";
593
- post.likes = 10;
594
- post.content = "Updated Content";
310
+ const changes = user.getTypedChanges();
311
+ const tagChanges = changes.for("TagReference");
595
312
 
596
- const history = post.getHistory();
597
- expect(history).toHaveLength(3);
598
- expect(history[0].path).toBe("title");
599
- expect(history[1].path).toBe("likes");
600
- expect(history[2].path).toBe("content");
313
+ expect(tagChanges.hasDeletes()).toBe(true);
314
+ expect(tagChanges.deletes).toHaveLength(1);
315
+ expect(tagChanges.deletes[0].tagId).toBe("tag-1");
601
316
  });
602
317
 
603
- it("should clear history", () => {
604
- const post = new Post({
605
- id: new Id("1"),
606
- title: "First Post",
607
- content: "Hello World",
608
- likes: 0,
318
+ it("should use composite identityKey for Likes", () => {
319
+ const like = new Like({
320
+ postId: "post-1",
321
+ userId: "user-1",
322
+ createdAt: new Date(),
609
323
  });
324
+ const comment = createComment({ likes: [like] });
325
+ const post = createPost({ comments: [comment] });
326
+ const user = createUser({ posts: [post] });
610
327
 
611
- post.title = "Second Title";
612
- expect(post.getHistory()).toHaveLength(1);
328
+ user.posts[0].comments[0].removeLike(like.postId, like.userId);
613
329
 
614
- post.clearHistory();
615
- expect(post.getHistory()).toHaveLength(0);
330
+ const changes = user.getTypedChanges();
331
+ const likeChanges = changes.for("Like");
332
+
333
+ expect(likeChanges.hasDeletes()).toBe(true);
334
+ expect(likeChanges.deletes).toHaveLength(1);
335
+ expect(likeChanges.deletes[0].postId).toBe("post-1");
336
+ expect(likeChanges.deletes[0].userId).toBe("user-1");
616
337
  });
617
338
  });
618
339
 
619
- // ==========================================================================
620
- // Multiple Subscribers Test
621
- // ==========================================================================
622
-
623
- describe("Multiple Subscribers", () => {
624
- it("should notify all subscribers on change", () => {
625
- const post = new Post({
626
- id: new Id("1"),
627
- title: "First Post",
628
- content: "Hello World",
629
- likes: 0,
630
- });
340
+ describe("toBatchOperations", () => {
341
+ it("should group and order operations correctly", () => {
342
+ const comment = createComment();
343
+ const post = createPost({ comments: [comment] });
344
+ const address = createAddress();
345
+ const user = createUser({ posts: [post], address });
631
346
 
632
- let subscriber1Called = false;
633
- let subscriber2Called = false;
347
+ user.changeName("New Name"); // depth 0
348
+ user.address?.changeStreet("New Street"); // depth 1
349
+ user.posts[0].changeTitle("New Title"); // depth 1
350
+ user.posts[0].comments[0].changeText("New Comment"); // depth 2
634
351
 
635
- post.subscribe({
636
- title: {
637
- onChange: () => {
638
- subscriber1Called = true;
639
- },
640
- },
641
- });
352
+ const newPost = createPost({ title: "Brand New Post" });
353
+ user.addPost(newPost);
354
+
355
+ const changes = user.getTypedChanges();
356
+ const batch = changes.toBatchOperations();
642
357
 
643
- post.subscribe({
644
- title: {
645
- onChange: () => {
646
- subscriber2Called = true;
647
- },
648
- },
358
+ expect(batch.deletes).toHaveLength(0);
359
+
360
+ expect(batch.creates.length).toBe(1);
361
+ expect(batch.creates[0].entity).toBe("Post");
362
+ expect(batch.creates[0].depth).toBe(1);
363
+
364
+ expect(batch.updates.length).toBe(4);
365
+ });
366
+
367
+ it("should order deletes by depth DESC (leaf → root)", () => {
368
+ const like = new Like({
369
+ postId: "post-1",
370
+ userId: "user-1",
371
+ createdAt: new Date(),
649
372
  });
373
+ const comment = createComment({ likes: [like] });
374
+ const post = createPost({ comments: [comment] });
375
+ const user = createUser({ posts: [post] });
376
+
377
+ user.posts[0].comments[0].removeLike(like.postId, like.userId);
378
+ user.posts[0].comments = [];
379
+ user.posts = [];
380
+
381
+ const changes = user.getChanges();
382
+ const batch = changes.toBatchOperations();
383
+
384
+ // Like (depth 3) → Comment (depth 2) → Post (depth 1)
385
+ const likeIdx = batch.deletes.findIndex((d) => d.entity === "Like");
386
+ const commentIdx = batch.deletes.findIndex((d) => d.entity === "Comment");
387
+ const postIdx = batch.deletes.findIndex((d) => d.entity === "Post");
388
+
389
+ expect(batch.deletes).toHaveLength(3);
390
+ expect(likeIdx).toBeLessThan(commentIdx);
391
+ expect(commentIdx).toBeLessThan(postIdx);
392
+ });
650
393
 
651
- post.title = "Updated Title";
394
+ it("should order creates by depth ASC (root → leaf)", () => {
395
+ const user = createUser();
652
396
 
653
- expect(subscriber1Called).toBe(true);
654
- expect(subscriber2Called).toBe(true);
397
+ const newComment = createComment({ text: "New Comment" });
398
+ const newPost = createPost({ title: "New Post" });
399
+ user.addPost(newPost);
400
+ user.posts[0].addComment(newComment);
401
+
402
+ const changes = user.getTypedChanges();
403
+ const batch = changes.toBatchOperations();
404
+
405
+ const postIdx = batch.creates.findIndex((c) => c.entity === "Post");
406
+ const commentIdx = batch.creates.findIndex((c) => c.entity === "Comment");
407
+
408
+ expect(batch.creates.length).toBe(2);
409
+ expect(batch.creates[0].entity).toBe("Post");
410
+ expect(batch.creates[0].depth).toBe(1);
411
+ expect(batch.creates[1].entity).toBe("Comment");
412
+ expect(batch.creates[1].depth).toBe(2);
413
+ expect(postIdx).toBeLessThan(commentIdx);
655
414
  });
656
415
  });
657
416
 
658
- // ==========================================================================
659
- // Plain Object Tests
660
- // ==========================================================================
417
+ describe("markAsClean", () => {
418
+ it("should reset changes after clearHistory", () => {
419
+ const user = createUser();
661
420
 
662
- describe("Plain Object", () => {
663
- it("should create a plain object", () => {
664
- const user = new User({
665
- id: new Id("1"),
666
- name: "John Doe",
667
- email: "john@example.com",
668
- posts: [],
669
- address: new Address({
670
- street: "Main St",
671
- city: "NYC",
672
- zipCode: "10001",
673
- }),
674
- comments: [],
675
- });
421
+ user.changeName("Changed Name");
676
422
 
677
- user.subscribe({
678
- email: {
679
- onChange: ({ previous, current }) => {
680
- expect(previous).toBe("john@example.com");
681
- expect(current).toBe("new@example.com");
682
- },
683
- },
684
- extra: {
685
- onChange: ({ previous, current }) => {
686
- expect(previous).toBe(undefined);
687
- expect(current).toEqual({
688
- age: 20,
689
- height: 180,
690
- });
691
- },
692
- },
693
- address: {
694
- onChange: ({ previous, current }) => {
695
- expect(previous).toBeInstanceOf(Address);
696
- expect(current).toBeInstanceOf(Address);
697
- },
698
- },
699
- });
423
+ expect(user.getChanges().hasChanges()).toBe(true);
700
424
 
701
- user.changeExtra({
702
- age: 20,
703
- height: 180,
704
- });
425
+ user.markAsClean();
426
+
427
+ expect(user.getChanges().isEmpty()).toBe(true);
428
+ });
429
+
430
+ it("should reset changes after markAsClean", () => {
431
+ const user = createUser();
432
+
433
+ user.changeName("Changed Name");
434
+ user.markAsClean();
435
+
436
+ expect(user.getChanges().isEmpty()).toBe(true);
705
437
  });
706
438
  });
707
439
  });