arckode-framework 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/adapters/jwt.ts +6 -4
  2. package/adapters/mysql.ts +7 -2
  3. package/adapters/postgres.ts +37 -0
  4. package/adapters/sqlite.ts +7 -1
  5. package/adapters/vendor.d.ts +48 -0
  6. package/cli/analyze/checks.ts +333 -0
  7. package/cli/analyze/index.ts +44 -0
  8. package/cli/analyze/report.ts +107 -0
  9. package/cli/analyze/types.ts +46 -0
  10. package/cli/analyze/utils.ts +36 -0
  11. package/cli/analyze.ts +2 -647
  12. package/cli/commands/db-migrate.ts +213 -89
  13. package/cli/commands/db-seed.ts +97 -32
  14. package/cli/commands/db-utils.ts +192 -0
  15. package/cli/commands/new.ts +175 -0
  16. package/cli/commands/routes.ts +94 -0
  17. package/cli/index.ts +57 -404
  18. package/cli/stubs/module/core.ts +162 -0
  19. package/cli/stubs/module/data.ts +171 -0
  20. package/cli/stubs/module/index.ts +5 -0
  21. package/cli/stubs/module/service.ts +198 -0
  22. package/cli/stubs/module/types.ts +12 -0
  23. package/cli/stubs/module-stub.ts +2 -552
  24. package/kernel/auth.ts +114 -0
  25. package/kernel/cache.ts +37 -0
  26. package/kernel/config.ts +129 -0
  27. package/kernel/container.ts +64 -0
  28. package/kernel/db/orm-migrate.ts +136 -0
  29. package/kernel/db/orm-repository.ts +45 -0
  30. package/kernel/db/orm-utils.ts +93 -0
  31. package/kernel/db/orm.ts +254 -0
  32. package/kernel/db/transactor.ts +17 -0
  33. package/kernel/db/types.ts +72 -0
  34. package/kernel/errors.ts +102 -0
  35. package/kernel/framework.default.ts +41 -0
  36. package/kernel/framework.ts +8 -2144
  37. package/kernel/http/router.ts +131 -0
  38. package/kernel/http/server.ts +303 -0
  39. package/kernel/http/types.ts +56 -0
  40. package/kernel/index.ts +25 -0
  41. package/kernel/logger.ts +50 -0
  42. package/kernel/middlewares.ts +19 -7
  43. package/kernel/modules/create-module.ts +5 -0
  44. package/kernel/modules/system.ts +149 -0
  45. package/kernel/modules/types.ts +46 -0
  46. package/kernel/seeds.ts +48 -0
  47. package/kernel/static.ts +11 -2
  48. package/kernel/testing.ts +8 -3
  49. package/kernel/validator.ts +116 -0
  50. package/modules/events/index.ts +19 -3
  51. package/modules/mail/index.ts +14 -2
  52. package/modules/storage/local-adapter.ts +19 -5
  53. package/modules/ws/index.ts +123 -18
  54. package/package.json +8 -11
  55. package/skills/auth/SKILL.md +36 -220
  56. package/skills/cli/SKILL.md +32 -251
  57. package/skills/config/SKILL.md +30 -239
  58. package/skills/connectors/SKILL.md +32 -295
  59. package/skills/helpers/SKILL.md +26 -195
  60. package/skills/middlewares/SKILL.md +30 -280
  61. package/skills/orm/SKILL.md +42 -349
  62. package/skills/realtime/SKILL.md +22 -297
  63. package/skills/services/SKILL.md +40 -183
  64. package/skills/testing/SKILL.md +34 -266
@@ -1,2144 +1,8 @@
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' | 'text' | 'number' | 'boolean' | 'json' | 'date'
390
- required?: boolean
391
- nullable?: boolean // alias semántico de !required — campo puede ser NULL
392
- default?: unknown
393
- unique?: boolean
394
- indexed?: boolean
395
- maxLength?: number
396
- }
397
-
398
- // ── Definición de modelo ──
399
- export interface ModelDefinition {
400
- table: string
401
- fields: Record<string, FieldDefinition>
402
- timestamps?: boolean
403
- softDelete?: boolean
404
- }
405
-
406
- // ── Resultado de consulta ──
407
- export interface ModelResult {
408
- id: string
409
- [key: string]: unknown
410
- }
411
-
412
- // ── Opciones de consulta ──
413
- export interface OrderByClause {
414
- field: string
415
- dir?: 'ASC' | 'DESC'
416
- }
417
-
418
- export interface FindOptions {
419
- limit?: number
420
- offset?: number
421
- orderBy?: OrderByClause | OrderByClause[]
422
- /** Campos a seleccionar. Si se omite: SELECT *. Los nombres se validan contra el modelo. */
423
- select?: string[]
424
- }
425
-
426
- export interface PageResult<T = ModelResult> {
427
- data: T[]
428
- total: number
429
- limit: number
430
- offset: number
431
- pages: number
432
- }
433
-
434
- // ═══════════════════════════════════════════════════════════════
435
- // REPOSITORY ADAPTER — Interfaz genérica de capa de datos
436
- // ═══════════════════════════════════════════════════════════════
437
- //
438
- // Los services DEBEN depender de RepositoryAdapter<T>, NO del ORM.
439
- // Esto permite swapear SQLite → MongoDB → Prisma → Redis sin tocar el service.
440
- //
441
- // Implementaciones disponibles:
442
- // OrmRepository<T> → built-in SQL ORM (SQLite, Postgres)
443
- // Cualquier clase que implemente esta interfaz → Mongoose, Prisma, etc.
444
- //
445
- // Ejemplo en composition-root.ts:
446
- // const productos = new OrmRepository<Producto>(orm, 'Producto')
447
- // // O con MongoDB:
448
- // const productos = new MongoProductoRepository(mongoCollection)
449
- //
450
- // El service recibe RepositoryAdapter<Producto> — no sabe qué hay atrás.
451
-
452
- export interface RepositoryAdapter<T extends object = Record<string, unknown>> {
453
- findMany(filters?: Record<string, unknown>, options?: FindOptions): Promise<T[]>
454
- findById(id: string, select?: string[]): Promise<T | null>
455
- findOne(filters: Record<string, unknown>): Promise<T | null>
456
- create(data: Omit<T, 'id'>): Promise<T>
457
- update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null>
458
- delete(id: string): Promise<boolean>
459
- count(filters?: Record<string, unknown>): Promise<number>
460
- paginate(
461
- filters?: Record<string, unknown>,
462
- options?: FindOptions & { limit: number },
463
- ): Promise<PageResult<T>>
464
- }
465
-
466
- // ── Transactor — atomicidad cross-modelo SIN exponer ORM al service ──
467
- //
468
- // Los services NO reciben ORM. Para operaciones atómicas multi-tabla
469
- // (ledger doble entrada, transferencias, etc.), reciben Transactor.
470
- //
471
- // El service obtiene repos transaccionales por modelo dentro del run():
472
- //
473
- // await this.transactor.run(async (repos) => {
474
- // const walletRepo = repos.for<WalletDTO>('Wallet')
475
- // const txRepo = repos.for<TransactionDTO>('Transaction')
476
- // await walletRepo.update(id, { balance })
477
- // await txRepo.create({ ... })
478
- // })
479
- //
480
- // Si una operación dentro de run() falla, todo se rolleabackea automáticamente.
481
- //
482
- // Implementaciones:
483
- // OrmTransactor → built-in, delega a ORM.transaction()
484
- // Custom → implementar para Prisma, Drizzle, MongoDB, etc.
485
-
486
- export interface TransactionalRepos {
487
- /** Crea un RepositoryAdapter<T> ligado a la transacción activa para el modelo dado. */
488
- for<T extends object = Record<string, unknown>>(modelName: string): RepositoryAdapter<T>
489
- }
490
-
491
- export interface Transactor {
492
- run<R>(fn: (repos: TransactionalRepos) => Promise<R>): Promise<R>
493
- }
494
-
495
- // ── ORM — SOLID: S = solo ORM, D = depende de DbAdapter ──
496
- export class ORM {
497
- private models = new Map<string, ModelDefinition>()
498
-
499
- constructor(private db: DbAdapter) {}
500
-
501
- define(name: string, def: ModelDefinition): this {
502
- this.models.set(name, def)
503
- return this
504
- }
505
-
506
- getDefinition(name: string): ModelDefinition {
507
- const def = this.models.get(name)
508
- if (!def) throw new NotFoundError(`Modelo "${name}" no definido`)
509
- return def
510
- }
511
-
512
- /**
513
- * Ejecuta múltiples operaciones en una transacción atómica.
514
- * Si el adaptador no soporta transacciones, ejecuta sin ellas.
515
- *
516
- * @example
517
- * await orm.transaction(async (tx) => {
518
- * await tx.create('Pedido', { productoId, cantidad })
519
- * await tx.update('Producto', productoId, { stock: nuevoStock })
520
- * })
521
- */
522
- async transaction<T>(fn: (tx: ORM) => Promise<T>): Promise<T> {
523
- if (this.db.transaction) {
524
- return this.db.transaction(async (txAdapter) => {
525
- const txOrm = new ORM(txAdapter)
526
- for (const [name, def] of this.models) txOrm.define(name, def)
527
- return fn(txOrm)
528
- })
529
- }
530
- // Fallback: sin transacción si el adaptador no lo soporta
531
- return fn(this)
532
- }
533
-
534
- // ── Helpers privados ──
535
-
536
- private serializeForDb(def: ModelDefinition, record: Record<string, unknown>): Record<string, unknown> {
537
- const out: Record<string, unknown> = {}
538
- for (const [k, v] of Object.entries(record)) {
539
- if (def.fields[k]?.type === 'json' && v !== null && v !== undefined && typeof v !== 'string') {
540
- out[k] = JSON.stringify(v)
541
- } else {
542
- out[k] = v
543
- }
544
- }
545
- return out
546
- }
547
-
548
- private deserializeFromDb(def: ModelDefinition, row: ModelResult): ModelResult {
549
- const result = { ...row } as Record<string, unknown>
550
-
551
- // Deserializar campos JSON almacenados como string
552
- for (const [k, field] of Object.entries(def.fields)) {
553
- if (field.type === 'json' && typeof result[k] === 'string') {
554
- try { result[k] = JSON.parse(result[k] as string) } catch { /* no es JSON válido */ }
555
- }
556
- }
557
-
558
- // Normalizar timestamps que PostgreSQL devuelve en minúscula
559
- // PostgreSQL baja el case de identificadores no-quoted: createdAt → createdat
560
- const tsMap: Record<string, string> = {
561
- createdat: 'createdAt',
562
- updatedat: 'updatedAt',
563
- deletedat: 'deletedAt',
564
- }
565
- for (const [lower, camel] of Object.entries(tsMap)) {
566
- if (lower in result && !(camel in result)) {
567
- result[camel] = result[lower]
568
- delete result[lower]
569
- }
570
- }
571
-
572
- return result as ModelResult
573
- }
574
-
575
- private getAllowedFields(def: ModelDefinition): Set<string> {
576
- const allowed = new Set(['id', ...Object.keys(def.fields)])
577
- if (def.timestamps) { allowed.add('createdAt'); allowed.add('updatedAt') }
578
- if (def.softDelete) allowed.add('deletedAt')
579
- return allowed
580
- }
581
-
582
- private buildSelect(def: ModelDefinition, fields?: string[]): string {
583
- if (!fields || fields.length === 0) return '*'
584
- const allowed = this.getAllowedFields(def)
585
- for (const f of fields) {
586
- if (!allowed.has(f)) throw new ValidationError(`Invalid select field: "${f}"`)
587
- }
588
- return fields.join(', ')
589
- }
590
-
591
- private buildWhere(def: ModelDefinition, filters?: Record<string, unknown>): { clause: string; params: unknown[] } {
592
- const parts: string[] = []
593
- const params: unknown[] = []
594
-
595
- if (filters && Object.keys(filters).length > 0) {
596
- const allowed = this.getAllowedFields(def)
597
- for (const [k, v] of Object.entries(filters)) {
598
- if (!allowed.has(k)) throw new ValidationError(`Invalid filter field: "${k}"`)
599
- params.push(v)
600
- parts.push(`${k} = ?`)
601
- }
602
- }
603
-
604
- if (def.softDelete) parts.push('deletedAt IS NULL')
605
-
606
- return {
607
- clause: parts.length > 0 ? ' WHERE ' + parts.join(' AND ') : '',
608
- params,
609
- }
610
- }
611
-
612
- // ── CRUD ──
613
-
614
- async findMany(name: string, filters?: Record<string, unknown>, options?: FindOptions): Promise<ModelResult[]> {
615
- const def = this.getDefinition(name)
616
- const { clause, params } = this.buildWhere(def, filters)
617
-
618
- const selectClause = this.buildSelect(def, options?.select)
619
- let sql = `SELECT ${selectClause} FROM ${def.table}${clause}`
620
-
621
- if (options?.orderBy) {
622
- const allowed = this.getAllowedFields(def)
623
- const clauses = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy]
624
- const parts = clauses.map(({ field, dir = 'ASC' }) => {
625
- if (!allowed.has(field)) throw new ValidationError(`Invalid sort field: "${field}"`)
626
- return `${field} ${dir === 'DESC' ? 'DESC' : 'ASC'}`
627
- })
628
- sql += ` ORDER BY ${parts.join(', ')}`
629
- }
630
-
631
- if (options?.limit !== undefined) {
632
- sql += ` LIMIT ${Math.max(1, Math.floor(options.limit))}`
633
- if (options.offset !== undefined) {
634
- sql += ` OFFSET ${Math.max(0, Math.floor(options.offset))}`
635
- }
636
- }
637
-
638
- const rows = (await this.db.query(sql, params)) as ModelResult[]
639
- return rows.map(r => this.deserializeFromDb(def, r))
640
- }
641
-
642
- async findById(name: string, id: string, select?: string[]): Promise<ModelResult | null> {
643
- const def = this.getDefinition(name)
644
- const selectClause = this.buildSelect(def, select)
645
- let sql = `SELECT ${selectClause} FROM ${def.table} WHERE id = ?`
646
- if (def.softDelete) sql += ' AND deletedAt IS NULL'
647
- const rows = await this.db.query(sql, [id])
648
- const row = (rows as ModelResult[])[0] ?? null
649
- return row ? this.deserializeFromDb(def, row) : null
650
- }
651
-
652
- async findOne(name: string, filters: Record<string, unknown>): Promise<ModelResult | null> {
653
- const results = await this.findMany(name, filters, { limit: 1 })
654
- return results[0] ?? null
655
- }
656
-
657
- async count(name: string, filters?: Record<string, unknown>): Promise<number> {
658
- const def = this.getDefinition(name)
659
- const { clause, params } = this.buildWhere(def, filters)
660
- const rows = await this.db.query(`SELECT COUNT(*) as n FROM ${def.table}${clause}`, params)
661
- return Number((rows as Array<{ n: number | string }>)[0]?.n ?? 0)
662
- }
663
-
664
- async paginate(
665
- name: string,
666
- filters?: Record<string, unknown>,
667
- options: FindOptions & { limit: number } = { limit: 20 },
668
- ): Promise<PageResult> {
669
- const limit = Math.max(1, Math.floor(options.limit))
670
- const offset = Math.max(0, Math.floor(options.offset ?? 0))
671
-
672
- const [data, total] = await Promise.all([
673
- this.findMany(name, filters, { ...options, limit, offset }),
674
- this.count(name, filters),
675
- ])
676
-
677
- return { data, total, limit, offset, pages: Math.ceil(total / limit) }
678
- }
679
-
680
- async create(name: string, data: Record<string, unknown>): Promise<ModelResult> {
681
- const def = this.getDefinition(name)
682
- const allowedFields = new Set(Object.keys(def.fields))
683
- const id = crypto.randomUUID()
684
- const now = new Date().toISOString()
685
-
686
- const record: Record<string, unknown> = { id }
687
- for (const [k, v] of Object.entries(data)) {
688
- if (allowedFields.has(k)) record[k] = v
689
- }
690
- if (def.timestamps) {
691
- record.createdAt = now
692
- record.updatedAt = now
693
- }
694
-
695
- const dbRecord = this.serializeForDb(def, record)
696
- const keys = Object.keys(dbRecord)
697
- const values = Object.values(dbRecord)
698
- const placeholders = keys.map(() => '?').join(', ')
699
-
700
- await this.db.run(
701
- `INSERT INTO ${def.table} (${keys.join(', ')}) VALUES (${placeholders})`,
702
- values,
703
- )
704
-
705
- return record as ModelResult
706
- }
707
-
708
- async update(name: string, id: string, data: Record<string, unknown>): Promise<ModelResult | null> {
709
- const def = this.getDefinition(name)
710
- const now = new Date().toISOString()
711
-
712
- const record = { ...data }
713
- if (def.timestamps) record.updatedAt = now
714
-
715
- const dbRecord = this.serializeForDb(def, record)
716
- const keys = Object.keys(dbRecord)
717
- const values = Object.values(dbRecord)
718
- const setClause = keys.map(k => `${k} = ?`).join(', ')
719
-
720
- await this.db.run(`UPDATE ${def.table} SET ${setClause} WHERE id = ?`, [...values, id])
721
- return this.findById(name, id)
722
- }
723
-
724
- async delete(name: string, id: string): Promise<boolean> {
725
- const def = this.getDefinition(name)
726
-
727
- if (def.softDelete) {
728
- const result = await this.db.run(`UPDATE ${def.table} SET deletedAt = ? WHERE id = ?`, [new Date().toISOString(), id])
729
- return result.changes > 0
730
- }
731
-
732
- const result = await this.db.run(`DELETE FROM ${def.table} WHERE id = ?`, [id])
733
- return result.changes > 0
734
- }
735
-
736
- // ── Bulk operations ──
737
-
738
- /**
739
- * Inserta múltiples registros en una sola query + transacción.
740
- * Mucho más eficiente que N llamadas a create().
741
- * Retorna todos los registros creados con sus IDs generados.
742
- */
743
- async createMany(name: string, records: Record<string, unknown>[]): Promise<ModelResult[]> {
744
- if (records.length === 0) return []
745
- const def = this.getDefinition(name)
746
- const allowedFields = new Set(Object.keys(def.fields))
747
- const now = new Date().toISOString()
748
-
749
- const prepared = records.map(data => {
750
- const record: Record<string, unknown> = { id: crypto.randomUUID() }
751
- for (const [k, v] of Object.entries(data)) {
752
- if (allowedFields.has(k)) record[k] = v
753
- }
754
- if (def.timestamps) { record.createdAt = now; record.updatedAt = now }
755
- return record
756
- })
757
-
758
- const dbPrepared = prepared.map(r => this.serializeForDb(def, r))
759
- const keys = Object.keys(dbPrepared[0] ?? {})
760
- const rowPlaceholders = `(${keys.map(() => '?').join(', ')})`
761
- const allPlaceholders = dbPrepared.map(() => rowPlaceholders).join(', ')
762
- const allValues = dbPrepared.flatMap(r => Object.values(r))
763
-
764
- await this.db.run(
765
- `INSERT INTO ${def.table} (${keys.join(', ')}) VALUES ${allPlaceholders}`,
766
- allValues,
767
- )
768
-
769
- return prepared as ModelResult[]
770
- }
771
-
772
- /**
773
- * Actualiza todos los registros que coincidan con los filtros.
774
- * Retorna la cantidad de filas afectadas.
775
- */
776
- async updateMany(
777
- name: string,
778
- filters: Record<string, unknown>,
779
- changes: Record<string, unknown>,
780
- ): Promise<number> {
781
- const def = this.getDefinition(name)
782
- const { clause, params: whereParams } = this.buildWhere(def, filters)
783
- const now = new Date().toISOString()
784
-
785
- const data = { ...changes }
786
- if (def.timestamps) data.updatedAt = now
787
-
788
- const dbData = this.serializeForDb(def, data)
789
- const setClause = Object.keys(dbData).map(k => `${k} = ?`).join(', ')
790
- const result = await this.db.run(
791
- `UPDATE ${def.table} SET ${setClause}${clause}`,
792
- [...Object.values(dbData), ...whereParams],
793
- )
794
- return result.changes
795
- }
796
-
797
- /**
798
- * Elimina (o soft-delete) todos los registros que coincidan con los filtros.
799
- * Retorna la cantidad de filas afectadas.
800
- */
801
- async deleteMany(name: string, filters: Record<string, unknown>): Promise<number> {
802
- if (Object.keys(filters).length === 0) {
803
- throw new ValidationError('deleteMany requires at least one filter to avoid deleting the entire table')
804
- }
805
- const def = this.getDefinition(name)
806
- const { clause, params } = this.buildWhere(def, filters)
807
-
808
- if (def.softDelete) {
809
- const result = await this.db.run(
810
- `UPDATE ${def.table} SET deletedAt = ?${clause}`,
811
- [new Date().toISOString(), ...params],
812
- )
813
- return result.changes
814
- }
815
-
816
- const result = await this.db.run(`DELETE FROM ${def.table}${clause}`, params)
817
- return result.changes
818
- }
819
-
820
- // ── Migraciones (inicialización de schema) ──
821
- // Crea tablas si no existen y altera columnas faltantes.
822
- // Para migraciones complejas, usar herramientas externas (knex, drizzle).
823
-
824
- static readonly SCHEMA_TABLE = '_arckode_schema'
825
-
826
- private assertSafeIdentifier(value: string, context: string): void {
827
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
828
- throw new ValidationError(`Invalid SQL identifier in ${context}: "${value}". Only letters, numbers and underscores.`)
829
- }
830
- }
831
-
832
- async migrate(): Promise<void> {
833
- // _arckode_migrations es propiedad exclusiva de db:migrate (migraciones SQL manuales).
834
- // ORM.migrate() usa _arckode_schema para rastrear qué tablas auto-migró.
835
- await this.db.run(
836
- `CREATE TABLE IF NOT EXISTS _arckode_schema (table_name TEXT PRIMARY KEY, migratedAt TEXT)`
837
- )
838
-
839
- for (const [modelName, def] of this.models) {
840
- // Validar nombres de tabla y campos antes de cualquier SQL
841
- this.assertSafeIdentifier(def.table, `modelo "${modelName}" → table`)
842
- for (const fieldName of Object.keys(def.fields)) {
843
- this.assertSafeIdentifier(fieldName, `modelo "${modelName}" → campo`)
844
- }
845
-
846
- // 1. Crear tabla si no existe
847
- const hasExplicitId = Object.keys(def.fields).includes('id')
848
- const columns = Object.entries(def.fields).map(([name, field]) => {
849
- const sqlType = this.fieldTypeToSQL(field.type)
850
- const parts = [name, sqlType]
851
-
852
- if (name === 'id') parts.push('PRIMARY KEY')
853
- if (field.required) parts.push('NOT NULL')
854
- if (field.unique) parts.push('UNIQUE')
855
- if (field.default !== undefined) {
856
- parts.push(`DEFAULT ${typeof field.default === 'string' ? `'${field.default}'` : field.default}`)
857
- }
858
-
859
- return parts.join(' ')
860
- })
861
-
862
- // Auto-añadir id TEXT PRIMARY KEY si el modelo no lo declara explícitamente
863
- if (!hasExplicitId) {
864
- columns.unshift('id TEXT PRIMARY KEY')
865
- }
866
-
867
- if (def.timestamps) {
868
- columns.push('createdAt TEXT')
869
- columns.push('updatedAt TEXT')
870
- }
871
-
872
- if (def.softDelete) {
873
- columns.push('deletedAt TEXT')
874
- }
875
-
876
- await this.db.run(
877
- `CREATE TABLE IF NOT EXISTS ${def.table} (${columns.join(', ')})`
878
- )
879
-
880
- // 2. Crear índices para campos con indexed: true
881
- for (const [fieldName, field] of Object.entries(def.fields)) {
882
- if (field.indexed && !field.unique) {
883
- const idxName = `idx_${def.table}_${fieldName}`
884
- try {
885
- await this.db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${def.table}(${fieldName})`)
886
- } catch { /* índice ya existe o adaptador no soporta */ }
887
- }
888
- }
889
-
890
- // 3. Agregar columnas nuevas que no existen en la tabla
891
- // Solución portable: intentar ALTER TABLE y silenciar el error "ya existe"
892
- for (const [name, field] of Object.entries(def.fields)) {
893
- const sqlType = this.fieldTypeToSQL(field.type)
894
- try {
895
- await this.db.run(`ALTER TABLE ${def.table} ADD COLUMN ${name} ${sqlType}`)
896
- } catch {
897
- // Error esperado: la columna ya existe — ignorar
898
- }
899
-
900
- // 3c. Corregir drift de nullability: si el modelo dice nullable pero la columna es NOT NULL, quitarlo
901
- // Solo PostgreSQL soporta ALTER COLUMN ... DROP NOT NULL de forma confiable
902
- if (field.nullable === true) {
903
- try {
904
- await this.db.run(`ALTER TABLE ${def.table} ALTER COLUMN ${name} DROP NOT NULL`)
905
- } catch { /* SQLite no soporta esto — ignorar */ }
906
- }
907
- }
908
-
909
- // 3b. Agregar columnas de timestamps/softDelete si el modelo las requiere pero no existen aún
910
- if (def.timestamps) {
911
- try { await this.db.run(`ALTER TABLE ${def.table} ADD COLUMN createdAt TEXT`) } catch { /* ya existe */ }
912
- try { await this.db.run(`ALTER TABLE ${def.table} ADD COLUMN updatedAt TEXT`) } catch { /* ya existe */ }
913
- }
914
- if (def.softDelete) {
915
- try { await this.db.run(`ALTER TABLE ${def.table} ADD COLUMN deletedAt TEXT`) } catch { /* ya existe */ }
916
- }
917
-
918
- // 4. Detectar drift de schema: columnas en la BD que ya no están en el modelo
919
- // Normalizar a lowercase para compatibilidad con PostgreSQL (retorna nombres en minúscula)
920
- const definedCols = new Set([
921
- 'id',
922
- ...Object.keys(def.fields).map(k => k.toLowerCase()),
923
- ...(def.timestamps ? ['createdat', 'updatedat'] : []),
924
- ...(def.softDelete ? ['deletedat'] : []),
925
- ])
926
-
927
- const dbCols = await this.getTableColumns(def.table)
928
-
929
- for (const col of dbCols) {
930
- if (definedCols.has(col.toLowerCase())) continue
931
-
932
- // Intentar DROP COLUMN (SQLite ≥ 3.35.0 y Postgres lo soportan)
933
- try {
934
- await this.db.run(`ALTER TABLE ${def.table} DROP COLUMN ${col}`)
935
- console.warn(
936
- `[arckode/migrate] Column "${col}" dropped from "${def.table}" — no longer in model`
937
- )
938
- } catch {
939
- console.warn(
940
- `[arckode/migrate] ⚠ Orphan column "${col}" in table "${def.table}" — ` +
941
- `not in model but could not be auto-dropped. ` +
942
- `Create a manual migration: arckode make:migration drop_${col}_from_${def.table}`
943
- )
944
- }
945
- }
946
- }
947
- }
948
-
949
- // Introspección portable: SQLite usa PRAGMA, Postgres usa information_schema
950
- private async getTableColumns(table: string): Promise<string[]> {
951
- // SQLite
952
- try {
953
- const rows = await this.db.query(`PRAGMA table_info(${table})`)
954
- if (Array.isArray(rows) && rows.length > 0 && 'name' in (rows[0] as object)) {
955
- return (rows as { name: string }[]).map(r => r.name)
956
- }
957
- } catch { /* no es SQLite o PRAGMA no disponible */ }
958
-
959
- // Postgres / estándar SQL
960
- try {
961
- const rows = await this.db.query(
962
- `SELECT column_name FROM information_schema.columns WHERE table_name = '${table}' AND table_schema = 'public'`
963
- )
964
- if (Array.isArray(rows) && rows.length > 0) {
965
- return (rows as { column_name: string }[]).map(r => r.column_name)
966
- }
967
- } catch { /* adaptador no soporta introspección */ }
968
-
969
- return []
970
- }
971
-
972
- private fieldTypeToSQL(type: FieldDefinition['type']): string {
973
- const map: Record<FieldDefinition['type'], string> = {
974
- string: 'TEXT',
975
- text: 'TEXT', // alias de string para TEXT largo
976
- number: 'REAL',
977
- boolean: 'BOOLEAN', // INTEGER falla en Postgres con DEFAULT false/true
978
- json: 'TEXT',
979
- date: 'TEXT',
980
- }
981
- return map[type]
982
- }
983
- }
984
-
985
- // ── OrmRepository<T> — Adaptador SQL del ORM a RepositoryAdapter<T> ──
986
- //
987
- // Uso en composition-root.ts:
988
- // const productoRepo = new OrmRepository<ProductoDTO>(orm, 'Producto')
989
- // const service = new ProductoService(productoRepo) // service depende de RepositoryAdapter, no ORM
990
- //
991
- // Para reemplazar con MongoDB, Prisma, etc.:
992
- // class MongoProductoRepo implements RepositoryAdapter<ProductoDTO> { ... }
993
- // const productoRepo = new MongoProductoRepo(mongoCollection)
994
- // // El service NO cambia.
995
-
996
- export class OrmRepository<T extends object = Record<string, unknown>>
997
- implements RepositoryAdapter<T> {
998
- constructor(
999
- private readonly orm: ORM,
1000
- private readonly modelName: string,
1001
- ) {}
1002
-
1003
- findMany(filters?: Record<string, unknown>, options?: FindOptions): Promise<T[]> {
1004
- return this.orm.findMany(this.modelName, filters, options) as unknown as Promise<T[]>
1005
- }
1006
-
1007
- findById(id: string, select?: string[]): Promise<T | null> {
1008
- return this.orm.findById(this.modelName, id, select) as unknown as Promise<T | null>
1009
- }
1010
-
1011
- findOne(filters: Record<string, unknown>): Promise<T | null> {
1012
- return this.orm.findOne(this.modelName, filters) as unknown as Promise<T | null>
1013
- }
1014
-
1015
- create(data: Omit<T, 'id'>): Promise<T> {
1016
- return this.orm.create(this.modelName, data as Record<string, unknown>) as unknown as Promise<T>
1017
- }
1018
-
1019
- update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null> {
1020
- return this.orm.update(this.modelName, id, data as Record<string, unknown>) as unknown as Promise<T | null>
1021
- }
1022
-
1023
- delete(id: string): Promise<boolean> {
1024
- return this.orm.delete(this.modelName, id)
1025
- }
1026
-
1027
- count(filters?: Record<string, unknown>): Promise<number> {
1028
- return this.orm.count(this.modelName, filters)
1029
- }
1030
-
1031
- paginate(
1032
- filters?: Record<string, unknown>,
1033
- options: FindOptions & { limit: number } = { limit: 20 },
1034
- ): Promise<PageResult<T>> {
1035
- return this.orm.paginate(this.modelName, filters, options) as unknown as Promise<PageResult<T>>
1036
- }
1037
- }
1038
-
1039
- // ── OrmTransactor — implementación SQL del Transactor ──
1040
- //
1041
- // Composition root:
1042
- // const transactor = new OrmTransactor(orm)
1043
- // const service = new WalletsService(walletRepo, txRepo, transactor, logger, cache)
1044
- //
1045
- // El service recibe Transactor (interfaz). Para swapear a Prisma:
1046
- // class PrismaTransactor implements Transactor { ... }
1047
- // const transactor = new PrismaTransactor(prisma)
1048
- // El service NO cambia.
1049
-
1050
- export class OrmTransactor implements Transactor {
1051
- constructor(private readonly orm: ORM) {}
1052
-
1053
- run<R>(fn: (repos: TransactionalRepos) => Promise<R>): Promise<R> {
1054
- return this.orm.transaction(async (txOrm) => {
1055
- const repos: TransactionalRepos = {
1056
- for: <T extends object = Record<string, unknown>>(modelName: string) =>
1057
- new OrmRepository<T>(txOrm, modelName) as RepositoryAdapter<T>,
1058
- }
1059
- return fn(repos)
1060
- })
1061
- }
1062
- }
1063
-
1064
- // ═══════════════════════════════════════════════════════════════
1065
- // 6. ROUTER + HTTP — Enrutamiento con middlewares y error handler
1066
- // ═══════════════════════════════════════════════════════════════
1067
-
1068
- export interface UploadedFile {
1069
- fieldName: string
1070
- originalName: string
1071
- buffer: Buffer
1072
- mimeType: string
1073
- size: number
1074
- }
1075
-
1076
- export interface HttpRequest {
1077
- id: string
1078
- method: string
1079
- path: string
1080
- params: Record<string, string>
1081
- query: Record<string, string>
1082
- headers: Record<string, string>
1083
- body: unknown
1084
- files?: Record<string, UploadedFile>
1085
- user?: { id: string; role: string }
1086
- }
1087
-
1088
- export interface HttpResponse {
1089
- status: number
1090
- body?: unknown
1091
- headers?: Record<string, string>
1092
- /** SSE / streaming: async generator que emite chunks de texto plano */
1093
- stream?: AsyncGenerator<string>
1094
- }
1095
-
1096
- /** Helper: crea una respuesta SSE a partir de un async generator.
1097
- * Cada valor yielded se envía como `data: <valor>\n\n`.
1098
- * Para eventos con tipo: yield `event: nombre\ndata: payload\n\n` */
1099
- export function sseResponse(
1100
- generator: AsyncGenerator<string>,
1101
- headers?: Record<string, string>,
1102
- ): HttpResponse {
1103
- return {
1104
- status: 200,
1105
- headers: {
1106
- 'Content-Type': 'text/event-stream',
1107
- 'Cache-Control': 'no-cache',
1108
- Connection: 'keep-alive',
1109
- ...headers,
1110
- },
1111
- stream: generator,
1112
- }
1113
- }
1114
-
1115
- export type RouteHandler = (req: HttpRequest) => HttpResponse | Promise<HttpResponse>
1116
- export type MiddlewareHandler = (req: HttpRequest, next: () => Promise<HttpResponse>) => Promise<HttpResponse>
1117
-
1118
- interface RouteEntry {
1119
- method: string
1120
- pattern: RegExp
1121
- paramNames: string[]
1122
- handler: RouteHandler
1123
- middlewares: MiddlewareHandler[]
1124
- }
1125
-
1126
- export class Router {
1127
- private routes: RouteEntry[] = []
1128
- private globalMiddlewares: MiddlewareHandler[] = []
1129
- private logger?: Logger
1130
-
1131
- setLogger(logger: Logger): void { this.logger = logger }
1132
-
1133
- use(middleware: MiddlewareHandler): void {
1134
- this.globalMiddlewares.push(middleware)
1135
- }
1136
-
1137
- private add(method: string, path: string, handler: RouteHandler, middlewares: MiddlewareHandler[] = []): void {
1138
- const paramNames: string[] = []
1139
- const parts = path.split('/').filter(Boolean)
1140
- const regexParts = parts.map(part => {
1141
- if (part.startsWith(':')) {
1142
- const rawName = part.slice(1)
1143
- if (rawName.endsWith('(*)')) {
1144
- paramNames.push(rawName.slice(0, -3))
1145
- return '(.*)'
1146
- }
1147
- paramNames.push(rawName)
1148
- return '([^/]+)'
1149
- }
1150
- return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1151
- })
1152
-
1153
- this.routes.push({
1154
- method,
1155
- pattern: new RegExp(`^/${regexParts.join('/')}/?$`),
1156
- paramNames,
1157
- handler,
1158
- middlewares,
1159
- })
1160
- }
1161
-
1162
- // Acepta ambas convenciones:
1163
- // (path, handler) — sin middlewares
1164
- // (path, handler, mw[]) — handler primero (legado)
1165
- // (path, mw[], handler) — middlewares primero (Express-like, convención del framework)
1166
- private resolve2(
1167
- handlerOrMw: RouteHandler | MiddlewareHandler[],
1168
- handlerOrUndefined?: RouteHandler | MiddlewareHandler[],
1169
- ): { handler: RouteHandler; mw: MiddlewareHandler[] } {
1170
- if (Array.isArray(handlerOrMw)) {
1171
- return { handler: handlerOrUndefined as RouteHandler, mw: handlerOrMw }
1172
- }
1173
- return { handler: handlerOrMw, mw: (handlerOrUndefined as MiddlewareHandler[] | undefined) ?? [] }
1174
- }
1175
-
1176
- get(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolve2(handlerOrMw, h); this.add('GET', path, handler, mw) }
1177
- post(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolve2(handlerOrMw, h); this.add('POST', path, handler, mw) }
1178
- put(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolve2(handlerOrMw, h); this.add('PUT', path, handler, mw) }
1179
- patch(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolve2(handlerOrMw, h); this.add('PATCH', path, handler, mw) }
1180
- delete(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolve2(handlerOrMw, h); this.add('DELETE', path, handler, mw) }
1181
- options(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolve2(handlerOrMw, h); this.add('OPTIONS', path, handler, mw) }
1182
-
1183
- async resolve(method: string, path: string, extras?: Partial<HttpRequest>): Promise<HttpResponse> {
1184
- const reqId = crypto.randomUUID().slice(0, 8)
1185
-
1186
- // Helper para construir HttpRequest y ejecutar middlewares
1187
- const buildRequest = (route: RouteEntry, match: RegExpMatchArray): HttpRequest => {
1188
- const params: Record<string, string> = {}
1189
- route.paramNames.forEach((name, i) => { params[name] = decodeURIComponent(match[i + 1] ?? '') })
1190
- return {
1191
- id: reqId,
1192
- method,
1193
- path,
1194
- params,
1195
- query: extras?.query ?? {},
1196
- headers: extras?.headers ?? {},
1197
- body: extras?.body ?? null,
1198
- user: extras?.user,
1199
- }
1200
- }
1201
-
1202
- const runAll = async (req: HttpRequest, routeMiddlewares: MiddlewareHandler[], routeHandler: RouteHandler): Promise<HttpResponse> => {
1203
- const allMiddlewares = [...this.globalMiddlewares, ...routeMiddlewares]
1204
- let index = 0
1205
- const next = async (): Promise<HttpResponse> => {
1206
- if (index < allMiddlewares.length) {
1207
- const mw = allMiddlewares[index++]!
1208
- return mw(req, next)
1209
- }
1210
- return routeHandler(req)
1211
- }
1212
- try {
1213
- return await next()
1214
- } catch (error) {
1215
- if (error instanceof ErrorContract) {
1216
- return { status: error.httpStatus, body: error.toJSON() }
1217
- }
1218
- if (this.logger) {
1219
- const stack = error instanceof Error ? error.stack : String(error)
1220
- this.logger.error(`Unhandled error in ${method} ${path}`, { stack, requestId: reqId })
1221
- }
1222
- return { status: 500, body: { error: 'Error interno del servidor', code: 'INTERNAL_ERROR' } }
1223
- }
1224
- }
1225
-
1226
- if (method === 'OPTIONS') {
1227
- // CORS preflight: buscar cualquier ruta que matchee la path (ignorando método)
1228
- let anyMatch: { route: RouteEntry; match: RegExpMatchArray } | null = null
1229
- for (const route of this.routes) {
1230
- const m = path.match(route.pattern)
1231
- if (m) { anyMatch = { route, match: m }; break }
1232
- }
1233
- if (anyMatch) {
1234
- const req = buildRequest(anyMatch.route, anyMatch.match)
1235
- return runAll(req, [], () => Promise.resolve({ status: 204, body: null }))
1236
- }
1237
- }
1238
-
1239
- for (const route of this.routes) {
1240
- if (route.method !== method) continue
1241
- const match = path.match(route.pattern)
1242
- if (!match) continue
1243
-
1244
- const req = buildRequest(route, match)
1245
- return runAll(req, route.middlewares, route.handler)
1246
- }
1247
-
1248
- return { status: 404, body: { error: 'Route not found', code: 'NOT_FOUND' } }
1249
-
1250
- }
1251
- }
1252
-
1253
- // ═══════════════════════════════════════════════════════════════
1254
- // 7. HTTP SERVER — Adapter de Node HTTP
1255
- // ═══════════════════════════════════════════════════════════════
1256
-
1257
- import { createServer as createNodeServer, IncomingMessage, ServerResponse } from 'node:http'
1258
- import { AsyncLocalStorage } from 'node:async_hooks'
1259
-
1260
- // ── Request Context — propagación transparente del requestId ──
1261
- // Accesible desde cualquier punto del call-stack async sin pasar props manualmente.
1262
- // Útil en services, adapters externos, logs, y tracing distribuido.
1263
- export const requestStorage = new AsyncLocalStorage<{ requestId: string; startTime: number }>()
1264
-
1265
- /** Retorna el requestId del request activo o undefined si no hay contexto HTTP activo */
1266
- export function getRequestId(): string | undefined {
1267
- return requestStorage.getStore()?.requestId
1268
- }
1269
-
1270
- /** Retorna el tiempo transcurrido (ms) desde que inició el request activo */
1271
- export function getRequestElapsed(): number | undefined {
1272
- const store = requestStorage.getStore()
1273
- return store ? Date.now() - store.startTime : undefined
1274
- }
1275
-
1276
- export interface ServerAdapter {
1277
- start(handler: (req: HttpRequest) => Promise<HttpResponse>): Promise<void>
1278
- stop(): Promise<void>
1279
- }
1280
-
1281
- // ── Envelope estándar de respuesta API ──────────────────────────
1282
- // Todas las respuestas siguen este contrato. Un solo lugar para cambiar el formato.
1283
- // Inspirado en JSend + convenciones de GitHub/Laravel:
1284
- // success → { success: true, data: T, meta: { pagination? } | null, error: null }
1285
- // error → { success: false, data: null, meta: null, error: { code, message, details } }
1286
- export interface ApiResponse<T = unknown> {
1287
- success: boolean
1288
- data: T | null
1289
- meta: { pagination?: unknown } | null
1290
- error: { code: string; message: string; details?: unknown } | null
1291
- }
1292
-
1293
- function buildEnvelope(status: number, body: unknown): string {
1294
- // Errores 4xx / 5xx
1295
- if (status >= 400) {
1296
- const b = (body ?? {}) as Record<string, unknown>
1297
- return JSON.stringify({
1298
- success: false,
1299
- data: null,
1300
- meta: null,
1301
- error: {
1302
- code: (b.code ?? 'ERROR') as string,
1303
- message: (b.error ?? 'Error') as string,
1304
- details: b.details ?? null,
1305
- },
1306
- } satisfies ApiResponse)
1307
- }
1308
-
1309
- // Sin contenido (DELETE 204)
1310
- if (body === null || body === undefined) {
1311
- return JSON.stringify({ success: true, data: null, meta: null, error: null } satisfies ApiResponse)
1312
- }
1313
-
1314
- const b = body as Record<string, unknown>
1315
-
1316
- // Lista paginada — el service retorna { data: [], pagination: {} }
1317
- if (Array.isArray(b.data) && b.pagination !== undefined) {
1318
- return JSON.stringify({
1319
- success: true,
1320
- data: b.data,
1321
- meta: { pagination: b.pagination },
1322
- error: null,
1323
- } satisfies ApiResponse)
1324
- }
1325
-
1326
- // Recurso único o cualquier otro payload exitoso
1327
- return JSON.stringify({ success: true, data: body, meta: null, error: null } satisfies ApiResponse)
1328
- }
1329
-
1330
- function indexOfBuffer(haystack: Buffer, needle: Buffer, offset = 0): number {
1331
- const limit = haystack.length - needle.length
1332
- outer: for (let i = offset; i <= limit; i++) {
1333
- for (let j = 0; j < needle.length; j++) {
1334
- if (haystack[i + j] !== needle[j]) continue outer
1335
- }
1336
- return i
1337
- }
1338
- return -1
1339
- }
1340
-
1341
- export class NodeServer implements ServerAdapter {
1342
- private server?: ReturnType<typeof createNodeServer>
1343
- private maxBodyBytes: number
1344
- private drainTimeoutMs: number
1345
- private activeRequests = 0
1346
-
1347
- constructor(
1348
- private port: number,
1349
- private logger: Logger,
1350
- opts: { maxBodyBytes?: number; drainTimeoutMs?: number } = {},
1351
- ) {
1352
- this.maxBodyBytes = opts.maxBodyBytes ?? 10 * 1024 * 1024 // 10MB default
1353
- this.drainTimeoutMs = opts.drainTimeoutMs ?? 30_000
1354
- }
1355
-
1356
- async start(handler: (req: HttpRequest) => Promise<HttpResponse>): Promise<void> {
1357
- return new Promise((resolve) => {
1358
- this.server = createNodeServer(async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => {
1359
- this.activeRequests++
1360
- const requestId = crypto.randomUUID().slice(0, 8)
1361
-
1362
- await requestStorage.run({ requestId, startTime: Date.now() }, async () => {
1363
- try {
1364
- const { body, files } = await this.readBody(nodeReq, this.maxBodyBytes)
1365
-
1366
- const url = new URL(nodeReq.url ?? '/', `http://${nodeReq.headers.host ?? 'localhost'}`)
1367
- const query: Record<string, string> = {}
1368
- url.searchParams.forEach((value, key) => { query[key] = value })
1369
-
1370
- const req: HttpRequest = {
1371
- id: requestId,
1372
- method: nodeReq.method ?? 'GET',
1373
- path: url.pathname,
1374
- params: {},
1375
- query,
1376
- headers: nodeReq.headers as Record<string, string>,
1377
- body,
1378
- ...(files ? { files } : {}),
1379
- }
1380
-
1381
- const res = await handler(req)
1382
-
1383
- // ── Streaming / SSE ──────────────────────────────
1384
- if (res.stream) {
1385
- nodeRes.writeHead(res.status, {
1386
- 'Content-Type': 'text/event-stream',
1387
- 'Cache-Control': 'no-cache',
1388
- Connection: 'keep-alive',
1389
- 'X-Request-Id': req.id,
1390
- ...res.headers,
1391
- })
1392
- try {
1393
- for await (const chunk of res.stream) {
1394
- nodeRes.write(`data: ${chunk}\n\n`)
1395
- }
1396
- } finally {
1397
- nodeRes.end()
1398
- }
1399
- return
1400
- }
1401
-
1402
- // ── Respuesta normal (JSON o binario comprimido) ──
1403
- const isBuffer = Buffer.isBuffer(res.body)
1404
- // 204 prohíbe body por spec HTTP — con envelope usamos 200 para "no content"
1405
- const effectiveStatus = res.status === 204 ? 200 : res.status
1406
- const responseBody = isBuffer ? res.body : buildEnvelope(effectiveStatus, res.body)
1407
- nodeRes.writeHead(effectiveStatus, {
1408
- 'Content-Type': 'application/json',
1409
- 'X-Request-Id': req.id,
1410
- ...res.headers,
1411
- })
1412
- nodeRes.end(responseBody)
1413
- } catch (error) {
1414
- const httpStatus = (error as any)?.httpStatus
1415
- if (httpStatus) {
1416
- nodeRes.writeHead(httpStatus, { 'Content-Type': 'application/json' })
1417
- nodeRes.end(buildEnvelope(httpStatus, { error: (error as Error).message, code: 'REQUEST_ERROR' }))
1418
- return
1419
- }
1420
- const stack = error instanceof Error ? error.stack : String(error)
1421
- this.logger.error('Error no manejado en HTTP', { error: String(error), stack })
1422
- nodeRes.writeHead(500, { 'Content-Type': 'application/json' })
1423
- nodeRes.end(buildEnvelope(500, { error: 'Error interno', code: 'INTERNAL_ERROR' }))
1424
- } finally {
1425
- this.activeRequests--
1426
- }
1427
- })
1428
- })
1429
-
1430
- this.server.listen(this.port, () => {
1431
- this.logger.info(`Servidor HTTP escuchando en :${this.port}`)
1432
- resolve()
1433
- })
1434
- })
1435
- }
1436
-
1437
- async stop(): Promise<void> {
1438
- return new Promise((resolve) => {
1439
- let resolved = false
1440
- const done = () => { if (!resolved) { resolved = true; resolve() } }
1441
-
1442
- // Deja de aceptar conexiones nuevas; resuelve cuando todos los sockets cierran
1443
- this.server?.close(done)
1444
-
1445
- // Espera que los requests en vuelo terminen (con deadline)
1446
- const deadline = Date.now() + this.drainTimeoutMs
1447
- const poll = setInterval(() => {
1448
- if (this.activeRequests === 0 || Date.now() >= deadline) {
1449
- clearInterval(poll)
1450
- if (this.activeRequests > 0) {
1451
- this.logger.warn(`Graceful shutdown: ${this.activeRequests} request(s) sin terminar, forzando cierre`)
1452
- }
1453
- done()
1454
- }
1455
- }, 50)
1456
- })
1457
- }
1458
-
1459
- /** Retorna el puerto real asignado (útil cuando port=0 en tests) */
1460
- getPort(): number {
1461
- const addr = this.server?.address()
1462
- return typeof addr === 'object' && addr ? addr.port : this.port
1463
- }
1464
-
1465
- private readBody(
1466
- req: IncomingMessage,
1467
- maxBytes = 10 * 1024 * 1024,
1468
- ): Promise<{ body: unknown; files?: Record<string, UploadedFile> }> {
1469
- return new Promise((resolve, reject) => {
1470
- const chunks: Buffer[] = []
1471
- let total = 0
1472
-
1473
- req.on('data', (chunk: Buffer) => {
1474
- total += chunk.length
1475
- if (total > maxBytes) {
1476
- req.destroy()
1477
- reject(Object.assign(new Error('Payload Too Large'), { httpStatus: 413 }))
1478
- return
1479
- }
1480
- chunks.push(chunk)
1481
- })
1482
-
1483
- req.on('end', () => {
1484
- const rawBuffer = Buffer.concat(chunks)
1485
- if (!rawBuffer.length) return resolve({ body: null })
1486
-
1487
- const contentType = (req.headers['content-type'] ?? '').toLowerCase()
1488
- const boundaryMatch = contentType.match(/multipart\/form-data;\s*boundary=(.+)/)
1489
-
1490
- if (boundaryMatch) {
1491
- const boundary = (boundaryMatch[1] ?? '').trim()
1492
- const { fields, files } = this.parseMultipart(rawBuffer, boundary)
1493
- return resolve({ body: fields, files })
1494
- }
1495
-
1496
- const raw = rawBuffer.toString()
1497
- try { resolve({ body: JSON.parse(raw) }) } catch { resolve({ body: raw }) }
1498
- })
1499
-
1500
- req.on('error', reject)
1501
- })
1502
- }
1503
-
1504
- private parseMultipart(
1505
- buffer: Buffer,
1506
- boundary: string,
1507
- ): { fields: Record<string, string>; files: Record<string, UploadedFile> } {
1508
- const fields: Record<string, string> = {}
1509
- const files: Record<string, UploadedFile> = {}
1510
-
1511
- const firstDelim = Buffer.from(`--${boundary}`)
1512
- const innerDelim = Buffer.from(`\r\n--${boundary}`)
1513
- const doubleCRLF = Buffer.from('\r\n\r\n')
1514
-
1515
- let pos = indexOfBuffer(buffer, firstDelim, 0)
1516
- if (pos === -1) return { fields, files }
1517
- pos += firstDelim.length
1518
-
1519
- while (pos < buffer.length) {
1520
- // Skip \r\n after boundary line, or detect closing --
1521
- if (buffer[pos] === 0x2d && buffer[pos + 1] === 0x2d) break
1522
- if (buffer[pos] === 0x0d && buffer[pos + 1] === 0x0a) pos += 2
1523
- else break
1524
-
1525
- const headerEnd = indexOfBuffer(buffer, doubleCRLF, pos)
1526
- if (headerEnd === -1) break
1527
-
1528
- const headerStr = buffer.subarray(pos, headerEnd).toString()
1529
- pos = headerEnd + 4
1530
-
1531
- const nextBound = indexOfBuffer(buffer, innerDelim, pos)
1532
- if (nextBound === -1) break
1533
-
1534
- const partBody = buffer.subarray(pos, nextBound)
1535
- pos = nextBound + innerDelim.length
1536
-
1537
- const headers: Record<string, string> = {}
1538
- for (const line of headerStr.split('\r\n')) {
1539
- const colon = line.indexOf(':')
1540
- if (colon === -1) continue
1541
- headers[line.slice(0, colon).toLowerCase().trim()] = line.slice(colon + 1).trim()
1542
- }
1543
-
1544
- const disp = headers['content-disposition'] ?? ''
1545
- const nameMatch = /name="([^"]*)"/.exec(disp)
1546
- const fileMatch = /filename="([^"]*)"/.exec(disp)
1547
- if (!nameMatch) continue
1548
-
1549
- const fieldName = nameMatch[1] ?? ''
1550
-
1551
- if (fileMatch) {
1552
- files[fieldName] = {
1553
- fieldName,
1554
- originalName: fileMatch[1] ?? '',
1555
- buffer: partBody,
1556
- mimeType: headers['content-type'] ?? 'application/octet-stream',
1557
- size: partBody.length,
1558
- }
1559
- } else {
1560
- fields[fieldName] = partBody.toString()
1561
- }
1562
-
1563
- if (buffer[pos] === 0x2d && buffer[pos + 1] === 0x2d) break
1564
- }
1565
-
1566
- return { fields, files }
1567
- }
1568
- }
1569
-
1570
- // ═══════════════════════════════════════════════════════════════
1571
- // 8. VALIDADOR — Schemas planos, sin dependencias
1572
- // ═══════════════════════════════════════════════════════════════
1573
-
1574
- export type ValidatorType = 'string' | 'number' | 'boolean' | 'email' | 'url' | 'date'
1575
-
1576
- export interface ValidationRule {
1577
- type: ValidatorType
1578
- required?: boolean
1579
- min?: number
1580
- max?: number
1581
- pattern?: RegExp
1582
- enum?: string[]
1583
- message?: string
1584
- /** true = trim + colapsar espacios. 'html' = trim + escapar caracteres HTML peligrosos */
1585
- sanitize?: boolean | 'html'
1586
- }
1587
-
1588
- export type ValidationSchema = Record<string, ValidationRule>
1589
-
1590
- export function validateSchema(schema: ValidationSchema, input: unknown): Record<string, unknown> {
1591
- const errors: Record<string, string[]> = {}
1592
- const output: Record<string, unknown> = {}
1593
-
1594
- if (typeof input !== 'object' || input === null) {
1595
- throw new ValidationError('Request body must be an object')
1596
- }
1597
-
1598
- const data = input as Record<string, unknown>
1599
-
1600
- for (const [field, rule] of Object.entries(schema)) {
1601
- const value = data[field]
1602
- const fieldErrors: string[] = []
1603
-
1604
- if (value === undefined || value === null) {
1605
- if (rule.required) {
1606
- fieldErrors.push(rule.message ?? `${field} is required`)
1607
- errors[field] = fieldErrors
1608
- }
1609
- continue
1610
- }
1611
-
1612
- switch (rule.type) {
1613
- case 'string': {
1614
- if (typeof value !== 'string') {
1615
- fieldErrors.push(`${field} must be a string`)
1616
- } else {
1617
- let sanitized = value.trim().replace(/\s+/g, ' ')
1618
- if (rule.sanitize === 'html') {
1619
- sanitized = sanitized
1620
- .replace(/&/g, '&amp;')
1621
- .replace(/</g, '&lt;')
1622
- .replace(/>/g, '&gt;')
1623
- .replace(/"/g, '&quot;')
1624
- .replace(/'/g, '&#x27;')
1625
- }
1626
- if (rule.min !== undefined && sanitized.length < rule.min) fieldErrors.push(`Minimum ${rule.min} characters`)
1627
- if (rule.max !== undefined && sanitized.length > rule.max) fieldErrors.push(`Maximum ${rule.max} characters`)
1628
- if (rule.pattern && !rule.pattern.test(sanitized)) fieldErrors.push(rule.message ?? `Invalid format`)
1629
- if (rule.enum && !rule.enum.includes(sanitized)) fieldErrors.push(`Must be one of: ${rule.enum.join(', ')}`)
1630
- output[field] = sanitized
1631
- }
1632
- break
1633
- }
1634
- case 'number': {
1635
- const num = typeof value === 'string' ? Number(value) : value
1636
- if (typeof num !== 'number' || isNaN(num)) {
1637
- fieldErrors.push(`${field} must be a number`)
1638
- } else {
1639
- if (rule.min !== undefined && num < rule.min) fieldErrors.push(`Minimum ${rule.min}`)
1640
- if (rule.max !== undefined && num > rule.max) fieldErrors.push(`Maximum ${rule.max}`)
1641
- output[field] = num
1642
- }
1643
- break
1644
- }
1645
- case 'boolean': {
1646
- if (typeof value !== 'boolean') {
1647
- fieldErrors.push(`${field} must be a boolean`)
1648
- } else {
1649
- output[field] = value
1650
- }
1651
- break
1652
- }
1653
- case 'email': {
1654
- if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
1655
- fieldErrors.push(rule.message ?? `${field} is not a valid email`)
1656
- } else {
1657
- output[field] = value
1658
- }
1659
- break
1660
- }
1661
- case 'url': {
1662
- if (typeof value !== 'string') { fieldErrors.push(`${field} must be a string`) }
1663
- else {
1664
- try { new URL(value); output[field] = value } catch { fieldErrors.push(rule.message ?? `${field} is not a valid URL`) }
1665
- }
1666
- break
1667
- }
1668
- case 'date': {
1669
- if (typeof value !== 'string' || isNaN(Date.parse(value))) {
1670
- fieldErrors.push(rule.message ?? `${field} is not a valid date`)
1671
- } else {
1672
- output[field] = value
1673
- }
1674
- break
1675
- }
1676
- }
1677
-
1678
- if (fieldErrors.length > 0) {
1679
- errors[field] = fieldErrors
1680
- }
1681
- }
1682
-
1683
- if (Object.keys(errors).length > 0) {
1684
- throw new ValidationError('Validation error', errors)
1685
- }
1686
-
1687
- return output
1688
- }
1689
-
1690
- // ═══════════════════════════════════════════════════════════════
1691
- // 9. AUTH — JWT con adapter, middleware de roles
1692
- // ═══════════════════════════════════════════════════════════════
1693
-
1694
- export interface JwtAdapter {
1695
- sign(payload: Record<string, unknown>, secret: string, expiresIn: string): string
1696
- verify(token: string, secret: string): Record<string, unknown>
1697
- }
1698
-
1699
- export class Auth {
1700
- private readonly refreshSecret: string
1701
-
1702
- constructor(
1703
- private jwt: JwtAdapter,
1704
- private secret: string,
1705
- private logger: Logger,
1706
- private expiresIn: string = '24h',
1707
- private refreshExpiresIn: string = '30d',
1708
- ) {
1709
- // Refresh secret derivado: distinto al access secret para no reutilizar tokens
1710
- this.refreshSecret = secret + '__refresh__'
1711
- }
1712
-
1713
- createToken(payload: { id: string; role: string }, expiresIn?: string): string {
1714
- const ttl = expiresIn ?? this.expiresIn
1715
- const token = this.jwt.sign({ id: payload.id, role: payload.role, type: 'access' }, this.secret, ttl)
1716
- this.logger.debug('Token creado', { userId: payload.id, expiresIn: ttl })
1717
- return token
1718
- }
1719
-
1720
- createRefreshToken(payload: { id: string; role: string }): string {
1721
- const token = this.jwt.sign(
1722
- { id: payload.id, role: payload.role, type: 'refresh', jti: crypto.randomUUID() },
1723
- this.refreshSecret,
1724
- this.refreshExpiresIn,
1725
- )
1726
- this.logger.debug('Refresh token creado', { userId: payload.id })
1727
- return token
1728
- }
1729
-
1730
- /** Verifica un refresh token y devuelve un nuevo access token + nuevo refresh token */
1731
- refresh(refreshToken: string): { accessToken: string; refreshToken: string } {
1732
- try {
1733
- const payload = this.jwt.verify(refreshToken, this.refreshSecret)
1734
- if (payload.type !== 'refresh') throw new AuthError('Invalid token type')
1735
- const user = { id: payload.id as string, role: payload.role as string }
1736
- return {
1737
- accessToken: this.createToken(user),
1738
- refreshToken: this.createRefreshToken(user),
1739
- }
1740
- } catch (e) {
1741
- if (e instanceof AuthError) throw e
1742
- throw new AuthError('Invalid or expired refresh token')
1743
- }
1744
- }
1745
-
1746
- verifyToken(token: string): { id: string; role: string } {
1747
- try {
1748
- const payload = this.jwt.verify(token, this.secret)
1749
- // Un refresh token NO puede usarse como access token
1750
- if (payload.type === 'refresh') throw new AuthError('Invalid token type')
1751
- return { id: payload.id as string, role: payload.role as string }
1752
- } catch (e) {
1753
- if (e instanceof AuthError) throw e
1754
- throw new AuthError('Invalid or expired token')
1755
- }
1756
- }
1757
-
1758
- authenticate(...allowedRoles: string[]): MiddlewareHandler {
1759
- return async (req, next) => {
1760
- const header = req.headers['authorization']
1761
- if (!header?.startsWith('Bearer ')) {
1762
- throw new AuthError('Authentication token required')
1763
- }
1764
-
1765
- const user = this.verifyToken(header.slice(7))
1766
-
1767
- if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) {
1768
- throw new ForbiddenError(`Required role: ${allowedRoles.join(' or ')}`)
1769
- }
1770
-
1771
- req.user = user
1772
- return next()
1773
- }
1774
- }
1775
-
1776
- /**
1777
- * Verifica que el usuario autenticado sea el dueño del recurso.
1778
- * Previene IDOR (Insecure Direct Object Reference).
1779
- * Los admins (adminRole) pueden acceder a cualquier recurso.
1780
- *
1781
- * @example
1782
- * const pedido = await orm.findById('Pedido', id)
1783
- * if (!pedido) throw new NotFoundError('Pedido no encontrado')
1784
- * auth.assertOwnership(pedido.userId as string, req.user!.id, req.user!.role)
1785
- * // → solo el dueño o un admin puede continuar
1786
- */
1787
- assertOwnership(
1788
- resourceOwnerId: string,
1789
- requestingUserId: string,
1790
- requestingUserRole?: string,
1791
- adminRole = 'admin',
1792
- ): void {
1793
- if (requestingUserId === resourceOwnerId) return
1794
- if (requestingUserRole === adminRole) return
1795
- throw new ForbiddenError('Forbidden: resource belongs to another user')
1796
- }
1797
-
1798
- /**
1799
- * Hashea una contraseña con scrypt + salt aleatorio.
1800
- * Usa node:crypto — cero dependencias externas.
1801
- * Resultado: "salt:hash" (ambos en hex).
1802
- *
1803
- * @example
1804
- * const hash = await auth.hashPassword('miPassword123')
1805
- * // Guardar hash en DB, nunca el password plano
1806
- */
1807
- async hashPassword(password: string): Promise<string> {
1808
- const { scrypt, randomBytes } = await import('node:crypto')
1809
- const salt = randomBytes(16).toString('hex')
1810
- const hash = await new Promise<Buffer>((resolve, reject) =>
1811
- scrypt(password, salt, 64, (err, key) => (err ? reject(err) : resolve(key))),
1812
- )
1813
- return `${salt}:${hash.toString('hex')}`
1814
- }
1815
-
1816
- /**
1817
- * Compara un password en plano contra un hash generado por hashPassword().
1818
- * Usa timingSafeEqual para prevenir timing attacks.
1819
- *
1820
- * @example
1821
- * const ok = await auth.comparePassword('miPassword123', usuario.passwordHash)
1822
- * if (!ok) throw new AuthError('Invalid credentials')
1823
- */
1824
- async comparePassword(password: string, stored: string): Promise<boolean> {
1825
- const { scrypt, timingSafeEqual } = await import('node:crypto')
1826
- const [salt, hash] = stored.split(':')
1827
- if (!salt || !hash) return false
1828
- const storedBuf = Buffer.from(hash, 'hex')
1829
- const attempt = await new Promise<Buffer>((resolve, reject) =>
1830
- scrypt(password, salt, 64, (err, key) => (err ? reject(err) : resolve(key))),
1831
- )
1832
- return timingSafeEqual(storedBuf, attempt)
1833
- }
1834
- }
1835
-
1836
- // ═══════════════════════════════════════════════════════════════
1837
- // 10. CACHE — En memoria, interfaz para reemplazar por Redis
1838
- // ═══════════════════════════════════════════════════════════════
1839
-
1840
- export interface CacheAdapter {
1841
- get<T>(key: string): Promise<T | null>
1842
- set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>
1843
- delete(key: string): Promise<void>
1844
- flush(): Promise<void>
1845
- }
1846
-
1847
- export class MemoryCache implements CacheAdapter {
1848
- private store = new Map<string, { value: unknown; expiresAt: number }>()
1849
- private hits = 0
1850
- private misses = 0
1851
-
1852
- async get<T>(key: string): Promise<T | null> {
1853
- const entry = this.store.get(key)
1854
- if (!entry) { this.misses++; return null }
1855
- if (Date.now() > entry.expiresAt) { this.store.delete(key); this.misses++; return null }
1856
- this.hits++
1857
- return entry.value as T
1858
- }
1859
-
1860
- async set<T>(key: string, value: T, ttlSeconds = 3600): Promise<void> {
1861
- this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 })
1862
- }
1863
-
1864
- async delete(key: string): Promise<void> { this.store.delete(key) }
1865
- async flush(): Promise<void> { this.store.clear() }
1866
-
1867
- get stats() {
1868
- const total = this.hits + this.misses
1869
- return { size: this.store.size, hits: this.hits, misses: this.misses, hitRate: total > 0 ? `${((this.hits / total) * 100).toFixed(1)}%` : '0%' }
1870
- }
1871
- }
1872
-
1873
- // ═══════════════════════════════════════════════════════════════
1874
- // 11. SISTEMA DE MÓDULOS — El corazón de la arquitectura
1875
- // ═══════════════════════════════════════════════════════════════
1876
-
1877
- // ── Contrato que un módulo EXPONE hacia afuera ──
1878
- export interface ModuleContract {
1879
- name: string
1880
- version: string
1881
- description: string
1882
- actions: string[]
1883
- events: string[]
1884
- tables: string[]
1885
- dependencies: string[]
1886
- rules: string[]
1887
- }
1888
-
1889
- // ── Dependencias que un módulo RECIBE ──
1890
- export interface ModuleDependencies {
1891
- logger: Logger
1892
- orm: ORM
1893
- router: Router
1894
- config: ConfigStore
1895
- cache: CacheAdapter
1896
- auth?: Auth
1897
- }
1898
-
1899
- // ── Interfaz opcional para módulos que aceptan sockets post-init ──
1900
- export interface SocketsAware {
1901
- setSockets(sockets: Record<string, unknown>): void
1902
- }
1903
-
1904
- // ── Definición de un módulo ──
1905
- export interface ModuleDefinition<TModule> {
1906
- name: string
1907
- version: string
1908
- description: string
1909
- contract: ModuleContract
1910
- create(deps: ModuleDependencies): TModule
1911
- validate?(instance: TModule): void
1912
- /** Hook de cierre: se llama en system.stop(). Cerrar conexiones, detener timers, etc. */
1913
- onStop?(instance: TModule): Promise<void>
1914
- }
1915
-
1916
- // ── Crea un módulo con validación de contrato ──
1917
- export function createModule<T>(def: ModuleDefinition<T>): ModuleDefinition<T> {
1918
- return def
1919
- }
1920
-
1921
- // ── Tipos para conectores ──
1922
- export interface ConnectorContext {
1923
- resolveModule<T>(name: string, sockets?: Record<string, unknown>): T
1924
- /** Los sockets se asignan al módulo ANTES de su inicialización.
1925
- * Internamente el módulo recibe los sockets vía ModuleDependencies.
1926
- * Para acceder a sockets después de init, el módulo debe aceptarlos
1927
- * en su factory (create) y guardarlos internamente. */
1928
- }
1929
-
1930
- export type ConnectorDefinition = (ctx: ConnectorContext) => void | Promise<void>
1931
-
1932
- // ── SISTEMA ──
1933
- export class System {
1934
- public readonly logger: Logger
1935
- public readonly config: ConfigStore
1936
- public readonly container: Container
1937
- public readonly orm: ORM
1938
- public readonly router: Router
1939
- public readonly http: ServerAdapter
1940
- public readonly cache: CacheAdapter
1941
- public readonly auth?: Auth
1942
-
1943
- private modules = new Map<string, { definition: ModuleDefinition<unknown>; instance: unknown; sockets?: Record<string, unknown> }>()
1944
- private connectors: { name: string; fn: ConnectorDefinition }[] = []
1945
- private pendingSockets = new Map<string, Record<string, unknown>>()
1946
- private initialized = false
1947
-
1948
- constructor(params: {
1949
- config: ConfigStore
1950
- container: Container
1951
- logger: Logger
1952
- orm: ORM
1953
- router: Router
1954
- http: ServerAdapter
1955
- cache: CacheAdapter
1956
- auth?: Auth
1957
- }) {
1958
- this.config = params.config
1959
- this.container = params.container
1960
- this.logger = params.logger
1961
- this.orm = params.orm
1962
- this.router = params.router
1963
- this.http = params.http
1964
- this.cache = params.cache
1965
- this.auth = params.auth
1966
- }
1967
-
1968
- addModule<T>(definition: ModuleDefinition<T>): this {
1969
- if (this.initialized) throw new InternalError(`Cannot add module "${definition.name}" after initialization`)
1970
- this.modules.set(definition.name, { definition, instance: undefined })
1971
- return this
1972
- }
1973
-
1974
- addConnector(name: string, fn: ConnectorDefinition): this {
1975
- this.connectors.push({ name, fn })
1976
- return this
1977
- }
1978
-
1979
- resolveModule<T>(name: string): T {
1980
- const entry = this.modules.get(name)
1981
- if (!entry?.instance) throw new NotFoundError(`Module "${name}" not found or not initialized`)
1982
- return entry.instance as T
1983
- }
1984
-
1985
- getModule<T>(name: string): T {
1986
- return this.resolveModule<T>(name)
1987
- }
1988
-
1989
- init(): void {
1990
- if (this.initialized) return
1991
- this.initialized = true
1992
-
1993
- // 1. Inicializar container
1994
- this.container.init()
1995
-
1996
- // 2. Migrar base de datos
1997
- // La migración se hace explícitamente desde composition-root
1998
-
1999
- // 3. Inicializar módulos
2000
- for (const [name, entry] of this.modules) {
2001
- const sockets = this.pendingSockets.get(name)
2002
- const deps: ModuleDependencies = {
2003
- logger: this.logger.child(name),
2004
- orm: this.orm,
2005
- router: this.router,
2006
- config: this.config,
2007
- cache: this.cache,
2008
- auth: this.auth,
2009
- }
2010
-
2011
- entry.instance = entry.definition.create(deps)
2012
- this.logger.info(`Module initialized: ${name} v${entry.definition.version}`)
2013
- }
2014
-
2015
- // 4. Ejecutar conectores
2016
- for (const connector of this.connectors) {
2017
- const ctx: ConnectorContext = {
2018
- resolveModule: <T>(name: string, sockets?: Record<string, unknown>): T => {
2019
- const instance = this.resolveModule<Record<string, unknown>>(name)
2020
- if (sockets && typeof instance === 'object' && instance) {
2021
- if ('setSockets' in instance && typeof (instance as unknown as SocketsAware).setSockets === 'function') {
2022
- ;(instance as unknown as SocketsAware).setSockets(sockets)
2023
- }
2024
- }
2025
- return instance as unknown as T
2026
- },
2027
- }
2028
-
2029
- connector.fn(ctx)
2030
- this.logger.info(`Connector executed: ${connector.name}`)
2031
- }
2032
- }
2033
-
2034
- async start(): Promise<void> {
2035
- this.router.setLogger(this.logger)
2036
- this.init()
2037
- await this.http.start((req) => this.router.resolve(req.method, req.path, req))
2038
- this.logger.info('══════════════════════════════════')
2039
- this.logger.info(' System started successfully')
2040
- this.logger.info(` Modules: ${[...this.modules.keys()].join(', ')}`)
2041
- this.logger.info(` Connectors: ${this.connectors.map(c => c.name).join(', ')}`)
2042
- this.logger.info('══════════════════════════════════')
2043
- }
2044
-
2045
- async stop(): Promise<void> {
2046
- this.logger.info('Shutting down system...')
2047
-
2048
- for (const [name, entry] of this.modules) {
2049
- if (entry.definition.onStop && entry.instance) {
2050
- try {
2051
- await entry.definition.onStop(entry.instance)
2052
- this.logger.info(`Module stopped: ${name}`)
2053
- } catch (e) {
2054
- this.logger.error(`Error stopping module "${name}"`, { error: String(e) })
2055
- }
2056
- }
2057
- }
2058
-
2059
- await this.http.stop()
2060
- await this.container.destroy()
2061
- this.logger.info('System stopped')
2062
- }
2063
- }
2064
-
2065
- // ═══════════════════════════════════════════════════════════════
2066
- // 12. SEED SYSTEM — Poblar base de datos con datos de prueba
2067
- // ═══════════════════════════════════════════════════════════════
2068
-
2069
- export type SeedFunction = (orm: ORM) => Promise<void>
2070
-
2071
- export class SeedRunner {
2072
- private seeds: { name: string; run: SeedFunction }[] = []
2073
-
2074
- constructor(
2075
- private orm: ORM,
2076
- private logger: Logger,
2077
- ) {}
2078
-
2079
- add(name: string, run: SeedFunction): this {
2080
- this.seeds.push({ name, run })
2081
- return this
2082
- }
2083
-
2084
- async runAll(): Promise<void> {
2085
- for (const seed of this.seeds) {
2086
- try {
2087
- await seed.run(this.orm)
2088
- this.logger.info(`Seed executed: ${seed.name}`)
2089
- } catch (error) {
2090
- this.logger.error(`Error in seed "${seed.name}"`, { error: String(error) })
2091
- throw error
2092
- }
2093
- }
2094
- }
2095
-
2096
- async runOne(name: string): Promise<void> {
2097
- const seed = this.seeds.find(s => s.name === name)
2098
- if (!seed) throw new NotFoundError(`Seed "${name}" not found`)
2099
-
2100
- try {
2101
- await seed.run(this.orm)
2102
- this.logger.info(`Seed executed: ${name}`)
2103
- } catch (error) {
2104
- this.logger.error(`Error in seed "${name}"`, { error: String(error) })
2105
- throw error
2106
- }
2107
- }
2108
-
2109
- get list(): string[] {
2110
- return this.seeds.map(s => s.name)
2111
- }
2112
- }
2113
-
2114
- // ═══════════════════════════════════════════════════════════════
2115
- // EXPORT
2116
- // ═══════════════════════════════════════════════════════════════
2117
-
2118
- export default {
2119
- // Errors
2120
- ErrorContract, ValidationError, AuthError, ForbiddenError,
2121
- NotFoundError, ConflictError, RateLimitError, RepositoryError,
2122
- InternalError, ModuleRuleError,
2123
-
2124
- // Infrastructure
2125
- Logger, ConsoleTransport,
2126
- ConfigStore, loadEnv,
2127
- Container,
2128
- ORM, OrmRepository, OrmTransactor,
2129
- Router, NodeServer,
2130
- validateSchema,
2131
- Auth, MemoryCache,
2132
-
2133
- // Module System
2134
- createModule, System,
2135
-
2136
- // Seeds
2137
- SeedRunner,
2138
-
2139
- // Response envelope
2140
- // ApiResponse interface is exported as named export above
2141
-
2142
- // File uploads (multipart/form-data)
2143
- // UploadedFile interface is exported as named export above
2144
- }
1
+ // kernel/framework.tsBackward compatibility shim
2
+ // Este archivo re-exporta todo desde el kernel modular.
3
+ // Cualquier import existente de './kernel/framework' o 'arckode-framework'
4
+ // sigue funcionando sin ningún cambio.
5
+ export * from './index'
6
+
7
+ // Default export mantenido por compatibilidad
8
+ export { default } from './framework.default'