arckode-framework 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/jwt.ts +6 -4
- package/adapters/mysql.ts +7 -2
- package/adapters/postgres.ts +37 -0
- package/adapters/sqlite.ts +7 -1
- package/adapters/vendor.d.ts +48 -0
- package/cli/analyze/checks.ts +333 -0
- package/cli/analyze/index.ts +44 -0
- package/cli/analyze/report.ts +107 -0
- package/cli/analyze/types.ts +46 -0
- package/cli/analyze/utils.ts +36 -0
- package/cli/analyze.ts +2 -647
- package/cli/commands/db-migrate.ts +213 -89
- package/cli/commands/db-seed.ts +97 -32
- package/cli/commands/db-utils.ts +192 -0
- package/cli/commands/new.ts +175 -0
- package/cli/commands/routes.ts +94 -0
- package/cli/index.ts +57 -404
- package/cli/stubs/module/core.ts +162 -0
- package/cli/stubs/module/data.ts +171 -0
- package/cli/stubs/module/index.ts +5 -0
- package/cli/stubs/module/service.ts +198 -0
- package/cli/stubs/module/types.ts +12 -0
- package/cli/stubs/module-stub.ts +2 -552
- package/kernel/auth.ts +114 -0
- package/kernel/cache.ts +37 -0
- package/kernel/config.ts +129 -0
- package/kernel/container.ts +64 -0
- package/kernel/db/orm-migrate.ts +136 -0
- package/kernel/db/orm-repository.ts +45 -0
- package/kernel/db/orm-utils.ts +93 -0
- package/kernel/db/orm.ts +254 -0
- package/kernel/db/transactor.ts +17 -0
- package/kernel/db/types.ts +72 -0
- package/kernel/errors.ts +102 -0
- package/kernel/framework.default.ts +41 -0
- package/kernel/framework.ts +8 -2144
- package/kernel/http/router.ts +131 -0
- package/kernel/http/server.ts +303 -0
- package/kernel/http/types.ts +56 -0
- package/kernel/index.ts +25 -0
- package/kernel/logger.ts +50 -0
- package/kernel/middlewares.ts +19 -7
- package/kernel/modules/create-module.ts +5 -0
- package/kernel/modules/system.ts +149 -0
- package/kernel/modules/types.ts +46 -0
- package/kernel/seeds.ts +48 -0
- package/kernel/static.ts +11 -2
- package/kernel/testing.ts +8 -3
- package/kernel/validator.ts +116 -0
- package/modules/events/index.ts +19 -3
- package/modules/mail/index.ts +14 -2
- package/modules/storage/local-adapter.ts +19 -5
- package/modules/ws/index.ts +123 -18
- package/package.json +8 -11
- package/skills/auth/SKILL.md +36 -220
- package/skills/cli/SKILL.md +32 -251
- package/skills/config/SKILL.md +30 -239
- package/skills/connectors/SKILL.md +32 -295
- package/skills/helpers/SKILL.md +26 -195
- package/skills/middlewares/SKILL.md +30 -280
- package/skills/orm/SKILL.md +42 -349
- package/skills/realtime/SKILL.md +22 -297
- package/skills/services/SKILL.md +40 -183
- package/skills/testing/SKILL.md +34 -266
package/kernel/framework.ts
CHANGED
|
@@ -1,2144 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
|
|
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, '&')
|
|
1621
|
-
.replace(/</g, '<')
|
|
1622
|
-
.replace(/>/g, '>')
|
|
1623
|
-
.replace(/"/g, '"')
|
|
1624
|
-
.replace(/'/g, ''')
|
|
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.ts — Backward 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'
|