ADR 010: Drizzle ORM para acceso a base de datos D1

AceptadoFecha: 2025-03-15

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 TABLE y 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 .sql sin procesar aplicados via wrangler 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.

Drizzle ORM for D1 Drizzle ORM for D1 Positivo Seguridad de tipos completa del esquema al resultado Driver nativo D1 (drizzle-orm/d1) Flujo drizzle-kit generate + migrate Ligero, sin runtime pesado Negativo Nueva dependencia para todos los Workers Curva de aprendizaje del query builder API Cambios de esquema requieren paso de codegen

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

ComandoEntornoProposito
drizzle-kit generateDev / CICompara el esquema contra las migraciones existentes y genera nuevos archivos de migracion .sql
drizzle-kit migrateStaging / ProductionAplica migraciones pendientes en orden
drizzle-kit pushDev localEnvía el esquema directamente a D1 local (iteracion rapida, omite archivos de migracion)
drizzle-kit studioDev localAbre un navegador visual para la base de datos D1 local
drizzle-kit generate --checkCI (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-schema define todas las tablas una sola vez. Los Workers importan objetos de esquema en lugar de duplicar strings SQL.
  • Migraciones automatizadas: drizzle-kit generate compara 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/d1 envuelve 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-orm y @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 generate antes de hacer commit. CI lo refuerza con drizzle-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