ADR 010: Drizzle ORM para acceso a base de datos D1
Contexto
Todo el acceso a bases de datos Cloudflare D1 en la plataforma utiliza actualmente SQL directo via env.DB.prepare(). Aunque funcional, este enfoque tiene inconvenientes significativos:
- Sin seguridad de tipos: Los resultados de las consultas no tienen tipos (
unknown) a menos que se haga un cast manual, lo que facilita la introduccion de errores en tiempo de ejecucion cuando los nombres de columnas cambian o se anaden nuevas columnas. - Sin esquema compartido: Cada Worker duplica sentencias
CREATE TABLEy logica de consultas. Los cambios de esquema requieren actualizar manualmente cada archivo que referencia la tabla afectada. - Migraciones manuales: Las migraciones de base de datos son archivos
.sqlsin procesar aplicados viawrangler d1 execute. No hay comparacion de esquemas, no hay generacion de migraciones, y no hay forma de verificar que el codigo coincide con el esquema actual. - Construccion fragil de consultas: Las consultas dinamicas (por ejemplo, filtros opcionales en
getDeploymentHistory) recurren a concatenacion de strings, lo cual es propenso a errores y dificil de revisar.
La plataforma necesita un ORM ligero que funcione de forma nativa con Cloudflare D1, proporcione inferencia de tipos TypeScript a partir de definiciones de esquema, y soporte la API de transacciones batch de D1 para garantias de atomicidad.
Decisión
Adoptar Drizzle ORM (drizzle-orm@^0.45.1) con drizzle-kit (drizzle-kit@^0.31.9) para todo el acceso a bases de datos D1. Crear un paquete compartido @a2r/db-schema en el monorepo que contenga todas las definiciones de esquema Drizzle, y consumirlo desde cada Worker que acceda a D1.
Definición del esquema
Todas las tablas se definen en el paquete compartido @a2r/db-schema usando el helper sqliteTable de Drizzle. Esta unica fuente de verdad reemplaza todo el SQL CREATE TABLE disperso en documentacion y archivos de migracion.
// packages/db-schema/src/schema.ts
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
// ── version_configs ──────────────────────────────────────────────
export const versionConfigs = sqliteTable(
"version_configs",
{
id: integer("id").primaryKey({ autoIncrement: true }),
environment: text("environment").notNull(), // 'dev' | 'staging' | 'production'
mfeName: text("mfe_name").notNull(),
version: text("version").notNull(),
entryUrl: text("entry_url").notNull(),
integrityHash: text("integrity_hash"),
isActive: integer("is_active", { mode: "boolean" }).default(false),
activatedAt: text("activated_at"),
activatedBy: text("activated_by"),
createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`),
createdBy: text("created_by").notNull(),
},
(table) => [
uniqueIndex("uq_version_configs_env_mfe_version").on(
table.environment,
table.mfeName,
table.version
),
index("idx_version_configs_active")
.on(table.environment, table.mfeName, table.isActive),
index("idx_version_configs_lookup")
.on(table.environment, table.mfeName, table.version),
index("idx_version_configs_history")
.on(table.environment, table.mfeName, table.createdAt),
]
);
// ── deployment_events ────────────────────────────────────────────
export const deploymentEvents = sqliteTable(
"deployment_events",
{
id: integer("id").primaryKey({ autoIncrement: true }),
environment: text("environment").notNull(),
mfeName: text("mfe_name").notNull(),
version: text("version").notNull(),
eventType: text("event_type").notNull(), // 'registered' | 'activated' | 'deactivated' | 'rollback'
metadata: text("metadata"), // JSON blob
createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`),
createdBy: text("created_by").notNull(),
},
(table) => [
index("idx_deployment_events_lookup")
.on(table.environment, table.mfeName, table.createdAt),
index("idx_deployment_events_type")
.on(table.eventType, table.createdAt),
]
);
// ── version_changes (03-cloudflare-infrastructure audit trail) ───
export const versionChanges = sqliteTable(
"version_changes",
{
id: integer("id").primaryKey({ autoIncrement: true }),
mfeName: text("mfe_name").notNull(),
version: text("version").notNull(),
previousVersion: text("previous_version"),
manifestUrl: text("manifest_url").notNull(),
environment: text("environment").notNull(),
changedBy: text("changed_by").notNull(),
reason: text("reason").notNull().default("Manual update"),
timestamp: text("timestamp").notNull().default(sql`(datetime('now'))`),
},
(table) => [
uniqueIndex("uq_version_changes_mfe_ver_env_ts").on(
table.mfeName,
table.version,
table.environment,
table.timestamp
),
index("idx_version_changes_mfe_env")
.on(table.mfeName, table.environment, table.timestamp),
index("idx_version_changes_env_timestamp")
.on(table.environment, table.timestamp),
index("idx_version_changes_changed_by")
.on(table.changedBy, table.timestamp),
]
);
Patrones de consulta — Antes y después
Verificacion de duplicados (SQL directo):
const existing = await env.DB.prepare(
'SELECT id FROM version_configs WHERE environment = ? AND mfe_name = ? AND version = ?'
)
.bind(body.environment, body.mfeName, body.version)
.first();
Verificacion de duplicados (Drizzle):
import { eq, and } from "drizzle-orm";
import { versionConfigs } from "@a2r/db-schema";
const existing = await db
.select({ id: versionConfigs.id })
.from(versionConfigs)
.where(
and(
eq(versionConfigs.environment, body.environment),
eq(versionConfigs.mfeName, body.mfeName),
eq(versionConfigs.version, body.version)
)
)
.get();
Insercion (SQL directo):
await env.DB.prepare(
`INSERT INTO version_configs (environment, mfe_name, version, entry_url, integrity_hash, created_by)
VALUES (?, ?, ?, ?, ?, ?)`
)
.bind(body.environment, body.mfeName, body.version, body.entryUrl, body.integrityHash ?? null, body.createdBy)
.run();
Insercion (Drizzle):
const result = await db.insert(versionConfigs).values({
environment: body.environment,
mfeName: body.mfeName,
version: body.version,
entryUrl: body.entryUrl,
integrityHash: body.integrityHash ?? null,
createdBy: body.createdBy,
});
Transaccion batch D1 (SQL directo):
const stmtDeactivate = env.DB.prepare(
`UPDATE version_configs SET is_active = false WHERE environment = ? AND mfe_name = ? AND is_active = true`
).bind(body.environment, body.mfeName);
const stmtActivate = env.DB.prepare(
`UPDATE version_configs SET is_active = true, activated_at = datetime('now'), activated_by = ? WHERE id = ?`
).bind(body.activatedBy, targetVersion.id);
await env.DB.batch([stmtDeactivate, stmtActivate, stmtEvent]);
Transaccion batch D1 (Drizzle):
import { eq, and } from "drizzle-orm";
import { versionConfigs, deploymentEvents } from "@a2r/db-schema";
await db.batch([
db.update(versionConfigs)
.set({ isActive: false })
.where(
and(
eq(versionConfigs.environment, body.environment),
eq(versionConfigs.mfeName, body.mfeName),
eq(versionConfigs.isActive, true)
)
),
db.update(versionConfigs)
.set({
isActive: true,
activatedAt: sql`datetime('now')`,
activatedBy: body.activatedBy,
})
.where(eq(versionConfigs.id, targetVersion.id)),
db.insert(deploymentEvents).values({
environment: body.environment,
mfeName: body.mfeName,
version: body.version,
eventType: eventType,
metadata: JSON.stringify({ previousVersion: targetVersion.version }),
createdBy: body.activatedBy,
}),
]);
Configuración de drizzle-kit
// drizzle.config.ts (at monorepo root or in each Worker)
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./node_modules/@a2r/db-schema/src/schema.ts",
out: "./migrations",
dialect: "sqlite",
driver: "d1-http",
});
Flujo de trabajo de migraciones
| Comando | Entorno | Proposito |
|---|---|---|
drizzle-kit generate | Dev / CI | Compara el esquema contra las migraciones existentes y genera nuevos archivos de migracion .sql |
drizzle-kit migrate | Staging / Production | Aplica migraciones pendientes en orden |
drizzle-kit push | Dev local | Envía el esquema directamente a D1 local (iteracion rapida, omite archivos de migracion) |
drizzle-kit studio | Dev local | Abre un navegador visual para la base de datos D1 local |
drizzle-kit generate --check | CI (PR checks) | Verifica que las migraciones estan actualizadas con el esquema (falla si se necesita una nueva migracion pero no esta commiteada) |
Transacciones batch D1
Drizzle preserva la API batch() de D1 para operaciones atomicas multi-sentencia. El metodo db.batch([...]) se mapea directamente a env.DB.batch() de D1, ejecutando todas las sentencias en una unica transaccion. Si alguna sentencia falla, todo el batch se revierte.
Consecuencias
Positivas
- Consultas con seguridad de tipos: Los nombres de columnas, tipos y relaciones se infieren del esquema. TypeScript detecta inconsistencias en tiempo de compilacion.
- Fuente unica de verdad: El paquete
@a2r/db-schemadefine todas las tablas una sola vez. Los Workers importan objetos de esquema en lugar de duplicar strings SQL. - Migraciones automatizadas:
drizzle-kit generatecompara el esquema TypeScript contra la ultima migracion y genera archivos SQL incrementales. No mas scripts de migracion escritos a mano. - Runtime ligero: Drizzle no tiene dependencias de runtime pesadas. El driver
drizzle-orm/d1envuelve el binding nativo de D1 sin overhead adicional mas alla de la construccion de consultas. - Nativo para D1: Drizzle soporta la API de transacciones batch de D1 (
db.batch()), D1 Time Travel, y la API HTTP de D1 para desarrollo remoto. - Drizzle Studio: Proporciona un navegador visual de base de datos para desarrollo local, eliminando la necesidad de comandos manuales de inspeccion con
wrangler d1 execute.
Negativas
- Nueva dependencia: Cada Worker que accede a D1 ahora depende de
drizzle-ormy@a2r/db-schema. Esto incrementa la superficie de dependencias y el tamano del bundle ligeramente. - Paso de codegen: Los cambios de esquema requieren ejecutar
drizzle-kit generateantes de hacer commit. CI lo refuerza condrizzle-kit generate --check, pero anade un paso al flujo de trabajo de desarrollo. - Curva de aprendizaje: Los desarrolladores familiarizados con SQL directo necesitan aprender la API del query builder de Drizzle. Sin embargo, la API de Drizzle refleja de cerca la sintaxis SQL, minimizando la brecha de aprendizaje.
Alternativas consideradas
SQL directo (enfoque actual)
El enfoque actual de usar env.DB.prepare() con strings de SQL directo. Cero dependencias y control total, pero sin seguridad de tipos, sin esquema compartido, migraciones manuales y construccion fragil de consultas dinamicas.
Kysely
Un query builder SQL con seguridad de tipos para TypeScript. Excelente inferencia de tipos, pero sin adaptador nativo para Cloudflare D1. Requeriria un wrapper de driver personalizado y no proporciona definicion de esquema ni herramientas de migracion comparables a drizzle-kit.
Prisma
ORM rico en funcionalidades con diseno schema-first y un potente sistema de migraciones. Sin embargo, Prisma no tiene soporte nativo para D1 (requiere el proxy Prisma Accelerate o un adaptador D1 experimental), incluye un motor de consultas pesado basado en Rust que anade un tamano de bundle significativo, y su modelo de runtime no esta optimizado para el entorno de ejecucion de Cloudflare Workers (V8 isolates con limites estrictos de tiempo de CPU).
Documentación Relacionada
- Vision General de Arquitectura — Stack tecnologico y contexto del sistema
- Infraestructura Cloudflare — Esquema D1, Version Config Service, patrones de consulta comunes
- Desarrollo Local — Comandos de drizzle-kit para gestion de base de datos local
- Estrategia de Repositorio — Paquete compartido
@a2r/db-schemaen el monorepo - Gestion de Versiones — Consultas de registro, activacion y promocion de versiones
- CI/CD y Despliegue — Pipeline de migraciones y verificacion de esquema