@woltz/rich-domain 1.9.1 → 1.9.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/dist/cjs/core/aggregate-changes.d.ts +14 -0
- package/dist/cjs/core/aggregate-changes.d.ts.map +1 -1
- package/dist/cjs/core/aggregate-changes.js +18 -0
- package/dist/cjs/core/aggregate-changes.js.map +1 -1
- package/dist/cjs/core/base-entity.d.ts +2 -0
- package/dist/cjs/core/base-entity.d.ts.map +1 -1
- package/dist/cjs/core/base-entity.js +39 -41
- package/dist/cjs/core/base-entity.js.map +1 -1
- package/dist/cjs/core/change-tracker.d.ts +8 -0
- package/dist/cjs/core/change-tracker.d.ts.map +1 -1
- package/dist/cjs/core/change-tracker.js +36 -6
- package/dist/cjs/core/change-tracker.js.map +1 -1
- package/dist/cjs/core/value-object.d.ts.map +1 -1
- package/dist/cjs/core/value-object.js +3 -5
- package/dist/cjs/core/value-object.js.map +1 -1
- package/dist/cjs/repository/entity-schema-registry.d.ts +56 -3
- package/dist/cjs/repository/entity-schema-registry.d.ts.map +1 -1
- package/dist/cjs/repository/entity-schema-registry.js +61 -6
- package/dist/cjs/repository/entity-schema-registry.js.map +1 -1
- package/dist/cjs/utils/helpers.d.ts +1 -0
- package/dist/cjs/utils/helpers.d.ts.map +1 -1
- package/dist/cjs/utils/helpers.js +4 -0
- package/dist/cjs/utils/helpers.js.map +1 -1
- package/dist/esm/core/aggregate-changes.d.ts +14 -0
- package/dist/esm/core/aggregate-changes.d.ts.map +1 -1
- package/dist/esm/core/aggregate-changes.js +18 -0
- package/dist/esm/core/aggregate-changes.js.map +1 -1
- package/dist/esm/core/base-entity.d.ts +2 -0
- package/dist/esm/core/base-entity.d.ts.map +1 -1
- package/dist/esm/core/base-entity.js +37 -39
- package/dist/esm/core/base-entity.js.map +1 -1
- package/dist/esm/core/change-tracker.d.ts +8 -0
- package/dist/esm/core/change-tracker.d.ts.map +1 -1
- package/dist/esm/core/change-tracker.js +36 -6
- package/dist/esm/core/change-tracker.js.map +1 -1
- package/dist/esm/core/value-object.d.ts.map +1 -1
- package/dist/esm/core/value-object.js +1 -3
- package/dist/esm/core/value-object.js.map +1 -1
- package/dist/esm/repository/entity-schema-registry.d.ts +56 -3
- package/dist/esm/repository/entity-schema-registry.d.ts.map +1 -1
- package/dist/esm/repository/entity-schema-registry.js +61 -6
- package/dist/esm/repository/entity-schema-registry.js.map +1 -1
- package/dist/esm/utils/helpers.d.ts +1 -0
- package/dist/esm/utils/helpers.d.ts.map +1 -1
- package/dist/esm/utils/helpers.js +3 -0
- package/dist/esm/utils/helpers.js.map +1 -1
- package/dist/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/tsconfig.types.tsbuildinfo +1 -1
- package/dist/types/core/aggregate-changes.d.ts +14 -0
- package/dist/types/core/aggregate-changes.d.ts.map +1 -1
- package/dist/types/core/base-entity.d.ts +2 -0
- package/dist/types/core/base-entity.d.ts.map +1 -1
- package/dist/types/core/change-tracker.d.ts +8 -0
- package/dist/types/core/change-tracker.d.ts.map +1 -1
- package/dist/types/core/value-object.d.ts.map +1 -1
- package/dist/types/repository/entity-schema-registry.d.ts +56 -3
- package/dist/types/repository/entity-schema-registry.d.ts.map +1 -1
- package/dist/types/utils/helpers.d.ts +1 -0
- package/dist/types/utils/helpers.d.ts.map +1 -1
- package/package.json +68 -67
- package/src/constants.ts +82 -0
- package/src/core/aggregate-changes.ts +466 -0
- package/src/core/base-aggregate.ts +76 -0
- package/src/core/base-entity.ts +552 -0
- package/src/core/change-tracker.ts +1327 -0
- package/src/core/domain-event.ts +41 -0
- package/src/core/entity-changes.ts +146 -0
- package/src/core/entity.ts +13 -0
- package/src/core/id.ts +124 -0
- package/src/core/index.ts +9 -0
- package/src/core/value-object.ts +179 -0
- package/src/criteria.ts +574 -0
- package/src/exceptions.ts +549 -0
- package/src/index.ts +74 -0
- package/src/repository/base-repository.ts +81 -0
- package/src/repository/entity-schema-registry.ts +620 -0
- package/src/repository/index.ts +5 -0
- package/src/repository/mapper.ts +7 -0
- package/src/repository/paginated-result.ts +251 -0
- package/src/repository/unit-of-work.ts +76 -0
- package/src/types/change-tracker.ts +268 -0
- package/src/types/criteria.ts +197 -0
- package/src/types/domain-event.ts +29 -0
- package/src/types/domain.ts +41 -0
- package/src/types/event-bus.ts +17 -0
- package/src/types/index.ts +9 -0
- package/src/types/outbox-store.ts +97 -0
- package/src/types/standard-schema.ts +19 -0
- package/src/types/unit-of-work.ts +46 -0
- package/src/types/utils.ts +24 -0
- package/src/utils/criteria-operator-validation.ts +209 -0
- package/src/utils/crypto.ts +31 -0
- package/src/utils/helpers.ts +50 -0
- package/src/validation-error.ts +219 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { BaseEntity } from "./base-entity.js";
|
|
2
|
+
import { BaseProps, IDomainEvent, IDomainEventBus } from "../types/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WeakMap-based storage for domain events.
|
|
6
|
+
* Using a WeakMap avoids ES2022 class field initialization order issues:
|
|
7
|
+
* class fields are set AFTER super() returns, which would overwrite any
|
|
8
|
+
* events added during the `onCreate` hook (called inside super()).
|
|
9
|
+
*/
|
|
10
|
+
const eventsStore = new WeakMap<BaseAggregate<any>, IDomainEvent[]>();
|
|
11
|
+
|
|
12
|
+
function getEvents(instance: BaseAggregate<any>): IDomainEvent[] {
|
|
13
|
+
if (!eventsStore.has(instance)) eventsStore.set(instance, []);
|
|
14
|
+
return eventsStore.get(instance)!;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Base class for Aggregates that adds domain event management capabilities.
|
|
19
|
+
* In Domain-Driven Design (DDD), only Aggregates (aggregate roots) should emit domain events.
|
|
20
|
+
* Regular Entities and Value Objects should NOT manage domain events.
|
|
21
|
+
*/
|
|
22
|
+
export abstract class BaseAggregate<
|
|
23
|
+
T extends BaseProps,
|
|
24
|
+
TOptionalInput extends keyof T = never,
|
|
25
|
+
> extends BaseEntity<T, TOptionalInput> {
|
|
26
|
+
/**
|
|
27
|
+
* Add a domain event to this aggregate.
|
|
28
|
+
* Safe to call inside the `onCreate` hook.
|
|
29
|
+
*/
|
|
30
|
+
protected addDomainEvent(event: IDomainEvent): void {
|
|
31
|
+
getEvents(this).push(event);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Dispatch all events through the event bus
|
|
36
|
+
*/
|
|
37
|
+
public async dispatchAll(bus: IDomainEventBus): Promise<void> {
|
|
38
|
+
await bus.publishAll(this.getUncommittedEvents());
|
|
39
|
+
this.clearEvents();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get all uncommitted domain events
|
|
44
|
+
*/
|
|
45
|
+
getUncommittedEvents(): IDomainEvent[] {
|
|
46
|
+
return [...getEvents(this)];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Clear all domain events (call after publishing)
|
|
51
|
+
*/
|
|
52
|
+
clearEvents(): void {
|
|
53
|
+
eventsStore.set(this, []);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if aggregate has uncommitted events
|
|
58
|
+
*/
|
|
59
|
+
hasUncommittedEvents(): boolean {
|
|
60
|
+
return getEvents(this).length > 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get a domain event by name
|
|
65
|
+
*/
|
|
66
|
+
getEvent(eventName: string): IDomainEvent | undefined {
|
|
67
|
+
return getEvents(this).find((event) => event.eventName === eventName);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if an event has been added to the aggregate
|
|
72
|
+
*/
|
|
73
|
+
hasEvent(eventName: string): boolean {
|
|
74
|
+
return getEvents(this).some((event) => event.eventName === eventName);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ValidationError,
|
|
3
|
+
ValidationIssue,
|
|
4
|
+
ValidationIssueCollector,
|
|
5
|
+
} from "../validation-error.js";
|
|
6
|
+
import {
|
|
7
|
+
BaseProps,
|
|
8
|
+
HistoryEntry,
|
|
9
|
+
DeepJsonResult,
|
|
10
|
+
EntityHooks,
|
|
11
|
+
ValidationConfig,
|
|
12
|
+
StandardSchema,
|
|
13
|
+
EntityValidation,
|
|
14
|
+
} from "../types/index.js";
|
|
15
|
+
import { DEFAULT_VALIDATION_CONFIG } from "../constants.js";
|
|
16
|
+
import { DomainError } from "../exceptions.js";
|
|
17
|
+
import { ChangeTracker, AggregateChanges, Id, ValueObject } from "./index";
|
|
18
|
+
import { getStaticProperty } from "../utils/helpers.js";
|
|
19
|
+
|
|
20
|
+
export abstract class BaseEntity<
|
|
21
|
+
T extends BaseProps,
|
|
22
|
+
TOptionalInput extends keyof T = never,
|
|
23
|
+
> {
|
|
24
|
+
private _props: T;
|
|
25
|
+
private tracker: ChangeTracker;
|
|
26
|
+
private proxiedProps: T;
|
|
27
|
+
private snapshot: T | null = null;
|
|
28
|
+
private validationConfig: Required<ValidationConfig>;
|
|
29
|
+
private entityHooks?: EntityHooks<T, any>;
|
|
30
|
+
private entitySchema?: StandardSchema<T>;
|
|
31
|
+
private readonly issueCollector = new ValidationIssueCollector();
|
|
32
|
+
|
|
33
|
+
protected static validation?: EntityValidation<any>;
|
|
34
|
+
protected static hooks?: EntityHooks<any, any>;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
props: Omit<T, TOptionalInput | "id"> &
|
|
38
|
+
Partial<Pick<T, TOptionalInput>> & { id?: Id }
|
|
39
|
+
) {
|
|
40
|
+
const validation = getStaticProperty<EntityValidation<T>>(
|
|
41
|
+
this,
|
|
42
|
+
"validation"
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const hooks = getStaticProperty<EntityHooks<T, any>>(this, "hooks");
|
|
46
|
+
|
|
47
|
+
if (!props.id) {
|
|
48
|
+
props.id = new Id();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (hooks?.onBeforeCreate) {
|
|
52
|
+
hooks.onBeforeCreate(props as T);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.entityHooks = hooks;
|
|
56
|
+
|
|
57
|
+
if (validation?.schema) {
|
|
58
|
+
this.entitySchema = validation.schema;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.validationConfig = {
|
|
62
|
+
...DEFAULT_VALIDATION_CONFIG,
|
|
63
|
+
...validation?.config,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
let finalProps = { ...props } as T;
|
|
67
|
+
|
|
68
|
+
if (this.entitySchema && this.validationConfig.onCreate) {
|
|
69
|
+
this.validateProps(finalProps);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this._props = finalProps;
|
|
73
|
+
this.tracker = new ChangeTracker(this._props, this.constructor.name);
|
|
74
|
+
|
|
75
|
+
if (this.validationConfig.onUpdate) {
|
|
76
|
+
this.setupUpdateValidation();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.proxiedProps = this.tracker.createProxy();
|
|
80
|
+
|
|
81
|
+
if (hooks?.rules) {
|
|
82
|
+
this.runRulesHook();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (hooks?.onCreate) {
|
|
86
|
+
hooks.onCreate(this as any);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.takeSnapshot();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Add a validation issue during rules hook execution (non-throwing mode).
|
|
94
|
+
*/
|
|
95
|
+
public addValidationIssue(path: string | string[], message: string): void {
|
|
96
|
+
this.issueCollector.add(path, message);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private beginValidationCycle(): void {
|
|
100
|
+
this.issueCollector.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private finalizeValidation(collectedIssues: ValidationIssue[] = []): void {
|
|
104
|
+
const existing = (this as any)._validationError as
|
|
105
|
+
| ValidationError
|
|
106
|
+
| undefined;
|
|
107
|
+
const merged = ValidationError.merge(existing, collectedIssues, {
|
|
108
|
+
entityName: this.constructor.name,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!merged) {
|
|
112
|
+
delete (this as any)._validationError;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (this.validationConfig.throwOnError) {
|
|
117
|
+
throw merged;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
(this as any)._validationError = merged;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private runRulesHook(): void {
|
|
124
|
+
if (!this.entityHooks?.rules) return;
|
|
125
|
+
|
|
126
|
+
this.beginValidationCycle();
|
|
127
|
+
this.entityHooks.rules(this as any);
|
|
128
|
+
this.finalizeValidation([...this.issueCollector.getIssues()]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private validateProps(props: T): void {
|
|
132
|
+
const schemaError = this.validateSchema(props);
|
|
133
|
+
if (!schemaError) return;
|
|
134
|
+
|
|
135
|
+
if (this.validationConfig.throwOnError) {
|
|
136
|
+
throw schemaError;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
(this as any)._validationError = schemaError;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private validateSchema(props: T): ValidationError | null {
|
|
143
|
+
if (!this.entitySchema) return null;
|
|
144
|
+
|
|
145
|
+
const result = this.entitySchema["~standard"].validate(props);
|
|
146
|
+
|
|
147
|
+
if (result instanceof Promise) {
|
|
148
|
+
throw new DomainError(
|
|
149
|
+
"Async validation not supported in constructor. Use sync validation schema."
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (result.issues && result.issues.length > 0) {
|
|
154
|
+
return new ValidationError(
|
|
155
|
+
result.issues.map((issue) => ({
|
|
156
|
+
path: issue.path?.map((p) => this.extractPathKey(p)) || [],
|
|
157
|
+
message: issue.message,
|
|
158
|
+
})),
|
|
159
|
+
{
|
|
160
|
+
entityName: this.constructor.name,
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private handleValidationFailure(issues: ValidationIssue[]): void {
|
|
169
|
+
if (issues.length === 0) {
|
|
170
|
+
this.clearValidationError();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const error = ValidationError.fromIssues(issues, {
|
|
175
|
+
entityName: this.constructor.name,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (this.validationConfig.throwOnError) {
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
(this as any)._validationError = error;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private clearValidationError(): void {
|
|
186
|
+
delete (this as any)._validationError;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Validates the full current props (schema + rules). Used on updates when
|
|
191
|
+
* throwOnError is false so validationErrors reflects every invalid field.
|
|
192
|
+
*/
|
|
193
|
+
private collectCurrentValidationIssues(): ValidationIssue[] {
|
|
194
|
+
const issues: ValidationIssue[] = [];
|
|
195
|
+
|
|
196
|
+
const schemaError = this.entitySchema
|
|
197
|
+
? this.validateSchema(this._props)
|
|
198
|
+
: null;
|
|
199
|
+
if (schemaError) {
|
|
200
|
+
issues.push(...schemaError.issues);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (this.entityHooks?.rules) {
|
|
204
|
+
this.beginValidationCycle();
|
|
205
|
+
this.entityHooks.rules(this as any);
|
|
206
|
+
issues.push(...this.issueCollector.getIssues());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return issues;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @returns true when the entity has no validation issues after refresh
|
|
214
|
+
*/
|
|
215
|
+
private refreshValidationStateFromCurrentProps(): boolean {
|
|
216
|
+
const issues = this.collectCurrentValidationIssues();
|
|
217
|
+
|
|
218
|
+
if (issues.length === 0) {
|
|
219
|
+
this.clearValidationError();
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this.handleValidationFailure(issues);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* When true, failed schema/rules updates keep the mutated value and refresh
|
|
229
|
+
* validationErrors (dirty / form mode). Requires throwOnError: false and
|
|
230
|
+
* persistInvalidMutations: true.
|
|
231
|
+
*/
|
|
232
|
+
private shouldPersistInvalidMutation(): boolean {
|
|
233
|
+
return (
|
|
234
|
+
!this.validationConfig.throwOnError &&
|
|
235
|
+
this.validationConfig.persistInvalidMutations
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private extractPathKey(pathSegment: unknown): string {
|
|
240
|
+
if (pathSegment === null || pathSegment === undefined) {
|
|
241
|
+
return "";
|
|
242
|
+
}
|
|
243
|
+
if (typeof pathSegment === "string" || typeof pathSegment === "number") {
|
|
244
|
+
return String(pathSegment);
|
|
245
|
+
}
|
|
246
|
+
if (typeof pathSegment === "symbol") {
|
|
247
|
+
return pathSegment.toString();
|
|
248
|
+
}
|
|
249
|
+
if (typeof pathSegment === "object" && "key" in pathSegment) {
|
|
250
|
+
return String((pathSegment as { key: unknown }).key);
|
|
251
|
+
}
|
|
252
|
+
return String(pathSegment);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Setup validation that runs on every property change.
|
|
257
|
+
* Uses the ChangeTracker's onChangeValidator callback.
|
|
258
|
+
*/
|
|
259
|
+
private setupUpdateValidation(): void {
|
|
260
|
+
const self = this;
|
|
261
|
+
|
|
262
|
+
this.tracker.setOnChangeValidator((path, newValue) => {
|
|
263
|
+
const originalValue = this.getValueAtPath(self._props, path);
|
|
264
|
+
this.setValueAtPath(self._props, path, newValue);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
if (
|
|
268
|
+
!self.validationConfig.persistInvalidMutations &&
|
|
269
|
+
(self as any)._validationError
|
|
270
|
+
) {
|
|
271
|
+
this.setValueAtPath(self._props, path, originalValue);
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (self.entityHooks?.onBeforeUpdate && self.snapshot) {
|
|
276
|
+
const shouldContinue = self.entityHooks.onBeforeUpdate(
|
|
277
|
+
self as any,
|
|
278
|
+
self.snapshot
|
|
279
|
+
);
|
|
280
|
+
if (!shouldContinue) {
|
|
281
|
+
this.setValueAtPath(self._props, path, originalValue);
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!self.validationConfig.throwOnError) {
|
|
287
|
+
const isValid = self.refreshValidationStateFromCurrentProps();
|
|
288
|
+
if (!isValid) {
|
|
289
|
+
if (!self.shouldPersistInvalidMutation()) {
|
|
290
|
+
this.setValueAtPath(self._props, path, originalValue);
|
|
291
|
+
}
|
|
292
|
+
return self.shouldPersistInvalidMutation();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.setValueAtPath(self._props, path, originalValue);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const schemaError = self.entitySchema
|
|
300
|
+
? self.validateSchema(self._props)
|
|
301
|
+
: null;
|
|
302
|
+
|
|
303
|
+
if (schemaError) {
|
|
304
|
+
this.setValueAtPath(self._props, path, originalValue);
|
|
305
|
+
throw schemaError;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (self.entityHooks?.rules) {
|
|
309
|
+
self.beginValidationCycle();
|
|
310
|
+
self.entityHooks.rules(self as any);
|
|
311
|
+
const collected = [...self.issueCollector.getIssues()];
|
|
312
|
+
|
|
313
|
+
if (collected.length > 0) {
|
|
314
|
+
this.setValueAtPath(self._props, path, originalValue);
|
|
315
|
+
throw ValidationError.fromIssues(collected, {
|
|
316
|
+
entityName: self.constructor.name,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
self.clearValidationError();
|
|
322
|
+
this.setValueAtPath(self._props, path, originalValue);
|
|
323
|
+
return true;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
this.setValueAtPath(self._props, path, originalValue);
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private takeSnapshot(): void {
|
|
332
|
+
this.snapshot = this.deepCloneProps(this._props);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private deepCloneProps(obj: any, seen: WeakSet<object> = new WeakSet()): any {
|
|
336
|
+
if (obj === null || obj === undefined) return obj;
|
|
337
|
+
if (typeof obj !== "object") return obj;
|
|
338
|
+
if (obj instanceof Id) return obj;
|
|
339
|
+
if (obj instanceof Date) return new Date(obj.getTime());
|
|
340
|
+
|
|
341
|
+
if (seen.has(obj)) {
|
|
342
|
+
return obj;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (obj instanceof BaseEntity) {
|
|
346
|
+
return obj;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (
|
|
350
|
+
obj.constructor &&
|
|
351
|
+
obj.constructor.name !== "Object" &&
|
|
352
|
+
obj.constructor.name !== "Array"
|
|
353
|
+
) {
|
|
354
|
+
if (
|
|
355
|
+
typeof obj.toJSON === "function" &&
|
|
356
|
+
typeof obj.equals === "function"
|
|
357
|
+
) {
|
|
358
|
+
return obj;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
seen.add(obj);
|
|
363
|
+
|
|
364
|
+
if (Array.isArray(obj)) {
|
|
365
|
+
return obj.map((item) => this.deepCloneProps(item, seen));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (obj.constructor === Object) {
|
|
369
|
+
try {
|
|
370
|
+
return structuredClone(obj);
|
|
371
|
+
} catch {
|
|
372
|
+
const cloned: any = {};
|
|
373
|
+
for (const key in obj) {
|
|
374
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
375
|
+
cloned[key] = this.deepCloneProps(obj[key], seen);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return cloned;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return obj;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
get id(): Id {
|
|
386
|
+
return this._props.id;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
public isNew(): boolean {
|
|
390
|
+
return this._props.id.isNew();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Check equality with another entity by comparing IDs
|
|
395
|
+
*/
|
|
396
|
+
equals(other: BaseEntity<T> | Id | string): boolean {
|
|
397
|
+
if (!other) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (other instanceof BaseEntity) {
|
|
402
|
+
return this.id.equals(other.id);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (other instanceof Id) {
|
|
406
|
+
return this.id.equals(other);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (typeof other === "string") {
|
|
410
|
+
return this.id.equals(other);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
public get props(): T {
|
|
417
|
+
return this.proxiedProps;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Check if entity has validation errors (when throwOnError is false)
|
|
422
|
+
*/
|
|
423
|
+
get hasValidationErrors(): boolean {
|
|
424
|
+
return !!(this as any)._validationError;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Get validation errors (when throwOnError is false)
|
|
429
|
+
*/
|
|
430
|
+
get validationErrors(): ValidationError | undefined {
|
|
431
|
+
return (this as any)._validationError;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Returns all detected changes as AggregateChanges.
|
|
436
|
+
*
|
|
437
|
+
* @example
|
|
438
|
+
* ```typescript
|
|
439
|
+
* const changes = user.getChanges();
|
|
440
|
+
* const batch = changes.toBatchOperations();
|
|
441
|
+
*
|
|
442
|
+
* for (const del of batch.deletes) { ... }
|
|
443
|
+
* for (const create of batch.creates) { ... }
|
|
444
|
+
* for (const upd of batch.updates) { ... }
|
|
445
|
+
* ```
|
|
446
|
+
*/
|
|
447
|
+
getChanges<TEntityMap = Record<string, any>>(): AggregateChanges<TEntityMap> {
|
|
448
|
+
return this.tracker.getChanges<TEntityMap>();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Returns the change history (for debugging).
|
|
453
|
+
*/
|
|
454
|
+
getHistory(): HistoryEntry[] {
|
|
455
|
+
return this.tracker.getHistory();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Clears history and marks entity as "clean".
|
|
460
|
+
* Recursively marks all nested entities as clean.
|
|
461
|
+
*/
|
|
462
|
+
markAsClean(): void {
|
|
463
|
+
this.tracker.markAsClean();
|
|
464
|
+
this.takeSnapshot();
|
|
465
|
+
this.forEachNestedEntity((entity) => entity.markAsClean());
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Clears history, marks entity as "clean" and marks the Id as not new.
|
|
470
|
+
* Recursively marks all nested entities as persisted.
|
|
471
|
+
* Call this after successfully persisting to the database.
|
|
472
|
+
*/
|
|
473
|
+
markAsPersisted(): void {
|
|
474
|
+
this.tracker.markAsClean();
|
|
475
|
+
this.takeSnapshot();
|
|
476
|
+
this.id.markAsNotNew();
|
|
477
|
+
this.forEachNestedEntity((entity) => entity.markAsPersisted());
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Iterates over all nested entities (direct children only) and executes a callback.
|
|
482
|
+
* This includes entities in arrays and single entity properties.
|
|
483
|
+
*/
|
|
484
|
+
private forEachNestedEntity(
|
|
485
|
+
callback: (entity: BaseEntity<any>) => void
|
|
486
|
+
): void {
|
|
487
|
+
for (const value of Object.values(this._props)) {
|
|
488
|
+
if (value instanceof BaseEntity) {
|
|
489
|
+
callback(value);
|
|
490
|
+
} else if (Array.isArray(value)) {
|
|
491
|
+
for (const item of value) {
|
|
492
|
+
if (item instanceof BaseEntity) {
|
|
493
|
+
callback(item);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
toJSON(): DeepJsonResult<T> {
|
|
501
|
+
return this.deepToJson(this._props) as DeepJsonResult<T>;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private deepToJson(obj: any): any {
|
|
505
|
+
if (obj === null || obj === undefined) return obj;
|
|
506
|
+
if (obj instanceof Id) return obj.value;
|
|
507
|
+
if (obj instanceof ValueObject) return obj.value;
|
|
508
|
+
if (obj instanceof Date) return obj.toISOString();
|
|
509
|
+
if (Array.isArray(obj)) return obj.map((item) => this.deepToJson(item));
|
|
510
|
+
if (obj instanceof BaseEntity) return obj.toJSON();
|
|
511
|
+
if (obj && typeof obj.toJSON === "function") return obj.toJSON();
|
|
512
|
+
if (typeof obj === "object") {
|
|
513
|
+
const result: any = {};
|
|
514
|
+
for (const key in obj) {
|
|
515
|
+
if (obj.hasOwnProperty(key)) result[key] = this.deepToJson(obj[key]);
|
|
516
|
+
}
|
|
517
|
+
return result;
|
|
518
|
+
}
|
|
519
|
+
return obj;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private setValueAtPath(obj: any, path: string, value: any): void {
|
|
523
|
+
if (!path) return;
|
|
524
|
+
|
|
525
|
+
const parts = path.split(/[.\[\]]+/).filter(Boolean);
|
|
526
|
+
let current = obj;
|
|
527
|
+
|
|
528
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
529
|
+
const part = parts[i];
|
|
530
|
+
if (current[part] === null || current[part] === undefined) {
|
|
531
|
+
current[part] = {};
|
|
532
|
+
}
|
|
533
|
+
current = current[part];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
current[parts[parts.length - 1]!] = value;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private getValueAtPath(obj: any, path: string): any {
|
|
540
|
+
if (!path) return obj;
|
|
541
|
+
|
|
542
|
+
const parts = path.split(/[.\[\]]+/).filter(Boolean);
|
|
543
|
+
let current = obj;
|
|
544
|
+
|
|
545
|
+
for (const part of parts) {
|
|
546
|
+
if (current === null || current === undefined) return undefined;
|
|
547
|
+
current = current[part];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return current;
|
|
551
|
+
}
|
|
552
|
+
}
|