effidevFlutter · Edge de Cloudflare · Optimización de costes en la nube
Español

Drizzle ORM + Cloudflare D1: Bundle 99% menor que Prisma

Diagrama de arquitectura SQLite en el borde con Drizzle ORM y Cloudflare D1

Por qué los ORM son un problema en el edge

¿Ha experimentado un tiempo de Cold Start absurdamente lento al usar Prisma en Cloudflare Workers? La razón es sencilla: el tamaño del bundle de Prisma es de 1.6 MB. Aunque el límite del tamaño de script de Workers es de 10 MB, al incluir un ORM de 1.6 MB se evaporan cientos de milisegundos durante el Cold Start.

Drizzle ORM está diseñado con una filosofía completamente opuesta. Su tamaño de bundle es de 12.2 KB (min+gzip), un 99.2% más pequeño en comparación con Prisma. No requiere una etapa de generación de código ni incluye un motor Rust. Envuelve SQL en TypeScript puro, por lo que funciona de manera ideal en runtimes de edge.

En este artículo se explica paso a paso cómo construir una arquitectura de base de datos en el edge completamente segura en tipos combinando Drizzle ORM y Cloudflare D1. Crearemos una API REST con Hono, gestionaremos las migraciones con drizzle-kit y configuraremos el entorno de desarrollo local.

Drizzle ORM vs Prisma: Comparación en entornos edge

Al comparar ambos ORM en un entorno de producción real, las diferencias son evidentes.

Característica Drizzle ORM Prisma 7+
Tamaño de bundle (min+gzip) 12.2 KB 1.6 MB
Impacto en Cold Start Mínimo (~50-100ms) Bajo (~80-150ms)
Soporte para edge runtime First-class Desde v7
Paso de generación de código Ninguno Existe (prisma generate)
Método de inferencia de tipos Inferencia directa en TypeScript Cliente generado
Nivel de control SQL Alto (SQL-first) Medio (Abstracción)
Driver oficial de D1 drizzle-orm/d1 Ninguno (Comunidad)
Curva de aprendizaje Media Baja

Es cierto que Prisma 7 eliminó el motor Rust y migró a TypeScript/WASM puro. Sin embargo, sigue existiendo una diferencia de 130 veces en el tamaño del bundle. En entornos como Workers, donde puede ocurrir un Cold Start en cada petición, esta diferencia es crítica.

Qué es Cloudflare D1

D1 es la base de datos SQLite serverless de Cloudflare. Se accede directamente a través de bindings de Workers, por lo que no existe overhead de conexión TCP adicional. Admite réplicas de lectura globales y permite hasta 5 millones de consultas diarias en su plan gratuito.

Petición de Cloudflare Workers


  D1 Binding (env.DB)
        │ (IPC dentro del mismo centro de datos)

  Base de datos SQLite
  (Instancia serverless de D1)

La clave radica en acceder a D1 mediante un binding sin peticiones HTTP. La latencia se reduce a aproximadamente una décima parte en comparación con la conexión a una base de datos externa.

Configuración inicial del proyecto

1. Scaffolding de Hono + Drizzle + D1

# Crear plantilla de Hono Worker
npm create cloudflare@latest my-d1-app -- --template hono

cd my-d1-app

# Instalación de Drizzle
npm install drizzle-orm
npm install -D drizzle-kit

2. Creación de la base de datos D1

# Crear base de datos D1
npx wrangler d1 create my-app-db

# Ejemplo de salida:
# [[d1_databases]]
# binding = "DB"
# database_name = "my-app-db"
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

3. Configuración de wrangler.jsonc

// wrangler.jsonc
{
  "name": "my-d1-app",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-app-db",
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      "migrations_dir": "drizzle/migrations"
    }
  ]
}

4. Configuración de drizzle.config.ts

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "sqlite",
  schema: "./src/db/schema.ts",
  out: "./drizzle/migrations",
  driver: "d1-http",
  dbCredentials: {
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
    databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
    token: process.env.CLOUDFLARE_D1_TOKEN!,
  },
});

Consejo para desarrollo local: En local se utiliza wrangler d1 migrations apply, por lo que las propiedades driver y dbCredentials en drizzle.config.ts son exclusivamente para migraciones remotas en producción.

Definición del esquema: TypeScript-First

La principal fortaleza de Drizzle es que la definición del esquema y la inferencia de tipos se unifican en un solo paso. No se requiere una etapa independiente como prisma generate.

// src/db/schema.ts
import {
  sqliteTable,
  text,
  integer,
  real,
  blob,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";

// Tabla de usuarios
export const users = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  email: text("email").notNull().unique(),
  name: text("name").notNull(),
  role: text("role", { enum: ["admin", "user", "guest"] })
    .notNull()
    .default("user"),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(datetime('now'))`),
  updatedAt: text("updated_at")
    .notNull()
    .default(sql`(datetime('now'))`),
});

// Tabla de publicaciones
export const posts = sqliteTable("posts", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  slug: text("slug").notNull().unique(),
  content: text("content").notNull(),
  authorId: integer("author_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  published: integer("published", { mode: "boolean" }).default(false),
  viewCount: integer("view_count").default(0),
  publishedAt: text("published_at"),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(datetime('now'))`),
});

// Tabla de etiquetas (relación M:N)
export const tags = sqliteTable("tags", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull().unique(),
  slug: text("slug").notNull().unique(),
});

export const postTags = sqliteTable("post_tags", {
  postId: integer("post_id")
    .notNull()
    .references(() => posts.id, { onDelete: "cascade" }),
  tagId: integer("tag_id")
    .notNull()
    .references(() => tags.id, { onDelete: "cascade" }),
});

// Inferencia de tipos — Lista para usar de inmediato sin generación de código
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type Tag = typeof tags.$inferSelect;

Diferencia decisiva con Prisma: No es necesario ejecutar prisma generate. El propio archivo del esquema actúa como la fuente de verdad para los tipos de TypeScript. El autocompletado en el IDE funciona al instante.

Flujo de trabajo de migraciones

Generación de archivos de migración SQL

npx drizzle-kit generate

Este comando genera un archivo SQL en la carpeta drizzle/migrations/:

-- drizzle/migrations/0000_initial.sql
CREATE TABLE `users` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `email` text NOT NULL,
  `name` text NOT NULL,
  `role` text DEFAULT 'user' NOT NULL,
  `created_at` text DEFAULT (datetime('now')) NOT NULL,
  `updated_at` text DEFAULT (datetime('now')) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
--> statement-breakpoint
CREATE TABLE `posts` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `title` text NOT NULL,
  `slug` text NOT NULL,
  `content` text NOT NULL,
  `author_id` integer NOT NULL,
  `published` integer DEFAULT false,
  `view_count` integer DEFAULT 0,
  `published_at` text,
  `created_at` text DEFAULT (datetime('now')) NOT NULL,
  FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

Aplicación de migraciones

# Aplicar en D1 local (desarrollo)
npx wrangler d1 migrations apply DB --local

# Aplicar en D1 de producción
npx wrangler d1 migrations apply DB --remote

Wrangler gestiona el historial de aplicaciones a través de la tabla d1_migrations, eliminando cualquier riesgo de aplicación duplicada.

Ejemplo de modificación del esquema

# Generar nueva migración tras modificar el esquema
npx drizzle-kit generate

# Verificar la migración generada y aplicarla
npx wrangler d1 migrations apply DB --local

Integración de Workers + Hono

Inicialización del cliente DB

// src/db/client.ts
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";

export type Env = {
  DB: D1Database;
};

export function createDb(d1: D1Database) {
  return drizzle(d1, { schema });
}

export type DrizzleDb = ReturnType<typeof createDb>;

Configuración de la aplicación Hono

// src/index.ts
import { Hono } from "hono";
import { createDb, type Env } from "./db/client";
import { usersRouter } from "./routes/users";
import { postsRouter } from "./routes/posts";

const app = new Hono<{ Bindings: Env }>();

// Middleware para DB
app.use("*", async (c, next) => {
  c.set("db", createDb(c.env.DB));
  await next();
});

app.route("/api/users", usersRouter);
app.route("/api/posts", postsRouter);

app.get("/health", (c) => c.json({ status: "ok" }));

export default app;

Enrutador de usuarios: CRUD completo

// src/routes/users.ts
import { Hono } from "hono";
import { eq, like, desc, count } from "drizzle-orm";
import { users, posts, type NewUser } from "../db/schema";
import type { DrizzleDb, Env } from "../db/client";

const usersRouter = new Hono<{
  Bindings: Env;
  Variables: { db: DrizzleDb };
}>();

// GET /api/users — Paginación + Búsqueda
usersRouter.get("/", async (c) => {
  const db = c.get("db");
  const { page = "1", limit = "20", q } = c.req.query();

  const pageNum = Math.max(1, parseInt(page));
  const limitNum = Math.min(100, parseInt(limit));
  const offset = (pageNum - 1) * limitNum;

  // Construcción dinámica de la condición WHERE
  const whereClause = q ? like(users.name, `%${q}%`) : undefined;

  const [userList, totalResult] = await Promise.all([
    db
      .select()
      .from(users)
      .where(whereClause)
      .orderBy(desc(users.createdAt))
      .limit(limitNum)
      .offset(offset)
      .all(),
    db
      .select({ count: count() })
      .from(users)
      .where(whereClause)
      .get(),
  ]);

  return c.json({
    data: userList,
    pagination: {
      page: pageNum,
      limit: limitNum,
      total: totalResult?.count ?? 0,
      hasNext: offset + limitNum < (totalResult?.count ?? 0),
    },
  });
});

// GET /api/users/:id — Consulta de usuario + número de publicaciones
usersRouter.get("/:id", async (c) => {
  const db = c.get("db");
  const id = parseInt(c.req.param("id"));

  const user = await db
    .select({
      id: users.id,
      email: users.email,
      name: users.name,
      role: users.role,
      createdAt: users.createdAt,
      postCount: count(posts.id),
    })
    .from(users)
    .leftJoin(posts, eq(posts.authorId, users.id))
    .where(eq(users.id, id))
    .groupBy(users.id)
    .get();

  if (!user) {
    return c.json({ error: "User not found" }, 404);
  }

  return c.json(user);
});

// POST /api/users — Creación de usuario
usersRouter.post("/", async (c) => {
  const db = c.get("db");
  const body = await c.req.json<NewUser>();

  // Verificación de correo duplicado
  const existing = await db
    .select({ id: users.id })
    .from(users)
    .where(eq(users.email, body.email))
    .get();

  if (existing) {
    return c.json({ error: "Email already exists" }, 409);
  }

  const [newUser] = await db
    .insert(users)
    .values({
      email: body.email,
      name: body.name,
      role: body.role ?? "user",
    })
    .returning();

  return c.json(newUser, 201);
});

// PATCH /api/users/:id — Actualización parcial
usersRouter.patch("/:id", async (c) => {
  const db = c.get("db");
  const id = parseInt(c.req.param("id"));
  const body = await c.req.json<Partial<NewUser>>();

  const [updated] = await db
    .update(users)
    .set({
      ...body,
      updatedAt: new Date().toISOString(),
    })
    .where(eq(users.id, id))
    .returning();

  if (!updated) {
    return c.json({ error: "User not found" }, 404);
  }

  return c.json(updated);
});

// DELETE /api/users/:id
usersRouter.delete("/:id", async (c) => {
  const db = c.get("db");
  const id = parseInt(c.req.param("id"));

  const [deleted] = await db
    .delete(users)
    .where(eq(users.id, id))
    .returning({ id: users.id });

  if (!deleted) {
    return c.json({ error: "User not found" }, 404);
  }

  return c.json({ message: "Deleted", id: deleted.id });
});

export { usersRouter };

Consultas avanzadas: JOIN de datos relacionados

// src/routes/posts.ts — JOIN de datos relacionados
import { Hono } from "hono";
import { eq, and, desc, inArray } from "drizzle-orm";
import { posts, users, tags, postTags } from "../db/schema";
import type { DrizzleDb, Env } from "../db/client";

const postsRouter = new Hono<{
  Bindings: Env;
  Variables: { db: DrizzleDb };
}>();

// GET /api/posts/:slug — Detalle de publicación (incluye usuario + etiquetas)
postsRouter.get("/:slug", async (c) => {
  const db = c.get("db");
  const slug = c.req.param("slug");

  // 1. Consulta de publicación + autor
  const post = await db
    .select({
      id: posts.id,
      title: posts.title,
      slug: posts.slug,
      content: posts.content,
      published: posts.published,
      viewCount: posts.viewCount,
      publishedAt: posts.publishedAt,
      author: {
        id: users.id,
        name: users.name,
        email: users.email,
      },
    })
    .from(posts)
    .innerJoin(users, eq(posts.authorId, users.id))
    .where(and(eq(posts.slug, slug), eq(posts.published, true)))
    .get();

  if (!post) {
    return c.json({ error: "Post not found" }, 404);
  }

  // 2. Consulta de etiquetas (consulta independiente: dos consultas simples son más rápidas que una subconsulta compleja en D1)
  const postTagList = await db
    .select({ name: tags.name, slug: tags.slug })
    .from(tags)
    .innerJoin(postTags, eq(postTags.tagId, tags.id))
    .where(eq(postTags.postId, post.id))
    .all();

  // 3. Incremento del contador de visitas (fire-and-forget)
  c.executionCtx.waitUntil(
    db
      .update(posts)
      .set({ viewCount: post.viewCount + 1 })
      .where(eq(posts.id, post.id))
      .run()
  );

  return c.json({ ...post, tags: postTagList });
});

export { postsRouter };

El incremento del contador de visitas mediante c.executionCtx.waitUntil() es un patrón fundamental en Workers. Permite ejecutar actualizaciones en la base de datos en segundo plano incluso después de haber devuelto la respuesta.

Desarrollo local: Integración de D1 con SQLite local

# Iniciar servidor de desarrollo local
npx wrangler dev

# Aplicar migraciones en una terminal independiente
npx wrangler d1 migrations apply DB --local

Wrangler almacena el estado local de D1 como un archivo SQLite en la carpeta .wrangler/state/v3/d1/. Los datos persisten incluso al reiniciar el servidor de desarrollo.

Consulta directa a la base de datos local:

npx wrangler d1 execute DB --local --command "SELECT * FROM users LIMIT 5;"

Inserción de datos semilla en local:

npx wrangler d1 execute DB --local --file ./drizzle/seed.sql

Transacciones y procesamiento por lotes (Batch)

Aunque D1 admite transacciones al ser un SQLite serverless, su uso a través de Drizzle resulta aún más seguro.

// Transacción: Creación simultánea de usuario + publicación
async function createUserWithPost(
  db: DrizzleDb,
  userData: NewUser,
  postData: Omit<NewPost, "authorId">
) {
  return await db.transaction(async (tx) => {
    const [user] = await tx
      .insert(users)
      .values(userData)
      .returning();

    const [post] = await tx
      .insert(posts)
      .values({ ...postData, authorId: user.id })
      .returning();

    return { user, post };
  });
}

Optimización de múltiples consultas mediante la API Batch de D1:

// API Batch de D1 — Ejecución de múltiples consultas en 1 solo viaje de red
async function batchInsertPosts(db: DrizzleDb, postsData: NewPost[]) {
  // Helper batch de Drizzle (específico para D1)
  const statements = postsData.map((post) =>
    db.insert(posts).values(post)
  );

  // Procesamiento por lotes completo en una única petición HTTP
  await db.batch(statements);
}

db.batch() ejecuta múltiples consultas realizando únicamente 1 llamada a la API HTTP de D1. Ejecutar 1000 instrucciones INSERT de forma individual requeriría 1000 viajes de red, mientras que procesarlas por lotes requiere solo 1.

Optimización de costes: Operar dentro de los límites del plan gratuito de D1

Límites del plan gratuito de D1 y métodos de optimización con Drizzle:

Concepto Límite gratuito De pago (Workers Paid)
Peticiones de lectura 5 millones/día Ilimitado
Peticiones de escritura 100 mil/día Ilimitado
Capacidad de almacenamiento 5 GB 25 GB
Número de bases de datos 10 Ilimitado

Patrones de Drizzle para reducir el número de consultas:

// ❌ Problema de consultas N+1
const postList = await db.select().from(posts).all();
for (const post of postList) {
  const author = await db
    .select()
    .from(users)
    .where(eq(users.id, post.authorId))
    .get(); // Se genera una consulta por cada publicación
}

// ✅ Resuelto en 1 sola consulta con JOIN
const postListWithAuthor = await db
  .select({
    postId: posts.id,
    title: posts.title,
    authorName: users.name,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .all();
// ✅ Datos leídos con frecuencia → Reducción de peticiones de lectura en D1 mediante caché en KV
export type CacheEnv = {
  DB: D1Database;
  KV: KVNamespace;
};

async function getPostWithCache(
  db: DrizzleDb,
  kv: KVNamespace,
  slug: string
) {
  const cacheKey = `post:${slug}`;

  // Cache hit
  const cached = await kv.get(cacheKey, "json");
  if (cached) return cached;

  // Cache miss → Consulta a D1 y posterior almacenamiento en caché
  const post = await db
    .select()
    .from(posts)
    .where(eq(posts.slug, slug))
    .get();

  if (post) {
    // Almacenamiento en caché con TTL de 5 minutos
    await kv.put(cacheKey, JSON.stringify(post), { expirationTtl: 300 });
  }

  return post;
}

El coste unitario de las peticiones de lectura en D1 es de $0.001 por cada millón de consultas. Al almacenar en caché los datos leídos frecuentemente con KV, se pueden reducir los costes de D1 en más de un 90%.

Cliente API con seguridad de tipos: Combinación con Hono RPC

Al propagar los tipos de Drizzle hasta el frontend mediante Hono RPC, se logra una auténtica seguridad de tipos End-to-End.

// src/index.ts — Exportación de tipos de Hono RPC
import { Hono } from "hono";
import type { User, Post } from "./db/schema";

const app = new Hono<{ Bindings: Env }>();

const routes = app
  .get("/api/users", async (c) => {
    // ... Implementación
    return c.json({ data: [] as User[] });
  })
  .post("/api/users", async (c) => {
    // ... Implementación
    return c.json({} as User, 201);
  });

export type AppType = typeof routes;
export default app;
// Frontend (React / Next.js)
import { hc } from "hono/client";
import type { AppType } from "../worker/src/index";

const client = hc<AppType>("https://my-app.workers.dev");

// Inferencia de tipos completa — Se extiende desde el esquema D1 hasta el frontend
const response = await client.api.users.$get();
const { data } = await response.json();
// data es de tipo User[] — Autocompletado del IDE funcionando al 100%

Los tipos se propagan automáticamente: Definición del esquema → Tipos de Drizzle → Respuesta de Hono → Cliente RPC. Se implementa una seguridad de tipos End-to-End utilizando únicamente TypeScript puro, sin necesidad de prisma generate, configuraciones de tRPC ni esquemas de GraphQL.

CI/CD: Pipeline de despliegue con GitHub Actions

# .github/workflows/deploy.yml
name: Deploy to Cloudflare Workers

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Verificación de tipos TypeScript
        run: npx tsc --noEmit

      - name: Aplicación de migraciones D1 (producción)
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: npx wrangler d1 migrations apply DB --remote

      - name: Despliegue en Workers
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
        run: npx wrangler deploy

El orden entre migración y despliegue es fundamental. Es necesario aplicar las migraciones primero para que el nuevo código de Workers pueda hacer referencia al nuevo esquema.

Drizzle Studio: Visualización de la base de datos sin GUI

# Ejecutar Drizzle Studio conectado a D1 local
npx drizzle-kit studio

Al abrir https://local.drizzle.studio en el navegador, se pueden consultar y editar los datos de D1 mediante una interfaz gráfica. Para quienes estén familiarizados con Prisma Studio, la experiencia es idéntica.

Benchmark de rendimiento: Latencia real de D1

Valores medidos reales al utilizar D1 en Workers (según la documentación oficial de Cloudflare):

Tipo de consulta Latencia media
SELECT simple (índice) 1-5ms
Consulta JOIN 5-15ms
INSERT 5-10ms
Consulta de agregación compleja 10-50ms
INSERT por lotes (100 registros) 10-20ms

En comparación con PostgreSQL externo (Neon, Supabase, etc.), las llamadas a los bindings de D1 dentro de Workers se realizan sin conexiones TCP, lo que resulta en una latencia entre 3 y 5 veces menor. Si el objetivo es un TTFB inferior a 100ms, D1 es la mejor opción.

Criterios de elección: D1 vs otras bases de datos en el edge

Escenario Recomendación
Stack todo en uno en Cloudflare D1 + Drizzle
Multicloud (Uso mixto de Vercel + CF) Turso + Drizzle
Necesidad de funciones de PostgreSQL (JSONB, Full-text) Neon + Drizzle
Necesidad de sincronización en tiempo real Supabase + Drizzle
Consultas analíticas complejas PostgreSQL externo

Si trabaja exclusivamente dentro del ecosistema de Cloudflare, D1 es sin duda la mejor alternativa. Si necesita capacidad multicloud, Turso (basado en libSQL) ofrece portabilidad utilizando la misma API de Drizzle que D1.

Lista de verificación para la migración

Aspectos a comprobar al migrar de Prisma a Drizzle:

Aunque la transición pueda parecer laboriosa al principio, una vez que se adquiere familiaridad con Drizzle, el control directo sobre el SQL resulta mucho más intuitivo. Responde a la filosofía de que el ORM no debe ocultar el SQL, sino ser una herramienta para expresarlo a través de TypeScript.

Conclusión

La combinación de Drizzle ORM + Cloudflare D1 constituye una de las opciones más eficientes para el desarrollo full-stack en el edge en 2026.

Si el bundle de 1.6 MB de Prisma ha representado un inconveniente en el entorno de Workers, la migración a Drizzle no concierne únicamente al tamaño del bundle. Se trata también de una transición filosófica: dejar de emplear el ORM como una caja mágica para comenzar a expresar SQL mediante TypeScript.

Artículo relacionado: En Hono RPC y Cloudflare Workers: Seguridad de tipos full-stack con TanStack Query v5 puede consultar un ejemplo full-stack completo que combina la API de Drizzle con Hono RPC.