Salta al contenuto principale

Come usare Drizzle ORM con Capacitor e SQLite

Configura Drizzle ORM con Capacitor e SQLite: un driver sqlite-proxy funzionante, schema tipizzato, query relazionali, transazioni e migrazioni Drizzle Kit in-app.

Logo di Capacitor per una guida sull'uso di Drizzle ORM con Capacitor e SQLite

Drizzle ORM funziona in un'app Capacitor tramite il suo sqlite-proxy driver: fornisci a Drizzle una funzione asincrona che esegue SQL su un plugin SQLite nativo e restituisce le righe come array, e Drizzle gestisce il query builder tipizzato, le query relazionali e le transazioni. Questa guida crea quella funzione per @capacitor-community/sqlite, mostra come distribuire le migrazioni di Drizzle Kit all'interno del bundle dell'app ed elenca i casi limite che corrompono silenziosamente i risultati se li ignori.

Tutto quanto segue è stato testato con drizzle-orm 0.45 e drizzle-kit 0.31 su Capacitor 8.

Perché usare Drizzle su un database mobile

  • Schema in TypeScript. Le tabelle sono semplici oggetti TypeScript. I tipi per select e insert sono inferiti, senza step di generazione code.
  • API in stile SQL. db.select().from(users).where(eq(users.id, 1)) corrisponde uno a uno all'SQL, così puoi capire cosa viene eseguito sul dispositivo.
  • Migrazioni generate dallo schema. Drizzle Kit confronta il tuo schema e scrive i file di migrazione SQL.
  • Nessun decorator. Funziona con Vite e esbuild senza flag TypeScript aggiuntivi.

Il compromesso: Drizzle non ha un driver Capacitor nel suo package, quindi scrivi tu il bridge. Sono circa 30 righe.

Scegliere il plugin SQLite

Drizzle’s sqlite-proxy mappa le righe dei risultati per posizione: per all e values si aspetta un array di array, per get un singolo array, nello stesso ordine delle colonne nel SELECT. I plugin Capacitor SQLite restituiscono oggetti con chiavi basate sul nome della colonna, quindi il driver deve riconvertire ogni oggetto in un array. Funziona solo se le chiavi dell'oggetto mantengono l'ordine delle colonne.

Plugin Formato riga Ordine colonne preservato Compatibile con proxy Drizzle
@capacitor-community/sqlite Oggetti Sì. Su iOS il plugin invia un ios_columns list e lo strato JS ricostruisce ogni riga nell'ordine delle colonne Buono
@capgo/capacitor-fast-sql Oggetti Non garantito su iOS, le righe vengono serializzate da un dizionario Swift Usa Kysely invece

Per questo motivo questa guida usa il plugin della community. Se usi già Fast SQL, o vuoi il suo percorso dati più veloce, Kysely legge le righe per nome di colonna e ci lavora direttamente. Per una panoramica di entrambi i plugin, vedi Come usare SQLite in un'app Capacitor.

Installa

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

@capacitor-community/sqlite integra SQLCipher su iOS e Android, quindi la tua app include la crittografia code anche se non cripti nulla. Tienilo presente per le questioni di conformità all'esportazione dell'App Store.

Definisci lo schema

// 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] }),
}));

Alcuni dettagli contano su mobile:

  • mode: 'timestamp' salva i secondi dall'epoch come intero e ti restituisce Date oggetti in TypeScript.
  • $defaultFn(() => new Date()) imposta il default in JavaScript. Un default SQL come unixepoch() richiede SQLite 3.38 o superiore, che il vecchio SQLite di sistema di Android non ha.
  • mode: 'boolean' salva 1/0. Drizzle converte in entrambe le direzioni.
  • relations() è ciò che alimenta db.query.* con with: {...}. Le chiavi esterne da sole non bastano per le query relazionali.

Apri la connessione

Il plugin della community mantiene un registro delle connessioni. Durante il live reload o l'hot module replacement, il tuo code può essere eseguito due volte, quindi riutilizza una connessione esistente invece di crearne una seconda.

// 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;
}

Il plugin aggiunge SQLite.db al nome del file, quindi app diventa appSQLite.db su disco. Sul web, il plugin richiede il jeep-sqlite elemento custom e sqlite.initWebStore() prima della prima connessione. Segui la configurazione web del plugin se il tuo target è il browser.

Il driver sqlite-proxy

Questo è il cuore dell'integrazione. Drizzle chiama la funzione con la query SQL, i parametri e un method:

  • run: non servono righe (INSERT, UPDATE, DELETE, DDL, controllo transazioni).
  • all: ogni riga, come array.
  • get: la prima riga, come array, oppure undefined.
  • values: ogni riga, come array (usato da raw values() chiamate e internals).
// 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>;

Perché esiste ogni ramo:

  • TX_CONTROL: Drizzle implementa db.transaction() inviando begin, commit, rollback, e savepoint/release per le transazioni annidate. Passano attraverso execute(sql, false), lo stesso percorso utilizzato dal driver ufficiale Capacitor di TypeORM per BEGIN/COMMIT.
  • false come terzo argomento di run(): il default del plugin avvolge ogni istruzione in una transazione separata, entrando in conflitto con quella esplicita di Drizzle begin.
  • returnMode 'all': rende .returning() funzionare per insert, update e delete.
  • Object.values(row): trasforma la riga con chiavi in un array posizionale.

Ho eseguito questa stessa funzione su un SQLite in-memory che restituisce le righe come fa il plugin. Le insert con .returning(), select filtrate, .get(), query relazionali, transazioni e rollback in caso di errore lanciato danno tutti risultati corretti.

Inizializza una volta

// 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;
}

Migrazioni con Drizzle Kit

Il migrator integrato di Drizzle sqlite-proxy il migrator legge .sql i file da disco con Node fs, che un WebView non ha. La soluzione è far bundleare a Drizzle Kit il SQL in un modulo JavaScript e applicarlo manualmente.

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' non significa che serve Expo. Dice a Drizzle Kit di scrivere anche drizzle/migrations.js, che importa il journal e ogni .sql file. È il formato necessario per qualsiasi runtime non-Node.

2. Genera

bunx drizzle-kit generate

Ottieni drizzle/0000_<name>.sql, drizzle/meta/_journal.json e drizzle/migrations.js. Committa tutti e tre. Esegua generate di nuovo dopo ogni modifica dello schema. Non modificare i file SQL già rilasciati.

3. Fai importare .sql come testo al bundler

migrations.js ha righe come import m0000 from './0000_fearless_selene.sql'. Vite non conosce .sql, quindi aggiunga un piccolo plugin invece di un'altra dipendenza:

// 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 };
        }
      },
    },
  ],
});

E una dichiarazione di tipo affinché TypeScript accetti l'import:

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

Con webpack (custom builder di Angular, setup più vecchi) usi un asset/source rule per .sql file invece.

4. Applica le migrazioni all'avvio

// 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;
    }
  }
}

Questo segue la stessa regola usata dai migratori di Drizzle: una migrazione è in sospeso se il suo timestamp nel journal è più recente di quello più recente registrato. Ogni migrazione viene eseguita nella propria transazione, quindi un errore lascia lo stato precedente intatto. Chiamarla a ogni avvio ha un costo trascurabile.

Se distribuisci JavaScript con Live updates di Capgo, le nuove migrazioni raggiungono gli utenti senza un rilascio sullo store. Tienile additive (nuove tabelle, nuove colonne nullable), perché un rollback a un bundle più vecchio verrà eseguito contro lo schema più recente.

Query

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);

Query relazionali

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 costruisce questi con le funzioni JSON di SQLite, poi analizza il risultato. Funzionano su iOS e sulle versioni recenti di Android. Se il tuo minSdkVersion è vecchio, controlla SELECT json('{}') sul tuo dispositivo di test più vecchio.

Join: alias per colonne duplicate

Questo è l'unico punto critico di qualsiasi proxy driver basato su righe di oggetti. Drizzle non aggiunge AS alias alle colonne della join, quindi questa query:

// 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 due colonne chiamate id. Si fondono in un'unica chiave nell'oggetto riga, l'array ha un valore in meno e Drizzle assegna i valori ai campi sbagliati. Nel mio test postId è tornato con l'id utente e userId era undefined. Non viene lanciato alcun errore.

Assegna loro un alias esplicitamente:

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));

Selezionare colonne con nomi diversi (posts.title, users.name) va bene. Evita db.select().from(a).innerJoin(b, ...) senza una lista di campi quando entrambe le tabelle condividono nomi di colonna come id oppure created_at. Le query relazionali non hanno questo problema.

Transazioni

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' });
  });
});

Se il callback lancia un errore, Drizzle invia rollback. Chiamare tx.rollback() interrompe anche la transazione, lanciando un errore.

C'è una connessione per ogni database, quindi mantieni le transazioni brevi e non await chiamate di rete al loro interno. Altre scritture sulla stessa connessione verrebbero eseguite all'interno della tua transazione aperta.

Risoluzione problemi

Errore “Connection already exists” all'avvio. L'inizializzazione è stata eseguita due volte, di solito a causa dell'hot reload. Usa il checkConnectionsConsistency e retrieveConnection pattern mostrato sopra.

cannot start a transaction within a transaction. Un run() la chiamata è stata eseguita senza false come terzo argomento, quindi il plugin ha aperto la propria transazione all'interno di quella di Drizzle.

Le migrazioni vengono eseguite ad ogni avvio e falliscono con table already exists. Il __drizzle_migrations insert mancante o annullato. Verifica che il journal when i valori nel tuo bundle corrispondano alle righe nella tabella.

Errore di build Failed to parse source for import analysis su .sql. Il plugin di trasformazione Vite è mancante o viene eseguito dopo un altro plugin che tenta di analizzare il file. Mettilo per primo nell'array plugins array.

.returning() restituisce un array vuoto. La scrittura è andata a buon fine run con modalità di ritorno no. Assicurati che il driver passi 'all' quando method non è run.

Le date sono sfasate di un fattore 1000. mode: 'timestamp' memorizza i secondi, mode: 'timestamp_ms' memorizza i millisecondi. Scegline uno per colonna e non mescolarlo con Date.now() insert grezzi.

Quando scegliere un'alternativa

Aggiungere un plugin nativo richiede di ricompilare le app iOS e Android. Se non hai un Mac a portata di mano, Build Capgo compila e firma entrambe nel cloud.

Live updates per le app Capacitor

Quando un bug nel web-layer è live, rilascia la fix tramite Capgo invece di aspettare giorni l'approvazione degli store. Gli utenti ricevono l'update in background, mentre le modifiche native restano nel normale processo di review.

Ultimi articoli dal blog

Capgo ti offre gli insight migliori che ti servono per creare un'app mobile davvero professionale.