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.
Actualizado Publicado 39 min de lectura

FastHTML Tutorial Series
Part 5 of 6
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:
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
sqlite3de la librería estándar. APSW no tiene el valor por defectocheck_same_thread=Trueque hace asqlite3frá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 aCREATE TABLE IF NOT EXISTS.transform=Truegestiona 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:
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:
# 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
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
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
pip install python-fasthtml fastliteFastlite 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:
OPENROUTER_API_KEY=your_api_key_here
DB_PATH=tools.db
DEFAULT_MODEL=openai/gpt-4o-minigpt-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:
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
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)
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
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
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:
from fastlite import NotFoundErrorConstrucció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
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:
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:
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
dben lugar deHistoryDAO - 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 auvicorn.run(...)
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:
python main.pyserve() 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:
sqlite3 tools.db 'SELECT COUNT(*) FROM title_history;'Salida esperada tras generar una tanda de títulos:
1Si 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:
python main.pyVisita 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
sqlite3 tools.db 'PRAGMA journal_mode;'Salida esperada:
walSi 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:
sqlite3 tools.db 'PRAGMA journal_mode=WAL;'O añádelo al arranque de tu app en db.py:
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:
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):
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):
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:
volumes:
- ./data:/app/dataLuego 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
sqlite3manual con ~15 líneas from fastlite import database: esta importación se sacó defasthtml.commonen la 0.14.1db.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 auvicorn.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
.dben despliegues con Docker/Dokploy
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: empieza aquí si eres nuevo
- cómo estructurar un sitio web multipágina con FastHTML: enrutado y estructura del proyecto
- crear herramientas de IA multipágina en FastHTML: patrones de herramientas complejas
- generador de títulos con IA usando FastHTML y 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: a continuación, vincular el historial a cuentas de usuario


