Zum Hauptinhalt springen

Drizzle ORM mit Capacitor und SQLite verwenden

Setze Drizzle ORM mit Capacitor und SQLite auf: ein funktionierender sqlite-proxy-Treiber, ein typisiertes Schema, relationale Abfragen, Transaktionen und in-app-Migrations für Drizzle Kit.

Artikelcredits

Martin Donadieu

Schreiber

Valeria

Rezensent

Jordan

Redakteur

Verwende Drizzle ORM mit Capacitor und SQLite

Drizzle ORM läuft in einer Capacitor-Anwendung durch ihren sqlite-proxy Treiber: Sie geben Drizzle eine asynchrone Funktion, die SQL auf einem native SQLite-Plugin ausführt und die Ergebnisse als Arrays zurückgibt, und Drizzle handhabt den typisierten Query Builder, relationale Abfragen und Transaktionen darüber. @capacitor-community/sqlite, zeigt, wie man Drizzle Kit-Migrations innerhalb der App-Bundle schickt, und listet die Ränder, die Ergebnisse, wenn man sie verpasst, stumm verderben.

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

Weshalb Drizzle auf einem mobilen Datenbank

  • Schema in TypeScript. Die Tabellen sind einfache TypeScript-Objekte. Die Typen für SELECT- und INSERT-Anfragen werden automatisch ermittelt, kein code-Erstellungs-Schritt.
  • SQL-förmige API. db.select().from(users).where(eq(users.id, 1)) mappiert ein zu eins auf SQL, so dass Sie sich überlegen können, was auf dem Gerät ausgeführt wird.
  • Migrations, die aus dem Schema generiert werden. Drizzle Kit differt Ihr Schema und schreibt SQL-Migrationsdateien.
  • Keine Dekoratoren. Funktioniert mit Vite und esbuild ohne zusätzliche TypeScript-Flags.

Der Kompromiss: Drizzle hat keinen Capacitor-Treiber in seinem Paket, also schreiben Sie den Brückenkopf selbst. Es handelt sich um etwa 30 Zeilen.

Auswahl des SQLite-Plugins

Drizzle’s sqlite-proxy Der Treiber kartiert Ergebniszeilen nach Position: für all und values es erwartet eine Array von Arrays, für get ein einzelnes Array, in der gleichen Reihenfolge wie die Spalten im generierten SELECT. Capacitor SQLite-Plugins liefern Objekte, die durch Spaltennamen gekennzeichnet sind, daher muss der Treiber jeden Objekt wieder in ein Array umwandeln. Das funktioniert nur, wenn die Objekt-Schlüssel die Spaltenreihenfolge aufrechterhalten.

Plugin Zeilenformat Spaltenreihenfolge wird beibehalten Für den Drizzle-Proxy geeignet
@capacitor-community/sqlite Objekte Ja. Auf iOS sendet das Plugin ein ios_columns list und das JS-Layer rekonstruiert jede Zeile in Spaltenordnung Good
@capgo/capacitor-fast-sql Objekte Nicht garantiert auf iOS, Zeilen werden aus einem Swift-Wörterbuch serialisiert Verwenden Sie Kysely anstatt

Deshalb verwendet diese Anleitung die Community-Plugin. Wenn Sie bereits das Plug-in Fast SQL verwenden oder einen schnelleren Datenpfad wünschen, Kysely Die Community-Plugin wird verwendet, da es nicht garantiert ist, dass die Zeilen auf iOS in der richtigen Reihenfolge sind. How to use SQLite in einer Capacitor-Anwendung.

Installieren

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

@capacitor-community/sqlite SQLCipher auf iOS und Android verpackt, so dass Ihre App Verschlüsselung code mitliefert, auch wenn Sie nichts verschlüsseln.

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

Eine paar Details sind auf mobilen Geräten wichtig:

  • mode: 'timestamp' sekunden seit der Epoche als Ganzzahl speichert und Ihnen Date Objekte in TypeScript liefert.
  • $defaultFn(() => new Date()) die Standardwert in JavaScript setzt. Ein SQL-Standardwert wie unixepoch() erforderlich ist, um SQLite 3.38 oder neuer zu verwenden, was ältere Android-Systeme nicht haben.
  • mode: 'boolean' speichert 1/0. Drizzle konvertiert beide Richtungen.
  • relations() was Drizzle antreibt db.query.* mit with: {...}. Auslösende Schlüssel sind allein für relationale Abfragen nicht ausreichend.

Öffnen Sie die Verbindung

Die Community-Plugin-Registry hält eine Liste der Verbindungen. Während des Live-Neustarts oder der Hot-Modul-ersetzung kann Ihr code zweimal ausgeführt werden, daher werten Sie eine bestehende Verbindung stattdessen als eine zweite aus.

// 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 fügt SQLite.db zum Dateinamen hinzu, so dass app becomes appSQLite.db auf dem Disk. Auf der Web benötigt der Plugin jeep-sqlite custom element und sqlite.initWebStore() vor der ersten Verbindung. Folgen Sie der Web-Einrichtung des Plugins, wenn Sie sich auf den Browser konzentrieren.

Der sqlite-proxy-Treiber

Dies ist das Herz 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 Arrays.
  • get: die erste Zeile, als Array, oder undefined.
  • values: jede Zeile, als Arrays (von raw values() Warum jede Zweig existiert:
// 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 implementiert

  • TX_CONTROLdurch das Senden db.transaction() , und begin, commit, rollback, and savepoint/release für verschachtelte Transaktionen. Sie gehen durch execute(sql, false), der gleiche Pfad wie der offizielle Capacitor Treiber von TypeORM verwendet BEGIN/COMMIT.
  • false calls and internals). run(): Die Standard-Einstellung des Plugins umhüllt jeden Statement in seiner eigenen Transaktion, was mit Drizzle's expliziter Kollidiert. begin.
  • returnMode 'all': Macht .returning() : Funktioniert für Inserts, Updates und Deletes.
  • Object.values(row): Wandelt die geordnete Zeile wieder in eine positionale Array um.

: Ich habe diese Funktion genau gegen einen in-Memory SQLite durchgeführt, der Zeilen zurückgibt, wie das Plugin es tut. Inserts mit .returning(), gefilterte Selects .get(), relationale Abfragen, Transaktionen und Rollbacks bei einem geworfenen Fehler erzeugen alle korrekte Ergebnisse.

: Initialisieren Sie einmal

// 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 mit Drizzle Kit

: Drizzle's integrierte sqlite-proxy : Migrator liest .sql : Dateien vom Disk mit Node's fsund, was ein WebView nicht hat. Die Lösung besteht darin, Drizzle Kit die SQL in ein JavaScript-Modul zu packen und es dann selbst anzuwenden.

1. Konfigurieren Sie 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' Es bedeutet nicht, dass Sie Expo benötigen. Es sagt Drizzle Kit, auch zu schreiben drizzle/migrations.js, die das Journal und jedes .sql Datei. Das ist die Format, das jeder nicht-Node- Runtime benötigt.

2. Generieren

bunx drizzle-kit generate

Sie erhalten drizzle/0000_<name>.sql, drizzle/meta/_journal.json und drizzle/migrations.jsKommitee alle drei. Führe aus. generate Ändern Sie die SQL-Dateien nicht, die bereits abgeschickt wurden.

3. Lassen Sie den Bundler .sql als Text importieren

migrations.js hat Zeilen wie import m0000 from './0000_fearless_selene.sql'. Vite weißt es nicht .sql, also fügen Sie stattdessen ein kleines Plugin hinzu.

// 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 die Importe akzeptiert:

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

Mit webpack (Angular-Custom-Buildern, ältere Konfigurationen) verwenden Sie eine asset/source Regel für .sql Dateien anstatt.

4. Migrations anstarten

// 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 der gleichen Regel, die Drizzle's eigene Migratoren verwenden: Eine Migration ist ausstehend, wenn ihr Journal-Timestamp neuer ist als der neueste, der aufgezeichnet wurde. Jede Migration läuft in einer eigenen Transaktion ab, sodass ein Fehler den vorherigen Zustand unverändert lässt. Aufrufen Sie es bei jedem Start, es ist günstig.

Wenn Sie JavaScript mit Capgo Live-Updates liefern, erreichen neue Migrations die Benutzer ohne einen Speicher-Release. Halten Sie sie additiv (neue Tabellen, neue nullable Spalten), weil ein Zurücksetzen zu einem älteren Bundle gegen die neueren Schema läuft.

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

Beziehungsorientierte Abfragen

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 baut diese mit SQLite’s JSON-Funktionen auf, dann analysiert es das Ergebnis. Sie funktionieren auf iOS und auf den neuesten Android-Versionen. Wenn Ihr minSdkVersion ist alt, überprüfen Sie SELECT json('{}') auf Ihrem ältesten Testgerät.

Joins: Alias für duplizierte Spaltennamen

Dies ist der eine scharfe Kantenpunkt von jedem Proxytreiber, der auf Objektzeilen aufbaut. Drizzle fügt nichts hinzu AS produziert zwei Spalten mit dem Namen

// 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 mit dem Namen 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 kam zurück mit der Benutzer-ID und userId was undefinedproduces two columns called __CAPGO_KEEP_0__ and __CAPGO_KEEP_1__

Alias sie explizit an:

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

Wenn Sie Spalten mit unterschiedlichen Namen (posts.title, users.nameEs ist in Ordnung. Vermeide db.select().from(a).innerJoin(b, ...) ohne eine Feldliste, wenn beide Tabellen Spalten mit gleichen Namen wie id or created_at. Relationale Abfragen 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 auslöst, sendet Drizzle rollback. Aufrufen tx.rollback() beendet auch die Transaktion, indem es auslöst.

Es gibt eine Verbindung pro Datenbank, daher sollten Transaktionen kurz gehalten und nicht await Netzanfragen innerhalb davon. Andere Schreibvorgänge auf derselben Verbindung würden innerhalb Ihrer offenen Transaktion ausgeführt.

Troubleshooting

"Verbindung bereits existiert"-Fehler beim Start. Ihr Initialisierung wurde zweimal ausgeführt, meistens wegen Hot Reload. Verwenden Sie das checkConnectionsConsistency und retrieveConnection Muster, das oben gezeigt wird.

cannot start a transaction within a transaction. Eine run() Aufruf wurde ohne false als dritte Argument gemacht, sodass das Plugin seine eigene Transaktion innerhalb von Drizzle eröffnete.

Migrations werden bei jedem Start durchgeführt und schließen mit table already exists. Der __drizzle_migrations insert is missing or rolled back. Check that the journal when Werte in Ihrem Bundle entsprechen den Zeilen in der Tabelle.

Buildfehler Failed to parse source for import analysis auf .sql. Der Vite-Transform-Plugin fehlt oder läuft nach einem anderen Plugin, das versucht, das Datei zu parsen. Setze es an erster Stelle in der plugins Liste.

.returning() erstellt eine leere Liste. Die Schreiboperation ist erfolgreich run mit Rückgabemodus no. Stelle sicher, dass der Treiber 'all' wenn method ist nicht run.

Die Daten sind um einen Faktor von 1000 falsch. mode: 'timestamp' speichert Sekunden, mode: 'timestamp_ms' speichert Millisekunden. Wählen Sie eine pro Spalte aus und vermischen Sie sie nicht mit Rohdaten. Date.now() eingibt.

Wann wählen Sie etwas anderes?

Das Hinzufügen eines nativen Plugins bedeutet, dass die iOS- und Android-Apps neu erstellt werden müssen. Wenn Sie kein Mac zur Hand haben: Capgo Build erstellt und signiert beide im Cloud.

Live Updates für Capacitor-Apps

When a web-layer bug is live, ship the fix through Capgo instead of waiting days for app store approval. Users get the update in the background while native changes stay in the normal review path.

Menschliche Unterstützung von Martin

Capgo gives you the best insights you need to create a truly professional mobile app.