Capítulo 3: Persistencia Segura de Datos con D1 y Drizzle ORM
Capítulo 3: Persistencia Segura de Datos con D1 y Drizzle ORM
Hasta ahora, nuestra API es stateless. En este capítulo, introduciremos la persistencia de datos utilizando Cloudflare D1, la base de datos SQL distribuida globalmente de Cloudflare, y Drizzle ORM, un ORM TypeScript moderno y seguro que nos permitirá interactuar con D1 de forma tipada y eficiente.
< Volver al Índice --- < Capítulo 2: Hono --- Capítulo 4: Arquitectura Hexagonal >
Cloudflare D1: Base de Datos SQL en el Edge
Cloudflare D1 es una base de datos relacional (basada en SQLite) diseñada para el Edge.
-
Características Principales:
- Distribuida Globalmente: Réplicas cercanas a tus usuarios para baja latencia de lectura (la escritura se dirige a una región primaria).
- Serverless: Sin gestión de servidores, escala automáticamente.
- Basada en SQLite: Sintaxis SQL estándar y familiar.
- Integración con Workers: Fácil acceso desde tus Workers a través de bindings.
- Consistencia: Garantiza consistencia fuerte en la región primaria y eventual en las réplicas.
-
Creación Segura de la Base de Datos: Puedes crear una base de datos D1 usando la CLI de Wrangler:
# Crear la base de datos (ej: 'tasks-db') wrangler d1 create tasks-db
Wrangler te devolverá la configuración del binding que necesitas añadir a tu
wrangler.toml
. Es crucial manejar el acceso a esta base de datos de forma segura. -
Binding en
wrangler.toml
: Añade el binding a tu archivowrangler.toml
bajo la sección[[d1_databases]]
:# wrangler.toml # ... otras configuraciones ... [[d1_databases]] binding = "DB" # Nombre de la variable en env (env.DB) database_name = "tasks-db" database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # ID proporcionado por el comando create # Para desarrollo local y testing, puedes usar una DB local: # preview_database_id = "tasks-db-local" # Opcional, usa el mismo ID si quieres
Importante: Recuerda añadir el tipo correspondiente (
DB: D1Database
) a tu interfazEnv
ensrc/index.ts
(o donde la definas).// src/index.ts o similar export interface Env { // ... otros bindings ... DB: D1Database; // Binding a nuestra base de datos D1 }
Drizzle ORM: TypeScript SQL con Esteroides
Drizzle ORM es un “ORM sin ORM” que se enfoca en escribir SQL (o un constructor de consultas similar a SQL) de forma segura y totalmente tipada con TypeScript, sin la sobrecarga o abstracciones complejas de ORMs tradicionales.
-
Ventajas con Workers/D1:
- Seguridad de Tipos: Evita errores en tiempo de ejecución al escribir consultas.
- Prevención de Inyección SQL: El constructor de consultas parametriza las consultas por defecto.
- Ligero: Huella mínima, ideal para el Edge.
- Drizzle Kit: Herramienta para gestionar esquemas y generar/aplicar migraciones SQL.
- Soporte para D1: Adaptador específico para Cloudflare D1.
-
Instalación: Necesitamos Drizzle ORM y Drizzle Kit (para migraciones):
# ORM principal y driver para D1 bun add drizzle-orm @drizzle-team/d1 # Herramienta de migraciones (desarrollo) bun add --dev drizzle-kit
Definición de Esquemas y Tipos con Drizzle
Definiremos nuestro esquema de base de datos usando la sintaxis de Drizzle. Crearemos una tabla simple para gestionar “tareas”.
- Crear Archivo de Esquema (
src/db/schema.ts
): Crea un directoriosrc/db
y dentro un archivoschema.ts
.
Usamos// src/db/schema.ts import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; // Para valores por defecto como CURRENT_TIMESTAMP // Definición de la tabla 'tasks' export const tasks = sqliteTable('tasks', { id: integer('id').primaryKey({ autoIncrement: true }), // ID autoincremental title: text('title').notNull(), // Título obligatorio description: text('description'), // Descripción opcional completed: integer('completed', { mode: 'boolean' }).notNull().default(false), // Booleano, por defecto false createdAt: integer('created_at', { mode: 'timestamp_ms' }) // Usar timestamp numérico para D1 .notNull() .default(sql`(unixepoch('subsec') * 1000)`), // Valor por defecto: timestamp actual en ms updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) .$onUpdate(() => sql`(unixepoch('subsec') * 1000)`), // Se actualiza automáticamente }); // Tipos inferidos para usar en nuestra aplicación (opcional pero recomendado) export type Task = typeof tasks.$inferSelect; // Tipo para seleccionar tareas export type NewTask = typeof tasks.$inferInsert; // Tipo para insertar nuevas tareas
sqliteTable
porque D1 se basa en SQLite. Definimos columnas con sus tipos (text
,integer
), constraints (notNull
,primaryKey
,default
) y tipos inferidos (Task
,NewTask
) para usar en nuestro código TypeScript. Nota el uso deinteger
conmode: 'timestamp_ms'
para las fechas, ya que es una forma compatible y eficiente en D1/SQLite.
Migraciones Seguras con Drizzle Kit
Drizzle Kit nos ayuda a generar y aplicar migraciones SQL basadas en los cambios de nuestro esquema.
-
Configuración de Drizzle Kit (
drizzle.config.ts
): Crea un archivodrizzle.config.ts
en la raíz del proyecto:// drizzle.config.ts import type { Config } from 'drizzle-kit'; export default { schema: './src/db/schema.ts', // Ruta a tu archivo de esquema out: './drizzle/migrations', // Directorio donde se guardarán las migraciones driver: 'd1', // Especifica que usas D1 dbCredentials: { // Necesita la información del binding de wrangler.toml para conectarse wranglerConfigPath: './wrangler.toml', dbName: 'tasks-db' // El nombre de tu base de datos D1 } } satisfies Config;
Asegúrate de que
wranglerConfigPath
ydbName
coincidan con tu configuración. -
Añadir Script de Migración a
package.json
:// package.json { // ... "scripts": { "test": "vitest", "db:generate": "drizzle-kit generate:sqlite", // Genera SQL de migración "db:migrate": "wrangler d1 migrations apply tasks-db --local", // Aplica migraciones localmente "db:migrate:prod": "wrangler d1 migrations apply tasks-db" // Aplica migraciones en producción // ... } }
-
Generar la Primera Migración: Como acabamos de definir el esquema, generamos la migración inicial:
bun run db:generate
Esto creará un archivo SQL en
./drizzle/migrations
con elCREATE TABLE
paratasks
. Revisa el archivo generado. -
Aplicar la Migración (Localmente): Para aplicar la migración a tu base de datos D1 local (simulada por Wrangler/Miniflare):
bun run db:migrate
Esto ejecutará el SQL generado contra la base de datos local. Para producción, usarías
bun run db:migrate:prod
.
Conectando Hono con D1 a través de Drizzle ORM
Ahora, conectaremos todo. Crearemos una instancia de Drizzle y la usaremos en nuestros manejadores Hono para interactuar con la base de datos.
-
Crear Instancia de Drizzle (
src/db/index.ts
): Crea un archivosrc/db/index.ts
para inicializar Drizzle con el binding D1.// src/db/index.ts import { drizzle } from 'drizzle-orm/d1'; import * as schema from './schema'; // Importa todo el esquema import type { D1Database } from '@cloudflare/workers-types'; // Importa el tipo D1Database // Función para obtener la instancia de Drizzle // Recibe el binding D1Database del entorno (env.DB) export const getDb = (d1: D1Database) => { return drizzle(d1, { schema }); // Pasa el esquema para funcionalidad completa }; // Exporta el esquema también para facilitar importaciones export { schema };
-
Usando Drizzle en Rutas Hono (Ejemplo): Podemos empezar a usar
getDb
en nuestros manejadores. Por ejemplo, en una futura ruta para obtener tareas (la implementaremos en el Cap. 4):// Ejemplo conceptual (ej: en src/routes/tasks.ts) import { Hono } from 'hono'; import type { Env } from '../index'; import { getDb, schema } from '../db'; // Importar getDb y schema import { eq } from 'drizzle-orm'; // Importar helpers de Drizzle si son necesarios const tasksApp = new Hono<{ Bindings: Env }>(); // Ruta GET para obtener todas las tareas (simplificado) tasksApp.get('/', async (c) => { try { const db = getDb(c.env.DB); // Obtener instancia de Drizzle con el binding DB const allTasks = await db.select().from(schema.tasks).all(); // Consulta tipada! return c.json(allTasks); } catch (error) { console.error("Error fetching tasks:", error); return c.json({ error: "Failed to fetch tasks" }, 500); } }); // Ruta GET para obtener tarea por ID (simplificado) tasksApp.get('/:id', async (c) => { const id = parseInt(c.req.param('id'), 10); if (isNaN(id)) { return c.json({ error: 'Invalid ID' }, 400); } try { const db = getDb(c.env.DB); // Usar `eq` para la condición WHERE, `findFirst` para obtener un solo resultado const task = await db.select().from(schema.tasks).where(eq(schema.tasks.id, id)).get(); if (!task) { return c.json({ error: 'Task not found' }, 404); } return c.json(task); } catch (error) { console.error(`Error fetching task ${id}:`, error); return c.json({ error: "Failed to fetch task" }, 500); } }); export default tasksApp; // No olvides montar tasksApp en src/index.ts: app.route('/tasks', tasksApp);
Observa cómo obtenemos la instancia
db
pasandoc.env.DB
y luego usamos métodos comodb.select().from(schema.tasks).all()
o...where(eq(schema.tasks.id, id)).get()
para realizar consultas SQL de forma segura y tipada.
Testing: Pruebas de Integración para Queries Drizzle
Podemos escribir pruebas que interactúen con la base de datos D1 simulada por Miniflare.
- Ejemplo de Prueba de Integración (
test/db.test.ts
): Crea un archivotest/db.test.ts
.
Estas pruebas usan// test/db.test.ts import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; import { env } from 'cloudflare:test'; // Importar el entorno de prueba import { getDb, schema } from '../src/db'; // Importar getDb y schema import type { D1Database } from '@cloudflare/workers-types'; import type { NewTask } from '../src/db/schema'; import { eq } from 'drizzle-orm'; // Import eq for the test queries // Declarar la instancia DB fuera para que esté disponible en todos los tests let db: ReturnType<typeof getDb>; beforeAll(() => { // Obtener la instancia de la BD una vez antes de todas las pruebas // Asume que wrangler.toml está configurado para crear/usar la DB en modo local db = getDb(env.DB as D1Database); // 'env.DB' es inyectado por vitest-pool-workers }); beforeEach(async () => { // Limpiar la tabla antes de cada prueba para aislamiento // ¡Importante! Asegúrate de que la BD exista antes de ejecutar delete try { // Intenta eliminar la tabla si existe para empezar limpio await db.run(sql`DROP TABLE IF EXISTS tasks`); // Vuelve a aplicar las migraciones para asegurar que la tabla esté creada // Esto puede requerir una forma de ejecutar migraciones programáticamente o // confiar en que la tabla ya existe tras la ejecución inicial de `db:migrate`. // Una opción más simple es solo borrar los datos: await db.delete(schema.tasks).run(); } catch (e) { // Puede fallar si la tabla no existe en la primera ejecución, ignora o loguea. console.warn('Could not clean/reset tasks table (may not exist yet):', e) } }); describe('Database Tests (Tasks)', () => { it('debería insertar y recuperar una tarea', async () => { const newTask: NewTask = { title: 'Probar Drizzle' }; // Insertar const inserted = await db.insert(schema.tasks).values(newTask).returning().get(); expect(inserted).toBeDefined(); expect(inserted.id).toBeTypeOf('number'); expect(inserted.title).toBe('Probar Drizzle'); expect(inserted.completed).toBe(false); // Verificar valor por defecto // Recuperar const retrieved = await db.select().from(schema.tasks).where(eq(schema.tasks.id, inserted.id!)).get(); expect(retrieved).toBeDefined(); expect(retrieved).toEqual(inserted); }); it('debería devolver undefined si la tarea no existe', async () => { const retrieved = await db.select().from(schema.tasks).where(eq(schema.tasks.id, 999)).get(); expect(retrieved).toBeUndefined(); }); it('debería actualizar una tarea', async () => { const newTask: NewTask = { title: 'Tarea Inicial' }; const inserted = await db.insert(schema.tasks).values(newTask).returning().get(); // Actualizar const updated = await db.update(schema.tasks) .set({ title: 'Tarea Actualizada', completed: true }) .where(eq(schema.tasks.id, inserted.id!)) .returning() .get(); expect(updated).toBeDefined(); expect(updated.title).toBe('Tarea Actualizada'); expect(updated.completed).toBe(true); // Verificar que se actualizó const retrieved = await db.select().from(schema.tasks).where(eq(schema.tasks.id, inserted.id!)).get(); expect(retrieved?.title).toBe('Tarea Actualizada'); expect(retrieved?.completed).toBe(true); }); // Añadir más tests para delete, listar, etc. });
beforeAll
para obtener la instancia de la DB ybeforeEach
para limpiar la tabla, asegurando que cada prueba se ejecute en un estado limpio. Luego, insertan, recuperan y actualizan datos, verificando los resultados.
Hemos cubierto cómo configurar D1, definir esquemas con Drizzle, manejar migraciones y escribir pruebas de integración para nuestra capa de datos. Ahora tenemos una base de datos funcional y segura en el Edge. En el próximo capítulo, construiremos los endpoints CRUD completos en nuestra API Hono.
< Volver al Índice --- < Capítulo 2: Hono --- Capítulo 4: Arquitectura Hexagonal >