arckode-framework 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +546 -0
- package/adapters/__tests__/mysql.test.ts +283 -0
- package/adapters/jwt.ts +18 -0
- package/adapters/mysql.ts +98 -0
- package/adapters/postgres.ts +52 -0
- package/adapters/redis-cache.ts +64 -0
- package/adapters/sqlite.ts +73 -0
- package/adapters/vendor.d.ts +48 -0
- package/bin/arckode.js +7 -0
- package/cli/analyze.ts +506 -0
- package/cli/commands/db-migrate.ts +121 -0
- package/cli/commands/db-seed.ts +54 -0
- package/cli/commands/generate-api-client.ts +106 -0
- package/cli/commands/make-adapter.ts +132 -0
- package/cli/commands/make-auth.ts +297 -0
- package/cli/commands/make-frontend-module.ts +271 -0
- package/cli/commands/make-helper.ts +65 -0
- package/cli/commands/make-migration.ts +30 -0
- package/cli/commands/make-seed.ts +29 -0
- package/cli/generate.ts +132 -0
- package/cli/index.ts +604 -0
- package/cli/stubs/frontend-stub.ts +294 -0
- package/cli/stubs/fullstack-stub.ts +46 -0
- package/cli/stubs/module-stub.ts +469 -0
- package/kernel/__tests__/adapters.test.ts +101 -0
- package/kernel/__tests__/analyzer.test.ts +282 -0
- package/kernel/__tests__/framework.test.ts +617 -0
- package/kernel/__tests__/middlewares.test.ts +174 -0
- package/kernel/__tests__/static.test.ts +94 -0
- package/kernel/framework.ts +1851 -0
- package/kernel/middlewares.ts +179 -0
- package/kernel/static.ts +76 -0
- package/kernel/testing.ts +237 -0
- package/modules/events/index.ts +99 -0
- package/modules/mail/index.ts +51 -0
- package/modules/mail/smtp-adapter.ts +42 -0
- package/modules/queue/index.ts +78 -0
- package/modules/storage/index.ts +40 -0
- package/modules/storage/local-adapter.ts +41 -0
- package/modules/ws/__tests__/ws.test.ts +114 -0
- package/modules/ws/index.ts +136 -0
- package/package.json +99 -0
- package/skills/auth/SKILL.md +243 -0
- package/skills/cli/SKILL.md +258 -0
- package/skills/config/SKILL.md +253 -0
- package/skills/connectors/SKILL.md +259 -0
- package/skills/helpers/SKILL.md +206 -0
- package/skills/middlewares/SKILL.md +282 -0
- package/skills/orm/SKILL.md +260 -0
- package/skills/realtime/SKILL.md +307 -0
- package/skills/services/SKILL.md +206 -0
- package/skills/testing/SKILL.md +257 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// adapters/__tests__/mysql.test.ts
|
|
2
|
+
// Testea MySQLAdapter con mocks de mysql2/promise — sin servidor real.
|
|
3
|
+
// Para tests de integración real ver el bloque comentado al final.
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeAll, beforeEach, mock } from 'bun:test'
|
|
6
|
+
import type { MySQLAdapter } from '../mysql'
|
|
7
|
+
|
|
8
|
+
// ── Mocks de conexión (transacciones) ────────────────────────────────────────
|
|
9
|
+
const connMock = {
|
|
10
|
+
beginTransaction: mock(async () => {}),
|
|
11
|
+
execute: mock(async (): Promise<unknown[]> => [[], []]),
|
|
12
|
+
commit: mock(async () => {}),
|
|
13
|
+
rollback: mock(async () => {}),
|
|
14
|
+
release: mock(() => {}),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Mock del pool ─────────────────────────────────────────────────────────────
|
|
18
|
+
const poolMock = {
|
|
19
|
+
getConnection: mock(async () => connMock),
|
|
20
|
+
execute: mock(async (): Promise<unknown[]> => [[], []]),
|
|
21
|
+
end: mock(async () => {}),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Registrar el mock ANTES de que el adapter importe mysql2/promise.
|
|
25
|
+
// El dynamic import en beforeAll garantiza que mysql2/promise ya está mockeado.
|
|
26
|
+
mock.module('mysql2/promise', () => ({
|
|
27
|
+
default: { createPool: mock(() => poolMock) },
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
// ── Carga diferida del adapter ────────────────────────────────────────────────
|
|
31
|
+
let Adapter: typeof MySQLAdapter
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
const mod = await import('../mysql')
|
|
35
|
+
Adapter = mod.MySQLAdapter
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
39
|
+
function resetMocks() {
|
|
40
|
+
connMock.beginTransaction.mockClear()
|
|
41
|
+
connMock.execute.mockClear()
|
|
42
|
+
connMock.commit.mockClear()
|
|
43
|
+
connMock.rollback.mockClear()
|
|
44
|
+
connMock.release.mockClear()
|
|
45
|
+
poolMock.getConnection.mockClear()
|
|
46
|
+
poolMock.execute.mockClear()
|
|
47
|
+
poolMock.end.mockClear()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeAdapter() {
|
|
51
|
+
return new Adapter({ host: 'localhost', user: 'test', password: 'test', database: 'testdb' })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe('MySQLAdapter', () => {
|
|
57
|
+
beforeEach(resetMocks)
|
|
58
|
+
|
|
59
|
+
// ── connect() ───────────────────────────────────────────────────────────────
|
|
60
|
+
describe('connect()', () => {
|
|
61
|
+
it('crea el pool y verifica la conexión con getConnection+release', async () => {
|
|
62
|
+
const adapter = makeAdapter()
|
|
63
|
+
await adapter.connect()
|
|
64
|
+
|
|
65
|
+
expect(poolMock.getConnection).toHaveBeenCalledTimes(1)
|
|
66
|
+
expect(connMock.release).toHaveBeenCalledTimes(1)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// ── query() ─────────────────────────────────────────────────────────────────
|
|
71
|
+
describe('query()', () => {
|
|
72
|
+
let adapter: MySQLAdapter
|
|
73
|
+
|
|
74
|
+
beforeEach(async () => {
|
|
75
|
+
adapter = makeAdapter()
|
|
76
|
+
await adapter.connect()
|
|
77
|
+
resetMocks() // limpia las calls de connect()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('retorna el array de filas', async () => {
|
|
81
|
+
const filas = [{ id: '1', nombre: 'Laptop' }, { id: '2', nombre: 'Mouse' }]
|
|
82
|
+
poolMock.execute.mockImplementation(async () => [filas, []])
|
|
83
|
+
|
|
84
|
+
const result = await adapter.query('SELECT * FROM productos')
|
|
85
|
+
expect(result).toEqual(filas)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('pasa los params al execute del pool', async () => {
|
|
89
|
+
poolMock.execute.mockImplementation(async () => [[{ id: '1' }], []])
|
|
90
|
+
|
|
91
|
+
await adapter.query('SELECT * FROM productos WHERE id = ?', ['1'])
|
|
92
|
+
expect(poolMock.execute).toHaveBeenCalledWith(
|
|
93
|
+
'SELECT * FROM productos WHERE id = ?',
|
|
94
|
+
['1'],
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('retorna array vacío cuando no hay resultados', async () => {
|
|
99
|
+
poolMock.execute.mockImplementation(async () => [[], []])
|
|
100
|
+
|
|
101
|
+
const result = await adapter.query('SELECT * FROM productos WHERE id = ?', ['999'])
|
|
102
|
+
expect(result).toEqual([])
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ── run() ────────────────────────────────────────────────────────────────────
|
|
107
|
+
describe('run()', () => {
|
|
108
|
+
let adapter: MySQLAdapter
|
|
109
|
+
|
|
110
|
+
beforeEach(async () => {
|
|
111
|
+
adapter = makeAdapter()
|
|
112
|
+
await adapter.connect()
|
|
113
|
+
resetMocks()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('INSERT retorna changes y lastId como string', async () => {
|
|
117
|
+
poolMock.execute.mockImplementation(async () => [{ affectedRows: 1, insertId: 42 }, []])
|
|
118
|
+
|
|
119
|
+
const result = await adapter.run(
|
|
120
|
+
'INSERT INTO productos (nombre, precio) VALUES (?, ?)',
|
|
121
|
+
['Laptop', 999],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
expect(result.changes).toBe(1)
|
|
125
|
+
expect(result.lastId).toBe('42')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('UPDATE retorna changes y lastId undefined (insertId === 0)', async () => {
|
|
129
|
+
poolMock.execute.mockImplementation(async () => [{ affectedRows: 3, insertId: 0 }, []])
|
|
130
|
+
|
|
131
|
+
const result = await adapter.run(
|
|
132
|
+
'UPDATE productos SET activo = ? WHERE categoria = ?',
|
|
133
|
+
[false, 'perifericos'],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(result.changes).toBe(3)
|
|
137
|
+
expect(result.lastId).toBeUndefined()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('DELETE retorna changes y sin lastId', async () => {
|
|
141
|
+
poolMock.execute.mockImplementation(async () => [{ affectedRows: 1, insertId: 0 }, []])
|
|
142
|
+
|
|
143
|
+
const result = await adapter.run('DELETE FROM productos WHERE id = ?', ['1'])
|
|
144
|
+
expect(result.changes).toBe(1)
|
|
145
|
+
expect(result.lastId).toBeUndefined()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// ── transaction() ────────────────────────────────────────────────────────────
|
|
150
|
+
describe('transaction()', () => {
|
|
151
|
+
let adapter: MySQLAdapter
|
|
152
|
+
|
|
153
|
+
beforeEach(async () => {
|
|
154
|
+
adapter = makeAdapter()
|
|
155
|
+
await adapter.connect()
|
|
156
|
+
resetMocks()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('adquiere conexión, abre transacción, ejecuta fn y hace commit', async () => {
|
|
160
|
+
const filas = [{ id: '1' }]
|
|
161
|
+
connMock.execute.mockImplementation(async () => [filas, []])
|
|
162
|
+
|
|
163
|
+
const result = await adapter.transaction(async (tx) => {
|
|
164
|
+
return tx.query('SELECT * FROM productos')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
expect(poolMock.getConnection).toHaveBeenCalledTimes(1)
|
|
168
|
+
expect(connMock.beginTransaction).toHaveBeenCalledTimes(1)
|
|
169
|
+
expect(connMock.commit).toHaveBeenCalledTimes(1)
|
|
170
|
+
expect(connMock.rollback).not.toHaveBeenCalled()
|
|
171
|
+
expect(connMock.release).toHaveBeenCalledTimes(1)
|
|
172
|
+
expect(result).toEqual(filas)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('el adapter de transacción usa la conexión, no el pool', async () => {
|
|
176
|
+
connMock.execute.mockImplementation(async () => [[{ total: 5 }], []])
|
|
177
|
+
|
|
178
|
+
await adapter.transaction(async (tx) => {
|
|
179
|
+
await tx.query('SELECT COUNT(*) as total FROM productos')
|
|
180
|
+
await tx.run('INSERT INTO auditoria (evento) VALUES (?)', ['sync'])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// Ambas llamadas van a connMock.execute, NO a poolMock.execute
|
|
184
|
+
expect(connMock.execute).toHaveBeenCalledTimes(2)
|
|
185
|
+
expect(poolMock.execute).not.toHaveBeenCalled()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('hace rollback y lanza el error si fn falla', async () => {
|
|
189
|
+
const error = new Error('error de negocio')
|
|
190
|
+
|
|
191
|
+
await expect(
|
|
192
|
+
adapter.transaction(async () => {
|
|
193
|
+
throw error
|
|
194
|
+
}),
|
|
195
|
+
).rejects.toThrow('error de negocio')
|
|
196
|
+
|
|
197
|
+
expect(connMock.beginTransaction).toHaveBeenCalledTimes(1)
|
|
198
|
+
expect(connMock.rollback).toHaveBeenCalledTimes(1)
|
|
199
|
+
expect(connMock.commit).not.toHaveBeenCalled()
|
|
200
|
+
expect(connMock.release).toHaveBeenCalledTimes(1)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('libera la conexión aunque el rollback falle (finally garantizado)', async () => {
|
|
204
|
+
connMock.rollback.mockImplementation(async () => {
|
|
205
|
+
throw new Error('rollback falló')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
await expect(
|
|
209
|
+
adapter.transaction(async () => {
|
|
210
|
+
throw new Error('error original')
|
|
211
|
+
}),
|
|
212
|
+
).rejects.toBeDefined()
|
|
213
|
+
|
|
214
|
+
// release() debe ejecutarse siempre — está en finally
|
|
215
|
+
expect(connMock.release).toHaveBeenCalledTimes(1)
|
|
216
|
+
|
|
217
|
+
// Restaurar implementación para tests siguientes
|
|
218
|
+
connMock.rollback.mockImplementation(async () => {})
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// ── close() ──────────────────────────────────────────────────────────────────
|
|
223
|
+
describe('close()', () => {
|
|
224
|
+
it('termina el pool', async () => {
|
|
225
|
+
const adapter = makeAdapter()
|
|
226
|
+
await adapter.connect()
|
|
227
|
+
resetMocks()
|
|
228
|
+
|
|
229
|
+
await adapter.close()
|
|
230
|
+
expect(poolMock.end).toHaveBeenCalledTimes(1)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
// Test de integración real (requiere MySQL corriendo):
|
|
237
|
+
//
|
|
238
|
+
// describe('MySQLAdapter — integración', () => {
|
|
239
|
+
// let adapter: MySQLAdapter
|
|
240
|
+
//
|
|
241
|
+
// beforeAll(async () => {
|
|
242
|
+
// const { MySQLAdapter } = await import('../mysql')
|
|
243
|
+
// adapter = new MySQLAdapter({
|
|
244
|
+
// host: 'localhost',
|
|
245
|
+
// user: 'test',
|
|
246
|
+
// password: 'test',
|
|
247
|
+
// database: 'arckode_test',
|
|
248
|
+
// })
|
|
249
|
+
// await adapter.connect()
|
|
250
|
+
// await adapter.run('CREATE TABLE IF NOT EXISTS test_items (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(255))')
|
|
251
|
+
// })
|
|
252
|
+
//
|
|
253
|
+
// afterAll(async () => {
|
|
254
|
+
// await adapter.run('DROP TABLE IF EXISTS test_items')
|
|
255
|
+
// await adapter.close()
|
|
256
|
+
// })
|
|
257
|
+
//
|
|
258
|
+
// it('INSERT + SELECT funcionan con placeholders ?', async () => {
|
|
259
|
+
// await adapter.run('INSERT INTO test_items (nombre) VALUES (?)', ['Laptop'])
|
|
260
|
+
// const rows = await adapter.query('SELECT * FROM test_items WHERE nombre = ?', ['Laptop'])
|
|
261
|
+
// expect(rows).toHaveLength(1)
|
|
262
|
+
// expect((rows[0] as any).nombre).toBe('Laptop')
|
|
263
|
+
// })
|
|
264
|
+
//
|
|
265
|
+
// it('transacción commit persiste los datos', async () => {
|
|
266
|
+
// await adapter.transaction(async (tx) => {
|
|
267
|
+
// await tx.run('INSERT INTO test_items (nombre) VALUES (?)', ['Mouse'])
|
|
268
|
+
// })
|
|
269
|
+
// const rows = await adapter.query('SELECT * FROM test_items WHERE nombre = ?', ['Mouse'])
|
|
270
|
+
// expect(rows).toHaveLength(1)
|
|
271
|
+
// })
|
|
272
|
+
//
|
|
273
|
+
// it('transacción rollback no persiste los datos', async () => {
|
|
274
|
+
// await expect(
|
|
275
|
+
// adapter.transaction(async (tx) => {
|
|
276
|
+
// await tx.run('INSERT INTO test_items (nombre) VALUES (?)', ['Keyboard'])
|
|
277
|
+
// throw new Error('forzar rollback')
|
|
278
|
+
// })
|
|
279
|
+
// ).rejects.toThrow()
|
|
280
|
+
// const rows = await adapter.query('SELECT * FROM test_items WHERE nombre = ?', ['Keyboard'])
|
|
281
|
+
// expect(rows).toHaveLength(0)
|
|
282
|
+
// })
|
|
283
|
+
// })
|
package/adapters/jwt.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// adapters/jwt.ts — Adapter JWT
|
|
2
|
+
// Responsabilidad ÚNICA: traducir llamadas de JwtAdapter a jsonwebtoken
|
|
3
|
+
// SOLID: implementa JwtAdapter, puede reemplazarse sin cambiar Auth
|
|
4
|
+
|
|
5
|
+
import jwt from 'jsonwebtoken'
|
|
6
|
+
import type { JwtAdapter } from '../kernel/framework'
|
|
7
|
+
|
|
8
|
+
export const jwtTokenAdapter: JwtAdapter = {
|
|
9
|
+
sign(payload, secret, expiresIn) {
|
|
10
|
+
// jsonwebtoken's expiresIn accepts ms-compatible strings (e.g. "24h", "30d")
|
|
11
|
+
// We cast via unknown to satisfy its StringValue/number union
|
|
12
|
+
return jwt.sign(payload, secret, { expiresIn: expiresIn as unknown as number })
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
verify(token, secret) {
|
|
16
|
+
return jwt.verify(token, secret) as Record<string, unknown>
|
|
17
|
+
},
|
|
18
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// adapters/mysql.ts — Adapter MySQL
|
|
2
|
+
// Usa mysql2/promise. Placeholders ? nativos (sin conversión, a diferencia de Postgres).
|
|
3
|
+
// Instalar: bun add mysql2
|
|
4
|
+
|
|
5
|
+
import mysql from 'mysql2/promise'
|
|
6
|
+
import type { DbAdapter } from '../kernel/framework'
|
|
7
|
+
|
|
8
|
+
export interface MySQLConfig {
|
|
9
|
+
host: string
|
|
10
|
+
port?: number
|
|
11
|
+
user: string
|
|
12
|
+
password: string
|
|
13
|
+
database: string
|
|
14
|
+
poolMin?: number
|
|
15
|
+
poolMax?: number
|
|
16
|
+
/** Timeout en ms para adquirir una conexión del pool. Default: 10000 */
|
|
17
|
+
acquireTimeout?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Adapter para usar dentro de una transacción (conexión dedicada) ──
|
|
21
|
+
class MySQLTransactionAdapter implements DbAdapter {
|
|
22
|
+
constructor(private conn: mysql.PoolConnection) {}
|
|
23
|
+
|
|
24
|
+
async query(sql: string, params: unknown[] = []): Promise<unknown[]> {
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
const [rows] = await this.conn.execute(sql, params as any)
|
|
27
|
+
return rows as unknown[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async run(sql: string, params: unknown[] = []): Promise<{ changes: number; lastId?: string }> {
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
const [result] = await this.conn.execute(sql, params as any) as [mysql.ResultSetHeader, mysql.FieldPacket[]]
|
|
33
|
+
return {
|
|
34
|
+
changes: result.affectedRows,
|
|
35
|
+
lastId: result.insertId > 0 ? String(result.insertId) : undefined,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async close(): Promise<void> {
|
|
40
|
+
// no-op: la conexión la cierra MySQLAdapter.transaction() al finalizar
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class MySQLAdapter implements DbAdapter {
|
|
45
|
+
private pool!: mysql.Pool
|
|
46
|
+
|
|
47
|
+
constructor(private config: MySQLConfig) {}
|
|
48
|
+
|
|
49
|
+
async connect(): Promise<void> {
|
|
50
|
+
this.pool = mysql.createPool({
|
|
51
|
+
host: this.config.host,
|
|
52
|
+
port: this.config.port ?? 3306,
|
|
53
|
+
user: this.config.user,
|
|
54
|
+
password: this.config.password,
|
|
55
|
+
database: this.config.database,
|
|
56
|
+
connectionLimit: this.config.poolMax ?? 10,
|
|
57
|
+
waitForConnections: true,
|
|
58
|
+
queueLimit: 0,
|
|
59
|
+
})
|
|
60
|
+
// Verificar conexión al arrancar (fail fast)
|
|
61
|
+
const conn = await this.pool.getConnection()
|
|
62
|
+
conn.release()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async query(sql: string, params: unknown[] = []): Promise<unknown[]> {
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
|
+
const [rows] = await this.pool.execute(sql, params as any)
|
|
68
|
+
return rows as unknown[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async run(sql: string, params: unknown[] = []): Promise<{ changes: number; lastId?: string }> {
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
+
const [result] = await this.pool.execute(sql, params as any) as [mysql.ResultSetHeader, mysql.FieldPacket[]]
|
|
74
|
+
return {
|
|
75
|
+
changes: result.affectedRows,
|
|
76
|
+
lastId: result.insertId > 0 ? String(result.insertId) : undefined,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async transaction<T>(fn: (adapter: DbAdapter) => Promise<T>): Promise<T> {
|
|
81
|
+
const conn = await this.pool.getConnection()
|
|
82
|
+
await conn.beginTransaction()
|
|
83
|
+
try {
|
|
84
|
+
const result = await fn(new MySQLTransactionAdapter(conn))
|
|
85
|
+
await conn.commit()
|
|
86
|
+
return result
|
|
87
|
+
} catch (err) {
|
|
88
|
+
await conn.rollback()
|
|
89
|
+
throw err
|
|
90
|
+
} finally {
|
|
91
|
+
conn.release()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async close(): Promise<void> {
|
|
96
|
+
await this.pool.end()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// adapters/postgres.ts — Adapter Postgres
|
|
2
|
+
// Convierte ? placeholders (propios del ORM) a $1, $2, $3 (formato PostgreSQL)
|
|
3
|
+
|
|
4
|
+
import pg, { type Pool as PgPool } from 'pg'
|
|
5
|
+
import type { DbAdapter } from '../kernel/framework'
|
|
6
|
+
|
|
7
|
+
export interface PostgresConfig {
|
|
8
|
+
connectionString: string
|
|
9
|
+
poolMin?: number
|
|
10
|
+
poolMax?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class PostgresAdapter implements DbAdapter {
|
|
14
|
+
private pool!: PgPool
|
|
15
|
+
|
|
16
|
+
constructor(private config: PostgresConfig) {}
|
|
17
|
+
|
|
18
|
+
async connect(): Promise<void> {
|
|
19
|
+
this.pool = new pg.Pool({
|
|
20
|
+
connectionString: this.config.connectionString,
|
|
21
|
+
min: this.config.poolMin ?? 2,
|
|
22
|
+
max: this.config.poolMax ?? 10,
|
|
23
|
+
})
|
|
24
|
+
const client = await this.pool.connect()
|
|
25
|
+
client.release()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Convierte ? a $1, $2, $3... para compatibilidad con PostgreSQL
|
|
29
|
+
private convertPlaceholders(sql: string): string {
|
|
30
|
+
let index = 0
|
|
31
|
+
return sql.replace(/\?/g, () => `$${++index}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async query(sql: string, params: unknown[] = []): Promise<unknown[]> {
|
|
35
|
+
const pgSql = this.convertPlaceholders(sql)
|
|
36
|
+
const result = await this.pool.query(pgSql, params)
|
|
37
|
+
return result.rows
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async run(sql: string, params: unknown[] = []): Promise<{ changes: number; lastId?: string }> {
|
|
41
|
+
const pgSql = this.convertPlaceholders(sql)
|
|
42
|
+
const result = await this.pool.query(pgSql, params)
|
|
43
|
+
return {
|
|
44
|
+
changes: result.rowCount ?? 0,
|
|
45
|
+
lastId: result.rows[0]?.id ? String(result.rows[0].id) : undefined,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async close(): Promise<void> {
|
|
50
|
+
await this.pool.end()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// adapters/redis-cache.ts — Adapter Redis para cache
|
|
2
|
+
// SOLID: implementa CacheAdapter, reemplaza MemoryCache sin cambiar los módulos
|
|
3
|
+
|
|
4
|
+
import { createClient, type RedisClientType } from 'redis'
|
|
5
|
+
import type { CacheAdapter } from '../kernel/framework'
|
|
6
|
+
|
|
7
|
+
export interface RedisCacheConfig {
|
|
8
|
+
url: string
|
|
9
|
+
keyPrefix?: string
|
|
10
|
+
defaultTTL?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class RedisCacheAdapter implements CacheAdapter {
|
|
14
|
+
private client!: RedisClientType
|
|
15
|
+
private connected = false
|
|
16
|
+
|
|
17
|
+
constructor(private config: RedisCacheConfig) {}
|
|
18
|
+
|
|
19
|
+
async connect(): Promise<void> {
|
|
20
|
+
this.client = createClient({ url: this.config.url })
|
|
21
|
+
await this.client.connect()
|
|
22
|
+
this.connected = true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private prefix(key: string): string {
|
|
26
|
+
return this.config.keyPrefix ? `${this.config.keyPrefix}:${key}` : key
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async get<T>(key: string): Promise<T | null> {
|
|
30
|
+
if (!this.connected) return null
|
|
31
|
+
const val = await this.client.get(this.prefix(key))
|
|
32
|
+
if (!val) return null
|
|
33
|
+
try { return JSON.parse(val) as T } catch { return val as unknown as T }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
|
37
|
+
if (!this.connected) return
|
|
38
|
+
const serialized = typeof value === 'string' ? value : JSON.stringify(value)
|
|
39
|
+
const ttl = ttlSeconds ?? this.config.defaultTTL ?? 3600
|
|
40
|
+
await this.client.setEx(this.prefix(key), ttl, serialized)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async delete(key: string): Promise<void> {
|
|
44
|
+
if (!this.connected) return
|
|
45
|
+
await this.client.del(this.prefix(key))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async flush(): Promise<void> {
|
|
49
|
+
if (!this.connected) return
|
|
50
|
+
if (this.config.keyPrefix) {
|
|
51
|
+
const keys = await this.client.keys(`${this.config.keyPrefix}:*`)
|
|
52
|
+
if (keys.length) await this.client.del(keys)
|
|
53
|
+
} else {
|
|
54
|
+
await this.client.flushAll()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async shutdown(): Promise<void> {
|
|
59
|
+
if (this.connected) {
|
|
60
|
+
await this.client.quit()
|
|
61
|
+
this.connected = false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// adapters/sqlite.ts — Adapter SQLite
|
|
2
|
+
// Responsabilidad ÚNICA: traducir llamadas de DbAdapter a better-sqlite3
|
|
3
|
+
// SOLID: implementa DbAdapter, puede reemplazarse por PostgresAdapter sin cambiar el ORM
|
|
4
|
+
|
|
5
|
+
import Database from 'better-sqlite3'
|
|
6
|
+
import type { DbAdapter } from '../kernel/framework'
|
|
7
|
+
|
|
8
|
+
interface SqliteConfig {
|
|
9
|
+
path: string
|
|
10
|
+
wal?: boolean
|
|
11
|
+
foreignKeys?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Configuración con valores por defecto
|
|
15
|
+
const DEFAULT_CONFIG: SqliteConfig = {
|
|
16
|
+
path: './data/db.sqlite',
|
|
17
|
+
wal: true,
|
|
18
|
+
foreignKeys: true,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class SqliteAdapter implements DbAdapter {
|
|
22
|
+
private db!: Database.Database
|
|
23
|
+
private config: SqliteConfig
|
|
24
|
+
|
|
25
|
+
constructor(config?: Partial<SqliteConfig>) {
|
|
26
|
+
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async connect(): Promise<void> {
|
|
30
|
+
this.db = new Database(this.config.path)
|
|
31
|
+
|
|
32
|
+
if (this.config.wal) this.db.pragma('journal_mode = WAL')
|
|
33
|
+
if (this.config.foreignKeys) this.db.pragma('foreign_keys = ON')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
query(sql: string, params: unknown[] = []): Promise<unknown[]> {
|
|
37
|
+
const stmt = this.db.prepare(sql)
|
|
38
|
+
const rows = params.length > 0 ? stmt.all(...params) : stmt.all()
|
|
39
|
+
return Promise.resolve(rows)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
run(sql: string, params: unknown[] = []): Promise<{ changes: number; lastId?: string }> {
|
|
43
|
+
const stmt = this.db.prepare(sql)
|
|
44
|
+
const result = params.length > 0 ? stmt.run(...params) : stmt.run()
|
|
45
|
+
return Promise.resolve({
|
|
46
|
+
changes: result.changes,
|
|
47
|
+
lastId: result.lastInsertRowid ? String(result.lastInsertRowid) : undefined,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async transaction<T>(fn: (adapter: DbAdapter) => Promise<T>): Promise<T> {
|
|
52
|
+
// better-sqlite3 usa transacciones síncronas — necesitamos un wrapper async
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const txFn = this.db.transaction((args: unknown[]) => args[0])
|
|
55
|
+
// Ejecutar la función dentro de BEGIN/COMMIT de better-sqlite3
|
|
56
|
+
this.db.exec('BEGIN')
|
|
57
|
+
fn(this)
|
|
58
|
+
.then((result) => {
|
|
59
|
+
this.db.exec('COMMIT')
|
|
60
|
+
resolve(result)
|
|
61
|
+
})
|
|
62
|
+
.catch((err) => {
|
|
63
|
+
try { this.db.exec('ROLLBACK') } catch { /* ya hizo rollback */ }
|
|
64
|
+
reject(err)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
close(): Promise<void> {
|
|
70
|
+
this.db.close()
|
|
71
|
+
return Promise.resolve()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// adapters/vendor.d.ts — Minimal type stubs for optional peer dependencies.
|
|
2
|
+
// pg and redis are optional — users install them only when using those adapters.
|
|
3
|
+
// These stubs let TypeScript compile without the packages installed.
|
|
4
|
+
|
|
5
|
+
declare module 'pg' {
|
|
6
|
+
export interface PoolConfig {
|
|
7
|
+
connectionString?: string
|
|
8
|
+
min?: number
|
|
9
|
+
max?: number
|
|
10
|
+
[key: string]: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface QueryResult<R = Record<string, unknown>> {
|
|
14
|
+
rows: R[]
|
|
15
|
+
rowCount: number | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PoolClient {
|
|
19
|
+
release(): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Pool {
|
|
23
|
+
constructor(config?: PoolConfig)
|
|
24
|
+
connect(): Promise<PoolClient>
|
|
25
|
+
query(sql: string, params?: unknown[]): Promise<QueryResult>
|
|
26
|
+
end(): Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pg: {
|
|
30
|
+
Pool: typeof Pool
|
|
31
|
+
} & typeof import('pg')
|
|
32
|
+
export default pg
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
declare module 'redis' {
|
|
36
|
+
export type RedisClientType = {
|
|
37
|
+
connect(): Promise<void>
|
|
38
|
+
get(key: string): Promise<string | null>
|
|
39
|
+
set(key: string, value: string): Promise<unknown>
|
|
40
|
+
setEx(key: string, seconds: number, value: string): Promise<unknown>
|
|
41
|
+
del(keys: string | string[]): Promise<unknown>
|
|
42
|
+
keys(pattern: string): Promise<string[]>
|
|
43
|
+
flushAll(): Promise<unknown>
|
|
44
|
+
quit(): Promise<unknown>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createClient(options?: { url?: string; [key: string]: unknown }): RedisClientType
|
|
48
|
+
}
|
package/bin/arckode.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const dir = fileURLToPath(new URL('..', import.meta.url))
|
|
6
|
+
process.argv[1] = resolve(dir, 'cli/index.ts')
|
|
7
|
+
import(resolve(dir, 'cli/index.ts')).catch(console.error)
|