Collaborative editing artinya beberapa orang mengubah teks yang sama dari tempat berbeda, dan semua layar akhirnya menampilkan teks yang sama tanpa ada ketikan yang hilang. Di Laundry Bu Rini, tiap order punya kolom catatan order. Bu Rini mengisinya di meja kasir, Mas Eko di tablet dekat mesin cuci. Bayangin mereka masing-masing memegang fotokopi catatan yang sama, menulis di fotokopinya sendiri, lalu saling mengirim apa yang mereka tulis. Masalahnya: "huruf ke-14" di fotokopi Bu Rini bisa jadi huruf ke-16 di fotokopi Mas Eko. CRDT menyelesaikannya dengan memberi nama tetap ke setiap huruf, jadi orang bilang "sesudah huruf 14@rini", bukan "di posisi 14".
🧪 Tebakan cepat: Catatan order #12 isinya
kemeja dipisah. Di detik yang sama, Bu Rini menambahkan, jangan pakai pewangidi akhir, dan Mas Eko menambahkan2di depan. Server menyimpan teks utuh yang terakhir datang. Apa isi catatan setelahnya? Jawabannya ada di bagian "Last-write-wins: satu ketikan hilang".
Last-write-wins: satu ketikan hilang
Cara paling gampang: setiap kali selesai mengetik, kirim seluruh isi kolom, dan server menyimpan yang terakhir datang (last-write-wins, disingkat LWW).
// browser
input.addEventListener('change', () => socket.emit('catatan:simpan', { id: 12, teks: input.value }))
// server
socket.on('catatan:simpan', ({ id, teks }) => { catatan.set(id, teks); io.to('staf').emit('catatan', { id, teks }) })
| Jam | Siapa | Kirim teks utuh | Isi di server |
|---|---|---|---|
| 09.41.02 | Bu Rini | kemeja dipisah, jangan pakai pewangi |
versi Bu Rini |
| 09.41.03 | Mas Eko | 2 kemeja dipisah |
versi Mas Eko |
Hasilnya 2 kemeja dipisah. Itu jawaban tebakannya: tulisan Bu Rini hilang tanpa pesan error, dan cucian Pak Darto bisa dicuci pakai pewangi. LWW cuma aman untuk nilai yang memang diganti utuh, misalnya nomor HP atau status order.
Kirim operasi, bukan teks utuh
Lebih hemat dan gak menimpa: kirim operasi (apa yang berubah). Bu Rini mengirim sisip(14, ", jangan pakai pewangi"), Mas Eko mengirim sisip(0, "2 "). Tapi kalau tiap layar menerapkan posisi itu apa adanya:
| Layar | Sebelum | Menerapkan | Hasil |
|---|---|---|---|
| Bu Rini | kemeja dipisah, jangan pakai pewangi |
sisip(0, "2 ") |
2 kemeja dipisah, jangan pakai pewangi |
| Mas Eko | 2 kemeja dipisah |
sisip(14, ", jangan ...") |
2 kemeja dipis, jangan pakai pewangiah |
Di tablet Mas Eko, posisi 14 sudah bergeser 2 huruf karena 2 di depan. Dua layar berbeda, dan salah satunya rusak.
OT: server menggeser posisinya
Operational Transformation (OT) memperbaiki posisi sebelum menerapkan. Server pusat memutuskan urutan: sisip(0, "2 ") duluan. Operasi Bu Rini yang datang belakangan ditransformasi: "ada 2 huruf disisipkan sebelum posisi 14, jadi geser ke 16", menjadi sisip(16, ...). Google Docs memakai pendekatan ini. Kelemahannya: butuh server pusat yang mengurutkan semua operasi, dan fungsi transform untuk setiap pasangan operasi (sisip lawan hapus, hapus lawan hapus) terkenal sulit dibuat benar. OT tanpa server pusat ada di makalah, tapi dalam praktik hampir selalu ada server di tengahnya.
CRDT: tiap huruf punya nama
CRDT (Conflict-free Replicated Data Type) adalah struktur data yang setiap salinannya boleh diubah sendiri-sendiri, lalu digabung dalam urutan apa pun, dan hasil akhirnya pasti sama di semua salinan. Masalah yang ia selesaikan: konflik saat beberapa user mengedit data yang sama secara bersamaan. Untuk teks, idenya:
- Setiap huruf punya id tetap:
angka@situs.kemeja dipisahmilik Bu Rini adalah1@rinisampai14@rini. Angkanya satu lebih besar dari angka terbesar yang pernah dilihat situs itu (jam Lamport). - Operasi sisip menyebut jangkar: "huruf
,dengan id15@rini, sesudah14@rini". Id gak pernah berubah, jadi gak ada posisi yang bergeser. - Kalau dua huruf disisipkan sesudah jangkar yang sama, id yang lebih besar di kiri (angka dulu, lalu nama situs). Semua salinan memakai aturan yang sama, jadi urutannya sama.
- Hapus gak membuang huruf, cuma menandainya tombstone. Hurufnya tetap ada sebagai jangkar untuk sisipan orang lain.
const kunci = (id) => `${id.c}@${id.s}` // "15@rini"
const lebihBesar = (a, b) => (a.c !== b.c ? a.c > b.c : a.s > b.s)
function terapkan(doc, op) { // false artinya: belum bisa, tunggu
if (op.type === 'hapus') {
const el = doc.find((e) => kunci(e.id) === op.target)
if (!el) return false // huruf yang dihapus belum datang
el.dihapus = true // tombstone
return true
}
if (doc.some((e) => kunci(e.id) === kunci(op.id))) return true // sudah pernah
let i = 0
if (op.after) {
const a = doc.findIndex((e) => kunci(e.id) === op.after)
if (a < 0) return false // jangkarnya belum datang
i = a + 1
}
while (i < doc.length && lebihBesar(doc[i].id, op.id)) i++ // tie-break
doc.splice(i, 0, { id: op.id, ch: op.ch, dihapus: false })
return true
}
function terima(replika, op) { // antrean sebab-akibat
replika.antre.push(op)
for (let maju = true; maju; ) {
maju = false
for (const o of [...replika.antre]) {
if (terapkan(replika.doc, o)) { replika.antre.splice(replika.antre.indexOf(o), 1); maju = true }
}
}
}
const teks = (doc) => doc.filter((e) => !e.dihapus).map((e) => e.ch).join('')
terapkanmengembalikanfalsekalau jangkar atau huruf yang mau dihapus belum datang.terimamenyimpannya di antrean dan mencoba lagi setiap ada operasi lain yang berhasil masuk.- Loop
whilemelewati huruf ber-id lebih besar sesudah jangkar: itulah tie-break. - Operasi yang sama datang dua kali gak menambah apa pun.
Urutan datang boleh acak
Contoh di Lab: Bu Rini mengetik , jangan pakai pewangi (22 operasi sisip, 15@rini sampai 36@rini). Mas Eko mengetik 2 di depan (15@eko, 16@eko), lalu menghapus dipisah (7 operasi hapus). Kedua salinan bertukar 31 operasi dan sama-sama menjadi 2 kemeja , jangan pakai pewangi. Sisipan Bu Rini tetap mendarat sesudah 14@rini walaupun huruf itu sudah dihapus, karena tombstone-nya masih ada.
Dicoba: 31 operasi itu dikirim ke salinan ketiga (HP Pak Darto) dalam 2.000 urutan acak. Hasilnya 2.000 kali sama, antrean selalu kosong di akhir. Perhatikan juga: CRDT menjamin hasilnya sama, bukan kalimatnya bagus. Spasi sebelum koma tetap harus dirapikan manusia.
Tombstone dan harganya
Huruf yang dihapus tetap disimpan. Di CRDT buatan sendiri tadi, catatan 31 huruf disimpan sebagai 38 elemen (7 tombstone). Ketik lalu hapus satu huruf 1.000 kali, dan dokumen 14 huruf membawa 1.014 elemen. Library sungguhan menekan biaya ini: Yjs membuang isi huruf yang dihapus dan menggabungkan deretan tombstone. Diukur dengan Yjs 13.6.33 (clientID acak, seperti aslinya): kemeja dipisah = 28 byte, ditambah lalu dihapus 22 huruf = 44 byte (66 byte kalau gc: false), 1.000 kali ketik dan hapus = 46 byte.
Yjs dan Automerge: alat yang dipakai sungguhan
Jangan menulis CRDT sendiri untuk produksi. Versi yang dicek 11 Oktober 2026: yjs 13.6.33 (dengan y-websocket 3.1.0 untuk sinkron lewat WebSocket) dan @automerge/automerge 3.5.0.
import * as Y from 'yjs'
const rini = new Y.Doc(), eko = new Y.Doc()
rini.getText('catatan').insert(0, 'kemeja dipisah')
Y.applyUpdate(eko, Y.encodeStateAsUpdate(rini)) // eko menyalin isi awal
const sebelum = Y.encodeStateVector(rini)
rini.getText('catatan').insert(14, ', jangan pakai pewangi')
eko.getText('catatan').insert(0, '2 ')
Y.applyUpdate(eko, Y.encodeStateAsUpdate(rini, sebelum)) // 39 byte
Y.applyUpdate(rini, Y.encodeStateAsUpdate(eko))
console.log(rini.getText('catatan').toString()) // 2 kemeja dipisah, jangan pakai pewangi
- Dicoba: kedua dokumen menghasilkan
2 kemeja dipisah, jangan pakai pewangi. Update yang datang sebelum dasarnya disimpan dulu (pending) sampai dasarnya tiba. - Di Yjs, sisipan bersamaan di tempat yang sama diurutkan dengan
clientID: yang lebih kecil di kiri. - Di Automerge 3.5.0, teks diedit dengan
Automerge.splice(d, ['catatan'], 14, 0, ', jangan pakai pewangi')di dalamAutomerge.change, laluAutomerge.merge. Hasilnya sama. Untuk nilai biasa (misalnyastatus), Automerge memilih satu pemenang dan menyimpan yang kalah digetConflicts. - CRDT gak butuh server untuk menggabung, jadi bisa peer-to-peer dan offline. OT butuh server pusat yang mengurutkan. Itu perbedaan utama keduanya.
⚠️ Jebakan yang sering ketemu
- Menyimpan teks utuh dengan last-write-wins. Ketikan orang lain hilang tanpa error. Cocok cuma untuk nilai yang diganti utuh.
- Mengirim posisi angka tanpa transform. Begitu ada sisipan di depan, semua posisi sesudahnya meleset.
- Membuang huruf yang dihapus. Sisipan yang jangkarnya huruf itu kehilangan tempat. Hapus berarti tandai.
- Menulis CRDT sendiri untuk produksi. Tie-break, antrean, tombstone, dan ukuran data sudah diurus Yjs dan Automerge.
TL;DR: Last-write-wins menyimpan teks utuh terakhir dan diam-diam membuang ketikan orang lain. Operasi dengan posisi angka meleset begitu ada sisipan di depan. OT memperbaikinya dengan menggeser posisi, tapi butuh server pusat yang mengurutkan (Google Docs). CRDT memberi tiap huruf id tetap (15@rini), sisipan menyebut jangkar, tie-break "id lebih besar di kiri", dan hapus cuma menandai tombstone, jadi operasi boleh datang dalam urutan apa pun (2.000 urutan acak, 1 hasil) tanpa server, cocok untuk peer-to-peer dan offline. Tombstone memakan tempat; Yjs (13.6.33) dan Automerge (3.5.0) menekannya, jadi pakai mereka, bukan buatan sendiri.