Saltar al contenido principal

Cómo usar Drizzle ORM con Capacitor y SQLite

Configura Drizzle ORM con Capacitor y SQLite: un driver sqlite-proxy funcional, esquema tipado, queries relacionales, transacciones y migraciones de Drizzle Kit en la app.

Logotipo de Capacitor para una guía sobre el uso de Drizzle ORM con Capacitor y SQLite

Drizzle ORM funciona en una app de Capacitor a través de su sqlite-proxy driver: le pasas a Drizzle una función asíncrona que ejecuta SQL en un plugin nativo de SQLite y devuelve las filas como arrays, y Drizzle se encarga del builder de consultas tipadas, las consultas relacionales y las transacciones encima. Esta guía construye esa función para @capacitor-community/sqlite, muestra cómo incluir las migraciones de Drizzle Kit dentro del app bundle y enumera los casos límite que pueden corromper los resultados si se pasan por alto.

Todo lo siguiente se probó con drizzle-orm 0.45 y drizzle-kit 0.31 en Capacitor 8.

Por qué usar Drizzle en una base de datos móvil

  • Schema en TypeScript. Las tablas son objetos TypeScript simples. Los tipos para selects e inserts se infieren, sin paso de generación de code.
  • API con estructura SQL. db.select().from(users).where(eq(users.id, 1)) se mapea uno a uno a SQL, así puedes entender qué se ejecuta en el dispositivo.
  • Migraciones generadas desde el schema. Drizzle Kit compara tu schema y escribe archivos de migración SQL.
  • Sin decoradores. Funciona con Vite y esbuild sin flags adicionales de TypeScript.

El contratiempo: Drizzle no incluye un driver de Capacitor en su paquete, así que tienes que escribir el puente tú mismo. Son unas 30 líneas.

Elegir el plugin de SQLite

El sqlite-proxy el driver mapea las filas del resultado por posición: para all y values espera un array de arrays, para get un solo array, en el mismo orden que las columnas en el SELECTgenerado. Los plugins de SQLite de Capacitor devuelven objetos con claves por nombre de columna, por lo que el driver debe convertir cada objeto de nuevo en un array. Esto solo funciona si las claves del objeto mantienen el orden de las columnas.

Plugin Formato de fila Orden de columnas preservado Apto para el proxy de Drizzle
@capacitor-community/sqlite Objetos Sí. En iOS, el plugin envía un ios_columns lista y la capa JS reconstruye cada fila en orden de columnas Bueno
@capgo/capacitor-fast-sql Objetos No garantizado en iOS, las filas se serializan desde un diccionario Swift Usa Kysely en su lugar

Por eso esta guía usa el plugin de la comunidad. Si ya usas Fast SQL, o quieres su ruta de datos más rápida, Kysely lee las filas por nombre de columna y funciona directamente con él. Para un resumen de ambos plugins, consulta Cómo usar SQLite en una app de Capacitor.

Instalar

bun add drizzle-orm @capacitor-community/sqlite
bun add -d drizzle-kit
bunx cap sync

@capacitor-community/sqlite incluye SQLCipher en iOS y Android, por lo que tu app incluye cifrado code aunque no cifres nada. Tenlo en cuenta para las preguntas de cumplimiento de exportación de la App Store.

Definir el esquema

// src/db/schema.ts
import { relations } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .$defaultFn(() => new Date()),
});

export const posts = sqliteTable(
  'posts',
  {
    id: integer('id').primaryKey({ autoIncrement: true }),
    authorId: integer('author_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    title: text('title').notNull(),
    published: integer('published', { mode: 'boolean' }).notNull().default(false),
  },
  (t) => [index('idx_posts_author').on(t.authorId)],
);

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));

Hay algunos detalles importantes en móvil:

  • mode: 'timestamp' almacena los segundos desde epoch como un entero y te da Date objetos en TypeScript.
  • $defaultFn(() => new Date()) establece el valor por defecto en JavaScript. Un valor por defecto SQL como unixepoch() requiere SQLite 3.38 o superior, que el SQLite del sistema en Android antiguo no tiene.
  • mode: 'boolean' almacena 1/0. Drizzle convierte en ambas direcciones.
  • relations() es lo que impulsa db.query.* con with: {...}. Las claves foráneas por sí solas no son suficientes para consultas relacionales.

Abrir la conexión

El plugin de la comunidad mantiene un registro de conexiones. Durante el live reload o el reemplazo de módulos en caliente, tu code puede ejecutarse dos veces, así que reutiliza una conexión existente en lugar de crear una segunda.

// src/db/connection.ts
import {
  CapacitorSQLite,
  SQLiteConnection,
  type SQLiteDBConnection,
} from '@capacitor-community/sqlite';

const sqlite = new SQLiteConnection(CapacitorSQLite);

export async function openConnection(name: string): Promise<SQLiteDBConnection> {
  const consistent = (await sqlite.checkConnectionsConsistency()).result;
  const exists = (await sqlite.isConnection(name, false)).result;

  const conn =
    consistent && exists
      ? await sqlite.retrieveConnection(name, false)
      : await sqlite.createConnection(name, false, 'no-encryption', 1, false);

  await conn.open();
  await conn.execute('PRAGMA foreign_keys = ON', false);
  return conn;
}

El plugin añade SQLite.db al nombre del archivo, por lo que app pasa a ser appSQLite.db en el disco. En la web, el plugin necesita el jeep-sqlite elemento personalizado y sqlite.initWebStore() antes de la primera conexión. Sigue la configuración web del plugin si tu objetivo es el navegador.

El driver sqlite-proxy

Este es el núcleo de la integración. Drizzle llama a la función con el SQL, los parámetros y un method:

  • run: no se necesitan filas (INSERT, UPDATE, DELETE, DDL, control de transacciones).
  • all: todas las filas, como arrays.
  • get: la primera fila, como array, o undefined.
  • values: todas las filas, como arrays (usado por values() llamadas raw y partes internas).
// src/db/client.ts
import type { SQLiteDBConnection } from '@capacitor-community/sqlite';
import { drizzle } from 'drizzle-orm/sqlite-proxy';
import * as schema from './schema';

const READ = /^\s*(select|pragma|with|explain)\b/i;
const TX_CONTROL = /^\s*(begin|commit|rollback|savepoint|release)\b/i;

export function createDb(conn: SQLiteDBConnection) {
  return drizzle(
    async (sql, params, method) => {
      // Transaction control: no params, no rows, no implicit wrapping
      if (TX_CONTROL.test(sql)) {
        await conn.execute(sql, false);
        return { rows: [] };
      }

      let rows: Record<string, unknown>[];
      if (READ.test(sql)) {
        rows = (await conn.query(sql, params)).values ?? [];
      } else {
        // 'all' returns rows for INSERT/UPDATE/DELETE ... RETURNING
        const res = await conn.run(sql, params, false, method === 'run' ? 'no' : 'all');
        rows = res.changes?.values ?? [];
      }

      if (method === 'run') return { rows: [] };

      // Drizzle maps by position. The plugin keeps column order in each object.
      const arrays = rows.map((row) => Object.values(row));
      return { rows: method === 'get' ? (arrays[0] as any) : arrays };
    },
    { schema },
  );
}

export type AppDb = ReturnType<typeof createDb>;

Por qué existe cada rama:

  • TX_CONTROL: Drizzle implementa db.transaction() enviando begin, commit, rollback, y savepoint/release para transacciones anidadas. Pasan por execute(sql, false), la misma ruta que usa el driver oficial Capacitor de TypeORM para BEGIN/COMMIT.
  • false como tercer argumento de run(): el default del plugin envuelve cada sentencia en su propia transacción, lo que choca con las explícitas de Drizzle begin.
  • returnMode 'all': hace que .returning() funcione para inserts, updates y deletes.
  • Object.values(row): convierte la fila con clave de nuevo en un array posicional.

Ejecuté esta función exacta contra un SQLite en memoria que devuelve filas de la misma forma que el plugin. Los inserts con .returning(), selects filtrados, .get(), consultas relacionales, transacciones y rollbacks ante un error lanzado producen resultados correctos.

Inicializa una vez

// src/db/index.ts
import { openConnection } from './connection';
import { createDb, type AppDb } from './client';
import { runMigrations } from './migrate';
import bundle from '../../drizzle/migrations';

let dbPromise: Promise<AppDb> | null = null;

export function getDb(): Promise<AppDb> {
  if (!dbPromise) {
    dbPromise = (async () => {
      const conn = await openConnection('app');
      await runMigrations(conn, bundle);
      return createDb(conn);
    })();
  }
  return dbPromise;
}

Migraciones con Drizzle Kit

La integrada de Drizzle sqlite-proxy migrator integrado de Drizzle lee .sql archivos del disco con fs, algo que un WebView no tiene. La solución es dejar que Drizzle Kit empaquete el SQL en un módulo JavaScript y aplicarlo tú mismo.

1. Configura Drizzle Kit

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

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'sqlite',
  driver: 'expo',
});

driver: 'expo' no significa que necesites Expo. Le indica a Drizzle Kit que también escriba drizzle/migrations.js, que importa el journal y cada .sql archivo. Ese es el formato que necesita cualquier runtime no-Node.

2. Genera

bunx drizzle-kit generate

Obtienes drizzle/0000_<name>.sql, drizzle/meta/_journal.json y drizzle/migrations.js. Commitea los tres. Ejecuta generate de nuevo tras cada cambio de esquema. No edites archivos SQL que ya se hayan publicado.

3. Deja que el bundler importe .sql como texto

migrations.js tiene líneas como import m0000 from './0000_fearless_selene.sql'. Vite no sabe .sql, así que añade un plugin diminuto en lugar de otra dependencia:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    {
      name: 'sql-as-text',
      transform(code, id) {
        if (id.endsWith('.sql')) {
          return { code: `export default ${JSON.stringify(code)};`, map: null };
        }
      },
    },
  ],
});

Y una declaración de tipos para que TypeScript acepte el import:

// src/sql.d.ts
declare module '*.sql' {
  const content: string;
  export default content;
}

Con webpack (custom builders de Angular, configuraciones antiguas) usa una asset/source regla para .sql archivos en su lugar.

4. Aplicar migraciones al inicio

// src/db/migrate.ts
import type { SQLiteDBConnection } from '@capacitor-community/sqlite';

interface MigrationBundle {
  journal: { entries: { idx: number; when: number; tag: string }[] };
  migrations: Record<string, string>;
}

export async function runMigrations(conn: SQLiteDBConnection, bundle: MigrationBundle) {
  await conn.execute(
    `CREATE TABLE IF NOT EXISTS __drizzle_migrations (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       hash TEXT NOT NULL,
       created_at NUMERIC
     )`,
    false,
  );

  const res = await conn.query(
    'SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1',
  );
  const lastApplied = Number(res.values?.[0]?.created_at ?? 0);

  for (const entry of bundle.journal.entries) {
    if (entry.when <= lastApplied) continue;

    const key = `m${entry.idx.toString().padStart(4, '0')}`;
    const sqlText = bundle.migrations[key];
    if (!sqlText) throw new Error(`Missing migration ${entry.tag}`);

    const statements = sqlText
      .split('--> statement-breakpoint')
      .map((s) => s.trim())
      .filter(Boolean);

    await conn.beginTransaction();
    try {
      for (const statement of statements) {
        await conn.execute(statement, false);
      }
      await conn.run(
        'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
        [entry.tag, entry.when],
        false,
      );
      await conn.commitTransaction();
    } catch (error) {
      await conn.rollbackTransaction();
      throw error;
    }
  }
}

Esto sigue la misma regla que usan los propios migradores de Drizzle: una migración está pendiente cuando su timestamp del journal es más reciente que el último registrado. Cada migración se ejecuta en su propia transacción, así que un fallo deja intacto el estado anterior. Hacerlo en cada arranque es barato.

Si distribuyes JavaScript con actualizaciones Live Update de Capgo, las nuevas migraciones llegan a los usuarios sin una release en la store. Mantenlas aditivas (tablas nuevas, nuevas columnas nullable), porque un rollback a un bundle anterior se ejecutará contra el esquema más nuevo.

Consultas

import { and, desc, eq, sql } from 'drizzle-orm';
import { getDb } from './db';
import { posts, users } from './db/schema';

const db = await getDb();

// Insert and get the row back
const [ada] = await db
  .insert(users)
  .values({ name: 'Ada', email: 'ada@example.com' })
  .returning();

// Bulk insert
await db.insert(posts).values([
  { authorId: ada.id, title: 'Draft' },
  { authorId: ada.id, title: 'Live', published: true },
]);

// Select with filters
const live = await db
  .select()
  .from(posts)
  .where(and(eq(posts.authorId, ada.id), eq(posts.published, true)))
  .orderBy(desc(posts.id));

// Single row or undefined
const user = await db.select().from(users).where(eq(users.email, 'ada@example.com')).get();

// Update and delete
await db.update(posts).set({ published: true }).where(eq(posts.id, live[0].id));
await db.delete(posts).where(eq(posts.published, false));

// Aggregate
const [{ count }] = await db
  .select({ count: sql<number>`count(*)` })
  .from(posts);

Consultas relacionales

const authors = await db.query.users.findMany({
  with: { posts: { where: (p, { eq }) => eq(p.published, true) } },
});

const one = await db.query.users.findFirst({
  where: (u, { eq }) => eq(u.id, ada.id),
  with: { posts: true },
});

Drizzle las construye con las funciones JSON de SQLite y luego analiza el resultado. Funcionan en iOS y en versiones recientes de Android. Si tu minSdkVersion es antiguo, comprueba SELECT json('{}') en tu dispositivo de prueba más antiguo.

Joins: alias para nombres de columnas duplicados

Este es el punto crítico de cualquier proxy driver basado en filas de objetos. Drizzle no añade AS aliases a las columnas unidas, por lo que esta consulta:

// Wrong: both columns are named "id" in the result set
await db
  .select({ postId: posts.id, userId: users.id })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id));

genera dos columnas llamadas id. Se fusionan en una sola clave en el objeto de la fila, el array queda con un valor menos y Drizzle asigna los valores a los campos incorrectos. En mi prueba postId devolvió el id de usuario y userId era undefined. No se lanza ningún error.

Asigna los aliases explícitamente:

await db
  .select({
    postId: sql<number>`${posts.id}`.as('post_id'),
    userId: sql<number>`${users.id}`.as('user_id'),
    title: posts.title,
    author: users.name,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id));

Seleccionar columnas con nombres diferentes (posts.title, users.name) está bien. Evita db.select().from(a).innerJoin(b, ...) sin una lista de campos cuando ambas tablas comparten nombres de columnas como id o created_at. Las consultas relacionales no tienen este problema.

Transacciones

await db.transaction(async (tx) => {
  const [user] = await tx
    .insert(users)
    .values({ name: 'Grace', email: 'grace@example.com' })
    .returning();

  await tx.insert(posts).values({ authorId: user.id, title: 'Hello' });

  // Nested transactions become savepoints
  await tx.transaction(async (inner) => {
    await inner.insert(posts).values({ authorId: user.id, title: 'Maybe' });
  });
});

Si el callback lanza una excepción, Drizzle envía rollback. Llamar a tx.rollback() también interrumpe la transacción, lanzando una excepción.

Hay una conexión por base de datos, así que mantén las transacciones cortas y no await llamadas de red dentro de ellas. Otras escrituras en la misma conexión se ejecutarían dentro de tu transacción abierta.

Solución de problemas

Error “Connection already exists” al iniciar. Tu inicialización se ejecutó dos veces, normalmente por el hot reload. Usa el checkConnectionsConsistency y retrieveConnection patrón que se muestra arriba.

cannot start a transaction within a transaction. Una run() se realizó la llamada sin false como tercer argumento, por lo que el plugin abrió su propia transacción dentro de la de Drizzle.

Las migraciones se ejecutan en cada inicio y fallan con table already exists. El __drizzle_migrations el insert falta o se hizo rollback. Verifica que el journal when los valores en tu bundle coincidan con las filas de la tabla.

Error de build Failed to parse source for import analysis en .sql. El plugin de transformación de Vite no está o se ejecuta después de otro plugin que intenta parsear el archivo. Colócalo primero en el plugins array.

.returning() devuelve un array vacío. La escritura se realizó run con el modo return no. Asegúrate de que el driver pase 'all' cuando method no es run.

Las fechas están desfasadas por un factor de 1000. mode: 'timestamp' almacena segundos, mode: 'timestamp_ms' almacena milisegundos. Elige uno por columna y no lo mezcles con raw Date.now() inserts.

Cuándo elegir otra opción

Añadir un plugin nativo implica recompilar las apps de iOS y Android. Si no tienes un Mac a mano, Build de Capgo compila y firma ambas en la nube.

Live updates para apps Capacitor

Cuando un bug de la capa web está en producción, publica el fix a través de Capgo en lugar de esperar días a la aprobación de la app store. Los usuarios reciben la actualización en segundo plano mientras los cambios nativos siguen el flujo de revisión habitual.

Lo último de nuestro blog

Capgo te da la información clave que necesitas para crear una app móvil realmente profesional.