@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
@@ -1,14 +1,8 @@
1
- // ============================================================================
2
- // Base Entity Class with Standard Schema Validation
3
- // ============================================================================
4
-
5
1
  import { Id } from "./id";
6
- import { DeepProxy } from "./deep-proxy";
7
2
  import { ValidationError } from "./validation-error";
8
3
  import { IDomainEvent } from ".";
9
4
  import {
10
5
  BaseProps,
11
- SubscriptionConfig,
12
6
  HistoryEntry,
13
7
  DeepJsonResult,
14
8
  EntityHooks,
@@ -19,6 +13,8 @@ import {
19
13
  import { DomainEventBus } from "./domain-event-bus";
20
14
  import { DEFAULT_VALIDATION_CONFIG } from "./constants";
21
15
  import { DomainError } from "./exceptions";
16
+ import { HistoryTracker } from "./history-tracker";
17
+ import { AggregateChanges } from "./aggregate-changes";
22
18
 
23
19
  // Helper to get static properties from constructor
24
20
  function getStaticProperty<T>(
@@ -30,7 +26,7 @@ function getStaticProperty<T>(
30
26
 
31
27
  export abstract class BaseEntity<T extends BaseProps> {
32
28
  private _props: T;
33
- private proxy: DeepProxy;
29
+ private tracker: HistoryTracker;
34
30
  private proxiedProps: T;
35
31
  private snapshot: T | null = null;
36
32
  private validationConfig: Required<ValidationConfig>;
@@ -74,8 +70,16 @@ export abstract class BaseEntity<T extends BaseProps> {
74
70
  }
75
71
 
76
72
  this._props = finalProps;
77
- this.proxy = new DeepProxy(this._props);
78
- this.proxiedProps = this.proxy.createProxy();
73
+
74
+ // Initialize tracker and proxy
75
+ this.tracker = new HistoryTracker(this._props, this.constructor.name);
76
+
77
+ // Setup validation on update BEFORE creating proxy
78
+ if (this.validationConfig.onUpdate) {
79
+ this.setupUpdateValidation();
80
+ }
81
+
82
+ this.proxiedProps = this.tracker.createProxy();
79
83
 
80
84
  // Execute rules (custom validations)
81
85
  if (hooks?.rules) {
@@ -87,15 +91,14 @@ export abstract class BaseEntity<T extends BaseProps> {
87
91
  hooks.onCreate(this as any);
88
92
  }
89
93
 
90
- // Setup update validation
91
- if (this.entitySchema && this.validationConfig.onUpdate) {
92
- this.setupUpdateValidation();
93
- }
94
-
95
94
  // Take initial snapshot for onBeforeUpdate
96
95
  this.takeSnapshot();
97
96
  }
98
97
 
98
+ // ============================================================================
99
+ // Validation
100
+ // ============================================================================
101
+
99
102
  private validateProps(props: T): void {
100
103
  if (!this.entitySchema) return;
101
104
 
@@ -119,7 +122,6 @@ export abstract class BaseEntity<T extends BaseProps> {
119
122
  throw validationError;
120
123
  }
121
124
 
122
- // If not throwing, store error for later retrieval
123
125
  (this as any)._validationError = validationError;
124
126
  }
125
127
  }
@@ -128,90 +130,105 @@ export abstract class BaseEntity<T extends BaseProps> {
128
130
  if (pathSegment === null || pathSegment === undefined) {
129
131
  return "";
130
132
  }
131
- // Handle PropertyKey (string | number | symbol)
132
133
  if (typeof pathSegment === "string" || typeof pathSegment === "number") {
133
134
  return String(pathSegment);
134
135
  }
135
136
  if (typeof pathSegment === "symbol") {
136
137
  return pathSegment.toString();
137
138
  }
138
- // Handle object with 'key' property (Zod's PathSegment)
139
139
  if (typeof pathSegment === "object" && "key" in pathSegment) {
140
140
  return String((pathSegment as { key: unknown }).key);
141
141
  }
142
- // Fallback
143
142
  return String(pathSegment);
144
143
  }
145
144
 
145
+ /**
146
+ * Setup validation that runs on every property change.
147
+ * Uses the HistoryTracker's onChangeValidator callback.
148
+ */
146
149
  private setupUpdateValidation(): void {
147
150
  const self = this;
148
151
 
149
- const validateOnChange = () => {
150
- // Check onBeforeUpdate hook
151
- if (self.entityHooks?.onBeforeUpdate && self.snapshot) {
152
- const shouldContinue = self.entityHooks.onBeforeUpdate(
153
- self as any,
154
- self.snapshot
155
- );
156
- if (!shouldContinue) {
157
- // Revert changes
158
- Object.assign(self._props, self.snapshot);
159
- return;
152
+ this.tracker.setOnChangeValidator((path, oldValue, newValue) => {
153
+ // Temporarily apply the change to validate
154
+ const originalValue = self._props[path as keyof T];
155
+ (self._props as any)[path] = newValue;
156
+
157
+ try {
158
+ // Check onBeforeUpdate hook
159
+ if (self.entityHooks?.onBeforeUpdate && self.snapshot) {
160
+ const shouldContinue = self.entityHooks.onBeforeUpdate(
161
+ self as any,
162
+ self.snapshot
163
+ );
164
+ if (!shouldContinue) {
165
+ // Revert change
166
+ (self._props as any)[path] = originalValue;
167
+ return false;
168
+ }
160
169
  }
161
- }
162
170
 
163
- // Validate with schema
164
- if (self.entitySchema) {
165
- const result = self.entitySchema["~standard"].validate(self._props);
171
+ // Validate with schema
172
+ if (self.entitySchema) {
173
+ const result = self.entitySchema["~standard"].validate(self._props);
166
174
 
167
- if (result instanceof Promise) {
168
- console.warn(
169
- "Async validation on update not supported. Consider using sync validation."
170
- );
171
- return;
172
- }
175
+ if (result instanceof Promise) {
176
+ console.warn(
177
+ "Async validation on update not supported. Consider using sync validation."
178
+ );
179
+ (self._props as any)[path] = originalValue;
180
+ return true;
181
+ }
173
182
 
174
- if (result.issues && result.issues.length > 0) {
175
- const validationError = new ValidationError(
176
- result.issues.map((issue) => ({
177
- path: issue.path?.map((p) => self.extractPathKey(p)) || [],
178
- message: issue.message,
179
- }))
180
- );
183
+ if (result.issues && result.issues.length > 0) {
184
+ const validationError = new ValidationError(
185
+ result.issues.map((issue) => ({
186
+ path: issue.path?.map((p) => self.extractPathKey(p)) || [],
187
+ message: issue.message,
188
+ }))
189
+ );
181
190
 
182
- if (self.validationConfig.throwOnError) {
183
- // Revert changes before throwing
184
- if (self.snapshot) {
185
- Object.assign(self._props, self.snapshot);
191
+ // Revert change before throwing
192
+ (self._props as any)[path] = originalValue;
193
+
194
+ if (self.validationConfig.throwOnError) {
195
+ throw validationError;
186
196
  }
187
- throw validationError;
197
+
198
+ console.error("Validation failed on update:", validationError);
199
+ return false;
188
200
  }
189
- console.error("Validation failed on update:", validationError);
190
201
  }
191
- }
192
202
 
193
- // Execute rules after schema validation
194
- if (self.entityHooks?.rules) {
195
- try {
196
- self.entityHooks.rules(self as any);
197
- } catch (error) {
198
- if (self.validationConfig.throwOnError) {
199
- // Revert changes before throwing
200
- if (self.snapshot) {
201
- Object.assign(self._props, self.snapshot);
203
+ // Execute rules after schema validation
204
+ if (self.entityHooks?.rules) {
205
+ try {
206
+ self.entityHooks.rules(self as any);
207
+ } catch (error) {
208
+ // Revert change before throwing
209
+ (self._props as any)[path] = originalValue;
210
+
211
+ if (self.validationConfig.throwOnError) {
212
+ throw error;
202
213
  }
203
- throw error;
214
+
215
+ console.error("Rules validation failed on update:", error);
216
+ return false;
204
217
  }
205
- console.error("Rules validation failed on update:", error);
206
218
  }
207
- }
208
219
 
209
- // Update snapshot after successful validation
210
- self.takeSnapshot();
211
- };
220
+ // Revert for now - the actual set will happen in the proxy
221
+ (self._props as any)[path] = originalValue;
212
222
 
213
- // Subscribe to all changes
214
- this.proxy.subscribe("*", validateOnChange);
223
+ // Update snapshot after successful validation
224
+ // Note: snapshot is updated after the change is applied
225
+ return true;
226
+ } catch (error) {
227
+ // Revert on any error
228
+ (self._props as any)[path] = originalValue;
229
+ throw error;
230
+ }
231
+ });
215
232
  }
216
233
 
217
234
  private takeSnapshot(): void {
@@ -220,36 +237,28 @@ export abstract class BaseEntity<T extends BaseProps> {
220
237
 
221
238
  private deepCloneProps(obj: any, seen: WeakSet<object> = new WeakSet()): any {
222
239
  if (obj === null || obj === undefined) return obj;
223
-
224
- // Primitives
225
240
  if (typeof obj !== "object") return obj;
226
-
227
- // Special cases - don't clone these, just return the reference
228
241
  if (obj instanceof Id) return obj;
229
242
  if (obj instanceof Date) return new Date(obj.getTime());
230
243
 
231
- // Check for circular references
232
244
  if (seen.has(obj)) {
233
- return obj; // Return reference to avoid infinite loop
245
+ return obj;
234
246
  }
235
247
 
236
- // Handle BaseEntity instances - just keep the reference
237
248
  if (obj instanceof BaseEntity) {
238
249
  return obj;
239
250
  }
240
251
 
241
- // Handle ValueObject instances - just keep the reference (they're immutable)
242
252
  if (
243
253
  obj.constructor &&
244
254
  obj.constructor.name !== "Object" &&
245
255
  obj.constructor.name !== "Array"
246
256
  ) {
247
- // Check if it has toJson method (likely a ValueObject or similar)
248
257
  if (
249
258
  typeof obj.toJson === "function" &&
250
259
  typeof obj.equals === "function"
251
260
  ) {
252
- return obj; // Keep reference for ValueObjects
261
+ return obj;
253
262
  }
254
263
  }
255
264
 
@@ -259,7 +268,6 @@ export abstract class BaseEntity<T extends BaseProps> {
259
268
  return obj.map((item) => this.deepCloneProps(item, seen));
260
269
  }
261
270
 
262
- // Plain objects only
263
271
  if (obj.constructor === Object) {
264
272
  const cloned: any = {};
265
273
  for (const key in obj) {
@@ -270,10 +278,13 @@ export abstract class BaseEntity<T extends BaseProps> {
270
278
  return cloned;
271
279
  }
272
280
 
273
- // For other object types (custom classes), just keep the reference
274
281
  return obj;
275
282
  }
276
283
 
284
+ // ============================================================================
285
+ // Identity
286
+ // ============================================================================
287
+
277
288
  get id(): Id {
278
289
  return this._props.id;
279
290
  }
@@ -284,27 +295,20 @@ export abstract class BaseEntity<T extends BaseProps> {
284
295
 
285
296
  /**
286
297
  * Check equality with another entity by comparing IDs
287
- * Entities are equal if they have the same ID, regardless of other properties
288
- *
289
- * @param other - Another entity or an ID to compare with
290
- * @returns true if entities have the same ID
291
298
  */
292
299
  equals(other: BaseEntity<T> | Id | string): boolean {
293
300
  if (!other) {
294
301
  return false;
295
302
  }
296
303
 
297
- // Compare with another entity
298
304
  if (other instanceof BaseEntity) {
299
305
  return this.id.equals(other.id);
300
306
  }
301
307
 
302
- // Compare with an ID
303
308
  if (other instanceof Id) {
304
309
  return this.id.equals(other);
305
310
  }
306
311
 
307
- // Compare with a string ID
308
312
  if (typeof other === "string") {
309
313
  return this.id.equals(other);
310
314
  }
@@ -312,6 +316,10 @@ export abstract class BaseEntity<T extends BaseProps> {
312
316
  return false;
313
317
  }
314
318
 
319
+ // ============================================================================
320
+ // Props Access
321
+ // ============================================================================
322
+
315
323
  public get props(): T {
316
324
  return this.proxiedProps;
317
325
  }
@@ -330,23 +338,47 @@ export abstract class BaseEntity<T extends BaseProps> {
330
338
  return (this as any)._validationError;
331
339
  }
332
340
 
333
- subscribe(config: SubscriptionConfig<T>): void {
334
- Object.keys(config).forEach((key) => {
335
- const sub = config[key as keyof T];
336
- if (sub && "onChange" in sub) {
337
- this.proxy.subscribe(key, sub.onChange);
338
- }
339
- });
341
+ // ============================================================================
342
+ // Change Tracking
343
+ // ============================================================================
344
+
345
+ /**
346
+ * Returns all detected changes as AggregateChanges.
347
+ *
348
+ * @example
349
+ * ```typescript
350
+ * const changes = user.getChanges();
351
+ * const batch = changes.toBatchOperations();
352
+ *
353
+ * for (const del of batch.deletes) { ... }
354
+ * for (const create of batch.creates) { ... }
355
+ * for (const upd of batch.updates) { ... }
356
+ * ```
357
+ */
358
+ getChanges<TEntityMap = Record<string, any>>(): AggregateChanges<TEntityMap> {
359
+ return this.tracker.getChanges<TEntityMap>();
340
360
  }
341
361
 
362
+ /**
363
+ * Returns the change history (for debugging).
364
+ */
342
365
  getHistory(): HistoryEntry[] {
343
- return this.proxy.getHistory();
366
+ return this.tracker.getHistory();
344
367
  }
345
368
 
346
- clearHistory(): void {
347
- this.proxy.clearHistory();
369
+ /**
370
+ * Clears history and marks entity as "clean".
371
+ * Call this after successfully persisting to the database.
372
+ */
373
+ markAsClean(): void {
374
+ this.tracker.markAsClean();
375
+ this.takeSnapshot();
348
376
  }
349
377
 
378
+ // ============================================================================
379
+ // Domain Events
380
+ // ============================================================================
381
+
350
382
  /**
351
383
  * Add a domain event to this entity
352
384
  */
@@ -354,8 +386,12 @@ export abstract class BaseEntity<T extends BaseProps> {
354
386
  this.domainEvents.push(event);
355
387
  }
356
388
 
357
- public async dispatchAll(bus: DomainEventBus) {
389
+ /**
390
+ * Dispatch all events through the event bus
391
+ */
392
+ public async dispatchAll(bus: DomainEventBus): Promise<void> {
358
393
  await bus.publishAll(this.getUncommittedEvents());
394
+ this.clearEvents();
359
395
  }
360
396
 
361
397
  /**
@@ -379,6 +415,10 @@ export abstract class BaseEntity<T extends BaseProps> {
379
415
  return this.domainEvents.length > 0;
380
416
  }
381
417
 
418
+ // ============================================================================
419
+ // Serialization
420
+ // ============================================================================
421
+
382
422
  toJson(): DeepJsonResult<T> {
383
423
  return this.deepToJson(this._props) as DeepJsonResult<T>;
384
424
  }
package/src/criteria.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  PathValue,
14
14
  Search,
15
15
  TypedFilter,
16
+ TypedOrder,
16
17
  } from "./types";
17
18
  import {
18
19
  isValidOperatorForType,
@@ -278,7 +279,7 @@ export class Criteria<T = any> {
278
279
  static fromObject<T>(
279
280
  obj: {
280
281
  filters?: TypedFilter<T>[];
281
- orders?: Order[];
282
+ orders?: TypedOrder<T>[];
282
283
  pagination?: Pagination;
283
284
  search?: { fields: FieldPath<T>[]; value: string };
284
285
  },
package/src/crypto.ts ADDED
@@ -0,0 +1,31 @@
1
+ const customCrypto = {
2
+ randomUUID: () => {
3
+ if (
4
+ typeof globalThis !== "undefined" &&
5
+ globalThis?.crypto &&
6
+ typeof globalThis.crypto.randomUUID === "function"
7
+ ) {
8
+ return globalThis.crypto.randomUUID();
9
+ }
10
+
11
+ const hexChars = "0123456789abcdef";
12
+ let uuid = "";
13
+
14
+ for (let i = 0; i < 36; i++) {
15
+ if (i === 8 || i === 13 || i === 18 || i === 23) {
16
+ uuid += "-";
17
+ } else if (i === 14) {
18
+ uuid += "4";
19
+ } else if (i === 19) {
20
+ uuid += hexChars.charAt(Math.floor(Math.random() * 4) + 8);
21
+ } else {
22
+ uuid += hexChars.charAt(Math.floor(Math.random() * 16));
23
+ }
24
+ }
25
+
26
+ return uuid;
27
+ },
28
+ };
29
+
30
+ export const UUID = customCrypto.randomUUID;
31
+ export default UUID;
@@ -0,0 +1,151 @@
1
+ // ============================================================================
2
+ // Entity Changes
3
+ // Changes filtered by entity type
4
+ // ============================================================================
5
+
6
+ import {
7
+ Operation,
8
+ CreateOperation,
9
+ UpdateOperation,
10
+ DeleteOperation,
11
+ } from "./types/change-tracker";
12
+
13
+ /**
14
+ * Represents the changes filtered for a specific entity.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const changes = user.getChanges();
19
+ * const postChanges = changes.for('Post');
20
+ *
21
+ * if (postChanges.hasCreates()) {
22
+ * console.log('Created posts:', postChanges.creates);
23
+ * }
24
+ *
25
+ * if (postChanges.hasUpdates()) {
26
+ * postChanges.updates.forEach(({ entity, changed }) => {
27
+ * console.log(`Post ${entity.id} has changed:`, changed);
28
+ * });
29
+ * }
30
+ * ```
31
+ */
32
+ export class EntityChanges<T = any> {
33
+ constructor(private readonly operations: Operation<T>[]) {}
34
+
35
+ /**
36
+ * Returns all created entities
37
+ */
38
+ get creates(): T[] {
39
+ return this.operations
40
+ .filter((op): op is CreateOperation<T> => op.type === "create")
41
+ .map((op) => op.data);
42
+ }
43
+
44
+ /**
45
+ * Returns all updated entities with their changed fields
46
+ */
47
+ get updates(): Array<{ entity: T; changed: Record<string, any> }> {
48
+ return this.operations
49
+ .filter((op): op is UpdateOperation<T> => op.type === "update")
50
+ .map((op) => ({
51
+ entity: op.data,
52
+ changed: op.changedFields,
53
+ }));
54
+ }
55
+
56
+ /**
57
+ * Returns all deleted entities
58
+ */
59
+ get deletes(): T[] {
60
+ return this.operations
61
+ .filter((op): op is DeleteOperation<T> => op.type === "delete")
62
+ .map((op) => op.data);
63
+ }
64
+
65
+ /**
66
+ * Returns the IDs of the created entities
67
+ */
68
+ get createIds(): string[] {
69
+ return this.operations
70
+ .filter((op): op is CreateOperation<T> => op.type === "create")
71
+ .map((op) => this.extractId(op.data))
72
+ .filter((id): id is string => id !== undefined);
73
+ }
74
+
75
+ /**
76
+ * Returns the IDs of the updated entities
77
+ */
78
+ get updateIds(): string[] {
79
+ return this.operations
80
+ .filter((op): op is UpdateOperation<T> => op.type === "update")
81
+ .map((op) => op.id);
82
+ }
83
+
84
+ /**
85
+ * Returns the IDs of the deleted entities
86
+ */
87
+ get deleteIds(): string[] {
88
+ return this.operations
89
+ .filter((op): op is DeleteOperation<T> => op.type === "delete")
90
+ .map((op) => op.id);
91
+ }
92
+
93
+ /**
94
+ * Checks if there are any creates
95
+ */
96
+ hasCreates(): boolean {
97
+ return this.creates.length > 0;
98
+ }
99
+
100
+ /**
101
+ * Checks if there are any updates
102
+ */
103
+ hasUpdates(): boolean {
104
+ return this.updates.length > 0;
105
+ }
106
+
107
+ /**
108
+ * Checks if there are any deletes
109
+ */
110
+ hasDeletes(): boolean {
111
+ return this.deletes.length > 0;
112
+ }
113
+
114
+ /**
115
+ * Checks if there is any change
116
+ */
117
+ hasChanges(): boolean {
118
+ return this.operations.length > 0;
119
+ }
120
+
121
+ /**
122
+ * Checks if it is empty (no changes)
123
+ */
124
+ isEmpty(): boolean {
125
+ return this.operations.length === 0;
126
+ }
127
+
128
+ /**
129
+ * Returns the total number of operations
130
+ */
131
+ get count(): number {
132
+ return this.operations.length;
133
+ }
134
+
135
+ /**
136
+ * Returns the raw operations (for advanced use cases)
137
+ */
138
+ get rawOperations(): Operation<T>[] {
139
+ return [...this.operations];
140
+ }
141
+
142
+ /**
143
+ * Extracts the ID from an entity
144
+ */
145
+ private extractId(entity: any): string | undefined {
146
+ if (!entity) return undefined;
147
+ if (entity.id?.value) return entity.id.value;
148
+ if (typeof entity.id === "string") return entity.id;
149
+ return undefined;
150
+ }
151
+ }