19 Oktober 202610 menit
Setelah v0.5.0 rilis dan spam email itu masuk, saya sempat mikir bakal pelan-pelan dulu. Rencananya cuma ngerapihin sisa-sisa pekerjaan, nambah satu dua test, lalu balik ke project lain. Kenyataannya sebulan berikutnya jadi bulan paling padat sepanjang umur project ini. Ada tiga rilis beruntun, dan masing-masing membuka lapisan masalah yang sebelumnya cuma saya tulis di catatan sebagai "nanti kalau sempat".
Tulisan ini merangkum perjalanan dari v0.6.0 sampai v0.7.1. Bukan changelog yang dibacakan ulang, tapi cerita soal kenapa tiap perubahan itu ada, dan apa yang saya pelajari dari urutannya. Ada juga satu bagian yang nggak masuk changelog, yaitu lima hari di rumah sakit yang mengubah ritme sebulan ini.
Sinkronisasi satu arah ternyata punya batas
Sampai v0.5.0, sinkronisasi ke server cuma satu arah. Mutasi lokal masuk antrean, lalu dikirim lewat pusher. Arah sebaliknya nyaris nggak pernah disentuh. Cross-tab sync lewat BroadcastChannel memang udah ada, tapi itu cuma menyamakan tab di browser yang sama, bukan data yang datang dari server.
Selama aplikasinya dipakai satu orang di satu perangkat, model itu cukup. Begitu ada dua device, atau satu server yang mengirim update, semuanya jadi abu-abu. Data lokal dan data remote bisa sama-sama berubah, dan engine-nya nggak punya pendapat soal mana yang menang.
v0.6.0 menjawab itu dengan resolveConflict. Default-nya tetap lastWriteWins, karena mayoritas aplikasi kecil memang cuma butuh aturan sesederhana itu. Tapi sekarang ada opsi custom yang menerima three-way merge dengan tiga masukan, yaitu base, local, dan remote. Base adalah kondisi terakhir yang disepakati, local adalah draft yang belum terkirim, dan remote adalah versi yang baru datang dari server.
const { data, update, applyRemoteState } = useSync<TodoState>("todos", {
conflictStrategy: "custom",
resolver: ({ base, local, remote }) => ({
...remote,
todos: mergeTodos(base.todos, local.todos, remote.todos),
}),
onConflictResolved: (info) => {
logger.info("conflict resolved", info);
},
});
Yang lebih penting dari resolver-nya adalah applyRemoteState(). Fungsi ini menerima state dari WebSocket, server-sent events, atau polling, lalu merekonsiliasi patch yang masih mengantre di outbox. Tanpa itu, update remote gampang menimpa perubahan lokal yang belum sempat dikirim, dan pengguna melihat pekerjaannya hilang begitu saja.
Saya sengaja nggak bikin ini otomatis. Konflik itu keputusan produk, bukan keputusan library. Ada aplikasi yang lebih baik memilih versi server, ada yang lebih baik mempertahankan draft lokal.
Menyerahkan pilihan itu ke pemakai terasa lebih jujur daripada menyembunyikannya di balik default yang terlihat pintar.
Storage penuh, dan state yang harus mundur
Masalah kedua muncul dari pertanyaan yang kedengeran sepele. Apa yang terjadi kalau IndexedDB penuh?
Jawaban jujurnya waktu itu: nggak tahu, dan itu memalukan. Optimistic update langsung tayang di UI, lalu penulisan ke storage gagal. Selama ini kegagalan itu cuma jadi error di console, sementara layar tetap menampilkan data yang sebenarnya nggak pernah tersimpan. Begitu tab ditutup, semuanya hilang.
v0.6.0 menambahkan isQuotaExceededError() yang mengenali varian error lintas browser, dari QuotaExceededError di WebKit sampai NS_ERROR_DOM_QUOTA_REACHED di Gecko. Di atasnya ada withQuotaGuard, interceptor yang membungkus operasi storage. Kalau kuota habis, draft in-memory di-rollback ke kondisi terakhir yang valid, error dinormalisasi jadi SyncraftError dengan source: "store" dan retryable: false, lalu callback onQuotaExceeded dipanggil.
const { data, update } = useSync<TodoState>("todos", {
onQuotaExceeded: (info) => {
toast.error("Penyimpanan penuh. Perubahan terakhir dibatalkan.");
reportToTelemetry(info);
},
});
Rollback itu terasa seperti langkah mundur, tapi justru itu yang bikin aplikasi bisa dipercaya. Pengguna lebih baik melihat pesan "perubahan dibatalkan" daripada melihat daftar tugas yang rapi di layar, lalu besok paginya mendapati daftar itu kosong.
Kenapa CRDT akhirnya ditunda
Selama ngerjain conflict resolution, pertanyaan yang paling sering muncul di kepala saya adalah: kenapa nggak pakai CRDT saja? Yjs dan Automerge udah menyelesaikan masalah ini bertahun-tahun lalu, dan hasilnya jauh lebih tahan banting untuk kolaborasi real-time.
Jadi saya tulis RFC-001. Isinya evaluasi jujur antara CRDT berbasis state dan operasi melawan pendekatan JSON Patch yang udah dipakai Syncraft. Kesimpulannya nggak seheboh yang mungkin diharapkan. JSON Patch tetap jadi default, karena ukurannya kecil, biasanya di bawah dua kilobyte, gampang di-debug, dan gampang dikirim ke backend biasa. CRDT ditunda jadi plugin opsional pasca-v1.0, dengan nama kerja @syncraft-labs/crdt-yjs. Dokumen lengkapnya ada di situs dokumentasi.
Ada satu keputusan kecil yang menyertai RFC itu, yaitu mencabut keyword "crdt" dari metadata package. Sebelumnya kata itu ada di sana, dan menurut saya sekarang itu menyesatkan.
Kalau engine-nya belum benar-benar CRDT, jangan pasang labelnya. Klaim yang berlebihan di npm itu murah, tapi kepercayaan yang hilang mahal.
Bagi saya ini juga pengingat soal versi 0.x. API masih bisa berubah, dan ada hal yang sengaja belum disentuh. Lebih baik mengakuinya di dokumen daripada membiarkan orang mengira ini udah selesai.
Lima hari yang memaksa saya berhenti
Rencana awal, v0.7.0 bakal rilis di minggu pertama Oktober. Semua kode utamanya udah selesai sejak akhir September, tinggal naikin versi dan nulis changelog. Tapi di awal bulan itu saya sakit, dan dokter bilang harus opname.
Lima hari di rumah sakit. Nggak ada laptop, nggak ada keyboard, nggak ada commit. Ponsel pun lebih sering saya tinggal di meja daripada saya pegang. Awalnya terasa mengganggu, karena kepala masih penuh dengan daftar hal yang mestinya dikerjakan. Tapi di hari kedua saya sadar nggak ada yang bisa saya lakukan selain diam dan tidur.
Yang menarik, justru di situ saya kepikiran soal observability. Saya bayangin orang yang pakai Syncraft di aplikasinya, lalu sync-nya berhenti diam-diam. Dia nggak bisa lihat apa yang salah, nggak tahu patch mana yang nyangkut, dan cuma bisa nebak dari gejala di layar. Rasanya mirip dengan jadi pasien yang cuma bisa bilang "badan saya nggak enak" sambil berharap dokternya tahu bagian mana yang bermasalah.
Pulang dari rumah sakit, saya masih harus libur kerja beberapa hari. Di situlah v0.7.0 akhirnya saya selesaikan pelan-pelan, dan baru rilis 12 Oktober. Nggak ada yang dramatis dari prosesnya. Cuma tidur lebih banyak, kerja lebih pendek, dan menerima bahwa versi 0.7.0 nggak akan rilis sesuai jadwal.
Kalau ada pelajaran yang nggak ada di changelog, ini dia. Rilis bisa ditunda, changelog bisa dirapikan besok, tapi badan nggak punya mekanisme rollback.
Sync yang gagal nggak boleh diam
Kalau v0.6.0 soal correctness, v0.7.0 soal kejelasan. Saya capek debugging sync loop yang cuma kelihatan dari perilaku aneh di UI. Kadang state berhenti terkirim, kadang retry jalan terus tanpa henti, dan satu-satunya cara tahu adalah membuka DevTools lalu menebak-nebak.
Rilis ini menambahkan lifecycle hooks, yaitu onSyncStart, onSyncSuccess, dan onSyncError di adapter React dan Vue. Ketiganya bisa dipakai tanpa ikut berlangganan ke mutasi state internal. Ada juga onRollback(error, previousState) yang dipanggil saat penulisan lokal gagal dan draft optimistis dikembalikan.
useSync<TodoState>("todos", {
onSyncStart: () => metrics.start("sync"),
onSyncSuccess: () => metrics.end("sync"),
onSyncError: (error) => logger.error("sync failed", { error }),
onRollback: (error, previousState) => {
logger.warn("state rolled back", { error, previousState });
},
});
Selain hooks, ada window.__SYNCRAFT_DEVTOOLS__ yang otomatis mendaftarkan store aktif di browser. Isinya Map berisi snapshot dan outbox yang masih mengantre, jadi saya bisa memeriksa kondisi engine langsung dari console tanpa menambah kode debug sementara. Store juga otomatis dilepas saat destroy().
Logging akhirnya bisa diarahkan. Sebelumnya semua peringatan ditulis langsung ke console, dan itu menyulitkan saat aplikasi udah punya logger sendiri. Sekarang ada interface logger dengan warn, error, dan opsional info serta debug, yang bisa disambungkan ke framework logging atau layanan monitoring.
Dua perbaikan lain di rilis yang sama kelihatan kecil tapi dampaknya besar. Pertama, penulisan state dan outbox sekarang berjalan dalam satu transaksi IndexedDB atomik, jadi nggak ada lagi kondisi di mana state tersimpan tapi antreannya hilang. Kedua, sync loop berhenti saat menerima error 4xx yang nggak bisa di-retry, bukan mengulang exponential backoff sampai menit ke-60 untuk kesalahan yang jelas permanen.
Terakhir, ada selector di useSync. Komponen yang cuma butuh satu potong state nggak perlu render ulang setiap kali cabang lain berubah. Di state tree besar, ini terasa langsung, dan di Vue listener sekarang dibersihkan lewat onScopeDispose supaya nggak bocor saat SSR atau Suspense boundary dibongkar.
OIDC, dan bayangan insiden dua bulan lalu
Ada satu perubahan di v0.7.0 yang bagi saya pribadi lebih berarti dari semua fitur di atas, yaitu migrasi publikasi npm ke Trusted Publishers dengan OpenID Connect.
Artinya sederhana. Nggak ada lagi token npm berumur panjang yang disimpan di secrets. Pipeline mengambil kredensial jangka pendek langsung dari identity provider saat proses publish, lalu kredensial itu kedaluwarsa.
Kalau ada yang berhasil membaca environment build, yang didapat cuma tiket yang udah hangus.
Buat yang belum baca ceritanya, insiden PolinRider terjadi karena PAT GitHub saya dicuri dari laptop yang terinfeksi, lalu dipakai menerbitkan versi 0.4.1 yang udah disuntik malware. Setelah itu saya wajibkan 2FA untuk publishing, rotasi semua token, dan menganggap urusannya selesai. Ternyata belum. Selama masih ada token statis yang bisa dicuri, pintu yang sama masih terbuka.
OIDC menutup pintu itu dengan cara yang lebih baik daripada menambah lapisan 2FA. Bukan karena saya nggak percaya pada 2FA, tapi karena kredensial yang nggak pernah ada di disk nggak bisa dicuri dari disk.
Ini pelajaran yang mahal, dan saya lebih suka menuliskannya di sini daripada membiarkannya jadi catatan pribadi.
Di rilis yang sama, CI juga memasang driftgraph sebagai penjaga arsitektur. Fungsinya mendeteksi dependency melingkar dan pelanggaran batas modul sebelum masuk ke main. Nggak ada hubungan langsung dengan insiden, tapi semangatnya sama, yaitu bikin kesalahan kelihatan lebih awal.
Prototype pollution dan rilis yang nggak glamor
v0.7.1 datang tiga hari setelah v0.7.0. Isinya bukan fitur baru, melainkan hal-hal yang mestinya udah beres dari dulu.
Yang paling penting adalah hardening terhadap prototype pollution. applyPatches dan handler proxy sekarang menolak key __proto__, constructor, dan prototype saat state dimutasi.
Sebelumnya, patch yang datang dari luar, misalnya dari outbox yang tersimpan lama atau dari payload server, secara teori bisa dipakai untuk menyentuh prototype objek. Saya nggak menemukan eksploit yang benar-benar terjadi, tapi celah yang bisa dibayangkan tetap celah yang harus ditutup.
Sisanya soal kerapian yang berpengaruh ke maintainability. use-sync.ts di adapter React dipecah dari 352 baris jadi controller.ts, use-network.ts, use-sync-suspense.ts, dan use-sync.ts yang tersisa 146 baris. Adapter Vue melakukan hal yang sama, dari 271 baris jadi tiga modul. Bukan target yang sakral, tapi cukup untuk memaksa satu file punya satu tanggung jawab.
Situs dokumentasi ikut naik ke Astro v7 dan Starlight v0.42, plus astro-mermaid untuk merender diagram langsung di browser. Playground juga dipecah jadi komponen kecil dengan CSS module masing-masing. Nggak ada yang bisa saya jual sebagai fitur baru dari perubahan ini, tapi dokumentasi yang enak dibaca adalah bagian dari produk, bukan pelengkap.
Yang belum selesai
Setelah v0.7.1, posisinya begini. Syncraft udah punya conflict resolution, quota recovery, observability, dan dasar keamanan yang lebih rapi. Tapi masih banyak yang belum.
Roadmap berikutnya berisi enkripsi at rest dan audit trail outbox di v0.8.0, dogfooding dan audit ergonomics API di v0.9.0, lalu coverage publik, benchmark, dan soft API freeze di v0.10.0. Baru setelah itu v1.0.0 dengan komitmen semver dan proses RFC formal.
Urutannya sengaja begitu. Saya nggak mau menulis komitmen stabilitas sebelum API-nya benar-benar diuji di aplikasi nyata. Dan saya nggak mau mengklaim siap produksi sebelum ada benchmark yang bisa diperiksa orang lain.
Kalau ditarik mundur, sebulan ini meninggalkan beberapa hal yang saya bawa ke pekerjaan berikutnya. Sinkronisasi satu arah selalu terlihat cukup sampai ada perangkat kedua, dan begitu arah balik dibutuhkan, keputusan soal konflik harus diambil sadar, bukan diwariskan dari default. Rollback juga bukan kegagalan, tapi kontrak. Aplikasi yang berani membatalkan perubahan dan mengatakannya lebih jujur daripada aplikasi yang pura-pura semuanya tersimpan.
Menolak teknologi yang sedang populer butuh alasan tertulis. Observability juga ternyata bukan pelengkap, melainkan fitur. Dan soal keamanan, yang benar biasanya mengurangi permukaan, bukan menambah lapisan. OIDC lebih baik bukan karena lebih rumit, tapi karena tidak ada rahasia yang perlu dijaga. Sisanya soal rilis yang nggak glamor. Hardening dan pemecahan file nggak menghasilkan demo, tapi menentukan apakah project ini masih enak dirawat tahun depan.
Yang terakhir, dan ini di luar urusan kode. Lima hari di rumah sakit ngajarin saya bahwa jadwal rilis itu nggak suci. Versi bisa telat seminggu dan nggak ada yang mati. Yang nggak bisa ditunda cuma satu, yaitu berhenti ketika badan minta berhenti.
Dari tool internal kecil yang cuma dipakai sendiri, melewati insiden keamanan yang bikin saya belajar soal supply chain, sampai tiga rilis dalam sebulan yang membawa conflict resolution, observability, dan publikasi tanpa token. Perjalanan Syncraft Labs masih jauh dari selesai, dan itu justru bagian yang bikin saya betah.
Kalau kamu sedang membangun aplikasi local-first dan butuh tempat bertanya soal resolusi konflik, quota storage, atau cara publish package tanpa token panjang, kabari saya. Kotak masuk email saya selalu terbuka.