@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.
- package/CHANGELOG.md +58 -0
- package/dist/aggregate-changes.d.ts +164 -0
- package/dist/aggregate-changes.d.ts.map +1 -0
- package/dist/aggregate-changes.js +281 -0
- package/dist/aggregate-changes.js.map +1 -0
- package/dist/base-entity.d.ts +32 -8
- package/dist/base-entity.d.ts.map +1 -1
- package/dist/base-entity.js +86 -93
- package/dist/base-entity.js.map +1 -1
- package/dist/change-tracker.d.ts +97 -0
- package/dist/change-tracker.d.ts.map +1 -0
- package/dist/change-tracker.js +758 -0
- package/dist/change-tracker.js.map +1 -0
- package/dist/constants.d.ts +7 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +65 -0
- package/dist/constants.js.map +1 -1
- package/dist/criteria.d.ts +3 -3
- package/dist/criteria.d.ts.map +1 -1
- package/dist/criteria.js +6 -4
- package/dist/criteria.js.map +1 -1
- package/dist/crypto.d.ts +3 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +29 -0
- package/dist/crypto.js.map +1 -0
- package/dist/domain-event.d.ts.map +1 -1
- package/dist/domain-event.js +0 -3
- package/dist/domain-event.js.map +1 -1
- package/dist/entity-changes.d.ts +84 -0
- package/dist/entity-changes.d.ts.map +1 -0
- package/dist/entity-changes.js +131 -0
- package/dist/entity-changes.js.map +1 -0
- package/dist/entity-schema-registry.d.ts +148 -0
- package/dist/entity-schema-registry.d.ts.map +1 -0
- package/dist/entity-schema-registry.js +213 -0
- package/dist/entity-schema-registry.js.map +1 -0
- package/dist/entity.d.ts +0 -6
- package/dist/entity.d.ts.map +1 -1
- package/dist/entity.js +0 -9
- package/dist/entity.js.map +1 -1
- package/dist/id.d.ts +11 -10
- package/dist/id.d.ts.map +1 -1
- package/dist/id.js +4 -28
- package/dist/id.js.map +1 -1
- package/dist/index.d.ts +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -11
- package/dist/index.js.map +1 -1
- package/dist/mapper.d.ts +1 -1
- package/dist/mapper.d.ts.map +1 -1
- package/dist/mapper.js.map +1 -1
- package/dist/paginated-result.d.ts.map +1 -1
- package/dist/paginated-result.js +0 -15
- package/dist/paginated-result.js.map +1 -1
- package/dist/repository/base-repository.d.ts +7 -33
- package/dist/repository/base-repository.d.ts.map +1 -1
- package/dist/repository/base-repository.js +0 -27
- package/dist/repository/base-repository.js.map +1 -1
- package/dist/repository/index.d.ts.map +1 -1
- package/dist/repository/index.js +0 -6
- package/dist/repository/index.js.map +1 -1
- package/dist/repository/unit-of-work.d.ts +0 -25
- package/dist/repository/unit-of-work.d.ts.map +1 -1
- package/dist/repository/unit-of-work.js +0 -28
- package/dist/repository/unit-of-work.js.map +1 -1
- package/dist/types/change-tracker.d.ts +196 -0
- package/dist/types/change-tracker.d.ts.map +1 -0
- package/dist/types/change-tracker.js +2 -0
- package/dist/types/change-tracker.js.map +1 -0
- package/dist/types/criteria.d.ts +5 -1
- package/dist/types/criteria.d.ts.map +1 -1
- package/dist/types/domain.d.ts +4 -6
- package/dist/types/domain.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/utils.d.ts +0 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/utils/criteria-operator-validation.d.ts +1 -0
- package/dist/utils/criteria-operator-validation.d.ts.map +1 -1
- package/dist/utils/criteria-operator-validation.js +39 -17
- package/dist/utils/criteria-operator-validation.js.map +1 -1
- package/dist/validation-error.d.ts.map +1 -1
- package/dist/validation-error.js +1 -6
- package/dist/validation-error.js.map +1 -1
- package/dist/value-object.d.ts +57 -8
- package/dist/value-object.d.ts.map +1 -1
- package/dist/value-object.js +49 -22
- package/dist/value-object.js.map +1 -1
- package/package.json +2 -1
- package/src/aggregate-changes.ts +335 -0
- package/src/base-entity.ts +102 -109
- package/src/change-tracker.ts +1062 -0
- package/src/constants.ts +75 -1
- package/src/criteria.ts +11 -4
- package/src/crypto.ts +31 -0
- package/src/domain-event.ts +0 -4
- package/src/entity-changes.ts +146 -0
- package/src/entity-schema-registry.ts +255 -0
- package/src/entity.ts +0 -11
- package/src/id.ts +17 -26
- package/src/index.ts +15 -19
- package/src/mapper.ts +4 -1
- package/src/paginated-result.ts +0 -21
- package/src/repository/base-repository.ts +7 -38
- package/src/repository/index.ts +0 -9
- package/src/repository/unit-of-work.ts +0 -29
- package/src/types/change-tracker.ts +233 -0
- package/src/types/criteria.ts +6 -1
- package/src/types/domain.ts +4 -8
- package/src/types/index.ts +1 -1
- package/src/types/utils.ts +0 -9
- package/src/utils/criteria-operator-validation.ts +57 -19
- package/src/validation-error.ts +1 -7
- package/src/value-object.ts +84 -24
- package/tests/aggregate-changes.test.ts +284 -0
- package/tests/criteria.test.ts +122 -161
- package/tests/entity-equality.test.ts +38 -61
- package/tests/entity-schema-registry.test.ts +382 -0
- package/tests/entity-validation.test.ts +7 -94
- package/tests/history-tracker.spec.ts +349 -617
- package/tests/id.test.ts +41 -44
- package/tests/load-test/data.json +346041 -0
- package/tests/load-test/entities.ts +97 -0
- package/tests/load-test/generate-data.ts +81 -0
- package/tests/load-test/lead-to-domain.mapper.ts +24 -0
- package/tests/load-test/load.test.ts +38 -0
- package/tests/repository.test.ts +30 -54
- package/tests/to-json.test.ts +14 -18
- package/tests/utils.ts +138 -102
- package/tests/value-objects.test.ts +57 -29
- package/dist/deep-proxy.d.ts +0 -36
- package/dist/deep-proxy.d.ts.map +0 -1
- package/dist/deep-proxy.js +0 -384
- package/dist/deep-proxy.js.map +0 -1
- package/dist/types/history-tracker.d.ts +0 -36
- package/dist/types/history-tracker.d.ts.map +0 -1
- package/dist/types/history-tracker.js +0 -2
- package/dist/types/history-tracker.js.map +0 -1
- package/src/deep-proxy.ts +0 -447
- package/src/types/history-tracker.ts +0 -45
- package/tests/entity.test.ts +0 -33
package/src/value-object.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
212
|
+
* Returns all uncommitted domain events.
|
|
153
213
|
*/
|
|
154
214
|
getUncommittedEvents(): IDomainEvent[] {
|
|
155
215
|
return [...this.domainEvents];
|
|
156
216
|
}
|
|
157
217
|
|
|
158
218
|
/**
|
|
159
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
178
|
-
*
|
|
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
|
+
});
|