← Volver al listado de tecnologías

Capítulo 3: Persistencia Segura de Datos con D1 y Drizzle ORM

Por: Tu Nombre
cloudflareworkersd1drizzle-ormdatabasesqlserverlessedgehonosecuritytestingmigrations

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 archivo wrangler.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 interfaz Env en src/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 directorio src/db y dentro un archivo schema.ts.
    // 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
    Usamos 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 de integer con mode: '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 archivo drizzle.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 y dbName 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 el CREATE TABLE para tasks. 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 archivo src/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 pasando c.env.DB y luego usamos métodos como db.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 archivo test/db.test.ts.
    // 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.
    });
    Estas pruebas usan beforeAll para obtener la instancia de la DB y beforeEach 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 >