Drizzle ORM läuft in einer Capacitor-Anwendung über seinen 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-Migrationen innerhalb der App-Bundle schickt, und listet die Randfälle auf, die Ergebnisse stumm verderben, wenn man sie übersehen lässt.
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. Tabellen sind einfache TypeScript-Objekte. Die Typen für SELECT- und INSERT-Anfragen werden automatisch ermittelt, kein code-Erstellungsprozess.
- SQL-förmige API.
db.select().from(users).where(eq(users.id, 1))macht eine zu einer SQL-Anfrage, sodass Sie sich überlegen können, was auf dem Gerät ausgeführt wird. - Migrations, die aus dem Schema generiert werden. Drizzle Kit vergleicht Ihr Schema und schreibt SQL-Migrationsdateien.
- Keine Dekoratoren. Arbeitet 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 in der generierten SELECTDie 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 die JS-Schicht rekonstruiert jede Zeile in Spaltenordnung |
Good |
@capgo/capacitor-fast-sql |
Objekte | Nicht garantiert auf iOS, Zeilen werden aus einem Swift-Datenverzeichnis serialisiert | Verwenden Sie Kysely anstatt |
Deshalb wird in dieser Anleitung die Community-Plugin verwendet. Wenn Sie bereits Fast SQL verwenden oder einen schnelleren Datenpfad wünschen, Kysely liest Zeilen nach Spaltenname und arbeitet direkt mit ihm. Für eine Übersicht über beide Plugins siehe Wie man SQLite in einer Capacitor-Anwendung verwendet.
Installieren
bun add drizzle-orm @capacitor-community/sqlite
bun add -d drizzle-kit
bunx cap sync
@capacitor-community/sqlite SQLCipher wird auf iOS und Android gebündelt, so dass Ihre App Verschlüsselung code bereitstellt, selbst wenn Sie nichts verschlüsseln. Beachten Sie dies bei Fragen zur Exportkonzession für den App Store.
Definieren Sie das 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] }),
}));
Einige Details sind auf Mobilgeräten wichtig:
mode: 'timestamp'speichert Sekunden seit der Epochenzeit als Ganzzahl und liefert IhnenDateObjekte in TypeScript.$defaultFn(() => new Date())setzt die Standardwerte in JavaScript. Ein SQL-Standardwert wieunixepoch()erfordert SQLite 3.38 oder neuer, was ältere Android-System-SQLite nicht hat.mode: 'boolean'speichert1/0Drizzle konvertiert beide Richtungen.relations()ist das, was Drizzle antriebtdb.query.*mitwith: {...}. Auslösende Schlüssel alleine sind 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 verwenden Sie 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 fügt SQLite.db zum Dateinamen hinzu, so dass app wird. appSQLite.db auf dem Disk. Im Web benötigt das Plugin das jeep-sqlite customes Element und sqlite.initWebStore() vor der ersten Verbindung. Folgen Sie der Web-Einrichtung des Plugins, wenn Sie den Browser anvisieren.
Der sqlite-proxy-Treiber
Dies ist das Herz der Integration. Drizzle ruft die Funktion mit der SQL-Anweisung, 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, oderundefined.values: jede Zeile, als Arrays (von rawvalues()Aufrufen und internen Funktionen 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 jede Zweig existiert:
TX_CONTROL: Drizzle implementiertdb.transaction()durch das Sendenbegin,commit,rollbackvonsavepoint/releasefür verschachtelte Transaktionen. Sie gehen durchexecute(sql, false), der gleiche Pfad, den der offizielle Capacitor-Treiber von TypeORM verwendet.BEGIN/COMMIT.falseals dritte Argumentrun(): 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 gekeyte Zeile wieder in eine positionale Array um.
: Ich habe diese genaue Funktion gegen einen in-Memory SQLite ausgerichtet, der Zeilen zurückgibt, wie das Plugin es tut. Inserts mit .returning(), gefilterten Selects .get(), relationalen Queries, 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 von der Festplatte mit Node's fswas 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 jede nicht-Node-Umgebung benötigt.
2. Generieren
bunx drizzle-kit generate
Sie erhalten drizzle/0000_<name>.sql, drizzle/meta/_journal.json und drizzle/migrations.jsKommende Änderungen. Commit alle drei. Ausführen generate Ändern Sie die SQL-Dateien nicht, die bereits abgesandt 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 nicht .sql, füge also einen kleinen Plugin hinzu anstatt einer weiteren 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 die Importe akzeptiert:
// src/sql.d.ts
declare module '*.sql' {
const content: string;
export default content;
}
Mit webpack (Angular Custom-Buildern, ältere Konfigurationen) verwende ein asset/source Regel für .sql Dateien anstatt.
4. Migrations anwenden, wenn der Server startet
// 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 es bei jedem Start ist günstig.
Wenn du JavaScript mit Capgo live Updatesschickst, erreichen neue Migrations die Benutzer ohne eine Store-Release. Halte sie additiv (neue Tabellen, neue nullable Spalten), weil eine Rückschaltung auf eine ältere Bundle gegen die neue 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);
Relationenabfragen
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 Kanten von jedem Proxy-Treiber, der auf Objektzeilen aufgebaut ist. Drizzle fügt keine AS Alias zu den abgefragten Spalten hinzu, also 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));
produziert zwei Spalten mit dem Namen id. Sie kollabieren in einer Zeile zu einem Schlüssel, der Array ist um einen Wert kürzer und Drizzle zuweist die Werte in die falschen Felder. In meinem Test postId hat mit dem Benutzer-ID und userId war undefined. Keine Fehlermeldung wird ausgelöst.
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.name) ist in Ordnung. Vermeide db.select().from(a).innerJoin(b, ...) ohne eine Feldliste, wenn beide Tabellen Spalten mit gleichen Namen wie id context created_athaben. 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 einen Fehler wirft, sendet Drizzle rollback. Aufrufen tx.rollback() beendet auch die Transaktion, indem es einen Fehler wirft.
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.
Fehlerbehebung
"Verbindung bereits existiert"-Fehler beim Start. Deine Initialisierung wurde zweimal ausgeführt, meistens wegen Hot Reload. Verwende das checkConnectionsConsistency und retrieveConnection Ein Muster wie oben.
cannot start a transaction within a transaction. A run() Eine Anfrage wurde ohne false als dritte Argument gemacht, also öffnete der Plugin seine eigene Transaktion innerhalb von Drizzle’s.
Migrations werden bei jedem Start durchgeführt und schließen mit table already exists. Das __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 wird nach einem Plugin ausgeführt, das versucht, das Datei zu parsen. Setze ihn an erster Stelle in der plugins array.
.returning() Die Schreiboperation ist erfolgreich mit Rückgabemodus run . Stelle sicher, dass der Treiber noMachen Sie sicher, dass der Treiber funktioniert 'all' ist nicht 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?
- Sollten Sie den schnellsten Datenpfad mit Fast SQL oder Objekte in Zeilen mit Namen haben: Kysely mit Capacitor.
- Sollten Sie Dekoratoren, Entitäten und Repositories bevorzugen oder eine TypeORM-Hintergrundmodell-Portierung durchführen: TypeORM mit Capacitor.
- Brauchen Sie eine Verschlüsselung am Speicherort: Verschlüsseln eines SQLite-Datenbanks in Capacitor.
Ein native Plugin hinzufügen 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.