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,1851 @@
1
+ // Arckode Framework — Kernel
2
+ // SOLID. Modular. AI-oriented. ~900 líneas. La IA lo lee completo.
3
+ // ────────────────────────────────────────────────────────────────
4
+ // Principios:
5
+ // S — Cada clase hace UNA cosa
6
+ // O — Adapters y middlewares se agregan sin modificar el kernel
7
+ // L — Cualquier DbAdapter funciona con ORM, cualquier JwtAdapter con Auth
8
+ // I — Interfaces pequeñas: DbAdapter solo query/run, JwtAdapter solo sign/verify
9
+ // D — ORM depende de DbAdapter (interfaz), Auth depende de JwtAdapter (interfaz)
10
+
11
+ /* eslint-disable @typescript-eslint/no-explicit-any */
12
+
13
+ // ═══════════════════════════════════════════════════════════════
14
+ // 1. ERRORES — Tesperados con código HTTP y metadata
15
+ // ═══════════════════════════════════════════════════════════════
16
+
17
+ export abstract class ErrorContract extends Error {
18
+ public abstract readonly httpStatus: number
19
+ public abstract readonly isExpected: boolean
20
+ public abstract readonly canRetry: boolean
21
+ public abstract readonly errorCode: string
22
+
23
+ constructor(
24
+ message: string,
25
+ public readonly details?: Record<string, unknown>,
26
+ ) {
27
+ super(message)
28
+ this.name = this.constructor.name
29
+ }
30
+
31
+ toJSON(): Record<string, unknown> {
32
+ return {
33
+ error: this.message,
34
+ code: this.errorCode,
35
+ ...(this.details ? { details: this.details } : {}),
36
+ }
37
+ }
38
+ }
39
+
40
+ export class ValidationError extends ErrorContract {
41
+ httpStatus = 400
42
+ isExpected = true
43
+ canRetry = false
44
+ errorCode = 'VALIDATION_ERROR'
45
+ constructor(msg: string, public fields?: Record<string, string[]>) {
46
+ super(msg, fields ? { fields } : undefined)
47
+ }
48
+ }
49
+
50
+ export class AuthError extends ErrorContract {
51
+ httpStatus = 401
52
+ isExpected = true
53
+ canRetry = false
54
+ errorCode = 'AUTH_ERROR'
55
+ constructor(message = 'Authentication error', details?: Record<string, unknown>) {
56
+ super(message, details)
57
+ }
58
+ }
59
+
60
+ export class ForbiddenError extends ErrorContract {
61
+ httpStatus = 403
62
+ isExpected = true
63
+ canRetry = false
64
+ errorCode = 'FORBIDDEN'
65
+ }
66
+
67
+ export class NotFoundError extends ErrorContract {
68
+ httpStatus = 404
69
+ isExpected = true
70
+ canRetry = false
71
+ errorCode = 'NOT_FOUND'
72
+ }
73
+
74
+ export class ConflictError extends ErrorContract {
75
+ httpStatus = 409
76
+ isExpected = true
77
+ canRetry = false
78
+ errorCode = 'CONFLICT'
79
+ }
80
+
81
+ export class RateLimitError extends ErrorContract {
82
+ httpStatus = 429
83
+ isExpected = true
84
+ canRetry = true
85
+ errorCode = 'RATE_LIMIT'
86
+ }
87
+
88
+ export class RepositoryError extends ErrorContract {
89
+ httpStatus = 500
90
+ isExpected = true
91
+ canRetry = true
92
+ errorCode = 'REPOSITORY_ERROR'
93
+ }
94
+
95
+ export class InternalError extends ErrorContract {
96
+ httpStatus = 500
97
+ isExpected = false
98
+ canRetry = false
99
+ errorCode = 'INTERNAL_ERROR'
100
+ }
101
+
102
+ export class ModuleRuleError extends ErrorContract {
103
+ httpStatus = 500
104
+ isExpected = false
105
+ canRetry = false
106
+ errorCode = 'MODULE_RULE_VIOLATION'
107
+ constructor(module: string, rule: string) {
108
+ super(`[${module}] Regla violada: ${rule}`, { module, rule })
109
+ }
110
+ }
111
+
112
+ // ═══════════════════════════════════════════════════════════════
113
+ // 2. LOGGER — Estructurado, por módulo, transportable
114
+ // ═══════════════════════════════════════════════════════════════
115
+
116
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
117
+
118
+ export interface LoggerTransport {
119
+ write(entry: { timestamp: string; level: LogLevel; source: string; message: string; meta?: Record<string, unknown> }): void
120
+ }
121
+
122
+ export class Logger {
123
+ constructor(
124
+ public readonly source: string = 'app',
125
+ private level: LogLevel = 'info',
126
+ private transports: LoggerTransport[] = [new ConsoleTransport()],
127
+ ) {}
128
+
129
+ child(name: string): Logger {
130
+ return new Logger(`${this.source}.${name}`, this.level, this.transports)
131
+ }
132
+
133
+ debug(message: string, meta?: Record<string, unknown>): void { this.emit('debug', message, meta) }
134
+ info(message: string, meta?: Record<string, unknown>): void { this.emit('info', message, meta) }
135
+ warn(message: string, meta?: Record<string, unknown>): void { this.emit('warn', message, meta) }
136
+ error(message: string, meta?: Record<string, unknown>): void { this.emit('error', message, meta) }
137
+
138
+ private emit(level: LogLevel, message: string, meta?: Record<string, unknown>): void {
139
+ const weight = { debug: 0, info: 1, warn: 2, error: 3 }
140
+ if (weight[level] < weight[this.level]) return
141
+
142
+ const entry: Record<string, unknown> = {
143
+ timestamp: new Date().toISOString(),
144
+ level,
145
+ source: this.source,
146
+ message,
147
+ }
148
+
149
+ if (meta && Object.keys(meta).length > 0) {
150
+ entry.meta = { ...meta }
151
+ }
152
+
153
+ for (const t of this.transports) t.write(entry as any)
154
+ }
155
+ }
156
+
157
+ class ConsoleTransport implements LoggerTransport {
158
+ write(entry: { level: LogLevel; message: string; source: string } & Record<string, unknown>): void {
159
+ const line = JSON.stringify(entry)
160
+ if (entry.level === 'error') console.error(line)
161
+ else if (entry.level === 'warn') console.warn(line)
162
+ else console.log(line)
163
+ }
164
+ }
165
+
166
+ // ═══════════════════════════════════════════════════════════════
167
+ // 3. CONFIG — Validada al startup. Fail fast.
168
+ // ═══════════════════════════════════════════════════════════════
169
+
170
+ /**
171
+ * Carga variables de entorno con soporte de stages (development / staging / production).
172
+ * Parsea archivos .env y .env.{NODE_ENV} — el stage-specific sobreescribe el base.
173
+ * No tiene dependencias externas.
174
+ *
175
+ * @example
176
+ * // En composition-root.ts — ANTES de config.load()
177
+ * const env = await loadEnv()
178
+ * config.define({ PORT: { type: 'number', default: 3000 } }).load(env)
179
+ *
180
+ * // Archivos soportados (en orden, el último tiene prioridad):
181
+ * // .env → valores base (nunca commitear secrets)
182
+ * // .env.development → sobreescribe en NODE_ENV=development
183
+ * // .env.staging → sobreescribe en NODE_ENV=staging
184
+ * // .env.production → sobreescribe en NODE_ENV=production
185
+ */
186
+ export async function loadEnv(opts: { cwd?: string } = {}): Promise<Record<string, string | undefined>> {
187
+ const { readFile } = await import('node:fs/promises')
188
+ const { join } = await import('node:path')
189
+
190
+ const cwd = opts.cwd ?? process.cwd()
191
+ const stage = (process.env.NODE_ENV ?? 'development').toLowerCase()
192
+
193
+ const parseEnvFile = (content: string): Record<string, string> => {
194
+ const result: Record<string, string> = {}
195
+ for (const line of content.split('\n')) {
196
+ const trimmed = line.trim()
197
+ if (!trimmed || trimmed.startsWith('#')) continue
198
+ const eq = trimmed.indexOf('=')
199
+ if (eq === -1) continue
200
+ const key = trimmed.slice(0, eq).trim()
201
+ let val = trimmed.slice(eq + 1).trim()
202
+ // Quitar comillas opcionales: "valor" o 'valor'
203
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
204
+ val = val.slice(1, -1)
205
+ }
206
+ result[key] = val
207
+ }
208
+ return result
209
+ }
210
+
211
+ const tryRead = async (path: string): Promise<Record<string, string>> => {
212
+ try { return parseEnvFile(await readFile(path, 'utf-8')) } catch { return {} }
213
+ }
214
+
215
+ const base = await tryRead(join(cwd, '.env'))
216
+ const staged = await tryRead(join(cwd, `.env.${stage}`))
217
+
218
+ // process.env tiene la prioridad más alta (variables de entorno reales del SO)
219
+ return { ...base, ...staged, ...process.env }
220
+ }
221
+
222
+ export type ConfigType = 'string' | 'number' | 'boolean' | 'url' | 'email'
223
+
224
+ export interface ConfigDefinition {
225
+ type: ConfigType
226
+ required?: boolean
227
+ default?: string | number | boolean
228
+ description?: string
229
+ secret?: boolean
230
+ }
231
+
232
+ export class ConfigStore {
233
+ private definitions = new Map<string, ConfigDefinition>()
234
+ private values = new Map<string, unknown>()
235
+ private isLoaded = false
236
+
237
+ define(schema: Record<string, ConfigDefinition>): this {
238
+ for (const [key, def] of Object.entries(schema)) {
239
+ this.definitions.set(key, def)
240
+ }
241
+ return this
242
+ }
243
+
244
+ load(source: Record<string, string | undefined>): this {
245
+ const errors: string[] = []
246
+
247
+ for (const [key, def] of this.definitions) {
248
+ const raw = source[key] ?? def.default
249
+
250
+ if (raw == null || raw === '') {
251
+ if (def.required) errors.push(`CONFIG: ${key} es requerido`)
252
+ continue
253
+ }
254
+
255
+ let parsed: unknown
256
+
257
+ switch (def.type) {
258
+ case 'string':
259
+ parsed = String(raw)
260
+ break
261
+ case 'number': {
262
+ const n = Number(raw)
263
+ if (isNaN(n)) { errors.push(`CONFIG: ${key} must be a number`); continue }
264
+ parsed = n
265
+ break
266
+ }
267
+ case 'boolean':
268
+ parsed = raw === 'true' || raw === '1'
269
+ break
270
+ case 'url':
271
+ try { new URL(String(raw)); parsed = String(raw) }
272
+ catch { errors.push(`CONFIG: ${key} is not a valid URL`); continue }
273
+ break
274
+ case 'email':
275
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(raw))) {
276
+ errors.push(`CONFIG: ${key} is not a valid email`); continue
277
+ }
278
+ parsed = String(raw)
279
+ break
280
+ }
281
+
282
+ this.values.set(key, parsed)
283
+ }
284
+
285
+ if (errors.length > 0) {
286
+ throw new InternalError(`Invalid configuration:\n${errors.join('\n')}`)
287
+ }
288
+
289
+ this.isLoaded = true
290
+ return this
291
+ }
292
+
293
+ get<T = string>(key: string): T {
294
+ if (!this.isLoaded) throw new InternalError('Config no cargada — llamar load()')
295
+ if (!this.values.has(key)) throw new InternalError(`Config "${key}" no definida`)
296
+ return this.values.get(key) as T
297
+ }
298
+
299
+ getOrThrow<T = string>(key: string): T {
300
+ return this.get<T>(key)
301
+ }
302
+
303
+ toJSON(): Record<string, unknown> {
304
+ const result: Record<string, unknown> = {}
305
+ for (const [key] of this.definitions) {
306
+ if (this.values.has(key)) {
307
+ const def = this.definitions.get(key)!
308
+ result[key] = def.secret ? '••••••' : this.values.get(key)
309
+ }
310
+ }
311
+ return result
312
+ }
313
+ }
314
+
315
+ // ═══════════════════════════════════════════════════════════════
316
+ // 4. CONTAINER — DI explícito, sin magia
317
+ // ═══════════════════════════════════════════════════════════════
318
+
319
+ interface ServiceEntry<T> {
320
+ name: string
321
+ factory: () => T
322
+ instance?: T
323
+ destructor?: () => Promise<void>
324
+ singleton: boolean
325
+ }
326
+
327
+ export class Container {
328
+ private services = new Map<string, ServiceEntry<unknown>>()
329
+ private initialized = false
330
+
331
+ register<T>(name: string, factory: () => T, destructor?: () => Promise<void>): this {
332
+ if (this.initialized) throw new InternalError(`Container: "${name}" registered after init`)
333
+ this.services.set(name, { name, factory, destructor, singleton: true })
334
+ return this
335
+ }
336
+
337
+ resolve<T>(name: string): T {
338
+ const entry = this.services.get(name) as ServiceEntry<T> | undefined
339
+ if (!entry) throw new InternalError(`Container: "${name}" no registrado`)
340
+ if (entry.singleton && entry.instance) return entry.instance
341
+ entry.instance = entry.factory()
342
+ return entry.instance as T
343
+ }
344
+
345
+ get<T>(name: string): T {
346
+ return this.resolve<T>(name)
347
+ }
348
+
349
+ init(): void {
350
+ this.initialized = true
351
+ for (const [name] of this.services) {
352
+ this.resolve(name)
353
+ }
354
+ }
355
+
356
+ async destroy(): Promise<void> {
357
+ const entries = [...this.services.values()].reverse()
358
+ for (const entry of entries) {
359
+ if (entry.destructor) {
360
+ try { await entry.destructor() } catch (e) {
361
+ console.error(`[Container] Error al destruir "${entry.name}":`, e)
362
+ }
363
+ }
364
+ }
365
+ this.services.clear()
366
+ this.initialized = false
367
+ }
368
+
369
+ get registered(): string[] {
370
+ return [...this.services.keys()]
371
+ }
372
+ }
373
+
374
+ // ═══════════════════════════════════════════════════════════════
375
+ // 5. ORM — Sobre DbAdapter. SOLID: depende de interfaz, no de implementación
376
+ // ═══════════════════════════════════════════════════════════════
377
+
378
+ // ── Contrato de adaptador de base de datos ──
379
+ export interface DbAdapter {
380
+ query(sql: string, params?: unknown[]): Promise<unknown[]>
381
+ run(sql: string, params?: unknown[]): Promise<{ changes: number; lastId?: string }>
382
+ close(): Promise<void>
383
+ /** Opcional: soporte de transacciones. Si no lo implementa, se ejecuta sin transacción. */
384
+ transaction?<T>(fn: (adapter: DbAdapter) => Promise<T>): Promise<T>
385
+ }
386
+
387
+ // ── Definición de campo ──
388
+ export interface FieldDefinition {
389
+ type: 'string' | 'number' | 'boolean' | 'json' | 'date'
390
+ required?: boolean
391
+ default?: unknown
392
+ unique?: boolean
393
+ indexed?: boolean
394
+ maxLength?: number
395
+ }
396
+
397
+ // ── Definición de modelo ──
398
+ export interface ModelDefinition {
399
+ table: string
400
+ fields: Record<string, FieldDefinition>
401
+ timestamps?: boolean
402
+ softDelete?: boolean
403
+ }
404
+
405
+ // ── Resultado de consulta ──
406
+ export interface ModelResult {
407
+ id: string
408
+ [key: string]: unknown
409
+ }
410
+
411
+ // ── Opciones de consulta ──
412
+ export interface OrderByClause {
413
+ field: string
414
+ dir?: 'ASC' | 'DESC'
415
+ }
416
+
417
+ export interface FindOptions {
418
+ limit?: number
419
+ offset?: number
420
+ orderBy?: OrderByClause | OrderByClause[]
421
+ /** Campos a seleccionar. Si se omite: SELECT *. Los nombres se validan contra el modelo. */
422
+ select?: string[]
423
+ }
424
+
425
+ export interface PageResult<T = ModelResult> {
426
+ data: T[]
427
+ total: number
428
+ limit: number
429
+ offset: number
430
+ pages: number
431
+ }
432
+
433
+ // ═══════════════════════════════════════════════════════════════
434
+ // REPOSITORY ADAPTER — Interfaz genérica de capa de datos
435
+ // ═══════════════════════════════════════════════════════════════
436
+ //
437
+ // Los services DEBEN depender de RepositoryAdapter<T>, NO del ORM.
438
+ // Esto permite swapear SQLite → MongoDB → Prisma → Redis sin tocar el service.
439
+ //
440
+ // Implementaciones disponibles:
441
+ // OrmRepository<T> → built-in SQL ORM (SQLite, Postgres)
442
+ // Cualquier clase que implemente esta interfaz → Mongoose, Prisma, etc.
443
+ //
444
+ // Ejemplo en composition-root.ts:
445
+ // const productos = new OrmRepository<Producto>(orm, 'Producto')
446
+ // // O con MongoDB:
447
+ // const productos = new MongoProductoRepository(mongoCollection)
448
+ //
449
+ // El service recibe RepositoryAdapter<Producto> — no sabe qué hay atrás.
450
+
451
+ export interface RepositoryAdapter<T extends object = Record<string, unknown>> {
452
+ findMany(filters?: Record<string, unknown>, options?: FindOptions): Promise<T[]>
453
+ findById(id: string, select?: string[]): Promise<T | null>
454
+ findOne(filters: Record<string, unknown>): Promise<T | null>
455
+ create(data: Omit<T, 'id'>): Promise<T>
456
+ update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null>
457
+ delete(id: string): Promise<boolean>
458
+ count(filters?: Record<string, unknown>): Promise<number>
459
+ paginate(
460
+ filters?: Record<string, unknown>,
461
+ options?: FindOptions & { limit: number },
462
+ ): Promise<PageResult<T>>
463
+ }
464
+
465
+ // ── ORM — SOLID: S = solo ORM, D = depende de DbAdapter ──
466
+ export class ORM {
467
+ private models = new Map<string, ModelDefinition>()
468
+
469
+ constructor(private db: DbAdapter) {}
470
+
471
+ define(name: string, def: ModelDefinition): this {
472
+ this.models.set(name, def)
473
+ return this
474
+ }
475
+
476
+ getDefinition(name: string): ModelDefinition {
477
+ const def = this.models.get(name)
478
+ if (!def) throw new NotFoundError(`Modelo "${name}" no definido`)
479
+ return def
480
+ }
481
+
482
+ /**
483
+ * Ejecuta múltiples operaciones en una transacción atómica.
484
+ * Si el adaptador no soporta transacciones, ejecuta sin ellas.
485
+ *
486
+ * @example
487
+ * await orm.transaction(async (tx) => {
488
+ * await tx.create('Pedido', { productoId, cantidad })
489
+ * await tx.update('Producto', productoId, { stock: nuevoStock })
490
+ * })
491
+ */
492
+ async transaction<T>(fn: (tx: ORM) => Promise<T>): Promise<T> {
493
+ if (this.db.transaction) {
494
+ return this.db.transaction(async (txAdapter) => {
495
+ const txOrm = new ORM(txAdapter)
496
+ for (const [name, def] of this.models) txOrm.define(name, def)
497
+ return fn(txOrm)
498
+ })
499
+ }
500
+ // Fallback: sin transacción si el adaptador no lo soporta
501
+ return fn(this)
502
+ }
503
+
504
+ // ── Helpers privados ──
505
+
506
+ private getAllowedFields(def: ModelDefinition): Set<string> {
507
+ const allowed = new Set(['id', ...Object.keys(def.fields)])
508
+ if (def.timestamps) { allowed.add('createdAt'); allowed.add('updatedAt') }
509
+ if (def.softDelete) allowed.add('deletedAt')
510
+ return allowed
511
+ }
512
+
513
+ private buildSelect(def: ModelDefinition, fields?: string[]): string {
514
+ if (!fields || fields.length === 0) return '*'
515
+ const allowed = this.getAllowedFields(def)
516
+ for (const f of fields) {
517
+ if (!allowed.has(f)) throw new ValidationError(`Invalid select field: "${f}"`)
518
+ }
519
+ return fields.join(', ')
520
+ }
521
+
522
+ private buildWhere(def: ModelDefinition, filters?: Record<string, unknown>): { clause: string; params: unknown[] } {
523
+ const parts: string[] = []
524
+ const params: unknown[] = []
525
+
526
+ if (filters && Object.keys(filters).length > 0) {
527
+ const allowed = this.getAllowedFields(def)
528
+ for (const [k, v] of Object.entries(filters)) {
529
+ if (!allowed.has(k)) throw new ValidationError(`Invalid filter field: "${k}"`)
530
+ params.push(v)
531
+ parts.push(`${k} = ?`)
532
+ }
533
+ }
534
+
535
+ if (def.softDelete) parts.push('deletedAt IS NULL')
536
+
537
+ return {
538
+ clause: parts.length > 0 ? ' WHERE ' + parts.join(' AND ') : '',
539
+ params,
540
+ }
541
+ }
542
+
543
+ // ── CRUD ──
544
+
545
+ async findMany(name: string, filters?: Record<string, unknown>, options?: FindOptions): Promise<ModelResult[]> {
546
+ const def = this.getDefinition(name)
547
+ const { clause, params } = this.buildWhere(def, filters)
548
+
549
+ const selectClause = this.buildSelect(def, options?.select)
550
+ let sql = `SELECT ${selectClause} FROM ${def.table}${clause}`
551
+
552
+ if (options?.orderBy) {
553
+ const allowed = this.getAllowedFields(def)
554
+ const clauses = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy]
555
+ const parts = clauses.map(({ field, dir = 'ASC' }) => {
556
+ if (!allowed.has(field)) throw new ValidationError(`Invalid sort field: "${field}"`)
557
+ return `${field} ${dir === 'DESC' ? 'DESC' : 'ASC'}`
558
+ })
559
+ sql += ` ORDER BY ${parts.join(', ')}`
560
+ }
561
+
562
+ if (options?.limit !== undefined) {
563
+ sql += ` LIMIT ${Math.max(1, Math.floor(options.limit))}`
564
+ if (options.offset !== undefined) {
565
+ sql += ` OFFSET ${Math.max(0, Math.floor(options.offset))}`
566
+ }
567
+ }
568
+
569
+ return (await this.db.query(sql, params)) as ModelResult[]
570
+ }
571
+
572
+ async findById(name: string, id: string, select?: string[]): Promise<ModelResult | null> {
573
+ const def = this.getDefinition(name)
574
+ const selectClause = this.buildSelect(def, select)
575
+ let sql = `SELECT ${selectClause} FROM ${def.table} WHERE id = ?`
576
+ if (def.softDelete) sql += ' AND deletedAt IS NULL'
577
+ const rows = await this.db.query(sql, [id])
578
+ return (rows as ModelResult[])[0] ?? null
579
+ }
580
+
581
+ async findOne(name: string, filters: Record<string, unknown>): Promise<ModelResult | null> {
582
+ const results = await this.findMany(name, filters, { limit: 1 })
583
+ return results[0] ?? null
584
+ }
585
+
586
+ async count(name: string, filters?: Record<string, unknown>): Promise<number> {
587
+ const def = this.getDefinition(name)
588
+ const { clause, params } = this.buildWhere(def, filters)
589
+ const rows = await this.db.query(`SELECT COUNT(*) as n FROM ${def.table}${clause}`, params)
590
+ return Number((rows as Array<{ n: number | string }>)[0]?.n ?? 0)
591
+ }
592
+
593
+ async paginate(
594
+ name: string,
595
+ filters?: Record<string, unknown>,
596
+ options: FindOptions & { limit: number } = { limit: 20 },
597
+ ): Promise<PageResult> {
598
+ const limit = Math.max(1, Math.floor(options.limit))
599
+ const offset = Math.max(0, Math.floor(options.offset ?? 0))
600
+
601
+ const [data, total] = await Promise.all([
602
+ this.findMany(name, filters, { ...options, limit, offset }),
603
+ this.count(name, filters),
604
+ ])
605
+
606
+ return { data, total, limit, offset, pages: Math.ceil(total / limit) }
607
+ }
608
+
609
+ async create(name: string, data: Record<string, unknown>): Promise<ModelResult> {
610
+ const def = this.getDefinition(name)
611
+ const allowedFields = new Set(Object.keys(def.fields))
612
+ const id = crypto.randomUUID()
613
+ const now = new Date().toISOString()
614
+
615
+ const record: Record<string, unknown> = { id }
616
+ for (const [k, v] of Object.entries(data)) {
617
+ if (allowedFields.has(k)) record[k] = v
618
+ }
619
+ if (def.timestamps) {
620
+ record.createdAt = now
621
+ record.updatedAt = now
622
+ }
623
+
624
+ const keys = Object.keys(record)
625
+ const values = Object.values(record)
626
+ const placeholders = keys.map(() => '?').join(', ')
627
+
628
+ await this.db.run(
629
+ `INSERT INTO ${def.table} (${keys.join(', ')}) VALUES (${placeholders})`,
630
+ values,
631
+ )
632
+
633
+ return record as ModelResult
634
+ }
635
+
636
+ async update(name: string, id: string, data: Record<string, unknown>): Promise<ModelResult | null> {
637
+ const def = this.getDefinition(name)
638
+ const now = new Date().toISOString()
639
+
640
+ const record = { ...data }
641
+ if (def.timestamps) record.updatedAt = now
642
+
643
+ const keys = Object.keys(record)
644
+ const values = Object.values(record)
645
+ const setClause = keys.map(k => `${k} = ?`).join(', ')
646
+
647
+ await this.db.run(`UPDATE ${def.table} SET ${setClause} WHERE id = ?`, [...values, id])
648
+ return this.findById(name, id)
649
+ }
650
+
651
+ async delete(name: string, id: string): Promise<boolean> {
652
+ const def = this.getDefinition(name)
653
+
654
+ if (def.softDelete) {
655
+ const result = await this.db.run(`UPDATE ${def.table} SET deletedAt = ? WHERE id = ?`, [new Date().toISOString(), id])
656
+ return result.changes > 0
657
+ }
658
+
659
+ const result = await this.db.run(`DELETE FROM ${def.table} WHERE id = ?`, [id])
660
+ return result.changes > 0
661
+ }
662
+
663
+ // ── Bulk operations ──
664
+
665
+ /**
666
+ * Inserta múltiples registros en una sola query + transacción.
667
+ * Mucho más eficiente que N llamadas a create().
668
+ * Retorna todos los registros creados con sus IDs generados.
669
+ */
670
+ async createMany(name: string, records: Record<string, unknown>[]): Promise<ModelResult[]> {
671
+ if (records.length === 0) return []
672
+ const def = this.getDefinition(name)
673
+ const allowedFields = new Set(Object.keys(def.fields))
674
+ const now = new Date().toISOString()
675
+
676
+ const prepared = records.map(data => {
677
+ const record: Record<string, unknown> = { id: crypto.randomUUID() }
678
+ for (const [k, v] of Object.entries(data)) {
679
+ if (allowedFields.has(k)) record[k] = v
680
+ }
681
+ if (def.timestamps) { record.createdAt = now; record.updatedAt = now }
682
+ return record
683
+ })
684
+
685
+ const keys = Object.keys(prepared[0] ?? {})
686
+ const rowPlaceholders = `(${keys.map(() => '?').join(', ')})`
687
+ const allPlaceholders = prepared.map(() => rowPlaceholders).join(', ')
688
+ const allValues = prepared.flatMap(r => Object.values(r))
689
+
690
+ await this.db.run(
691
+ `INSERT INTO ${def.table} (${keys.join(', ')}) VALUES ${allPlaceholders}`,
692
+ allValues,
693
+ )
694
+
695
+ return prepared as ModelResult[]
696
+ }
697
+
698
+ /**
699
+ * Actualiza todos los registros que coincidan con los filtros.
700
+ * Retorna la cantidad de filas afectadas.
701
+ */
702
+ async updateMany(
703
+ name: string,
704
+ filters: Record<string, unknown>,
705
+ changes: Record<string, unknown>,
706
+ ): Promise<number> {
707
+ const def = this.getDefinition(name)
708
+ const { clause, params: whereParams } = this.buildWhere(def, filters)
709
+ const now = new Date().toISOString()
710
+
711
+ const data = { ...changes }
712
+ if (def.timestamps) data.updatedAt = now
713
+
714
+ const setClause = Object.keys(data).map(k => `${k} = ?`).join(', ')
715
+ const result = await this.db.run(
716
+ `UPDATE ${def.table} SET ${setClause}${clause}`,
717
+ [...Object.values(data), ...whereParams],
718
+ )
719
+ return result.changes
720
+ }
721
+
722
+ /**
723
+ * Elimina (o soft-delete) todos los registros que coincidan con los filtros.
724
+ * Retorna la cantidad de filas afectadas.
725
+ */
726
+ async deleteMany(name: string, filters: Record<string, unknown>): Promise<number> {
727
+ if (Object.keys(filters).length === 0) {
728
+ throw new ValidationError('deleteMany requires at least one filter to avoid deleting the entire table')
729
+ }
730
+ const def = this.getDefinition(name)
731
+ const { clause, params } = this.buildWhere(def, filters)
732
+
733
+ if (def.softDelete) {
734
+ const result = await this.db.run(
735
+ `UPDATE ${def.table} SET deletedAt = ?${clause}`,
736
+ [new Date().toISOString(), ...params],
737
+ )
738
+ return result.changes
739
+ }
740
+
741
+ const result = await this.db.run(`DELETE FROM ${def.table}${clause}`, params)
742
+ return result.changes
743
+ }
744
+
745
+ // ── Migraciones (inicialización de schema) ──
746
+ // Crea tablas si no existen y altera columnas faltantes.
747
+ // Para migraciones complejas, usar herramientas externas (knex, drizzle).
748
+
749
+ static readonly SCHEMA_TABLE = '_arckode_schema'
750
+
751
+ private assertSafeIdentifier(value: string, context: string): void {
752
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
753
+ throw new ValidationError(`Invalid SQL identifier in ${context}: "${value}". Only letters, numbers and underscores.`)
754
+ }
755
+ }
756
+
757
+ async migrate(): Promise<void> {
758
+ // _arckode_migrations es propiedad exclusiva de db:migrate (migraciones SQL manuales).
759
+ // ORM.migrate() usa _arckode_schema para rastrear qué tablas auto-migró.
760
+ await this.db.run(
761
+ `CREATE TABLE IF NOT EXISTS _arckode_schema (table_name TEXT PRIMARY KEY, migratedAt TEXT)`
762
+ )
763
+
764
+ for (const [modelName, def] of this.models) {
765
+ // Validar nombres de tabla y campos antes de cualquier SQL
766
+ this.assertSafeIdentifier(def.table, `modelo "${modelName}" → table`)
767
+ for (const fieldName of Object.keys(def.fields)) {
768
+ this.assertSafeIdentifier(fieldName, `modelo "${modelName}" → campo`)
769
+ }
770
+
771
+ // 1. Crear tabla si no existe
772
+ const columns = Object.entries(def.fields).map(([name, field]) => {
773
+ const sqlType = this.fieldTypeToSQL(field.type)
774
+ const parts = [name, sqlType]
775
+
776
+ if (name === 'id') parts.push('PRIMARY KEY')
777
+ if (field.required) parts.push('NOT NULL')
778
+ if (field.unique) parts.push('UNIQUE')
779
+ if (field.default !== undefined) {
780
+ parts.push(`DEFAULT ${typeof field.default === 'string' ? `'${field.default}'` : field.default}`)
781
+ }
782
+
783
+ return parts.join(' ')
784
+ })
785
+
786
+ if (def.timestamps) {
787
+ columns.push('createdAt TEXT')
788
+ columns.push('updatedAt TEXT')
789
+ }
790
+
791
+ if (def.softDelete) {
792
+ columns.push('deletedAt TEXT')
793
+ }
794
+
795
+ await this.db.run(
796
+ `CREATE TABLE IF NOT EXISTS ${def.table} (${columns.join(', ')})`
797
+ )
798
+
799
+ // 2. Crear índices para campos con indexed: true
800
+ for (const [fieldName, field] of Object.entries(def.fields)) {
801
+ if (field.indexed && !field.unique) {
802
+ const idxName = `idx_${def.table}_${fieldName}`
803
+ try {
804
+ await this.db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${def.table}(${fieldName})`)
805
+ } catch { /* índice ya existe o adaptador no soporta */ }
806
+ }
807
+ }
808
+
809
+ // 3. Agregar columnas nuevas que no existen en la tabla
810
+ // Solución portable: intentar ALTER TABLE y silenciar el error "ya existe"
811
+ for (const [name, field] of Object.entries(def.fields)) {
812
+ const sqlType = this.fieldTypeToSQL(field.type)
813
+ try {
814
+ await this.db.run(`ALTER TABLE ${def.table} ADD COLUMN ${name} ${sqlType}`)
815
+ } catch {
816
+ // Error esperado: la columna ya existe — ignorar
817
+ }
818
+ }
819
+
820
+ // 4. Detectar drift de schema: columnas en la BD que ya no están en el modelo
821
+ const definedCols = new Set([
822
+ 'id',
823
+ ...Object.keys(def.fields),
824
+ ...(def.timestamps ? ['createdAt', 'updatedAt'] : []),
825
+ ...(def.softDelete ? ['deletedAt'] : []),
826
+ ])
827
+
828
+ const dbCols = await this.getTableColumns(def.table)
829
+
830
+ for (const col of dbCols) {
831
+ if (definedCols.has(col)) continue
832
+
833
+ // Intentar DROP COLUMN (SQLite ≥ 3.35.0 y Postgres lo soportan)
834
+ try {
835
+ await this.db.run(`ALTER TABLE ${def.table} DROP COLUMN ${col}`)
836
+ console.warn(
837
+ `[arckode/migrate] Column "${col}" dropped from "${def.table}" — no longer in model`
838
+ )
839
+ } catch {
840
+ console.warn(
841
+ `[arckode/migrate] ⚠ Orphan column "${col}" in table "${def.table}" — ` +
842
+ `not in model but could not be auto-dropped. ` +
843
+ `Create a manual migration: arckode make:migration drop_${col}_from_${def.table}`
844
+ )
845
+ }
846
+ }
847
+ }
848
+ }
849
+
850
+ // Introspección portable: SQLite usa PRAGMA, Postgres usa information_schema
851
+ private async getTableColumns(table: string): Promise<string[]> {
852
+ // SQLite
853
+ try {
854
+ const rows = await this.db.query(`PRAGMA table_info(${table})`)
855
+ if (Array.isArray(rows) && rows.length > 0 && 'name' in (rows[0] as object)) {
856
+ return (rows as { name: string }[]).map(r => r.name)
857
+ }
858
+ } catch { /* no es SQLite o PRAGMA no disponible */ }
859
+
860
+ // Postgres / estándar SQL
861
+ try {
862
+ const rows = await this.db.query(
863
+ `SELECT column_name FROM information_schema.columns WHERE table_name = '${table}'`
864
+ )
865
+ if (Array.isArray(rows) && rows.length > 0) {
866
+ return (rows as { column_name: string }[]).map(r => r.column_name)
867
+ }
868
+ } catch { /* adaptador no soporta introspección */ }
869
+
870
+ return []
871
+ }
872
+
873
+ private fieldTypeToSQL(type: FieldDefinition['type']): string {
874
+ const map: Record<FieldDefinition['type'], string> = {
875
+ string: 'TEXT',
876
+ number: 'REAL',
877
+ boolean: 'INTEGER',
878
+ json: 'TEXT',
879
+ date: 'TEXT',
880
+ }
881
+ return map[type]
882
+ }
883
+ }
884
+
885
+ // ── OrmRepository<T> — Adaptador SQL del ORM a RepositoryAdapter<T> ──
886
+ //
887
+ // Uso en composition-root.ts:
888
+ // const productoRepo = new OrmRepository<ProductoDTO>(orm, 'Producto')
889
+ // const service = new ProductoService(productoRepo) // service depende de RepositoryAdapter, no ORM
890
+ //
891
+ // Para reemplazar con MongoDB, Prisma, etc.:
892
+ // class MongoProductoRepo implements RepositoryAdapter<ProductoDTO> { ... }
893
+ // const productoRepo = new MongoProductoRepo(mongoCollection)
894
+ // // El service NO cambia.
895
+
896
+ export class OrmRepository<T extends object = Record<string, unknown>>
897
+ implements RepositoryAdapter<T> {
898
+ constructor(
899
+ private readonly orm: ORM,
900
+ private readonly modelName: string,
901
+ ) {}
902
+
903
+ findMany(filters?: Record<string, unknown>, options?: FindOptions): Promise<T[]> {
904
+ return this.orm.findMany(this.modelName, filters, options) as unknown as Promise<T[]>
905
+ }
906
+
907
+ findById(id: string, select?: string[]): Promise<T | null> {
908
+ return this.orm.findById(this.modelName, id, select) as unknown as Promise<T | null>
909
+ }
910
+
911
+ findOne(filters: Record<string, unknown>): Promise<T | null> {
912
+ return this.orm.findOne(this.modelName, filters) as unknown as Promise<T | null>
913
+ }
914
+
915
+ create(data: Omit<T, 'id'>): Promise<T> {
916
+ return this.orm.create(this.modelName, data as Record<string, unknown>) as unknown as Promise<T>
917
+ }
918
+
919
+ update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null> {
920
+ return this.orm.update(this.modelName, id, data as Record<string, unknown>) as unknown as Promise<T | null>
921
+ }
922
+
923
+ delete(id: string): Promise<boolean> {
924
+ return this.orm.delete(this.modelName, id)
925
+ }
926
+
927
+ count(filters?: Record<string, unknown>): Promise<number> {
928
+ return this.orm.count(this.modelName, filters)
929
+ }
930
+
931
+ paginate(
932
+ filters?: Record<string, unknown>,
933
+ options: FindOptions & { limit: number } = { limit: 20 },
934
+ ): Promise<PageResult<T>> {
935
+ return this.orm.paginate(this.modelName, filters, options) as unknown as Promise<PageResult<T>>
936
+ }
937
+ }
938
+
939
+ // ═══════════════════════════════════════════════════════════════
940
+ // 6. ROUTER + HTTP — Enrutamiento con middlewares y error handler
941
+ // ═══════════════════════════════════════════════════════════════
942
+
943
+ export interface HttpRequest {
944
+ id: string
945
+ method: string
946
+ path: string
947
+ params: Record<string, string>
948
+ query: Record<string, string>
949
+ headers: Record<string, string>
950
+ body: unknown
951
+ user?: { id: string; role: string }
952
+ }
953
+
954
+ export interface HttpResponse {
955
+ status: number
956
+ body?: unknown
957
+ headers?: Record<string, string>
958
+ /** SSE / streaming: async generator que emite chunks de texto plano */
959
+ stream?: AsyncGenerator<string>
960
+ }
961
+
962
+ /** Helper: crea una respuesta SSE a partir de un async generator.
963
+ * Cada valor yielded se envía como `data: <valor>\n\n`.
964
+ * Para eventos con tipo: yield `event: nombre\ndata: payload\n\n` */
965
+ export function sseResponse(
966
+ generator: AsyncGenerator<string>,
967
+ headers?: Record<string, string>,
968
+ ): HttpResponse {
969
+ return {
970
+ status: 200,
971
+ headers: {
972
+ 'Content-Type': 'text/event-stream',
973
+ 'Cache-Control': 'no-cache',
974
+ Connection: 'keep-alive',
975
+ ...headers,
976
+ },
977
+ stream: generator,
978
+ }
979
+ }
980
+
981
+ export type RouteHandler = (req: HttpRequest) => HttpResponse | Promise<HttpResponse>
982
+ export type MiddlewareHandler = (req: HttpRequest, next: () => Promise<HttpResponse>) => Promise<HttpResponse>
983
+
984
+ interface RouteEntry {
985
+ method: string
986
+ pattern: RegExp
987
+ paramNames: string[]
988
+ handler: RouteHandler
989
+ middlewares: MiddlewareHandler[]
990
+ }
991
+
992
+ export class Router {
993
+ private routes: RouteEntry[] = []
994
+ private globalMiddlewares: MiddlewareHandler[] = []
995
+ private logger?: Logger
996
+
997
+ setLogger(logger: Logger): void { this.logger = logger }
998
+
999
+ use(middleware: MiddlewareHandler): void {
1000
+ this.globalMiddlewares.push(middleware)
1001
+ }
1002
+
1003
+ private add(method: string, path: string, handler: RouteHandler, middlewares: MiddlewareHandler[] = []): void {
1004
+ const paramNames: string[] = []
1005
+ const parts = path.split('/').filter(Boolean)
1006
+ const regexParts = parts.map(part => {
1007
+ if (part.startsWith(':')) {
1008
+ const rawName = part.slice(1)
1009
+ if (rawName.endsWith('(*)')) {
1010
+ paramNames.push(rawName.slice(0, -3))
1011
+ return '(.*)'
1012
+ }
1013
+ paramNames.push(rawName)
1014
+ return '([^/]+)'
1015
+ }
1016
+ return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1017
+ })
1018
+
1019
+ this.routes.push({
1020
+ method,
1021
+ pattern: new RegExp(`^/${regexParts.join('/')}/?$`),
1022
+ paramNames,
1023
+ handler,
1024
+ middlewares,
1025
+ })
1026
+ }
1027
+
1028
+ get(path: string, handler: RouteHandler, mw?: MiddlewareHandler[]): void { this.add('GET', path, handler, mw) }
1029
+ post(path: string, handler: RouteHandler, mw?: MiddlewareHandler[]): void { this.add('POST', path, handler, mw) }
1030
+ put(path: string, handler: RouteHandler, mw?: MiddlewareHandler[]): void { this.add('PUT', path, handler, mw) }
1031
+ patch(path: string, handler: RouteHandler, mw?: MiddlewareHandler[]): void { this.add('PATCH', path, handler, mw) }
1032
+ delete(path: string, handler: RouteHandler, mw?: MiddlewareHandler[]): void { this.add('DELETE', path, handler, mw) }
1033
+ options(path: string, handler: RouteHandler, mw?: MiddlewareHandler[]): void { this.add('OPTIONS', path, handler, mw) }
1034
+
1035
+ async resolve(method: string, path: string, extras?: Partial<HttpRequest>): Promise<HttpResponse> {
1036
+ const reqId = crypto.randomUUID().slice(0, 8)
1037
+
1038
+ // Helper para construir HttpRequest y ejecutar middlewares
1039
+ const buildRequest = (route: RouteEntry, match: RegExpMatchArray): HttpRequest => {
1040
+ const params: Record<string, string> = {}
1041
+ route.paramNames.forEach((name, i) => { params[name] = decodeURIComponent(match[i + 1] ?? '') })
1042
+ return {
1043
+ id: reqId,
1044
+ method,
1045
+ path,
1046
+ params,
1047
+ query: extras?.query ?? {},
1048
+ headers: extras?.headers ?? {},
1049
+ body: extras?.body ?? null,
1050
+ user: extras?.user,
1051
+ }
1052
+ }
1053
+
1054
+ const runAll = async (req: HttpRequest, routeMiddlewares: MiddlewareHandler[], routeHandler: RouteHandler): Promise<HttpResponse> => {
1055
+ const allMiddlewares = [...this.globalMiddlewares, ...routeMiddlewares]
1056
+ let index = 0
1057
+ const next = async (): Promise<HttpResponse> => {
1058
+ if (index < allMiddlewares.length) {
1059
+ const mw = allMiddlewares[index++]!
1060
+ return mw(req, next)
1061
+ }
1062
+ return routeHandler(req)
1063
+ }
1064
+ try {
1065
+ return await next()
1066
+ } catch (error) {
1067
+ if (error instanceof ErrorContract) {
1068
+ return { status: error.httpStatus, body: error.toJSON() }
1069
+ }
1070
+ if (this.logger) {
1071
+ const stack = error instanceof Error ? error.stack : String(error)
1072
+ this.logger.error(`Unhandled error in ${method} ${path}`, { stack, requestId: reqId })
1073
+ }
1074
+ return { status: 500, body: { error: 'Error interno del servidor', code: 'INTERNAL_ERROR' } }
1075
+ }
1076
+ }
1077
+
1078
+ if (method === 'OPTIONS') {
1079
+ // CORS preflight: buscar cualquier ruta que matchee la path (ignorando método)
1080
+ let anyMatch: { route: RouteEntry; match: RegExpMatchArray } | null = null
1081
+ for (const route of this.routes) {
1082
+ const m = path.match(route.pattern)
1083
+ if (m) { anyMatch = { route, match: m }; break }
1084
+ }
1085
+ if (anyMatch) {
1086
+ const req = buildRequest(anyMatch.route, anyMatch.match)
1087
+ return runAll(req, [], () => Promise.resolve({ status: 204, body: null }))
1088
+ }
1089
+ }
1090
+
1091
+ for (const route of this.routes) {
1092
+ if (route.method !== method) continue
1093
+ const match = path.match(route.pattern)
1094
+ if (!match) continue
1095
+
1096
+ const req = buildRequest(route, match)
1097
+ return runAll(req, route.middlewares, route.handler)
1098
+ }
1099
+
1100
+ return { status: 404, body: { error: 'Route not found', code: 'NOT_FOUND' } }
1101
+
1102
+ }
1103
+ }
1104
+
1105
+ // ═══════════════════════════════════════════════════════════════
1106
+ // 7. HTTP SERVER — Adapter de Node HTTP
1107
+ // ═══════════════════════════════════════════════════════════════
1108
+
1109
+ import { createServer as createNodeServer, IncomingMessage, ServerResponse } from 'node:http'
1110
+ import { AsyncLocalStorage } from 'node:async_hooks'
1111
+
1112
+ // ── Request Context — propagación transparente del requestId ──
1113
+ // Accesible desde cualquier punto del call-stack async sin pasar props manualmente.
1114
+ // Útil en services, adapters externos, logs, y tracing distribuido.
1115
+ export const requestStorage = new AsyncLocalStorage<{ requestId: string; startTime: number }>()
1116
+
1117
+ /** Retorna el requestId del request activo o undefined si no hay contexto HTTP activo */
1118
+ export function getRequestId(): string | undefined {
1119
+ return requestStorage.getStore()?.requestId
1120
+ }
1121
+
1122
+ /** Retorna el tiempo transcurrido (ms) desde que inició el request activo */
1123
+ export function getRequestElapsed(): number | undefined {
1124
+ const store = requestStorage.getStore()
1125
+ return store ? Date.now() - store.startTime : undefined
1126
+ }
1127
+
1128
+ export interface ServerAdapter {
1129
+ start(handler: (req: HttpRequest) => Promise<HttpResponse>): Promise<void>
1130
+ stop(): Promise<void>
1131
+ }
1132
+
1133
+ export class NodeServer implements ServerAdapter {
1134
+ private server?: ReturnType<typeof createNodeServer>
1135
+ private maxBodyBytes: number
1136
+ private drainTimeoutMs: number
1137
+ private activeRequests = 0
1138
+
1139
+ constructor(
1140
+ private port: number,
1141
+ private logger: Logger,
1142
+ opts: { maxBodyBytes?: number; drainTimeoutMs?: number } = {},
1143
+ ) {
1144
+ this.maxBodyBytes = opts.maxBodyBytes ?? 10 * 1024 * 1024 // 10MB default
1145
+ this.drainTimeoutMs = opts.drainTimeoutMs ?? 30_000
1146
+ }
1147
+
1148
+ async start(handler: (req: HttpRequest) => Promise<HttpResponse>): Promise<void> {
1149
+ return new Promise((resolve) => {
1150
+ this.server = createNodeServer(async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => {
1151
+ this.activeRequests++
1152
+ const requestId = crypto.randomUUID().slice(0, 8)
1153
+
1154
+ await requestStorage.run({ requestId, startTime: Date.now() }, async () => {
1155
+ try {
1156
+ const body = await this.readBody(nodeReq, this.maxBodyBytes)
1157
+
1158
+ const url = new URL(nodeReq.url ?? '/', `http://${nodeReq.headers.host ?? 'localhost'}`)
1159
+ const query: Record<string, string> = {}
1160
+ url.searchParams.forEach((value, key) => { query[key] = value })
1161
+
1162
+ const req: HttpRequest = {
1163
+ id: requestId,
1164
+ method: nodeReq.method ?? 'GET',
1165
+ path: url.pathname,
1166
+ params: {},
1167
+ query,
1168
+ headers: nodeReq.headers as Record<string, string>,
1169
+ body,
1170
+ }
1171
+
1172
+ const res = await handler(req)
1173
+
1174
+ // ── Streaming / SSE ──────────────────────────────
1175
+ if (res.stream) {
1176
+ nodeRes.writeHead(res.status, {
1177
+ 'Content-Type': 'text/event-stream',
1178
+ 'Cache-Control': 'no-cache',
1179
+ Connection: 'keep-alive',
1180
+ 'X-Request-Id': req.id,
1181
+ ...res.headers,
1182
+ })
1183
+ try {
1184
+ for await (const chunk of res.stream) {
1185
+ nodeRes.write(`data: ${chunk}\n\n`)
1186
+ }
1187
+ } finally {
1188
+ nodeRes.end()
1189
+ }
1190
+ return
1191
+ }
1192
+
1193
+ // ── Respuesta normal (JSON o binario comprimido) ──
1194
+ const isBuffer = Buffer.isBuffer(res.body)
1195
+ const responseBody = isBuffer ? res.body : JSON.stringify(res.body)
1196
+ nodeRes.writeHead(res.status, {
1197
+ 'Content-Type': 'application/json',
1198
+ 'X-Request-Id': req.id,
1199
+ ...res.headers,
1200
+ })
1201
+ nodeRes.end(responseBody)
1202
+ } catch (error) {
1203
+ const httpStatus = (error as any)?.httpStatus
1204
+ if (httpStatus) {
1205
+ nodeRes.writeHead(httpStatus, { 'Content-Type': 'application/json' })
1206
+ nodeRes.end(JSON.stringify({ error: (error as Error).message, code: 'REQUEST_ERROR' }))
1207
+ return
1208
+ }
1209
+ const stack = error instanceof Error ? error.stack : String(error)
1210
+ this.logger.error('Error no manejado en HTTP', { error: String(error), stack })
1211
+ nodeRes.writeHead(500, { 'Content-Type': 'application/json' })
1212
+ nodeRes.end(JSON.stringify({ error: 'Error interno', code: 'INTERNAL_ERROR' }))
1213
+ } finally {
1214
+ this.activeRequests--
1215
+ }
1216
+ })
1217
+ })
1218
+
1219
+ this.server.listen(this.port, () => {
1220
+ this.logger.info(`Servidor HTTP escuchando en :${this.port}`)
1221
+ resolve()
1222
+ })
1223
+ })
1224
+ }
1225
+
1226
+ async stop(): Promise<void> {
1227
+ return new Promise((resolve) => {
1228
+ let resolved = false
1229
+ const done = () => { if (!resolved) { resolved = true; resolve() } }
1230
+
1231
+ // Deja de aceptar conexiones nuevas; resuelve cuando todos los sockets cierran
1232
+ this.server?.close(done)
1233
+
1234
+ // Espera que los requests en vuelo terminen (con deadline)
1235
+ const deadline = Date.now() + this.drainTimeoutMs
1236
+ const poll = setInterval(() => {
1237
+ if (this.activeRequests === 0 || Date.now() >= deadline) {
1238
+ clearInterval(poll)
1239
+ if (this.activeRequests > 0) {
1240
+ this.logger.warn(`Graceful shutdown: ${this.activeRequests} request(s) sin terminar, forzando cierre`)
1241
+ }
1242
+ done()
1243
+ }
1244
+ }, 50)
1245
+ })
1246
+ }
1247
+
1248
+ /** Retorna el puerto real asignado (útil cuando port=0 en tests) */
1249
+ getPort(): number {
1250
+ const addr = this.server?.address()
1251
+ return typeof addr === 'object' && addr ? addr.port : this.port
1252
+ }
1253
+
1254
+ private readBody(req: IncomingMessage, maxBytes = 10 * 1024 * 1024): Promise<unknown> {
1255
+ return new Promise((resolve, reject) => {
1256
+ const chunks: Buffer[] = []
1257
+ let total = 0
1258
+
1259
+ req.on('data', (chunk: Buffer) => {
1260
+ total += chunk.length
1261
+ if (total > maxBytes) {
1262
+ req.destroy()
1263
+ reject(Object.assign(new Error('Payload Too Large'), { httpStatus: 413 }))
1264
+ return
1265
+ }
1266
+ chunks.push(chunk)
1267
+ })
1268
+
1269
+ req.on('end', () => {
1270
+ const raw = Buffer.concat(chunks).toString()
1271
+ if (!raw) return resolve(null)
1272
+ try { resolve(JSON.parse(raw)) } catch { resolve(raw) }
1273
+ })
1274
+
1275
+ req.on('error', reject)
1276
+ })
1277
+ }
1278
+ }
1279
+
1280
+ // ═══════════════════════════════════════════════════════════════
1281
+ // 8. VALIDADOR — Schemas planos, sin dependencias
1282
+ // ═══════════════════════════════════════════════════════════════
1283
+
1284
+ export type ValidatorType = 'string' | 'number' | 'boolean' | 'email' | 'url' | 'date'
1285
+
1286
+ export interface ValidationRule {
1287
+ type: ValidatorType
1288
+ required?: boolean
1289
+ min?: number
1290
+ max?: number
1291
+ pattern?: RegExp
1292
+ enum?: string[]
1293
+ message?: string
1294
+ /** true = trim + colapsar espacios. 'html' = trim + escapar caracteres HTML peligrosos */
1295
+ sanitize?: boolean | 'html'
1296
+ }
1297
+
1298
+ export type ValidationSchema = Record<string, ValidationRule>
1299
+
1300
+ export function validateSchema(schema: ValidationSchema, input: unknown): Record<string, unknown> {
1301
+ const errors: Record<string, string[]> = {}
1302
+ const output: Record<string, unknown> = {}
1303
+
1304
+ if (typeof input !== 'object' || input === null) {
1305
+ throw new ValidationError('Request body must be an object')
1306
+ }
1307
+
1308
+ const data = input as Record<string, unknown>
1309
+
1310
+ for (const [field, rule] of Object.entries(schema)) {
1311
+ const value = data[field]
1312
+ const fieldErrors: string[] = []
1313
+
1314
+ if (value === undefined || value === null) {
1315
+ if (rule.required) {
1316
+ fieldErrors.push(rule.message ?? `${field} is required`)
1317
+ errors[field] = fieldErrors
1318
+ }
1319
+ continue
1320
+ }
1321
+
1322
+ switch (rule.type) {
1323
+ case 'string': {
1324
+ if (typeof value !== 'string') {
1325
+ fieldErrors.push(`${field} must be a string`)
1326
+ } else {
1327
+ let sanitized = value.trim().replace(/\s+/g, ' ')
1328
+ if (rule.sanitize === 'html') {
1329
+ sanitized = sanitized
1330
+ .replace(/&/g, '&amp;')
1331
+ .replace(/</g, '&lt;')
1332
+ .replace(/>/g, '&gt;')
1333
+ .replace(/"/g, '&quot;')
1334
+ .replace(/'/g, '&#x27;')
1335
+ }
1336
+ if (rule.min !== undefined && sanitized.length < rule.min) fieldErrors.push(`Minimum ${rule.min} characters`)
1337
+ if (rule.max !== undefined && sanitized.length > rule.max) fieldErrors.push(`Maximum ${rule.max} characters`)
1338
+ if (rule.pattern && !rule.pattern.test(sanitized)) fieldErrors.push(rule.message ?? `Invalid format`)
1339
+ if (rule.enum && !rule.enum.includes(sanitized)) fieldErrors.push(`Must be one of: ${rule.enum.join(', ')}`)
1340
+ output[field] = sanitized
1341
+ }
1342
+ break
1343
+ }
1344
+ case 'number': {
1345
+ const num = typeof value === 'string' ? Number(value) : value
1346
+ if (typeof num !== 'number' || isNaN(num)) {
1347
+ fieldErrors.push(`${field} must be a number`)
1348
+ } else {
1349
+ if (rule.min !== undefined && num < rule.min) fieldErrors.push(`Minimum ${rule.min}`)
1350
+ if (rule.max !== undefined && num > rule.max) fieldErrors.push(`Maximum ${rule.max}`)
1351
+ output[field] = num
1352
+ }
1353
+ break
1354
+ }
1355
+ case 'boolean': {
1356
+ if (typeof value !== 'boolean') {
1357
+ fieldErrors.push(`${field} must be a boolean`)
1358
+ } else {
1359
+ output[field] = value
1360
+ }
1361
+ break
1362
+ }
1363
+ case 'email': {
1364
+ if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
1365
+ fieldErrors.push(rule.message ?? `${field} is not a valid email`)
1366
+ } else {
1367
+ output[field] = value
1368
+ }
1369
+ break
1370
+ }
1371
+ case 'url': {
1372
+ if (typeof value !== 'string') { fieldErrors.push(`${field} must be a string`) }
1373
+ else {
1374
+ try { new URL(value); output[field] = value } catch { fieldErrors.push(rule.message ?? `${field} is not a valid URL`) }
1375
+ }
1376
+ break
1377
+ }
1378
+ case 'date': {
1379
+ if (typeof value !== 'string' || isNaN(Date.parse(value))) {
1380
+ fieldErrors.push(rule.message ?? `${field} is not a valid date`)
1381
+ } else {
1382
+ output[field] = value
1383
+ }
1384
+ break
1385
+ }
1386
+ }
1387
+
1388
+ if (fieldErrors.length > 0) {
1389
+ errors[field] = fieldErrors
1390
+ }
1391
+ }
1392
+
1393
+ if (Object.keys(errors).length > 0) {
1394
+ throw new ValidationError('Validation error', errors)
1395
+ }
1396
+
1397
+ return output
1398
+ }
1399
+
1400
+ // ═══════════════════════════════════════════════════════════════
1401
+ // 9. AUTH — JWT con adapter, middleware de roles
1402
+ // ═══════════════════════════════════════════════════════════════
1403
+
1404
+ export interface JwtAdapter {
1405
+ sign(payload: Record<string, unknown>, secret: string, expiresIn: string): string
1406
+ verify(token: string, secret: string): Record<string, unknown>
1407
+ }
1408
+
1409
+ export class Auth {
1410
+ private readonly refreshSecret: string
1411
+
1412
+ constructor(
1413
+ private jwt: JwtAdapter,
1414
+ private secret: string,
1415
+ private logger: Logger,
1416
+ private expiresIn: string = '24h',
1417
+ private refreshExpiresIn: string = '30d',
1418
+ ) {
1419
+ // Refresh secret derivado: distinto al access secret para no reutilizar tokens
1420
+ this.refreshSecret = secret + '__refresh__'
1421
+ }
1422
+
1423
+ createToken(payload: { id: string; role: string }, expiresIn?: string): string {
1424
+ const ttl = expiresIn ?? this.expiresIn
1425
+ const token = this.jwt.sign({ id: payload.id, role: payload.role, type: 'access' }, this.secret, ttl)
1426
+ this.logger.debug('Token creado', { userId: payload.id, expiresIn: ttl })
1427
+ return token
1428
+ }
1429
+
1430
+ createRefreshToken(payload: { id: string; role: string }): string {
1431
+ const token = this.jwt.sign(
1432
+ { id: payload.id, role: payload.role, type: 'refresh' },
1433
+ this.refreshSecret,
1434
+ this.refreshExpiresIn,
1435
+ )
1436
+ this.logger.debug('Refresh token creado', { userId: payload.id })
1437
+ return token
1438
+ }
1439
+
1440
+ /** Verifica un refresh token y devuelve un nuevo access token + nuevo refresh token */
1441
+ refresh(refreshToken: string): { accessToken: string; refreshToken: string } {
1442
+ try {
1443
+ const payload = this.jwt.verify(refreshToken, this.refreshSecret)
1444
+ if (payload.type !== 'refresh') throw new AuthError('Invalid token type')
1445
+ const user = { id: payload.id as string, role: payload.role as string }
1446
+ return {
1447
+ accessToken: this.createToken(user),
1448
+ refreshToken: this.createRefreshToken(user),
1449
+ }
1450
+ } catch (e) {
1451
+ if (e instanceof AuthError) throw e
1452
+ throw new AuthError('Invalid or expired refresh token')
1453
+ }
1454
+ }
1455
+
1456
+ verifyToken(token: string): { id: string; role: string } {
1457
+ try {
1458
+ const payload = this.jwt.verify(token, this.secret)
1459
+ // Un refresh token NO puede usarse como access token
1460
+ if (payload.type === 'refresh') throw new AuthError('Invalid token type')
1461
+ return { id: payload.id as string, role: payload.role as string }
1462
+ } catch (e) {
1463
+ if (e instanceof AuthError) throw e
1464
+ throw new AuthError('Invalid or expired token')
1465
+ }
1466
+ }
1467
+
1468
+ authenticate(...allowedRoles: string[]): MiddlewareHandler {
1469
+ return async (req, next) => {
1470
+ const header = req.headers['authorization']
1471
+ if (!header?.startsWith('Bearer ')) {
1472
+ throw new AuthError('Authentication token required')
1473
+ }
1474
+
1475
+ const user = this.verifyToken(header.slice(7))
1476
+
1477
+ if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) {
1478
+ throw new ForbiddenError(`Required role: ${allowedRoles.join(' or ')}`)
1479
+ }
1480
+
1481
+ req.user = user
1482
+ return next()
1483
+ }
1484
+ }
1485
+
1486
+ /**
1487
+ * Verifica que el usuario autenticado sea el dueño del recurso.
1488
+ * Previene IDOR (Insecure Direct Object Reference).
1489
+ * Los admins (adminRole) pueden acceder a cualquier recurso.
1490
+ *
1491
+ * @example
1492
+ * const pedido = await orm.findById('Pedido', id)
1493
+ * if (!pedido) throw new NotFoundError('Pedido no encontrado')
1494
+ * auth.assertOwnership(pedido.userId as string, req.user!.id, req.user!.role)
1495
+ * // → solo el dueño o un admin puede continuar
1496
+ */
1497
+ assertOwnership(
1498
+ resourceOwnerId: string,
1499
+ requestingUserId: string,
1500
+ requestingUserRole?: string,
1501
+ adminRole = 'admin',
1502
+ ): void {
1503
+ if (requestingUserId === resourceOwnerId) return
1504
+ if (requestingUserRole === adminRole) return
1505
+ throw new ForbiddenError('Forbidden: resource belongs to another user')
1506
+ }
1507
+
1508
+ /**
1509
+ * Hashea una contraseña con scrypt + salt aleatorio.
1510
+ * Usa node:crypto — cero dependencias externas.
1511
+ * Resultado: "salt:hash" (ambos en hex).
1512
+ *
1513
+ * @example
1514
+ * const hash = await auth.hashPassword('miPassword123')
1515
+ * // Guardar hash en DB, nunca el password plano
1516
+ */
1517
+ async hashPassword(password: string): Promise<string> {
1518
+ const { scrypt, randomBytes } = await import('node:crypto')
1519
+ const salt = randomBytes(16).toString('hex')
1520
+ const hash = await new Promise<Buffer>((resolve, reject) =>
1521
+ scrypt(password, salt, 64, (err, key) => (err ? reject(err) : resolve(key))),
1522
+ )
1523
+ return `${salt}:${hash.toString('hex')}`
1524
+ }
1525
+
1526
+ /**
1527
+ * Compara un password en plano contra un hash generado por hashPassword().
1528
+ * Usa timingSafeEqual para prevenir timing attacks.
1529
+ *
1530
+ * @example
1531
+ * const ok = await auth.comparePassword('miPassword123', usuario.passwordHash)
1532
+ * if (!ok) throw new AuthError('Invalid credentials')
1533
+ */
1534
+ async comparePassword(password: string, stored: string): Promise<boolean> {
1535
+ const { scrypt, timingSafeEqual } = await import('node:crypto')
1536
+ const [salt, hash] = stored.split(':')
1537
+ if (!salt || !hash) return false
1538
+ const storedBuf = Buffer.from(hash, 'hex')
1539
+ const attempt = await new Promise<Buffer>((resolve, reject) =>
1540
+ scrypt(password, salt, 64, (err, key) => (err ? reject(err) : resolve(key))),
1541
+ )
1542
+ return timingSafeEqual(storedBuf, attempt)
1543
+ }
1544
+ }
1545
+
1546
+ // ═══════════════════════════════════════════════════════════════
1547
+ // 10. CACHE — En memoria, interfaz para reemplazar por Redis
1548
+ // ═══════════════════════════════════════════════════════════════
1549
+
1550
+ export interface CacheAdapter {
1551
+ get<T>(key: string): Promise<T | null>
1552
+ set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>
1553
+ delete(key: string): Promise<void>
1554
+ flush(): Promise<void>
1555
+ }
1556
+
1557
+ export class MemoryCache implements CacheAdapter {
1558
+ private store = new Map<string, { value: unknown; expiresAt: number }>()
1559
+ private hits = 0
1560
+ private misses = 0
1561
+
1562
+ async get<T>(key: string): Promise<T | null> {
1563
+ const entry = this.store.get(key)
1564
+ if (!entry) { this.misses++; return null }
1565
+ if (Date.now() > entry.expiresAt) { this.store.delete(key); this.misses++; return null }
1566
+ this.hits++
1567
+ return entry.value as T
1568
+ }
1569
+
1570
+ async set<T>(key: string, value: T, ttlSeconds = 3600): Promise<void> {
1571
+ this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 })
1572
+ }
1573
+
1574
+ async delete(key: string): Promise<void> { this.store.delete(key) }
1575
+ async flush(): Promise<void> { this.store.clear() }
1576
+
1577
+ get stats() {
1578
+ const total = this.hits + this.misses
1579
+ return { size: this.store.size, hits: this.hits, misses: this.misses, hitRate: total > 0 ? `${((this.hits / total) * 100).toFixed(1)}%` : '0%' }
1580
+ }
1581
+ }
1582
+
1583
+ // ═══════════════════════════════════════════════════════════════
1584
+ // 11. SISTEMA DE MÓDULOS — El corazón de la arquitectura
1585
+ // ═══════════════════════════════════════════════════════════════
1586
+
1587
+ // ── Contrato que un módulo EXPONE hacia afuera ──
1588
+ export interface ModuleContract {
1589
+ name: string
1590
+ version: string
1591
+ description: string
1592
+ actions: string[]
1593
+ events: string[]
1594
+ tables: string[]
1595
+ dependencies: string[]
1596
+ rules: string[]
1597
+ }
1598
+
1599
+ // ── Dependencias que un módulo RECIBE ──
1600
+ export interface ModuleDependencies {
1601
+ logger: Logger
1602
+ orm: ORM
1603
+ router: Router
1604
+ config: ConfigStore
1605
+ cache: CacheAdapter
1606
+ auth?: Auth
1607
+ }
1608
+
1609
+ // ── Interfaz opcional para módulos que aceptan sockets post-init ──
1610
+ export interface SocketsAware {
1611
+ setSockets(sockets: Record<string, unknown>): void
1612
+ }
1613
+
1614
+ // ── Definición de un módulo ──
1615
+ export interface ModuleDefinition<TModule> {
1616
+ name: string
1617
+ version: string
1618
+ description: string
1619
+ contract: ModuleContract
1620
+ create(deps: ModuleDependencies): TModule
1621
+ validate?(instance: TModule): void
1622
+ /** Hook de cierre: se llama en system.stop(). Cerrar conexiones, detener timers, etc. */
1623
+ onStop?(instance: TModule): Promise<void>
1624
+ }
1625
+
1626
+ // ── Crea un módulo con validación de contrato ──
1627
+ export function createModule<T>(def: ModuleDefinition<T>): ModuleDefinition<T> {
1628
+ return def
1629
+ }
1630
+
1631
+ // ── Tipos para conectores ──
1632
+ export interface ConnectorContext {
1633
+ resolveModule<T>(name: string, sockets?: Record<string, unknown>): T
1634
+ /** Los sockets se asignan al módulo ANTES de su inicialización.
1635
+ * Internamente el módulo recibe los sockets vía ModuleDependencies.
1636
+ * Para acceder a sockets después de init, el módulo debe aceptarlos
1637
+ * en su factory (create) y guardarlos internamente. */
1638
+ }
1639
+
1640
+ export type ConnectorDefinition = (ctx: ConnectorContext) => void | Promise<void>
1641
+
1642
+ // ── SISTEMA ──
1643
+ export class System {
1644
+ public readonly logger: Logger
1645
+ public readonly config: ConfigStore
1646
+ public readonly container: Container
1647
+ public readonly orm: ORM
1648
+ public readonly router: Router
1649
+ public readonly http: ServerAdapter
1650
+ public readonly cache: CacheAdapter
1651
+ public readonly auth?: Auth
1652
+
1653
+ private modules = new Map<string, { definition: ModuleDefinition<unknown>; instance: unknown; sockets?: Record<string, unknown> }>()
1654
+ private connectors: { name: string; fn: ConnectorDefinition }[] = []
1655
+ private pendingSockets = new Map<string, Record<string, unknown>>()
1656
+ private initialized = false
1657
+
1658
+ constructor(params: {
1659
+ config: ConfigStore
1660
+ container: Container
1661
+ logger: Logger
1662
+ orm: ORM
1663
+ router: Router
1664
+ http: ServerAdapter
1665
+ cache: CacheAdapter
1666
+ auth?: Auth
1667
+ }) {
1668
+ this.config = params.config
1669
+ this.container = params.container
1670
+ this.logger = params.logger
1671
+ this.orm = params.orm
1672
+ this.router = params.router
1673
+ this.http = params.http
1674
+ this.cache = params.cache
1675
+ this.auth = params.auth
1676
+ }
1677
+
1678
+ addModule<T>(definition: ModuleDefinition<T>): this {
1679
+ if (this.initialized) throw new InternalError(`Cannot add module "${definition.name}" after initialization`)
1680
+ this.modules.set(definition.name, { definition, instance: undefined })
1681
+ return this
1682
+ }
1683
+
1684
+ addConnector(name: string, fn: ConnectorDefinition): this {
1685
+ this.connectors.push({ name, fn })
1686
+ return this
1687
+ }
1688
+
1689
+ resolveModule<T>(name: string): T {
1690
+ const entry = this.modules.get(name)
1691
+ if (!entry?.instance) throw new NotFoundError(`Module "${name}" not found or not initialized`)
1692
+ return entry.instance as T
1693
+ }
1694
+
1695
+ getModule<T>(name: string): T {
1696
+ return this.resolveModule<T>(name)
1697
+ }
1698
+
1699
+ init(): void {
1700
+ if (this.initialized) return
1701
+ this.initialized = true
1702
+
1703
+ // 1. Inicializar container
1704
+ this.container.init()
1705
+
1706
+ // 2. Migrar base de datos
1707
+ // La migración se hace explícitamente desde composition-root
1708
+
1709
+ // 3. Inicializar módulos
1710
+ for (const [name, entry] of this.modules) {
1711
+ const sockets = this.pendingSockets.get(name)
1712
+ const deps: ModuleDependencies = {
1713
+ logger: this.logger.child(name),
1714
+ orm: this.orm,
1715
+ router: this.router,
1716
+ config: this.config,
1717
+ cache: this.cache,
1718
+ auth: this.auth,
1719
+ }
1720
+
1721
+ entry.instance = entry.definition.create(deps)
1722
+ this.logger.info(`Module initialized: ${name} v${entry.definition.version}`)
1723
+ }
1724
+
1725
+ // 4. Ejecutar conectores
1726
+ for (const connector of this.connectors) {
1727
+ const ctx: ConnectorContext = {
1728
+ resolveModule: <T>(name: string, sockets?: Record<string, unknown>): T => {
1729
+ const instance = this.resolveModule<Record<string, unknown>>(name)
1730
+ if (sockets && typeof instance === 'object' && instance) {
1731
+ if ('setSockets' in instance && typeof (instance as unknown as SocketsAware).setSockets === 'function') {
1732
+ ;(instance as unknown as SocketsAware).setSockets(sockets)
1733
+ }
1734
+ }
1735
+ return instance as unknown as T
1736
+ },
1737
+ }
1738
+
1739
+ connector.fn(ctx)
1740
+ this.logger.info(`Connector executed: ${connector.name}`)
1741
+ }
1742
+ }
1743
+
1744
+ async start(): Promise<void> {
1745
+ this.router.setLogger(this.logger)
1746
+ this.init()
1747
+ await this.http.start((req) => this.router.resolve(req.method, req.path, req))
1748
+ this.logger.info('══════════════════════════════════')
1749
+ this.logger.info(' System started successfully')
1750
+ this.logger.info(` Modules: ${[...this.modules.keys()].join(', ')}`)
1751
+ this.logger.info(` Connectors: ${this.connectors.map(c => c.name).join(', ')}`)
1752
+ this.logger.info('══════════════════════════════════')
1753
+ }
1754
+
1755
+ async stop(): Promise<void> {
1756
+ this.logger.info('Shutting down system...')
1757
+
1758
+ for (const [name, entry] of this.modules) {
1759
+ if (entry.definition.onStop && entry.instance) {
1760
+ try {
1761
+ await entry.definition.onStop(entry.instance)
1762
+ this.logger.info(`Module stopped: ${name}`)
1763
+ } catch (e) {
1764
+ this.logger.error(`Error stopping module "${name}"`, { error: String(e) })
1765
+ }
1766
+ }
1767
+ }
1768
+
1769
+ await this.http.stop()
1770
+ await this.container.destroy()
1771
+ this.logger.info('System stopped')
1772
+ }
1773
+ }
1774
+
1775
+ // ═══════════════════════════════════════════════════════════════
1776
+ // 12. SEED SYSTEM — Poblar base de datos con datos de prueba
1777
+ // ═══════════════════════════════════════════════════════════════
1778
+
1779
+ export type SeedFunction = (orm: ORM) => Promise<void>
1780
+
1781
+ export class SeedRunner {
1782
+ private seeds: { name: string; run: SeedFunction }[] = []
1783
+
1784
+ constructor(
1785
+ private orm: ORM,
1786
+ private logger: Logger,
1787
+ ) {}
1788
+
1789
+ add(name: string, run: SeedFunction): this {
1790
+ this.seeds.push({ name, run })
1791
+ return this
1792
+ }
1793
+
1794
+ async runAll(): Promise<void> {
1795
+ for (const seed of this.seeds) {
1796
+ try {
1797
+ await seed.run(this.orm)
1798
+ this.logger.info(`Seed executed: ${seed.name}`)
1799
+ } catch (error) {
1800
+ this.logger.error(`Error in seed "${seed.name}"`, { error: String(error) })
1801
+ throw error
1802
+ }
1803
+ }
1804
+ }
1805
+
1806
+ async runOne(name: string): Promise<void> {
1807
+ const seed = this.seeds.find(s => s.name === name)
1808
+ if (!seed) throw new NotFoundError(`Seed "${name}" not found`)
1809
+
1810
+ try {
1811
+ await seed.run(this.orm)
1812
+ this.logger.info(`Seed executed: ${name}`)
1813
+ } catch (error) {
1814
+ this.logger.error(`Error in seed "${name}"`, { error: String(error) })
1815
+ throw error
1816
+ }
1817
+ }
1818
+
1819
+ get list(): string[] {
1820
+ return this.seeds.map(s => s.name)
1821
+ }
1822
+ }
1823
+
1824
+ // ═══════════════════════════════════════════════════════════════
1825
+ // EXPORT
1826
+ // ═══════════════════════════════════════════════════════════════
1827
+
1828
+ export default {
1829
+ // Errors
1830
+ ErrorContract, ValidationError, AuthError, ForbiddenError,
1831
+ NotFoundError, ConflictError, RateLimitError, RepositoryError,
1832
+ InternalError, ModuleRuleError,
1833
+
1834
+ // Infrastructure
1835
+ Logger, ConsoleTransport,
1836
+ ConfigStore, loadEnv,
1837
+ Container,
1838
+ ORM, OrmRepository,
1839
+ Router, NodeServer,
1840
+ validateSchema,
1841
+ Auth, MemoryCache,
1842
+
1843
+ // Module System
1844
+ createModule, System,
1845
+
1846
+ // Seeds
1847
+ SeedRunner,
1848
+
1849
+ // Types (re-exported for convenience)
1850
+ // All interfaces are exported above
1851
+ }