@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.
Files changed (69) hide show
  1. package/README.md +11 -4
  2. package/package.json +1 -1
  3. package/skills/forge/SKILL.md +57 -128
  4. package/skills/forge/command/forge.md +59 -14
  5. package/skills/forge/reference/adr.md +242 -0
  6. package/skills/forge/reference/anti-corruption-layer.md +340 -0
  7. package/skills/forge/reference/api-design.md +7 -0
  8. package/skills/forge/reference/api-versioning.md +354 -0
  9. package/skills/forge/reference/architectural-depth-checklist.md +311 -0
  10. package/skills/forge/reference/architecture-template.md +41 -0
  11. package/skills/forge/reference/assay.md +6 -0
  12. package/skills/forge/reference/bounded-contexts.md +311 -0
  13. package/skills/forge/reference/chain.md +6 -0
  14. package/skills/forge/reference/cohesion-checklist.md +256 -0
  15. package/skills/forge/reference/cqrs.md +286 -0
  16. package/skills/forge/reference/data-patterns.md +6 -0
  17. package/skills/forge/reference/di-strategies.md +6 -0
  18. package/skills/forge/reference/errors.md +5 -0
  19. package/skills/forge/reference/events.md +8 -0
  20. package/skills/forge/reference/evolutionary-architecture.md +300 -0
  21. package/skills/forge/reference/forge.md +7 -0
  22. package/skills/forge/reference/hooks.md +6 -0
  23. package/skills/forge/reference/idempotency.md +283 -0
  24. package/skills/forge/reference/inscribe.md +5 -0
  25. package/skills/forge/reference/inspect.md +6 -0
  26. package/skills/forge/reference/modular-monolith.md +252 -0
  27. package/skills/forge/reference/observability.md +5 -0
  28. package/skills/forge/reference/quench.md +5 -0
  29. package/skills/forge/reference/relocate.md +6 -0
  30. package/skills/forge/reference/sagas.md +359 -0
  31. package/skills/forge/reference/security-patterns.md +6 -0
  32. package/skills/forge/reference/smelt.md +6 -0
  33. package/skills/forge/reference/temper.md +6 -0
  34. package/skills/forge/reference/testing-patterns.md +6 -0
  35. package/skills/forge/reference/transactional-outbox.md +311 -0
  36. package/skills/forge/scripts/architecture.mjs +10 -5
  37. package/skills/forge/scripts/assay.mjs +2 -2
  38. package/skills/forge/scripts/chain.mjs +31 -5
  39. package/skills/forge/scripts/context.mjs +24 -4
  40. package/skills/forge/scripts/detect.mjs +57 -36
  41. package/skills/forge/scripts/forge-boot.mjs +108 -0
  42. package/skills/forge/scripts/forge-config.mjs +182 -3
  43. package/skills/forge/scripts/forge-state.mjs +1 -1
  44. package/skills/forge/scripts/forgeSentinel-lib.mjs +2 -2
  45. package/skills/forge/scripts/forgeSentinel.mjs +2 -2
  46. package/skills/forge/scripts/forgeSmith.mjs +2 -2
  47. package/skills/forge/scripts/graph.mjs +65 -9
  48. package/skills/forge/scripts/hook.mjs +2 -2
  49. package/skills/forge/scripts/inspect.mjs +56 -48
  50. package/skills/forge/scripts/parse-imports.mjs +0 -2
  51. package/skills/forge/scripts/posttool.mjs +211 -17
  52. package/skills/forge/scripts/recommendation-engine.mjs +125 -0
  53. package/skills/forge/scripts/rename.mjs +62 -14
  54. package/skills/forge/scripts/rollback.mjs +5 -3
  55. package/skills/forge/templates/feature/acl-gateway.ts.md +52 -0
  56. package/skills/forge/templates/feature/acl-repository.ts.md +36 -0
  57. package/skills/forge/templates/feature/acl-translator.ts.md +24 -0
  58. package/skills/forge/templates/feature/cqrs-query.ts.md +40 -0
  59. package/skills/forge/templates/feature/entity.ts.md +1 -1
  60. package/skills/forge/templates/feature/mapper.ts.md +1 -1
  61. package/skills/forge/templates/feature/outbox-repository.ts.md +36 -0
  62. package/skills/forge/templates/feature/repository-impl.ts.md +2 -2
  63. package/skills/forge/templates/feature/repository-interface.ts.md +2 -2
  64. package/skills/forge/templates/feature/saga-orchestrator.ts.md +72 -0
  65. package/skills/forge/templates/feature/schema.ts.md +1 -1
  66. package/skills/forge/templates/feature/use-case.ts.md +2 -2
  67. package/skills/forge/templates/platform/outbox-relayer.ts.md +80 -0
  68. package/skills/forge/tests/core.test.mjs +288 -4
  69. 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/entity: <Entity>.entity.ts
182
- // Entity name derived from feature dir or current filename
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
- const noEntity = stem.replace(/[Ee]ntity$/, "");
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/repository interface: I<Entity>.repository.ts
199
- if (rule.subdir === "domain" && rule.pattern === "repository") {
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
- return null;
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 expectedName = `${toPascalCase(stem)}.uc${ext}`;
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 that look like entity/repository but don't match (loose 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
- return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain: I<Name>.repository.ts` };
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
- return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain: <Name>.entity.ts` };
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 out = shell("node", [join(import.meta.url, "..", "inspect.mjs"), "--diff", "--json"]);
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}/110)"`;
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}/110 | ${result.improved ? "✓ Mejoró/igual" : "✘ Empeoró"}${result.suggestedCommit ? `\nSugerencia: ${result.suggestedCommit}` : ""}`);
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,5 +1,5 @@
1
1
  ```typescript
2
- // src/features/<domain>/domain/<Domain>.entity.ts
2
+ // src/features/<domain>/domain/entities/<Domain>.entity.ts
3
3
  import type { SomeGlobalType } from "@/shared/types/types.js";
4
4
 
5
5
  export interface <Domain> {
@@ -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 "./<Domain>.entity.js";
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
+ ```