@ronaldjdevfs/forge 1.3.0-beta → 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 +1 -1
- 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 +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 +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/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/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 +288 -4
- package/src/cli.js +26 -13
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# Evolutionary Architecture
|
|
2
|
+
|
|
3
|
+
La arquitectura no es un diseño inicial que se congela. Es una estructura que evoluciona con el conocimiento del dominio, el tamaño del equipo y las restricciones del negocio. Forge está diseñado para guiar esa evolución sin reescrituras.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Definición
|
|
8
|
+
|
|
9
|
+
Una **arquitectura evolutiva** es aquella en la que los cambios significativos pueden realizarse de forma incremental, guiados por **fitness functions** que verifican que las propiedades arquitectónicas se mantienen.
|
|
10
|
+
|
|
11
|
+
**Propiedades de una arquitectura evolutiva:**
|
|
12
|
+
- **Incremental**: los cambios grandes se dividen en pasos pequeños y reversibles
|
|
13
|
+
- **Guiada por métricas**: se sabe si un cambio mejora o degrada la arquitectura
|
|
14
|
+
- **Fitness functions automatizadas**: las propiedades se verifican en CI
|
|
15
|
+
- **Sin big-bang rewrites**: nunca se reescribe desde cero
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Fitness Functions
|
|
20
|
+
|
|
21
|
+
Una **fitness function** es un test automatizado que valida una característica arquitectónica. Forge ya implementa varias:
|
|
22
|
+
|
|
23
|
+
### Built-in (R1-R9)
|
|
24
|
+
|
|
25
|
+
Las 9 reglas de Forge son fitness functions:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// scripts/registry/rules.mjs (conceptual)
|
|
29
|
+
const rules = {
|
|
30
|
+
R1: {
|
|
31
|
+
name: "feature → infra prohibited",
|
|
32
|
+
severity: "CRITICAL",
|
|
33
|
+
check: (edge) =>
|
|
34
|
+
edge.from.type === "feature" && edge.to.type === "infra",
|
|
35
|
+
},
|
|
36
|
+
R8: {
|
|
37
|
+
name: "cross-feature direct import prohibited",
|
|
38
|
+
severity: "ERROR",
|
|
39
|
+
check: (edge) =>
|
|
40
|
+
edge.from.type === "feature" && edge.to.type === "feature",
|
|
41
|
+
},
|
|
42
|
+
// R2-R7, R9 con estructura similar
|
|
43
|
+
};
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Estas fitness functions se ejecutan en:
|
|
47
|
+
- `node scripts/detect.mjs` — detección local
|
|
48
|
+
- `forge quench` — validación completa
|
|
49
|
+
- PostToolUse hook — después de cada escritura del agente
|
|
50
|
+
|
|
51
|
+
### Custom Fitness Functions
|
|
52
|
+
|
|
53
|
+
Los usuarios pueden registrar sus propias funciones:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// scripts/registry/rules.mjs
|
|
57
|
+
import { registerRule } from "./registry/rules.mjs";
|
|
58
|
+
|
|
59
|
+
registerRule({
|
|
60
|
+
id: "CUSTOM_01",
|
|
61
|
+
name: "no console.log in use-cases",
|
|
62
|
+
severity: "WARNING",
|
|
63
|
+
check: ({ filePath, content }) =>
|
|
64
|
+
filePath.includes("application/use-cases") && content.includes("console.log"),
|
|
65
|
+
description: "Los casos de uso no deben tener console.log. Usar logger inyectado.",
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Tipos de Fitness Functions
|
|
70
|
+
|
|
71
|
+
| Tipo | Ejecución | Ejemplo |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| **Estática** | Lint/build | `detect.mjs` analiza imports |
|
|
74
|
+
| **Dinámica** | Runtime | Verificar que event bus no pierde eventos |
|
|
75
|
+
| **Benchmark** | CI periódico | Tiempo de respuesta de queries < 200ms |
|
|
76
|
+
| **Trigger-based** | Evento (PR, deploy) | No hay imports directos entre features |
|
|
77
|
+
| **Contrato** | CI multi-servicio | Las APIs son compatibles con versiones anteriores |
|
|
78
|
+
|
|
79
|
+
### Integración en CI
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
# .github/workflows/forge-fitness.yml
|
|
83
|
+
jobs:
|
|
84
|
+
forge-quench:
|
|
85
|
+
runs-on: ubuntu-latest
|
|
86
|
+
steps:
|
|
87
|
+
- uses: actions/checkout@v4
|
|
88
|
+
- run: node .opencode/skills/forge/scripts/detect.mjs
|
|
89
|
+
env:
|
|
90
|
+
FORGE_STRICT: "true"
|
|
91
|
+
- run: node .opencode/skills/forge/scripts/chain.mjs --json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Guided Change
|
|
97
|
+
|
|
98
|
+
El flujo de cambio guiado de Forge asegura que cada modificación arquitectónica es segura:
|
|
99
|
+
|
|
100
|
+
### 1. Estado actual (before)
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
node scripts/inspect.mjs --json
|
|
104
|
+
# Score: 85
|
|
105
|
+
# Violaciones: 2 WARNING (R9 borderline, naming)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 2. Proponer cambio
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
forge reforge --move features/old-payments to features/payments/v2
|
|
112
|
+
# Generate plan: split feature, add ACL, migrate use-cases
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### 3. Verificar antes de aplicar
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
forge quench --diff
|
|
119
|
+
# Simula el cambio y reporta impacto en score
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 4. Ejecutar con rollback
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
forge reforge --apply
|
|
126
|
+
# Guarda backup en .forge/backup/
|
|
127
|
+
# Si falla: forge rollback --last
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 5. Estado actual (after)
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
node scripts/inspect.mjs --json
|
|
134
|
+
# Score: 92 (+7)
|
|
135
|
+
# Violaciones: 0
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Si el score baja, el cambio se rechaza automáticamente (configurable con `FORGE_ALLOW_DEGRADE=true`).
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Evolución Típica de un Proyecto
|
|
143
|
+
|
|
144
|
+
### Fase 1: Prototipo / MVP
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
10 features, 1 equipo, monolith
|
|
148
|
+
Reglas: todas activas
|
|
149
|
+
Foco: velocidad de entrega
|
|
150
|
+
Tolerancia: alta para violaciones temporales
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
forge quench --allow-warnings
|
|
155
|
+
# Reporta violaciones pero no bloquea
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Fase 2: Crecimiento
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
20 features, 2 equipos, monolith modular
|
|
162
|
+
Reglas: estrictas (R1-R9 bloquean)
|
|
163
|
+
Foco: boundaries
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
forge quench --strict
|
|
168
|
+
# Las violaciones ERROR bloquean el PR
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Fase 3: Escalamiento
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
40 features, 3+ equipos, modular → microservicios
|
|
175
|
+
Reglas: R1-R9 + custom (CQRS, outbox, SLA)
|
|
176
|
+
Foco: autonomía de equipos
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
forge inspect --extended
|
|
181
|
+
# Incluye fitness functions custom
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Fase 4: Madurez
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
N features, equipos autónomos, servicios independientes
|
|
188
|
+
Reglas: fitness functions distribuidas
|
|
189
|
+
Foco: evolución continua sin regresión
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Smallest Viable Change
|
|
195
|
+
|
|
196
|
+
Cada cambio arquitectónico debe ser el **cambio más pequeño que mejora la arquitectura sin romper funcionalidad**.
|
|
197
|
+
|
|
198
|
+
| Cambio grande (evitar) | Cambio pequeño (preferir) |
|
|
199
|
+
|---|---|
|
|
200
|
+
| Extraer 3 features como microservicios a la vez | Extraer 1 feature, verificar, repetir |
|
|
201
|
+
| Reescribir todo el ORM | Migrar un repositorio por PR |
|
|
202
|
+
| Refactorizar "toda la capa de aplicación" | Refactorizar un caso de uso, probar, continuar |
|
|
203
|
+
| Cambiar de framework | Aislar framework tras interfaces primero, luego reemplazar |
|
|
204
|
+
|
|
205
|
+
### Patrón: Scaffolding antes de Feature
|
|
206
|
+
|
|
207
|
+
Antes de implementar una feature completa, crear la estructura del feature y verificar que no viola reglas:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
# Fase 1: scaffold
|
|
211
|
+
mkdir -p src/features/payments/{domain,application/use-cases,adapters/in/http,adapters/out/persistence}
|
|
212
|
+
touch src/features/payments/domain/PaymentEntity.ts
|
|
213
|
+
touch src/features/payments/domain/IPaymentRepository.ts
|
|
214
|
+
|
|
215
|
+
# Fase 2: verify
|
|
216
|
+
forge quench
|
|
217
|
+
# Score: 100 (aún sin implementar, pero los boundaries son correctos)
|
|
218
|
+
|
|
219
|
+
# Fase 3: implementar use-case
|
|
220
|
+
touch src/features/payments/application/use-cases/ProcessPaymentUseCase.ts
|
|
221
|
+
|
|
222
|
+
# Fase 4: verify again
|
|
223
|
+
forge quench
|
|
224
|
+
# Score: 100 (el use-case solo importa de domain y shared)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Evolución de Boundaries
|
|
230
|
+
|
|
231
|
+
### Partir un feature
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
# Antes: features/catalog (15k líneas, toca todo)
|
|
235
|
+
# Después: features/catalog (core) + features/search (búsqueda)
|
|
236
|
+
|
|
237
|
+
forge cast search --from catalog
|
|
238
|
+
# 1. Crea features/search con estructura completa
|
|
239
|
+
# 2. Mueve SearchService y SearchIndexer de catalog a search
|
|
240
|
+
# 3. Crea contratos en shared/contracts/catalog/ para que search consuma datos
|
|
241
|
+
# 4. Verifica que no quedan imports de catalog → search ni viceversa
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Fusionar features
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
# Antes: features/standard-checkout + features/express-checkout
|
|
248
|
+
# (80% del código duplicado entre ambos)
|
|
249
|
+
|
|
250
|
+
forge reforge --merge features/express-checkout into features/checkout
|
|
251
|
+
# 1. Mueve el código único de express a checkout
|
|
252
|
+
# 2. Parametriza el checkout con "mode: standard | express"
|
|
253
|
+
# 3. Elimina features/express-checkout
|
|
254
|
+
# 4. Verifica que nada importaba de express-checkout
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Mover un shared kernel a package
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
# Antes: src/shared/contracts/ (referencia local)
|
|
261
|
+
# Después: @company/contracts (npm package)
|
|
262
|
+
|
|
263
|
+
forge reforge --publish shared/contracts as @company/contracts
|
|
264
|
+
# 1. Extrae contracts/ a packages/contracts/
|
|
265
|
+
# 2. Configura build con tsc
|
|
266
|
+
# 3. Actualiza imports en todas las features
|
|
267
|
+
# 4. Verifica con detect.mjs que los nuevos imports son válidos
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Anti-patrones
|
|
273
|
+
|
|
274
|
+
| Anti-patrón | Problema | Solución |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| **Big-Bang Rewrite** | Se reescribe todo. El sistema legacy se congela. El rewrite nunca alcanza el feature parity. | Strangler Fig + ACL. Migrar feature por feature. |
|
|
277
|
+
| **Frozen Architecture** | "No podemos cambiar la estructura ahora". La arquitectura se vuelve un impedimento. | Fitness functions + guided change. El cambio seguro está soportado por diseño. |
|
|
278
|
+
| **Analysis Paralysis** | Demasiado tiempo diseñando, poco tiempo implementando. | Smallest viable change. El diseño emerge, no se predice. |
|
|
279
|
+
| **Tech Debt sin métrica** | Se acumula deuda sin saber cuánta ni dónde. | `forge inspect` da score numérico. La deuda se mide, no se estima. |
|
|
280
|
+
| **Rewrite por moda** | "Pasamos a microservicios porque es moderno". | `reference/modular-monolith.md` — evaluar antes de partir. |
|
|
281
|
+
| **Golden Hammer** | Forzar CQRS, Event Sourcing o Hexagonal en features que no lo necesitan. | Cada referencia tiene "cuándo usarlo". Si no aplica, no lo fuerces. |
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Conexión con Forge
|
|
286
|
+
|
|
287
|
+
| Comando | Acción |
|
|
288
|
+
|---|---|
|
|
289
|
+
| `forge inspect` | Score + violaciones + fitness functions |
|
|
290
|
+
| `forge quench` | Ejecuta fitness functions en el código actual |
|
|
291
|
+
| `forge reforge` | Cambio arquitectónico guiado con verificación |
|
|
292
|
+
| `forge relocate` | Migración incremental con rollback |
|
|
293
|
+
| `forge chain` | Verifica que el grafo de dependencias evoluciona saludablemente |
|
|
294
|
+
| `forge graph` | Visualiza la evolución del grafo arquitectónico |
|
|
295
|
+
|
|
296
|
+
## Ver también
|
|
297
|
+
|
|
298
|
+
- `reference/adr.md` — ADRs como registro de cambios evolutivos
|
|
299
|
+
- `reference/principles.md` — principios que las fitness functions protegen
|
|
300
|
+
- `reference/modular-monolith.md` — evolución de monolith a microservicios
|
|
@@ -42,3 +42,10 @@ Inicializa un proyecto para trabajar con Forge como Backend Architecture Operati
|
|
|
42
42
|
| Platform layer ausente | SUGGESTION |
|
|
43
43
|
| Dependencias faltantes | WARNING |
|
|
44
44
|
| Ownership con huérfanos | WARNING |
|
|
45
|
+
|
|
46
|
+
## Ver también
|
|
47
|
+
|
|
48
|
+
- `reference/bounded-contexts.md` — identificación de contexts al inicializar
|
|
49
|
+
- `reference/modular-monolith.md` — decisión de estructura al iniciar proyecto
|
|
50
|
+
- `reference/principles.md` — principios que guían la inicialización
|
|
51
|
+
- `reference/evolutionary-architecture.md` — bootstrap como primer paso evolutivo
|
|
@@ -60,3 +60,9 @@ Las reglas ignoradas se almacenan en `.forge/hooks-ignore.json`:
|
|
|
60
60
|
|
|
61
61
|
Cuando una regla está ignorada, el hook no bloquea el commit por
|
|
62
62
|
violaciones de esa regla, aunque el detector sigue reportándolas.
|
|
63
|
+
|
|
64
|
+
## Ver también
|
|
65
|
+
|
|
66
|
+
- `reference/quench.md` — validación que el hook ejecuta en pre-commit
|
|
67
|
+
- `reference/evolutionary-architecture.md` — fitness functions como hook
|
|
68
|
+
- `reference/adr.md` — ADRs como insumo para validación en hook
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Idempotency — Operaciones Seguras para Retry
|
|
2
|
+
|
|
3
|
+
En sistemas distribuidos, las operaciones fallan. Los clientes reintentan. Sin idempotencia, un reintento puede crear facturas duplicadas, cargar dos veces la misma tarjeta o enviar un email dos veces. La idempotencia garantiza que N intentos producen el mismo resultado que uno.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Definición
|
|
8
|
+
|
|
9
|
+
Una operación es **idempotente** si ejecutarla una o varias veces produce el mismo efecto secundario.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
GET /orders/123 → idempotente (natural, no cambia estado)
|
|
13
|
+
PUT /orders/123 → idempotente (el mismo body produce el mismo estado final)
|
|
14
|
+
DELETE /orders/123 → idempotente (borrar algo ya borrado no cambia nada)
|
|
15
|
+
POST /orders → NO es idempotente (crea un recurso nuevo cada vez)
|
|
16
|
+
PATCH /orders/123 → NO es idempotente por defecto (depende del delta)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Para hacer POST y PATCH idempotentes se necesita un **idempotency key**.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Idempotency Keys en APIs
|
|
24
|
+
|
|
25
|
+
El cliente envía una clave única en el header `Idempotency-Key`. El servidor:
|
|
26
|
+
1. Si la clave es nueva: ejecuta la operación, guarda la respuesta, la retorna
|
|
27
|
+
2. Si la clave ya existe: retorna la respuesta guardada sin ejecutar la operación
|
|
28
|
+
|
|
29
|
+
### Implementación
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// Platform middleware
|
|
33
|
+
// platform/http/middleware/IdempotencyMiddleware.ts
|
|
34
|
+
export class IdempotencyMiddleware {
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly idempotencyRepo: IIdempotencyRepository,
|
|
37
|
+
private readonly clock: IClock,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
41
|
+
// Solo para métodos mutantes
|
|
42
|
+
if (!["POST", "PATCH", "PUT"].includes(req.method)) return next();
|
|
43
|
+
|
|
44
|
+
const key = req.headers["idempotency-key"] as string;
|
|
45
|
+
if (!key) {
|
|
46
|
+
// POST sin key es válido pero no tiene protección de idempotencia
|
|
47
|
+
// Para operaciones críticas (pagos), debería ser obligatorio
|
|
48
|
+
return next();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Validar formato UUID
|
|
52
|
+
if (!this.isValidUUID(key)) {
|
|
53
|
+
return res.status(400).json({
|
|
54
|
+
error: "Invalid idempotency key format. Must be a UUID v4.",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const existing = await this.idempotencyRepo.find(key);
|
|
59
|
+
if (existing) {
|
|
60
|
+
// Clave ya usada → devolver respuesta en caché
|
|
61
|
+
// Si la clave expiró, rechazar con 422
|
|
62
|
+
if (existing.isExpired(this.clock)) {
|
|
63
|
+
return res.status(422).json({
|
|
64
|
+
error: "Idempotency key expired. Use a new key.",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return res.status(existing.statusCode).json(existing.responseBody);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Registrar clave antes de ejecutar (protege contra duplicados simultáneos)
|
|
71
|
+
await this.idempotencyRepo.save(IdempotencyEntry.pending(key, req));
|
|
72
|
+
|
|
73
|
+
// Ejecutar y guardar respuesta
|
|
74
|
+
res.on("finish", async () => {
|
|
75
|
+
await this.idempotencyRepo.save(
|
|
76
|
+
IdempotencyEntry.completed(key, res.statusCode, res.body),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
next();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Repositorio de idempotencia
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
// infra/redis/IdempotencyRepository.ts
|
|
89
|
+
export class RedisIdempotencyRepository implements IIdempotencyRepository {
|
|
90
|
+
private readonly TTL_SECONDS = 60 * 60 * 24; // 24 horas
|
|
91
|
+
|
|
92
|
+
constructor(private readonly redis: Redis) {}
|
|
93
|
+
|
|
94
|
+
async find(key: string): Promise<IdempotencyEntry | null> {
|
|
95
|
+
const data = await this.redis.get(`idempotency:${key}`);
|
|
96
|
+
if (!data) return null;
|
|
97
|
+
return IdempotencyEntry.fromJSON(data);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async save(entry: IdempotencyEntry): Promise<void> {
|
|
101
|
+
await this.redis.set(
|
|
102
|
+
`idempotency:${entry.key}`,
|
|
103
|
+
entry.toJSON(),
|
|
104
|
+
"EX",
|
|
105
|
+
this.TTL_SECONDS,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Contract en shared/
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// shared/contracts/http/IIdempotencyRepository.ts
|
|
115
|
+
export interface IIdempotencyRepository {
|
|
116
|
+
find(key: string): Promise<IdempotencyEntry | null>;
|
|
117
|
+
save(entry: IdempotencyEntry): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Reglas del Idempotency Key
|
|
122
|
+
|
|
123
|
+
1. **Generado por el cliente** (nunca por el servidor): el cliente necesita poder reintentar sin haber recibido respuesta
|
|
124
|
+
2. **Formato UUID v4**: único, no secuencial, sin colisiones
|
|
125
|
+
3. **TTL**: 24 horas típicamente. Si el cliente reintenta después del TTL, la clave expiró y debe generar una nueva
|
|
126
|
+
4. **Único por recurso/operación**: un mismo key no debe reusarse para operaciones distintas
|
|
127
|
+
5. **Protección de concurrencia**: dos requests simultáneos con el mismo key deben producir exactamente una ejecución (lock optimista en Redis)
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Idempotencia en Eventos
|
|
132
|
+
|
|
133
|
+
Los consumidores de eventos deben deduplicar por event ID para garantizar exactly-once processing.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// base consumer pattern
|
|
137
|
+
export abstract class IdempotentEventConsumer<T extends DomainEvent> {
|
|
138
|
+
constructor(
|
|
139
|
+
protected readonly processedEvents: IProcessedEventRepository,
|
|
140
|
+
) {}
|
|
141
|
+
|
|
142
|
+
async handle(event: T): Promise<void> {
|
|
143
|
+
// 1. Deduplicación
|
|
144
|
+
const alreadyProcessed = await this.processedEvents.exists(event.eventId);
|
|
145
|
+
if (alreadyProcessed) return;
|
|
146
|
+
|
|
147
|
+
// 2. Ejecutar lógica de negocio
|
|
148
|
+
await this.processEvent(event);
|
|
149
|
+
|
|
150
|
+
// 3. Marcar como procesado (idealmente en la misma transacción)
|
|
151
|
+
await this.processedEvents.markProcessed(event.eventId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
protected abstract processEvent(event: T): Promise<void>;
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// Implementación concreta
|
|
160
|
+
export class OrderPlacedHandler extends IdempotentEventConsumer<OrderPlacedEvent> {
|
|
161
|
+
constructor(
|
|
162
|
+
processedEvents: IProcessedEventRepository,
|
|
163
|
+
private readonly inventoryService: IInventoryService,
|
|
164
|
+
) {
|
|
165
|
+
super(processedEvents);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
protected async processEvent(event: OrderPlacedEvent): Promise<void> {
|
|
169
|
+
await this.inventoryService.reserveStock(event.orderId, event.items);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Tabla de eventos procesados
|
|
175
|
+
|
|
176
|
+
```sql
|
|
177
|
+
CREATE TABLE IF NOT EXISTS public.processed_events (
|
|
178
|
+
event_id VARCHAR(255) PRIMARY KEY,
|
|
179
|
+
consumer_name VARCHAR(255) NOT NULL,
|
|
180
|
+
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
181
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
-- Cleanup periódico: eventos procesados > 7 días
|
|
185
|
+
CREATE INDEX idx_processed_events_cleanup ON public.processed_events (processed_at);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Idempotencia Natural por Operación
|
|
191
|
+
|
|
192
|
+
| Operación HTTP | Idempotencia natural | Cómo hacerla idempotente |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| **GET** | ✅ Sí | Nada. Es idempotente por definición. |
|
|
195
|
+
| **PUT** | ✅ Sí | Mismo body → mismo estado. |
|
|
196
|
+
| **DELETE** | ✅ Sí | Borrar algo ya borrado no cambia estado. |
|
|
197
|
+
| **POST** | ❌ No | Idempotency-Key header. |
|
|
198
|
+
| **PATCH** | ❌ No | Idempotency-Key header + operaciones basadas en estado (ej. "set status = X" es idempotente, "increment count" no lo es). |
|
|
199
|
+
|
|
200
|
+
### Operaciones condicionalmente idempotentes
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// ❌ NO idempotente: el resultado cambia cada vez
|
|
204
|
+
PATCH /orders/123 { "action": "addItem", "productId": "abc" }
|
|
205
|
+
|
|
206
|
+
// ✅ Idempotente: el resultado es el mismo estado final
|
|
207
|
+
PUT /orders/123 { "items": ["abc", "def"] }
|
|
208
|
+
|
|
209
|
+
// ✅ Condicionalmente idempotente con idempotency key
|
|
210
|
+
POST /orders/123/items
|
|
211
|
+
Idempotency-Key: uuid-abc-123
|
|
212
|
+
Body: { "productId": "abc", "quantity": 1 }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Implementación en el Modelo de Forge
|
|
218
|
+
|
|
219
|
+
### Estructura
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
src/platform/
|
|
223
|
+
http/
|
|
224
|
+
middleware/
|
|
225
|
+
IdempotencyMiddleware.ts ← middleware que intercepta requests
|
|
226
|
+
contracts/
|
|
227
|
+
IIdempotencyRepository.ts ← interfaz en shared/
|
|
228
|
+
|
|
229
|
+
src/infra/
|
|
230
|
+
redis/
|
|
231
|
+
IdempotencyRepository.ts ← implementación con Redis
|
|
232
|
+
|
|
233
|
+
src/features/<name>/
|
|
234
|
+
adapters/
|
|
235
|
+
in/
|
|
236
|
+
events/
|
|
237
|
+
<Name>Handler.ts ← consumer con deduplicación
|
|
238
|
+
out/
|
|
239
|
+
persistence/
|
|
240
|
+
ProcessedEventRepository.ts ← tabla processed_events
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Reglas de ubicación
|
|
244
|
+
|
|
245
|
+
| Componente | Capa | Razón |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| Middleware HTTP | platform/ | Es infraestructura transversal |
|
|
248
|
+
| Interfaz IIdempotencyRepository | shared/contracts/ | Contrato puro sin implementación |
|
|
249
|
+
| Implementación Redis | infra/ | Implementación concreta |
|
|
250
|
+
| IdempotencyKey generación | Cliente (frontend/CLI) | El cliente necesita la clave antes del request |
|
|
251
|
+
| Deduplicación en consumidores | feature/adapters/in/events/ | Específico del feature |
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Anti-patrones
|
|
256
|
+
|
|
257
|
+
| Anti-patrón | Problema | Solución |
|
|
258
|
+
|---|---|---|
|
|
259
|
+
| **Idempotency sin expiración** | Las claves se acumulan infinitamente. | TTL obligatorio (24h típico). Configurable por operación. |
|
|
260
|
+
| **Claves generadas por el servidor** | El servidor genera la key y la devuelve. Si la respuesta se pierde, el cliente no puede reintentar. | El cliente genera la key (UUID v4). El servidor solo la valida. |
|
|
261
|
+
| **Idempotencia en GET** | GET debe ser idempotente por definición. Si no lo es, el problema es otro (efectos secundarios en GET). | No añadir idempotency keys a GET. Mover efectos secundarios a POST/PUT. |
|
|
262
|
+
| **Mutex como idempotencia** | Bloquear el recurso en vez de deduplicar la operación. | Idempotencia no es locking. La clave permite deduplicar sin bloquear. |
|
|
263
|
+
| **Idempotency key corta** | Claves predecibles (incrementales, timestamp). Un cliente puede adivinar claves de otros. | UUID v4 obligatorio. No secuencial. No predecible. |
|
|
264
|
+
| **No verificar método** | El middleware aplica a GET también, añadiendo latencia innecesaria. | Solo POST, PATCH, PUT. GET pasa sin verificar. |
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Conexión con Forge
|
|
269
|
+
|
|
270
|
+
| Comando | Acción |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `forge cast` | Incluir idempotency middleware + repo Redis en features críticas |
|
|
273
|
+
| `forge quench` | Regla detect: feature con POST sin idempotency key |
|
|
274
|
+
| `forge inspect` | Reporta endpoints sin protección de idempotencia |
|
|
275
|
+
| `forge api` | Verifica que los contratos OpenAPI incluyen idempotency-key header |
|
|
276
|
+
| `forge temper` | Endurece DI del middleware de idempotencia |
|
|
277
|
+
|
|
278
|
+
## Ver también
|
|
279
|
+
|
|
280
|
+
- `reference/transactional-outbox.md` — deduplicación en relayer
|
|
281
|
+
- `reference/api-design.md` — idempotency-key header en APIs
|
|
282
|
+
- `reference/events.md` — handlers idempotentes
|
|
283
|
+
- `reference/sagas.md` — retry seguro en sagas
|
|
@@ -127,3 +127,8 @@ Genera y mantiene el archivo `ARCHITECTURE.md` del proyecto.
|
|
|
127
127
|
- Se actualiza automáticamente después de cada comando
|
|
128
128
|
- No editar manualmente los campos auto-detectados
|
|
129
129
|
- Forge preserva cualquier sección adicional que el usuario agregue
|
|
130
|
+
|
|
131
|
+
## Ver también
|
|
132
|
+
|
|
133
|
+
- `reference/adr.md` — Architecture Decision Records como insumo para ARCHITECTURE.md
|
|
134
|
+
- `reference/principles.md` — principios arquitectónicos documentados en ARCHITECTURE.md
|
|
@@ -55,4 +55,10 @@ Inspecciona la conformidad arquitectónica del proyecto.
|
|
|
55
55
|
node .opencode/skills/forge/scripts/inspect.mjs
|
|
56
56
|
node .opencode/skills/forge/scripts/inspect.mjs --json
|
|
57
57
|
node .opencode/skills/forge/scripts/detect.mjs --severity ERROR
|
|
58
|
+
|
|
59
|
+
## Ver también
|
|
60
|
+
|
|
61
|
+
- `reference/evolutionary-architecture.md` — fitness functions como complemento al inspect
|
|
62
|
+
- `reference/assay.md` — interpretación cualitativa multi-persona del reporte de inspect
|
|
63
|
+
- `reference/principles.md` — principios contra los que inspect evalúa el proyecto
|
|
58
64
|
```
|