@woltz/rich-domain 0.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.
- package/.github/workflows/ci.yml +40 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.versionrc.json +21 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +81 -0
- package/LICENSE +21 -0
- package/README.md +712 -0
- package/commitlint.config.js +23 -0
- package/dist/base-entity.d.ts +67 -0
- package/dist/base-entity.d.ts.map +1 -0
- package/dist/base-entity.js +309 -0
- package/dist/base-entity.js.map +1 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -0
- package/dist/criteria.d.ts +60 -0
- package/dist/criteria.d.ts.map +1 -0
- package/dist/criteria.js +214 -0
- package/dist/criteria.js.map +1 -0
- package/dist/deep-proxy.d.ts +34 -0
- package/dist/deep-proxy.d.ts.map +1 -0
- package/dist/deep-proxy.js +297 -0
- package/dist/deep-proxy.js.map +1 -0
- package/dist/domain-event-bus.d.ts +57 -0
- package/dist/domain-event-bus.d.ts.map +1 -0
- package/dist/domain-event-bus.js +112 -0
- package/dist/domain-event-bus.js.map +1 -0
- package/dist/domain-event.d.ts +55 -0
- package/dist/domain-event.d.ts.map +1 -0
- package/dist/domain-event.js +42 -0
- package/dist/domain-event.js.map +1 -0
- package/dist/entity.d.ts +13 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/entity.js +15 -0
- package/dist/entity.js.map +1 -0
- package/dist/filtering.d.ts +107 -0
- package/dist/filtering.d.ts.map +1 -0
- package/dist/filtering.js +202 -0
- package/dist/filtering.js.map +1 -0
- package/dist/id.d.ts +51 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +84 -0
- package/dist/id.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/ordering.d.ts +93 -0
- package/dist/ordering.d.ts.map +1 -0
- package/dist/ordering.js +154 -0
- package/dist/ordering.js.map +1 -0
- package/dist/paginated-result.d.ts +62 -0
- package/dist/paginated-result.d.ts.map +1 -0
- package/dist/paginated-result.js +201 -0
- package/dist/paginated-result.js.map +1 -0
- package/dist/pagination.d.ts +218 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +281 -0
- package/dist/pagination.js.map +1 -0
- package/dist/repository/base-repository.d.ts +77 -0
- package/dist/repository/base-repository.d.ts.map +1 -0
- package/dist/repository/base-repository.js +80 -0
- package/dist/repository/base-repository.js.map +1 -0
- package/dist/repository/in-memory-repository.d.ts +46 -0
- package/dist/repository/in-memory-repository.d.ts.map +1 -0
- package/dist/repository/in-memory-repository.js +85 -0
- package/dist/repository/in-memory-repository.js.map +1 -0
- package/dist/repository/index.d.ts +42 -0
- package/dist/repository/index.d.ts.map +1 -0
- package/dist/repository/index.js +47 -0
- package/dist/repository/index.js.map +1 -0
- package/dist/repository/mapper.d.ts +56 -0
- package/dist/repository/mapper.d.ts.map +1 -0
- package/dist/repository/mapper.js +15 -0
- package/dist/repository/mapper.js.map +1 -0
- package/dist/repository/types.d.ts +87 -0
- package/dist/repository/types.d.ts.map +1 -0
- package/dist/repository/types.js +6 -0
- package/dist/repository/types.js.map +1 -0
- package/dist/repository/unit-of-work.d.ts +70 -0
- package/dist/repository/unit-of-work.d.ts.map +1 -0
- package/dist/repository/unit-of-work.js +122 -0
- package/dist/repository/unit-of-work.js.map +1 -0
- package/dist/repository.d.ts +2 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +21 -0
- package/dist/repository.js.map +1 -0
- package/dist/specification.d.ts +102 -0
- package/dist/specification.d.ts.map +1 -0
- package/dist/specification.js +187 -0
- package/dist/specification.js.map +1 -0
- package/dist/types/criteria.d.ts +35 -0
- package/dist/types/criteria.d.ts.map +1 -0
- package/dist/types/criteria.js +17 -0
- package/dist/types/criteria.js.map +1 -0
- package/dist/types/domain.d.ts +30 -0
- package/dist/types/domain.d.ts.map +1 -0
- package/dist/types/domain.js +2 -0
- package/dist/types/domain.js.map +1 -0
- package/dist/types/history-tracker.d.ts +36 -0
- package/dist/types/history-tracker.d.ts.map +1 -0
- package/dist/types/history-tracker.js +2 -0
- package/dist/types/history-tracker.js.map +1 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +8 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/repository.d.ts +43 -0
- package/dist/types/repository.d.ts.map +1 -0
- package/dist/types/repository.js +2 -0
- package/dist/types/repository.js.map +1 -0
- package/dist/types/standard-schema.d.ts +15 -0
- package/dist/types/standard-schema.d.ts.map +1 -0
- package/dist/types/standard-schema.js +2 -0
- package/dist/types/standard-schema.js.map +1 -0
- package/dist/types/unit-of-work.d.ts +39 -0
- package/dist/types/unit-of-work.d.ts.map +1 -0
- package/dist/types/unit-of-work.js +2 -0
- package/dist/types/unit-of-work.js.map +1 -0
- package/dist/types/utils.d.ts +14 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/dist/types/utils.js +2 -0
- package/dist/types/utils.js.map +1 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/validation-error.d.ts +42 -0
- package/dist/validation-error.d.ts.map +1 -0
- package/dist/validation-error.js +73 -0
- package/dist/validation-error.js.map +1 -0
- package/dist/value-object.d.ts +47 -0
- package/dist/value-object.d.ts.map +1 -0
- package/dist/value-object.js +136 -0
- package/dist/value-object.js.map +1 -0
- package/eslint.config.js +51 -0
- package/jest.config.js +21 -0
- package/package.json +58 -0
- package/src/base-entity.ts +401 -0
- package/src/constants.ts +7 -0
- package/src/criteria.ts +291 -0
- package/src/deep-proxy.ts +339 -0
- package/src/domain-event-bus.ts +166 -0
- package/src/domain-event.ts +90 -0
- package/src/entity.ts +16 -0
- package/src/id.ts +94 -0
- package/src/index.ts +33 -0
- package/src/paginated-result.ts +274 -0
- package/src/repository/base-repository.ts +152 -0
- package/src/repository/in-memory-repository.ts +104 -0
- package/src/repository/index.ts +55 -0
- package/src/repository/mapper.ts +74 -0
- package/src/repository/unit-of-work.ts +148 -0
- package/src/types/criteria.ts +79 -0
- package/src/types/domain.ts +37 -0
- package/src/types/history-tracker.ts +45 -0
- package/src/types/index.ts +7 -0
- package/src/types/repository.ts +51 -0
- package/src/types/standard-schema.ts +19 -0
- package/src/types/unit-of-work.ts +46 -0
- package/src/types/utils.ts +29 -0
- package/src/validation-error.ts +97 -0
- package/src/value-object.ts +187 -0
- package/tests/criteria.test.ts +432 -0
- package/tests/domain-events.test.ts +445 -0
- package/tests/entity-equality.test.ts +487 -0
- package/tests/entity-validation.test.ts +339 -0
- package/tests/entity.test.ts +33 -0
- package/tests/history-tracker.spec.ts +667 -0
- package/tests/id.test.ts +341 -0
- package/tests/repository.test.ts +641 -0
- package/tests/to-json.test.ts +91 -0
- package/tests/utils.ts +151 -0
- package/tests/value-object-validation.test.ts +228 -0
- package/tests/value-objects.test.ts +52 -0
- package/tsconfig.json +31 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Rich Domain Library - Main Exports
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
// Core Classes
|
|
6
|
+
export { Id } from "./id";
|
|
7
|
+
export { BaseEntity } from "./base-entity";
|
|
8
|
+
export { Entity, Aggregate } from "./entity";
|
|
9
|
+
export { ValueObject } from "./value-object";
|
|
10
|
+
|
|
11
|
+
export * from "./validation-error";
|
|
12
|
+
|
|
13
|
+
// Domain Events
|
|
14
|
+
export * from "./domain-event";
|
|
15
|
+
|
|
16
|
+
export * from "./domain-event-bus";
|
|
17
|
+
|
|
18
|
+
// Criteria & Repository
|
|
19
|
+
export * from "./criteria";
|
|
20
|
+
export * from "./paginated-result";
|
|
21
|
+
|
|
22
|
+
// Repository
|
|
23
|
+
export * from "./repository";
|
|
24
|
+
// Backward compatibility - re-export InMemoryRepository at top level
|
|
25
|
+
export { InMemoryRepository } from "./repository";
|
|
26
|
+
|
|
27
|
+
// Types
|
|
28
|
+
export * from "./types";
|
|
29
|
+
|
|
30
|
+
export * from "./constants";
|
|
31
|
+
|
|
32
|
+
// Internal (for advanced usage)
|
|
33
|
+
export { DeepProxy } from "./deep-proxy";
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// PaginatedResult - Container for paginated data with deep serialization
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import { Id } from "./id";
|
|
6
|
+
import type { Criteria } from "./criteria";
|
|
7
|
+
import type { Pagination, PaginationMeta, Filter } from "./types";
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Type Utilities
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Infers the JSON result type from T
|
|
15
|
+
* - If T has toJson(), returns its return type
|
|
16
|
+
* - Otherwise returns T as-is
|
|
17
|
+
*/
|
|
18
|
+
type InferJsonResult<T> = T extends { toJson(): infer R } ? R : T;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Type for the serialized result of PaginatedResult.toJSON()
|
|
22
|
+
*/
|
|
23
|
+
export type PaginatedJsonResult<T> = {
|
|
24
|
+
data: InferJsonResult<T>[];
|
|
25
|
+
meta: PaginationMeta;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// PaginatedResult Class
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export class PaginatedResult<T> {
|
|
33
|
+
constructor(
|
|
34
|
+
public readonly data: T[],
|
|
35
|
+
public readonly meta: PaginationMeta
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a PaginatedResult with calculated metadata
|
|
40
|
+
*/
|
|
41
|
+
static create<T>(
|
|
42
|
+
data: T[],
|
|
43
|
+
pagination: Pagination,
|
|
44
|
+
total: number
|
|
45
|
+
): PaginatedResult<T> {
|
|
46
|
+
const meta = this.createMeta(pagination, total);
|
|
47
|
+
return new PaginatedResult(data, meta);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates pagination metadata from total count
|
|
52
|
+
*/
|
|
53
|
+
static createMeta(pagination: Pagination, total: number): PaginationMeta {
|
|
54
|
+
const totalPages = Math.ceil(total / pagination.limit);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
page: pagination.page,
|
|
58
|
+
limit: pagination.limit,
|
|
59
|
+
total,
|
|
60
|
+
totalPages,
|
|
61
|
+
hasNext: pagination.page < totalPages,
|
|
62
|
+
hasPrevious: pagination.page > 1,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Applies criteria to an in-memory array (useful for testing)
|
|
68
|
+
*/
|
|
69
|
+
static fromArray<T>(
|
|
70
|
+
items: T[],
|
|
71
|
+
criteria: Criteria<T>
|
|
72
|
+
): PaginatedResult<T> {
|
|
73
|
+
let result = [...items];
|
|
74
|
+
|
|
75
|
+
// Apply filters
|
|
76
|
+
for (const filter of criteria.getFilters()) {
|
|
77
|
+
result = result.filter((item) => applyFilter(item, filter));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const total = result.length;
|
|
81
|
+
|
|
82
|
+
// Apply ordering
|
|
83
|
+
for (const order of criteria.getOrders().reverse()) {
|
|
84
|
+
result.sort((a, b) => {
|
|
85
|
+
const aVal = getNestedValue(a, order.field);
|
|
86
|
+
const bVal = getNestedValue(b, order.field);
|
|
87
|
+
|
|
88
|
+
let comparison = 0;
|
|
89
|
+
if (aVal < bVal) comparison = -1;
|
|
90
|
+
if (aVal > bVal) comparison = 1;
|
|
91
|
+
|
|
92
|
+
return order.direction === "desc" ? -comparison : comparison;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Apply pagination
|
|
97
|
+
const pagination = criteria.getPagination();
|
|
98
|
+
if (pagination) {
|
|
99
|
+
result = result.slice(
|
|
100
|
+
pagination.offset,
|
|
101
|
+
pagination.offset + pagination.limit
|
|
102
|
+
);
|
|
103
|
+
return PaginatedResult.create(result, pagination, total);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// No pagination - return all with default meta
|
|
107
|
+
return PaginatedResult.create(
|
|
108
|
+
result,
|
|
109
|
+
{ page: 1, limit: result.length, offset: 0 },
|
|
110
|
+
total
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Converts the result to JSON, deeply serializing all entities/aggregates/value objects
|
|
116
|
+
* - Entities/Aggregates → calls toJson() recursively
|
|
117
|
+
* - Value Objects → calls toJson()
|
|
118
|
+
* - Id → converts to string
|
|
119
|
+
* - Arrays → maps recursively
|
|
120
|
+
* - Plain objects → serializes properties recursively
|
|
121
|
+
* - Primitives → returns as-is
|
|
122
|
+
*/
|
|
123
|
+
toJSON(): PaginatedJsonResult<T> {
|
|
124
|
+
return {
|
|
125
|
+
data: this.data.map((item) =>
|
|
126
|
+
this.deepSerialize(item)
|
|
127
|
+
) as InferJsonResult<T>[],
|
|
128
|
+
meta: this.meta,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Deep serialization logic (similar to BaseEntity.deepToJson)
|
|
134
|
+
*/
|
|
135
|
+
private deepSerialize(obj: any): any {
|
|
136
|
+
if (obj === null || obj === undefined) return obj;
|
|
137
|
+
|
|
138
|
+
// Id → string
|
|
139
|
+
if (obj instanceof Id) return obj.value;
|
|
140
|
+
|
|
141
|
+
// Arrays → map recursively
|
|
142
|
+
if (Array.isArray(obj)) {
|
|
143
|
+
return obj.map((item) => this.deepSerialize(item));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Objects with toJson() method (Entity/Aggregate/ValueObject)
|
|
147
|
+
if (obj && typeof obj.toJson === "function") {
|
|
148
|
+
return obj.toJson();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Plain objects → serialize properties recursively
|
|
152
|
+
if (typeof obj === "object") {
|
|
153
|
+
const result: any = {};
|
|
154
|
+
for (const key in obj) {
|
|
155
|
+
if (obj.hasOwnProperty(key)) {
|
|
156
|
+
result[key] = this.deepSerialize(obj[key]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Primitives
|
|
163
|
+
return obj;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Transform each item in the result using a mapper function
|
|
168
|
+
*/
|
|
169
|
+
map<U>(fn: (item: T) => U): PaginatedResult<U> {
|
|
170
|
+
return new PaginatedResult(this.data.map(fn), this.meta);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if result has no data
|
|
175
|
+
*/
|
|
176
|
+
get isEmpty(): boolean {
|
|
177
|
+
return this.data.length === 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Check if there are more pages available
|
|
182
|
+
*/
|
|
183
|
+
get hasMore(): boolean {
|
|
184
|
+
return this.meta.hasNext;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// Helper Functions (moved from criteria.ts)
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
function applyFilter<T>(item: T, filter: Filter): boolean {
|
|
193
|
+
const value = getNestedValue(item, filter.field);
|
|
194
|
+
|
|
195
|
+
const isValueDate = value instanceof Date;
|
|
196
|
+
|
|
197
|
+
const parseValue = (v: any) => {
|
|
198
|
+
if (isValueDate && typeof v === "string") return new Date(v);
|
|
199
|
+
if (isValueDate && typeof v === "number") return new Date(v);
|
|
200
|
+
return v;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
switch (filter.operator) {
|
|
204
|
+
case "equals":
|
|
205
|
+
return value === filter.value;
|
|
206
|
+
|
|
207
|
+
case "notEquals":
|
|
208
|
+
return value !== filter.value;
|
|
209
|
+
|
|
210
|
+
case "greaterThan": {
|
|
211
|
+
const compareTo = parseValue(filter.value);
|
|
212
|
+
return isValueDate ? value > compareTo : value > (filter.value as any);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case "greaterThanOrEqual": {
|
|
216
|
+
const compareTo = parseValue(filter.value);
|
|
217
|
+
return isValueDate ? value >= compareTo : value >= (filter.value as any);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case "lessThan":
|
|
221
|
+
const lt = parseValue(filter.value);
|
|
222
|
+
return isValueDate ? value < lt : value < (filter.value as any);
|
|
223
|
+
|
|
224
|
+
case "lessThanOrEqual":
|
|
225
|
+
const lte = parseValue(filter.value);
|
|
226
|
+
return isValueDate ? value <= lte : value <= (filter.value as any);
|
|
227
|
+
|
|
228
|
+
case "contains":
|
|
229
|
+
return String(value)
|
|
230
|
+
.toLowerCase()
|
|
231
|
+
.includes(String(filter.value).toLowerCase());
|
|
232
|
+
|
|
233
|
+
case "startsWith":
|
|
234
|
+
return String(value)
|
|
235
|
+
.toLowerCase()
|
|
236
|
+
.startsWith(String(filter.value).toLowerCase());
|
|
237
|
+
|
|
238
|
+
case "endsWith":
|
|
239
|
+
return String(value)
|
|
240
|
+
.toLowerCase()
|
|
241
|
+
.endsWith(String(filter.value).toLowerCase());
|
|
242
|
+
|
|
243
|
+
case "in":
|
|
244
|
+
return Array.isArray(filter.value) && filter.value.includes(value);
|
|
245
|
+
|
|
246
|
+
case "notIn":
|
|
247
|
+
return Array.isArray(filter.value) && !filter.value.includes(value);
|
|
248
|
+
|
|
249
|
+
case "between":
|
|
250
|
+
if (Array.isArray(filter.value) && filter.value.length === 2) {
|
|
251
|
+
const [min, max] = filter.value.map(parseValue);
|
|
252
|
+
return isValueDate
|
|
253
|
+
? value >= min && value <= max
|
|
254
|
+
: value >= filter.value[0] && value <= filter.value[1];
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
|
|
258
|
+
case "isNull":
|
|
259
|
+
return value === null || value === undefined;
|
|
260
|
+
|
|
261
|
+
case "isNotNull":
|
|
262
|
+
return value !== null && value !== undefined;
|
|
263
|
+
|
|
264
|
+
default:
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getNestedValue(obj: any, path: string): any {
|
|
270
|
+
return path.split(".").reduce((current, key) => {
|
|
271
|
+
if (current === null || current === undefined) return undefined;
|
|
272
|
+
return current[key];
|
|
273
|
+
}, obj);
|
|
274
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Base Repository - Abstract implementation with common logic
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type { Id } from "../id";
|
|
6
|
+
import type { Aggregate } from "../entity";
|
|
7
|
+
import type { Criteria } from "../criteria";
|
|
8
|
+
import { PaginatedResult } from "../paginated-result";
|
|
9
|
+
import type { IRepository } from "../types";
|
|
10
|
+
import type { IMapper } from "./mapper";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Abstract base repository
|
|
14
|
+
* Implements common logic, delegates persistence to subclasses
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* class UserRepository extends BaseRepository<User, PrismaUser> {
|
|
19
|
+
* constructor(prisma: PrismaClient) {
|
|
20
|
+
* super(new UserMapper());
|
|
21
|
+
* this.prisma = prisma;
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* protected async insertOne(data: PrismaUser): Promise<PrismaUser> {
|
|
25
|
+
* return this.prisma.user.create({ data });
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* protected async updateOne(id: string, data: PrismaUser): Promise<PrismaUser> {
|
|
29
|
+
* return this.prisma.user.update({ where: { id }, data });
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* // ... implement other abstract methods
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export abstract class BaseRepository<
|
|
37
|
+
TDomain extends Aggregate<any>,
|
|
38
|
+
TPersistence = any
|
|
39
|
+
> implements IRepository<TDomain>
|
|
40
|
+
{
|
|
41
|
+
constructor(protected readonly mapper: IMapper<TDomain, TPersistence>) {}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Abstract methods - Must be implemented by subclasses
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Insert new record in database
|
|
49
|
+
*/
|
|
50
|
+
protected abstract insertOne(data: TPersistence): Promise<TPersistence>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Update existing record in database
|
|
54
|
+
*/
|
|
55
|
+
protected abstract updateOne(
|
|
56
|
+
id: string,
|
|
57
|
+
data: TPersistence
|
|
58
|
+
): Promise<TPersistence>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Delete record from database
|
|
62
|
+
*/
|
|
63
|
+
protected abstract deleteOne(id: string): Promise<void>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find record by ID in database
|
|
67
|
+
*/
|
|
68
|
+
protected abstract findOneById(id: string): Promise<TDomain | null>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Find all records in database (no filtering)
|
|
72
|
+
*/
|
|
73
|
+
protected abstract findMany(): Promise<TDomain[]>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Apply criteria to query (filtering, ordering, pagination)
|
|
77
|
+
* Returns [data, total]
|
|
78
|
+
*/
|
|
79
|
+
protected abstract applyCriteria(
|
|
80
|
+
criteria: Criteria<TDomain>
|
|
81
|
+
): Promise<PaginatedResult<TDomain>>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Count records matching criteria
|
|
85
|
+
*/
|
|
86
|
+
protected abstract countByCriteria(
|
|
87
|
+
criteria?: Criteria<TDomain>
|
|
88
|
+
): Promise<number>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if record exists by ID
|
|
92
|
+
*/
|
|
93
|
+
protected abstract existsById(id: string): Promise<boolean>;
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Public API - Implemented using abstract methods
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
async findById(id: Id): Promise<TDomain | null> {
|
|
100
|
+
const domain = await this.findOneById(id.value);
|
|
101
|
+
if (!domain) return null;
|
|
102
|
+
return domain;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async find(criteria: Criteria<TDomain>): Promise<PaginatedResult<TDomain>> {
|
|
106
|
+
return await this.applyCriteria(criteria);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async findAll(criteria?: Criteria<TDomain>): Promise<TDomain[]> {
|
|
110
|
+
if (criteria) {
|
|
111
|
+
const result = await this.find(criteria);
|
|
112
|
+
return result.data;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const domains = await this.findMany();
|
|
116
|
+
return domains;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async findOne(criteria: Criteria<TDomain>): Promise<TDomain | null> {
|
|
120
|
+
// Limit to 1 result
|
|
121
|
+
const limitedCriteria = criteria.clone().limit(1);
|
|
122
|
+
const result = await this.find(limitedCriteria);
|
|
123
|
+
|
|
124
|
+
return result.data.length > 0 ? result.data[0] : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async save(aggregate: TDomain): Promise<void> {
|
|
128
|
+
const persistence = this.mapper.toPersistence(aggregate);
|
|
129
|
+
|
|
130
|
+
if (aggregate.isNew) {
|
|
131
|
+
await this.insertOne(persistence);
|
|
132
|
+
} else {
|
|
133
|
+
await this.updateOne(aggregate.id.value, persistence);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async delete(aggregate: TDomain): Promise<void> {
|
|
138
|
+
await this.deleteOne(aggregate.id.value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async deleteById(id: Id): Promise<void> {
|
|
142
|
+
await this.deleteOne(id.value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async exists(id: Id): Promise<boolean> {
|
|
146
|
+
return this.existsById(id.value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async count(criteria?: Criteria<TDomain>): Promise<number> {
|
|
150
|
+
return this.countByCriteria(criteria);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// In-Memory Repository - Perfect for testing
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type { Id } from "../id";
|
|
6
|
+
import type { Aggregate } from "../entity";
|
|
7
|
+
import type { Criteria } from "../criteria";
|
|
8
|
+
import { PaginatedResult } from "../paginated-result";
|
|
9
|
+
import type { IRepository } from "../types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* In-memory repository implementation
|
|
13
|
+
* Perfect for unit tests and prototyping
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const userRepo = new InMemoryRepository<User>();
|
|
18
|
+
*
|
|
19
|
+
* await userRepo.save(user);
|
|
20
|
+
* const found = await userRepo.findById(user.id);
|
|
21
|
+
* const active = await userRepo.find(
|
|
22
|
+
* Criteria.create<User>().whereEquals('status', 'active')
|
|
23
|
+
* );
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export class InMemoryRepository<TDomain extends Aggregate<any>>
|
|
27
|
+
implements IRepository<TDomain>
|
|
28
|
+
{
|
|
29
|
+
protected items: Map<string, TDomain> = new Map();
|
|
30
|
+
|
|
31
|
+
async findById(id: Id): Promise<TDomain | null> {
|
|
32
|
+
return this.items.get(id.value) || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async find(criteria: Criteria<TDomain>): Promise<PaginatedResult<TDomain>> {
|
|
36
|
+
const allItems = Array.from(this.items.values());
|
|
37
|
+
return PaginatedResult.fromArray(allItems, criteria);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async findAll(criteria?: Criteria<TDomain>): Promise<TDomain[]> {
|
|
41
|
+
if (criteria) {
|
|
42
|
+
const result = await this.find(criteria);
|
|
43
|
+
return result.data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return Array.from(this.items.values());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async findOne(criteria: Criteria<TDomain>): Promise<TDomain | null> {
|
|
50
|
+
const result = await this.find(criteria.clone().limit(1));
|
|
51
|
+
return result.data.length > 0 ? result.data[0] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async save(aggregate: TDomain): Promise<void> {
|
|
55
|
+
this.items.set(aggregate.id.value, aggregate);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async saveMany(aggregates: TDomain[]): Promise<void> {
|
|
59
|
+
for (const aggregate of aggregates) {
|
|
60
|
+
await this.save(aggregate);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async delete(aggregate: TDomain): Promise<void> {
|
|
65
|
+
this.items.delete(aggregate.id.value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async deleteById(id: Id): Promise<void> {
|
|
69
|
+
this.items.delete(id.value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async exists(id: Id): Promise<boolean> {
|
|
73
|
+
return this.items.has(id.value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async count(criteria?: Criteria<TDomain>): Promise<number> {
|
|
77
|
+
if (criteria) {
|
|
78
|
+
const result = await this.find(criteria);
|
|
79
|
+
return result.meta.total;
|
|
80
|
+
}
|
|
81
|
+
return this.items.size;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clear all items (useful for test cleanup)
|
|
86
|
+
*/
|
|
87
|
+
clear(): void {
|
|
88
|
+
this.items.clear();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get all items as array (useful for debugging)
|
|
93
|
+
*/
|
|
94
|
+
getAll(): TDomain[] {
|
|
95
|
+
return Array.from(this.items.values());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get items count
|
|
100
|
+
*/
|
|
101
|
+
size(): number {
|
|
102
|
+
return this.items.size;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Repository Module - Clean exports
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
// Mapper
|
|
6
|
+
export { BaseMapper } from "./mapper";
|
|
7
|
+
export type { IMapper } from "./mapper";
|
|
8
|
+
|
|
9
|
+
// Base implementations
|
|
10
|
+
export { BaseRepository } from "./base-repository";
|
|
11
|
+
export { InMemoryRepository } from "./in-memory-repository";
|
|
12
|
+
|
|
13
|
+
// Unit of Work
|
|
14
|
+
export {
|
|
15
|
+
UnitOfWork,
|
|
16
|
+
BaseTransactionContext,
|
|
17
|
+
InMemoryUnitOfWork,
|
|
18
|
+
} from "./unit-of-work";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* QUICK START:
|
|
22
|
+
*
|
|
23
|
+
* 1. For Testing:
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { InMemoryRepository } from 'rich-domain';
|
|
26
|
+
*
|
|
27
|
+
* const userRepo = new InMemoryRepository<User>();
|
|
28
|
+
* await userRepo.save(user);
|
|
29
|
+
* const found = await userRepo.findById(user.id);
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* 2. For Production (Prisma, TypeORM, etc):
|
|
33
|
+
* - Extend BaseRepository
|
|
34
|
+
* - Implement abstract methods
|
|
35
|
+
* - Create a Mapper
|
|
36
|
+
* - See examples/ folder for reference
|
|
37
|
+
*
|
|
38
|
+
* 3. With Criteria:
|
|
39
|
+
* ```ts
|
|
40
|
+
* const result = await userRepo.find(
|
|
41
|
+
* Criteria.create<User>()
|
|
42
|
+
* .whereEquals('status', 'active')
|
|
43
|
+
* .orderByDesc('createdAt')
|
|
44
|
+
* .paginate(1, 10)
|
|
45
|
+
* );
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* 4. With Unit of Work:
|
|
49
|
+
* ```ts
|
|
50
|
+
* await uow.transaction(async (ctx) => {
|
|
51
|
+
* const userRepo = uow.getRepository(UserRepository);
|
|
52
|
+
* await userRepo.save(user);
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Mapper - Domain ↔ Persistence
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type { Aggregate } from "../entity";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mapper interface for converting between Domain and Persistence models
|
|
9
|
+
*
|
|
10
|
+
* @template TDomain - Domain aggregate/entity
|
|
11
|
+
* @template TPersistence - Database model (Prisma, TypeORM, etc.)
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* class UserMapper implements IMapper<User, PrismaUser> {
|
|
16
|
+
* toDomain(persistence: PrismaUser): User {
|
|
17
|
+
* return new User({
|
|
18
|
+
* id: Id.from(persistence.id),
|
|
19
|
+
* name: persistence.name,
|
|
20
|
+
* email: persistence.email,
|
|
21
|
+
* });
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* toPersistence(domain: User): PrismaUser {
|
|
25
|
+
* return {
|
|
26
|
+
* id: domain.id.value,
|
|
27
|
+
* name: domain.props.name,
|
|
28
|
+
* email: domain.props.email,
|
|
29
|
+
* };
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export interface IMapper<TDomain extends Aggregate<any>, TPersistence = any> {
|
|
35
|
+
/**
|
|
36
|
+
* Convert from persistence model to domain aggregate
|
|
37
|
+
*/
|
|
38
|
+
toDomain(persistence: TPersistence): TDomain;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Convert from domain aggregate to persistence model
|
|
42
|
+
*/
|
|
43
|
+
toPersistence(domain: TDomain): TPersistence;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Convert array of persistence models to domain aggregates
|
|
47
|
+
*/
|
|
48
|
+
toDomainList?(persistence: TPersistence[]): TDomain[];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Convert array of domain aggregates to persistence models
|
|
52
|
+
*/
|
|
53
|
+
toPersistenceList?(domain: TDomain[]): TPersistence[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Base mapper with default array implementations
|
|
58
|
+
*/
|
|
59
|
+
export abstract class BaseMapper<
|
|
60
|
+
TDomain extends Aggregate<any>,
|
|
61
|
+
TPersistence = any
|
|
62
|
+
> implements IMapper<TDomain, TPersistence>
|
|
63
|
+
{
|
|
64
|
+
abstract toDomain(persistence: TPersistence): TDomain;
|
|
65
|
+
abstract toPersistence(domain: TDomain): TPersistence;
|
|
66
|
+
|
|
67
|
+
toDomainList(persistence: TPersistence[]): TDomain[] {
|
|
68
|
+
return persistence.map((p) => this.toDomain(p));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
toPersistenceList(domain: TDomain[]): TPersistence[] {
|
|
72
|
+
return domain.map((d) => this.toPersistence(d));
|
|
73
|
+
}
|
|
74
|
+
}
|