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.
Files changed (52) hide show
  1. package/README.md +546 -0
  2. package/adapters/__tests__/mysql.test.ts +283 -0
  3. package/adapters/jwt.ts +18 -0
  4. package/adapters/mysql.ts +98 -0
  5. package/adapters/postgres.ts +52 -0
  6. package/adapters/redis-cache.ts +64 -0
  7. package/adapters/sqlite.ts +73 -0
  8. package/adapters/vendor.d.ts +48 -0
  9. package/bin/arckode.js +7 -0
  10. package/cli/analyze.ts +506 -0
  11. package/cli/commands/db-migrate.ts +121 -0
  12. package/cli/commands/db-seed.ts +54 -0
  13. package/cli/commands/generate-api-client.ts +106 -0
  14. package/cli/commands/make-adapter.ts +132 -0
  15. package/cli/commands/make-auth.ts +297 -0
  16. package/cli/commands/make-frontend-module.ts +271 -0
  17. package/cli/commands/make-helper.ts +65 -0
  18. package/cli/commands/make-migration.ts +30 -0
  19. package/cli/commands/make-seed.ts +29 -0
  20. package/cli/generate.ts +132 -0
  21. package/cli/index.ts +604 -0
  22. package/cli/stubs/frontend-stub.ts +294 -0
  23. package/cli/stubs/fullstack-stub.ts +46 -0
  24. package/cli/stubs/module-stub.ts +469 -0
  25. package/kernel/__tests__/adapters.test.ts +101 -0
  26. package/kernel/__tests__/analyzer.test.ts +282 -0
  27. package/kernel/__tests__/framework.test.ts +617 -0
  28. package/kernel/__tests__/middlewares.test.ts +174 -0
  29. package/kernel/__tests__/static.test.ts +94 -0
  30. package/kernel/framework.ts +1851 -0
  31. package/kernel/middlewares.ts +179 -0
  32. package/kernel/static.ts +76 -0
  33. package/kernel/testing.ts +237 -0
  34. package/modules/events/index.ts +99 -0
  35. package/modules/mail/index.ts +51 -0
  36. package/modules/mail/smtp-adapter.ts +42 -0
  37. package/modules/queue/index.ts +78 -0
  38. package/modules/storage/index.ts +40 -0
  39. package/modules/storage/local-adapter.ts +41 -0
  40. package/modules/ws/__tests__/ws.test.ts +114 -0
  41. package/modules/ws/index.ts +136 -0
  42. package/package.json +99 -0
  43. package/skills/auth/SKILL.md +243 -0
  44. package/skills/cli/SKILL.md +258 -0
  45. package/skills/config/SKILL.md +253 -0
  46. package/skills/connectors/SKILL.md +259 -0
  47. package/skills/helpers/SKILL.md +206 -0
  48. package/skills/middlewares/SKILL.md +282 -0
  49. package/skills/orm/SKILL.md +260 -0
  50. package/skills/realtime/SKILL.md +307 -0
  51. package/skills/services/SKILL.md +206 -0
  52. 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