Transfer saldo dompet digital adalah janji lintas document: saldo Budi berkurang tepat sebanyak saldo Siti bertambah, tanpa keadaan di antaranya. Di Chapter Multi-Document Transactions (Section Relationships dan Consistency) kita menjalankan janji itu dari mongosh; sekarang aplikasi JavaScript yang harus menjaganya — dan bentuk API-nya berubah: dari perintah shell menjadi method driver yang menerima opsi session. Server latihan kita adalah replica set rs0, sehingga seluruh skenario di chapter ini dijalankan sungguhan, bukan sekadar dibayangkan: commit, abort, sampai kegagalan di tengah callback.

Session adalah Tiket Masuk Transaksi #

Transaksi menempel pada session — object yang dibuat lewat client.startSession(). Session menjadi identitas bersama untuk satu rangkaian operasi: begitu startTransaction() dipanggil, setiap operasi yang membawa opsi { session } dianggap bagian transaksi itu. Lewatkan opsi ini di method CRUD mana pun — updateOne, insertOne, find, hingga stage aggregate() — sebagai properti terakhir pada objek opsi.

Urutan core API-nya: buat session, mulai transaksi, jalankan operasi, akhiri dengan commitTransaction() (perubahan permanen) atau abortTransaction() (seluruh perubahan digulung), lalu endSession() untuk melepaskan session.

Core API: commit dan abort Manual #

File pertama menjalankan dua skenario pada collection dompet: transfer yang di-commit, dan transfer yang di-abort tepat sebelum commit:

js
// transfer-saldo.js — transaksi core API: startTransaction, commit, abort
// Server WAJIB replica set (server latihan rs0 memenuhinya).
// Jalankan: MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node transfer-saldo.js
const { MongoClient } = require("mongodb")

const uri = process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/?directConnection=true"

async function bacaSaldo(dompet) {
  const hasil = await dompet.find({}, { projection: { _id: 0, pengguna: 1, saldo: 1 } }).sort({ pengguna: 1 }).toArray()
  return hasil.map(d => `${d.pengguna}=${d.saldo}`).join(", ")
}

async function main() {
  const client = new MongoClient(uri)
  await client.connect()
  const db = client.db("latihan_js")
  const dompet = db.collection("dompet")
  await dompet.drop().catch(() => {})
  await dompet.insertMany([
    { pengguna: "Budi", saldo: 100000 },
    { pengguna: "Siti", saldo: 50000 },
  ])

  console.log("saldo awal        :", await bacaSaldo(dompet))

  // ===== Skenario 1: commit — perubahan jadi permanen =====
  const s1 = client.startSession()
  try {
    s1.startTransaction()
    await dompet.updateOne({ pengguna: "Budi" }, { $inc: { saldo: -25000 } }, { session: s1 })
    await dompet.updateOne({ pengguna: "Siti" }, { $inc: { saldo: 25000 } }, { session: s1 })
    await s1.commitTransaction()
    console.log("commit 25000     : permanen")
  } finally {
    await s1.endSession()
  }
  console.log("saldo setelahnya :", await bacaSaldo(dompet))

  // ===== Skenario 2: abort — seluruh perubahan digulung =====
  const s2 = client.startSession()
  try {
    s2.startTransaction()
    await dompet.updateOne({ pengguna: "Budi" }, { $inc: { saldo: -10000 } }, { session: s2 })
    await dompet.updateOne({ pengguna: "Siti" }, { $inc: { saldo: 10000 } }, { session: s2 })
    await s2.abortTransaction()
    console.log("abort 10000      : digulung, seolah tidak terjadi")
  } finally {
    await s2.endSession()
  }
  console.log("saldo setelahnya :", await bacaSaldo(dompet))

  await client.close()
}

main().catch(err => {
  console.error("GAGAL:", err.name, "-", err.message)
  process.exit(1)
})
bash
MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node transfer-saldo.js

Output yang Dihasilkan:

text
saldo awal        : Budi=100000, Siti=50000
commit 25000     : permanen
saldo setelahnya : Budi=75000, Siti=75000
abort 10000      : digulung, seolah tidak terjadi
saldo setelahnya : Budi=75000, Siti=75000

Mari kita bedah baris-baris kuncinya: client.startSession() membuat session dari client yang sudah terhubung — bukan dari database atau collection. Kedua updateOne membawa { session: s1 } sebagai argumen ketiga; itulah yang menyatukan keduanya dalam satu transaksi. Pada skenario pertama commitTransaction() membuat keduanya permanen bersama; pada skenario kedua abortTransaction() menggulung semuanya walau kedua updateOne sudah “berhasil” dieksekusi. endSession() di blok finally memastikan session selalu dilepaskan, apa pun akhir transaksinya.

Kapan core API dipilih padahal callback API lebih ringkas? Core API memberi kendali penuh atas momen commit dan abort — berguna saat keputusan tersebut bergantung pada hasil pembacaan di luar transaksi, atau saat kita ingin membaca data yang baru ditulis namun belum di-commit dari dalam session yang sama. Untuk sisanya, withTransaction adalah pilihan bawaan: lebih pendek, sulit salah urutan, dan sudah menyertakan penanganan galat transien.

Satu prasyarat server perlu dicatat: transaksi multi-document hanya berjalan pada replica set dan sharded cluster. startTransaction() sendiri murni keadaan client-side, tetapi perintah pertama yang dikirim ke server standalone akan ditolak dengan galat IllegalOperation (“Transaction numbers are only allowed on a replica set member or mongos”). Server latihan kita memakai replica set rs0 (single-node) sehingga seluruh contoh di atas dieksekusi langsung.

Callback API: withTransaction #

Core API menuntut kita memanggil commit/abort sendiri di setiap cabang kode. withTransaction(callback) memadatkannya: driver memulai transaksi, menjalankan callback, lalu commit otomatis bila callback selesai normal — dan abort otomatis bila callback melempar galat. Callback menerima session sebagai parameter, dan operasi di dalamnya tetap wajib membawa { session }:

js
// transfer-withtransaction.js — callback API: withTransaction dengan commit otomatis
// Jalankan: MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node transfer-withtransaction.js
const { MongoClient } = require("mongodb")

const uri = process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/?directConnection=true"

async function bacaSaldo(dompet) {
  const hasil = await dompet.find({}, { projection: { _id: 0, pengguna: 1, saldo: 1 } }).sort({ pengguna: 1 }).toArray()
  return hasil.map(d => `${d.pengguna}=${d.saldo}`).join(", ")
}

async function main() {
  const client = new MongoClient(uri)
  await client.connect()
  const db = client.db("latihan_js")
  const dompet = db.collection("dompet")
  const mutasi = db.collection("mutasi")
  await dompet.drop().catch(() => {})
  await mutasi.drop().catch(() => {})
  await dompet.insertMany([
    { pengguna: "Budi", saldo: 100000 },
    { pengguna: "Siti", saldo: 50000 },
  ])
  // unique index untuk keperluan uji kegagalan — dibuat DI LUAR transaksi
  await mutasi.createIndex({ kode: 1 }, { unique: true })

  console.log("saldo awal        :", await bacaSaldo(dompet))

  // ===== Skenario 1: callback selesai tanpa galat -> commit otomatis =====
  const s1 = client.startSession()
  await s1.withTransaction(async (session) => {
    await dompet.updateOne({ pengguna: "Budi" }, { $inc: { saldo: -10000 } }, { session })
    await dompet.updateOne({ pengguna: "Siti" }, { $inc: { saldo: 10000 } }, { session })
    await mutasi.insertOne({ kode: "TRF-100", dari: "Budi", ke: "Siti", nominal: 10000 }, { session })
  })
  await s1.endSession()
  console.log("withTransaction   : commit otomatis + mutasi tercatat")

  // ===== Skenario 2: callback melempar galat -> abort otomatis =====
  const s2 = client.startSession()
  try {
    await s2.withTransaction(async (session) => {
      await dompet.updateOne({ pengguna: "Budi" }, { $inc: { saldo: -10000 } }, { session })
      await dompet.updateOne({ pengguna: "Siti" }, { $inc: { saldo: 10000 } }, { session })
      // kode TRF-100 sudah dipakai skenario 1 -> duplicate key di dalam transaksi
      await mutasi.insertOne({ kode: "TRF-100", dari: "Budi", ke: "Siti", nominal: 10000 }, { session })
    })
  } catch (err) {
    console.log("callback gagal    :", err.name, "| code:", err.code)
    console.log("                   ", err.message.split("\n")[0])
  } finally {
    await s2.endSession()
  }

  console.log("saldo setelahnya  :", await bacaSaldo(dompet), "(tidak berubah)")

  await db.dropDatabase()
  await client.close()
}

main().catch(err => {
  console.error("GAGAL:", err.name, "-", err.message)
  process.exit(1)
})
bash
MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node transfer-withtransaction.js

Output yang Dihasilkan:

text
saldo awal        : Budi=100000, Siti=50000
withTransaction   : commit otomatis + mutasi tercatat
callback gagal    : MongoServerError | code: 11000
                    E11000 duplicate key error collection: latihan_js.mutasi index: kode_1 dup key: { kode: "TRF-100" }
saldo setelahnya  : Budi=90000, Siti=60000 (tidak berubah)

Skenario pertama berakhir commit: saldo bergeser 10000 dan mutasi TRF-100 tercatat. Skenario kedua sengaja menyisipkan mutasi ber-kode sama di akhir callback — insertOne melempar MongoServerError dengan code: 11000 (duplicate key), withTransaction meng-abort seluruh transaksi, dan galat itu diteruskan ke catch kita. Saldo akhir membuktikan kedua updateOne ikut tergulung — padahal keduanya dieksekusi sebelum kegagalan terjadi.

Dua perilaku withTransaction melengkapi gambar ini. Pertama, driver otomatis mengulang callback bila galat bertanda transient — kegagalan sesaat seperti pemilihan node baru saat failover — sehingga pola coba-ulang tidak perlu kita tulis sendiri. Kedua, seluruh callback dibatasi waktu 60 detik secara bawaan; callback yang lebih lama dari itu dihentikan dan digulung. Makna error label seperti TransientTransactionError sudah kita bedah di Section Relationships dan Consistency, dan pola coba-ulang eksplisit untuk operasi non-transaksi kita praktikkan di Chapter Error Handling dan Retries.

Rangkuman #

  • Transaksi menempel pada session dari client.startSession(); setiap operasi di dalamnya wajib membawa opsi { session }.
  • Core API: startTransaction() → operasi → commitTransaction()/abortTransaction() → endSession() di finally.
  • withTransaction(callback) melakukan commit/abort otomatis, mengulang galat transien, dan membatasi callback 60 detik.
  • Kegagalan di tengah callback (mis. duplicate key code: 11000) menggulung seluruh operasi transaksi — diverifikasi dari saldo yang tidak berubah.