Membangun fondasi data offline-first CatatanKu: SQLite sebagai database lokal, penyimpanan konfigurasi, pola migrasi skema, dan strategi cache agar aplikasi cepat dan tetap berfungsi tanpa koneksi internet

Di episode 4-6 kita menyimpan catatan di state memori — hilang saat aplikasi ditutup. Episode ini memperbaikinya. Data aplikasi desktop yang serius disimpan lokal dan persisten, dan ini salah satu keunggulan desktop atas web: pengguna memegang datanya, aplikasi tetap berfungsi tanpa internet.
Pola yang kita bangun disebut offline-first: data lokal adalah sumber utama, jaringan hanya pelengkap untuk sinkronisasi (episode 22). Fondasinya: SQLite untuk data terstruktur, file konfigurasi untuk pengaturan, dan strategi cache agar pencarian serta akses tetap cepat. Episode ini membuat lapisan storage CatatanKu yang akan dipakai sampai akhir series.
SQLite adalah database relasional yang tertanam di dalam aplikasi — satu file .sqlite, tanpa server, tanpa port, tanpa konfigurasi. Ini database paling banyak terpasang di dunia (ada di browser, smartphone, dan sistem operasi) dan pilihan tepat untuk data lokal aplikasi desktop.
| Kelebihan | Catatan |
|---|---|
| Zero-config | Satu file, dibuka langsung |
| Transaksi & ACID | Integritas data terjamin |
| SQL penuh | Query kompleks mudah ditulis |
| Ringan | Cocok untuk data jutaan baris |
Bandingkan dengan menyimpan JSON manual: SQLite memberi query, indeks, dan transaksi — sedangkan JSON harus diurai, diindeks, dan disinkronkan secara manual. Untuk data relasional seperti catatan + tag + metadata, SQLite menang jauh.
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (note_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at DESC);Catatan desain penting:
id TEXT PRIMARY KEY — menggunakan UUID, bukan auto-increment. Alasan: id harus tetap unik saat sinkronisasi antar perangkat (episode 22).deleted_at — soft delete agar penghapusan bisa disinkronkan dan dipulihkan.journal_mode = WAL — Write-Ahead Logging untuk performa baca-tulis yang lebih baik.updated_at terindeks — dasar pencarian "catatan terbaru" dan sinkronisasi delta.use tauri_plugin_sql::{Builder as SqlBuilder, Migration, MigrationKind};
let migrations = vec![
Migration {
version: 1,
description: "create_notes_table",
sql: include_str!("../migrations/001_notes.sql"),
kind: MigrationKind::Up,
},
];
Builder::default()
.add_migrations("sqlite:catatanku.db", migrations)
.build()Frontend memanggil SQL lewat command plugin:
import Database from "@tauri-apps/plugin-sql";
const db = await Database.load("sqlite:catatanku.db");
const rows = await db.select(
"SELECT id, title, updated_at FROM notes WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT 50"
);const Database = require("better-sqlite3");
const { app } = require("electron");
const path = require("node:path");
const dbPath = path.join(app.getPath("userData"), "catatanku.sqlite");
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
function listNotes() {
return db.prepare(`
SELECT id, title, updated_at FROM notes
WHERE deleted_at IS NULL
ORDER BY updated_at DESC LIMIT 50
`).all();
}
module.exports = { listNotes };Kunci di Electron: database hanya diakses di main process, bukan renderer — konsisten dengan aturan minimal surface di episode 5. Renderer memanggil lewat IPC (notes:list).
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class NoteRepository {
static const _dbName = 'catatanku.db';
Future<Database> open() async {
final path = join(await getDatabasesPath(), _dbName);
return openDatabase(path, version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''');
});
}
}Ketiga framework memakai SQLite dengan API yang hampir sama — SQL itu sendiri adalah bahasa bersama lintas framework.
Pengaturan aplikasi (tema, ukuran font, folder sinkronisasi) tidak perlu database — cukup key-value store:
SQLite → data relasional: notes, tags, note_tags
Config store → settings: tema, ukuran font, akun
Cache → hasil query yang dihitung ulang mahalTauri punya tauri-plugin-store untuk konfigurasi, Electron memakai electron-store, dan Flutter memakai shared_preferences. Polanya sama: JSON di file, di-load saat startup, disimpan saat berubah.
Offline-first juga berarti semua query utama harus cepat secara lokal. Dua strategi yang kita pakai di CatatanKu:
FTS5 untuk pencarian isi catatan yang tidak bisa diandalkan dengan LIKE %...% pada data besar.CREATE VIRTUAL TABLE notes_fts USING fts5(
title, content,
content='notes', content_rowid='rowid'
);
INSERT INTO notes_fts(notes_fts) VALUES ('rebuild');
SELECT title FROM notes_fts
WHERE notes_fts MATCH 'rust' AND title LIKE '%rust%'
ORDER BY rank LIMIT 20;? placeholder).Warning
Aturan keamanan data: jangan pernah membangun SQL dengan interpolasi string dari input pengguna. Selalu gunakan parameterized query. Input dari UI melewati IPC lalu masuk ke SQLite — tanpa parameter binding, celah SQL injection terbuka lebar.
Inti yang harus dibawa pulang:
Di episode 9 selanjutnya kita akan membahas background & long-running tasks — async, workers, dan manajemen task — agar indexing, sinkronisasi, dan pekerjaan berat tidak pernah membekukan UI CatatanKu. Sampai jumpa di episode 9!