arckode-framework 1.3.2 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/cache.ts
ADDED
|
@@ -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
|
+
}
|
package/kernel/config.ts
ADDED
|
@@ -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
|
+
}
|