Zum Hauptinhalt springen

Drizzle ORM mit Capacitor und SQLite nutzen

Drizzle ORM mit Capacitor und SQLite einrichten: ein funktionierender sqlite-proxy-Treiber, typisiertes Schema, relationale Abfragen, Transaktionen und In-App-Migrationen mit Drizzle Kit.

Capacitor-Logo für einen Leitfaden zur Verwendung von Drizzle ORM mit Capacitor und SQLite

Drizzle ORM läuft in einer Capacitor-App über seinen sqlite-proxy Driver: Du übergibst Drizzle eine asynchrone Funktion, die SQL auf einem nativen SQLite-Plugin ausführt und Zeilen als Arrays zurückgibt. Drizzle übernimmt darüber den typisierten Query-Builder, relationale Abfragen und Transaktionen. Dieser Leitfaden erstellt diese Funktion für @capacitor-community/sqlite, zeigt, wie du Drizzle Kit Migrations im App-Bundle auslieferst, und listet die Edge Cases, die Ergebnisse still verfälschen, wenn du sie übersiehst.

Alles Folgende wurde getestet mit drizzle-orm 0.45 und drizzle-kit 0.31 auf Capacitor 8.

Warum Drizzle für mobile Datenbanken

  • Schema in TypeScript. Tabellen sind einfache TypeScript-Objekte. Typen für Selects und Inserts werden inferiert, kein code-Generierungsschritt.
  • API in SQL-Form. db.select().from(users).where(eq(users.id, 1)) Mappt eins zu eins auf SQL, sodass du genau nachvollziehen kannst, was auf dem Device läuft.
  • Migrations, generiert aus dem Schema. Drizzle Kit vergleicht dein Schema und schreibt SQL-Migration-Files.
  • Keine Decorators. Funktioniert mit Vite und esbuild ohne zusätzliche TypeScript-Flags.

Der Kompromiss: Drizzle hat keinen Capacitor-Treiber in seinem Paket, daher schreibst du die Bridge selbst. Das sind etwa 30 Zeilen.

Das SQLite-Plugin auswählen

Der sqlite-proxy Treiber von Drizzle nach Position: for all und values erwartet ein Array von Arrays, für get ein einzelnes Array, in derselben Reihenfolge wie die Spalten im generierten SELECT. Capacitor SQLite-Plugins geben Objekte zurück, die nach Spaltennamen indiziert sind, daher muss der Driver jedes Objekt wieder in ein Array umwandeln. Das funktioniert nur, wenn die Objekt-Keys die Spaltenreihenfolge beibehalten.

Plugin Zeilenformat Spaltenreihenfolge beibehalten Geeignet für Drizzle-Proxy
@capacitor-community/sqlite Objekte Ja. Auf iOS sendet das Plugin eine ios_columns Liste und die JS-Schicht baut jede Zeile in Spaltenreihenfolge neu auf Gut
@capgo/capacitor-fast-sql Objekte Auf iOS nicht garantiert, Zeilen werden aus einem Swift-Dictionary serialisiert Stattdessen Kysely verwenden

Deshalb nutzt dieser Guide das Community-Plugin. Falls du bereits Fast SQL nutzt oder dessen schnellere Datenpfade bevorzugst, Kysely liest Zeilen nach Spaltennamen aus und arbeitet direkt damit. Eine Übersicht beider Plugins findest du unter So nutzt du SQLite in einer Capacitor App.

Installieren

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

@capacitor-community/sqlite bündelt SQLCipher auf iOS und Android, sodass deine App code mit Verschlüsselung ausgeliefert wird, selbst wenn du nichts verschlüsselst. Denk daran bei Fragen zur Export-Compliance für den App Store.

Schema definieren

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

Auf Mobile kommt es auf ein paar Details an:

  • mode: 'timestamp' speichert Sekunden seit Epoch als Integer und liefert dir Date Objekte in TypeScript.
  • $defaultFn(() => new Date()) setzt den Standard in JavaScript. Ein SQL-Default wie unixepoch() erfordert SQLite 3.38 oder neuer, über das ältere Android-System-SQLite nicht verfügt.
  • mode: 'boolean' speichert 1/0. Drizzle konvertiert in beide Richtungen.
  • relations() ist die Grundlage für db.query.* mit with: {...}. Fremdschlüssel allein reichen für relationale Abfragen nicht aus.

Verbindung öffnen

Das Community-Plugin verwaltet ein Registry der Verbindungen. Bei Live Reload oder Hot Module Replacement kann dein code zweimal ausgeführt werden. Wiederverwende daher eine bestehende Verbindung, anstatt eine zweite zu erstellen.

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

Das Plugin hängt SQLite.db an den Dateinamen an, sodass app wird zu appSQLite.db auf der Festplatte. Im Web benötigt das Plugin das jeep-sqlite Custom Element und sqlite.initWebStore() vor der ersten Verbindung. Wenn du den Browser als Ziel hast, folge dem Web-Setup des Plugins.

Der sqlite-proxy-Treiber

Das ist der Kern der Integration. Drizzle ruft die Funktion mit dem SQL, den Parametern und einem method:

  • run: keine Zeilen erforderlich (INSERT, UPDATE, DELETE, DDL, Transaktionssteuerung).
  • all: jede Zeile als Array.
  • get: die erste Zeile als Array oder undefined.
  • values: jede Zeile als Array (wird bei Raw- values() Calls und Internals verwendet).
// 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>;

Warum es jeden Zweig gibt:

  • TX_CONTROL: Drizzle implementiert db.transaction() indem es sendet begin, commit, rollback, und savepoint/release für verschachtelte Transaktionen. Diese laufen über execute(sql, false), denselben Pfad, den der offizielle Capacitor Driver von TypeORM für BEGIN/COMMIT.
  • false als drittes Argument an run(): Die Standardkonfiguration des Plugins kapselt jede Anweisung in einer eigenen Transaktion, was mit den expliziten Transaktionen von Drizzle kollidiert. begin.
  • returnMode 'all': Ermöglicht .returning() für Inserts, Updates und Deletes.
  • Object.values(row): Wandelt die keybasierte Zeile wieder in ein positionales Array um.

Ich habe genau diese Funktion gegen ein in-memory SQLite ausgeführt, das Zeilen so zurückgibt, wie es das Plugin tut. Inserts mit .returning(), gefilterte Selects, .get(), relationale Abfragen, Transaktionen und Rollbacks bei einem Fehler liefern alle korrekte Ergebnisse.

Einmalige Initialisierung

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

Migrationen mit Drizzle Kit

Der integrierte sqlite-proxy Migrator liest .sql Dateien von der Festplatte mit Nodes fs, was eine WebView nicht besitzt. Die Lösung besteht darin, Drizzle Kit das SQL als JavaScript-Modul bündeln zu lassen, damit du es selbst anwenden kannst.

1. Drizzle Kit konfigurieren

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

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

driver: 'expo' bedeutet nicht, dass du Expo brauchst. Es bringt Drizzle Kit dazu, zusätzlich zu schreiben drizzle/migrations.js, das das Journal und jede .sql Datei importiert. Das ist das Format, das jede Nicht-Node-Runtime braucht.

2. Generieren

bunx drizzle-kit generate

Du bekommst drizzle/0000_<name>.sql, drizzle/meta/_journal.json und drizzle/migrations.js. Committe alle drei. Führe generate nach jeder Schema-Änderung erneut aus. Bearbeite keine SQL-Dateien, die bereits ausgeliefert wurden.

3. Bundler .sql als Text importieren lassen

migrations.js enthält Zeilen wie import m0000 from './0000_fearless_selene.sql'. Vite kennt das .sql, füge stattdessen ein kleines Plugin hinzu, anstatt eine weitere Abhängigkeit:

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

Und eine Typdeklaration, damit TypeScript den Import akzeptiert:

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

Mit webpack (Angular Custom Builders, ältere Setups) stattdessen eine asset/source Rule für .sql Dateien verwenden.

4. Migrationen beim Start anwenden

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

Dies folgt derselben Regel, die Drizzles eigene Migratoren verwenden: Eine Migration ist ausstehend, wenn ihr Journal-Zeitstempel neuer ist als der neueste aufgezeichnete. Jede Migration läuft in ihrer eigenen Transaktion ab, sodass ein Fehler den vorherigen Zustand intakt lässt. Der Aufruf bei jedem Start ist günstig.

Wenn du JavaScript mit Capgo Live Updatesauslieferst, erreichen neue Migrationen Nutzer ohne Store-Release. Halte sie additiv (neue Tabellen, neue nullable Spalten), da ein Rollback auf ein älteres Bundle gegen das neuere Schema ausgeführt wird.

Abfragen

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

Relationale Queries

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 erstellt diese mit den JSON-Funktionen von SQLite und parst dann das Ergebnis. Sie funktionieren auf iOS und auf neueren Android-Versionen. Wenn dein minSdkVersion alt ist, prüfe SELECT json('{}') auf deinem ältesten Testgerät.

Joins: doppelte Spaltennamen mit Aliasen

Das ist die eine scharfe Kante jedes Proxy-Treibers, der auf Objektzeilen basiert. Drizzle fügt nicht hinzu AS alias für verknüpfte Spalten, daher diese Abfrage:

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

erzeugt zwei Spalten namens id. Sie werden zu einem Schlüssel im Zeilenobjekt zusammengefasst, das Array ist um einen Wert kürzer, und Drizzle weist Werte den falschen Feldern zu. In meinem Test postId kam mit der Benutzer-ID zurück und userId war undefined. Es wird kein Fehler geworfen.

Vergib ihnen explizit Aliase:

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

Spalten mit unterschiedlichen Namen auszuwählen (posts.title, users.name) ist kein Problem. Vermeide db.select().from(a).innerJoin(b, ...) ohne Feldliste, wenn beide Tabellen dieselben Spaltennamen haben wie id oder created_at. Relational Queries haben dieses Problem nicht.

Transaktionen

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

Wenn der Callback einen Fehler wirft, sendet Drizzle rollback. Der Aufruf von tx.rollback() bricht die Transaktion ebenfalls durch einen Fehler ab.

Es gibt eine Verbindung pro Datenbank; halte Transaktionen daher kurz und vermeide await Netzwerkaufrufe darin. Andere Schreibvorgänge auf derselben Verbindung würden innerhalb Ihrer offenen Transaktion ausgeführt.

Fehlerbehebung

Fehler „Connection already exists“ beim Start. Ihre Initialisierung wurde zweimal ausgeführt, meist wegen Hot Reload. Verwenden Sie das checkConnectionsConsistency und retrieveConnection Muster, das oben gezeigt wurde.

cannot start a transaction within a transaction. Ein run() wurde ohne aufgerufen false als drittes Argument aufgerufen, sodass das Plugin seine eigene Transaktion innerhalb der von Drizzle geöffnet hat.

Migrations werden bei jedem Start ausgeführt und schlagen fehl mit table already exists. Der __drizzle_migrations insert fehlt oder wurde zurückgerollt. Prüfe, dass die Journal- when Werte in deinem Bundle mit den Zeilen in der Tabelle übereinstimmen.

Build-Fehler Failed to parse source for import analysis auf .sql. Das Vite-Transform-Plugin fehlt oder wird nach einem anderen Plugin ausgeführt, das versucht, die Datei zu parsen. Stelle es an den Anfang des plugins Arrays.

.returning() gibt ein leeres Array zurück. Der Write wurde ausgeführt run mit Return-Modus no. Stellen Sie sicher, dass der Driver 'all' wenn method ist nicht run.

Datumsangaben weichen um den Faktor 1000 ab. mode: 'timestamp' speichert Sekunden, mode: 'timestamp_ms' speichert Millisekunden. Wähle pro Spalte nur eines und vermische es nicht mit rohen Date.now() Inserts.

Wann du etwas anderes wählst

Ein Native Plugin hinzuzufügen bedeutet, die iOS- und Android-Apps neu zu builden. Falls du keinen Mac zur Hand hast, Capgo Build baut und signiert alles in der Cloud.

Live-Updates für Capacitor-Apps

Wenn ein Bug im Web-Layer live ist, liefere den Fix über Capgo aus, statt Tage auf die App-Store-Freigabe zu warten. Nutzer bekommen das Update im Hintergrund, während native Änderungen im normalen Review-Prozess bleiben.

Neues aus unserem Blog

Capgo gibt dir die besten Insights, um eine wirklich professionelle Mobile App zu erstellen.