Backend JavaScript hampir selalu berakhir di database, dan node-postgres — paket pg — adalah driver PostgreSQL yang paling banyak dipakai di ekosistem Node.js: framework seperti Express, Fastify, hingga ORM populer bertumpu di atasnya. Lingkungan latihan kursus ini memasang Node.js dan paket pg sungguhan, jadi seperti chapter Python sebelumnya, seluruh contoh di sini benar-benar dieksekusi dan output-nya direkam dari server. Kita mulai dari koneksi Client dan tabel produk Toko Maju Jaya, lanjut ke query berparameter $1 (termasuk membuktikan input injeksi mati di parameter), transaksi manual BEGIN/COMMIT, penanganan error berbasis SQLSTATE, dan ditutup dengan Pool sebagai jembatan menuju chapter pooling.

Instalasi node-postgres #

Paketnya bernama pg di registry npm:

bash
npm install pg

Contoh-contoh chapter ini ditulis sebagai ES module (berkas .mjs, import di atas berkas) dan dijalankan dengan node nama-file.mjs. Tipe hasil query node-postgres sederhana namun perlu dihafal: result.rows berisi array baris (untuk SELECT), result.rowCount berisi jumlah baris terpengaruh (untuk INSERT/UPDATE/DELETE), dan setiap error PostgreSQL menempel pada properti objek error seperti code dan constraint.

Koneksi Pertama dengan Client #

Kelas pg.Client mewakili satu koneksi server. Parameter koneksi diberikan sebagai object; contoh di bawah membaca dari variabel environment PG* — konvensi libpq yang sama dengan psql dan psycopg — sehingga kode berjalan di lingkungan latihan maupun di server Anda tanpa perubahan:

javascript buat-tabel.mjs
import pg from "pg";

// Parameter koneksi mengikuti variabel environment PG* -- konvensi
// libpq yang sama dengan psql. Sesuaikan untuk server Anda; kata
// sandi cukup diberikan lewat variabel environment PGPASSWORD.
const koneksi = {
  host: process.env.PGHOST ?? "localhost",
  port: process.env.PGPORT ?? 5432,
  user: process.env.PGUSER ?? "postgres",
  database: process.env.PGDATABASE ?? "toko_db",
};

// Data awal produk Toko Maju Jaya untuk seluruh contoh chapter ini.
const produkAwal = [
  [1, "Mouse Kantor Ergonomis", 85000, 25],
  [2, "Papan Ketik Mekanik", 750000, 12],
  [3, "Monitor 24 inci IPS", 1850000, 8],
  [4, "Kabel HDMI 2 meter", 65000, 40],
];

const client = new pg.Client(koneksi);
await client.connect();

await client.query("DROP TABLE IF EXISTS produk");
await client.query(`
  CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga bigint NOT NULL CHECK (harga >= 0),
    stok  integer NOT NULL DEFAULT 0
  )
`);

// Setiap baris dikirim sebagai parameter $1..$4, bukan ditempelkan
// ke teks SQL.
for (const [id, nama, harga, stok] of produkAwal) {
  await client.query(
    "INSERT INTO produk (id, nama, harga, stok) VALUES ($1, $2, $3, $4)",
    [id, nama, harga, stok],
  );
}
console.log("Tabel produk dibuat,", produkAwal.length, "baris terisi");

const hasil = await client.query("SELECT count(*)::int AS jumlah FROM produk");
console.log("Verifikasi jumlah produk:", hasil.rows[0].jumlah);

await client.end();

Output yang Dihasilkan:

text
Tabel produk dibuat, 4 baris terisi
Verifikasi jumlah produk: 4

Bedah singkatnya: await client.connect() membuka koneksi — satu kali, sebelum query apa pun. client.query() mengirim satu pernyataan dan mengembalikan hasilnya; teks SQL selalu dikirim bersama array nilai sebagai dua bagian terpisah, itulah jalur parameter yang aman. await client.end() menutup koneksi dengan sopan di akhir program. Node.js berjalan single-threaded per proses, sehingga satu Client cukup untuk skrip berurutan seperti ini — untuk banyak query serentak, Pool di akhir chapter mengambil alih.

Query dengan Parameter $1 #

Penanda parameter node-postgres mengikuti PostgreSQL asli: $1, $2, dan seterusnya, dengan nilai dikirim sebagai array di argumen kedua. File berikut membaca hasil SELECT lewat rows, memformat rupiah dengan fungsi kecil, lalu membuktikan sendiri bahwa payload injeksi mati sebagai parameter:

javascript query-parameter.mjs
import pg from "pg";

// Parameter koneksi mengikuti variabel environment PG* --
// konvensi libpq yang sama dengan psql.
const koneksi = {
  host: process.env.PGHOST ?? "localhost",
  port: process.env.PGPORT ?? 5432,
  user: process.env.PGUSER ?? "postgres",
  database: process.env.PGDATABASE ?? "toko_db",
};

// Format rupiah manual: kelompok tiga digit dipisah titik,
// misalnya 1850000 -> Rp1.850.000.
function rupiah(n) {
  return "Rp" + n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
}

const client = new pg.Client(koneksi);
await client.connect();

// 1) Parameter $1: nilai dikirim terpisah dari teks SQL.
//    Hasil SELECT tersedia sebagai array `rows`.
const murah = await client.query(
  "SELECT id, nama, harga FROM produk WHERE harga <= $1 ORDER BY id",
  [200000],
);
console.log("Produk dengan harga di bawah Rp200.000:");
for (const { id, nama, harga } of murah.rows) {
  console.log(`  ${id}. ${nama} ${rupiah(harga)}`);
}

// 2) Input berbahaya tetap diperlakukan sebagai teks biasa:
//    string ini dibandingkan dengan kolom nama, tidak pernah
//    dijalankan sebagai SQL.
const berbahaya = "'; DROP TABLE produk;--";
const cari = await client.query("SELECT id, nama FROM produk WHERE nama = $1", [
  berbahaya,
]);
console.log("Hasil pencarian", JSON.stringify(berbahaya), ":", cari.rows.length, "baris");

// 3) rowCount: jumlah baris yang terkena efek pernyataan.
const update = await client.query(
  "UPDATE produk SET stok = stok + $1 WHERE id = $2",
  [5, 4],
);
console.log("rowCount UPDATE:", update.rowCount);

await client.end();

Output yang Dihasilkan:

text
Produk dengan harga di bawah Rp200.000:
  1. Mouse Kantor Ergonomis Rp85.000
  4. Kabel HDMI 2 meter Rp65.000
Hasil pencarian "'; DROP TABLE produk;--" : 0 baris
rowCount UPDATE: 1

Bagian kedua contoh sengaja memasukkan string '; DROP TABLE produk;-- — payload legendaris dari chapter SQL Injection . Karena string itu dikirim sebagai nilai $1, server membandingkannya dengan kolom nama, tidak menemukan produk yang cocok, dan mengembalikan nol baris; tabel tetap utuh dan rowCount pada UPDATE terbaca normal. Fungsi rupiah() di atas memformat angka dengan regex pembagi ribuan — hasil yang sama dengan number_format() di PHP dan f-string Rp{harga:,} di Python pada chapter sebelumnya.

Transaksi Manual: BEGIN, COMMIT, ROLLBACK #

node-postgres tidak menyediakan metode transaksi tingkat tinggi; transaksi dijalankan sebagai pernyataan SQL biasa — persis seperti mengetik BEGIN/COMMIT di psql. Konsekuensinya urutan dan penanganan rollback menjadi tanggung jawab Anda, dan pola try/catch menjadi wajib:

javascript transaksi.mjs
import pg from "pg";

// Parameter koneksi mengikuti variabel environment PG* --
// konvensi libpq yang sama dengan psql.
const koneksi = {
  host: process.env.PGHOST ?? "localhost",
  port: process.env.PGPORT ?? 5432,
  user: process.env.PGUSER ?? "postgres",
  database: process.env.PGDATABASE ?? "toko_db",
};

const client = new pg.Client(koneksi);
await client.connect();

// Transaksi 1: dua perubahan stok yang harus terjadi bersama-sama
// (pindahan barang dari produk 2 ke produk 1).
try {
  await client.query("BEGIN");
  await client.query("UPDATE produk SET stok = stok - 2 WHERE id = 2");
  await client.query("UPDATE produk SET stok = stok + 2 WHERE id = 1");
  await client.query("COMMIT");
  console.log("Transaksi 1 di-commit: dua stok berubah bersamaan");
} catch (err) {
  await client.query("ROLLBACK");
  throw err;
}

// Transaksi 2: gagal di tengah jalan karena id=1 sudah ada.
// ROLLBACK membatalkan UPDATE yang tadinya sukses.
try {
  await client.query("BEGIN");
  await client.query("UPDATE produk SET stok = stok - 5 WHERE id = 3");
  await client.query(
    "INSERT INTO produk (id, nama, harga, stok) VALUES ($1, $2, $3, $4)",
    [1, "Produk Duplikat", 100000, 1],
  );
  await client.query("COMMIT");
} catch (err) {
  await client.query("ROLLBACK");
  console.log("Transaksi 2 di-rollback:", err.code, "-", err.constraint);
}

// Bukti akhir: hanya perubahan transaksi 1 yang tersimpan.
const stok = await client.query(
  "SELECT id, stok FROM produk WHERE id IN (1, 2, 3) ORDER BY id",
);
console.log("Stok akhir:");
for (const { id, stok: jumlah } of stok.rows) {
  console.log(`  produk ${id}: stok ${jumlah}`);
}

await client.end();

Output yang Dihasilkan:

text
Transaksi 1 di-commit: dua stok berubah bersamaan
Transaksi 2 di-rollback: 23505 - produk_pkey
Stok akhir:
  produk 1: stok 27
  produk 2: stok 10
  produk 3: stok 8

Output-nya identik dengan chapter Python secara semantik: transaksi 1 memindahkan stok (produk 1 menjadi 27, produk 2 menjadi 10), transaksi 2 dibatalkan seluruhnya setelah INSERT duplikat ditolak, dan stok produk 3 tetap 8. Perhatikan dua perbedaan gaya dari psycopg: ROLLBACK harus dipanggil eksplisit di catch (tidak ada context manager yang melakukannya), dan transaksi kedua sengaja menangkap error tanpa melempar ulang — error di sana memang skenario yang diharapkan, bukan bug.

Menangani Error dan SQLSTATE #

Error PostgreSQL yang tiba di catch node-postgres bukan sekadar Error dengan pesan: driver menempelkan field protokol error server sebagai properti — code (SQLSTATE), severity, message, detail, constraint, sampai table. File berikut memasukkan id duplikat dan membedah isi objek errornya:

javascript error-handling.mjs
import pg from "pg";

// Parameter koneksi mengikuti variabel environment PG* --
// konvensi libpq yang sama dengan psql.
const koneksi = {
  host: process.env.PGHOST ?? "localhost",
  port: process.env.PGPORT ?? 5432,
  user: process.env.PGUSER ?? "postgres",
  database: process.env.PGDATABASE ?? "toko_db",
};

const client = new pg.Client(koneksi);
await client.connect();

// INSERT sah terlebih dahulu.
await client.query(
  "INSERT INTO produk (id, nama, harga, stok) VALUES ($1, $2, $3, $4)",
  [90, "Docking Station USB-C", 950000, 5],
);
console.log("INSERT id=90: sukses");

// INSERT kedua memakai id yang sama: server mengirim error, dan
// node-postgres menempelkan field error PostgreSQL pada objek err.
try {
  await client.query(
    "INSERT INTO produk (id, nama, harga, stok) VALUES ($1, $2, $3, $4)",
    [90, "Docking Station USB-C", 950000, 5],
  );
} catch (err) {
  // err.code berisi SQLSTATE -- kode lima karakter yang sama di
  // seluruh driver dan didokumentasikan di PostgreSQL.
  if (err.code === "23505") {
    console.log("INSERT ulang id=90: ditolak server");
    console.log("  code      :", err.code);
    console.log("  severity  :", err.severity);
    console.log("  message   :", err.message);
    console.log("  detail    :", err.detail);
    console.log("  constraint:", err.constraint);
  } else {
    throw err;
  }
}

await client.end();

Output yang Dihasilkan:

text
INSERT id=90: sukses
INSERT ulang id=90: ditolak server
  code      : 23505
  severity  : ERROR
  message   : duplicate key value violates unique constraint "produk_pkey"
  detail    : Key (id)=(90) already exists.
  constraint: produk_pkey

SQLSTATE 23505 pada err.code adalah kode yang sama dengan errorInfo[0] di PDO dan e.sqlstate di psycopg — standar lima karakter yang didokumentasikan di Appendix A PostgreSQL, sehingga tabel rujukan kodenya berlaku untuk semua bahasa. Cabang else throw pada contoh meneruskan error yang tidak dikenal: error yang tidak Anda pahami sebaiknya tetap terdengar, bukan ditelan. Berbeda dengan psycopg, di sini tidak ada transaksi implisit yang perlu di-rollback manual setelah error — setiap client.query otomatis berjalan sebagai transaksinya sendiri kecuali Anda membuka BEGIN lebih dulu.

Sekilas Pool: Koneksi yang Daur Ulang #

Client membuka dan menutup satu koneksi; untuk server web yang melayani permintaan terus-menerus, biaya pembentukan koneksi itu terasa pada setiap request. Kelas pg.Pool menyimpan sekelompok koneksi siap pakai dan meminjamkannya per query:

javascript pool-dasar.mjs
import pg from "pg";

// Pool menyimpan koneksi siap pakai: query pertama membuka
// koneksi, query berikutnya mendaur ulangnya. Perhatikan tidak
// ada client.connect()/client.end() per query di sini.
const pool = new pg.Pool({
  host: process.env.PGHOST ?? "localhost",
  port: process.env.PGPORT ?? 5432,
  user: process.env.PGUSER ?? "postgres",
  database: process.env.PGDATABASE ?? "toko_db",
  max: 5,
});

// pool.query = pinjam koneksi, jalankan query, kembalikan ke pool.
const hasil = await pool.query("SELECT count(*)::int AS jumlah FROM produk");
console.log("Jumlah produk lewat pool.query:", hasil.rows[0].jumlah);

// Beberapa query serentak dilayani pool yang sama; Promise.all
// menjaga urutan hasil sesuai urutan pengiriman.
const tugas = [1, 2, 3].map((n) => pool.query("SELECT $1::int AS nomor", [n]));
for (const h of await Promise.all(tugas)) {
  console.log("query serentak selesai:", h.rows[0].nomor);
}

// Tutup seluruh koneksi pool saat aplikasi berhenti.
await pool.end();

Output yang Dihasilkan:

text
Jumlah produk lewat pool.query: 5
query serentak selesai: 1
query serentak selesai: 2
query serentak selesai: 3

Angka 5 pada opsi max adalah jumlah koneksi maksimum yang pool pelihara. Nilainya kecil secara sengaja — dan di situlah rancangan seluruh arsitektur koneksi aplikasi ditentukan: berapa pool per instance, berapa total terhadap max_connections, dan kapan PgBouncer perlu turun tangan. Chapter berikutnya mendedikasikan seluruh bahasannya untuk menjawab pertanyaan itu, lengkap dengan bukti nyata bahwa pool benar-benar mendaur ulang koneksi server yang sama.

Referensi #