Transfer uang antar rekening adalah operasi dua langkah: saldo pengirim dikurangi, saldo penerima ditambah. Bila proses berhenti di antara kedua langkah — karena listrik padam, server berhenti, atau query kedua gagal — uang telah berkurang dari satu rekening tanpa pernah sampai ke rekening lain. Transaksi adalah mekanisme database yang mencegah kejadian tersebut: serangkaian query dikelompokkan menjadi satu kesatuan yang berlaku seluruhnya atau tidak sama sekali. Chapter ini membedah semantik BEGIN, COMMIT, dan ROLLBACK melalui simulasi transfer yang bisa kita jalankan langsung dengan node:sqlite, lalu memetakan pola penerapannya di pg.

Semantik BEGIN, COMMIT, dan ROLLBACK #

Transaksi dikendalikan tiga perintah SQL. BEGIN membuka transaksi: mulai saat ini, semua perubahan yang kita buat bersifat sementara dan hanya terlihat oleh koneksi kita sendiri. COMMIT mengakhiri transaksi dengan mengukuhkan seluruh perubahan secara permanen — pada detik itu pula perubahan menjadi terlihat oleh koneksi lain. ROLLBACK mengakhiri transaksi dengan membuang seluruh perubahan sejak BEGIN, seolah tidak pernah terjadi. Dari sudut pandang data, hasilnya hanya dua: semua query dalam transaksi berhasil diterapkan, atau tidak ada satu pun yang diterapkan — sifat ini disebut atomicity.

Simulasi Transfer Uang #

Program berikut menyiapkan dua rekening, lalu menjalankan dua skenario transfer lewat satu function yang sama. Skenario pertama berhasil; skenario kedua sengaja meminta jumlah yang melebihi saldo agar kita bisa menyaksikan ROLLBACK bekerja:

js
import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');

db.exec(`
  CREATE TABLE rekening (
    nomor TEXT PRIMARY KEY,
    pemilik TEXT NOT NULL,
    saldo INTEGER NOT NULL CHECK (saldo >= 0)
  )
`);

db.exec(`
  INSERT INTO rekening (nomor, pemilik, saldo) VALUES
    ('001', 'Budi', 500000),
    ('002', 'Siti', 200000)
`);

function tampilSaldo() {
  const baris = db.prepare('SELECT nomor, pemilik, saldo FROM rekening ORDER BY nomor').all();
  console.log('Saldo terkini:', JSON.stringify(baris));
}

function transfer(dari, ke, jumlah) {
  db.exec('BEGIN');
  try {
    const sumber = db.prepare('SELECT saldo FROM rekening WHERE nomor = ?').get(dari);
    if (sumber.saldo < jumlah) {
      throw new Error(`saldo ${sumber.saldo} tidak cukup untuk transfer ${jumlah}`);
    }
    db.prepare('UPDATE rekening SET saldo = saldo - ? WHERE nomor = ?').run(jumlah, dari);
    db.prepare('UPDATE rekening SET saldo = saldo + ? WHERE nomor = ?').run(jumlah, ke);
    db.exec('COMMIT');
    return 'berhasil';
  } catch (error) {
    db.exec('ROLLBACK');
    return `dibatalkan (${error.message})`;
  }
}

console.log('Keadaan awal:');
tampilSaldo();

console.log('Transfer 150000 dari 001 ke 002:', transfer('001', '002', 150000));
tampilSaldo();

console.log('Transfer 999999 dari 002 ke 001:', transfer('002', '001', 999999));
tampilSaldo();

db.close();

Output yang Dihasilkan:

text
Keadaan awal:
Saldo terkini: [{"nomor":"001","pemilik":"Budi","saldo":500000},{"nomor":"002","pemilik":"Siti","saldo":200000}]
Transfer 150000 dari 001 ke 002: berhasil
Saldo terkini: [{"nomor":"001","pemilik":"Budi","saldo":350000},{"nomor":"002","pemilik":"Siti","saldo":350000}]
Transfer 999999 dari 002 ke 001: dibatalkan (saldo 350000 tidak cukup untuk transfer 999999)
Saldo terkini: [{"nomor":"001","pemilik":"Budi","saldo":350000},{"nomor":"002","pemilik":"Siti","saldo":350000}]

Mari kita bedah function transfer sebagai inti dari seluruh program:

  1. Baris 25 (db.exec('BEGIN')): transaksi dibuka sebelum kondisi apapun diperiksa. Semua UPDATE setelah baris ini berada dalam mode sementara.
  2. Baris 27-30 (pemeriksaan saldo): baca saldo sumber, lalu lempar Error bila tidak mencukupi. Melempar error di sini terasa seperti kegagalan, tetapi justru itu jalur yang kita rancang — throw meneruskan kendali ke blok catch untuk pembatalan.
  3. Baris 31-32 (dua UPDATE): pengurangan dan penambahan saldo berdiri dalam satu transaksi. Tanpa transaksi, kegagalan di baris kedua akan meninggalkan saldo yang sudah terpotong tanpa kompensasi.
  4. Baris 33 (db.exec('COMMIT')): kedua perubahan dikukuhkan bersama-sama; tidak ada titik waktu ketika database menyimpan hasil parsial.
  5. Baris 35-38 (ROLLBACK di catch): seluruh perubahan sejak BEGIN dibuang. Output skenario kedua membuktikannya: saldo kedua rekening tetap 350000 dan 350000, persis seperti akhir skenario pertama.
  6. Baris 9 (CHECK (saldo >= 0)): batasan ini adalah jaring pengaman tambahan — meski logika aplikasi keliru mencoba mengurangi saldo hingga minus, database menolaknya. Kegagalan dari batasan ini pun berakhir di catch dan memicu ROLLBACK.

Pola Transaksi di pg #

Pada pg, transaksi membutuhkan satu Client yang dipinjam dari pool selama rangkaian query berlangsung — itulah guna pool.connect() yang kita singgung pada Chapter *Connection Pooling`. Pola lengkapnya:

js Transaksi dengan pg dari dalam pool
import pg from 'pg';

const { Pool } = pg;

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function transfer(dari, ke, jumlah) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const { rows } = await client.query(
      'SELECT saldo FROM rekening WHERE nomor = $1 FOR UPDATE',
      [dari]
    );
    if (rows[0].saldo < jumlah) {
      throw new Error('Saldo tidak cukup');
    }
    await client.query(
      'UPDATE rekening SET saldo = saldo - $1 WHERE nomor = $2',
      [jumlah, dari]
    );
    await client.query(
      'UPDATE rekening SET saldo = saldo + $1 WHERE nomor = $2',
      [jumlah, ke]
    );
    await client.query('COMMIT');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

Struktur try/catch/finally pada snippet ini adalah pola baku yang layak dihafal. Blok try menjalankan seluruh pekerjaan sampai COMMIT; blok catch membatalkan lewat ROLLBACK lalu melempar kembali error agar pemanggil tahu transfer gagal; blok finally memastikan koneksi kembali ke pool apa pun akhirnya — tanpa release(), koneksi bocor sedikit demi sedikit hingga pool habis. Klausal FOR UPDATE mengunci baris sumber selama transaksi berjalan, mencegah dua transfer serentak membaca saldo yang sama lalu keduanya lolos pemeriksaan.

Kapan Transaksi Diperlukan #

Tidak semua operasi butuh transaksi. Satu statement tunggal — satu INSERT, satu UPDATE — sudah atomic secara bawaan; membungkusnya dengan BEGIN/COMMIT hanya menambah gerakan tanpa manfaat. Transaksi menjadi wajib ketika dua kondisi ini hadir bersama:

  • Beberapa statement harus terjadi sebagai satu kesatuan, misalnya dua UPDATE transfer, atau INSERT pesanan diikuti INSERT rinciannya.
  • Ada jendela kegagalan di antara statement tersebut — constraint dilanggar, koneksi terputus, atau validasi bisnis gagal.

Kita kini mampu menjaga integritas data di tingkat baris dan koneksi. Namun sepanjang Section ini, kita tetap menulis SQL dengan tangan: merangkai SELECT, mengisi placeholder, menjaga transaksi. Lapisan perangkat lunak bernama ORM menawarkan jalan lain — menulis operasi database sebagai kode JavaScript yang type-safe — dan menilai apakah tawaran itu layak diambil adalah pembahasan terakhir Section ini, pada Chapter ORM dengan Prisma.