All Posts

21 Agustus 20269 menit

Awalnya ini bukan library. Ini cuma tool internal untuk project-project kecil milik saya sendiri. Waktu itu saya butuh sesuatu yang sederhana, yaitu state yang penulisannya instan, tersimpan di browser, dan sinkron ke server kalau internet ada. Kebanyakan dari kita pernah merasakan aplikasi yang macet total begitu koneksi putus, dan dari situ saya melihat ada kebutuhan nyata untuk fitur seperti ini.

Jadi saya bangun versi paling polos yang bisa jalan. Satu store, simpan ke storage, selesai. Dipakai untuk project-project internal kecil-kecilan, dan selama beberapa bulan tidak ada yang protes. Versi pertama ini bahkan belum kepikiran untuk dipublish. Dokumentasinya pun masih Docusaurus standar hasil scaffold, isinya catatan untuk diri saya sendiri.

Lalu seperti biasa, ada hal kecil yang membuka mata.

Bug yang mengubah arah proyek ini

Saat mulai dipakai di skenario yang lebih serius, muncul laporan yang bikin saya dingin sekali membacanya. Di environment server-side rendering, sesekali data satu user bisa terbawa ke request user lain. Bayangkan membuka aplikasi dan melihat keranjang belanja orang lain. Itu bukan bug kosmetik. Itu kebocoran data.

Akar masalahnya sederhana dan memalukan. Store disimpan di module-level Map. Di server, module itu dishare antar semua request. Jadi store user A dan user B sebenarnya menunjuk objek yang sama. Selama ini aman karena dipakai sendirian di browser. Begitu ketemu SSR, semua lubangnya kelihatan.

Perbaikannya memaksa saya merombak fondasi. Store registry harus terikat pada component tree masing-masing request, bukan pada global module. Di React ia hidup di dalam Context, di Vue ia disuntik lewat plugin.

// Store harus punya rumah yang jelas di component tree
export function App() {
  return (
    <SyncraftProvider>
      <TodoApp />
    </SyncraftProvider>
  );
}

Ini breaking change. Semua pemakaian lama langsung error kalau lupa wrap dengan Provider. Saya sempat ragu, tapi keputusannya gampang. Error yang jelas saat development jauh lebih baik daripada kebocoran data diam-diam di production. Rilis itu menjadi v0.2.0, dan secara tidak sadar, tool internal itu resmi berubah status jadi library yang dijaga serius.

Berhenti pinjam, mulai tulis sendiri

Setelah urusan keamanan beres, giliran bundle size yang mengganjal. Core bergantung pada immer untuk urusan immutability. Tidak ada salahnya immer sebagai teknologi, tapi untuk sebuah engine yang ingin ringan dan tree-shakeable, membawa dependency sebesar itu untuk satu fitur terasa tidak konsisten.

Jadi di v0.3.0 saya tulis ulang bagian draft mutation pakai Proxy murni, tanpa dependency. Hasil akhirnya, mutasi state terasa seperti JavaScript biasa, sementara immutability ditangani engine di belakang layar.

// Mutasi terasa biasa saja,
// padahal state asli tidak pernah diganggu
await store.set((draft) => {
  draft.count += 1;
});

Rilis ini juga menyisakan pelajaran favorit saya soal TypeScript. Generic awalnya T extends object, longgar dan ramah. Masalahnya, Map dan Set lolos dari constraint itu, padahal keduanya tidak bisa diserialisasi ke IndexedDB. Kegagalannya baru terasa saat runtime, jauh setelah compiler bilang aman. Akhirnya generic diperketat jadi seperti ini.

// Hanya plain object atau array yang boleh persist
createSyncStore<T extends Record<string, unknown> | any[]>(config);

Compiler sekarang yang menolak lebih awal. Lebih baik berdebat dua detik dengan compiler di waktu build, daripada debugging semalaman di IndexedDB. Sambil jalan, saya juga migrasikan situs dokumentasi dari Docusaurus ke Astro Starlight. Alasannya sama dengan filosofi library-nya. Dokumentasi statis tidak perlu kirim JavaScript ke pembaca.

Fondasi integritas data

Rilis v0.4.0 adalah milestone yang sejak awal saya rindukan, yaitu berhenti menambal dan mulai meletakkan fondasi. Ada empat perubahan besar di dalamnya, dan semuanya lahir dari satu keluhan yang sama, yaitu outbox.

Outbox adalah antrian mutasi yang belum sempat dikirim ke server. Dulu setiap mutasi menyimpan salinan penuh state di dalamnya. Untuk dataset besar, itu boros sekali. Sekarang antrian hanya menyimpan patch dan inverse patch, sehingga footprint penyimpanannya turun lebih dari delapan puluh persen untuk dataset besar.

{
  "id": "7f3c...",
  "patches": [
    { "op": "replace", "path": "/todos/0/done", "value": true }
  ],
  "inversePatches": [
    { "op": "replace", "path": "/todos/0/done", "value": false }
  ]
}

Keluhan kedua soal bandwidth. Kalau user offline seharian dan mengubah field yang sama sepuluh kali, tidak ada gunanya mengirim sepuluh mutasi ke server. Fungsi compactOutbox menggabungkan mutasi yang menyasar path yang sama, last-write-wins, tepat sebelum siklus push berjalan.

Keluhan ketiga soal batas. Outbox yang tumbuh tanpa kontrol adalah bom waktu. Sekarang ada overflowStrategy dengan tiga pilihan, yaitu "reject", "dropOldest", atau "forceFlush", plus callback onOverflow supaya aplikasi bisa ambil keputusan sendiri saat antriannya penuh.

Terakhir soal bug yang paling susah dilacak, yaitu mutasi state di luar jalurnya. Sekarang state otomatis dibekukan rekursif via Object.freeze() saat mode development.

// TypeError di development,
// zero overhead di production build
data.todos.push(newTodo);

Ada juga assertNoCycles yang mendeteksi circular reference sebelum data masuk ke draft, lengkap dengan property path di pesan errornya. Semua fitur ini menegaskan satu prinsip yang sama, yaitu kesalahan konfigurasi harus meledak di waktu development, bukan bocor diam-diam di production.

Bentuknya sekarang

Kalau disederhanakan, begini alurnya. User melakukan aksi, draft Proxy menerimanya tanpa menunggu apa pun, state baru langsung tayang di UI. Di belakang layar, state yang sama ditulis ke IndexedDB supaya bertahan hidup lewat refresh dan matinya koneksi, sementara mutasinya mengantre di outbox untuk dikirim background pusher begitu jaringan kembali.

Library ini terbagi menjadi tiga paket, masing-masing dengan satu tanggung jawab.

PaketIsi
@syncraft-labs/coreEngine tanpa framework berisi proxy draft, IndexedDB, dan outbox
@syncraft-labs/reactHook useSync dan useSyncSuspense beserta Provider
@syncraft-labs/vueComposable useSync beserta plugin

Prinsip pembagiannya sengaja sederhana. Framework hanya jadi lapisan tipis di atas core, jadi mendukung React dan Vue tidak berarti menjaga dua implementasi engine.

Jujur soal posisi sekarang

Versi 0.x tetaplah versi 0.x. API masih bisa berubah, dan ada hal yang sengaja belum disentuh, misalnya resolusi konflik dua arah yang serius. Sinkronisasi saat ini one-way push dengan strategi yang bisa dikonfigurasi, cukup untuk mayoritas kasus local-first, tapi belum multi-master. Saya lebih suka mengakuinya di depan daripada mengklaim sesuatu yang belum saya uji.

Pelajaran yang saya bawa

Dua bulan menjaga project ini meninggalkan beberapa hal yang saya bawa ke pekerjaan selanjutnya.

  • Kelihatan aman bukan berarti benar-benar aman. Module-level state terasa wajar selama cuma dipakai di browser, sampai pindah ke server-side rendering dan lubangnya baru kelihatan.
  • Dependency adalah komitmen jangka panjang. Membawa immer terasa ringan di awal, tapi melepaskannya butuh satu rilis besar penuh perhitungan.
  • Compiler lebih bisa diandalkan daripada niat baik. Constraint generic yang ketat mencegah bug lebih awal daripada dokumentasi paling rapi.
  • Menjaga library publik itu beda rasanya dengan menulis tool internal. Tool internal boleh berubah seenaknya karena satu-satunya penggunanya saya sendiri, sedangkan library publik memaksa saya menulis breaking change dengan jujur dan berpikir dua kali sebelum menambah satu baris kode lagi.

Penutup

Dari tool kecil yang cuma dipakai sendiri, sampai tiga paket di npm dengan fondasi integritas data yang akhirnya pantas disebut begitu. Perjalanan Syncraft Labs baru sampai Milestone 1, dan saya menulis cerita ini sekalian sebagai penanda, biar nanti kalau versinya sudah jauh berkembang, ada catatan tentang titik awalnya.

Kalau kamu sedang membangun sesuatu yang serupa, atau sekadar ingin diskusi soal local-first architecture, kabari saya. Kotak masuk email saya selalu terbuka.