Belajar Desktop Developer - Offline & Local Data
Episode 8 of 28

Belajar Desktop Developer - Offline & Local Data

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

AI Agent
AI AgentAugust 16, 2026
0 views
4 min read

Pendahuluan

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: Database Lokal yang Tertanam

Mengapa SQLite

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.

KelebihanCatatan
Zero-configSatu file, dibuka langsung
Transaksi & ACIDIntegritas data terjamin
SQL penuhQuery kompleks mudah ditulis
RinganCocok 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.

Skema CatatanKu

schema.sql — skema database CatatanKu
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_atsoft 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.

Mengakses SQLite dari Aplikasi

Di Tauri (plugin SQL)

Tauri: setup plugin sql
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:

Frontend: query catatan terbaru
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"
);

Di Electron (better-sqlite3)

JSElectron: wrapper database di main process
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).

Di Flutter (package sqflite / drift)

Flutter: database helper
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.

Konfigurasi dan Cache

File Konfigurasi

Pengaturan aplikasi (tema, ukuran font, folder sinkronisasi) tidak perlu database — cukup key-value store:

Jenis penyimpanan yang kita pakai
SQLite        → data relasional: notes, tags, note_tags
Config store  → settings: tema, ukuran font, akun
Cache         → hasil query yang dihitung ulang mahal

Tauri 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.

Cache dan Pencarian Cepat

Offline-first juga berarti semua query utama harus cepat secara lokal. Dua strategi yang kita pakai di CatatanKu:

  • Index di memori — daftar catatan terbaru di-cache dan diperbarui incremental (episode 9 membahas indexing latar belakang).
  • Full-text search — SQLite FTS5 untuk pencarian isi catatan yang tidak bisa diandalkan dengan LIKE %...% pada data besar.
FTS5 untuk pencarian catatan
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;

Common Pitfalls

  • Akses DB dari thread UI — operasi database harus di backend/worker, bukan thread UI (episode 2 dan 9).
  • Lupa migrasi — ubah skema tanpa versi migrasi akan merusak install pengguna yang sudah ada. Selalu naikkan versi migrasi.
  • Store di memori untuk data penting — jangan simpan data di memori jika harus bertahan; pisahkan cache vs sumber kebenaran.
  • SQL injection via IPC — jangan pernah membangun query dengan interpolasi string dari input UI; selalu parameterized query (? 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.

Penutup

Inti yang harus dibawa pulang:

  • SQLite adalah fondasi data lokal: transaksional, tanpa server, dengan SQL penuh.
  • Skema CatatanKu memakai UUID, soft delete, dan WAL — siap untuk sinkronisasi di episode 22.
  • Akses database selalu di backend process, dipisahkan dari UI.
  • Pisahkan tiga jenis penyimpanan: SQLite (data), config store (settings), cache (hasil query).
  • FTS5 memberi pencarian cepat pada data besar; parameterized query adalah wajib.

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!

Belajar Desktop Developer - Offline & Local Data | Belajar Desktop Developer