PostgreSQL
PostgreSQL con Node.js y TypeScript
Uso de PostgreSQL desde Node.js y TypeScript con drivers, pools, queries parametrizadas, transacciones, mapping de tipos y repositorios tipados.
- Última actualización
- Actualizada
- Nivel
- Aplicación
PostgreSQL
Uso de PostgreSQL desde Node.js y TypeScript con drivers, pools, queries parametrizadas, transacciones, mapping de tipos y repositorios tipados.
TypeScript describe lo que tu código espera; PostgreSQL y los datos reales deciden lo que llega. Una integración robusta conserva SQL visible, valida en runtime y hace explícitos transacciones, tipos y errores.
Con pg, la arquitectura básica es:
Pool global
→ pool.query para operaciones simples
→ pool.connect para transacciones/streams
→ SQL parametrizado
→ validación del resultado
→ mapping de dominioNo crees un Pool por request ni escondas toda la interacción en un helper genérico sin semántica.
import { Pool } from 'pg';
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
connectionTimeoutMillis: 2_000,
idleTimeoutMillis: 30_000,
application_name: 'orders-api',
});Los valores dependen de capacidad total, número de procesos y proxy. Configura TLS según el entorno y no desactives validación de certificados por comodidad.
type OrderRow = {
id: string;
total_amount: string;
status: string;
created_at: Date;
};
const result = await pool.query<OrderRow>(
`SELECT id, total_amount, status, created_at
FROM orders
WHERE business_id = $1 AND id = $2`,
[businessId, orderId],
);El generic de TypeScript no valida el resultado. Solo informa al compilador. Si el SQL cambia, el tipo puede quedar mintiendo.
En boundaries críticos, valida:
const OrderRowSchema = z.object({
id: z.string(),
total_amount: z.string(),
status: z.enum(['pending', 'confirmed', 'cancelled']),
created_at: z.date(),
});También puedes generar tipos desde schema/queries o usar herramientas tipadas, pero siempre entiende qué garantía ofrecen y dónde sigue existiendo runtime uncertainty.
No expongas nombres SQL por toda la aplicación:
const mapOrder = (row: OrderRow): Order => ({
id: row.id,
totalAmount: new Decimal(row.total_amount),
status: row.status as OrderStatus,
createdAt: row.created_at,
});Mantén conversiones de numeric, bigint, date y nullable en un solo boundary.
Por defecto, int8 y numeric suelen conservarse como strings para evitar pérdida. Puedes registrar parsers globales, pero convertir bigint a number es inseguro si el rango supera Number.MAX_SAFE_INTEGER.
Decide por dominio:
Revisa cómo el driver parsea:
timestamptz: instante.timestamp without time zone: fecha/hora sin zona; no debe asumirse UTC automáticamente.date: puede requerir string para evitar desplazamientos de zona.Una política global evita bugs entre máquinas.
import type { PoolClient } from 'pg';
export async function withTransaction<T>(
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`SET LOCAL statement_timeout = '5s'`);
const value = await fn(client);
await client.query('COMMIT');
return value;
} catch (error) {
try {
await client.query('ROLLBACK');
} catch {
client.release(true);
throw error;
}
throw error;
} finally {
client.release();
}
}En código real evita liberar dos veces si destruiste el client y conserva el error original con contexto.
const RETRYABLE = new Set(['40001', '40P01']);El wrapper exterior crea un nuevo intento completo:
acquire → BEGIN → callback → error → ROLLBACK → release
backoff
acquire → BEGIN → callback otra vezNo reintentes 23505 salvo que sea parte del contrato ni llamadas externas dentro del callback.
await client.query(
`SELECT set_config('app.tenant_id', $1, true)`,
[tenantId],
);El tercer argumento true equivale a local a la transacción. El tenant debe derivarse de autorización confiable, no de input arbitrario.
import { DatabaseError } from 'pg';
if (error instanceof DatabaseError && error.code === '23505') {
if (error.constraint === 'orders_business_external_id_key') {
throw new DuplicateOrderError();
}
}No uses mensajes localizados como contrato. Conserva cause y registra SQLSTATE/constraint sin filtrar valores sensibles.
export interface OrderRepository {
findById(id: string, client?: PoolClient): Promise<Order | null>;
reserve(id: string, client: PoolClient): Promise<void>;
}Una operación que exige transacción debe requerir PoolClient, evitando que accidentalmente use el pool.
Alternativa: Unit of Work que agrupa repositorios ligados al mismo client.
No escondas queries grandes en strings concatenados dispersos.
Pueden construir filtros dinámicos manteniendo values parametrizados. Aun así:
await client.query(
`INSERT INTO order_items(order_id, product_id, quantity)
SELECT $1, product_id, quantity
FROM unnest($2::uuid[], $3::numeric[]) AS x(product_id, quantity)`,
[orderId, productIds, quantities],
);Los arrays deben tener longitudes compatibles. Para cargas grandes usa COPY streaming.
Librerías de cursor/stream deben:
No mezcles exportaciones largas con el mismo pool pequeño de APIs.
El soporte de AbortSignal y cancelación depende de versión de driver/API. Independientemente del mecanismo:
process.on('SIGTERM', async () => {
server.close();
await pool.end();
});Añade límite temporal y deja de aceptar tráfico antes de cerrar el pool.
Usa PostgreSQL real con migrations:
No compartas un singleton state mutable entre tests paralelos sin aislamiento.
rows[0] undefined.rowCount puede ser null según command/API; interpreta el método concreto.query<T> como validación.pool.query('BEGIN').query<OrderRow> no garantiza el shape?ORM y PostgreSQL evalúa cuánto abstraer sin perder constraints, SQL ni planes.