Tres horas. Media hora para aprender a leer y escribir ficheros sin corromperlos y a distinguir un fallo esperable de uno inesperado, y dos horas y media sacando el catálogo del código y poniéndolo en disco.
Hoy · Hoja de ruta
- 1. Aprende: Cómo se leen y escriben ficheros con promesas, cómo se componen rutas portables, cómo se implementa un CRUD sobre un fichero sin destruirlo, y qué se hace con cada familia de error.
- 2. Haz: Pon el catálogo en un fichero JSON, termina el almacén con sus cinco operaciones y da al proyecto un tratamiento de errores coherente.
- 3. Comprueba: Un fallo a mitad de escritura no destruye tus datos, y ningún error deja el programa a medias en silencio.
Antes de empezar · 5 minutos, sin apuntes
- ¿Qué pasa hoy con tus datos cuando el programa termina?
- Si el programa se interrumpe mientras escribe el fichero, ¿qué queda?
- ¿Es lo mismo «este producto no existe» que «el disco está lleno»?
Se explica
25 minutos · conceptos y demostración
Hoy los datos dejan de vivir en memoria, y con eso aparecen tres problemas nuevos: dónde está el fichero, qué pasa si la escritura se corta, y qué hacer con lo que falla. Los tres tienen respuestas conocidas.
La API de promesas
import { readFile, writeFile, mkdir } from "node:fs/promises";
const texto = await readFile(ruta, "utf8");
await writeFile(ruta, JSON.stringify(datos, null, 2), "utf8");
await mkdir(carpeta, { recursive: true });
Node ofrece tres formas de trabajar con ficheros: con callbacks, síncrona y con promesas. Usamos la de promesas, porque encaja con el async/await de la UD4 y porque la síncrona bloquea el proceso entero: en un servidor, leer un fichero de forma síncrona deja a todas las peticiones esperando.
El "utf8" no es opcional: sin él se reciben datos en bruto en lugar de texto y los acentos se representan de forma incorrecta.
Rutas portables
import path from "node:path";
import { fileURLToPath } from "node:url";
const aqui = path.dirname(fileURLToPath(import.meta.url));
const RUTA_DATOS = path.join(aqui, "..", "datos", "productos.json");
Nunca concatenes rutas con barras
Escribir carpeta + "/" + fichero funciona en tu máquina y falla en otra. path.join pone el separador que corresponde al sistema y limpia los tramos sobrantes.
Y componla siempre desde la posición del fichero, no desde donde se ejecutó el programa: si dependes de process.cwd(), tu servidor arrancará bien desde la carpeta del proyecto y fallará desde cualquier otra.
| Código | Significa |
|---|---|
ENOENT |
No existe el fichero o la carpeta |
EACCES |
Sin permisos |
EISDIR |
Es una carpeta, no un fichero |
ENOTEMPTY |
La carpeta no está vacía |
try {
const texto = await readFile(RUTA_DATOS, "utf8");
return JSON.parse(texto);
} catch (error) {
if (error.code === "ENOENT") return []; // primera ejecución: no hay datos
throw error; // cualquier otra cosa, que suba
}
Ese patrón —tratar el error que sabes tratar y dejar subir el resto— es el de la UD3, ahora con nombres concretos.
Las cuatro operaciones
export async function listar() {
return leerProductos();
}
export async function obtener(id) {
const productos = await leerProductos();
return productos.find((p) => p.id === id) ?? null;
}
export async function crear(datos) {
const productos = await leerProductos();
const id = productos.reduce((max, p) => Math.max(max, p.id), 0) + 1;
const nuevo = { id, ...datos, creadoEn: new Date().toISOString() };
await guardarProductos([...productos, nuevo]);
return nuevo;
}
export async function borrar(id) {
const productos = await leerProductos();
const restantes = productos.filter((p) => p.id !== id);
if (restantes.length === productos.length) return false;
await guardarProductos(restantes);
return true;
}
Fíjate en dos cosas. El identificador no es la longitud del array, porque tras borrar el tercero de tres volvería a repetirse el 3. Y borrar devuelve si borró algo, para que quien llame pueda responder 404 en la UD6.
Escribe en un temporal y renombra
Si el proceso muere a mitad de un writeFile, el fichero queda cortado: un JSON inválido, es decir, la pérdida completa de los datos. La reescritura íntegra es precisamente lo que realiza este almacén en cada operación.
La solución estándar: escribir en un fichero temporal y renombrarlo encima del bueno. Renombrar dentro del mismo disco es una operación indivisible, así que el fichero de datos siempre está completo, con el contenido viejo o con el nuevo.
import { writeFile, rename } from "node:fs/promises";
export async function guardarProductos(productos) {
const temporal = `${RUTA_DATOS}.tmp`;
await writeFile(temporal, JSON.stringify(productos, null, 2), "utf8");
await rename(temporal, RUTA_DATOS);
}
Esto no es una base de datos, y hay que saber por qué
Cada operación lee el fichero entero y lo reescribe entero. Con doscientos productos resulta suficiente; con doscientos mil, no. Si además dos peticiones escriben de forma simultánea, la segunda sobrescribe el resultado de la primera, porque ambas leyeron la misma versión.
Para lo que hacemos aquí es suficiente, y evita instalar y configurar un motor de base de datos. Conviene, no obstante, saber nombrar sus dos límites —tamaño y concurrencia— porque son exactamente los problemas que resuelve la base de datos del módulo de servidor.
Dos familias de fallo
| Familia | Ejemplo | Qué se hace |
|---|---|---|
| Esperable | El producto no existe; el precio es inválido | Se trata: mensaje claro y salida controlada |
| Inesperado | Sin permisos, sin disco, un error de programación | Se registra con su traza y se sale con error |
Un error esperable no es una excepción
La solicitud de un producto inexistente no constituye un fallo del sistema, sino un resultado previsto. Devuélvelo como dato —null, o un resultado que lo describa— y deja las excepciones para lo que de verdad no debería pasar.
Esa distinción es la que en la UD6 se convierte en la diferencia entre responder 404 y responder 500.
Un tipo de error propio permite distinguir en el catch qué clase de fallo llegó, sin comparar mensajes de texto:
export class ErrorDeValidacion extends Error {
constructor(errores) {
super("Los datos no son válidos");
this.name = "ErrorDeValidacion";
this.errores = errores;
}
}
catch (error) {
if (error instanceof ErrorDeValidacion) {
error.errores.forEach((mensaje) => console.error(` - ${mensaje}`));
process.exit(1);
}
throw error;
}
Por debajo de todo hace falta, además, una red de seguridad. Una promesa rechazada que nadie captura es el fallo silencioso más común de Node: el programa sigue corriendo como si nada, con una operación que no ocurrió.
process.on("uncaughtException", (error) => {
console.error("Error no capturado:", error);
process.exit(1);
});
process.on("unhandledRejection", (motivo) => {
console.error("Promesa rechazada sin tratar:", motivo);
process.exit(1);
});
Esos dos manejadores son la red, no el sitio donde tratar los errores.
Registrar con criterio
| Nivel | Para qué |
|---|---|
error |
Algo ha fallado y alguien debe mirarlo |
warn |
Algo raro que no ha impedido continuar |
info |
Hitos: arranque, apagado, configuración cargada |
debug |
Detalle, solo mientras se investiga |
console.error(`[${new Date().toISOString()}] crear producto falló: ${error.message}`);
Una regla no admite excepción: en los registros no se escriben contraseñas, tokens ni datos personales. Un fichero de log acaba copiado, enviado y guardado en sitios que nadie previó.
Se trabaja
150 minutos · el almacén del proyecto
Los tres primeros pasos construyen el almacén; los dos últimos lo rompen a propósito, que es la única forma de saber si aguanta.
Paso 1 · El catálogo en disco · 35 min
- Crea
datos/productos.jsoncon tu catálogo. - Escribe en
almacen.jslas funcionesleerProductosyguardarProductos. - Compón la ruta con
path.joindesde la posición del módulo. - Trata
ENOENTdevolviendo una lista vacía. - Crea la carpeta de datos automáticamente si no existe.
- Adapta el CLI para que lea y escriba de verdad.
Antes de continuar: ejecuta el programa desde otra carpeta distinta. Si falla, tu ruta dependía de process.cwd().
Paso 2 · El almacén completo · 45 min
Es el trabajo central de la sesión.
- Implementa
listar,obtener,crear,actualizaryborrar. - Genera los identificadores sin repetirlos nunca. Compruébalo: crea tres, borra el último, crea otro y mira qué identificador recibe.
- Escribe de forma segura, con temporal y renombrado.
- Guarda fechas de creación y modificación en formato ISO.
- Haz que
obtenerdevuelvanullyborrardevuelva un booleano, en lugar de lanzar. Esos dos valores serán el 404 de la UD6. - Conecta el CLI con el almacén, de punta a punta.
Paso 3 · Errores coherentes · 40 min
- Define
ErrorDeValidacionyErrorNoEncontrado. - Decide cuál de los dos usa el almacén y cuál devuelve datos. No es evidente, y la decisión que tomes aquí condiciona la UD6: escríbela.
- Trata en el CLI cada familia con su mensaje y su código de salida.
- Añade la red de seguridad de los dos manejadores de proceso.
- Registra cada operación con marca de tiempo y el nivel que le corresponda.
- Revisa que en ningún registro aparece un dato que no debería salir del programa.
Paso 4 · Provoca los fallos · 15 min
Un tratamiento de errores que no se ha probado no existe. Provoca estos seis y anota qué hace tu programa con cada uno:
| Fallo provocado | Cómo provocarlo | Qué debería hacer | Qué hace |
|---|---|---|---|
| El fichero no existe | Bórralo | ||
| El JSON está corrupto | Métele una coma de más | ||
| Sin permisos de escritura | Marca el fichero como solo lectura | ||
| Producto inexistente | Pide el id 9999 | ||
| Precio inválido | Pásale "abc" |
||
| Promesa rechazada sin tratar | Quita un await de una llamada que falla |
Los dos últimos son los interesantes. El quinto debe dar un mensaje y salir con código 1; el sexto, sin el manejador de proceso, no dice nada en absoluto. Pruébalo con y sin él.
Paso 5 · Los dos límites, demostrados · 15 min
- Genera un fichero con diez mil productos y mide cuánto tarda una operación de crear. Anótalo.
- Explica por qué tarda lo que tarda: cuenta cuántas veces se lee y se escribe el fichero entero para añadir un solo producto.
- Interrumpe el programa a mitad de una escritura —con
Ctrl+Cbien cronometrado, o metiendo una espera antes del renombrado— y comprueba que el fichero de datos sobrevive intacto. - Repite la prueba anterior sin el temporal, escribiendo directamente encima. Compara.
- Escribe en dos líneas cuál de los dos límites acabas de demostrar y cuál te queda por demostrar.
Ampliación si has completado el trabajo
Primero termina y comprueba los cinco pasos. El primer reto demuestra el límite que falta, y es el más instructivo de la unidad.
Reto 1 · Dos escrituras que se pisan
El segundo límite del almacén se enuncia fácil y cuesta creerlo hasta que se ve.
- Escribe un pequeño programa que llame a
crearun producto y termine. - Lánzalo dos veces a la vez, desde dos terminales, o encadenando las dos llamadas en segundo plano desde una sola.
- Cuenta los productos que hay al terminar. Repítelo varias veces: tarde o temprano habrá uno menos de los que creaste, y ningún error se habrá mostrado.
- Explica exactamente qué ocurrió, siguiendo el orden de las operaciones: quién leyó, quién escribió y qué versión de la lista tenía cada uno.
- Este problema tiene nombre en la disciplina. Búscalo y escríbelo.
- Propón dos soluciones de naturaleza distinta: una que se pueda implementar sobre ficheros, y otra que consista en no usar ficheros. Di cuál elegirías para este proyecto y por qué. La segunda es, literalmente, el temario del módulo de servidor.
Reto 2 · Lo que cuesta bloquear el proceso
La regla dice que en un servidor no se usa la API síncrona. Compruébalo en lugar de creerlo.
- Con el fichero de diez mil productos, mide el tiempo de
readFilede promesas y el dereadFileSync. En un programa de terminal, la diferencia de tiempo total es pequeña. - Ahora monta el escenario del servidor: un programa que, mientras lee el fichero, tiene que atender otra cosa. Usa un
setIntervalque imprima un punto cada 10 ms. - Ejecuta las dos versiones y cuenta los puntos que salen durante la lectura. Con la síncrona no sale ninguno.
- Explica en tres líneas qué significa eso en un servidor con cincuenta peticiones simultáneas.
- Como segunda parte, añade a tu almacén una copia de seguridad antes de cada escritura, conservando solo las tres últimas. Decide dónde van, cómo se nombran y quién las borra.
- Comprueba que tu copia funciona: corrompe el fichero principal a mano y restaura desde la última copia.
ENOENT tratado, las cinco operaciones funcionando y escritura segura con temporal.Cierre
5 minutos · comprobación y recuerdo
Lista de verificación de la sesión
- Tus datos viven en disco y se leen y escriben sin corromperse.
- Compones rutas con
pathy el programa funciona desde cualquier carpeta. - Los identificadores nunca se repiten.
- Distingues fallos esperables de inesperados, y cada uno tiene su código de salida.
- Sabes nombrar los dos límites de este almacén.
- Nada personal acaba en los registros.
Antes de cerrar · 3 minutos, sin mirar
- ¿Por qué no usamos la versión síncrona en un servidor?
- ¿Qué significa
ENOENT, y qué se hace con él la primera vez que arranca el programa? - ¿Por qué el identificador no puede ser la longitud del array?
- ¿Qué protege escribir en un temporal y renombrar?
- ¿Qué dos límites tiene un fichero JSON como almacén?
- ¿Qué diferencia hay entre un error esperable y uno inesperado?
Ver respuestas
1 · Porque bloquea el único hilo y deja esperando a todas las peticiones.
2 · Que el fichero o la carpeta no existe; en el primer arranque se trata devolviendo una lista vacía.
3 · Porque tras un borrado se repetiría un identificador ya usado.
4 · Que una interrupción deje el fichero de datos cortado e ilegible.
5 · El tamaño, porque se reescribe entero, y la concurrencia, porque dos escrituras se pisan.
6 · El esperable es un resultado posible del negocio y se devuelve como dato; el inesperado es un fallo del sistema y se registra con su traza.
Microprueba semanal 3 · 5–10 minutos
Individual, sin IA y sin apuntes.
- Escribe la lectura de un fichero que puede no existir, devolviendo una lista vacía en ese caso.
- ¿Por qué se escribe en un fichero temporal y después se renombra?
- Compón la ruta de
datos/productos.jsonsin depender de la carpeta desde la que se ejecute el programa.