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. 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. 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. 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

  1. ¿Qué pasa hoy con tus datos cuando el programa termina?
  2. Si el programa se interrumpe mientras escribe el fichero, ¿qué queda?
  3. ¿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

  1. Crea datos/productos.json con tu catálogo.
  2. Escribe en almacen.js las funciones leerProductos y guardarProductos.
  3. Compón la ruta con path.join desde la posición del módulo.
  4. Trata ENOENT devolviendo una lista vacía.
  5. Crea la carpeta de datos automáticamente si no existe.
  6. 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.

  1. Implementa listar, obtener, crear, actualizar y borrar.
  2. Genera los identificadores sin repetirlos nunca. Compruébalo: crea tres, borra el último, crea otro y mira qué identificador recibe.
  3. Escribe de forma segura, con temporal y renombrado.
  4. Guarda fechas de creación y modificación en formato ISO.
  5. Haz que obtener devuelva null y borrar devuelva un booleano, en lugar de lanzar. Esos dos valores serán el 404 de la UD6.
  6. Conecta el CLI con el almacén, de punta a punta.

Paso 3 · Errores coherentes · 40 min

  1. Define ErrorDeValidacion y ErrorNoEncontrado.
  2. 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.
  3. Trata en el CLI cada familia con su mensaje y su código de salida.
  4. Añade la red de seguridad de los dos manejadores de proceso.
  5. Registra cada operación con marca de tiempo y el nivel que le corresponda.
  6. 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

  1. Genera un fichero con diez mil productos y mide cuánto tarda una operación de crear. Anótalo.
  2. 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.
  3. Interrumpe el programa a mitad de una escritura —con Ctrl+C bien cronometrado, o metiendo una espera antes del renombrado— y comprueba que el fichero de datos sobrevive intacto.
  4. Repite la prueba anterior sin el temporal, escribiendo directamente encima. Compara.
  5. 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.

  1. Escribe un pequeño programa que llame a crear un producto y termine.
  2. Lánzalo dos veces a la vez, desde dos terminales, o encadenando las dos llamadas en segundo plano desde una sola.
  3. 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.
  4. 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.
  5. Este problema tiene nombre en la disciplina. Búscalo y escríbelo.
  6. 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.

  1. Con el fichero de diez mil productos, mide el tiempo de readFile de promesas y el de readFileSync. En un programa de terminal, la diferencia de tiempo total es pequeña.
  2. Ahora monta el escenario del servidor: un programa que, mientras lee el fichero, tiene que atender otra cosa. Usa un setInterval que imprima un punto cada 10 ms.
  3. Ejecuta las dos versiones y cuenta los puntos que salen durante la lectura. Con la síncrona no sale ninguno.
  4. Explica en tres líneas qué significa eso en un servidor con cincuenta peticiones simultáneas.
  5. 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.
  6. Comprueba que tu copia funciona: corrompe el fichero principal a mano y restaura desde la última copia.
Objetivo mínimoLectura y escritura con rutas portables y ENOENT tratado, las cinco operaciones funcionando y escritura segura con temporal.
Si lo tienesLa tabla de los seis fallos provocados contestada, y los dos tipos de error propios tratados con su código de salida.
RetoLa carrera de escrituras demostrada y explicada con su nombre, o la medición del bloqueo del proceso con el sistema de copias.

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 path y 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

  1. ¿Por qué no usamos la versión síncrona en un servidor?
  2. ¿Qué significa ENOENT, y qué se hace con él la primera vez que arranca el programa?
  3. ¿Por qué el identificador no puede ser la longitud del array?
  4. ¿Qué protege escribir en un temporal y renombrar?
  5. ¿Qué dos límites tiene un fichero JSON como almacén?
  6. ¿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.

  1. Escribe la lectura de un fichero que puede no existir, devolviendo una lista vacía en ese caso.
  2. ¿Por qué se escribe en un fichero temporal y después se renombra?
  3. Compón la ruta de datos/productos.json sin depender de la carpeta desde la que se ejecute el programa.