Menyempurnakan offline-first CatatanKu: memahami prinsip local-first, memakai CRDT dan LWW untuk resolusi konflik, serta merancang mesin sinkronisasi antar perangkat yang berfungsi di jaringan lambat atau tanpa koneksi

Sejak episode 8 CatatanKu menganut pola offline-first: data lokal adalah sumber utama, jaringan hanya pelengkap. Episode ini mewujudkan janji itu sepenuhnya: local-first & sync. Pengguna CatatanKu menulis di laptop kantor tanpa jaringan, lalu menulis lagi di komputer rumah — dan kedua catatan harus bersatu tanpa kehilangan satu karakter pun.
Ini masalah yang jauh lebih dalam dari sekadar "upload file". Dua perangkat mengedit data yang sama pada waktu berbeda — siapa yang menang? Jawaban naif ("yang terakhir menulis") menimpa pekerjaan yang satu lagi. Episode ini membangun mesin sinkronisasi CatatanKu dengan CRDT (struktur data tanpa konflik) dan LWW (last-write-wins) untuk metadata — fondasi yang dipakai aplikasi seperti Obsidian, Notion, dan Figma.
Sinkronisasi yang baik dimulai dari desain yang benar. Empat prinsip local-first yang kita pegang:
1. Data lokal = sumber kebenaran; cloud hanya replika
2. Aplikasi berfungsi penuh tanpa jaringan
3. Multi-device: setiap perangkat punya salinan lengkap
4. Konflik diselesaikan secara otomatis & deterministikKonsekuensi desainnya: database lokal (episode 8) menjadi kanonik, bukan cache dari server. Jika server hilang, pengguna tidak kehilangan apa pun. Ini kebalikan dari pola web tradisional di mana browser hanya "pemandangan" dari state server.
Di episode 8 kita memakai UUID sebagai id catatan — bukan kebetulan. Dengan UUID, setiap perangkat bisa membuat catatan baru secara mandiri tanpa khawatir tabrakan id saat sinkron. Ini prasyarat pertama sync yang sehat.
Untuk metadata sederhana (judul, updated_at), Last-Write-Wins sudah cukup: entri dengan timestamp terbesar yang menang. Murni, deterministik, dan mudah:
-- Tabel perubahan yang dikirim antar perangkat
CREATE TABLE outbox (
note_id TEXT NOT NULL,
field TEXT NOT NULL,
value TEXT NOT NULL,
version INTEGER NOT NULL, -- clock logika, bukan jam dinding
PRIMARY KEY (note_id, field)
);
-- Saat menerima: versi lebih besar menang
INSERT INTO notes (id, title, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
updated_at = excluded.updated_at
WHERE excluded.updated_at > notes.updated_at;Penting: pakai version clock (jumlah perubahan) atau timestamp dengan jam yang disinkronkan (NTP), bukan new Date() mentah — jam perangkat bisa meleset, dan meleset berarti data salah menang.
Isi catatan yang diedit bersamaan butuh lebih dari LWW — kalau dua perangkat menambahkan kalimat berbeda di paragraf yang sama, satu kalimat akan hilang. CRDT (Conflict-free Replicated Data Type) memungkinkan penggabungan tanpa kehilangan:
Perangkat A menulis: "Beli susu dan kopi"
Perangkat B menulis: "Beli susu dan gula"
Gabungan CRDT: "Beli susu dan kopi gula" ← tidak ada yang hilangImplementasi CRDT isi teks untuk CatatanKu bisa berupa Yjs (yang paling matang) atau Automerge:
import * as Y from "yjs";
const doc = new Y.Doc();
const notes = doc.getMap<Y.Text>("notes");
// update lokal: langsung operan pada struktur CRDT
const text = notes.get(noteId) ?? new Y.Text();
text.insert(text.length, " beli kopi");
// kirim update (diff) ke perangkat lain
const update = Y.encodeStateAsUpdate(doc);
sendToServer(update);
// terima update dari perangkat lain
Y.applyUpdate(doc, receivedUpdate);Kekuatan CRDT: update bisa tiba dalam urutan apa pun dan tetap menghasilkan state akhir yang sama di semua perangkat. Tidak ada server yang menunggu, tidak ada konflik yang harus dipecahkan pengguna — keajaiban matematika yang membuat kolaborasi offline terasa mulus.
Note
Memakai Yjs/Automerge bukan berarti tidak memahami konsepnya — justru sebaliknya. Kalian tetap perlu tahu di mana CRDT dipakai (isi teks) vs di mana LWW cukup (metadata), karena keduanya punya biaya berbeda. Jangan pakai CRDT untuk segala sesuatu.
Mesin sinkronisasi CatatanKu dirancang sebagai background task (episode 9) dengan pola antrean:
export class SyncEngine {
private cursor: number = 0;
async syncOnce() {
const localChanges = await db.query(
"SELECT * FROM outbox WHERE version > $1", [this.cursor]
);
const remoteChanges = await api.pushPull({
since: this.cursor, // incremental, bukan full upload
changes: localChanges,
});
for (const change of remoteChanges) {
await this.applyChange(change); // merge CRDT/LWW
}
await this.ackChanges(localChanges); // kirim terima
}
}Pola yang dipakai:
CRDT menangani isi teks; LWW menangani metadata. Untuk pengeditan nyata yang bersamaan (dua orang mengedit catatan yang sama dalam waktu dekat), gabungkan dengan kehadiran kolaboratif (episode 23-24 membahas extensibility; untuk sekarang, cukup tahu bahwa CRDT memberi fondasi yang memungkinkan itu).
Inti yang harus dibawa pulang:
Di episode 23 selanjutnya kita membuka CatatanKu untuk ekosistem: plugins & extensibility — arsitektur plugin, extension API, dan SDK untuk pengembang pihak ketiga. Sampai jumpa di episode 23!