---
title: "Añade una base de datos SQLite a tu app FastHTML con Fastlite"
description: "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."
date: 2025-03-03
categories: ["web-development"]
tags: ["fasthtml","sqlite","fastlite"]
---

import YouTubeEmbed from "@components/widgets/YouTubeEmbed.astro";
import Notice from "@components/widgets/Notice.astro";
import ListCheck from "@components/widgets/ListCheck.astro";
import Accordion from "@components/widgets/Accordion.astro";
import Tabs from "@components/widgets/Tabs.astro";
import Tab from "@components/widgets/Tab.astro";

Bienvenido de nuevo a la serie de FastHTML. En la [parte 4](/es/fasthtml-herramientas-pydanticai/) 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](/es/empezar-fasthtml/) y avanza desde ahí.

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

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

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](/best-python-web-frameworks/) 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`:

```
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)
```

<Notice type="info" title="Continuando desde la parte 4">
Este tutorial parte del proyecto de [cómo estructurar un sitio web multipágina con FastHTML](/es/fasthtml-multiples-paginas/) 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](/es/empezar-uv/).
</Notice>

## 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']`).

<Notice type="warning" title="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.
</Notice>

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

<Tabs>
<Tab name="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)
```
</Tab>
<Tab name="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()`.
</Tab>
</Tabs>

### 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
```

<Notice type="error" title="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.
</Notice>

`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)
```

<Notice type="info" title="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.
</Notice>

`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"
    )
```

<Accordion label="¿Cómo funciona la función «Generar similar»?" group="faq">
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.
</Accordion>

Para más información sobre cómo construir herramientas multipágina como esta, consulta [crear herramientas de IA multipágina en FastHTML](/es/fasthtml-herramientas-ia-complejas/).

## 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()
```

<Notice type="info" title="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.
</Notice>

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](/es/dokploy-python-railpack-uv/).

## 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:

```
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:

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

<Notice type="warning" title="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](/docker-run-python/) para ver patrones de volúmenes.
</Notice>

### 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:

<Accordion label="Gestión de usuarios" group="enhancements">
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](/es/fasthtml-autenticacion-usuarios/) para ver el tutorial completo.
</Accordion>

<Accordion label="Títulos favoritos" group="enhancements">
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.
</Accordion>

<Accordion label="Etiquetas y categorías" group="enhancements">
Categoriza las generaciones con etiquetas. Implementa el filtrado por etiqueta en la página del historial. Añade búsqueda basada en etiquetas.
</Accordion>

<Accordion label="Panel de analíticas" group="enhancements">
Sigue qué plataformas y estilos se usan más. Construye un panel sencillo con estadísticas de uso y visualización de tendencias.
</Accordion>

<Accordion label="Migraciones de esquema" group="enhancements">
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()`.
</Accordion>

<Accordion label="Exportar e importar" group="enhancements">
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.
</Accordion>

## Puntos clave

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

## Próximos pasos en la serie de FastHTML

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

- [FastHTML para principiantes: crea una interfaz para tu app Python](/es/empezar-fasthtml/): empieza aquí si eres nuevo
- [cómo estructurar un sitio web multipágina con FastHTML](/es/fasthtml-multiples-paginas/): enrutado y estructura del proyecto
- [crear herramientas de IA multipágina en FastHTML](/es/fasthtml-herramientas-ia-complejas/): patrones de herramientas complejas
- [generador de títulos con IA usando FastHTML y PydanticAI](/es/fasthtml-herramientas-pydanticai/): parte 4, la app que amplía este tutorial
- **Añadir una base de datos SQLite con fastlite**: este artículo
- [añadir autenticación de usuarios a tu generador de títulos con IA en FastHTML](/es/fasthtml-autenticacion-usuarios/): a continuación, vincular el historial a cuentas de usuario