Aplikasi Command Line Interface (CLI) modern sering membutuhkan persistensi data yang andal tanpa ketergantungan pada koneksi jaringan atau daemon eksternal seperti PostgreSQL atau Redis. Menggunakan SQLite embedded memungkinkan binary CLI beroperasi langsung pada filesystem lokal dengan jaminan transaksi ACID penuh.

Panduan ini membahas arsitektur offline CLI task tracker—terinspirasi sistem quest tracking dalam game RPG dunia nyata (Earth Game)—di mana setiap aktivitas harian dicatat sebagai quest terstruktur dengan reward dan log progres terverifikasi.

Arsitektur User Space dan Lokasi Database

Aplikasi CLI tidak boleh menulis file database sembarangan di direktori kerja saat ini (working directory). Lokasi file harus mengikuti standar XDG Base Directory di Linux/macOS atau AppData di Windows.

Direktori standar yang digunakan adalah $XDG_DATA_HOME/tasktracker (biasanya ~/.local/share/tasktracker) atau $HOME/.config/tasktracker. Menyiapkan path yang benar mencegah file tertimpa atau terhapus saat berpindah folder kerja.

import os
import sys
from pathlib import Path

def get_db_path() -> Path:
    # Mengikuti standar XDG data directory
    xdg_data = os.environ.get("XDG_DATA_HOME")
    if xdg_data:
        base_dir = Path(xdg_data)
    else:
        base_dir = Path.home() / ".local" / "share"
    
    app_dir = base_dir / "tasktracker"
    try:
        app_dir.mkdir(parents=True, exist_ok=True)
    except OSError as err:
        sys.stderr.write(f"Error alokasi direktori user space: {err}\n")
        sys.exit(1)
        
    return app_dir / "tracker.db"

Skema Relasional Quest dan Pola Migrasi Otomatis

Struktur data memisahkan metadata tugas (quests) dari catatan pengerjaan (progress logs). SQLite embedded mendukung schema migration otomatis berbasis PRAGMA user_version tanpa memerlukan perkakas migrasi eksternal.

import sqlite3

MIGRATIONS = [
    # Versi 1: Inisialisasi skema quest dan logs
    """
    CREATE TABLE quests (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        slug TEXT UNIQUE NOT NULL,
        title TEXT NOT NULL,
        exp_reward INTEGER NOT NULL DEFAULT 10,
        status TEXT CHECK(status IN ('OPEN', 'COMPLETED', 'FAILED')) NOT NULL DEFAULT 'OPEN',
        created_at TEXT DEFAULT (datetime('now'))
    );

    CREATE TABLE quest_logs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        quest_id INTEGER NOT NULL,
        notes TEXT NOT NULL,
        recorded_at TEXT DEFAULT (datetime('now')),
        FOREIGN KEY (quest_id) REFERENCES quests(id) ON DELETE CASCADE
    );

    CREATE INDEX idx_quests_status ON quests(status);
    CREATE INDEX idx_quest_logs_quest_id ON quest_logs(quest_id);
    """
]

def migrate_database(conn: sqlite3.Connection) -> None:
    current_version = conn.execute("PRAGMA user_version;").fetchone()[0]
    total_versions = len(MIGRATIONS)

    if current_version < total_versions:
        with conn:
            for v in range(current_version, total_versions):
                conn.executescript(MIGRATIONS[v])
                conn.execute(f"PRAGMA user_version = {v + 1};")

Transaksi Atomik ACID untuk Mutasi Status

Saat menandai quest sebagai selesai, dua hal harus terjadi secara bersamaan: memperbarui status tabel quests dan menyisipkan entri ke quest_logs. Jika salah satu gagal, seluruh operasi harus dibatalkan (rollback).

Gunakan mode BEGIN IMMEDIATE untuk mencegah database lock starvation jika ada proses CLI paralel yang mengakses file yang sama.

def complete_quest(conn: sqlite3.Connection, slug: str, log_notes: str) -> bool:
    try:
        with conn:
            # Mengaktifkan foreign keys untuk koneksi ini
            conn.execute("PRAGMA foreign_keys = ON;")
            
            # Verifikasi keberadaan quest
            cursor = conn.execute(
                "SELECT id, status FROM quests WHERE slug = ?;", (slug,)
            )
            row = cursor.fetchone()
            
            if not row:
                raise ValueError(f"Quest '{slug}' tidak ditemukan.")
            
            quest_id, status = row
            if status == "COMPLETED":
                raise ValueError(f"Quest '{slug}' sudah berstatus COMPLETED.")

            # Mutasi status
            conn.execute(
                "UPDATE quests SET status = 'COMPLETED' WHERE id = ?;",
                (quest_id,)
            )

            # Insert catatan progres
            conn.execute(
                "INSERT INTO quest_logs (quest_id, notes) VALUES (?, ?);",
                (quest_id, log_notes)
            )
            
        return True
    except sqlite3.OperationalError as err:
        # Error handling disk penuh, permission denied, atau SQLite file lock
        sys.stderr.write(f"I/O Database Error: {err}\n")
        return False
    except ValueError as err:
        sys.stderr.write(f"Business Logic Error: {err}\n")
        return False

Optimasi Konfigurasi SQLite Embedded

Secara default, SQLite beroperasi dalam mode rollback journal yang memicu lock penuh pada file saat proses penulisan. Untuk CLI yang cepat dan andal, aktifkan mode WAL (Write-Ahead Logging) dan busy_timeout:

def get_connection(db_path: Path) -> sqlite3.Connection:
    conn = sqlite3.connect(
        db_path,
        timeout=5.0, # Menunggu hingga 5 detik jika file di-lock proses lain
        isolation_level=None # Mengelola transaksi manual untuk kontrol ACID ketat
    )
    # Optimasi performa I/O
    conn.execute("PRAGMA journal_mode = WAL;")
    conn.execute("PRAGMA synchronous = NORMAL;")
    return conn

Error Handling dan Pemulihan I/O Lokal

Kendala umum database berbasis file pada level OS mencakup:

  • SQLite Busy / Locked: Terjadi jika terdapat dua terminal yang mengeksekusi operasi penulisan serentak. Parameter timeout pada koneksi menangani antrean ini secara transparan.
  • Disk Penuh (ENOSPC): SQLite menghasilkan sqlite3.OperationalError: database or disk is full. Pastikan transaksi dibungkus blok try/except agar file .db-wal tidak tertinggal dalam status korup.
  • Permission Denied (EACCES): Terjadi jika permission folder user space berubah. Tangani langsung sebelum koneksi SQLite dibuka melalui verifikasi os.access().

Arsitektur SQLite embedded ini menghasilkan perkakas CLI mandiri dengan performa eksekusi sub-milidetik, tanpa dependensi server eksternal, dan aman dari inkonsistensi data lokal.