Logotipo de Bitdoze

Añade una base de datos SQLite a tu app FastHTML con Fastlite

Aprende a añadir una base de datos SQLite a tu app FastHTML usando Fastlite. Cubre la configuración del esquema, operaciones CRUD, una página de historial y marcas de tiempo.

Dragos

Actualizado Publicado 39 min de lectura

Añade una base de datos SQLite a tu app FastHTML con Fastlite

Bienvenido de nuevo a la serie de FastHTML. En la parte 4 construimos un generador de títulos con IA usando FastHTML y PydanticAI. Esa app genera títulos bajo demanda, pero lo pierde todo cuando el servidor se reinicia.

Este tutorial lo soluciona. Vamos a añadir una base de datos SQLite en FastHTML usando fastlite, la librería oficial de bases de datos de FastHTML. Fastlite sustituye el viejo enfoque manual con sqlite3 con aproximadamente un 90 % menos de código, y usa APSW por debajo en lugar del sqlite3 de la librería estándar de Python, lo que significa un mejor comportamiento con hilos y sin la trampa de check_same_thread.

Si eres nuevo en la serie, empieza con FastHTML para principiantes: crea una interfaz para tu app Python y avanza desde ahí.

¿Por qué añadir una base de datos a tu app FastHTML?

  • Persistencia: el historial de generaciones sobrevive a los reinicios del servidor
  • Comodidad para el usuario: vuelve a ver generaciones anteriores sin llamar de nuevo a la IA
  • Análisis: sigue qué plataformas y estilos se usan más
  • Registro de auditoría: mantén un histórico de todas las interacciones con la IA
  • Extensibilidad: sienta las bases para cuentas de usuario, favoritos y etiquetas

SQLite encaja perfectamente aquí. Cero configuración, sin proceso de servidor independiente, integrado en la app y consultas SQL estándar. FastHTML lo recomienda oficialmente a través de fastlite, respaldado por APSW, un envoltorio ligero de SQLite con mejor semántica de concurrencia que el módulo sqlite3 de la librería estándar. Si has usado los mejores frameworks web de Python como Django o Flask con sus capas ORM, fastlite te resultará refrescantemente minimalista.

Estructura del proyecto: dónde vive la base de datos SQLite

Vamos a simplificar el proyecto de la parte 4. El antiguo directorio db/ con database.py y history_dao.py se sustituye por un único archivo db.py:

text
ai-title-generator/
├── main.py                   # Updated with history routes
├── config.py                 # Updated with DB settings
├── ai_service.py             # Unchanged
├── db.py                     # Replaces db/database.py + db/history_dao.py
├── components/
│   ├── __init__.py
│   ├── header.py             # Updated with history link
│   ├── footer.py
│   └── page_layout.py
├── pages/
│   ├── __init__.py
│   ├── home.py
│   ├── title_generator.py    # Updated to save history
│   └── history.py            # New history page
├── tools/
│   ├── __init__.py
│   └── title_generator.py
└── tools.db                  # SQLite file (created automatically)

Continuando desde la parte 4

Este tutorial parte del proyecto de cómo estructurar un sitio web multipágina con FastHTML y del generador de títulos con IA de la parte 4. Si empiezas desde cero, clona primero el repositorio de la serie. Para la gestión de dependencias, el enfoque recomendado es configurar tu proyecto Python con uv.

Configuración de la base de datos SQLite con fastlite

Por qué fastlite sustituye a sqlite3 en crudo

El enfoque anterior de este tutorial usaba una clase Database personalizada (~60 líneas) con gestores de contexto get_connection(), más una clase HistoryDAO (~130 líneas) con SQL escrito a mano. Fastlite sustituye todo eso con unas 15 líneas en total.

Diferencias clave:

  • Backend APSW: fastlite usa APSW en lugar del sqlite3 de la librería estándar. APSW no tiene el valor por defecto check_same_thread=True que hace a sqlite3 frágil en apps asíncronas o con hilos.
  • db.create(Class) es idempotente. Crea la tabla si no existe y devuelve el objeto tabla en cualquier caso. Sustituye a CREATE TABLE IF NOT EXISTS.
  • transform=True gestiona las migraciones de esquema automáticamente cuando cambian los campos.
  • Objetos dataclass: fastlite devuelve objetos con acceso por atributo (record.topic) en lugar de acceso tipo diccionario (record['topic']).

Cambio incompatible en FastHTML 0.14.1

database y Database se eliminaron de fasthtml.common en FastHTML 0.14.1. El código que hace from fasthtml.common import * y luego espera que database exista fallará. Debes añadir una importación explícita:

python
from fastlite import database  # or: from fastlite import *

Esto es lo primero que se romperá si copias fragmentos de tutoriales antiguos.

Crea la tabla TitleHistory con db.create()

Aquí está el db.py completo. Sustituye tanto a db/database.py como a db/history_dao.py de la versión anterior:

python
# db.py
import json
from datetime import datetime, timezone
from fastlite import database
from config import DB_PATH

db = database(DB_PATH)

class TitleHistory:
    id: int
    topic: str
    platform: str
    style: str
    number_of_titles: int
    titles: str          # JSON-encoded list
    created_at: str      # ISO-8601 UTC timestamp

histories = db.create(TitleHistory, pk='id', transform=True)

Esa es toda la capa de base de datos. db.create() crea la tabla si no existe y devuelve un objeto tabla. transform=True significa que si más tarde añades un campo a TitleHistory, fastlite alterará la tabla para que coincida. No hacen falta scripts de migración manuales.

Sintaxis basada en clases

python
class TitleHistory:
    id: int
    topic: str
    platform: str
    style: str
    number_of_titles: int
    titles: str
    created_at: str

histories = db.create(TitleHistory, pk='id', transform=True)

Sintaxis de anotación en línea

python
from fastlite import database

db = database('tools.db')
histories = db.create('title_history', id=int, topic=str,
                       platform=str, style=str,
                       number_of_titles=int, titles=str,
                       created_at=str, pk='id', transform=True)

La sintaxis en línea se salta la clase por completo y pasa los nombres de campo y los tipos directamente a db.create().

Instala fastlite y las dependencias

bash
pip install python-fasthtml fastlite

Fastlite es una dependencia de python-fasthtml, así que ya está instalado si tienes FastHTML. Aun así conviene mencionarlo explícitamente porque lo importarás directamente. Se requiere Python >= 3.10.

Crea tu archivo .env:

bash
OPENROUTER_API_KEY=your_api_key_here
DB_PATH=tools.db
DEFAULT_MODEL=openai/gpt-4o-mini

gpt-3.5-turbo se retira

El gpt-3.5-turbo de OpenAI (snapshot gpt-3.5-turbo-0125) se retira el 23 de octubre de 2026. Sustitúyelo por openai/gpt-4o-mini. Es más barato y mejor. Verifica el slug del modelo actual en OpenRouter al publicar, ya que los nombres de los modelos cambian.

config.py actualizado:

python
import os
from dotenv import load_dotenv

load_dotenv()

OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "openai/gpt-4o-mini")
DB_PATH = os.getenv("DB_PATH", "tools.db")
DEBUG = os.getenv("DEBUG", "True").lower() == "true"
APP_NAME = "AI Title Generator"

Operaciones CRUD en SQLite: insertar, consultar y borrar el historial

Estas cuatro funciones auxiliares sustituyen a la antigua clase HistoryDAO. Añádelas a db.py después de la configuración de la tabla:

Insertar una nueva generación de títulos

python
def save_generation(topic, platform, style, number_of_titles, titles):
    """Save a generation and return the new record."""
    return histories.insert(
        topic=topic,
        platform=platform,
        style=style,
        number_of_titles=number_of_titles,
        titles=json.dumps(titles),
        created_at=datetime.now(timezone.utc).isoformat()
    )

histories.insert() devuelve el objeto del registro insertado, así que result.id te da la nueva clave primaria. La lista titles se serializa a JSON y created_at se guarda como una cadena ISO-8601 UTC explícita. Sin el comportamiento ambiguo de CURRENT_TIMESTAMP.

Listar registros del historial (más recientes primero)

python
def get_history(limit=10, offset=0):
    """Get history records, newest first."""
    return histories(order_by='created_at DESC', limit=limit, offset=offset)

El callable de la tabla de fastlite admite order_by, limit y offset directamente. Devuelve una lista de objetos dataclass, así que accedes a los campos como record.topic, record.titles, etc.

Obtener un solo registro por ID

python
def get_by_id(record_id):
    """Get a single record. Raises NotFoundError if missing."""
    return histories[record_id]

Borrar un registro y gestionar IDs inexistentes

python
def delete_record(record_id):
    """Delete a record. Raises NotFoundError if missing."""
    histories.delete(record_id)

Fastlite lanza excepciones, no devuelve None

A diferencia del viejo enfoque con sqlite3 que devolvía None para filas inexistentes, fastlite lanza NotFoundError cuando accedes a un registro que no existe. Usa try/except NotFoundError en tus rutas en lugar de if not row: return None. Es más limpio: gestionas el error una sola vez a nivel de ruta.

NotFoundError viene de fastlite:

python
from fastlite import NotFoundError

Construcción de la página de historial

La página de historial tiene tres vistas: una lista paginada, una vista de detalle y una confirmación de borrado. Se mantienen las funciones de UX del tutorial original: paginación, vista previa de títulos, botones de copiar, prerrelleno de «Generar similar» y borrado con confirmación. El cambio principal es pasar del acceso tipo diccionario (record['topic']) al acceso por atributo (record.topic).

Archivo: pages/history.py

python
import json
from fasthtml.common import *
from db import get_history, get_by_id, delete_record
from fastlite import NotFoundError

RECORDS_PER_PAGE = 10

def history_page(page: int = 1):
    offset = (page - 1) * RECORDS_PER_PAGE
    history_records = get_history(limit=RECORDS_PER_PAGE, offset=offset)

    history_cards = []
    if not history_records:
        history_cards.append(
            Div(
                P("No generation history found. Try generating some titles first!",
                  cls="text-gray-600 italic"),
                cls="bg-white p-6 rounded-lg shadow-md"
            )
        )
    else:
        for record in history_records:
            titles = json.loads(record.titles)
            display_titles = titles[:3]
            has_more = len(titles) > 3

            title_items = [Li(t, cls="mb-1") for t in display_titles]
            if has_more:
                title_items.append(
                    Li(
                        A(f"...and {len(titles) - 3} more",
                          href=f"/history/{record.id}",
                          cls="text-blue-600 hover:underline italic"),
                        cls="mt-2"
                    )
                )

            # Format timestamp for display
            created_display = record.created_at[:19].replace('T', ' ')

            history_cards.append(
                Div(
                    Div(
                        Div(
                            H3(record.topic[:50] + ("..." if len(record.topic) > 50 else ""),
                               cls="text-lg font-semibold"),
                            P(f"{record.platform} • {record.style} • {record.number_of_titles} titles",
                              cls="text-sm text-gray-600"),
                            cls="flex-grow"
                        ),
                        P(created_display, cls="text-xs text-gray-500"),
                        cls="flex justify-between items-start mb-3"
                    ),
                    Div(
                        H4("Generated Titles:", cls="font-medium mb-2"),
                        Ul(*title_items, cls="list-disc pl-5 text-gray-700"),
                        cls="mb-3"
                    ),
                    Div(
                        A("View Details", href=f"/history/{record.id}",
                          cls="text-blue-600 hover:underline text-sm mr-4"),
                        A("Delete", href=f"/history/{record.id}/delete",
                          cls="text-red-600 hover:underline text-sm"),
                        cls="flex justify-end"
                    ),
                    cls="bg-white p-6 rounded-lg shadow-md mb-4"
                )
            )

    # Pagination
    has_next = len(history_records) == RECORDS_PER_PAGE
    pagination = Div(
        Div(
            A("← Previous",
              href=f"/history?page={page - 1}" if page > 1 else "#",
              cls=f"px-4 py-2 rounded {'bg-blue-600 text-white' if page > 1 else 'bg-gray-200 text-gray-500 cursor-default'}"),
            Span(f"Page {page}", cls="px-4 py-2"),
            A("Next →",
              href=f"/history?page={page + 1}" if has_next else "#",
              cls=f"px-4 py-2 rounded {'bg-blue-600 text-white' if has_next else 'bg-gray-200 text-gray-500 cursor-default'}"),
            cls="flex items-center justify-center space-x-2"
        ),
        cls="mt-6"
    )

    return Div(
        H1("Generation History", cls="text-3xl font-bold text-gray-800 mb-6"),
        P("View your previously generated titles.", cls="text-gray-600 mb-6"),
        Div(*history_cards),
        pagination,
        cls="max-w-4xl mx-auto"
    )

La página de detalle muestra todos los títulos con botones de copiar, metadatos y botones de acción:

python
def history_detail_page(record_id: int):
    try:
        record = get_by_id(record_id)
    except NotFoundError:
        return Div(
            H1("Record Not Found", cls="text-3xl font-bold text-red-600 mb-4"),
            P("The requested history record could not be found.", cls="mb-4"),
            A("Back to History", href="/history",
              cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
            cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
        )

    titles = json.loads(record.titles)
    created_display = record.created_at[:19].replace('T', ' ')

    title_items = []
    for title in titles:
        title_items.append(
            Li(
                Div(
                    P(title, cls="font-medium"),
                    Button(
                        "Copy", type="button",
                        onclick=f"navigator.clipboard.writeText('{title.replace(chr(39), chr(92)+chr(39))}'); this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 2000);",
                        cls="ml-auto text-sm bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded"
                    ),
                    cls="flex justify-between items-center"
                ),
                cls="p-3 border-b last:border-b-0"
            )
        )

    return Div(
        H1("Title Generation Details", cls="text-3xl font-bold text-gray-800 mb-6"),
        Div(
            Div(
                H2("Generation Information", cls="text-xl font-semibold mb-4"),
                Div(
                    Div(Strong("Date & Time:"), P(created_display, cls="text-gray-700 mb-2"), cls="mb-3"),
                    Div(Strong("Topic:"), P(record.topic, cls="text-gray-700 mb-2"), cls="mb-3"),
                    Div(Strong("Platform:"), P(record.platform, cls="text-gray-700 mb-2"), cls="mb-3"),
                    Div(Strong("Style:"), P(record.style, cls="text-gray-700 mb-2"), cls="mb-3"),
                    Div(Strong("Number of Titles:"), P(str(record.number_of_titles), cls="text-gray-700 mb-2"), cls="mb-3"),
                    cls="bg-gray-50 p-4 rounded-lg mb-6"
                ),
                H2("Generated Titles", cls="text-xl font-semibold mb-4"),
                P("Click 'Copy' to copy any title to your clipboard.", cls="text-gray-600 mb-3"),
                Ul(*title_items, cls="border rounded divide-y mb-6"),
                Div(
                    A("Generate Similar",
                      href=f"/title-generator?topic={record.topic}&platform={record.platform}&style={record.style}&number_of_titles={record.number_of_titles}",
                      cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3"),
                    A("Back to History", href="/history",
                      cls="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded mr-3"),
                    A("Delete Record", href=f"/history/{record_id}/delete",
                      cls="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"),
                    cls="flex flex-wrap gap-y-2"
                ),
                cls="bg-white p-6 rounded-lg shadow-md"
            ),
            cls="max-w-2xl mx-auto"
        )
    )

La página de confirmación de borrado:

python
def delete_confirm_page(record_id: int):
    try:
        record = get_by_id(record_id)
    except NotFoundError:
        return Div(
            H1("Record Not Found", cls="text-3xl font-bold text-red-600 mb-4"),
            P("The requested history record could not be found.", cls="mb-4"),
            A("Back to History", href="/history",
              cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
            cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
        )

    created_display = record.created_at[:19].replace('T', ' ')

    return Div(
        H1("Confirm Deletion", cls="text-3xl font-bold text-gray-800 mb-6"),
        Div(
            P("Are you sure you want to delete this history record?", cls="text-lg mb-4"),
            Div(
                P(f"Topic: {record.topic[:100]}{'...' if len(record.topic) > 100 else ''}", cls="mb-2"),
                P(f"Platform: {record.platform}", cls="mb-2"),
                P(f"Created: {created_display}", cls="mb-2"),
                cls="bg-gray-100 p-4 rounded-lg mb-6"
            ),
            P("This action cannot be undone.", cls="text-red-600 mb-6"),
            Form(
                Div(
                    Button("Yes, Delete Record", type="submit",
                           cls="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded mr-3"),
                    A("Cancel", href=f"/history/{record_id}",
                      cls="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded"),
                    cls="flex"
                ),
                method="post",
                action=f"/history/{record_id}/delete"
            ),
            cls="bg-white p-6 rounded-lg shadow-md"
        ),
        cls="max-w-2xl mx-auto"
    )
¿Cómo funciona la función «Generar similar»?

La página de detalle incluye un botón «Generar similar» que enlaza a /title-generator?topic=...&platform=...&style=...&number_of_titles=.... El manejador GET del formulario del generador de títulos lee estos parámetros de consulta y prerrellena los campos del formulario. Así, los usuarios pueden regenerar títulos con la misma configuración sin volver a escribirlo todo. Los parámetros se pasan como query params de la URL, por lo que el enlace se puede compartir y guardar en marcadores.

Para más información sobre cómo construir herramientas multipágina como esta, consulta crear herramientas de IA multipágina en FastHTML.

Conexión de las rutas y ejecución de la app

Aquí está el main.py actualizado con todas las rutas. Cambios clave respecto a la versión anterior:

  • Importa desde db en lugar de HistoryDAO
  • Los manejadores de ruta son síncronos (no async). FastHTML ejecuta los manejadores síncronos en un threadpool y APSW gestiona los hilos correctamente
  • serve() sustituye a uvicorn.run(...)
python
from fasthtml.common import *

from pages.home import home as home_page
from pages.title_generator import title_generator_form, title_generator_results
from pages.history import history_page, history_detail_page, delete_confirm_page
from components.page_layout import page_layout
from tools.title_generator import TitleGenerator
from db import save_generation, get_by_id, delete_record
from fastlite import NotFoundError
import config

app = FastHTML()
title_generator = TitleGenerator()

@app.get("/")
def home():
    return page_layout(
        title=f"Home - {config.APP_NAME}",
        content=home_page(),
        current_page="/"
    )

@app.get("/title-generator")
def title_generator_page(topic: str = "", platform: str = "Blog",
                          style: str = "Professional", number_of_titles: str = "5"):
    return page_layout(
        title=f"Title Generator - {config.APP_NAME}",
        content=title_generator_form(),
        current_page="/title-generator"
    )

@app.post("/title-generator/generate")
async def generate_titles(topic: str, platform: str, style: str, number_of_titles: str):
    try:
        if not topic:
            return page_layout(
                title=f"Error - {config.APP_NAME}",
                content=Div(
                    H1("Error", cls="text-3xl font-bold text-red-600 mb-4"),
                    P("Please provide a topic for your titles.", cls="mb-4"),
                    A("Try Again", href="/title-generator",
                      cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
                    cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
                ),
                current_page="/title-generator"
            )

        num_titles = int(number_of_titles)
        titles = await title_generator.generate_titles(
            topic=topic, platform=platform, style=style,
            number_of_titles=num_titles
        )

        # Save to database
        record = save_generation(
            topic=topic, platform=platform, style=style,
            number_of_titles=num_titles, titles=titles
        )

        return page_layout(
            title=f"Generated Titles - {config.APP_NAME}",
            content=title_generator_results(
                topic=topic, platform=platform, style=style,
                titles=titles, history_id=record.id
            ),
            current_page="/title-generator"
        )
    except Exception as e:
        return page_layout(
            title=f"Error - {config.APP_NAME}",
            content=Div(
                H1("Error", cls="text-3xl font-bold text-red-600 mb-4"),
                P(f"An error occurred: {str(e)}", cls="mb-4"),
                A("Try Again", href="/title-generator",
                  cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
                cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
            ),
            current_page="/title-generator"
        )

# History routes
@app.get("/history")
def history(page: int = 1):
    if page < 1:
        page = 1
    return page_layout(
        title=f"Generation History - {config.APP_NAME}",
        content=history_page(page=page),
        current_page="/history"
    )

@app.get("/history/{record_id:int}")
def history_detail(record_id: int):
    return page_layout(
        title=f"History Details - {config.APP_NAME}",
        content=history_detail_page(record_id=record_id),
        current_page="/history"
    )

@app.get("/history/{record_id:int}/delete")
def confirm_delete(record_id: int):
    return page_layout(
        title=f"Confirm Deletion - {config.APP_NAME}",
        content=delete_confirm_page(record_id=record_id),
        current_page="/history"
    )

@app.post("/history/{record_id:int}/delete")
def handle_delete(record_id: int):
    try:
        delete_record(record_id)
        return page_layout(
            title=f"Record Deleted - {config.APP_NAME}",
            content=Div(
                H1("Record Deleted", cls="text-3xl font-bold text-green-600 mb-4"),
                P("The history record has been successfully deleted.", cls="mb-4"),
                A("Back to History", href="/history",
                  cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
                cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
            ),
            current_page="/history"
        )
    except NotFoundError:
        return page_layout(
            title=f"Error - {config.APP_NAME}",
            content=Div(
                H1("Error", cls="text-3xl font-bold text-red-600 mb-4"),
                P("The record could not be deleted or doesn't exist.", cls="mb-4"),
                A("Back to History", href="/history",
                  cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
                cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
            ),
            current_page="/history"
        )

@app.get("/{path:path}")
def not_found(path: str):
    return page_layout(
        title=f"404 Not Found - {config.APP_NAME}",
        content=Div(
            H1("404 - Page Not Found", cls="text-3xl font-bold text-gray-800 mb-4"),
            P(f"Sorry, the page '/{path}' does not exist.", cls="mb-4"),
            A("Return Home", href="/",
              cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
            cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md text-center"
        ),
        current_page="/"
    )

serve()

Por qué los manejadores síncronos son correctos aquí

La ruta de generación de títulos (generate_titles) sigue siendo async porque llama a la API de IA de forma asíncrona. Pero todas las rutas CRUD de la base de datos son síncronas, y eso es correcto. FastHTML ejecuta los manejadores síncronos en un threadpool automáticamente. Con APSW (el backend de fastlite) no existe el problema de check_same_thread que tiene el sqlite3 de la librería estándar. El viejo patrón de async def haciendo llamadas síncronas a sqlite3 bloqueaba el bucle de eventos. Esto es más limpio.

Ejecuta la app:

bash
python main.py

serve() arranca el servidor en el puerto 5001 con la recarga automática activada. No hace falta el guard if __name__ == "__main__".

Para desplegar en producción, consulta desplegar un proyecto Python con uv usando Dokploy.

Verificar que tu base de datos SQLite funciona

El tutorial original terminaba con «abre el navegador y haz clic por ahí». Así es como se confirma de verdad que la base de datos funciona.

Prueba 1: genera y comprueba el archivo de la base de datos

Genera algunos títulos desde la interfaz y luego comprueba la base de datos desde la línea de comandos:

bash
sqlite3 tools.db 'SELECT COUNT(*) FROM title_history;'

Salida esperada tras generar una tanda de títulos:

text
1

Si ves 0, el guardado no se produjo. Revisa la terminal en busca de errores.

Prueba 2: reinicia y confirma la persistencia

Detén el servidor con Ctrl+C y reinícialo:

bash
python main.py

Visita http://localhost:5001/history. Tus registros deberían seguir ahí. Esto demuestra que los datos están en disco, no solo en memoria.

Prueba 3: confirma el modo WAL

bash
sqlite3 tools.db 'PRAGMA journal_mode;'

Salida esperada:

text
wal

Si ves delete, el modo WAL no está activo. Consulta la siguiente sección para activarlo.

Rendimiento y endurecimiento de SQLite para producción

Activa el modo WAL

WAL (Write-Ahead Logging) permite lectores concurrentes mientras hay una escritura en curso. Sin él, una escritura bloquea a todos los lectores. Para cualquier app que sirva peticiones HTTP mientras escribe en la base de datos, WAL es esencial.

Actívalo una vez desde la CLI de SQLite:

bash
sqlite3 tools.db 'PRAGMA journal_mode=WAL;'

O añádelo al arranque de tu app en db.py:

python
db = database(DB_PATH)
db.execute('PRAGMA journal_mode=WAL')

Añade índices para consultas más rápidas

Cuando tu historial supere unos cientos de registros, añade un índice en la columna de la marca de tiempo:

python
db.execute('CREATE INDEX IF NOT EXISTS idx_created_at ON title_history(created_at)')

Esto acelera la consulta ORDER BY created_at DESC que usa la lista del historial.

Haz copias de seguridad de tu base de datos SQLite

Dos enfoques:

1. Copia con la CLI (una línea en cron):

bash
sqlite3 tools.db ".backup tools-$(date +%F).db"

Ejecuta esto a diario con cron. Crea una copia fechada de la base de datos.

2. VACUUM INTO (copia compacta):

sql
VACUUM INTO 'tools-backup.db';

Esto crea una copia desfragmentada, más pequeña que la de .backup, pero tarda más.

Docker/Dokploy: monta un volumen para el archivo .db

Si contenerizas esta app, el archivo tools.db debe vivir en un volumen montado. El sistema de archivos del contenedor es efímero: tools.db desaparece en cada redespliegue. En docker-compose.yml:

yaml
volumes:
  - ./data:/app/data

Luego define DB_PATH=/app/data/tools.db en tu .env. Consulta ejecutar cualquier app Python en Docker con Docker Compose para ver patrones de volúmenes.

La limitación de un solo escritor de SQLite

SQLite permite un solo escritor a la vez. Si dos peticiones intentan escribir simultáneamente, una espera (con WAL espera brevemente; sin WAL puede dar error). Para una herramienta de IA personal como esta no pasa nada: no vas a tener cientos de escritores concurrentes. Si necesitas escrituras concurrentes desde varios procesos, PostgreSQL es la mejor opción.

Mejoras avanzadas de la base de datos

Aquí tienes mejoras que puedes construir sobre esta base:

Gestión de usuarios

Añade una tabla de usuarios y vincula los registros del historial a usuarios concretos. Es el siguiente paso natural: consulta añadir autenticación de usuarios a tu generador de títulos con IA en FastHTML para ver el tutorial completo.

Títulos favoritos

Permite marcar títulos concretos como favoritos. Crea una tabla favorites que referencie los registros de title_history. Añade una página de favoritos a la interfaz.

Etiquetas y categorías

Categoriza las generaciones con etiquetas. Implementa el filtrado por etiqueta en la página del historial. Añade búsqueda basada en etiquetas.

Panel de analíticas

Sigue qué plataformas y estilos se usan más. Construye un panel sencillo con estadísticas de uso y visualización de tendencias.

Migraciones de esquema

Con transform=True, fastlite gestiona la mayoría de los cambios de esquema automáticamente. Si añades un campo a TitleHistory, la tabla se altera en el siguiente arranque. Para migraciones más complejas, puedes ejecutar SQL en crudo con db.execute().

Exportar e importar

Añade exportación de los datos del historial a JSON o CSV. Útil para mover datos entre entornos o hacer copias de seguridad sin tocar directamente el archivo SQLite.

Puntos clave

  • fastlite sustituye ~190 líneas de código sqlite3 manual con ~15 líneas
  • from fastlite import database: esta importación se sacó de fasthtml.common en la 0.14.1
  • db.create(Class, transform=True) es idempotente y gestiona las migraciones de esquema
  • Usa manejadores de ruta síncronos para las llamadas a la BD: APSW gestiona los hilos correctamente
  • serve() sustituye a uvicorn.run() (puerto 5001, recarga automática activada)
  • Verifica siempre la persistencia con una prueba de reinicio y una comprobación con la CLI de sqlite3
  • Activa el modo WAL en cualquier app que lea y escriba concurrentemente
  • Monta un volumen para el archivo .db en despliegues con Docker/Dokploy

Próximos pasos en la serie de FastHTML

Aquí están todos los artículos de la serie: