Llevamos dos módulos con una deuda pendiente. datos/eventos.json existe desde la primera lección, tiene los tres eventos y las siete sesiones de Escena Viva, y la aplicación no lo ha leído nunca: src/catalogo-datos.js sigue devolviendo un array incrustado en el propio código. Hoy saldamos esa deuda.

El módulo fs (file system) es la puerta de Node al disco. Es también el sitio donde todo lo del Módulo 2 deja de ser teoría: fs.promises son promesas de las que ya conoces, readFileSync es exactamente el tipo de operación que congela el bucle de eventos, y elegir entre una y otra es la diferencia entre un servidor que responde a mil usuarios y uno que se queda mudo mientras lee un fichero.

Al terminar, obtenerCatalogo() será una función asíncrona que lee JSON del disco, sabrás por qué la asincronía se propaga hacia arriba, distinguirás los errores del sistema de archivos por su código, y podrás escribir un fichero sin riesgo de dejarlo a medias si el proceso muere en mitad de la operación.

Contenido

  1. Las tres APIs del módulo fs
  2. readFile, la codificación y qué pasa si la omites
  3. Cuándo es aceptable la versión síncrona (y cuándo es un desastre)
  4. El trabajo central: una capa de datos asíncrona
  5. La asincronía se propaga hacia arriba
  6. Los errores del sistema de archivos y sus códigos
  7. Por qué comprobar antes con exists es una mala idea
  8. Escribir: writeFile, appendFile y la opción flag
  9. Escritura atómica: fichero temporal y rename
  10. Memorizar el catálogo para no releer

  1. Las tres APIs del módulo fs

Node ofrece tres formas distintas de hacer lo mismo con ficheros. No es redundancia histórica accidental: cada una tiene su momento.

// 1. Promesas (la que usaremos en todo el curso)
const contenido = await require('node:fs/promises').readFile('datos/eventos.json', 'utf8');

// 2. Callbacks error-first (la API original de Node)
require('node:fs').readFile('datos/eventos.json', 'utf8', (error, contenido) => { /* ... */ });

// 3. Sincrona (bloquea el hilo principal)
const contenido = require('node:fs').readFileSync('datos/eventos.json', 'utf8');
API Cómo se importa ¿Bloquea el bucle? Manejo de errores Cuándo usarla
Promesas require('node:fs/promises') No try/catch con await Por defecto, siempre
Callbacks require('node:fs') No Primer argumento del callback Código antiguo; API que exige callback
Síncrona require('node:fs'), sufijo Sync Sí try/catch directo Solo en el arranque y en scripts de un uso

Dos detalles: node:fs/promises y node:fs son módulos distintos (también llegas al primero con require('node:fs').promises, pero importar el submódulo es más explícito), y las funciones con sufijo Sync solo existen en node:fs: en la API de promesas no hay ninguna, por diseño.

  1. readFile, la codificación y qué pasa si la omites

readFile abre el fichero, lo lee entero en memoria y lo cierra. Esa palabra —entero— es su característica definitoria y su límite: un fichero de 800 MB ocupa 800 MB de memoria. Para eso están los streams de la lección 03-04; para un JSON de unos kilobytes, readFile es la herramienta correcta.

const conCodificacion = await fs.readFile('datos/eventos.json', 'utf8');
console.log(typeof conCodificacion);              // 'string'

const sinCodificacion = await fs.readFile('datos/eventos.json');
console.log(Buffer.isBuffer(sinCodificacion));    // true
console.log(sinCodificacion.slice(0, 12));
// <Buffer 5b 0a 20 20 7b 0a 20 20 20 20 22 69>

Sin codificación, readFile devuelve un Buffer: una secuencia de bytes crudos. Con codificación, Node decodifica esos bytes a texto y te da una cadena.

La razón es que un fichero no contiene texto: contiene bytes. Que esos bytes signifiquen "Concierto" o los píxeles de un cartel depende de cómo los interpretes. 'utf8' le dice a Node: estos bytes son texto codificado en UTF-8, conviértelos. Los Buffer son el tema completo de la lección 03-06; por ahora basta con la regla: 'utf8' para JSON, CSV, configuración o cualquier texto; sin codificación para imágenes, PDF, ZIP o cualquier binario.

Una advertencia sobre la ruta: 'datos/eventos.json' es relativa al directorio desde el que ejecutas el proceso, no al fichero que contiene esa línea. Es una de las fuentes de error más habituales en Node y la desmontaremos en la lección 03-03; de momento, ejecuta siempre desde la raíz del proyecto.

  1. Cuándo es aceptable la versión síncrona (y cuándo es un desastre)

readFileSync no es "la versión fácil". Es una operación que detiene el hilo principal por completo: durante ese tiempo el bucle de eventos no avanza, no se atiende ninguna petición y no se ejecuta ningún temporizador. Vamos a medirlo con el medidor del Módulo 2.

// src/laboratorio/comparar-bloqueo.js
// Demuestra el efecto de readFileSync sobre el bucle de eventos.

const fs = require('node:fs');
const fsPromesas = require('node:fs/promises');
const { iniciarMedicion } = require('../utiles/medir-bucle.js');

const RUTA = 'datos/eventos.json';
const REPETICIONES = 400;

async function medir(etiqueta, tarea) {
  const inicio = process.hrtime.bigint();
  await tarea();
  console.log(`${etiqueta}: ${(Number(process.hrtime.bigint() - inicio) / 1e6).toFixed(1)} ms`);
}

async function principal() {
  // El medidor avisa por stderr cada vez que el bucle se retrasa mas de 20 ms.
  const detener = iniciarMedicion({ intervaloMs: 20, umbralMs: 20 });

  await medir('sincrona ', () => {
    for (let i = 0; i < REPETICIONES; i += 1) fs.readFileSync(RUTA, 'utf8');
  });
  await medir('asincrona', async () => {
    for (let i = 0; i < REPETICIONES; i += 1) await fsPromesas.readFile(RUTA, 'utf8');
  });

  detener();
}

if (require.main === module) principal();
node src/laboratorio/comparar-bloqueo.js
# [bucle] retraso de 31.4 ms
# sincrona : 38.2 ms
# asincrona: 61.7 ms

Los dos resultados sorprenden y los dos importan. La versión síncrona es más rápida en total: no hay que programar callbacks, ni pasar por el thread pool, ni volver al bucle de eventos. Y a la vez bloqueó el bucle 31 ms, mientras que la asíncrona no lo bloqueó ni una sola vez; durante esos 31 ms tu servidor estaba muerto para todo el mundo. Ahí está la clave que mucha gente no termina de entender: la asincronía no es más rápida, es más justa. No optimiza el trabajo de un usuario, permite atender a los demás mientras se hace. Con un solo usuario, Sync gana; con quinientos, Sync es una catástrofe.

Contexto ¿Sync aceptable? Motivo
Arranque del proceso, antes de escuchar peticiones Sí Todavía no hay nadie esperando; 30 ms de arranque no molestan
Script de línea de comandos de un solo uso Sí No hay concurrencia que proteger
Dentro de una petición HTTP Nunca Congela a todos los usuarios conectados, no solo al que pide
Dentro de un bucle sobre muchos ficheros Nunca El bloqueo se multiplica por el número de ficheros

Regla operativa: si el proceso ya está sirviendo tráfico, Sync está prohibido.

  1. El trabajo central: una capa de datos asíncrona

Ha llegado el momento. src/catalogo-datos.js se escribió en el Módulo 2 con los datos incrustados y con una firma diseñada precisamente para este cambio. Lo sustituimos entero:

// src/catalogo-datos.js
// Capa de acceso a los datos del catalogo de Escena Viva.
// Lee la semilla datos/eventos.json y devuelve objetos de dominio.

const fs = require('node:fs/promises');

const { Evento } = require('./dominio');

const RUTA_EVENTOS = 'datos/eventos.json';

// Crea un error de dominio conservando la causa original.
function fallo(codigo, mensaje, causa) {
  const error = new Error(mensaje);
  error.codigo = codigo;
  error.cause = causa;
  return error;
}

// Lee el fichero semilla y devuelve los datos planos ya analizados.
async function leerFicheroEventos() {
  let contenido;

  try {
    contenido = await fs.readFile(RUTA_EVENTOS, 'utf8');
  } catch (error) {
    if (error.code !== 'ENOENT') throw error;
    throw fallo('DATOS_NO_DISPONIBLES', `No se encuentra ${RUTA_EVENTOS}`, error);
  }

  try {
    return JSON.parse(contenido);
  } catch (error) {
    // JSON.parse lanza SyntaxError: lo traducimos al vocabulario del dominio.
    throw fallo('DATOS_CORRUPTOS', `${RUTA_EVENTOS} no contiene JSON valido`, error);
  }
}

// Devuelve el catalogo completo como instancias de Evento.
async function obtenerCatalogo() {
  const datos = await leerFicheroEventos();
  return datos.map((registro) => Evento.desdeJSON(registro));
}

// Devuelve un unico evento por su id, o undefined si no existe.
async function obtenerEventoPorId(id) {
  return (await obtenerCatalogo()).find((evento) => evento.id === id);
}

module.exports = { obtenerCatalogo, obtenerEventoPorId, RUTA_EVENTOS };

Cuatro decisiones que merece la pena señalar. Ya no hace falta structuredClone: antes copiábamos porque el array vivía en la caché de módulos y lo compartía todo el proceso; ahora cada llamada lee el fichero y construye instancias nuevas, así que el aislamiento es gratuito. La capa devuelve objetos de dominio, no datos planos: Evento.desdeJSON llevaba desde el Módulo 1 esperando este momento, y quien consume el catálogo recibe Evento con sus getters, no diccionarios anónimos. Los errores se traducen al vocabulario del dominio con error.codigo, porque quien llama no debería tener que conocer ENOENT ni SyntaxError. Y cause conserva el error original: es estándar desde Node 16 y console.error lo imprime automáticamente, así que no pierdes la traza mientras das un mensaje legible.

  1. La asincronía se propaga hacia arriba

Ahora obtenerCatalogo() devuelve una promesa. Ese cambio no se queda en el módulo: sube por toda la cadena de llamadas, y src/catalogo.js tiene que adaptarse.

// src/catalogo.js (fragmento: solo cambia principal)

async function principal() {
  const opciones = leerOpciones(process.argv.slice(2));

  let eventos;
  try {
    // El unico cambio real: un await. Ya no hace falta el .map de conversion,
    // porque la capa de datos devuelve instancias de Evento.
    eventos = await obtenerCatalogo();
  } catch (error) {
    console.error(`[catalogo] ${error.message}`);
    if (error.cause) console.error(`[catalogo] causa: ${error.cause.message}`);
    process.exitCode = 1;
    return;
  }

  // ...el resto (filtros, mostrarTablaResumen, mostrarCatalogo, totales) queda igual.
}

if (require.main === module) {
  // principal() ahora devuelve una promesa: hay que capturar su rechazo.
  principal().catch((error) => {
    console.error('[catalogo] error inesperado:', error);
    process.exitCode = 1;
  });
}

La asincronía es contagiosa hacia arriba: si una función espera algo asíncrono, ella misma se vuelve asíncrona, y quien la llame también. La cadena readFile → leerFicheroEventos → obtenerCatalogo → principal termina en un punto llamado frontera, donde alguien tiene que decidir qué hacer con el error. En un script de consola esa frontera es el require.main === module; en el Módulo 4 será el manejador de la petición HTTP. Lo que no debe pasar nunca es llamar a principal() sin .catch(): sería una promesa rechazada sin gestionar y, como viste en el Módulo 2, eso tumba el proceso con un unhandledRejection.

  1. Los errores del sistema de archivos y sus códigos

Los errores de fs traen una propiedad code con un identificador estable. Nunca compares el mensaje de texto: cambia entre versiones e idiomas del sistema.

code Significado Causa habitual
ENOENT No such file or directory Ruta mal escrita, fichero borrado, directorio padre inexistente
EACCES Permission denied El usuario del proceso no tiene permiso de lectura o escritura
EISDIR / ENOTDIR Es un directorio / un tramo no lo es Leer una carpeta como fichero; eventos.json/otro.txt
EEXIST El fichero ya existe Escritura con flag: 'wx'
EMFILE Demasiados ficheros abiertos Descriptores sin cerrar; abrir miles a la vez
ENOSPC No queda espacio en el disco Disco lleno al escribir
EPERM Operación no permitida Fichero bloqueado (típico en Windows)

El error trae además error.path (la ruta implicada), error.syscall (open, read, unlink) y error.errno. La distinción operativa es esta: ENOENT suele ser un caso previsto —el informe de hoy todavía no existe— y merece una rama del código; los demás son fallos reales que hay que propagar. Capturarlos todos por igual es la forma más rápida de esconder un EACCES de producción detrás de un "no había datos".

  1. Por qué comprobar antes con exists es una mala idea

Parece de sentido común escribir esto:

// MAL: comprobar antes de actuar.
async function leerSiExiste(ruta) {
  try {
    await fs.access(ruta);             // ¿existe?
  } catch {
    return null;                        // no existe
  }
  return fs.readFile(ruta, 'utf8');     // existe, lo leo
}

Es incorrecto por un motivo profundo. Entre la línea del access y la del readFile hay una ventana de tiempo en la que el bucle de eventos hace otras cosas, y en esa ventana otro proceso —o tu propio programa— puede borrar el fichero, renombrarlo o quitarle permisos. Cuando llega el readFile, la comprobación ya es mentira. Es la clásica condición de carrera TOCTOU (Time Of Check to Time Of Use): compruebas en un instante y usas en otro. Además de incorrecto, duplica el trabajo: dos llamadas al sistema donde bastaba una.

// BIEN: intentar y capturar.
async function leerSiExiste(ruta) {
  try {
    return await fs.readFile(ruta, 'utf8');
  } catch (error) {
    if (error.code === 'ENOENT') return null;  // caso previsto
    throw error;                                // fallo real
  }
}

La operación de lectura ya comprueba la existencia, de forma atómica y dentro del sistema operativo. La regla general: intenta y captura, no preguntes y actúes. Por eso fs.exists está obsoleto desde hace años (su callback ni siquiera seguía el convenio error-first); fs.existsSync sigue existiendo y es legítimo en el arranque de un script para dar un mensaje claro, pero jamás como paso previo a una operación.

  1. Escribir: writeFile, appendFile y la opción flag

// Escribe el fichero entero. Si existe, lo TRUNCA y lo sustituye.
await fs.writeFile('informes/ocupacion.json', JSON.stringify(datos, null, 2), 'utf8');

// Anade al final. Si no existe, lo crea.
await fs.appendFile('informes/auditoria.log', `${new Date().toISOString()} venta\n`, 'utf8');

Ambas aceptan una cadena o un Buffer. Si les pasas un objeto sin serializar obtendrás el literal [object Object] en el fichero: la serialización siempre es explícita, con JSON.stringify(x, null, 2) según la convención del proyecto. Detrás de las dos está la opción flag, que decide cómo se abre el fichero:

flag Uso Si no existe Si existe
'w' Escritura (por defecto en writeFile) Lo crea Lo vacía
'a' Añadir (por defecto en appendFile) Lo crea Escribe al final
'wx' Escritura exclusiva Lo crea Falla con EEXIST
'ax' Añadir exclusivo Lo crea Falla con EEXIST
'r' Solo lectura Falla con ENOENT Lo abre
'r+' Lectura y escritura Falla con ENOENT Lo abre sin vaciar

'wx' merece atención especial: es la forma correcta de decir "crea este fichero solo si no existe" sin condiciones de carrera, porque la exclusividad la garantiza el sistema operativo en una única llamada. Es el principio del apartado anterior aplicado a la escritura —intentar y capturar EEXIST, no comprobar antes—, y lo usaremos en la lección siguiente para no sobrescribir el informe del día.

  1. Escritura atómica: fichero temporal y rename

writeFile no es atómica. Primero trunca el fichero a cero bytes y después escribe el contenido nuevo. Si el proceso muere entre ambos pasos —un Ctrl+C, un fallo de energía, el OOM killer—, te quedas con datos/eventos.json vacío o cortado por la mitad. Has perdido el catálogo.

La solución estándar se apoya en una garantía del sistema de archivos: rename sobre el mismo sistema de ficheros es atómico. Un instante antes está el fichero viejo, un instante después el nuevo, sin ningún momento intermedio observable.

// src/utiles/escritura-atomica.js
// Escribe un fichero de forma que nunca quede a medias.

const fs = require('node:fs/promises');

async function escribirAtomico(ruta, contenido) {
  // El temporal va en el MISMO directorio: rename solo es atomico
  // dentro del mismo sistema de ficheros.
  const rutaTemporal = `${ruta}.${process.pid}.tmp`;

  try {
    await fs.writeFile(rutaTemporal, contenido, 'utf8');
    await fs.rename(rutaTemporal, ruta);      // paso atomico
  } catch (error) {
    await fs.rm(rutaTemporal, { force: true }); // no dejamos basura por el disco
    throw error;
  }
}

// Guarda el catalogo modificado sin poner en riesgo el original.
// toJSON() de Evento devuelve el registro plano equivalente a la semilla.
async function guardarCatalogo(ruta, eventos) {
  await escribirAtomico(ruta, JSON.stringify(eventos.map((e) => e.toJSON()), null, 2));
}

module.exports = { escribirAtomico, guardarCatalogo };

Por qué funciona: mientras se escribe el temporal, datos/eventos.json sigue intacto, así que cualquier lector concurrente ve la versión anterior completa y válida; el rename sustituye el nombre en el directorio de una sola vez, sin ventana de fichero truncado; si el proceso muere a mitad, lo peor que queda es un .tmp huérfano; y el pid en el nombre evita que dos procesos simultáneos se pisen el temporal. Para datos críticos de verdad hay un paso más —forzar el volcado del caché del sistema al disco con sync() antes de renombrar—, pero eso requiere la API FileHandle de la lección siguiente.

  1. Memorizar el catálogo para no releer

Nuestra obtenerCatalogo() lee el fichero en cada llamada. Para un script de consola da igual; para un servidor que atiende cien peticiones por segundo son cien lecturas de disco de un fichero que no cambia. La solución es la memorización: leer una vez, guardar el resultado y devolverlo en las siguientes llamadas.

// src/catalogo-datos.js (fragmento anadido)

let promesaCatalogo = null;   // Cache: guarda la PROMESA, no el resultado.

async function obtenerCatalogo({ recargar = false } = {}) {
  if (recargar) promesaCatalogo = null;

  if (promesaCatalogo === null) {
    // Guardamos la promesa inmediatamente, sin esperarla.
    promesaCatalogo = leerFicheroEventos()
      .then((datos) => datos.map((registro) => Evento.desdeJSON(registro)))
      .catch((error) => {
        // Un fallo no debe quedar cacheado para siempre: se limpia y se propaga.
        promesaCatalogo = null;
        throw error;
      });
  }

  return promesaCatalogo;
}

El detalle fino —el que separa una caché correcta de una inútil— es que cacheamos la promesa, no el valor resuelto. Si guardáramos el resultado (if (catalogo === null) { catalogo = await leer(); }), diez llamadas simultáneas durante el primer await verían todas catalogo === null y lanzarían diez lecturas de disco en paralelo: la llamada "estampida de caché". Al guardar la promesa de forma síncrona, antes de ningún await, la segunda llamada ya encuentra la promesa en curso y se engancha a ella. Una sola lectura, aunque lleguen mil peticiones a la vez.

Un efecto secundario que conviene conocer: como cada Evento se comparte ahora entre todos los consumidores, si alguien vende entradas sobre él el cambio se ve desde todas partes. Es lo que queremos hasta que llegue la base de datos del Módulo 7, pero también significa que un consumidor puede modificar lo que ve otro.

Errores Comunes y Consejos

  • Usar readFileSync "porque es más simple" dentro de un servidor. Es la causa número uno de latencias inexplicables en Node: simple para ti, catastrófico para tus usuarios.
  • Olvidar 'utf8' y sorprenderse con <Buffer 5b 0a ...>. Si esperabas texto y ves eso, te falta la codificación. Y no compares error.message: el mensaje es para humanos, el contrato es error.code.
  • Un try/catch gigante alrededor de todo. Envuelve la operación concreta que puede fallar de forma prevista, para distinguir un ENOENT esperado de un error de programación.
  • fs.writeFile(ruta, objeto) escribe [object Object]; y escribir en un directorio inexistente falla con ENOENT, porque writeFile no crea directorios (eso es mkdir con recursive, en la lección siguiente).
  • Consejo: cuando un error de fs te desconcierte, imprime error.code, error.syscall y error.path; esos tres campos resuelven casi cualquier misterio. Y valida los datos nada más leerlos: un JSON sintácticamente correcto puede traer aforo: "420" en texto y romper los cálculos mucho más adelante, en un sitio que no tiene nada que ver.

Ejercicios

Ejercicio 1: verificador de la semilla

Escribe src/laboratorio/verificar-semilla.js que lea datos/eventos.json con fs.promises y compruebe: que el fichero exista y sea JSON válido (distinguiendo ambos fallos con error.codigo); que haya 3 eventos y 7 sesiones; que todos los id sean únicos; que ninguna sesión tenga vendidas > aforo; y que los totales sean los conocidos (aforo 3000, vendidas 1811, libres 1189). Debe salir con process.exitCode = 1 si algo falla, imprimir diagnósticos por stderr y el resumen por stdout.

Ejercicio 2: subida de precios con escritura atómica

Escribe src/laboratorio/subir-precios.js que lea el catálogo con obtenerCatalogo(), suba un --porcentaje=10 el precioCentimos de todas las sesiones de una --sala="Sala Boveda" redondeando a céntimos enteros, guarde el resultado con escribirAtomico solo si se pasa --confirmar (sin esa opción, únicamente muestra los cambios) y añada una línea por sesión modificada a informes/cambios-precio.log con appendFile.

Ejercicio 3: medir la estampida de caché

Modifica obtenerCatalogo para contar cuántas veces se lee realmente el fichero. Escribe dos versiones de la memorización —una que cachee el valor y otra que cachee la promesa— y lanza 50 llamadas simultáneas con Promise.all sobre cada una. Muestra el número de lecturas de cada versión y explica la diferencia.

Soluciones

Solución 1. La carga es literalmente la leerFicheroEventos() del apartado 4, con sus dos try/catch separados y sus dos error.codigo (DATOS_NO_DISPONIBLES y DATOS_CORRUPTOS): reutilízala en lugar de duplicarla. Lo nuevo es la verificación:

// src/laboratorio/verificar-semilla.js (fragmento central)
function verificar(datos) {
  const problemas = [];
  const ids = new Set();
  let sesiones = 0, aforo = 0, vendidas = 0;

  for (const evento of datos) {
    if (ids.has(evento.id)) problemas.push(`id repetido: ${evento.id}`);
    ids.add(evento.id);

    for (const sesion of evento.sesiones) {
      if (ids.has(sesion.id)) problemas.push(`id repetido: ${sesion.id}`);
      ids.add(sesion.id);
      if (sesion.vendidas > sesion.aforo) {
        problemas.push(`${sesion.id}: vendidas ${sesion.vendidas} > aforo ${sesion.aforo}`);
      }
      sesiones += 1;
      aforo += sesion.aforo;
      vendidas += sesion.vendidas;
    }
  }

  if (sesiones !== 7) problemas.push(`se esperaban 7 sesiones, hay ${sesiones}`);
  if (aforo !== 3000) problemas.push(`aforo ${aforo}, se esperaba 3000`);
  if (vendidas !== 1811) problemas.push(`vendidas ${vendidas}, se esperaban 1811`);

  return { problemas, sesiones, aforo, vendidas, libres: aforo - vendidas };
}

Un solo Set sirve para eventos y sesiones porque los prefijos evt- y ses- ya los mantienen en espacios distintos. Y fíjate en que se acumulan los problemas en lugar de abortar en el primero: un verificador que solo cuenta el primer fallo obliga a ejecutarlo cinco veces. principal() imprime el resumen por stdout, vuelca los problemas por stderr y ajusta process.exitCode.

Solución 2. La estructura es la de catalogo.js: leer opciones, transformar, decidir. Lo interesante es que el modo simulación es el predeterminado.

const eventos = await obtenerCatalogo();
const cambios = [];

for (const evento of eventos.filter((e) => e.sala === sala)) {
  for (const sesion of evento.sesiones) {
    const anterior = sesion.precioCentimos;
    sesion.precioCentimos = Math.round(anterior * (1 + porcentaje / 100));
    cambios.push({ sesion: sesion.id, anterior, nuevo: sesion.precioCentimos });
  }
}

console.table(cambios);

if (!confirmar) {
  console.error('Simulacion. Anade --confirmar para escribir los cambios.');
  return;
}

await guardarCatalogo(RUTA_EVENTOS, eventos);
await fs.appendFile('informes/cambios-precio.log', cambios
  .map((c) => `${new Date().toISOString()} ${c.sesion} ${c.anterior} -> ${c.nuevo}\n`)
  .join(''), 'utf8');

Que un script que modifica datos exija confirmación explícita no es un capricho: es la diferencia entre equivocarse y poder deshacerlo. Ojo con el appendFile: si informes/ no existe falla con ENOENT, porque escribir no crea directorios; créalo a mano con mkdir informes hasta la lección siguiente.

Solución 3. La versión que cachea el valor imprime 50 lecturas; la que cachea la promesa imprime 1. En la primera, las 50 llamadas entran, encuentran la variable a null —ninguna ha terminado todavía— y todas disparan su lectura antes de que la primera asigne nada. En la segunda, la asignación de promesaCatalogo ocurre de forma síncrona, antes del primer await, así que la llamada número 2 ya encuentra una promesa en curso y se limita a esperarla. La lección general: en una caché asíncrona se cachea la operación en curso, no su resultado.

Conclusión

Escena Viva ya lee del disco. src/catalogo-datos.js ha dejado de ser un array incrustado para convertirse en una capa de datos asíncrona que lee datos/eventos.json, lo analiza con JSON.parse, construye instancias de Evento con desdeJSON y traduce los fallos del sistema de archivos al vocabulario del dominio con error.codigo. La firma que diseñamos en el Módulo 2 aguantó el cambio sin romper a nadie: solo hubo que añadir un await y una frontera con .catch(). Por el camino has fijado los criterios que gobiernan todo uso de fs de aquí en adelante: node:fs/promises por defecto, la versión síncrona únicamente en el arranque o en scripts de un solo uso —y has medido con tus propios ojos los 31 ms de bucle congelado que cuesta ignorarlo—, 'utf8' para texto y su ausencia para binario, error.code en lugar de mensajes, intentar y capturar en lugar de preguntar y actuar, la opción flag para controlar cómo se abre el fichero, y la escritura atómica con temporal más rename para que un corte de luz no destruya el catálogo. Y sabes que en una caché asíncrona lo que se guarda es la promesa, no el valor.

Pero solo hemos usado dos funciones de fs, y el módulo tiene decenas. En la siguiente lección, El Módulo fs a Fondo, saldremos del fichero único: metadatos con stat y el objeto Stats, recorrido de directorios con readdir y withFileTypes, creación y borrado de árboles enteros con mkdir y rm recursivos, la API FileHandle con sus descriptores que hay que cerrar sin falta, permisos y vigilancia de cambios. Y lo aplicaremos a algo que Escena Viva ya necesita: un almacén de informes que organice las salidas por mes, las liste y purgue las antiguas.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados