@ronaldjdevfs/forge 1.2.0 → 1.3.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/README.md +36 -21
- package/package.json +7 -2
- package/skills/forge/SKILL.md +56 -122
- 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 +39 -30
- 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 +86 -0
- package/skills/forge/scripts/forgeSentinel.mjs +184 -0
- package/skills/forge/scripts/forgeSmith-admin.mjs +104 -0
- package/skills/forge/scripts/forgeSmith.mjs +164 -0
- 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/pin.mjs +10 -3
- package/skills/forge/scripts/posttool.mjs +2 -2
- package/skills/forge/scripts/recommendation-engine.mjs +125 -0
- package/skills/forge/scripts/rollback.mjs +5 -3
- package/skills/forge/templates/agents/SKILL.md.template +283 -0
- package/skills/forge/templates/agents/agents/hooks.json +18 -0
- package/skills/forge/templates/agents/claude/CLAUDE.md +17 -16
- package/skills/forge/templates/agents/claude/settings.local.json +18 -0
- package/skills/forge/templates/agents/codex/hooks.json +18 -0
- package/skills/forge/templates/agents/cursor/.cursorrules +22 -5
- package/skills/forge/templates/agents/cursor/hooks.json +11 -0
- package/skills/forge/templates/agents/gemini/SKILL.md +13 -0
- 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/outbox-repository.ts.md +36 -0
- package/skills/forge/templates/feature/saga-orchestrator.ts.md +72 -0
- package/skills/forge/templates/platform/outbox-relayer.ts.md +80 -0
- package/skills/forge/tests/core.test.mjs +284 -0
- package/src/agents.mjs +35 -2
- package/src/cli.js +112 -39
- package/src/wizard.mjs +142 -90
|
@@ -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/I<Domain>Repository.js";
|
|
5
|
+
import type { <Domain>Entity } from "../../domain/<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/<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
|
+
```
|
|
@@ -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
|
+
```
|
|
@@ -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/SagaInstance.entity.js";
|
|
5
|
+
import type { ISagaRepository } from "../../domain/ISagaRepository.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
|
+
```
|
|
@@ -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
|
+
```
|
|
@@ -125,6 +125,9 @@ describe("profile.mjs", () => {
|
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
describe("graph.mjs", () => {
|
|
128
|
+
before(() => scaffoldProject({}));
|
|
129
|
+
after(() => cleanup());
|
|
130
|
+
|
|
128
131
|
it("builds an empty graph for an empty project", async () => {
|
|
129
132
|
const { buildGraph } = await import("../scripts/graph.mjs");
|
|
130
133
|
const graph = buildGraph(process.cwd());
|
|
@@ -138,6 +141,9 @@ describe("graph.mjs", () => {
|
|
|
138
141
|
});
|
|
139
142
|
|
|
140
143
|
describe("armorer.mjs", () => {
|
|
144
|
+
before(() => scaffoldProject({}));
|
|
145
|
+
after(() => cleanup());
|
|
146
|
+
|
|
141
147
|
it("builds a healthy report for an empty project", async () => {
|
|
142
148
|
const { buildOwnershipReport } = await import("../scripts/armorer.mjs");
|
|
143
149
|
const report = buildOwnershipReport(process.cwd());
|
|
@@ -180,6 +186,9 @@ describe("forge-config.mjs", () => {
|
|
|
180
186
|
});
|
|
181
187
|
|
|
182
188
|
describe("chain.mjs", () => {
|
|
189
|
+
before(() => scaffoldProject({}));
|
|
190
|
+
after(() => cleanup());
|
|
191
|
+
|
|
183
192
|
it("builds an empty dependency graph for empty project", async () => {
|
|
184
193
|
const { buildDependencyGraph } = await import("../scripts/chain.mjs");
|
|
185
194
|
const graph = buildDependencyGraph();
|
|
@@ -319,6 +328,9 @@ describe("detect.mjs — inline ignores", () => {
|
|
|
319
328
|
});
|
|
320
329
|
|
|
321
330
|
describe("posttool.mjs", () => {
|
|
331
|
+
before(() => scaffoldProject({}));
|
|
332
|
+
after(() => cleanup());
|
|
333
|
+
|
|
322
334
|
it("postToolCheck returns empty for no files", async () => {
|
|
323
335
|
const { postToolCheck } = await import("../scripts/posttool.mjs");
|
|
324
336
|
const result = await postToolCheck([], {});
|
|
@@ -401,3 +413,275 @@ describe("assay.mjs", () => {
|
|
|
401
413
|
assert.ok(opinion.includes("R1") || opinion.includes("R8") || opinion.includes("acoplamiento") || opinion.includes("importan"));
|
|
402
414
|
});
|
|
403
415
|
});
|
|
416
|
+
|
|
417
|
+
describe("transactional-outbox pattern", () => {
|
|
418
|
+
it("outbox entry tracks lifecycle states", () => {
|
|
419
|
+
const entry = { id: "1", eventType: "OrderPlaced", processedAt: null, retryCount: 0, lastError: null };
|
|
420
|
+
assert.equal(entry.processedAt, null);
|
|
421
|
+
assert.equal(entry.retryCount, 0);
|
|
422
|
+
|
|
423
|
+
entry.processedAt = new Date();
|
|
424
|
+
entry.retryCount = 2;
|
|
425
|
+
entry.lastError = "timeout";
|
|
426
|
+
assert.ok(entry.processedAt instanceof Date);
|
|
427
|
+
assert.equal(entry.retryCount, 2);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("retry policy respects max retries and backoff", () => {
|
|
431
|
+
const maxRetries = 3;
|
|
432
|
+
const baseDelay = 100;
|
|
433
|
+
let attempts = 0;
|
|
434
|
+
|
|
435
|
+
async function executeWithRetry(fn) {
|
|
436
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
437
|
+
try {
|
|
438
|
+
return await fn();
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (attempt === maxRetries) throw error;
|
|
441
|
+
// exponential backoff: baseDelay * 2^(attempt-1)
|
|
442
|
+
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
443
|
+
// not actually waiting in test
|
|
444
|
+
void delay;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
assert.rejects(async () => {
|
|
450
|
+
await executeWithRetry(() => Promise.reject(new Error("fail")));
|
|
451
|
+
}, /fail/);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("outbox relayer moves event to DLQ after exceeding retries", () => {
|
|
455
|
+
const MAX_RETRIES = 10;
|
|
456
|
+
const entry = { id: "bad-event", retryCount: 9 };
|
|
457
|
+
assert.ok(entry.retryCount < MAX_RETRIES);
|
|
458
|
+
|
|
459
|
+
entry.retryCount++;
|
|
460
|
+
assert.equal(entry.retryCount, 10);
|
|
461
|
+
|
|
462
|
+
const shouldGoToDLQ = entry.retryCount >= MAX_RETRIES;
|
|
463
|
+
assert.ok(shouldGoToDLQ);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("outbox entry requires event_id, aggregate_id, and payload", () => {
|
|
467
|
+
const validEntry = {
|
|
468
|
+
id: crypto.randomUUID(),
|
|
469
|
+
eventType: "OrderPlaced",
|
|
470
|
+
aggregateId: "order-123",
|
|
471
|
+
aggregateType: "Order",
|
|
472
|
+
payload: { total: 100 },
|
|
473
|
+
occurredAt: new Date(),
|
|
474
|
+
processedAt: null,
|
|
475
|
+
retryCount: 0,
|
|
476
|
+
lastError: null,
|
|
477
|
+
};
|
|
478
|
+
assert.ok(validEntry.id);
|
|
479
|
+
assert.ok(validEntry.aggregateId);
|
|
480
|
+
assert.ok(validEntry.payload);
|
|
481
|
+
assert.equal(validEntry.eventType, "OrderPlaced");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("processed outbox entries are no longer picked up by relayer", () => {
|
|
485
|
+
const unprocessed = { processedAt: null };
|
|
486
|
+
const processed = { processedAt: new Date() };
|
|
487
|
+
|
|
488
|
+
const isPending = (entry) => entry.processedAt === null;
|
|
489
|
+
assert.ok(isPending(unprocessed));
|
|
490
|
+
assert.ok(!isPending(processed));
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe("idempotency pattern", () => {
|
|
495
|
+
it("idempotency key must be a valid UUID", () => {
|
|
496
|
+
function isValidUUID(key) {
|
|
497
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(key);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
assert.ok(isValidUUID("550e8400-e29b-41d4-a716-446655440000"));
|
|
501
|
+
assert.ok(!isValidUUID("not-a-uuid"));
|
|
502
|
+
assert.ok(!isValidUUID(""));
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("same idempotency key returns cached response", async () => {
|
|
506
|
+
const cache = new Map();
|
|
507
|
+
|
|
508
|
+
async function handleRequest(key, handler) {
|
|
509
|
+
if (cache.has(key)) return cache.get(key);
|
|
510
|
+
const result = await handler();
|
|
511
|
+
cache.set(key, result);
|
|
512
|
+
return result;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let callCount = 0;
|
|
516
|
+
async function processPayment() {
|
|
517
|
+
callCount++;
|
|
518
|
+
return { status: "success", transactionId: "txn-1" };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const key = "550e8400-e29b-41d4-a716-446655440000";
|
|
522
|
+
const result1 = await handleRequest(key, processPayment);
|
|
523
|
+
const result2 = await handleRequest(key, processPayment);
|
|
524
|
+
|
|
525
|
+
assert.equal(result1.status, "success");
|
|
526
|
+
assert.equal(result2.status, "success");
|
|
527
|
+
assert.equal(callCount, 1); // solo se ejecutó una vez
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("different idempotency keys create separate resources", async () => {
|
|
531
|
+
const store = new Map();
|
|
532
|
+
|
|
533
|
+
async function createResource(key, data) {
|
|
534
|
+
if (store.has(key)) return store.get(key);
|
|
535
|
+
const resource = { id: crypto.randomUUID(), ...data };
|
|
536
|
+
store.set(key, resource);
|
|
537
|
+
return resource;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const r1 = await createResource("key-1", { name: "A" });
|
|
541
|
+
const r2 = await createResource("key-2", { name: "B" });
|
|
542
|
+
|
|
543
|
+
assert.notEqual(r1.id, r2.id);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("idempotency key has TTL and expires", () => {
|
|
547
|
+
const entry = { key: "test", createdAt: new Date() };
|
|
548
|
+
const TTL_MS = 24 * 60 * 60 * 1000; // 24 horas
|
|
549
|
+
|
|
550
|
+
function isExpired(entry) {
|
|
551
|
+
return Date.now() - entry.createdAt.getTime() > TTL_MS;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
assert.ok(!isExpired(entry)); // creado ahora, no ha expirado
|
|
555
|
+
|
|
556
|
+
const oldEntry = { key: "old", createdAt: new Date(Date.now() - TTL_MS - 1) };
|
|
557
|
+
assert.ok(isExpired(oldEntry));
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("only POST, PATCH, PUT need idempotency keys", () => {
|
|
561
|
+
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
562
|
+
const needsKey = (method) => ["POST", "PATCH", "PUT"].includes(method);
|
|
563
|
+
|
|
564
|
+
assert.ok(!needsKey("GET"));
|
|
565
|
+
assert.ok(needsKey("POST"));
|
|
566
|
+
assert.ok(needsKey("PUT"));
|
|
567
|
+
assert.ok(needsKey("PATCH"));
|
|
568
|
+
assert.ok(!needsKey("DELETE"));
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
describe("anti-corruption-layer pattern", () => {
|
|
573
|
+
it("translator maps external DTO to domain entity", () => {
|
|
574
|
+
class CustomerTranslator {
|
|
575
|
+
toDomain(dto) {
|
|
576
|
+
return {
|
|
577
|
+
id: dto.customerId,
|
|
578
|
+
name: `${dto.firstName} ${dto.lastName}`,
|
|
579
|
+
email: dto.emailAddress,
|
|
580
|
+
status: dto.active ? "active" : "inactive",
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const externalDTO = {
|
|
586
|
+
customerId: "ext-123",
|
|
587
|
+
firstName: "John",
|
|
588
|
+
lastName: "Doe",
|
|
589
|
+
emailAddress: "john@example.com",
|
|
590
|
+
active: true,
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const translator = new CustomerTranslator();
|
|
594
|
+
const entity = translator.toDomain(externalDTO);
|
|
595
|
+
|
|
596
|
+
assert.equal(entity.id, "ext-123");
|
|
597
|
+
assert.equal(entity.name, "John Doe");
|
|
598
|
+
assert.equal(entity.email, "john@example.com");
|
|
599
|
+
assert.equal(entity.status, "active");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("translator maps domain entity back to external DTO", () => {
|
|
603
|
+
class CustomerTranslator {
|
|
604
|
+
toExternal(entity) {
|
|
605
|
+
return {
|
|
606
|
+
customerId: entity.id,
|
|
607
|
+
firstName: entity.name.split(" ")[0],
|
|
608
|
+
lastName: entity.name.split(" ").slice(1).join(" "),
|
|
609
|
+
emailAddress: entity.email,
|
|
610
|
+
active: entity.status === "active",
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const entity = {
|
|
616
|
+
id: "dom-456",
|
|
617
|
+
name: "Jane Smith",
|
|
618
|
+
email: "jane@example.com",
|
|
619
|
+
status: "active",
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const translator = new CustomerTranslator();
|
|
623
|
+
const dto = translator.toExternal(entity);
|
|
624
|
+
|
|
625
|
+
assert.equal(dto.customerId, "dom-456");
|
|
626
|
+
assert.equal(dto.firstName, "Jane");
|
|
627
|
+
assert.equal(dto.lastName, "Smith");
|
|
628
|
+
assert.equal(dto.active, true);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("translator handles null/missing values gracefully", () => {
|
|
632
|
+
class CustomerTranslator {
|
|
633
|
+
toDomain(dto) {
|
|
634
|
+
return {
|
|
635
|
+
id: dto.customerId ?? "unknown",
|
|
636
|
+
name: dto.firstName ? `${dto.firstName} ${dto.lastName ?? ""}`.trim() : "Unknown",
|
|
637
|
+
email: dto.emailAddress ?? "",
|
|
638
|
+
status: dto.active === true ? "active" : "inactive",
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const emptyDTO = { customerId: null, firstName: null, lastName: null, emailAddress: null, active: null };
|
|
644
|
+
const translator = new CustomerTranslator();
|
|
645
|
+
const entity = translator.toDomain(emptyDTO);
|
|
646
|
+
|
|
647
|
+
assert.equal(entity.id, "unknown");
|
|
648
|
+
assert.equal(entity.name, "Unknown");
|
|
649
|
+
assert.equal(entity.status, "inactive");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("gateway enforces timeout and returns null on 404", () => {
|
|
653
|
+
async function fetchCustomer(id) {
|
|
654
|
+
const response = { status: 404, ok: false, statusText: "Not Found" };
|
|
655
|
+
if (response.status === 404) return null;
|
|
656
|
+
if (!response.ok) throw new Error(response.statusText);
|
|
657
|
+
return { id };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
assert.doesNotReject(async () => {
|
|
661
|
+
const result = await fetchCustomer("nonexistent");
|
|
662
|
+
assert.equal(result, null);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("ACL delegates to translator and gateway in correct order", async () => {
|
|
667
|
+
let order = [];
|
|
668
|
+
|
|
669
|
+
const gateway = {
|
|
670
|
+
async fetch(id) { order.push("gateway"); return { customerId: id, firstName: "A", lastName: "B", emailAddress: "a@b.com", active: true }; },
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const translator = {
|
|
674
|
+
toDomain(dto) { order.push("translator"); return { id: dto.customerId, name: `${dto.firstName} ${dto.lastName}`, email: dto.emailAddress, status: "active" }; },
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
async function findById(id) {
|
|
678
|
+
const dto = await gateway.fetch(id);
|
|
679
|
+
return dto ? translator.toDomain(dto) : null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const result = await findById("123");
|
|
683
|
+
assert.deepEqual(order, ["gateway", "translator"]);
|
|
684
|
+
assert.equal(result.id, "123");
|
|
685
|
+
assert.equal(result.name, "A B");
|
|
686
|
+
});
|
|
687
|
+
});
|