ES | EN

SQLITE EN APPS DE ESCRITORIO: LA BASE DE DATOS QUE NO NECESITA SERVIDOR

TAGS: BASES DE DATOS / PYTHON / DESKTOP APPS READ_TIME: 12 MIN
SQLite en apps de escritorio: la base de datos que no necesita servidor

Toda app de escritorio necesita que sus datos datos sean persistentes. El instinto del desarrollador web es levantar PostgreSQL o MySQL. Para una app de escritorio personal eso es matar una mosca con un cañón: requiere un servidor corriendo, configuración de usuarios, manejo de conexiones, y complejidad que no aporta nada en un contexto de un solo usuario. SQLite es la alternativa correcta — un solo archivo en disco, cero configuración, cero servidor, cero mantenimiento.

PROJECT_STATUS: STABLE

Stack: Python · SQLite · JSON
Proyecto de referencia: FocOs — persistencia de datos
Objetivo: Persistencia robusta sin servidor — un archivo en disco, años de desarrollo sin tocar el schema

01. EL PROBLEMA QUE RESUELVE

PostgreSQL y MySQL están diseñados para múltiples usuarios concurrentes, alta disponibilidad y cargas de trabajo distribuidas. En una app de escritorio personal, ninguna de esas características aplica — y sin embargo arrastras toda la infraestructura. SQLite resuelve exactamente el problema que tienes: persistencia confiable para un solo usuario, sin proceso externo, sin configuración, sin que el usuario final sepa que existe una base de datos.

// Explicación no técnica

Imagina que necesitas guardar una lista de compras. Tienes dos opciones: contratar a un chef profesional con cocina industrial para que la guarde, o simplemente escribirla en un cuaderno. PostgreSQL es el chef. SQLite es el cuaderno. Para guardar tu lista de compras, el cuaderno es la respuesta correcta — y lo puedes llevar en el bolsillo.

02. LA ESTRUCTURA DE FOCOS EN SQLITE

FocOs usa cuatro tablas principales. Los dos PRAGMAs del inicio son obligatorios: WAL mejora el rendimiento de escritura significativamente, y foreign_keys=ON activa la integridad referencial que SQLite tiene desactivada por defecto.

# main.py — inicialización de la DB

import sqlite3
from pathlib import Path

DB_PATH = Path('data/focos.db')

def init_db():
    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(DB_PATH)
    conn.execute("PRAGMA journal_mode=WAL") # Escrituras más rápidas
    conn.execute("PRAGMA foreign_keys=ON") # Integridad referencial

    conn.executescript("""
        CREATE TABLE IF NOT EXISTS projects (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL UNIQUE,
            status TEXT DEFAULT 'active',
            meta TEXT DEFAULT '{}'
        );

        CREATE TABLE IF NOT EXISTS tasks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            project_id INTEGER REFERENCES projects(id),
            title TEXT NOT NULL,
            status TEXT DEFAULT 'pending',
            priority INTEGER DEFAULT 1,
            created_at TEXT DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS workspaces (
            ws_id INTEGER PRIMARY KEY,
            project_id INTEGER REFERENCES projects(id),
            layout TEXT DEFAULT 'default',
            state TEXT DEFAULT '{}'
        );

        CREATE TABLE IF NOT EXISTS sessions (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            project_id INTEGER REFERENCES projects(id),
            date TEXT DEFAULT (date('now')),
            duration INTEGER DEFAULT 0,
            notes TEXT DEFAULT ''
        );

        INSERT OR IGNORE INTO workspaces(ws_id) VALUES (1),(2),(3);
    """)
    conn.commit()
    conn.close()

03. CRUD COMPLETO — SIN ORM, SIN MAGIA

El patrón es consistente en todas las operaciones: abrir conexión, ejecutar, commit si escribe, cerrar en el finally. Sin ORM, sin abstracción innecesaria — el SQL es visible, debuggeable y predecible. El soft delete en lugar de DELETE real preserva el historial y evita errores irreversibles.

# CREATE
def create_project(self, name: str, status: str = 'active') -> dict:
    conn = sqlite3.connect(DB_PATH)
    try:
        cur = conn.execute(
            "INSERT INTO projects(name, status) VALUES(?,?)",
            (name, status)
        )
        conn.commit()
        return { 'ok': True, 'id': cur.lastrowid }
    except sqlite3.IntegrityError:
        return { 'ok': False, 'error': f"Proyecto '{name}' ya existe" }
    finally:
        conn.close()

# READ
def get_projects(self) -> list:
    conn = sqlite3.connect(DB_PATH)
    rows = conn.execute(
        "SELECT id, name, status, meta FROM projects ORDER BY id DESC"
    ).fetchall()
    conn.close()
    return [
        { 'id': r[0], 'name': r[1], 'status': r[2],
          'meta': json.loads(r[3] or '{}') }
        for r in rows
    ]

# UPDATE
def update_project_status(self, project_id: int, status: str) -> dict:
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        "UPDATE projects SET status=? WHERE id=?",
        (status, project_id)
    )
    conn.commit()
    conn.close()
    return { 'ok': True }

# DELETE (soft delete — cambiar status, no borrar)
def archive_project(self, project_id: int) -> dict:
    return self.update_project_status(project_id, 'archived')

04. JSON DENTRO DE SQLITE — EL CAMPO META

SQLite no tiene tipo JSON nativo — se almacena como TEXT y se serializa/deserializa manualmente. La ventaja es que el campo meta puede crecer indefinidamente sin alterar el schema. La regla es clara: datos que necesitan búsqueda van en columnas dedicadas, datos flexibles van en meta.

import json

# Guardar metadata compleja en un solo campo
meta = {
    'tipo': 'software',
    'stack': ['Python', 'JavaScript'],
    'filosofia': 'El foco es la función principal',
    'porcentaje': 85,
}

conn.execute(
    "UPDATE projects SET meta=? WHERE id=?",
    (json.dumps(meta, ensure_ascii=False), project_id)
)

# Leer y deserializar
row = conn.execute(
    "SELECT meta FROM projects WHERE id=?", (project_id,)
).fetchone()
meta = json.loads(row[0]) if row else {}
print(meta['porcentaje']) # 85
Tipo de dato Donde va Razon
ID, status, fechas, foreign keys Columna dedicada Se necesita en WHERE, ORDER BY o JOIN.
Stack, filosofía, porcentaje, config Campo meta (JSON) Solo se lee, no se filtra. Puede crecer sin migración.
Datos que evolucionan rápido Campo meta (JSON) Agregar campos sin ALTER TABLE ni migración.

05. BACKUP AUTOMÁTICO — UNA LÍNEA

SQLite es un archivo. Hacer backup es copiar ese archivo. Sin dumps, sin exports, sin herramientas externas. La estrategia en FocOs es llamar a backup_db() al inicio de cada sesión de trabajo — si algo se corrompe, el backup del día está disponible de forma inmediata.

import shutil
from datetime import datetime

def backup_db(self) -> dict:
    fecha = datetime.now().strftime('%Y-%m-%d')
    backup = DB_PATH.parent / f'focos_backup_{fecha}.db'
    shutil.copy2(DB_PATH, backup)
    return { 'ok': True, 'path': str(backup) }

# Llamar al inicio de cada sesión:
# backup_db() — si algo se corrompe, el backup del día está ahí

-- CONCLUSION

SQLite con el patrón meta JSON elimina la necesidad de migraciones de schema en proyectos que evolucionan rápido. Los campos estructurados van en columnas dedicadas para búsqueda rápida. Los datos flexibles van en el campo meta sin migración. Para una app de escritorio de un solo usuario, este enfoque es suficiente para años de desarrollo sin tocar la estructura de la base de datos.

> SYSTEM_READY > NODE_ONLINE

< session_end // node: exit >
> INFOGRATECH_CORE_SHELL X
$