Drizzle ORM s'exécute dans une app Capacitor via son sqlite-proxy driver : vous fournissez à Drizzle une fonction async qui exécute du SQL sur un plugin SQLite natif et renvoie les lignes sous forme de tableaux, et Drizzle gère par-dessus le query builder typé, les requêtes relationnelles et les transactions. Ce guide crée cette fonction pour @capacitor-community/sqlite, montre comment livrer les migrations Drizzle Kit dans le bundle de l'app, et liste les cas limites qui corrompent silencieusement les résultats si vous les oubliez.
Tout ce qui suit a été testé avec drizzle-orm 0.45 et drizzle-kit 0.31 sur Capacitor 8.
Pourquoi utiliser Drizzle sur une base de données mobile
- Le schéma en TypeScript. Les tables sont de simples objets TypeScript. Les types pour les selects et les inserts sont inférés, sans étape de génération code.
- API au format SQL.
db.select().from(users).where(eq(users.id, 1))se traduit un à un en SQL, ce qui vous permet de savoir ce qui s'exécute sur l'appareil. - Migrations générées à partir du schéma. Drizzle Kit compare votre schéma et écrit les fichiers de migration SQL.
- Pas de décorateurs. Compatible avec Vite et esbuild sans flags TypeScript supplémentaires.
Le compromis : Drizzle n'inclut pas de driver Capacitor dans son package, vous devez donc écrire le bridge vous-même. Cela fait environ 30 lignes.
Choisir le plugin SQLite
Drizzle’s sqlite-proxy mappe les lignes de résultats par position : pour all et values il attend un tableau de tableaux, pour get un seul tableau, dans le même ordre que les colonnes du SQL généré SELECT. Les plugins SQLite Capacitor renvoient des objets indexés par le nom de colonne, le driver doit donc reconvertir chaque objet en tableau. Cela ne fonctionne que si les clés de l'objet conservent l'ordre des colonnes.
| Plugin | Format des lignes | Ordre des colonnes préservé | Adapté au proxy Drizzle |
|---|---|---|---|
@capacitor-community/sqlite |
Objets | Oui. Sur iOS, le plugin envoie une ios_columns liste et la couche JS reconstruit chaque ligne selon l'ordre des colonnes |
Bon |
@capgo/capacitor-fast-sql |
Objets | Non garanti sur iOS, les lignes sont sérialisées à partir d'un dictionnaire Swift | Utilisez plutôt Kysely |
C'est pourquoi ce guide utilise le plugin communautaire. Si vous utilisez déjà Fast SQL, ou si vous voulez son chemin de données plus rapide, Kysely lit les lignes par nom de colonne et les utilise directement. Pour un aperçu des deux plugins, consultez Comment utiliser SQLite dans une application Capacitor.
Installer
bun add drizzle-orm @capacitor-community/sqlite
bun add -d drizzle-kit
bunx cap sync
@capacitor-community/sqlite inclut SQLCipher sur iOS et Android : votre app embarque donc le chiffrement code même si vous ne chiffrez rien. Gardez-l’en tête pour les questions de conformité à l'export de l'App Store.
Définir le schéma
// 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] }),
}));
Quelques détails sont importants sur mobile :
mode: 'timestamp'stocke les secondes depuis l'epoch sous forme d'entier et vous fournitDatedes objets en TypeScript.$defaultFn(() => new Date())définit la valeur par défaut en JavaScript. Une valeur par défaut SQL commeunixepoch()nécessite SQLite 3.38 ou plus, ce que le SQLite système des anciens Android ne propose pas.mode: 'boolean'stocke1/0. Drizzle convertit dans les deux sens.relations()est ce qui propulsedb.query.*parwith: {...}. Les clés étrangères ne suffisent pas pour les requêtes relationnelles.
Ouvrir la connexion
Le plugin communautaire maintient un registre des connexions. Lors d'un live reload ou d'un hot module replacement, votre code peut s'exécuter deux fois ; réutilisez donc une connexion existante au lieu d'en créer une seconde.
// 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;
}
Le plugin ajoute SQLite.db au nom du fichier, donc app devient appSQLite.db sur le disque. Sur le web, le plugin nécessite le jeep-sqlite l'élément personnalisé et sqlite.initWebStore() avant la première connexion. Suivez la configuration web du plugin si vous ciblez le navigateur.
Le driver sqlite-proxy
C'est le cœur de l'intégration. Drizzle appelle la fonction avec le SQL, les paramètres et un method:
run: aucune ligne n'est requise (INSERT, UPDATE, DELETE, DDL, contrôle de transaction).all: chaque ligne, sous forme de tableaux.get: la première ligne, sous forme de tableau, ouundefined.values: every row, as arrays (used by rawvalues()requêtes brutes et les fonctions internes).
// 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>;
Pourquoi chaque branche existe :
TX_CONTROL: Drizzle implémentedb.transaction()en envoyantbegin,commit,rollback, etsavepoint/releasepour les transactions imbriquées. Elles passent parexecute(sql, false), le même chemin que le driver officiel Capacitor de TypeORM pourBEGIN/COMMIT.falsecomme troisième argument derun(): par défaut, le plugin encapsule chaque instruction dans sa propre transaction, ce qui entre en conflit avec les transactions explicites de Drizzlebegin.returnMode 'all': permet à.returning()work for inserts, updates and deletes.Object.values(row): convertit la ligne indexée en tableau positionnel.
J’ai exécuté cette fonction exacte contre un SQLite en mémoire qui renvoie des lignes comme le plugin. Les inserts avec .returning(), les sélections filtrées, .get(), les requêtes relationnelles, les transactions et les rollbacks en cas d'erreur produisent tous des résultats corrects.
Initialiser une seule fois
// 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;
}
Migrations avec Drizzle Kit
Le migrateur intégré de Drizzle sqlite-proxy lit les fichiers .sql depuis le disque via Node fs, ce qu'une WebView ne possède pas. La solution consiste à laisser Drizzle Kit regrouper le SQL dans un module JavaScript et à l'appliquer vous-même.
1. Configurer 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' ne signifie pas qu'il faut utiliser Expo. Cela indique à Drizzle Kit d'écrire également drizzle/migrations.js, qui importe le journal et chaque fichier .sql . C'est le format nécessaire à tout runtime non-Node.
2. Générer
bunx drizzle-kit generate
Vous obtenez drizzle/0000_<name>.sql, drizzle/meta/_journal.json et drizzle/migrations.js. Committez les trois. Exécutez generate à chaque modification du schéma. Ne modifiez pas les fichiers SQL déjà déployés.
3. Laissez le bundler importer le .sql en tant que texte
migrations.js contient des lignes comme import m0000 from './0000_fearless_selene.sql'. Vite ne connaît pas .sql, ajoutez donc un petit plugin plutôt qu’une autre dépendance :
// 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 };
}
},
},
],
});
Et une déclaration de type pour que TypeScript accepte l’import :
// src/sql.d.ts
declare module '*.sql' {
const content: string;
export default content;
}
With webpack (Angular custom builders, older setups) use an asset/source règle pour .sql les fichiers à la place.
4. Appliquer les migrations au démarrage
// 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;
}
}
}
Cela suit la même règle que les migrateurs de Drizzle : une migration est en attente si son horodatage dans le journal est plus récent que le plus récent enregistré. Chaque migration s’exécute dans sa propre transaction, donc une échec laisse l’état précédent intact. L’appeler à chaque lancement est peu coûteux.
Si vous déployez du JavaScript avec les live updates Capgo, les nouvelles migrations atteignent les utilisateurs sans release sur le store. Gardez-les additives (nouvelles tables, nouvelles colonnes nullable), car un rollback vers un bundle plus ancien s’exécutera contre le schéma plus récent.
Requêtes
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);
Requêtes relationnelles
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 les génère avec les fonctions JSON de SQLite, puis analyse le résultat. Ils fonctionnent sur iOS et sur les versions récentes d'Android. Si votre minSdkVersion est ancienne, vérifiez SELECT json('{}') sur votre appareil de test le plus ancien.
Jointures : aliaser les noms de colonnes en double
C'est le seul point sensible de tout driver proxy basé sur des lignes d'objets. Drizzle n'ajoute pas d' AS alias aux colonnes jointes, donc cette requête :
// 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));
produit deux colonnes nommées id. Elles fusionnent en une seule clé dans l'objet de la ligne, il manque une valeur dans le tableau, et Drizzle affecte les valeurs aux mauvais champs. Lors de mon test, postId elle est revenue avec l'id utilisateur et userId était undefined. Aucune erreur n'est levée.
Utilisez des alias explicitement :
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));
Sélectionner des colonnes avec des noms différents (posts.title, users.name) ne pose aucun problème. Évitez db.select().from(a).innerJoin(b, ...) sans liste de champs lorsque les deux tables partagent des noms de colonnes comme id ou created_at. Les requêtes relationnelles n'ont pas ce problème.
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' });
});
});
Si le callback lève une erreur, Drizzle envoie rollback. L'appel de tx.rollback() annule également la transaction en levant une erreur.
Il y a une connexion par base de données, gardez donc les transactions courtes et ne await appels réseau à l'intérieur. Toute autre écriture sur la même connexion s'exécuterait dans votre transaction ouverte.
Troubleshooting
Erreur « Connection already exists » au démarrage. Votre initialisation s'est exécutée deux fois, généralement à cause du hot reload. Utilisez le checkConnectionsConsistency et retrieveConnection pattern présenté ci-dessus.
cannot start a transaction within a transaction. Un run() l'appel a été effectué sans false comme troisième argument, le plugin a donc ouvert sa propre transaction à l'intérieur de celle de Drizzle.
Les migrations s'exécutent à chaque lancement et échouent avec table already exists. Le __drizzle_migrations l'insert est manquant ou a subi un rollback. Vérifiez que le journal when les valeurs de votre bundle correspondent aux lignes de la table.
Erreur de build Failed to parse source for import analysis sur .sql. Le plugin de transformation Vite est manquant ou s'exécute après un autre plugin qui tente d'analyser le fichier. Placez-l’en premier dans le plugins tableau.
.returning() renvoie un tableau vide. L'écriture a abouti run avec le mode de retour no. Assurez-vous que le driver transmet 'all' Quand method n'est pas run.
Les dates sont décalées d'un facteur 1000. mode: 'timestamp' stocke les secondes, mode: 'timestamp_ms' stocke les millisecondes. Choisissez-en un par colonne et ne le mélangez pas avec des Date.now() inserts bruts.
Quand choisir une autre solution
- Vous voulez le chemin de données le plus rapide avec Fast SQL, ou des objets de ligne indexés par nom : Kysely avec Capacitor.
- Vous préférez les décorateurs, les entités et les dépôts, ou vous portez un modèle backend TypeORM : TypeORM avec Capacitor.
- Vous avez besoin du chiffrement au repos : Chiffrer une base de données SQLite dans Capacitor.
Ajouter un plugin natif implique de recompiler les applications iOS et Android. Si vous n’avez pas de Mac sous la main, Build Capgo compile et signe les deux dans le cloud.