Lulucat

Mengapa Kami Belum Menggunakan Database

Gaoge ZhangGaoge Zhang

Kami mengevaluasi Turso/libSQL untuk aplikasi tulisan tangan iPad kami, mengukur semuanya, lalu memilih file snapshot datar. Beban kerja ini tidak membutuhkan database — dan setiap langkah seharusnya hanya membayar masalah yang memang sudah ada.

Lulucat Notes adalah aplikasi tulisan tangan untuk iPad. Sampai minggu lalu, aplikasi ini hanya memiliki satu kanvas dan belum mengenal konsep catatan kedua. Kami akan menambahkan pustaka catatan — banyak dokumen, masing-masing dengan banyak halaman — dan pertanyaan arsitektur pertama adalah penyimpanan.

Database terasa seperti jawaban yang jelas. Aplikasi catatan menyimpan data terstruktur. Data terstruktur masuk ke database. Kami mengevaluasi Turso dan Swift SDK-nya, berhasil menjalankannya di simulator iOS, mengukur data stroke nyata, lalu memilih untuk tidak menggunakannya.

Sebagai gantinya, kami memilih file datar. Inilah yang kami temukan dan alasan kami mengambil keputusan itu.

Buku catatan terbuka dengan catatan tulisan tangan dan pena di atas meja kayu.

Foto oleh Gabriel Cox di Unsplash. Lisensi Unsplash.

Apa yang sebenarnya dilakukan aplikasi terhadap data

Aplikasi tulisan tangan memiliki pola akses data yang sempit dan mudah diprediksi. Membaca berarti membuka sebuah halaman dan memuat setiap elemennya — semua stroke, semua gambar — ke memori sekaligus. Kanvas menampung semuanya; kanvas tidak pernah menjalankan query parsial. Menulis berarti menyelesaikan satu stroke pena lalu menambahkan satu elemen ke halaman. Dalam kasus yang jarang terjadi, pengguna menghapus sebagian stroke, memindahkan seleksi, atau menghapus sesuatu, tetapi semuanya tetap merupakan operasi satu halaman dan satu elemen.

Tidak ada akses konkuren. Satu orang menulis di satu halaman dari satu dokumen pada satu waktu. Tidak ada pencarian lintas dokumen — pustaka catatan hanya membutuhkan judul, timestamp, jumlah halaman, dan thumbnail sampul untuk setiap dokumen; tidak satu pun memerlukan pembacaan isi halaman.

Query, indeks, dan koordinasi konkurensi adalah hal yang menjadi tujuan pembuatan database. Aplikasi kami tidak menggunakan satu pun dari ketiganya.

Evaluasi Turso

Kami mengevaluasi libsql-swift, Swift SDK resmi untuk mesin libSQL milik Turso.

SDK-nya berfungsi. Semua 9 kasus pengujiannya berhasil. Kami mengintegrasikannya ke salinan aplikasi, membangunnya untuk simulator iOS, menjalankannya, lalu membuat database lokal di sandbox aplikasi. Kami menulis 100 stroke yang masing-masing memiliki 3,400 titik sampling — 4,080,000 byte data BLOB — dalam satu transaksi. Prosesnya memerlukan sekitar 0.019 detik di Mac pengembangan kami.

Setelah menjalankan PRAGMA wal_checkpoint(TRUNCATE), file WAL menyusut menjadi nol dan kami dapat menyalin hanya file .db utama ke lokasi lain, membukanya, lalu membaca kembali semua datanya. Mesinnya sendiri kokoh.

SDK-nya memiliki biaya. CLibsql.xcframework berukuran 161 MB. Setelah ditautkan, build simulator Debug kami berubah dari sekitar 1.9 MB menjadi sekitar 8.2 MB. API-nya sinkron dan memblokir, tanpa wrapper Swift Concurrency. Tidak ada metode close() eksplisit. Transaction.commit() tidak melempar error — API C yang mendasarinya mengembalikan void. README repositori memberi label SDK ini sebagai “technical preview”, dan commit terbarunya dibuat sekitar setahun sebelum evaluasi kami, pada Juli 2025.

Ekosistem Turso memiliki celah. Kini Turso merekomendasikan mesin baru “Turso Database” dan protokol “Turso Sync” untuk proyek baru. Turso Sync memiliki SDK klien untuk TypeScript, Python, Go, dan Rust. SDK untuk Swift belum ada. Mode Embedded Replica yang lebih lama tersedia di libsql-swift, tetapi initializer Swift-nya tidak mengekspos parameter offline yang dibutuhkan aplikasi seluler local-first sepenuhnya. Mengadopsi libsql-swift hari ini memberi kami fork SQLite lokal, tetapi bukan kemampuan sinkronisasi yang membuat Turso berbeda.

Biaya database bagi kami saat ini

Bahkan jika SDK-nya sudah matang, kami tetap akan membayar biaya yang tidak memberikan manfaat untuk beban kerja kami:

Pengelolaan file pendamping WAL. Database yang berjalan membuat file pendamping -wal dan -shm. Menyalin sebuah dokumen berarti melakukan checkpoint terlebih dahulu atau menyalin ketiga file secara atomik. Mengekspor paket .lnote ke Files atau AirDrop kini memerlukan langkah pra-ekspor yang tidak dapat dilihat pengguna dan tidak boleh dilupakan oleh pengembang.

Lapisan adapter. Stroke harus diserialisasi menjadi BLOB lalu dideserialisasi kembali. Elemen halaman memiliki urutan array alami yang langsung dirender oleh kanvas; database akan memperkenalkan urutan baris dan kolom z-index. Kami akan menulis lapisan penerjemah antara dua representasi data yang sama, lalu memeliharanya setiap kali skema berubah.

Dependensi 161 MB. Untuk aplikasi yang build Debug-nya di bawah 2 MB, dependensi yang berukuran lebih dari 80× aplikasi itu sendiri adalah biaya yang patut diperhatikan — apalagi dependensi yang diberi label “technical preview” dan tidak aktif selama setahun.

Biaya-biaya ini bukan hipotetis. Biaya tersebut dimulai sejak dependensi ditautkan. Dan yang dibeli adalah kemampuan — query, pengindeksan, penulisan konkuren — yang tidak digunakan aplikasi kami.

Solusi yang kami kirim: paket file snapshot

Dokumen .lnote adalah paket direktori:

Documents/Notes/<UUID>.lnote/
  manifest.json              # library cache: title, time, page count, cover
  document.json              # source of truth: document metadata + page order
  pages/
    <page-uuid>.content      # one snapshot per page
  assets/                    # document-level shared resources
    <asset-uuid>.jpg
  thumbnails/
    <page-uuid>.jpg          # per-page thumbnail; first page doubles as cover

document.json adalah sumber kebenaran untuk struktur dokumen: ID, judul, timestamp, dan daftar halaman berurutan yang memuat ukuran kanvas, timestamp, serta jumlah elemen setiap halaman. File isi halaman menyimpan array elemen dalam encoding bilangan bulat terkuantisasi yang sudah digunakan aplikasi — koordinat dan radius dengan presisi 0.1 poin, tekanan dalam perseribu, timestamp dalam milidetik relatif.

Pustaka catatan hanya membaca manifest.json dan thumbnail sampul. Pustaka itu tidak pernah mem-parsing document.json atau isi halaman mana pun. Membuka halaman memuat satu file .content. Itulah satu-satunya pembacaan file yang menyentuh data stroke.

Halaman mengatasi write amplification

Aplikasi tulisan tangan sudah memiliki konsep halaman — itulah unit yang dipikirkan pengguna, sesuatu yang mereka geser untuk berpindah. Menjadikan halaman sebagai unit persistensi berarti auto-save hanya menulis ulang halaman yang berubah.

Satu halaman tulisan tangan — katakanlah 1,000 hingga 2,000 stroke — menempati sekitar 3 hingga 5 MB dalam format terkuantisasi kami. Satu rekaman 21 stroke dengan 3,400 titik sampling terkuantisasi menjadi sekitar 55 KB. Menulis satu snapshot halaman ke penyimpanan flash memerlukan 10 hingga 20 milidetik pada perangkat modern. Dengan debounce 0.5 detik, penyimpanan tidak terlihat oleh pengguna.

Biaya penyimpanan bertambah sesuai jumlah tulisan di halaman yang sedang dibuka, bukan jumlah seluruh halaman dalam dokumen. Buku catatan 200 halaman menyimpan secepat buku catatan 2 halaman, karena hanya halaman yang kotor yang ditulis ulang.

Setiap penulisan menggunakan operasi file atomik — menulis ke file sementara, lalu mengganti nama — sehingga crash di tengah penyimpanan tidak dapat menghasilkan halaman yang terpotong. Saat aplikasi masuk ke latar belakang, semua halaman yang kotor langsung di-flush, sama seperti perilaku yang sudah dimiliki aplikasi ketika hanya ada satu kanvas.

Konsistensi tanpa transaksi

Paket file tidak memiliki transaksi, tetapi memiliki aturan kepemilikan yang jelas dan menjalankan tujuan yang sama:

Resource sebelum referensi. Ketika pengguna menyisipkan gambar, file aset langsung ditulis ke assets/. Snapshot halaman yang merujuk aset berdasarkan ID ditulis kemudian oleh auto-save yang telah di-debounce. Tidak ada waktu ketika halaman merujuk aset yang belum ada di disk.

Sumber kebenaran menang. document.json dan direktori pages/ adalah sumber kebenaran. manifest.json adalah cache. Jika keduanya tidak cocok, penyimpanan berikutnya menyelaraskan cache agar sesuai dengan sumbernya. Thumbnail bersifat turunan dan dapat dibuat ulang kapan saja.

Orphan lebih baik daripada referensi yang menggantung. Hasil terburuk dari crash adalah aset orphan — file di assets/ yang tidak dirujuk halaman mana pun. Orphan dibersihkan saat dokumen ditutup. Kebalikannya — halaman merujuk file yang hilang — tidak dapat terjadi, karena aset ditulis sebelum snapshot halaman yang merujuknya.

Aturan-aturan ini lebih mudah dipahami daripada checkpointing WAL dan isolasi transaksi, dan sangat cocok dengan pola akses satu proses, satu halaman aplikasi.

Jalur peningkatan sudah dituliskan

Memilih file datar sekarang tidak berarti memilih file datar selamanya. Struktur paket dirancang agar peningkatan mesin penyimpanan mengubah isi paket tanpa mengubah paket itu sendiri.

Level 1: snapshot + jurnal append. Jika write amplification suatu hari terasa — misalnya penulisan terus-menerus pada halaman dengan ribuan stroke menyebabkan jeda penyimpanan yang terlihat — setiap file halaman dipecah menjadi snapshot dan jurnal append-only. Elemen baru ditambahkan sebagai frame [length][CRC][type][payload]. Saat jurnal diputar ulang, frame yang CRC-nya tidak cocok dibuang, sehingga tersedia keamanan terhadap crash. Ketika jurnal melewati ambang batas atau halaman ditutup, jurnal digabungkan kembali ke snapshot. Ini kira-kira 200 baris kode dengan nol dependensi eksternal.

Karena snapshot tingkat halaman sudah menghilangkan write amplification lintas halaman, level ini mungkin belum diperlukan untuk waktu yang lama. Penulisan ulang halaman 5 MB setiap 0.5 detik masih jauh di dalam anggaran penulisan flash.

Level 2: database SQLite. Jika aplikasi suatu hari memerlukan pencarian full-text di seluruh catatan, sinkronisasi per elemen, atau pengindeksan lintas dokumen, SQLite menjadi alat yang tepat. Mesin yang kemungkinan dipakai saat itu adalah GRDB, wrapper Swift yang matang dan dikompilasi dari source, dengan overhead ukuran biner yang nyaris nol. libsql-swift hanya akan dipertimbangkan kembali jika ekosistem Turso — khususnya Turso Sync untuk Swift — menjadi kebutuhan produk yang nyata.

Jalur migrasinya bersifat mekanis: array elemen setiap halaman dipetakan ke tabel strokes / images, dengan satu BLOB immutable per stroke (16 byte per titik sampling dalam biner little-endian). Benchmark 0.019 detik untuk 100 stroke mengonfirmasi bahwa pendekatan ini layak. Sebelum dirilis, migrasi akan memverifikasi bahwa jumlah elemen dan jumlah aset cocok antara paket lama dan database baru, serta bahwa pemulihan dari crash, batas WAL, dan flush di latar belakang semuanya berhasil.

Kapan harus membayar

Keputusan ini bukan penilaian terhadap database. SQLite menangani penulis konkuren, query kompleks, dan pemulihan dari crash di seluruh state bersama — tidak satu pun dibutuhkan aplikasi kami saat ini. Membayar kemampuan tersebut sebelum aplikasi memiliki masalah yang diselesaikannya adalah kerugian bersih.

Biaya database — dependensi, pengelolaan WAL, lapisan adapter, ukuran biner — dimulai sejak library ditautkan. Manfaatnya dimulai ketika aplikasi memiliki query untuk dijalankan, indeks untuk dipelihara, atau penulis konkuren untuk dikoordinasikan. Pada tahap ini aplikasi kami tidak memiliki satu pun dari ketiganya.

Setiap langkah dalam evolusi penyimpanan kami hanya akan membayar masalah yang sudah muncul. Snapshot tingkat halaman membayar masalah yang kami miliki hari ini: menyimpan dokumen multi-halaman tanpa menulis ulang seluruh file. Jika write amplification menjadi terukur, jurnal append akan membayar masalah itu. Jika pencarian atau sinkronisasi menjadi kebutuhan produk, database akan membayar masalah tersebut.

Struktur paket, manifest, dan skema dokumen tidak terikat pada mesin penyimpanan mana pun. Biaya perpindahan rendah karena batas-batasnya berada di tempat yang tepat. Ketika tiba hari saat kami memang membutuhkan database, kami akan mengadopsinya untuk masalah spesifik yang sudah diukur — bukan untuk masalah hipotetis.