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

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 propiedadesdriverydbCredentialsendrizzle.config.tsson 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:
- Conversión de
schema.prisma→src/db/schema.ts - Eliminación de scripts de
prisma generate - Reemplazo de la instancia de
PrismaClient→drizzle(env.DB) - Conversión del patrón
findManyyfindUniquede Prisma →select().from()de Drizzle - Conversión de
includede Prisma (consultas de relaciones) →innerJoin/leftJoinde Drizzle - Migración de
prisma migrate dev→drizzle-kit generate+wrangler d1 migrations apply
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.
- Tamaño de bundle de 12.2 KB: El impacto en el Cold Start de Workers es prácticamente nulo
- Inferencia de tipos sin generación de código: Configuración completa del sistema de tipos con un único archivo de esquema
- Binding de D1: Acceso con latencia ultra baja mediante IPC sin conexiones a bases de datos externas
- Integración con Hono RPC: Propagación automática de tipos desde el esquema de la base de datos hasta el frontend
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.