arckode-framework 1.3.1 → 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.
Files changed (65) hide show
  1. package/adapters/jwt.ts +6 -4
  2. package/adapters/mysql.ts +7 -2
  3. package/adapters/postgres.ts +37 -0
  4. package/adapters/sqlite.ts +7 -1
  5. package/adapters/vendor.d.ts +48 -0
  6. package/cli/analyze/checks.ts +333 -0
  7. package/cli/analyze/index.ts +44 -0
  8. package/cli/analyze/report.ts +107 -0
  9. package/cli/analyze/types.ts +46 -0
  10. package/cli/analyze/utils.ts +36 -0
  11. package/cli/analyze.ts +2 -647
  12. package/cli/commands/db-migrate.ts +213 -89
  13. package/cli/commands/db-seed.ts +97 -32
  14. package/cli/commands/db-utils.ts +192 -0
  15. package/cli/commands/new.ts +175 -0
  16. package/cli/commands/routes.ts +94 -0
  17. package/cli/index.ts +57 -404
  18. package/cli/stubs/claude-md-stub.ts +21 -8
  19. package/cli/stubs/module/core.ts +162 -0
  20. package/cli/stubs/module/data.ts +171 -0
  21. package/cli/stubs/module/index.ts +5 -0
  22. package/cli/stubs/module/service.ts +198 -0
  23. package/cli/stubs/module/types.ts +12 -0
  24. package/cli/stubs/module-stub.ts +2 -552
  25. package/kernel/auth.ts +114 -0
  26. package/kernel/cache.ts +37 -0
  27. package/kernel/config.ts +129 -0
  28. package/kernel/container.ts +64 -0
  29. package/kernel/db/orm-migrate.ts +136 -0
  30. package/kernel/db/orm-repository.ts +45 -0
  31. package/kernel/db/orm-utils.ts +93 -0
  32. package/kernel/db/orm.ts +254 -0
  33. package/kernel/db/transactor.ts +17 -0
  34. package/kernel/db/types.ts +72 -0
  35. package/kernel/errors.ts +102 -0
  36. package/kernel/framework.default.ts +41 -0
  37. package/kernel/framework.ts +8 -2144
  38. package/kernel/http/router.ts +131 -0
  39. package/kernel/http/server.ts +303 -0
  40. package/kernel/http/types.ts +56 -0
  41. package/kernel/index.ts +25 -0
  42. package/kernel/logger.ts +50 -0
  43. package/kernel/middlewares.ts +38 -21
  44. package/kernel/modules/create-module.ts +5 -0
  45. package/kernel/modules/system.ts +149 -0
  46. package/kernel/modules/types.ts +46 -0
  47. package/kernel/seeds.ts +48 -0
  48. package/kernel/static.ts +11 -2
  49. package/kernel/testing.ts +8 -3
  50. package/kernel/validator.ts +116 -0
  51. package/modules/events/index.ts +19 -3
  52. package/modules/mail/index.ts +14 -2
  53. package/modules/storage/local-adapter.ts +19 -5
  54. package/modules/ws/index.ts +123 -18
  55. package/package.json +8 -11
  56. package/skills/auth/SKILL.md +36 -220
  57. package/skills/cli/SKILL.md +32 -251
  58. package/skills/config/SKILL.md +30 -239
  59. package/skills/connectors/SKILL.md +32 -295
  60. package/skills/helpers/SKILL.md +26 -195
  61. package/skills/middlewares/SKILL.md +30 -267
  62. package/skills/orm/SKILL.md +42 -349
  63. package/skills/realtime/SKILL.md +22 -297
  64. package/skills/services/SKILL.md +40 -183
  65. package/skills/testing/SKILL.md +34 -266
@@ -0,0 +1,37 @@
1
+ export interface CacheAdapter {
2
+ get<T>(key: string): Promise<T | null>
3
+ set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>
4
+ delete(key: string): Promise<void>
5
+ flush(): Promise<void>
6
+ }
7
+
8
+ export class MemoryCache implements CacheAdapter {
9
+ private store = new Map<string, { value: unknown; expiresAt: number }>()
10
+ private hits = 0
11
+ private misses = 0
12
+
13
+ async get<T>(key: string): Promise<T | null> {
14
+ const entry = this.store.get(key)
15
+ if (!entry) { this.misses++; return null }
16
+ if (Date.now() > entry.expiresAt) { this.store.delete(key); this.misses++; return null }
17
+ this.hits++
18
+ return entry.value as T
19
+ }
20
+
21
+ async set<T>(key: string, value: T, ttlSeconds = 3600): Promise<void> {
22
+ this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 })
23
+ }
24
+
25
+ async delete(key: string): Promise<void> { this.store.delete(key) }
26
+ async flush(): Promise<void> { this.store.clear() }
27
+
28
+ get stats() {
29
+ const total = this.hits + this.misses
30
+ return {
31
+ size: this.store.size,
32
+ hits: this.hits,
33
+ misses: this.misses,
34
+ hitRate: total > 0 ? `${((this.hits / total) * 100).toFixed(1)}%` : '0%',
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,129 @@
1
+ import { InternalError } from './errors'
2
+
3
+ export async function loadEnv(opts: { cwd?: string } = {}): Promise<Record<string, string | undefined>> {
4
+ const { readFile } = await import('node:fs/promises')
5
+ const { join } = await import('node:path')
6
+
7
+ const cwd = opts.cwd ?? process.cwd()
8
+ const stage = (process.env.NODE_ENV ?? 'development').toLowerCase()
9
+
10
+ const parseEnvFile = (content: string): Record<string, string> => {
11
+ const result: Record<string, string> = {}
12
+ for (const line of content.split('\n')) {
13
+ const trimmed = line.trim()
14
+ if (!trimmed || trimmed.startsWith('#')) continue
15
+ const eq = trimmed.indexOf('=')
16
+ if (eq === -1) continue
17
+ const key = trimmed.slice(0, eq).trim()
18
+ let val = trimmed.slice(eq + 1).trim()
19
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
20
+ val = val.slice(1, -1)
21
+ }
22
+ result[key] = val
23
+ }
24
+ return result
25
+ }
26
+
27
+ const tryRead = async (path: string): Promise<Record<string, string>> => {
28
+ try { return parseEnvFile(await readFile(path, 'utf-8')) } catch { return {} }
29
+ }
30
+
31
+ const base = await tryRead(join(cwd, '.env'))
32
+ const staged = await tryRead(join(cwd, `.env.${stage}`))
33
+
34
+ return { ...base, ...staged, ...process.env }
35
+ }
36
+
37
+ export type ConfigType = 'string' | 'number' | 'boolean' | 'url' | 'email'
38
+
39
+ export interface ConfigDefinition {
40
+ type: ConfigType
41
+ required?: boolean
42
+ default?: string | number | boolean
43
+ description?: string
44
+ secret?: boolean
45
+ }
46
+
47
+ export class ConfigStore {
48
+ private definitions = new Map<string, ConfigDefinition>()
49
+ private values = new Map<string, unknown>()
50
+ private isLoaded = false
51
+
52
+ define(schema: Record<string, ConfigDefinition>): this {
53
+ for (const [key, def] of Object.entries(schema)) {
54
+ this.definitions.set(key, def)
55
+ }
56
+ return this
57
+ }
58
+
59
+ load(source: Record<string, string | undefined>): this {
60
+ const errors: string[] = []
61
+
62
+ for (const [key, def] of this.definitions) {
63
+ const raw = source[key] ?? def.default
64
+
65
+ if (raw == null || raw === '') {
66
+ if (def.required) errors.push(`CONFIG: ${key} es requerido`)
67
+ continue
68
+ }
69
+
70
+ let parsed: unknown
71
+
72
+ switch (def.type) {
73
+ case 'string':
74
+ parsed = String(raw)
75
+ break
76
+ case 'number': {
77
+ const n = Number(raw)
78
+ if (isNaN(n)) { errors.push(`CONFIG: ${key} must be a number`); continue }
79
+ parsed = n
80
+ break
81
+ }
82
+ case 'boolean':
83
+ parsed = raw === 'true' || raw === '1'
84
+ break
85
+ case 'url':
86
+ try { new URL(String(raw)); parsed = String(raw) }
87
+ catch { errors.push(`CONFIG: ${key} is not a valid URL`); continue }
88
+ break
89
+ case 'email':
90
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(raw))) {
91
+ errors.push(`CONFIG: ${key} is not a valid email`); continue
92
+ }
93
+ parsed = String(raw)
94
+ break
95
+ }
96
+
97
+ this.values.set(key, parsed)
98
+ }
99
+
100
+ if (errors.length > 0) {
101
+ throw new InternalError(`Invalid configuration:\n${errors.join('\n')}`)
102
+ }
103
+
104
+ this.isLoaded = true
105
+ return this
106
+ }
107
+
108
+ get<T = string>(key: string): T {
109
+ if (!this.isLoaded) throw new InternalError('Config no cargada — llamar load()')
110
+ if (!this.values.has(key)) throw new InternalError(`Config "${key}" no definida`)
111
+ return this.values.get(key) as T
112
+ }
113
+
114
+ /** @deprecated Usar get<T>(key) — hace exactamente lo mismo */
115
+ getOrThrow<T = string>(key: string): T {
116
+ return this.get<T>(key)
117
+ }
118
+
119
+ toJSON(): Record<string, unknown> {
120
+ const result: Record<string, unknown> = {}
121
+ for (const [key] of this.definitions) {
122
+ if (this.values.has(key)) {
123
+ const def = this.definitions.get(key)!
124
+ result[key] = def.secret ? '••••••' : this.values.get(key)
125
+ }
126
+ }
127
+ return result
128
+ }
129
+ }
@@ -0,0 +1,64 @@
1
+ import { InternalError } from './errors'
2
+
3
+ interface ServiceEntry<T> {
4
+ name: string
5
+ factory: () => T
6
+ instance?: T
7
+ destructor?: () => Promise<void>
8
+ singleton: boolean
9
+ }
10
+
11
+ export class Container {
12
+ private services = new Map<string, ServiceEntry<unknown>>()
13
+ private initialized = false
14
+
15
+ register<T>(name: string, factory: () => T, destructor?: () => Promise<void>): this {
16
+ if (this.initialized) throw new InternalError(`Container: "${name}" registered after init`)
17
+ this.services.set(name, { name, factory, destructor, singleton: true })
18
+ return this
19
+ }
20
+
21
+ registerTransient<T>(name: string, factory: () => T): this {
22
+ if (this.initialized) throw new InternalError(`Container: "${name}" registered after init`)
23
+ this.services.set(name, { name, factory, singleton: false })
24
+ return this
25
+ }
26
+
27
+ resolve<T>(name: string): T {
28
+ const entry = this.services.get(name) as ServiceEntry<T> | undefined
29
+ if (!entry) throw new InternalError(`Container: "${name}" no registrado`)
30
+ if (!entry.singleton) return entry.factory() as T
31
+ if (entry.instance !== undefined) return entry.instance
32
+ entry.instance = entry.factory()
33
+ return entry.instance as T
34
+ }
35
+
36
+ /** @deprecated Usar resolve<T>(name) — hace exactamente lo mismo */
37
+ get<T>(name: string): T {
38
+ return this.resolve<T>(name)
39
+ }
40
+
41
+ init(): void {
42
+ this.initialized = true
43
+ for (const [name] of this.services) {
44
+ this.resolve(name)
45
+ }
46
+ }
47
+
48
+ async destroy(): Promise<void> {
49
+ const entries = [...this.services.values()].reverse()
50
+ for (const entry of entries) {
51
+ if (entry.destructor) {
52
+ try { await entry.destructor() } catch (e) {
53
+ console.error(`[Container] Error al destruir "${entry.name}":`, e)
54
+ }
55
+ }
56
+ }
57
+ this.services.clear()
58
+ this.initialized = false
59
+ }
60
+
61
+ get registered(): string[] {
62
+ return [...this.services.keys()]
63
+ }
64
+ }
@@ -0,0 +1,136 @@
1
+ import type { DbAdapter, ModelDefinition } from './types'
2
+ import { assertSafeIdentifier, fieldTypeToSQL } from './orm-utils'
3
+
4
+ async function getTableColumns(db: DbAdapter, table: string): Promise<string[]> {
5
+ try {
6
+ const rows = await db.query(`PRAGMA table_info(${table})`)
7
+ if (Array.isArray(rows) && rows.length > 0 && 'name' in (rows[0] as object)) {
8
+ return (rows as { name: string }[]).map(r => r.name)
9
+ }
10
+ } catch { /* not SQLite */ }
11
+
12
+ try {
13
+ const rows = await db.query(
14
+ 'SELECT column_name FROM information_schema.columns WHERE table_name = ? AND table_schema = DATABASE()',
15
+ [table],
16
+ )
17
+ if (Array.isArray(rows) && rows.length > 0 && 'column_name' in (rows[0] as object)) {
18
+ return (rows as { column_name: string }[]).map(r => r.column_name)
19
+ }
20
+ } catch { /* not MySQL */ }
21
+
22
+ try {
23
+ const rows = await db.query(
24
+ "SELECT column_name FROM information_schema.columns WHERE table_name = ? AND table_schema = 'public'",
25
+ [table],
26
+ )
27
+ if (Array.isArray(rows) && rows.length > 0) {
28
+ return (rows as { column_name: string }[]).map(r => r.column_name)
29
+ }
30
+ } catch { /* adapter doesn't support introspection */ }
31
+
32
+ return []
33
+ }
34
+
35
+ export async function ormMigrate(
36
+ db: DbAdapter,
37
+ models: Map<string, ModelDefinition>,
38
+ opts: { allowDrop?: boolean } = {},
39
+ ): Promise<void> {
40
+ await db.run(
41
+ `CREATE TABLE IF NOT EXISTS _arckode_schema (table_name TEXT PRIMARY KEY, migratedAt TEXT)`
42
+ )
43
+
44
+ for (const [modelName, def] of models) {
45
+ assertSafeIdentifier(def.table, `modelo "${modelName}" → table`)
46
+ for (const fieldName of Object.keys(def.fields)) {
47
+ assertSafeIdentifier(fieldName, `modelo "${modelName}" → campo`)
48
+ }
49
+
50
+ const hasExplicitId = Object.keys(def.fields).includes('id')
51
+ const columns = Object.entries(def.fields).map(([name, field]) => {
52
+ const sqlType = fieldTypeToSQL(field.type)
53
+ const parts = [name, sqlType]
54
+
55
+ if (name === 'id') parts.push('PRIMARY KEY')
56
+ if (field.required) parts.push('NOT NULL')
57
+ if (field.unique) parts.push('UNIQUE')
58
+ if (field.default !== undefined) {
59
+ parts.push(`DEFAULT ${typeof field.default === 'string' ? `'${field.default.replace(/'/g, "''")}'` : field.default}`)
60
+ }
61
+
62
+ return parts.join(' ')
63
+ })
64
+
65
+ if (!hasExplicitId) columns.unshift('id TEXT PRIMARY KEY')
66
+
67
+ if (def.timestamps) {
68
+ columns.push('createdAt TEXT')
69
+ columns.push('updatedAt TEXT')
70
+ }
71
+
72
+ if (def.softDelete) columns.push('deletedAt TEXT')
73
+
74
+ await db.run(`CREATE TABLE IF NOT EXISTS ${def.table} (${columns.join(', ')})`)
75
+
76
+ for (const [fieldName, field] of Object.entries(def.fields)) {
77
+ if (field.indexed && !field.unique) {
78
+ const idxName = `idx_${def.table}_${fieldName}`
79
+ try {
80
+ await db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${def.table}(${fieldName})`)
81
+ } catch { /* index already exists */ }
82
+ }
83
+ }
84
+
85
+ for (const [name, field] of Object.entries(def.fields)) {
86
+ const sqlType = fieldTypeToSQL(field.type)
87
+ try {
88
+ await db.run(`ALTER TABLE ${def.table} ADD COLUMN ${name} ${sqlType}`)
89
+ } catch { /* column already exists */ }
90
+
91
+ if (field.nullable === true) {
92
+ try {
93
+ await db.run(`ALTER TABLE ${def.table} ALTER COLUMN ${name} DROP NOT NULL`)
94
+ } catch { /* SQLite doesn't support this */ }
95
+ }
96
+ }
97
+
98
+ if (def.timestamps) {
99
+ try { await db.run(`ALTER TABLE ${def.table} ADD COLUMN createdAt TEXT`) } catch { /* already exists */ }
100
+ try { await db.run(`ALTER TABLE ${def.table} ADD COLUMN updatedAt TEXT`) } catch { /* already exists */ }
101
+ }
102
+ if (def.softDelete) {
103
+ try { await db.run(`ALTER TABLE ${def.table} ADD COLUMN deletedAt TEXT`) } catch { /* already exists */ }
104
+ }
105
+
106
+ const definedCols = new Set([
107
+ 'id',
108
+ ...Object.keys(def.fields).map(k => k.toLowerCase()),
109
+ ...(def.timestamps ? ['createdat', 'updatedat'] : []),
110
+ ...(def.softDelete ? ['deletedat'] : []),
111
+ ])
112
+
113
+ const dbCols = await getTableColumns(db, def.table)
114
+
115
+ for (const col of dbCols) {
116
+ if (definedCols.has(col.toLowerCase())) continue
117
+
118
+ if (opts.allowDrop) {
119
+ try {
120
+ await db.run(`ALTER TABLE ${def.table} DROP COLUMN ${col}`)
121
+ console.warn(`[arckode/migrate] Column "${col}" dropped from "${def.table}" — no longer in model`)
122
+ } catch {
123
+ console.warn(
124
+ `[arckode/migrate] ⚠ Orphan column "${col}" in table "${def.table}" — ` +
125
+ `could not be auto-dropped. Create a manual migration: arckode make:migration drop_${col}_from_${def.table}`
126
+ )
127
+ }
128
+ } else {
129
+ console.warn(
130
+ `[arckode/migrate] ⚠ Orphan column "${col}" in table "${def.table}" — ` +
131
+ `not in model. To drop it: orm.migrate({ allowDrop: true }) or arckode make:migration drop_${col}_from_${def.table}`
132
+ )
133
+ }
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,45 @@
1
+ import type { FindOptions, ModelResult, PageResult, RepositoryAdapter } from './types'
2
+ import { ORM } from './orm'
3
+
4
+ export class OrmRepository<T extends object = Record<string, unknown>>
5
+ implements RepositoryAdapter<T> {
6
+ constructor(
7
+ private readonly orm: ORM,
8
+ private readonly modelName: string,
9
+ ) {}
10
+
11
+ findMany(filters?: Record<string, unknown>, options?: FindOptions): Promise<T[]> {
12
+ return this.orm.findMany<T>(this.modelName, filters, options)
13
+ }
14
+
15
+ findById(id: string, select?: string[]): Promise<T | null> {
16
+ return this.orm.findById<T>(this.modelName, id, select)
17
+ }
18
+
19
+ findOne(filters: Record<string, unknown>): Promise<T | null> {
20
+ return this.orm.findOne<T>(this.modelName, filters)
21
+ }
22
+
23
+ create(data: Omit<T, 'id'>): Promise<T> {
24
+ return this.orm.create<T>(this.modelName, data as Record<string, unknown>)
25
+ }
26
+
27
+ update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null> {
28
+ return this.orm.update<T>(this.modelName, id, data as Record<string, unknown>)
29
+ }
30
+
31
+ delete(id: string): Promise<boolean> {
32
+ return this.orm.delete(this.modelName, id)
33
+ }
34
+
35
+ count(filters?: Record<string, unknown>): Promise<number> {
36
+ return this.orm.count(this.modelName, filters)
37
+ }
38
+
39
+ paginate(
40
+ filters?: Record<string, unknown>,
41
+ options: FindOptions & { limit: number } = { limit: 20 },
42
+ ): Promise<PageResult<T>> {
43
+ return this.orm.paginate<T>(this.modelName, filters, options)
44
+ }
45
+ }
@@ -0,0 +1,93 @@
1
+ import { ValidationError } from '../errors'
2
+ import type { FieldDefinition, ModelDefinition, ModelResult } from './types'
3
+
4
+ export function assertSafeIdentifier(value: string, context: string): void {
5
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
6
+ throw new ValidationError(`Invalid SQL identifier in ${context}: "${value}". Only letters, numbers and underscores.`)
7
+ }
8
+ }
9
+
10
+ export function fieldTypeToSQL(type: FieldDefinition['type']): string {
11
+ const map: Record<FieldDefinition['type'], string> = {
12
+ string: 'TEXT',
13
+ text: 'TEXT',
14
+ number: 'REAL',
15
+ boolean: 'BOOLEAN',
16
+ json: 'TEXT',
17
+ date: 'TEXT',
18
+ }
19
+ return map[type]
20
+ }
21
+
22
+ export function serializeForDb(def: ModelDefinition, record: Record<string, unknown>): Record<string, unknown> {
23
+ const out: Record<string, unknown> = {}
24
+ for (const [k, v] of Object.entries(record)) {
25
+ if (def.fields[k]?.type === 'json' && v !== null && v !== undefined && typeof v !== 'string') {
26
+ out[k] = JSON.stringify(v)
27
+ } else {
28
+ out[k] = v
29
+ }
30
+ }
31
+ return out
32
+ }
33
+
34
+ export function deserializeFromDb(def: ModelDefinition, row: ModelResult): ModelResult {
35
+ const result = { ...row } as Record<string, unknown>
36
+
37
+ for (const [k, field] of Object.entries(def.fields)) {
38
+ if (field.type === 'json' && typeof result[k] === 'string') {
39
+ try { result[k] = JSON.parse(result[k] as string) } catch { console.warn(`[ORM] deserialize: campo "${k}" tiene JSON inválido en DB, se mantiene como string`) }
40
+ }
41
+ }
42
+
43
+ const tsMap: Record<string, string> = {
44
+ createdat: 'createdAt',
45
+ updatedat: 'updatedAt',
46
+ deletedat: 'deletedAt',
47
+ }
48
+ for (const [lower, camel] of Object.entries(tsMap)) {
49
+ if (lower in result && !(camel in result)) {
50
+ result[camel] = result[lower]
51
+ delete result[lower]
52
+ }
53
+ }
54
+
55
+ return result as ModelResult
56
+ }
57
+
58
+ export function getAllowedFields(def: ModelDefinition): Set<string> {
59
+ const allowed = new Set(['id', ...Object.keys(def.fields)])
60
+ if (def.timestamps) { allowed.add('createdAt'); allowed.add('updatedAt') }
61
+ if (def.softDelete) allowed.add('deletedAt')
62
+ return allowed
63
+ }
64
+
65
+ export function buildSelect(def: ModelDefinition, fields?: string[]): string {
66
+ if (!fields || fields.length === 0) return '*'
67
+ const allowed = getAllowedFields(def)
68
+ for (const f of fields) {
69
+ if (!allowed.has(f)) throw new ValidationError(`Invalid select field: "${f}"`)
70
+ }
71
+ return fields.join(', ')
72
+ }
73
+
74
+ export function buildWhere(def: ModelDefinition, filters?: Record<string, unknown>): { clause: string; params: unknown[] } {
75
+ const parts: string[] = []
76
+ const params: unknown[] = []
77
+
78
+ if (filters && Object.keys(filters).length > 0) {
79
+ const allowed = getAllowedFields(def)
80
+ for (const [k, v] of Object.entries(filters)) {
81
+ if (!allowed.has(k)) throw new ValidationError(`Invalid filter field: "${k}"`)
82
+ params.push(v)
83
+ parts.push(`${k} = ?`)
84
+ }
85
+ }
86
+
87
+ if (def.softDelete) parts.push('deletedAt IS NULL')
88
+
89
+ return {
90
+ clause: parts.length > 0 ? ' WHERE ' + parts.join(' AND ') : '',
91
+ params,
92
+ }
93
+ }