@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,7 +1,3 @@
1
- // ============================================================================
2
- // Value Object - Immutable Domain Objects
3
- // ============================================================================
4
-
5
1
  import { ValidationError } from "./validation-error";
6
2
  import { IDomainEvent } from ".";
7
3
  import {
@@ -13,7 +9,6 @@ import {
13
9
  import { DEFAULT_VALIDATION_CONFIG } from "./constants";
14
10
  import { DomainError } from "./exceptions";
15
11
 
16
- // Helper to get static properties from constructor
17
12
  function getStaticProperty<T>(
18
13
  instance: any,
19
14
  propertyName: string
@@ -21,6 +16,11 @@ function getStaticProperty<T>(
21
16
  return instance.constructor[propertyName];
22
17
  }
23
18
 
19
+ /**
20
+ * Identity key type for a Value Object. Can be a single key or an array of keys (composite key).
21
+ */
22
+ export type IdentityKeyDefinition<T> = (keyof T)[] | keyof T;
23
+
24
24
  export abstract class ValueObject<T> {
25
25
  protected readonly props!: T;
26
26
  private validationConfig: Required<ValidationConfig>;
@@ -28,12 +28,29 @@ export abstract class ValueObject<T> {
28
28
  private domainSchema?: StandardSchema<T>;
29
29
  private domainEvents: IDomainEvent[] = [];
30
30
 
31
- // Static properties that subclasses can override
32
31
  protected static validation?: EntityValidation<any>;
33
32
  protected static hooks?: VOHooks<any, any>;
34
33
 
34
+ /**
35
+ * Identity key for identification in collections.
36
+ * Used by ChangeTracker to track changes in arrays of Value Objects.
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * // Simple key
41
+ * class TagReference extends ValueObject<{ tagId: string }> {
42
+ * static readonly identityKey = 'tagId';
43
+ * }
44
+ *
45
+ * // Composite key
46
+ * class Like extends ValueObject<{ postId: string; userId: string }> {
47
+ * static readonly identityKey = ['postId', 'userId'];
48
+ * }
49
+ * ```
50
+ */
51
+ protected static identityKey?: IdentityKeyDefinition<any>;
52
+
35
53
  constructor(props: T) {
36
- // Get static configuration from subclass
37
54
  const validation = getStaticProperty<EntityValidation<T>>(
38
55
  this,
39
56
  "validation"
@@ -53,23 +70,18 @@ export abstract class ValueObject<T> {
53
70
 
54
71
  let finalProps = { ...props } as T;
55
72
 
56
- // Validate schema on creation
57
73
  if (this.domainSchema && this.validationConfig.onCreate) {
58
74
  this.validateProps(finalProps);
59
75
  }
60
76
 
61
- // Set props (not frozen yet) so rules can access them
62
77
  (this as any).props = finalProps;
63
78
 
64
- // Execute rules (custom validations) - after props is set but before freezing
65
79
  if (hooks?.rules) {
66
80
  hooks.rules(this as any);
67
81
  }
68
82
 
69
- // Now freeze the props for immutability
70
83
  Object.freeze(this.props);
71
84
 
72
- // Hook onCreate
73
85
  if (hooks?.onCreate) {
74
86
  hooks.onCreate(this as any);
75
87
  }
@@ -98,7 +110,6 @@ export abstract class ValueObject<T> {
98
110
  throw validationError;
99
111
  }
100
112
 
101
- // If not throwing, store error for later retrieval
102
113
  (this as any)._validationError = validationError;
103
114
  }
104
115
  }
@@ -107,63 +118,112 @@ export abstract class ValueObject<T> {
107
118
  if (pathSegment === null || pathSegment === undefined) {
108
119
  return "";
109
120
  }
110
- // Handle PropertyKey (string | number | symbol)
111
121
  if (typeof pathSegment === "string" || typeof pathSegment === "number") {
112
122
  return String(pathSegment);
113
123
  }
114
124
  if (typeof pathSegment === "symbol") {
115
125
  return pathSegment.toString();
116
126
  }
117
- // Handle object with 'key' property (Zod's PathSegment)
118
127
  if (typeof pathSegment === "object" && "key" in pathSegment) {
119
128
  return String((pathSegment as { key: unknown }).key);
120
129
  }
121
- // Fallback
122
130
  return String(pathSegment);
123
131
  }
124
132
 
125
133
  /**
126
- * Check if value object has validation errors (when throwOnError is false)
134
+ * Returns true if the value object has validation errors (when throwOnError is false).
127
135
  */
128
136
  get hasValidationErrors(): boolean {
129
137
  return !!(this as any)._validationError;
130
138
  }
131
139
 
132
140
  /**
133
- * Get validation errors (when throwOnError is false)
141
+ * Returns the validation errors (when throwOnError is false).
134
142
  */
135
143
  get validationErrors(): ValidationError | undefined {
136
144
  return (this as any)._validationError;
137
145
  }
138
146
 
147
+ /**
148
+ * Compare this ValueObject with another for equality based on their properties.
149
+ */
139
150
  equals(other: ValueObject<T>): boolean {
140
151
  if (!other || !(other instanceof ValueObject)) return false;
141
152
  return JSON.stringify(this.props) === JSON.stringify(other.props);
142
153
  }
143
154
 
144
155
  /**
145
- * Add a domain event to this value object
156
+ * Returns the identity key for this Value Object.
157
+ * Used for identification in collections when identityKey is set.
158
+ *
159
+ * @returns String with the identity key or null if not defined
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * const like = new Like({ postId: 'p1', userId: 'u1' });
164
+ * like.getIdentityKey(); // 'p1:u1'
165
+ *
166
+ * const tag = new TagReference({ tagId: 'tag-123' });
167
+ * tag.getIdentityKey(); // 'tag-123'
168
+ * ```
169
+ */
170
+ getIdentityKey(): string | null {
171
+ const keyDef = getStaticProperty<IdentityKeyDefinition<T>>(
172
+ this,
173
+ "identityKey"
174
+ );
175
+
176
+ if (!keyDef) {
177
+ return null;
178
+ }
179
+
180
+ if (Array.isArray(keyDef)) {
181
+ return keyDef.map((k) => String(this.props[k])).join(":");
182
+ }
183
+
184
+ return String(this.props[keyDef]);
185
+ }
186
+
187
+ /**
188
+ * Returns true if this Value Object has an identity key defined.
189
+ */
190
+ hasIdentityKey(): boolean {
191
+ return (
192
+ getStaticProperty<IdentityKeyDefinition<T>>(this, "identityKey") !==
193
+ undefined
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Returns the identity key definition (if any).
199
+ */
200
+ static getIdentityKeyDefinition<P>(): IdentityKeyDefinition<P> | undefined {
201
+ return (this as any).identityKey;
202
+ }
203
+
204
+ /**
205
+ * Adds a domain event to this value object.
146
206
  */
147
207
  protected addDomainEvent(event: IDomainEvent): void {
148
208
  this.domainEvents.push(event);
149
209
  }
150
210
 
151
211
  /**
152
- * Get all uncommitted domain events
212
+ * Returns all uncommitted domain events.
153
213
  */
154
214
  getUncommittedEvents(): IDomainEvent[] {
155
215
  return [...this.domainEvents];
156
216
  }
157
217
 
158
218
  /**
159
- * Clear all domain events (call after publishing)
219
+ * Clears all domain events (call after publishing).
160
220
  */
161
221
  clearEvents(): void {
162
222
  this.domainEvents = [];
163
223
  }
164
224
 
165
225
  /**
166
- * Check if value object has uncommitted events
226
+ * Returns true if the value object has uncommitted events.
167
227
  */
168
228
  hasUncommittedEvents(): boolean {
169
229
  return this.domainEvents.length > 0;
@@ -174,8 +234,8 @@ export abstract class ValueObject<T> {
174
234
  }
175
235
 
176
236
  /**
177
- * Create a new ValueObject with updated properties
178
- * Since ValueObjects are immutable, this returns a new instance
237
+ * Creates a new ValueObject with updated properties.
238
+ * ValueObjects are immutable, so this returns a new instance.
179
239
  */
180
240
  protected clone(updates: Partial<T>): this {
181
241
  const Constructor = this.constructor as new (props: T) => this;
@@ -0,0 +1,284 @@
1
+ // ============================================================================
2
+ // Tests: AggregateChanges
3
+ // ============================================================================
4
+
5
+ import { AggregateChanges } from "../src/aggregate-changes";
6
+
7
+ describe("AggregateChanges", () => {
8
+ let changes: AggregateChanges;
9
+
10
+ beforeEach(() => {
11
+ changes = new AggregateChanges();
12
+ });
13
+
14
+ describe("isEmpty / hasChanges", () => {
15
+ it("should be empty initially", () => {
16
+ expect(changes.isEmpty()).toBe(true);
17
+ expect(changes.hasChanges()).toBe(false);
18
+ expect(changes.count).toBe(0);
19
+ });
20
+
21
+ it("should not be empty after adding operation", () => {
22
+ changes.addCreate("User", { id: "1", name: "Test" }, 0);
23
+ expect(changes.isEmpty()).toBe(false);
24
+ expect(changes.hasChanges()).toBe(true);
25
+ expect(changes.count).toBe(1);
26
+ });
27
+ });
28
+
29
+ describe("addCreate", () => {
30
+ it("should add create operation", () => {
31
+ const data = { id: "1", name: "Test User" };
32
+ changes.addCreate("User", data, 0);
33
+
34
+ expect(changes.hasCreates()).toBe(true);
35
+ expect(changes.creates()).toHaveLength(1);
36
+ expect(changes.creates()[0]).toMatchObject({
37
+ type: "create",
38
+ entity: "User",
39
+ data,
40
+ depth: 0,
41
+ });
42
+ });
43
+
44
+ it("should add create with parent info", () => {
45
+ const data = { id: "post-1", title: "Test Post" };
46
+ changes.addCreate("Post", data, 1, "user-1", "User");
47
+
48
+ const create = changes.creates()[0];
49
+ expect(create.parentId).toBe("user-1");
50
+ expect(create.parentEntity).toBe("User");
51
+ });
52
+ });
53
+
54
+ describe("addUpdate", () => {
55
+ it("should add update operation", () => {
56
+ const data = { id: "1", name: "Updated" };
57
+ const changedFields = { name: "Updated" };
58
+ changes.addUpdate("User", "1", data, changedFields, 0);
59
+
60
+ expect(changes.hasUpdates()).toBe(true);
61
+ expect(changes.updates()).toHaveLength(1);
62
+ expect(changes.updates()[0]).toMatchObject({
63
+ type: "update",
64
+ entity: "User",
65
+ id: "1",
66
+ changedFields,
67
+ depth: 0,
68
+ });
69
+ });
70
+ });
71
+
72
+ describe("addDelete", () => {
73
+ it("should add delete operation", () => {
74
+ const data = { id: "1", name: "To Delete" };
75
+ changes.addDelete("User", "1", data, 0);
76
+
77
+ expect(changes.hasDeletes()).toBe(true);
78
+ expect(changes.deletes()).toHaveLength(1);
79
+ expect(changes.deletes()[0]).toMatchObject({
80
+ type: "delete",
81
+ entity: "User",
82
+ id: "1",
83
+ depth: 0,
84
+ });
85
+ });
86
+ });
87
+
88
+ describe("ordering", () => {
89
+ beforeEach(() => {
90
+ // Add operations in random order
91
+ changes.addCreate("Comment", { id: "c1" }, 2);
92
+ changes.addDelete("Like", "l1", { id: "l1" }, 3);
93
+ changes.addCreate("User", { id: "u1" }, 0);
94
+ changes.addDelete("Comment", "c2", { id: "c2" }, 2);
95
+ changes.addCreate("Post", { id: "p1" }, 1);
96
+ changes.addDelete("Post", "p2", { id: "p2" }, 1);
97
+ });
98
+
99
+ it("should order creates by depth ASC (root → leaf)", () => {
100
+ const creates = changes.creates();
101
+ expect(creates[0].entity).toBe("User"); // depth 0
102
+ expect(creates[1].entity).toBe("Post"); // depth 1
103
+ expect(creates[2].entity).toBe("Comment"); // depth 2
104
+ });
105
+
106
+ it("should order deletes by depth DESC (leaf → root)", () => {
107
+ const deletes = changes.deletes();
108
+ expect(deletes[0].entity).toBe("Like"); // depth 3
109
+ expect(deletes[1].entity).toBe("Comment"); // depth 2
110
+ expect(deletes[2].entity).toBe("Post"); // depth 1
111
+ });
112
+ });
113
+
114
+ describe("operations iterator", () => {
115
+ it("should yield operations in correct order: deletes, creates, updates", () => {
116
+ changes.addCreate("Post", { id: "p1" }, 1);
117
+ changes.addUpdate("User", "u1", { id: "u1" }, { name: "New" }, 0);
118
+ changes.addDelete("Comment", "c1", { id: "c1" }, 2);
119
+
120
+ const ops = [...changes.operations()];
121
+
122
+ expect(ops[0].type).toBe("delete"); // deletes first
123
+ expect(ops[1].type).toBe("create"); // creates second
124
+ expect(ops[2].type).toBe("update"); // updates last
125
+ });
126
+ });
127
+
128
+ describe("toBatchOperations", () => {
129
+ beforeEach(() => {
130
+ // Creates
131
+ changes.addCreate("User", { id: "u1" }, 0);
132
+ changes.addCreate("Post", { id: "p1" }, 1, "u1", "User");
133
+ changes.addCreate("Post", { id: "p2" }, 1, "u1", "User");
134
+ changes.addCreate("Comment", { id: "c1" }, 2, "p1", "Post");
135
+
136
+ // Updates
137
+ changes.addUpdate("User", "u2", { id: "u2" }, { name: "Updated" }, 0);
138
+ changes.addUpdate("Post", "p3", { id: "p3" }, { title: "New Title" }, 1);
139
+
140
+ // Deletes
141
+ changes.addDelete("Comment", "c2", { id: "c2" }, 2);
142
+ changes.addDelete("Comment", "c3", { id: "c3" }, 2);
143
+ changes.addDelete("Post", "p4", { id: "p4" }, 1);
144
+ });
145
+
146
+ it("should group deletes by entity and order by depth DESC", () => {
147
+ const batch = changes.toBatchOperations();
148
+
149
+ expect(batch.deletes).toHaveLength(2); // Comment and Post
150
+
151
+ // Comments should come first (depth 2)
152
+ expect(batch.deletes[0].entity).toBe("Comment");
153
+ expect(batch.deletes[0].ids).toEqual(["c2", "c3"]);
154
+
155
+ // Posts second (depth 1)
156
+ expect(batch.deletes[1].entity).toBe("Post");
157
+ expect(batch.deletes[1].ids).toEqual(["p4"]);
158
+ });
159
+
160
+ it("should group creates by entity and order by depth ASC", () => {
161
+ const batch = changes.toBatchOperations();
162
+
163
+ expect(batch.creates).toHaveLength(3); // User, Post, Comment
164
+
165
+ // User first (depth 0)
166
+ expect(batch.creates[0].entity).toBe("User");
167
+ expect(batch.creates[0].items).toHaveLength(1);
168
+
169
+ // Post second (depth 1)
170
+ expect(batch.creates[1].entity).toBe("Post");
171
+ expect(batch.creates[1].items).toHaveLength(2);
172
+
173
+ // Comment last (depth 2)
174
+ expect(batch.creates[2].entity).toBe("Comment");
175
+ expect(batch.creates[2].items).toHaveLength(1);
176
+ });
177
+
178
+ it("should group updates by entity", () => {
179
+ const batch = changes.toBatchOperations();
180
+
181
+ expect(batch.updates).toHaveLength(2); // User and Post
182
+
183
+ const userUpdates = batch.updates.find((u) => u.entity === "User");
184
+ expect(userUpdates?.items).toHaveLength(1);
185
+ expect(userUpdates?.items[0].id).toBe("u2");
186
+
187
+ const postUpdates = batch.updates.find((u) => u.entity === "Post");
188
+ expect(postUpdates?.items).toHaveLength(1);
189
+ expect(postUpdates?.items[0].id).toBe("p3");
190
+ });
191
+
192
+ it("should include parentId in create items", () => {
193
+ const batch = changes.toBatchOperations();
194
+
195
+ const postCreates = batch.creates.find((c) => c.entity === "Post");
196
+ expect(postCreates?.items[0].parentId).toBe("u1");
197
+ expect(postCreates?.items[1].parentId).toBe("u1");
198
+
199
+ const commentCreates = batch.creates.find((c) => c.entity === "Comment");
200
+ expect(commentCreates?.items[0].parentId).toBe("p1");
201
+ });
202
+ });
203
+
204
+ describe("for (filter by entity)", () => {
205
+ beforeEach(() => {
206
+ changes.addCreate("Post", { id: "p1", title: "Post 1" }, 1);
207
+ changes.addCreate("Post", { id: "p2", title: "Post 2" }, 1);
208
+ changes.addUpdate("Post", "p3", { id: "p3" }, { title: "Updated" }, 1);
209
+ changes.addDelete("Post", "p4", { id: "p4" }, 1);
210
+ changes.addCreate("Comment", { id: "c1" }, 2);
211
+ });
212
+
213
+ it("should filter creates by entity", () => {
214
+ const postChanges = changes.for("Post");
215
+ expect(postChanges.creates).toHaveLength(2);
216
+ expect(postChanges.creates[0].id).toBe("p1");
217
+ });
218
+
219
+ it("should filter updates by entity", () => {
220
+ const postChanges = changes.for("Post");
221
+ expect(postChanges.updates).toHaveLength(1);
222
+ expect(postChanges.updates[0].entity.id).toBe("p3");
223
+ });
224
+
225
+ it("should filter deletes by entity", () => {
226
+ const postChanges = changes.for("Post");
227
+ expect(postChanges.deletes).toHaveLength(1);
228
+ expect(postChanges.deletes[0].id).toBe("p4");
229
+ });
230
+
231
+ it("should return empty for non-existent entity", () => {
232
+ const userChanges = changes.for("User");
233
+ expect(userChanges.isEmpty()).toBe(true);
234
+ });
235
+
236
+ it("should have helper methods", () => {
237
+ const postChanges = changes.for("Post");
238
+ expect(postChanges.hasCreates()).toBe(true);
239
+ expect(postChanges.hasUpdates()).toBe(true);
240
+ expect(postChanges.hasDeletes()).toBe(true);
241
+ expect(postChanges.hasChanges()).toBe(true);
242
+ });
243
+ });
244
+
245
+ describe("getAffectedEntities", () => {
246
+ it("should return list of unique entities", () => {
247
+ changes.addCreate("User", { id: "u1" }, 0);
248
+ changes.addCreate("Post", { id: "p1" }, 1);
249
+ changes.addUpdate("Post", "p2", { id: "p2" }, {}, 1);
250
+ changes.addDelete("Comment", "c1", { id: "c1" }, 2);
251
+
252
+ const entities = changes.getAffectedEntities();
253
+
254
+ expect(entities).toContain("User");
255
+ expect(entities).toContain("Post");
256
+ expect(entities).toContain("Comment");
257
+ expect(entities).toHaveLength(3);
258
+ });
259
+ });
260
+
261
+ describe("clone", () => {
262
+ it("should create independent copy", () => {
263
+ changes.addCreate("User", { id: "u1" }, 0);
264
+
265
+ const cloned = changes.clone();
266
+ cloned.addCreate("Post", { id: "p1" }, 1);
267
+
268
+ expect(changes.count).toBe(1);
269
+ expect(cloned.count).toBe(2);
270
+ });
271
+ });
272
+
273
+ describe("clear", () => {
274
+ it("should remove all operations", () => {
275
+ changes.addCreate("User", { id: "u1" }, 0);
276
+ changes.addUpdate("Post", "p1", { id: "p1" }, {}, 1);
277
+
278
+ changes.clear();
279
+
280
+ expect(changes.isEmpty()).toBe(true);
281
+ expect(changes.count).toBe(0);
282
+ });
283
+ });
284
+ });