SQL con SQLite y TypeScript: Base de Datos para DevJobs
Integra una base de datos SQLite en la API Express usando better-sqlite3, TypeScript y Zod. Aprende SQL nativo con JOINs, GROUP_CONCAT y tipos estrictos.
Use this file to discover all available pages before exploring further.
Este módulo añade una base de datos SQLite real al backend de la API DevJobs, completamente tipada con TypeScript. A diferencia del módulo Express (que usaba un array en memoria), aquí los datos persisten en disco, las consultas utilizan SQL nativo con JOINs y los tipos de TypeScript se infieren directamente de los esquemas Zod.
El archivo db/seed.ts define las tablas con CREATE TABLE IF NOT EXISTS y las puebla con datos de ejemplo mediante una transacción atómica:
db.exec(` CREATE TABLE IF NOT EXISTS jobs ( id TEXT PRIMARY KEY, title TEXT NOT NULL, company TEXT NOT NULL, location TEXT NOT NULL, description TEXT NOT NULL, modality TEXT NOT NULL CHECK(modality IN ('remote', 'onsite', 'hybrid')), level TEXT NOT NULL CHECK(level IN ('junior', 'mid', 'senior')) ); CREATE TABLE IF NOT EXISTS job_technologies ( job_id TEXT NOT NULL, technology TEXT NOT NULL, PRIMARY KEY (job_id, technology), FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS job_content ( job_id TEXT PRIMARY KEY, description TEXT NOT NULL, responsibilities TEXT NOT NULL, requirements TEXT NOT NULL, about TEXT NOT NULL, FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE );`)
Tabla principal. Almacena los datos básicos del empleo: título, empresa, ubicación, modalidad y nivel. Usa CHECK constraints para valores enumerados.
job_technologies
Relación N:M entre empleos y tecnologías. Clave primaria compuesta (job_id, technology). ON DELETE CASCADE elimina las tecnologías al borrar el empleo.
job_content
Contenido detallado del empleo (descripción larga, responsabilidades, requisitos, descripción de empresa). Relación 1:1 con jobs, también con ON DELETE CASCADE.
const insertJob = db.prepare(` INSERT OR IGNORE INTO jobs (id, title, company, location, description, modality, level) VALUES (?, ?, ?, ?, ?, ?, ?)`)const insertTech = db.prepare(` INSERT OR IGNORE INTO job_technologies (job_id, technology) VALUES (?, ?)`)const seed = db.transaction(() => { insertJob.run('1', 'Senior Frontend Developer', 'Tech Corp', 'Madrid, Spain', 'Looking for a senior frontend developer', 'hybrid', 'senior') insertTech.run('1', 'React') insertTech.run('1', 'TypeScript') insertTech.run('1', 'CSS') insertJob.run('2', 'Full Stack Developer', 'StartupX', 'Remote', 'Join our team as a full stack developer', 'remote', 'mid') insertTech.run('2', 'Node.js') insertTech.run('2', 'React') insertTech.run('2', 'PostgreSQL') insertJob.run('3', 'Junior Backend Developer', 'FinTech Solutions', 'Barcelona, Spain', 'Great opportunity for junior developers', 'onsite', 'junior') insertTech.run('3', 'Node.js') insertTech.run('3', 'TypeScript') insertTech.run('3', 'MongoDB')})seed()
db.transaction() envuelve múltiples inserts en una transacción atómica: si cualquier insert falla, todos se revierten. INSERT OR IGNORE evita duplicados al re-ejecutar el seed.
El archivo types.ts define las interfaces que representan las entidades y los DTOs (Data Transfer Objects):
// Entidad principalexport interface Job { id: string title: string company: string location: string description: string data: JobData content?: JobContent}// Sub-entidad: datos estructurados del puestoexport interface JobData { technology: string[] modality: "remote" | "onsite" | "hybrid" level: "junior" | "mid" | "senior"}// Sub-entidad: contenido detallado (opcional)export interface JobContent { description: string responsibilities: string requirements: string about: string}// DTOs — derivados de la entidad con Utility Typesexport type CreateJobDTO = Omit<Job, "id"> // Todo excepto el idexport type UpdateJobDTO = Partial<CreateJobDTO> // Todo opcional para PATCH// Filtros para las consultasexport interface JobFilters { tech?: string modality?: JobData["modality"] level?: JobData["level"]}
Los tipos CreateJobDTO y UpdateJobDTO se construyen directamente desde Job usando Utility Types de TypeScript (Omit y Partial), manteniendo una única fuente de verdad para la estructura del empleo.
static async getAll(filters?: JobFilters): Promise<Job[]> { let query = ` SELECT j.*, GROUP_CONCAT(jt.technology) AS technologies FROM jobs j JOIN job_technologies jt ON j.id = jt.job_id ` const conditions: string[] = [] const params: unknown[] = [] if (filters?.tech) { conditions.push(``); params.push(filters.tech) } if (filters?.modality) { conditions.push(``); params.push(filters.modality) } if (filters?.level) { conditions.push(``); params.push(filters.level) } if (conditions.length > 0) { query += 'WHERE ' + conditions.join(' AND ') } query += ' GROUP BY j.id' const rows = db.prepare(query).all(...params) return rows.map(row => ({ id: row.id, title: row.title, company: row.company, location: row.location, description: row.description, data: { technology: row.technologies.split(','), modality: row.modality, level: row.level } }))}
GROUP_CONCAT(jt.technology) agrega todas las tecnologías de un empleo en una sola cadena separada por comas (p. ej. "React,TypeScript,CSS"). Después se hace .split(',') para restaurar el array. El GROUP BY j.id es necesario para que la agregación funcione correctamente.
El método update hace un deep merge del objeto data para que un PATCH parcial (p. ej. solo cambiar level) no sobreescriba el resto de los campos del objeto anidado.
El archivo schemas/job.ts define los esquemas de validación y exporta los tipos TypeScript inferidos directamente de ellos, eliminando la necesidad de mantener tipos y validaciones en sincronía manualmente:
import { z } from 'zod'export const jobDataSchema = z.object({ technology: z.array(z.string()), modality: z.enum(["remote", "onsite", "hybrid"]), level: z.enum(["junior", "mid", "senior"])})export const jobContentSchema = z.object({ description: z.string(), responsibilities: z.string(), requirements: z.string(), about: z.string()})export const jobSchema = z.object({ title: z.string({ required_error: "Title is required", invalid_type_error: "Title must be a string" }).min(3, "Title must be at least 3 characters"), company: z.string({ required_error: "Company is required" }), location: z.string({ required_error: "Location is required" }), description: z.string({ required_error: "Description is required" }), data: jobDataSchema, content: jobContentSchema.optional()})// Tipos inferidos automáticamente — no duplicar la definiciónexport type JobInput = z.infer<typeof jobSchema>export type JobDataInput = z.infer<typeof jobDataSchema>export type JobContentInput = z.infer<typeof jobContentSchema>// Funciones de validaciónexport function validateJob(input: unknown) { return jobSchema.safeParse(input)}export function validatePartialJob(input: unknown) { return jobSchema.partial().safeParse(input)}
z.infer<typeof jobSchema> extrae el tipo TypeScript que equivale al esquema Zod. Esto garantiza que el tipo y la validación nunca se desincronicen.
tsx transpila TypeScript on-the-fly y reinicia el servidor al detectar cambios.
3
Compila para producción
npm run build # tsc → genera dist/npm run start:prod # node dist/app.js
4
Prueba los endpoints
# Health checkcurl http://localhost:3000/# Listar empleoscurl "http://localhost:3000/jobs"# Filtrar por tecnología y nivelcurl "http://localhost:3000/jobs?tech=React&level=senior"# Crear un empleocurl -X POST http://localhost:3000/jobs \ -H "Content-Type: application/json" \ -d '{ "title": "Backend Developer", "company": "MiduCorp", "location": "Remote", "description": "Buscamos un backend developer", "data": { "technology": ["Node.js", "TypeScript"], "modality": "remote", "level": "mid" } }'
El archivo db/seed.ts debe ejecutarse una sola vez para crear las tablas e insertar los datos iniciales. Al usar INSERT OR IGNORE, re-ejecutarlo es seguro, pero si modificas el schema deberás eliminar el archivo .db manualmente para que los cambios surtan efecto.