@ronaldjdevfs/forge 1.3.0-beta → 1.3.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/README.md +11 -4
- package/package.json +1 -1
- package/skills/forge/SKILL.md +57 -128
- package/skills/forge/command/forge.md +59 -14
- package/skills/forge/reference/adr.md +242 -0
- package/skills/forge/reference/anti-corruption-layer.md +340 -0
- package/skills/forge/reference/api-design.md +7 -0
- package/skills/forge/reference/api-versioning.md +354 -0
- package/skills/forge/reference/architectural-depth-checklist.md +311 -0
- package/skills/forge/reference/architecture-template.md +41 -0
- package/skills/forge/reference/assay.md +6 -0
- package/skills/forge/reference/bounded-contexts.md +311 -0
- package/skills/forge/reference/chain.md +6 -0
- package/skills/forge/reference/cohesion-checklist.md +256 -0
- package/skills/forge/reference/cqrs.md +286 -0
- package/skills/forge/reference/data-patterns.md +6 -0
- package/skills/forge/reference/di-strategies.md +6 -0
- package/skills/forge/reference/errors.md +5 -0
- package/skills/forge/reference/events.md +8 -0
- package/skills/forge/reference/evolutionary-architecture.md +300 -0
- package/skills/forge/reference/forge.md +7 -0
- package/skills/forge/reference/hooks.md +6 -0
- package/skills/forge/reference/idempotency.md +283 -0
- package/skills/forge/reference/inscribe.md +5 -0
- package/skills/forge/reference/inspect.md +6 -0
- package/skills/forge/reference/modular-monolith.md +252 -0
- package/skills/forge/reference/observability.md +5 -0
- package/skills/forge/reference/quench.md +5 -0
- package/skills/forge/reference/relocate.md +6 -0
- package/skills/forge/reference/sagas.md +359 -0
- package/skills/forge/reference/security-patterns.md +6 -0
- package/skills/forge/reference/smelt.md +6 -0
- package/skills/forge/reference/temper.md +6 -0
- package/skills/forge/reference/testing-patterns.md +6 -0
- package/skills/forge/reference/transactional-outbox.md +311 -0
- package/skills/forge/scripts/architecture.mjs +10 -5
- package/skills/forge/scripts/assay.mjs +2 -2
- package/skills/forge/scripts/chain.mjs +31 -5
- package/skills/forge/scripts/context.mjs +24 -4
- package/skills/forge/scripts/detect.mjs +57 -36
- package/skills/forge/scripts/forge-boot.mjs +108 -0
- package/skills/forge/scripts/forge-config.mjs +182 -3
- package/skills/forge/scripts/forge-state.mjs +1 -1
- package/skills/forge/scripts/forgeSentinel-lib.mjs +2 -2
- package/skills/forge/scripts/forgeSentinel.mjs +2 -2
- package/skills/forge/scripts/forgeSmith.mjs +2 -2
- package/skills/forge/scripts/graph.mjs +65 -9
- package/skills/forge/scripts/hook.mjs +2 -2
- package/skills/forge/scripts/inspect.mjs +56 -48
- package/skills/forge/scripts/parse-imports.mjs +0 -2
- package/skills/forge/scripts/posttool.mjs +211 -17
- package/skills/forge/scripts/recommendation-engine.mjs +125 -0
- package/skills/forge/scripts/rename.mjs +62 -14
- package/skills/forge/scripts/rollback.mjs +5 -3
- package/skills/forge/templates/feature/acl-gateway.ts.md +52 -0
- package/skills/forge/templates/feature/acl-repository.ts.md +36 -0
- package/skills/forge/templates/feature/acl-translator.ts.md +24 -0
- package/skills/forge/templates/feature/cqrs-query.ts.md +40 -0
- package/skills/forge/templates/feature/entity.ts.md +1 -1
- package/skills/forge/templates/feature/mapper.ts.md +1 -1
- package/skills/forge/templates/feature/outbox-repository.ts.md +36 -0
- package/skills/forge/templates/feature/repository-impl.ts.md +2 -2
- package/skills/forge/templates/feature/repository-interface.ts.md +2 -2
- package/skills/forge/templates/feature/saga-orchestrator.ts.md +72 -0
- package/skills/forge/templates/feature/schema.ts.md +1 -1
- package/skills/forge/templates/feature/use-case.ts.md +2 -2
- package/skills/forge/templates/platform/outbox-relayer.ts.md +80 -0
- package/skills/forge/tests/core.test.mjs +288 -4
- package/src/cli.js +26 -13
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "fs";
|
|
4
4
|
import { join, relative, dirname, basename, extname, parse } from "path";
|
|
5
5
|
|
|
6
6
|
const ROOT = process.cwd();
|
|
@@ -66,7 +66,10 @@ const PLATFORM_RULES = {
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
const FEATURE_SUBDIR_RULES = [
|
|
69
|
+
{ subdir: "domain/entities", pattern: "entity", case: "pascal", description: "<Name>.entity.ts" },
|
|
70
|
+
{ subdir: "domain/repositories", pattern: "repository", prefix: "I", case: "pascal", description: "I<Name>.repository.ts" },
|
|
69
71
|
{ subdir: "domain", pattern: "entity", case: "pascal", description: "<Name>.entity.ts" },
|
|
72
|
+
{ subdir: "domain", pattern: "port", case: "pascal", description: "<Name>.port.ts" },
|
|
70
73
|
{ subdir: "domain", pattern: "repository", prefix: "I", case: "pascal", description: "I<Name>.repository.ts" },
|
|
71
74
|
{ subdir: "application/use-cases", pattern: "uc", case: "pascal", description: "<Action>.uc.ts" },
|
|
72
75
|
{ subdir: "application/mappers", pattern: "mapper", case: "pascal", description: "<Name>.mapper.ts" },
|
|
@@ -178,37 +181,73 @@ export function computeExpectedName(filePath) {
|
|
|
178
181
|
if (featureSubpath === rule.subdir || featureSubpath.startsWith(rule.subdir + "/")) {
|
|
179
182
|
const stemLower = stem.toLowerCase();
|
|
180
183
|
|
|
181
|
-
// For domain/
|
|
182
|
-
|
|
183
|
-
if (rule.subdir === "domain" && rule.pattern === "entity") {
|
|
184
|
+
// For domain/entities/: <Name>.entity.ts
|
|
185
|
+
if (rule.subdir === "domain/entities") {
|
|
184
186
|
let inferredEntity;
|
|
185
187
|
if (hasSuffix(stem, "entity")) {
|
|
186
188
|
inferredEntity = stem.replace(/\.entity$/i, "");
|
|
187
189
|
} else {
|
|
188
|
-
|
|
189
|
-
inferredEntity = noEntity || featureName;
|
|
190
|
+
inferredEntity = stem;
|
|
190
191
|
}
|
|
191
192
|
const entityStem = toPascalCase(inferredEntity);
|
|
192
193
|
const expectedName = `${entityStem}.entity${ext}`;
|
|
193
194
|
if (expectedName !== filename) {
|
|
194
|
-
return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain: ${rule.description}` };
|
|
195
|
+
return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain/entities: ${rule.description}` };
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
// For domain/
|
|
199
|
-
if (rule.subdir === "domain"
|
|
199
|
+
// For domain/repositories/: I<Name>.repository.ts
|
|
200
|
+
if (rule.subdir === "domain/repositories") {
|
|
200
201
|
const expectedStem = `I${entityName}.repository`;
|
|
201
202
|
const expectedName = `${expectedStem}${ext}`;
|
|
202
203
|
const currentMatches = stem === expectedStem;
|
|
203
204
|
if (!currentMatches) {
|
|
205
|
+
return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain/repositories: ${rule.description}` };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// For flat domain/entity: migrates to domain/entities/<Name>.entity.ts
|
|
210
|
+
if (rule.subdir === "domain" && rule.pattern === "entity" && featureSubpath === "domain") {
|
|
211
|
+
let inferredEntity;
|
|
212
|
+
if (hasSuffix(stem, "entity")) {
|
|
213
|
+
inferredEntity = stem.replace(/\.entity$/i, "");
|
|
214
|
+
} else {
|
|
215
|
+
const noEntity = stem.replace(/[Ee]ntity$/, "");
|
|
216
|
+
inferredEntity = noEntity || featureName;
|
|
217
|
+
}
|
|
218
|
+
const entityStem = toPascalCase(inferredEntity);
|
|
219
|
+
const expectedName = `${entityStem}.entity${ext}`;
|
|
220
|
+
const expectedPath = join(dirname(filePath), "entities", expectedName);
|
|
221
|
+
return { current: filePath, expected: expectedPath, relPath: join(dirname(relPath), "entities", expectedName), rule: `feature/${featureName}/domain → domain/entities: ${rule.description}` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// For domain/port: <Name>.port.ts
|
|
225
|
+
if (rule.subdir === "domain" && rule.pattern === "port") {
|
|
226
|
+
let inferredPort;
|
|
227
|
+
if (hasSuffix(stem, "port")) {
|
|
228
|
+
inferredPort = stem.replace(/\.port$/i, "");
|
|
229
|
+
} else {
|
|
230
|
+
inferredPort = stem;
|
|
231
|
+
}
|
|
232
|
+
const portStem = toPascalCase(inferredPort);
|
|
233
|
+
const expectedName = `${portStem}.port${ext}`;
|
|
234
|
+
if (expectedName !== filename) {
|
|
204
235
|
return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain: ${rule.description}` };
|
|
205
236
|
}
|
|
206
|
-
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// For flat domain/repository: migrates to domain/repositories/I<Entity>.repository.ts
|
|
240
|
+
if (rule.subdir === "domain" && rule.pattern === "repository" && featureSubpath === "domain") {
|
|
241
|
+
const expectedStem = `I${entityName}.repository`;
|
|
242
|
+
const expectedName = `${expectedStem}${ext}`;
|
|
243
|
+
const expectedPath = join(dirname(filePath), "repositories", expectedName);
|
|
244
|
+
return { current: filePath, expected: expectedPath, relPath: join(dirname(relPath), "repositories", expectedName), rule: `feature/${featureName}/domain → domain/repositories: ${rule.description}` };
|
|
207
245
|
}
|
|
208
246
|
|
|
209
247
|
// For use-cases: <Action>.uc.ts
|
|
210
248
|
if (rule.subdir === "application/use-cases") {
|
|
211
|
-
const
|
|
249
|
+
const cleanStem = hasSuffix(stem, "uc") ? stem.replace(/\.uc$/i, "") : stem;
|
|
250
|
+
const expectedName = `${toPascalCase(cleanStem)}.uc${ext}`;
|
|
212
251
|
if (expectedName !== filename) {
|
|
213
252
|
return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/application/use-cases: ${rule.description}` };
|
|
214
253
|
}
|
|
@@ -237,15 +276,21 @@ export function computeExpectedName(filePath) {
|
|
|
237
276
|
}
|
|
238
277
|
}
|
|
239
278
|
|
|
240
|
-
// Check domain/ for files
|
|
279
|
+
// Check domain/ for loose files without suffix → migrate to subdirectory
|
|
241
280
|
if (featureSubpath === "domain" && !stem.includes(".")) {
|
|
242
281
|
const lower = stem.toLowerCase();
|
|
243
282
|
if (lower.includes("repository") || stem.startsWith("I")) {
|
|
244
283
|
const expectedName = `I${entityName}.repository${ext}`;
|
|
245
|
-
|
|
284
|
+
const expectedPath = join(dirname(filePath), "repositories", expectedName);
|
|
285
|
+
return { current: filePath, expected: expectedPath, relPath: join(dirname(relPath), "repositories", expectedName), rule: `feature/${featureName}/domain → domain/repositories: I<Name>.repository.ts` };
|
|
286
|
+
}
|
|
287
|
+
if (lower.includes("port")) {
|
|
288
|
+
const expectedName = `${entityName}.port${ext}`;
|
|
289
|
+
return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain: <Name>.port.ts` };
|
|
246
290
|
}
|
|
247
291
|
const expectedName = `${entityName}.entity${ext}`;
|
|
248
|
-
|
|
292
|
+
const expectedPath = join(dirname(filePath), "entities", expectedName);
|
|
293
|
+
return { current: filePath, expected: expectedPath, relPath: join(dirname(relPath), "entities", expectedName), rule: `feature/${featureName}/domain → domain/entities: <Name>.entity.ts` };
|
|
249
294
|
}
|
|
250
295
|
|
|
251
296
|
return null;
|
|
@@ -536,6 +581,9 @@ export function renameFile(oldPath, newPath, projectRoot = ROOT) {
|
|
|
536
581
|
return { success: true, updatedImports: [], note: "El archivo ya tiene el nombre correcto" };
|
|
537
582
|
}
|
|
538
583
|
|
|
584
|
+
// Ensure target directory exists
|
|
585
|
+
mkdirSync(dirname(newPath), { recursive: true });
|
|
586
|
+
|
|
539
587
|
// Physical rename
|
|
540
588
|
try {
|
|
541
589
|
renameSync(oldPath, newPath);
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, cpSync, rmSync, statSync } from "fs";
|
|
20
20
|
import { join, relative, resolve, basename } from "path";
|
|
21
|
+
import { fileURLToPath } from "url";
|
|
21
22
|
import { execFileSync } from "child_process";
|
|
22
23
|
|
|
23
24
|
const ROOT = process.cwd();
|
|
@@ -157,7 +158,8 @@ export function listBackups(target) {
|
|
|
157
158
|
|
|
158
159
|
export function verifyAfterChange() {
|
|
159
160
|
try {
|
|
160
|
-
const
|
|
161
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
162
|
+
const out = shell("node", [join(__dirname, "inspect.mjs"), "--diff", "--json"]);
|
|
161
163
|
if (!out) return { score: 0, improved: false, error: "inspect falló" };
|
|
162
164
|
|
|
163
165
|
const result = JSON.parse(out);
|
|
@@ -167,7 +169,7 @@ export function verifyAfterChange() {
|
|
|
167
169
|
let suggestedCommit = null;
|
|
168
170
|
if (improved && score > 0) {
|
|
169
171
|
const branch = shell("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
170
|
-
suggestedCommit = `git add -A && git commit -m "forge: relocate/reforge (score: ${score}
|
|
172
|
+
suggestedCommit = `git add -A && git commit -m "forge: relocate/reforge (score: ${score})"`;
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
return { score, improved, suggestedCommit };
|
|
@@ -205,7 +207,7 @@ if (action === "backup" && arg) {
|
|
|
205
207
|
console.error(`rollback: ${result.error}`);
|
|
206
208
|
process.exit(1);
|
|
207
209
|
}
|
|
208
|
-
console.log(`Score: ${result.score}
|
|
210
|
+
console.log(`Score: ${result.score} | ${result.improved ? "✓ Mejoró/igual" : "✘ Empeoró"}${result.suggestedCommit ? `\nSugerencia: ${result.suggestedCommit}` : ""}`);
|
|
209
211
|
process.exit(result.improved ? 0 : 1);
|
|
210
212
|
} else {
|
|
211
213
|
console.log("Uso: node rollback.mjs <backup|restore|list|verify> [target|id]");
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
```typescript
|
|
2
|
+
// src/features/<domain>/adapters/out/legacy-<system>/<Domain>Gateway.ts
|
|
3
|
+
import { injectable } from "tsyringe";
|
|
4
|
+
import type { External<Domain>DTO } from "./External<Domain>DTO.js";
|
|
5
|
+
|
|
6
|
+
@injectable()
|
|
7
|
+
export class <Domain>Gateway {
|
|
8
|
+
private readonly baseUrl: string;
|
|
9
|
+
|
|
10
|
+
constructor() {
|
|
11
|
+
this.baseUrl = process.env.LEGACY_<SYSTEM>_URL ?? "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async fetch(id: string): Promise<External<Domain>DTO | null> {
|
|
15
|
+
const response = await fetch(`${this.baseUrl}/api/${id}`, {
|
|
16
|
+
headers: { Authorization: `Bearer ${this.getApiKey()}` },
|
|
17
|
+
signal: AbortSignal.timeout(5000),
|
|
18
|
+
});
|
|
19
|
+
if (response.status === 404) return null;
|
|
20
|
+
if (!response.ok) throw new Error(`Legacy error: ${response.statusText}`);
|
|
21
|
+
return response.json();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async upsert(dto: External<Domain>DTO): Promise<External<Domain>DTO> {
|
|
25
|
+
const response = await fetch(`${this.baseUrl}/api`, {
|
|
26
|
+
method: "PUT",
|
|
27
|
+
headers: {
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
Authorization: `Bearer ${this.getApiKey()}`,
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify(dto),
|
|
32
|
+
signal: AbortSignal.timeout(5000),
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) throw new Error(`Legacy error: ${response.statusText}`);
|
|
35
|
+
return response.json();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async remove(id: string): Promise<void> {
|
|
39
|
+
const response = await fetch(`${this.baseUrl}/api/${id}`, {
|
|
40
|
+
method: "DELETE",
|
|
41
|
+
headers: { Authorization: `Bearer ${this.getApiKey()}` },
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok && response.status !== 404) {
|
|
44
|
+
throw new Error(`Legacy error: ${response.statusText}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private getApiKey(): string {
|
|
49
|
+
return process.env.LEGACY_<SYSTEM>_API_KEY ?? "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
```typescript
|
|
2
|
+
// src/features/<domain>/adapters/out/legacy-<system>/<Domain>ACL.ts
|
|
3
|
+
import { injectable, inject } from "tsyringe";
|
|
4
|
+
import type { I<Domain>Repository } from "../../domain/repositories/I<Domain>.repository.js";
|
|
5
|
+
import type { <Domain>Entity } from "../../domain/entities/<Domain>.entity.js";
|
|
6
|
+
import { <Domain>Gateway } from "./<Domain>Gateway.js";
|
|
7
|
+
import { <Domain>Translator } from "./<Domain>Translator.js";
|
|
8
|
+
|
|
9
|
+
@injectable()
|
|
10
|
+
export class <Domain>ACLRepository implements I<Domain>Repository {
|
|
11
|
+
constructor(
|
|
12
|
+
@inject(<Domain>Gateway) private readonly gateway: <Domain>Gateway,
|
|
13
|
+
@inject(<Domain>Translator) private readonly translator: <Domain>Translator,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async findById(id: string): Promise<<Domain>Entity | null> {
|
|
17
|
+
try {
|
|
18
|
+
const dto = await this.gateway.fetch(id);
|
|
19
|
+
return dto ? this.translator.toDomain(dto) : null;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.message.includes("404")) return null;
|
|
22
|
+
throw new Error("<Domain>Repository.fetchFailed");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async save(entity: <Domain>Entity): Promise<<Domain>Entity> {
|
|
27
|
+
const dto = this.translator.toExternal(entity);
|
|
28
|
+
const result = await this.gateway.upsert(dto);
|
|
29
|
+
return this.translator.toDomain(result);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async delete(id: string): Promise<void> {
|
|
33
|
+
await this.gateway.remove(id);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
```typescript
|
|
2
|
+
// src/features/<domain>/adapters/out/legacy-<system>/<Domain>Translator.ts
|
|
3
|
+
import type { <Domain>Entity } from "../../../domain/entities/<Domain>.entity.js";
|
|
4
|
+
import type { External<Domain>DTO } from "./External<Domain>DTO.js";
|
|
5
|
+
|
|
6
|
+
export class <Domain>Translator {
|
|
7
|
+
toDomain(dto: External<Domain>DTO): <Domain>Entity {
|
|
8
|
+
return {
|
|
9
|
+
id: dto.externalId,
|
|
10
|
+
// mapear campos del DTO externo al modelo de dominio
|
|
11
|
+
// name: dto.fullName,
|
|
12
|
+
// email: dto.emailAddress,
|
|
13
|
+
// status: dto.isActive ? "active" : "inactive",
|
|
14
|
+
} as <Domain>Entity;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
toExternal(entity: <Domain>Entity): External<Domain>DTO {
|
|
18
|
+
return {
|
|
19
|
+
externalId: entity.id,
|
|
20
|
+
// mapear campos del dominio al DTO externo
|
|
21
|
+
} as External<Domain>DTO;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
```typescript
|
|
2
|
+
// src/features/<domain>/application/queries/Get<Domain>Query.ts
|
|
3
|
+
import { injectable, inject } from "tsyringe";
|
|
4
|
+
import type { I<Domain>ReadRepository } from "../../adapters/out/read/I<Domain>ReadRepository.js";
|
|
5
|
+
|
|
6
|
+
export type <Domain>QueryResult = {
|
|
7
|
+
// DTO plano desnormalizado para lectura
|
|
8
|
+
id: string;
|
|
9
|
+
// campos optimizados para consulta
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
@injectable()
|
|
13
|
+
export class Get<Domain>Query {
|
|
14
|
+
constructor(
|
|
15
|
+
@inject(I<Domain>ReadRepository)
|
|
16
|
+
private readonly readRepo: I<Domain>ReadRepository,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async execute(id: string): Promise<<Domain>QueryResult | null> {
|
|
20
|
+
return this.readRepo.findById(id);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Query con filtros
|
|
25
|
+
export class List<Domain>Query {
|
|
26
|
+
constructor(
|
|
27
|
+
@inject(I<Domain>ReadRepository)
|
|
28
|
+
private readonly readRepo: I<Domain>ReadRepository,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
async execute(filters: {
|
|
32
|
+
page?: number;
|
|
33
|
+
limit?: number;
|
|
34
|
+
sort?: string;
|
|
35
|
+
filter?: Record<string, string>;
|
|
36
|
+
}): Promise<{ data: <Domain>QueryResult[]; total: number }> {
|
|
37
|
+
return this.readRepo.findMany(filters);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
```typescript
|
|
2
2
|
// src/features/<domain>/application/mappers/<Domain>.mapper.ts
|
|
3
|
-
import type { <Domain> } from "../domain/<Domain>.entity.js";
|
|
3
|
+
import type { <Domain> } from "../domain/entities/<Domain>.entity.js";
|
|
4
4
|
|
|
5
5
|
export class <Domain>Mapper {
|
|
6
6
|
static toDomain(doc: Record<string, any>): <Domain> {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
```typescript
|
|
2
|
+
// src/features/<domain>/adapters/out/persistence/<Domain>OutboxRepository.ts
|
|
3
|
+
import { injectable } from "tsyringe";
|
|
4
|
+
import type { DomainEvent } from "../../../domain/events/DomainEvent.js";
|
|
5
|
+
|
|
6
|
+
export type OutboxEntry = {
|
|
7
|
+
id: string;
|
|
8
|
+
eventType: string;
|
|
9
|
+
aggregateId: string;
|
|
10
|
+
aggregateType: string;
|
|
11
|
+
payload: Record<string, unknown>;
|
|
12
|
+
occurredAt: Date;
|
|
13
|
+
processedAt: Date | null;
|
|
14
|
+
retryCount: number;
|
|
15
|
+
lastError: string | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
@injectable()
|
|
19
|
+
export class <Domain>OutboxRepository {
|
|
20
|
+
async add(event: DomainEvent): Promise<void> {
|
|
21
|
+
// Implementar: INSERT en tabla outbox dentro de la transacción actual
|
|
22
|
+
// const entry: OutboxEntry = {
|
|
23
|
+
// id: crypto.randomUUID(),
|
|
24
|
+
// eventType: event.constructor.name,
|
|
25
|
+
// aggregateId: event.aggregateId,
|
|
26
|
+
// aggregateType: "<Domain>",
|
|
27
|
+
// payload: JSON.parse(JSON.stringify(event)),
|
|
28
|
+
// occurredAt: new Date(),
|
|
29
|
+
// processedAt: null,
|
|
30
|
+
// retryCount: 0,
|
|
31
|
+
// lastError: null,
|
|
32
|
+
// };
|
|
33
|
+
// await this.db.outbox.create({ data: entry });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
```typescript
|
|
2
2
|
// src/features/<domain>/adapters/out/persistence/<Domain>.repository.ts
|
|
3
3
|
import { injectable } from "tsyringe";
|
|
4
|
-
import type { <Domain> } from "../../../domain/<Domain>.entity.js";
|
|
5
|
-
import type { I<Domain>Repository } from "../../../domain/I<Domain>.repository.js";
|
|
4
|
+
import type { <Domain> } from "../../../domain/entities/<Domain>.entity.js";
|
|
5
|
+
import type { I<Domain>Repository } from "../../../domain/repositories/I<Domain>.repository.js";
|
|
6
6
|
import { <Domain>Mapper } from "../../../application/mappers/<Domain>.mapper.js";
|
|
7
7
|
import <Domain>Model from "./<Domain>.schema.js";
|
|
8
8
|
import { RepositoryError } from "@/shared/errors/RepositoryError.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
```typescript
|
|
2
|
-
// src/features/<domain>/domain/I<Domain>.repository.ts
|
|
3
|
-
import type { <Domain> } from "
|
|
2
|
+
// src/features/<domain>/domain/repositories/I<Domain>.repository.ts
|
|
3
|
+
import type { <Domain> } from "../entities/<Domain>.entity.js";
|
|
4
4
|
|
|
5
5
|
export interface I<Domain>Repository {
|
|
6
6
|
create(data: Partial<<Domain>>, session?: unknown): Promise<<Domain>>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
```typescript
|
|
2
|
+
// src/features/<domain>/adapters/out/saga/SagaOrchestrator.ts
|
|
3
|
+
import { injectable, inject } from "tsyringe";
|
|
4
|
+
import type { SagaInstance, SagaStatus } from "../../domain/entities/SagaInstance.entity.js";
|
|
5
|
+
import type { ISagaRepository } from "../../domain/repositories/ISagaRepository.repository.js";
|
|
6
|
+
import type { IEventBus } from "@/platform/events/IEventBus.js";
|
|
7
|
+
import type { ILogger } from "@/platform/logger/ILogger.js";
|
|
8
|
+
|
|
9
|
+
export interface SagaStep {
|
|
10
|
+
name: string;
|
|
11
|
+
execute(ctx: Record<string, unknown>): Promise<void>;
|
|
12
|
+
compensate(ctx: Record<string, unknown>): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@injectable()
|
|
16
|
+
export class SagaOrchestrator {
|
|
17
|
+
constructor(
|
|
18
|
+
@inject(ISagaRepository) private readonly sagaRepo: ISagaRepository,
|
|
19
|
+
@inject(IEventBus) private readonly eventBus: IEventBus,
|
|
20
|
+
@inject(ILogger) private readonly logger: ILogger,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async execute(
|
|
24
|
+
sagaType: string,
|
|
25
|
+
steps: SagaStep[],
|
|
26
|
+
initialContext: Record<string, unknown>,
|
|
27
|
+
): Promise<string> {
|
|
28
|
+
const saga = SagaInstance.start(sagaType, initialContext);
|
|
29
|
+
await this.sagaRepo.save(saga);
|
|
30
|
+
|
|
31
|
+
for (const step of steps) {
|
|
32
|
+
try {
|
|
33
|
+
this.logger.info(`Saga ${saga.id}: executing step ${step.name}`);
|
|
34
|
+
await step.execute(saga.context);
|
|
35
|
+
saga.advance(step.name);
|
|
36
|
+
await this.sagaRepo.save(saga);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
this.logger.error(
|
|
39
|
+
`Saga ${saga.id}: step ${step.name} failed — ${error.message}`,
|
|
40
|
+
);
|
|
41
|
+
await this.compensate(saga, steps);
|
|
42
|
+
return saga.id;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
saga.complete();
|
|
47
|
+
await this.sagaRepo.save(saga);
|
|
48
|
+
this.eventBus.publish({ type: "saga.completed", sagaId: saga.id });
|
|
49
|
+
return saga.id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async compensate(
|
|
53
|
+
saga: SagaInstance,
|
|
54
|
+
steps: SagaStep[],
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const executed = steps.slice(0, saga.currentStepIndex);
|
|
57
|
+
for (const step of executed.reverse()) {
|
|
58
|
+
try {
|
|
59
|
+
this.logger.info(`Saga ${saga.id}: compensating step ${step.name}`);
|
|
60
|
+
await step.compensate(saga.context);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
this.logger.error(
|
|
63
|
+
`Saga ${saga.id}: compensation failed for ${step.name} — requiere intervención manual`,
|
|
64
|
+
);
|
|
65
|
+
saga.requireManualIntervention(step.name, err.message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
saga.fail();
|
|
69
|
+
await this.sagaRepo.save(saga);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
```typescript
|
|
2
2
|
// src/features/<domain>/adapters/out/persistence/<Domain>.schema.ts
|
|
3
3
|
import { model, Schema } from "mongoose";
|
|
4
|
-
import type { <Domain> } from "../../../domain/<Domain>.entity.js";
|
|
4
|
+
import type { <Domain> } from "../../../domain/entities/<Domain>.entity.js";
|
|
5
5
|
|
|
6
6
|
const <Domain>Schema = new Schema<<Domain>>(
|
|
7
7
|
{
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
```typescript
|
|
2
2
|
// src/features/<domain>/application/use-cases/Create<Domain>.uc.ts
|
|
3
3
|
import { injectable, inject } from "tsyringe";
|
|
4
|
-
import type { <Domain> } from "../../domain/<Domain>.entity.js";
|
|
5
|
-
import type { I<Domain>Repository } from "../../domain/I<Domain>.repository.js";
|
|
4
|
+
import type { <Domain> } from "../../domain/entities/<Domain>.entity.js";
|
|
5
|
+
import type { I<Domain>Repository } from "../../domain/repositories/I<Domain>.repository.js";
|
|
6
6
|
import { UseCaseError } from "@/shared/errors/UseCaseError.js";
|
|
7
7
|
// Opcional: errores de dominio y eventos
|
|
8
8
|
// import { <Domain>NotFoundError } from "../../domain/errors/<Domain>NotFound.error.js";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
```typescript
|
|
2
|
+
// src/platform/scheduler/OutboxRelayer.ts
|
|
3
|
+
import { injectable, inject } from "tsyringe";
|
|
4
|
+
import type { IEventBus } from "@/platform/events/IEventBus.js";
|
|
5
|
+
import type { ILogger } from "@/platform/logger/ILogger.js";
|
|
6
|
+
|
|
7
|
+
type OutboxEntry = {
|
|
8
|
+
id: string;
|
|
9
|
+
eventType: string;
|
|
10
|
+
payload: Record<string, unknown>;
|
|
11
|
+
retryCount: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
@injectable()
|
|
15
|
+
export class OutboxRelayer {
|
|
16
|
+
private readonly POLL_INTERVAL_MS = 1000;
|
|
17
|
+
private readonly MAX_RETRIES = 10;
|
|
18
|
+
private readonly BATCH_SIZE = 50;
|
|
19
|
+
private running = false;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
@inject(IEventBus) private readonly eventBus: IEventBus,
|
|
23
|
+
@inject(ILogger) private readonly logger: ILogger,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
async start(): Promise<void> {
|
|
27
|
+
this.running = true;
|
|
28
|
+
this.logger.info("OutboxRelayer started");
|
|
29
|
+
while (this.running) {
|
|
30
|
+
await this.processBatch();
|
|
31
|
+
await this.sleep(this.POLL_INTERVAL_MS);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stop(): void {
|
|
36
|
+
this.running = false;
|
|
37
|
+
this.logger.info("OutboxRelayer stopped");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async processBatch(): Promise<void> {
|
|
41
|
+
const batch = await this.fetchUnprocessed();
|
|
42
|
+
for (const entry of batch) {
|
|
43
|
+
try {
|
|
44
|
+
await this.eventBus.publish(entry.payload);
|
|
45
|
+
await this.markProcessed(entry.id);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
this.logger.error(`Outbox ${entry.id}: publish failed — ${error.message}`);
|
|
48
|
+
await this.incrementRetry(entry.id, error.message);
|
|
49
|
+
if (entry.retryCount >= this.MAX_RETRIES) {
|
|
50
|
+
await this.sendToDLQ(entry);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async fetchUnprocessed(): Promise<OutboxEntry[]> {
|
|
57
|
+
// SELECT * FROM outbox WHERE processed_at IS NULL
|
|
58
|
+
// AND retry_count < $1 ORDER BY occurred_at ASC LIMIT $2
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async markProcessed(id: string): Promise<void> {
|
|
63
|
+
// UPDATE outbox SET processed_at = NOW() WHERE id = $1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async incrementRetry(id: string, error: string): Promise<void> {
|
|
67
|
+
// UPDATE outbox SET retry_count = retry_count + 1, last_error = $1 WHERE id = $2
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async sendToDLQ(entry: OutboxEntry): Promise<void> {
|
|
71
|
+
this.logger.error(`Outbox ${entry.id}: moved to DLQ after ${this.MAX_RETRIES} retries`);
|
|
72
|
+
// INSERT INTO outbox_dlq SELECT * FROM outbox WHERE id = $1
|
|
73
|
+
// DELETE FROM outbox WHERE id = $1
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private sleep(ms: number): Promise<void> {
|
|
77
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|