Para mantener unida una transacción de node-postgres, obtén un cliente con pool.connect(), ejecuta todas las instrucciones a través de ese cliente y libéralo después de confirmar o revertir la transacción. Pasa también ese cliente a las funciones auxiliares. La guía sobre transacciones oficial exige este patrón.

Este tutorial está dirigido a quienes usan Terminal en una Mac para hacer que varios cambios en filas de artículos se confirmen o se reviertan juntos. Incluye un programa completo con filas de artículos sintéticas, una prueba de reversión deliberada y una secuencia de diagnóstico para consultas ocultas dentro de funciones auxiliares.

¿Por qué pool.query() rompe el alcance de la transacción?

Un pool administra conexiones; no hace que todas las consultas formen parte de una misma transacción. Cada llamada a pool.query() obtiene internamente un cliente disponible. Por lo tanto, no hay garantía de que las llamadas consecutivas usen la misma conexión. La API Pool de node-postgres advierte explícitamente que no se debe usar este método para transacciones.

Por ejemplo, enviar BEGIN a través del pool y luego enviar una inserción a través del pool no garantiza que esa inserción forme parte de la transacción iniciada. PostgreSQL normalmente ejecuta cada instrucción fuera de un bloque de transacción explícito en su propia transacción. Una reversión posterior en otra conexión no puede deshacer esos cambios confirmados de forma independiente. Consulta la documentación de BEGIN de PostgreSQL.

El límite relevante es el objeto cliente que se obtuvo del pool. El nombre de una función auxiliar, el módulo que la contiene y el hecho de que quien la invoca la espere con await no determinan si forma parte de la transacción. Una función auxiliar participa cuando sus consultas usan el cliente que inició la transacción. Esto se desprende directamente del requisito de usar el mismo cliente establecido en la guía sobre transacciones.

Ejecuta en tu Mac un ejemplo completo de confirmación y reversión

Necesitas Node.js con npm, un servidor PostgreSQL en ejecución y una base de datos de desarrollo local ya creada. Este recorrido supone que la base de datos se llama article_demo y que la configuración de conexión local habitual ya funciona. La instalación de la base de datos queda fuera del alcance de esta tarea sobre transacciones.

En Terminal, crea un directorio de trabajo nuevo e instala el controlador:

mkdir pg-transaction-demo
cd pg-transaction-demo
npm init -y
npm install pg

Guarda el siguiente programa completo como transaction-demo.mjs. Su tabla temporal solo contiene identificadores y títulos de artículos sintéticos. Las tablas temporales de PostgreSQL normalmente sobreviven a las confirmaciones y se eliminan cuando termina la sesión; estas propiedades permiten que el programa inspeccione un lote que se confirmó después de que se revierta un segundo lote. Consulta CREATE TABLE.

La función auxiliar de transacción que aparece a continuación sigue la secuencia documentada de BEGIN, COMMIT y ROLLBACK. Añade un manejo explícito para el caso en que también falle la reversión, conservando ambos errores en lugar de reemplazar el error original. Los valores de consulta usan la API de texto y valores documentada en Queries.

import pg from 'pg';
import assert from 'node:assert/strict';

const { Pool } = pg;

async function inTransaction(client, work) {
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');

  try {
    const result = await work(client);
    await client.query('COMMIT');
    return result;
  } catch (error) {
    try {
      await client.query('ROLLBACK');
    } catch (rollbackError) {
      throw new AggregateError(
        [error, rollbackError],
        'Transaction failed; rollback also failed'
      );
    }
    throw error;
  }
}

async function insertArticle(client, article) {
  const result = await client.query(
    `INSERT INTO pg_temp.transaction_demo_articles (id, title)
     VALUES ($1, $2)
     RETURNING id, title`,
    [article.id, article.title]
  );
  return result.rows[0];
}

async function insertBatch(client, articles) {
  const inserted = [];
  for (const article of articles) {
    inserted.push(await insertArticle(client, article));
  }
  return inserted;
}

async function main() {
  const pool = new Pool({ connectionTimeoutMillis: 5000 });
  let client;
  let discardClient = false;

  pool.on('error', (error) => {
    console.error('Idle client error:', error);
    process.exitCode = 1;
  });

  try {
    client = await pool.connect();

    await client.query(`
      CREATE TEMP TABLE transaction_demo_articles (
        id text PRIMARY KEY,
        title text NOT NULL
      ) ON COMMIT PRESERVE ROWS
    `);

    const saved = await inTransaction(client, (tx) =>
      insertBatch(tx, [
        { id: 'article-001', title: 'A guide to paper shapes' },
        { id: 'article-002', title: 'Choosing a notebook layout' }
      ])
    );
    assert.equal(saved.length, 2);

    await assert.rejects(
      () => inTransaction(client, (tx) =>
        insertBatch(tx, [
          { id: 'article-003', title: 'Arranging a reading desk' },
          { id: 'article-001', title: 'Deliberate duplicate ID' }
        ])
      ),
      (error) => error.code === '23505'
    );

    const remaining = await client.query(`
      SELECT id, title
      FROM pg_temp.transaction_demo_articles
      ORDER BY id
    `);

    assert.deepEqual(
      remaining.rows.map((row) => row.id),
      ['article-001', 'article-002']
    );

    console.log('Commit and rollback checks passed.');
    console.table(remaining.rows);
  } catch (error) {
    discardClient = true;
    throw error;
  } finally {
    if (client) client.release(discardClient);
    await pool.end();
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Ejecútalo desde el mismo directorio:

PGDATABASE=article_demo node transaction-demo.mjs

El pool lee las variables de entorno de conexión de PostgreSQL. Si tu configuración local requiere ajustes distintos, proporciona los valores apropiados de PGHOST y PGPORT junto con PGDATABASE. La guía oficial de conexión describe estas variables y sus valores predeterminados.

Si se ejecuta correctamente, verás Commit and rollback checks passed. seguido de las dos filas de artículos originales. El segundo lote inserta article-003 y luego intenta volver a usar article-001. Ese duplicado produce el SQLSTATE 23505, documentado como unique_violation en Serialization Failure Handling. Revertir el lote también elimina su inserción anterior; la aserción comprueba que article-003 no esté presente.

Las rutas de control de éxito, error durante el trabajo y error durante la reversión de la función auxiliar de transacción se comprobaron con un cliente simulado determinista. El ejemplo de base de datos no se ejecutó aquí porque el entorno carecía del paquete pg y de una conexión de prueba a PostgreSQL; el resultado esperado se describe arriba.

¿Cómo deben compartir el cliente de la transacción las funciones auxiliares?

Pasa el cliente explícitamente por toda la cadena de llamadas. En el programa, main() obtiene el cliente, inTransaction() pasa ese mismo objeto a su función de retorno y insertBatch() se lo pasa a insertArticle(). Ninguna de las dos funciones auxiliares de inserción obtiene una conexión del pool.

En esta implementación, deja claro quién es responsable de cada tarea:

Función Responsabilidad
main() Obtener el cliente, realizar la configuración de la demostración, liberar el cliente y cerrar el pool del script.
inTransaction() Iniciar y finalizar una transacción en el cliente proporcionado.
insertBatch() Esperar a que se complete cada operación de artículo y pasar el cliente proporcionado.
insertArticle() Ejecutar su inserción a través del cliente proporcionado.

El contrato de la función auxiliar es deliberadamente acotado: llama a inTransaction() solo cuando el cliente no tiene una transacción activa y permite que los errores se propaguen desde su función de retorno. Llamar a BEGIN dentro de una transacción de PostgreSQL existente no crea una transacción anidada independiente; PostgreSQL documenta los puntos de guardado como mecanismo para el trabajo anidado. Consulta BEGIN.

Espera a que se complete cada operación de la transacción antes de retornar desde su función de retorno. En este ejemplo, el bucle secuencial deja explícito cuándo se completan. Si inicias inserciones asíncronas sin retornar sus promesas ni esperarlas con await, la función de retorno podría terminar antes de haber recibido todos sus resultados.

¿Qué debería ocurrir si falla una consulta o la reversión?

Un error normal de consulta llega al bloque catch de la función auxiliar de transacción, que intenta revertir la transacción en el mismo cliente y vuelve a lanzar el error original. El duplicado deliberado solo se acepta después de que se complete esa reversión. Si falla la reversión, la función auxiliar lanza un AggregateError; la comprobación del error por duplicado no lo acepta, así que la ejecución llega al controlador de errores externo.

Ante cualquier error inesperado, el ejemplo destruye el cliente de forma conservadora con client.release(true). Por la ruta normal, devuelve el cliente con client.release(false). El argumento de destrucción está documentado en la API Pool.

Libera siempre todo cliente que hayas obtenido correctamente. De lo contrario, quienes llamen después podrían esperar una conexión que nunca estará disponible. Cierra el pool cuando termine este script independiente; en una aplicación, el cierre del pool debe ocurrir durante el cierre de la aplicación. La guía de agrupación de conexiones explica los requisitos de liberación y cómo pool.end() espera a que se devuelvan los clientes obtenidos del pool.

¿Un solo cliente garantiza una instantánea estable?

La continuidad del cliente y el nivel de aislamiento responden a preguntas distintas. El cliente determina a qué transacción llegan tus instrucciones. El nivel de aislamiento determina qué pueden observar esas instrucciones mientras se ejecutan otras transacciones. La documentación de aislamiento de transacciones de PostgreSQL define estos límites:

Nivel de aislamiento Límite relevante
READ COMMITTED Es el valor predeterminado. Las lecturas ordinarias sucesivas pueden ver datos distintos confirmados por transacciones concurrentes. Cada instrucción recibe una instantánea nueva.
REPEATABLE READ Usa una instantánea establecida en la primera instrucción que no sea de control de transacciones. PostgreSQL impide las lecturas fantasma, pero aún pueden ocurrir anomalías de serialización.
SERIALIZABLE Las transacciones serializables confirmadas se comportan de forma coherente con alguna ejecución en serie; una transacción puede fallar y requerir un nuevo intento.
READ UNCOMMITTED En PostgreSQL se comporta como READ COMMITTED.

Las lecturas también pueden ver los cambios que realizó su propia transacción. Por eso, observar una inserción propia no significa que todas las lecturas posteriores tengan una vista fija de los cambios de otras transacciones. Consulta Aislamiento de transacciones.

El ejemplo selecciona explícitamente READ COMMITTED en su instrucción inicial. Para usar otro nivel, cambia esa instrucción antes de ejecutar la transacción. PostgreSQL permite especificar las características de la transacción en BEGIN; si usas SET TRANSACTION, el aislamiento debe establecerse antes de la primera consulta o instrucción de modificación de datos. Consulta BEGIN y SET TRANSACTION.

Si eliges REPEATABLE READ o SERIALIZABLE, prepárate para el SQLSTATE 40001. Vuelve a intentar la transacción completa, incluida la lógica que elige las instrucciones y los valores, en lugar de volver a ejecutar solo la consulta que falló. El ejemplo propaga deliberadamente esos errores en vez de implementar reintentos. La gestión de errores de serialización de PostgreSQL explica este requisito y señala que los reintentos también pueden fallar.

¿Cómo puedes encontrar una consulta que haya quedado fuera de la transacción?

Sigue el recorrido de un lote desde que se obtiene la conexión hasta su confirmación o reversión final. Usa esta secuencia para revisar tu código:

  1. Localiza la llamada a pool.connect() e identifica el cliente que devuelve.
  2. Sigue cada función auxiliar invocada entre BEGIN y la finalización de la transacción. Confirma que cada una recibe y usa ese cliente.
  3. Inspecciona las funciones compartidas que envuelven consultas y busca llamadas ocultas a pool.query() u otras solicitudes de conexión.
  4. Confirma que se espera a que se complete cada operación y que las funciones auxiliares propagan los errores.
  5. Adapta la prueba de filas duplicadas sintéticas a tu lote y verifica que una inserción anterior desaparezca después de que falle una instrucción posterior.

Una prueba de reversión satisfactoria aporta evidencia útil para la ruta probada, pero no demuestra que todas las ramas de la aplicación usen el cliente correcto. Revisa también las funciones auxiliares condicionales. Si las consultas usan un solo cliente, pero las lecturas repetidas siguen difiriendo, compara ese comportamiento con el nivel de aislamiento seleccionado antes de cambiar el manejo de conexiones.

Mantén visible la obtención y liberación del cliente, pásalo explícitamente y haz que la finalización de la transacción dependa de que terminen todas las operaciones previstas. Así tendrás una estructura concreta que inspeccionar y una prueba de reversión que repetir a medida que crezca el flujo de trabajo de artículos.

Fuentes