arckode-framework 1.0.0
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 +546 -0
- package/adapters/__tests__/mysql.test.ts +283 -0
- package/adapters/jwt.ts +18 -0
- package/adapters/mysql.ts +98 -0
- package/adapters/postgres.ts +52 -0
- package/adapters/redis-cache.ts +64 -0
- package/adapters/sqlite.ts +73 -0
- package/adapters/vendor.d.ts +48 -0
- package/bin/arckode.js +7 -0
- package/cli/analyze.ts +506 -0
- package/cli/commands/db-migrate.ts +121 -0
- package/cli/commands/db-seed.ts +54 -0
- package/cli/commands/generate-api-client.ts +106 -0
- package/cli/commands/make-adapter.ts +132 -0
- package/cli/commands/make-auth.ts +297 -0
- package/cli/commands/make-frontend-module.ts +271 -0
- package/cli/commands/make-helper.ts +65 -0
- package/cli/commands/make-migration.ts +30 -0
- package/cli/commands/make-seed.ts +29 -0
- package/cli/generate.ts +132 -0
- package/cli/index.ts +604 -0
- package/cli/stubs/frontend-stub.ts +294 -0
- package/cli/stubs/fullstack-stub.ts +46 -0
- package/cli/stubs/module-stub.ts +469 -0
- package/kernel/__tests__/adapters.test.ts +101 -0
- package/kernel/__tests__/analyzer.test.ts +282 -0
- package/kernel/__tests__/framework.test.ts +617 -0
- package/kernel/__tests__/middlewares.test.ts +174 -0
- package/kernel/__tests__/static.test.ts +94 -0
- package/kernel/framework.ts +1851 -0
- package/kernel/middlewares.ts +179 -0
- package/kernel/static.ts +76 -0
- package/kernel/testing.ts +237 -0
- package/modules/events/index.ts +99 -0
- package/modules/mail/index.ts +51 -0
- package/modules/mail/smtp-adapter.ts +42 -0
- package/modules/queue/index.ts +78 -0
- package/modules/storage/index.ts +40 -0
- package/modules/storage/local-adapter.ts +41 -0
- package/modules/ws/__tests__/ws.test.ts +114 -0
- package/modules/ws/index.ts +136 -0
- package/package.json +99 -0
- package/skills/auth/SKILL.md +243 -0
- package/skills/cli/SKILL.md +258 -0
- package/skills/config/SKILL.md +253 -0
- package/skills/connectors/SKILL.md +259 -0
- package/skills/helpers/SKILL.md +206 -0
- package/skills/middlewares/SKILL.md +282 -0
- package/skills/orm/SKILL.md +260 -0
- package/skills/realtime/SKILL.md +307 -0
- package/skills/services/SKILL.md +206 -0
- package/skills/testing/SKILL.md +257 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# SKILL: Arckode ORM — Modelos, Queries y Migraciones
|
|
2
|
+
|
|
3
|
+
> Activar cuando: definir un modelo, escribir queries, crear migración, agregar campo, cambiar schema.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 0. NAMING FIJO (no negociable)
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Métodos de service: list() | getById() | create() | update() | delete()
|
|
11
|
+
Controller actions: index | show | store | update | destroy
|
|
12
|
+
Tablas DB: snake_case plural → pedidos, lineas_pedido
|
|
13
|
+
Campos del modelo: camelCase → usuarioId, fechaEntrega
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. DEFINIR UN MODELO
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// En types.ts del módulo
|
|
22
|
+
import type { ModelDefinition } from 'arckode-framework'
|
|
23
|
+
|
|
24
|
+
export const PedidoModel: ModelDefinition = {
|
|
25
|
+
table: 'pedidos',
|
|
26
|
+
fields: {
|
|
27
|
+
// Tipos: 'string' | 'number' | 'boolean' | 'date' | 'text' | 'json'
|
|
28
|
+
usuarioId: { type: 'string', required: true },
|
|
29
|
+
productoId: { type: 'string', required: true },
|
|
30
|
+
cantidad: { type: 'number', required: true },
|
|
31
|
+
total: { type: 'number', required: true },
|
|
32
|
+
estado: { type: 'string', default: 'pendiente' },
|
|
33
|
+
notas: { type: 'text', required: false },
|
|
34
|
+
metadata: { type: 'json', required: false },
|
|
35
|
+
},
|
|
36
|
+
timestamps: true, // agrega createdAt, updatedAt (RECOMENDADO)
|
|
37
|
+
softDelete: false, // true → agrega deletedAt, delete() solo marca, no borra
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Reglas de naming:**
|
|
42
|
+
- Tabla: snake_case plural (`pedidos`, `lineas_pedido`)
|
|
43
|
+
- Campos: camelCase (`usuarioId`, `fechaEntrega`)
|
|
44
|
+
- Solo alfanuméricos y guión bajo — `assertSafeIdentifier()` valida esto en ORM
|
|
45
|
+
- No usar palabras reservadas SQL: `order`, `table`, `select`, `where`
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 2. REGISTRAR EL MODELO (composition-root.ts)
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { PedidoModel } from './modules/pedidos/types'
|
|
53
|
+
|
|
54
|
+
// ANTES de llamar system.start()
|
|
55
|
+
orm.define('Pedido', PedidoModel)
|
|
56
|
+
await orm.migrate() // CREATE TABLE IF NOT EXISTS + drift detection
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Orden importa:** `orm.define()` → `orm.migrate()` → `system.start()`
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 3. REPOSITORYADAPTER<T> (la interfaz correcta)
|
|
64
|
+
|
|
65
|
+
El service recibe `RepositoryAdapter<T>`, nunca `ORM` directo:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import type { RepositoryAdapter } from 'arckode-framework'
|
|
69
|
+
import { OrmRepository } from 'arckode-framework'
|
|
70
|
+
|
|
71
|
+
// En el módulo (index.ts):
|
|
72
|
+
const repo = new OrmRepository<PedidoDTO>(orm, 'Pedido')
|
|
73
|
+
const service = new PedidosService(repo)
|
|
74
|
+
|
|
75
|
+
// En el service:
|
|
76
|
+
class PedidosService {
|
|
77
|
+
constructor(private repo: RepositoryAdapter<PedidoDTO>) {}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 4. QUERIES DISPONIBLES
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// Buscar muchos con filtros
|
|
87
|
+
await repo.findMany(
|
|
88
|
+
{ estado: 'pendiente', usuarioId: userId }, // filtros (AND implícito)
|
|
89
|
+
{ limit: 20, offset: 0, orderBy: 'createdAt', orderDir: 'desc' }
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// Buscar por ID
|
|
93
|
+
const pedido = await repo.findById(id) // null si no existe
|
|
94
|
+
|
|
95
|
+
// Buscar uno con filtro
|
|
96
|
+
const pedido = await repo.findOne({ usuarioId, estado: 'activo' })
|
|
97
|
+
|
|
98
|
+
// Crear
|
|
99
|
+
const nuevo = await repo.create({
|
|
100
|
+
usuarioId: 'u123',
|
|
101
|
+
productoId: 'p456',
|
|
102
|
+
cantidad: 2,
|
|
103
|
+
total: 200,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Actualizar (solo los campos pasados)
|
|
107
|
+
const actualizado = await repo.update(id, { estado: 'confirmado' })
|
|
108
|
+
|
|
109
|
+
// Eliminar
|
|
110
|
+
const ok = await repo.delete(id)
|
|
111
|
+
|
|
112
|
+
// Contar
|
|
113
|
+
const total = await repo.count({ estado: 'pendiente' })
|
|
114
|
+
|
|
115
|
+
// Paginación
|
|
116
|
+
const resultado = await repo.paginate(
|
|
117
|
+
{ estado: 'pendiente' },
|
|
118
|
+
{ page: 1, limit: 10 }
|
|
119
|
+
)
|
|
120
|
+
// → { data: PedidoDTO[], total: number, page: 1, limit: 10, totalPages: number }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Filtros disponibles
|
|
124
|
+
Los filtros son **igualdad exacta** (AND implícito). Para queries complejas (LIKE, IN, JOIN) → implementar `RepositoryAdapter<T>` custom con el driver directo.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// ✅ Soportado
|
|
128
|
+
{ estado: 'activo', usuarioId: 'u123' }
|
|
129
|
+
|
|
130
|
+
// ❌ No soportado directamente — necesita adapter custom
|
|
131
|
+
{ total: { gte: 1000 } }
|
|
132
|
+
{ nombre: { like: '%zapato%' } }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 5. TRANSACCIONES
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
// En el service — si necesitás atomicidad entre operaciones
|
|
141
|
+
await orm.transaction(async (tx) => {
|
|
142
|
+
const txRepo = new OrmRepository<PedidoDTO>(tx, 'Pedido')
|
|
143
|
+
const txInventarioRepo = new OrmRepository<InventarioDTO>(tx, 'Inventario')
|
|
144
|
+
|
|
145
|
+
await txRepo.create(datosPedido)
|
|
146
|
+
await txInventarioRepo.update(inventarioId, { stock: stockNuevo })
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Importante:** Si el adapter no soporta transacciones (MemoryDb, RecordingDb), el ORM ejecuta sin transacción — no falla, solo no es atómico.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 6. MIGRACIONES MANUALES (SQL explícito)
|
|
155
|
+
|
|
156
|
+
Para cambios que el auto-migrate no puede hacer (renombrar columna, cambiar tipo, agregar índice):
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// Generar archivo de migración
|
|
160
|
+
// arckode make:migration add_index_to_pedidos
|
|
161
|
+
|
|
162
|
+
// src/migrations/1716000000_add_index_to_pedidos.ts
|
|
163
|
+
export async function up(adapter: DbAdapter): Promise<void> {
|
|
164
|
+
await adapter.run('CREATE INDEX IF NOT EXISTS idx_pedidos_usuario ON pedidos(usuario_id)')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function down(adapter: DbAdapter): Promise<void> {
|
|
168
|
+
await adapter.run('DROP INDEX IF EXISTS idx_pedidos_usuario')
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
arckode db:migrate # corre up() de migraciones pendientes
|
|
174
|
+
arckode db:migrate --rollback # corre down() de la última migración
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Tabla de control:** `_arckode_migrations` (no tocar manualmente)
|
|
178
|
+
**Tabla de schema drift:** `_arckode_schema` (usada por `orm.migrate()`, separada)
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## 7. DRIFT DETECTION (automático)
|
|
183
|
+
|
|
184
|
+
`orm.migrate()` detecta:
|
|
185
|
+
- **Columnas nuevas en el modelo** → ejecuta `ALTER TABLE ADD COLUMN` automáticamente
|
|
186
|
+
- **Columnas que dejaron de existir en el modelo** → emite WARNING, NO las elimina (datos en producción)
|
|
187
|
+
|
|
188
|
+
Si necesitás eliminar una columna → hacerlo con una migración manual explícita.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## 8. ADAPTERS DE BASE DE DATOS
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
// SQLite (desarrollo)
|
|
196
|
+
import { SqliteAdapter } from 'arckode-framework/adapters/sqlite'
|
|
197
|
+
const db = new SqliteAdapter({ path: './data/db.sqlite' })
|
|
198
|
+
await db.connect()
|
|
199
|
+
|
|
200
|
+
// MySQL (producción)
|
|
201
|
+
import { MySqlAdapter } from 'arckode-framework/adapters/mysql'
|
|
202
|
+
const db = new MySqlAdapter({
|
|
203
|
+
host: config.get('DB_HOST'),
|
|
204
|
+
port: config.get<number>('DB_PORT'),
|
|
205
|
+
user: config.get('DB_USER'),
|
|
206
|
+
password: config.get('DB_PASSWORD'),
|
|
207
|
+
database: config.get('DB_NAME'),
|
|
208
|
+
})
|
|
209
|
+
await db.connect()
|
|
210
|
+
|
|
211
|
+
// PostgreSQL (producción)
|
|
212
|
+
import { PostgresAdapter } from 'arckode-framework/adapters/postgres'
|
|
213
|
+
const db = new PostgresAdapter({ connectionString: config.get('DATABASE_URL') })
|
|
214
|
+
await db.connect()
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**El ORM y todos los services son iguales independientemente del adapter.** Solo cambia el adapter en composition-root.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 9. ANTI-PATRONES DE ORM
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
// ❌ ORM directo en controller
|
|
225
|
+
router.get('/pedidos', async (req) => {
|
|
226
|
+
return orm.findMany('Pedido') // NO — viola REGLA #12
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// ❌ ORM directo en service (debe ser RepositoryAdapter)
|
|
230
|
+
class PedidosService {
|
|
231
|
+
constructor(private orm: ORM) {} // NO — viola REGLA #18
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ❌ N+1: ORM dentro de un loop (viola REGLA #17)
|
|
235
|
+
const pedidos = await repo.findMany()
|
|
236
|
+
for (const p of pedidos) {
|
|
237
|
+
const user = await orm.findById('Usuario', p.usuarioId) // N queries
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ❌ Dos módulos escribiendo en la misma tabla (viola REGLA #4)
|
|
241
|
+
// módulo-pedidos: orm.update('Inventario', ...) → NO
|
|
242
|
+
// módulo-inventario es el dueño de la tabla inventario
|
|
243
|
+
|
|
244
|
+
// ✅ Para queries complejas: adapter custom o dos queries separadas
|
|
245
|
+
const pedidos = await repo.findMany()
|
|
246
|
+
const userIds = [...new Set(pedidos.map(p => p.usuarioId))]
|
|
247
|
+
// luego pasar userIds al módulo de usuarios por acción pública
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 10. CHECKLIST ORM
|
|
253
|
+
|
|
254
|
+
- [ ] El modelo tiene `table` en snake_case plural
|
|
255
|
+
- [ ] Los campos usan camelCase y tipos válidos
|
|
256
|
+
- [ ] `orm.define()` en composition-root, NO en el módulo
|
|
257
|
+
- [ ] `orm.migrate()` después de todos los `define()`
|
|
258
|
+
- [ ] Service recibe `RepositoryAdapter<T>`, no `ORM`
|
|
259
|
+
- [ ] No hay ORM dentro de loops
|
|
260
|
+
- [ ] Migraciones manuales en `src/migrations/` para cambios de schema complejos
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# SKILL: Arckode Realtime — EventBus, Queue y WebSockets
|
|
2
|
+
|
|
3
|
+
> Activar cuando: comunicación entre módulos broadcast, jobs en background, WebSockets, tiempo real, notificaciones push.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. EVENTBUS (pub-sub broadcast)
|
|
8
|
+
|
|
9
|
+
**Cuándo usar EventBus vs Conectores:**
|
|
10
|
+
- **Conector directo** → 2 módulos específicos, flujo claro y predecible
|
|
11
|
+
- **EventBus** → 1 evento, N módulos reaccionan independientemente entre sí
|
|
12
|
+
|
|
13
|
+
### Setup en composition-root.ts
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { EventBus } from 'arckode-framework/events'
|
|
17
|
+
|
|
18
|
+
const events = new EventBus()
|
|
19
|
+
|
|
20
|
+
// Pasar a los módulos que lo necesiten
|
|
21
|
+
const system = new System({ ..., events })
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Emitir eventos (en el service)
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// El service emite — no sabe quién escucha
|
|
28
|
+
class PedidosService {
|
|
29
|
+
constructor(private events: EventBus, ...) {}
|
|
30
|
+
|
|
31
|
+
async confirmar(id: string): Promise<PedidoDTO> {
|
|
32
|
+
const pedido = await this.repo.update(id, { estado: 'confirmado' })
|
|
33
|
+
await this.events.emit('pedido.confirmado', pedido, 'pedidos')
|
|
34
|
+
return pedido!
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Suscribirse (en composition-root, no en módulos)
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// Los suscriptores van en composition-root — no en los módulos
|
|
43
|
+
events.on('pedido.confirmado', async (event) => {
|
|
44
|
+
const pedido = event.data as PedidoDTO
|
|
45
|
+
await inventario.descontarStock(pedido.productoId, pedido.cantidad)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
events.on('pedido.confirmado', async (event) => {
|
|
49
|
+
const pedido = event.data as PedidoDTO
|
|
50
|
+
await notificaciones.enviar(pedido.usuarioId, 'Pedido confirmado')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Suscribirse una sola vez
|
|
54
|
+
events.once('sistema.iniciado', async (event) => {
|
|
55
|
+
await seeds.cargar()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Cancelar suscripción
|
|
59
|
+
const handler = async (event: EventMessage) => { ... }
|
|
60
|
+
events.on('pedido.cancelado', handler)
|
|
61
|
+
events.off('pedido.cancelado', handler)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Inspeccionar (debug)
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// Historial de los últimos 100 eventos
|
|
68
|
+
const todos = events.getHistory()
|
|
69
|
+
const soloConfirmados = events.getHistory('pedido.confirmado')
|
|
70
|
+
|
|
71
|
+
// Cuántos listeners hay por evento
|
|
72
|
+
console.log(events.listeners) // { 'pedido.confirmado': 2, 'pedido.cancelado': 1 }
|
|
73
|
+
|
|
74
|
+
// Limpiar historial
|
|
75
|
+
events.clearHistory()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Nomenclatura de eventos
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
{módulo}.{acción} → pedido.confirmado, usuario.registrado
|
|
82
|
+
{módulo}.{entidad}.{acción} → inventario.stock.agotado
|
|
83
|
+
sistema.{acción} → sistema.iniciado, sistema.detenido
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 2. QUEUE SERVICE (jobs en background)
|
|
89
|
+
|
|
90
|
+
**Cuándo usar Queue:**
|
|
91
|
+
- Tareas que no deben bloquear la response HTTP (enviar email, generar PDF, procesar imagen)
|
|
92
|
+
- Tareas que pueden fallar y necesitan reintentos automáticos
|
|
93
|
+
- Tareas que deben ejecutarse con delay
|
|
94
|
+
|
|
95
|
+
### Setup en composition-root.ts
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { QueueService, MemoryQueueAdapter } from 'arckode-framework/modules/queue'
|
|
99
|
+
|
|
100
|
+
const queue = new QueueService(new MemoryQueueAdapter())
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Registrar handlers (antes de dispatch)
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// Registrar los handlers ANTES de empezar a despachar
|
|
107
|
+
queue.register('enviar-email', async (job) => {
|
|
108
|
+
const { to, subject, html } = job.data as { to: string; subject: string; html: string }
|
|
109
|
+
await mail.send({ to: { address: to }, subject, html })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
queue.register('generar-reporte', async (job) => {
|
|
113
|
+
const { userId, periodo } = job.data as { userId: string; periodo: string }
|
|
114
|
+
const pdf = await generarPDF(userId, periodo)
|
|
115
|
+
await storage.upload({ ...pdf, fieldName: 'reporte' }, 'reportes')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
queue.register('procesar-imagen', async (job) => {
|
|
119
|
+
const { imagePath, userId } = job.data as { imagePath: string; userId: string }
|
|
120
|
+
await redimensionarYOptimizar(imagePath)
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Despachar jobs (desde el service)
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// Fire-and-forget: no espera que el job termine
|
|
128
|
+
async registrar(dto: RegisterDTO): Promise<UserDTO> {
|
|
129
|
+
const user = await this.repo.create(...)
|
|
130
|
+
|
|
131
|
+
// El email no bloquea — se procesa en background
|
|
132
|
+
await this.queue.dispatch('enviar-email', {
|
|
133
|
+
to: user.email,
|
|
134
|
+
subject: 'Bienvenido',
|
|
135
|
+
html: `<h1>Hola ${user.nombre}!</h1>`,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return user
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Con delay (30 segundos)
|
|
142
|
+
await queue.dispatch('recordatorio-carrito', { userId }, { delay: 30_000 })
|
|
143
|
+
|
|
144
|
+
// Con reintentos personalizados
|
|
145
|
+
await queue.dispatch('procesar-pago', { monto, userId }, { maxAttempts: 5 })
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Manejo de errores y reintentos
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// MemoryQueueAdapter reintenta automáticamente con backoff exponencial:
|
|
152
|
+
// intento 1 → falla → espera 1s → intento 2 → falla → espera 2s → intento 3
|
|
153
|
+
// maxAttempts default: 3
|
|
154
|
+
|
|
155
|
+
// El handler recibe el job con metadatos
|
|
156
|
+
queue.register('procesar-pago', async (job) => {
|
|
157
|
+
console.log(`Intento ${job.attempts}/${job.maxAttempts}`)
|
|
158
|
+
if (job.error) console.log('Error anterior:', job.error)
|
|
159
|
+
|
|
160
|
+
const { monto } = job.data as { monto: number }
|
|
161
|
+
await procesarPago(monto)
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Nota:** `MemoryQueueAdapter` es solo para desarrollo — los jobs se pierden al reiniciar. Para producción → implementar `QueueAdapter` con Redis o una tabla en BD.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 3. WEBSOCKETS (tiempo real)
|
|
170
|
+
|
|
171
|
+
### Setup en composition-root.ts
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { WSServer } from 'arckode-framework/ws'
|
|
175
|
+
|
|
176
|
+
const ws = new WSServer()
|
|
177
|
+
|
|
178
|
+
// Adjuntar al servidor HTTP ANTES de iniciar
|
|
179
|
+
ws.attach(http.server)
|
|
180
|
+
|
|
181
|
+
await system.start()
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Broadcast a todos los clientes
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
// Desde cualquier service inyectado con ws
|
|
188
|
+
ws.broadcast('pedido.nuevo', {
|
|
189
|
+
id: pedido.id,
|
|
190
|
+
total: pedido.total,
|
|
191
|
+
estado: 'pendiente',
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
ws.broadcast('stock.actualizado', { productoId, stockActual })
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Enviar a un cliente específico
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
ws.sendTo(clientId, 'notificacion', {
|
|
201
|
+
tipo: 'success',
|
|
202
|
+
mensaje: 'Tu pedido fue confirmado',
|
|
203
|
+
})
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Gestionar clientes
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// Obtener todos los clientes conectados
|
|
210
|
+
const clientes = ws.getClients()
|
|
211
|
+
console.log(`${clientes.length} clientes conectados`)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Inyectar WebSocket en un módulo
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// En index.ts del módulo
|
|
218
|
+
create({ logger, orm, router, ws }: ModuleDependencies) {
|
|
219
|
+
const service = new PedidosService(repo, ws, logger.child('pedidos'))
|
|
220
|
+
...
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// En actions/service.ts
|
|
224
|
+
class PedidosService {
|
|
225
|
+
constructor(
|
|
226
|
+
private repo: RepositoryAdapter<PedidoDTO>,
|
|
227
|
+
private ws: WSServer,
|
|
228
|
+
private logger: Logger,
|
|
229
|
+
) {}
|
|
230
|
+
|
|
231
|
+
async confirmar(id: string): Promise<PedidoDTO> {
|
|
232
|
+
const pedido = await this.repo.update(id, { estado: 'confirmado' })
|
|
233
|
+
this.ws.broadcast('pedido.confirmado', { id, estado: 'confirmado' })
|
|
234
|
+
return pedido!
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### SSE como alternativa (Server-Sent Events)
|
|
240
|
+
|
|
241
|
+
Para notificaciones unidireccionales servidor → cliente sin WebSocket:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
// En el controller — SSE es más simple para notificaciones push
|
|
245
|
+
router.get('/eventos', [auth.authenticate()], async (req) => {
|
|
246
|
+
return {
|
|
247
|
+
status: 200,
|
|
248
|
+
stream: async function* () {
|
|
249
|
+
// Enviar evento inicial
|
|
250
|
+
yield `data: ${JSON.stringify({ tipo: 'conectado' })}\n\n`
|
|
251
|
+
|
|
252
|
+
// Mantener conexión abierta — enviar heartbeat cada 30s
|
|
253
|
+
while (true) {
|
|
254
|
+
await new Promise(r => setTimeout(r, 30_000))
|
|
255
|
+
yield `data: ${JSON.stringify({ tipo: 'heartbeat' })}\n\n`
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
headers: {
|
|
259
|
+
'Content-Type': 'text/event-stream',
|
|
260
|
+
'Cache-Control': 'no-cache',
|
|
261
|
+
'Connection': 'keep-alive',
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 4. COMBINACIONES COMUNES
|
|
270
|
+
|
|
271
|
+
### EventBus + Queue (decoupled async pipeline)
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
// Módulo emite → EventBus → Queue despacha en background
|
|
275
|
+
events.on('usuario.registrado', async (event) => {
|
|
276
|
+
const user = event.data as UserDTO
|
|
277
|
+
await queue.dispatch('enviar-bienvenida', { email: user.email, nombre: user.nombre })
|
|
278
|
+
await queue.dispatch('configurar-defaults', { userId: user.id })
|
|
279
|
+
})
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Queue + WebSocket (progreso en tiempo real)
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
queue.register('exportar-reporte', async (job) => {
|
|
286
|
+
const { userId } = job.data as { userId: string }
|
|
287
|
+
|
|
288
|
+
ws.sendTo(userId, 'reporte.progreso', { paso: 1, total: 3, mensaje: 'Generando...' })
|
|
289
|
+
const datos = await obtenerDatos(userId)
|
|
290
|
+
|
|
291
|
+
ws.sendTo(userId, 'reporte.progreso', { paso: 2, total: 3, mensaje: 'Formateando...' })
|
|
292
|
+
const pdf = await generarPDF(datos)
|
|
293
|
+
|
|
294
|
+
ws.sendTo(userId, 'reporte.listo', { url: pdf.url })
|
|
295
|
+
})
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## 5. CHECKLIST REALTIME
|
|
301
|
+
|
|
302
|
+
- [ ] EventBus: suscriptores en composition-root, NO dentro de módulos
|
|
303
|
+
- [ ] Queue: handlers registrados ANTES del primer dispatch
|
|
304
|
+
- [ ] `MemoryQueueAdapter` solo para dev — jobs se pierden al reiniciar
|
|
305
|
+
- [ ] WebSocket: `ws.attach(http.server)` ANTES de `system.start()`
|
|
306
|
+
- [ ] Eventos nombrados en `{módulo}.{acción}` — no strings aleatorios
|
|
307
|
+
- [ ] Handlers de queue con try/catch si el error debe notificarse al usuario
|