Drizzle ORM se ejecuta en una aplicación Capacitor a través de su sqlite-proxy driver: le das a Drizzle una función asíncrona que ejecuta SQL en un plugin de SQLite nativo y devuelve filas como matrices, y Drizzle maneja el constructor de consultas de tipo, consultas relacionales y transacciones encima. Este guía construye esa función para @capacitor-community/sqlite, muestra cómo enviar migraciones de Drizzle Kit dentro del paquete de la aplicación, y enumera los casos de borde que corrompen silenciosamente los resultados si los omites.
Todo lo que se muestra a continuación se probó con drizzle-orm 0.45 y drizzle-kit 0.31 en Capacitor 8.
¿Por qué Drizzle en una base de datos móvil
- Esquema en TypeScript. Las tablas son objetos de TypeScript puros. Los tipos para select y insert se infieren, no hay paso de generación de code.
- API SQL-estilizado.
db.select().from(users).where(eq(users.id, 1))Se mapea uno a uno a SQL, por lo que puedes razonar sobre qué se ejecuta en el dispositivo. - Migraciones generadas a partir del esquema. El Kit de Drizzle difiere tu esquema y escribe archivos de migración SQL.
- No hay decoradores. Funciona con Vite y esbuild sin flags adicionales de TypeScript.
El trueque: Drizzle no tiene un Capacitor driver en su paquete, por lo que escribes el puente tú mismo. Es de aproximadamente 30 líneas.
Seleccionar el plugin de SQLite
Drizzle’s sqlite-proxy El driver mapea filas de resultados por posición: para all y values Espera un array de arrays, para get un array único, en el mismo orden que las columnas en la generada SELECT. Los plugins de SQLite Capacitor devuelven objetos clave-valor, por lo que el driver tiene que volver a cada objeto a un array. Eso solo funciona cuando las claves de los objetos mantienen el orden de las columnas.
| Plugin | Formato de fila | Orden de columna preservada | Aptitud para el proxy de Drizzle |
|---|---|---|---|
@capacitor-community/sqlite |
Objetos | Sí. En iOS, el plugin envía un ios_columns La lista y la capa de JS reconstruyen cada fila en orden de columna |
Good |
@capgo/capacitor-fast-sql |
Objetos | No está garantizado en iOS, las filas se serializan desde un diccionario de Swift. | Utilice Kysely en su lugar |
Por eso, esta guía utiliza el plugin de la comunidad. Si ya usas Fast SQL, o deseas su ruta de datos más rápida, Kysely lee filas por nombre de columna y trabaja con ella directamente. Para obtener una visión general de ambos plugins, consulte How to use SQLite in a Capacitor app.
Instalar
bun add drizzle-orm @capacitor-community/sqlite
bun add -d drizzle-kit
bunx cap sync
@capacitor-community/sqlite Los paquetes SQLCipher se instalan en iOS y Android, por lo que su aplicación se envía con cifrado code incluso si no cifra nada. Tenga en cuenta esto para preguntas de cumplimiento de exportación de la Tienda de Mac.
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] }),
}));
Unos detalles importan en móviles:
mode: 'timestamp'almacena segundos desde la era como un entero y te daDateobjetos en TypeScript.$defaultFn(() => new Date())sets the default in JavaScript. A SQL default likeunixepoch()necesita SQLite 3.38 o posterior, que el sistema SQLite de sistemas Android más antiguos no tiene.mode: 'boolean'almacena1/0. Drizzle convierte ambos sentidos.relations()es lo que impulsadb.query.*conwith: {...}. Las claves foráneas solas no son suficientes para consultas relacionales.
Abra la conexión
El plugin de la comunidad mantiene un registro de conexiones. Durante el recarga en vivo o el reemplazo de módulo caliente, su code puede ejecutarse dos veces, por lo que reutilice 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 agrega SQLite.db a el nombre del archivo, por lo que app se convierte appSQLite.db en disco. En la web, el plugin necesita el jeep-sqlite elemento personalizado y sqlite.initWebStore() antes de la primera conexión. Siga la configuración web del plugin si se dirige al navegador.
El controlador sqlite-proxy
Esto es el núcleo de la integración. Drizzle llama a la función con la SQL, los parámetros y un method:
run: no filas necesarias (INSERT, UPDATE, DELETE, DDL, control de transacciones).all: cada fila, como arrays.get: la primera fila, como un array, oundefined.values: cada fila, como arrays (usado por raw)values()¿Por qué cada rama existe:
// 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>;
: Drizzle implementa
TX_CONTROL: Drizzle implementadb.transaction(), ybegin,commit,rollback, andsavepoint/releasepara transacciones anidadas. Pasan porexecute(sql, false), the same path TypeORM’s official Capacitor driver uses forBEGIN/COMMIT.falsecomo el tercer argumento arun()El plugin por defecto envuelve cada declaración en su propia transacción, lo que colisiona con la transacción explicita de Drizzle.begin.returnMode 'all': hace.returning(): funciona para insertar, actualizar y eliminar.Object.values(row): vuelve la fila clave a un array posicional.
Ejecuté esta función exacta contra una base de datos SQLite en memoria que devuelve filas de la misma manera que el plugin. Inserciones con .returning(): select filtrados .get(): consultas relacionales, transacciones y rollback en caso de error 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
Drizzle incorporado sqlite-proxy : archivos desde disco con Node .sql : archivos desde disco con Node fsque una WebView no tiene. La solución es dejar que Drizzle Kit empaquete la SQL en un módulo de JavaScript y la apliques 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 dice a Drizzle Kit que también escriba drizzle/migrations.jsque importa el registro y cada .sql archivo. Eso es el formato que cualquier entorno no de Node necesita.
2. Genera
bunx drizzle-kit generate
obtienes drizzle/0000_<name>.sql, drizzle/meta/_journal.json y drizzle/migrations.jsComita todos tres. Ejecuta generate again after every schema change. Do not edit SQL files that already shipped.
3. Deje que el empaquetador importe .sql como texto
migrations.js tiene líneas como import m0000 from './0000_fearless_selene.sql'. Vite no conoce .sql, así que agrega un plugin pequeño 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 tipo para que TypeScript acepte la importación:
// src/sql.d.ts
declare module '*.sql' {
const content: string;
export default content;
}
With webpack (Angular custom builders, older setups) use an asset/source regla para .sql archivos en lugar de eso.
4. Aplica las migraciones al arranque
// 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 utilizan los migradores de Drizzle: una migración está pendiente cuando su marca de tiempo de registro es más reciente que la más reciente registrada. Cada migración se ejecuta en su propia transacción, por lo que un error deja el estado anterior intacto. Llamarlo en cada arranque es barato.
Si envías JavaScript con Capgo actualizaciones en vivo, nuevas migraciones llegan a los usuarios sin una versión de almacenamiento. Manténlas adicionativas (nuevas tablas, nuevas columnas nulas), porque un retroceso a una versión más antigua 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 construye estas con las funciones JSON de SQLite, luego analiza el resultado. Funcionan en iOS y en las últimas versiones de Android. Si su minSdkVersion es viejo, revise SELECT json('{}') en su dispositivo de prueba más antiguo.
Uniones: nombres de columnas duplicados
Esta es la única arista afilada de cualquier driver de proxy construido sobre filas de objetos. Drizzle no agrega AS produce dos columnas llamadas
// 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));
produce dos columnas llamadas id. They collapse into one key in the row object, the array is one value short, and Drizzle assigns values to the wrong fields. In my test postId regresó con el id del usuario y userId was undefinedAlias duplicados de columnas
Aliasélos 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));
La selección de 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_atLas consultas relacionales no tienen este problema.
Transactions
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' });
});
});
If the callback throws, Drizzle sends rollback. Calling tx.rollback() también aborta la transacción, lanzando una excepción.
también aborta la transacción, lanzando una excepción. await llamadas a la red dentro de ellos. Las escrituras en la misma conexión correrían dentro de tu transacción abierta.
Troubleshooting
Error de conexión ya existe al iniciar. Su inicialización se ejecutó dos veces, generalmente debido a la recarga caliente. Utilice el checkConnectionsConsistency y retrieveConnection patrón mostrado anteriormente.
cannot start a transaction within a transaction. Un run() Se hizo una llamada sin false como el tercer argumento, por lo que el plugin abrió su propia transacción dentro de Drizzle.
Migraciones que se ejecutan en cada arranque y fallan con table already exists. El __drizzle_migrations inserta está faltando o se ha rechazado. Verifica que el registro when los valores en tu paquete coinciden con las filas de la tabla.
Build error Failed to parse source for import analysis en .sql. El plugin de transformación de Vite falta 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. El escritura se realizó con éxito run con modo de retorno no. Asegúrate de que el driver pase 'all' cuando method no es run.
Las fechas están desfasadas en un factor de 1000. mode: 'timestamp' almacena segundos, mode: 'timestamp_ms' almacena milisegundos. Elige uno por columna y no mezcles con datos crudos Date.now() inserta.
¿Cuándo elegir algo diferente
- Quieres el camino de datos más rápido con Fast SQL, o objetos de fila claveado por nombre: Kysely con Capacitor.
- Preferencias decoradoras, entidades y repositorios, o estás portando un modelo de backend TypeORM: TypeORM con Capacitor.
- Necesitas cifrado en reposo: Cifrando una base de datos SQLite en Capacitor.
Agregar un plugin nativo significa reconstruir las aplicaciones iOS y Android. Si no tienes un Mac a mano: Capgo Construcción construye y firma ambos en la nube.