Zum Hauptinhalt springen

Kysely mit Capacitor und SQLite nutzen

Nutze Kysely mit Capacitor und SQLite: schreibe einen kleinen Kysely-Dialekt für Fast SQL, typisiere deine Tabellen und führe Queries, Transaktionen und In-App-Migrationen aus.

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

Kysely funktioniert gut mit Capacitor und SQLite, weil es nur eine Sache von der Plattform braucht: einen Driver, der einen SQL-String mit Parametern sendet und Zeilen als Objekte zurückgibt. Dieser Guide baut diesen Driver für @capgo/capacitor-fast-sql, und behandelt dann typisierte Tabellen, CRUD, Joins, Transaktionen und Migrationen, die in der App auf iOS, Android und im Web laufen.

Du erhältst SQL mit Compile-Time-Checks ohne code-Generierung und ohne schweres Runtime.

Warum Kysely zu mobilem SQLite passt

Kysely ist ein Query Builder, kein ORM. Du schreibst Code, der wie SQL aussieht, und TypeScript prüft jeden Tabellen- und Spaltennamen und leitet den Ergebnistyp ab.

  • Keine Decorator oder Reflection, daher funktioniert es mit Vite und esbuild out of the box.
  • Kein code Generierungsschritt. Deine Database interface ist der Schema-Vertrag.
  • Kleines Runtime, das sich gut tree-shaken lässt – wichtig für die App-Bundle-Größe und die live update Download-Größe.
  • Die Driver-Schnittstelle ist minimal. Kysely enthält bereits den SQLite-Query-Compiler, den Adapter und den Introspector, sodass du nur den Transport implementieren musst.

Falls du SQLite in deiner App noch nicht eingerichtet hast, starte mit So nutzt du SQLite in einer Capacitor App. Es deckt die native Konfiguration ab, die Fast SQL benötigt (NSAllowsLocalNetworking auf iOS sowie eine localhost-Cleartext-Regel auf Android).

Installieren

bun add kysely @capgo/capacitor-fast-sql
bunx cap sync

Dieser Guide nutzt Kysely 0.29 und Fast SQL 8.2. In Kysely 0.29 wurden die Migrationsklassen zum kysely/migration Entry Point verschoben.

Fast SQL Dialect schreiben

Ein Kysely Dialect hat vier Factories. Drei davon stammen direkt aus dem SQLite-Support von Kysely. Die vierte ist unser Driver.

// src/db/fast-sql-dialect.ts
import {
  CompiledQuery,
  SqliteAdapter,
  SqliteIntrospector,
  SqliteQueryCompiler,
  type DatabaseConnection,
  type DatabaseIntrospector,
  type Dialect,
  type DialectAdapter,
  type Driver,
  type Kysely,
  type QueryCompiler,
  type QueryResult,
} from 'kysely';
import {
  FastSQL,
  type SQLConnection,
  type SQLConnectionOptions,
  type SQLValue,
} from '@capgo/capacitor-fast-sql';

// Kysely passes JS values as-is. Normalize them to what SQLite stores.
function toSqlValue(value: unknown): SQLValue {
  if (value === undefined || value === null) return null;
  if (typeof value === 'boolean') return value ? 1 : 0;
  if (value instanceof Date) return value.toISOString();
  if (typeof value === 'bigint') return Number(value);
  if (value instanceof Uint8Array) return value;
  if (typeof value === 'object') return JSON.stringify(value);
  return value as SQLValue;
}

class FastSqlConnection implements DatabaseConnection {
  constructor(readonly db: SQLConnection) {}

  async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
    const result = await this.db.execute(compiled.sql, compiled.parameters.map(toSqlValue));
    return {
      rows: result.rows as R[],
      numAffectedRows: result.rowsAffected != null ? BigInt(result.rowsAffected) : undefined,
      insertId: result.insertId != null ? BigInt(result.insertId) : undefined,
    };
  }

  // eslint-disable-next-line require-yield
  async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
    throw new Error('Streaming is not supported by the Fast SQL driver');
  }
}

// One native connection per database, so serialize access to it.
class ConnectionMutex {
  #pending: Promise<void> = Promise.resolve();
  #release?: () => void;

  async lock(): Promise<void> {
    const previous = this.#pending;
    let release!: () => void;
    this.#pending = new Promise<void>((resolve) => (release = resolve));
    await previous;
    this.#release = release;
  }

  unlock(): void {
    const release = this.#release;
    this.#release = undefined;
    release?.();
  }
}

class FastSqlDriver implements Driver {
  #connection?: FastSqlConnection;
  readonly #mutex = new ConnectionMutex();

  constructor(private readonly options: SQLConnectionOptions) {}

  async init(): Promise<void> {
    const db = await FastSQL.connect(this.options);
    this.#connection = new FastSqlConnection(db);
  }

  async acquireConnection(): Promise<DatabaseConnection> {
    await this.#mutex.lock();
    return this.#connection!;
  }

  async releaseConnection(): Promise<void> {
    this.#mutex.unlock();
  }

  async beginTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.beginTransaction();
  }

  async commitTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.commit();
  }

  async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.rollback();
  }

  async destroy(): Promise<void> {
    if (this.#connection) {
      await FastSQL.disconnect(this.options.database);
      this.#connection = undefined;
    }
  }
}

export class FastSqlDialect implements Dialect {
  constructor(private readonly options: SQLConnectionOptions) {}

  createDriver(): Driver {
    return new FastSqlDriver(this.options);
  }

  createQueryCompiler(): QueryCompiler {
    return new SqliteQueryCompiler();
  }

  createAdapter(): DialectAdapter {
    return new SqliteAdapter();
  }

  createIntrospector(db: Kysely<any>): DatabaseIntrospector {
    return new SqliteIntrospector(db);
  }
}

Was die einzelnen Teile machen:

  • toSqlValue konvertiert Booleans zu 1/0, Datumsangaben zu ISO-Strings und Plain Objects zu JSON. SQLite hat keinen Boolean- oder Date-Typ, und auf Android bindet das Plugin Read-Query-Parameter als Text, sodass true als String ankommen würden: 'true' und nie eine Übereinstimmung ergeben 1.
  • FastSqlConnection.executeQuery ruft execute() über die Fast SQL-Verbindung, die auf iOS und Android über den lokalen HTTP-Channel des Plugins läuft und im Web über SQLite Wasm. Zeilen kommen bereits als Objekte zurück, die nach Spaltennamen indiziert sind – genau das, was Kysely erwartet.
  • ConnectionMutex ist entscheidend. Fast SQL hält eine native Verbindung pro Datenbank. Ohne Lock würden zwei parallele Kysely-Transaktionen ihre Statements auf dieser einen Verbindung mischen. Kyselys eigener SQLite-Treiber nutzt dasselbe Muster.
  • Transaktionen über die native Schnittstelle des Plugins beginTransaction, commit und rollback statt rohem begin SQL, damit das Plugin den Transaktionsstatus auf beiden Plattformen korrekt verwaltet.
  • Savepoints sind nicht implementiert. Wenn du savepoint() auf einer kontrollierten Transaktion aufrufst, wirft Kysely The savepoint method is not supported by this driver. Füge hinzu savepoint, rollbackToSavepoint und releaseSavepoint Methoden, die den kompilierten Savepoint-SQL ausführen, falls du sie brauchst.

Beschreibe deine Tabellen

Der Database interface mappt Tabellennamen auf Row-Typen. Nutze Kyselys Helper-Typen, um zu beschreiben, was die Datenbank einfüllt.

// src/db/schema.ts
import type { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely';

export interface ProjectTable {
  id: Generated<number>;
  name: string;
  created_at: ColumnType<string, string | undefined, never>;
}

export interface TaskTable {
  id: Generated<number>;
  project_id: number;
  title: string;
  done: ColumnType<number, number | boolean | undefined, number | boolean>;
  due_at: string | null;
}

export interface Database {
  projects: ProjectTable;
  tasks: TaskTable;
}

export type Task = Selectable<TaskTable>;
export type NewTask = Insertable<TaskTable>;
export type TaskUpdate = Updateable<TaskTable>;

Generated<number> macht id beim Insert optional. ColumnType<Select, Insert, Update> lässt eine Spalte als einen Typ lesen und einen anderen akzeptieren, hier done liest als Zahl, akzeptiert aber Booleans beim Schreiben, die der Driver in 1/0. never beim Update bedeutet, dass die Spalte nicht geändert werden kann.

Erstelle die Kysely-Instanz

// src/db/index.ts
import { Kysely } from 'kysely';
import { FastSqlDialect } from './fast-sql-dialect';
import type { Database } from './schema';
import { runMigrations } from './migrations';

let instance: Promise<Kysely<Database>> | null = null;

export function getDb(): Promise<Kysely<Database>> {
  if (!instance) {
    instance = (async () => {
      const db = new Kysely<Database>({
        dialect: new FastSqlDialect({
          database: 'tasks',
          walMode: true,
          performancePresets: true, // also enables foreign_keys
        }),
      });
      await runMigrations(db);
      return db;
    })();
  }
  return instance;
}

Kysely ruft die Driver- init() lazy beim ersten Query auf, daher ist das Erstellen der Instanz günstig.

Migrations in der App

Kyselys FileMigrationProvider liest einen Ordner mit Node- fs, das in einer WebView nicht existiert. Stelle Migrations in code bereit. Die Migrator speichert angewendete Namen in einer kysely_migration Tabelle und führt ausstehende nacheinander aus.

// src/db/migrations.ts
import { sql, type Kysely } from 'kysely';
import { Migrator, type Migration, type MigrationProvider } from 'kysely/migration';

const migrations: Record<string, Migration> = {
  '0001_init': {
    async up(db: Kysely<any>) {
      await db.schema
        .createTable('projects')
        .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
        .addColumn('name', 'text', (col) => col.notNull())
        .addColumn('created_at', 'text', (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
        .execute();

      await db.schema
        .createTable('tasks')
        .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
        .addColumn('project_id', 'integer', (col) =>
          col.notNull().references('projects.id').onDelete('cascade'),
        )
        .addColumn('title', 'text', (col) => col.notNull())
        .addColumn('done', 'integer', (col) => col.notNull().defaultTo(0))
        .execute();
    },
  },
  '0002_due_date': {
    async up(db: Kysely<any>) {
      await db.schema.alterTable('tasks').addColumn('due_at', 'text').execute();
      await db.schema.createIndex('idx_tasks_project').on('tasks').column('project_id').execute();
    },
  },
};

class InCodeMigrationProvider implements MigrationProvider {
  async getMigrations(): Promise<Record<string, Migration>> {
    return migrations;
  }
}

export async function runMigrations(db: Kysely<any>): Promise<void> {
  const migrator = new Migrator({ db, provider: new InCodeMigrationProvider() });
  const { error, results } = await migrator.migrateToLatest();

  results?.forEach((r) => {
    if (r.status === 'Error') console.error(`Migration ${r.migrationName} failed`);
  });
  if (error) throw error;
}

Hinweise:

  • Namen werden alphabetisch sortiert, daher einen nullgefüllten Präfix verwenden.
  • migrateToLatest() wirft keine Fehler. Es gibt { error, results }, also prüfen error du selbst oder deine App mit einem halb migrierten Schema startet.
  • Der Aufruf bei jedem Start ist günstig. Wenn nichts aussteht, liest er die Migrationstabelle und kehrt zurück.
  • SQLite unterstützt transaktionales DDL, daher kapselt der Migrator den Durchlauf in einer Transaktion. Das funktioniert mit dem obigen Driver.
  • Schreibe Migrationen gegen Kysely<any>, nicht gegen deinen aktuellen Database Typ. Alte Migrationen müssen auch nach Schema-Änderungen noch kompilieren.

Abfragen

Einfügen

const db = await getDb();

const result = await db
  .insertInto('projects')
  .values({ name: 'Groceries' })
  .executeTakeFirst();

const projectId = Number(result.insertId);

await db
  .insertInto('tasks')
  .values([
    { project_id: projectId, title: 'Milk' },
    { project_id: projectId, title: 'Bread', done: true },
  ])
  .execute();

insertId ist ein bigint in Kysely. Konvertiere mit Number() für normale Row-IDs.

Vermeide .returning() wenn du mit der aktuellen Fast SQL-Version Android als Ziel hast: Android gibt nur Zeilen für Anweisungen zurück, die mit SELECT, PRAGMA oder EXPLAIN, also INSERT ... RETURNING läuft, liefert aber nichts zurück. iOS und Web geben die Zeilen aus. Lesen insertId funktioniert überall.

Wähle

const openTasks = await db
  .selectFrom('tasks')
  .select(['id', 'title', 'due_at'])
  .where('project_id', '=', projectId)
  .where('done', '=', 0)
  .orderBy('id')
  .execute();
// openTasks: { id: number; title: string; due_at: string | null }[]

const project = await db
  .selectFrom('projects')
  .selectAll()
  .where('id', '=', projectId)
  .executeTakeFirst(); // undefined if not found

Joins und Aggregationen

const summary = await db
  .selectFrom('projects as p')
  .leftJoin('tasks as t', 't.project_id', 'p.id')
  .select((eb) => [
    'p.id',
    'p.name',
    eb.fn.count<number>('t.id').as('task_count'),
    eb.fn.sum<number>('t.done').as('done_count'),
  ])
  .groupBy('p.id')
  .execute();

Kysely weiß, welche Spalten nach dem Join existieren, und die Aliase (task_count, done_count) landen im Resultat-Typ. Da Zeilen als Objekte mit Spaltennamen als Keys zurückkommen, muss jede ausgewählte Spalte einen eindeutigen Namen haben. Zwei Spalten mit demselben Namen id würden sich im Row-Objekt überschreiben, was für jeden SQLite-Treiber gilt, der Objekte zurückgibt.

Verschachtelte Ergebnisse mit JSON-Helfern

import { jsonArrayFrom } from 'kysely/helpers/sqlite';

const projects = await db
  .selectFrom('projects')
  .select((eb) => [
    'projects.id',
    'projects.name',
    jsonArrayFrom(
      eb.selectFrom('tasks')
        .select(['tasks.id', 'tasks.title'])
        .whereRef('tasks.project_id', '=', 'projects.id'),
    ).as('tasks'),
  ])
  .execute();

Dies basiert auf den JSON-Funktionen von SQLite. Sie sind in iOS, im Web-Build und in neueren Android-Releases bereits integriert, aber falls dein minSdkVersion alt ist, führe aus SELECT json('{}') zuerst auf deinem ältesten Testgerät aus.

Aktualisieren und Löschen

await db.updateTable('tasks').set({ done: true }).where('id', '=', taskId).execute();

const deleted = await db.deleteFrom('tasks').where('done', '=', 1).executeTakeFirst();
console.log(Number(deleted.numDeletedRows));

Transaktionen

await db.transaction().execute(async (trx) => {
  const { insertId } = await trx
    .insertInto('projects')
    .values({ name: 'Trip' })
    .executeTakeFirstOrThrow();

  await trx
    .insertInto('tasks')
    .values(['Passport', 'Tickets', 'Charger'].map((title) => ({
      project_id: Number(insertId),
      title,
    })))
    .execute();
});

Wirft der Callback einen Fehler, macht der Driver einen Rollback. Nutze trx, nicht db, innerhalb des Callbacks. Abfragen auf db warten auf den Mutex, den die Transaktion hält, und würden bis zum Ende der Transaktion im Deadlock hängen.

Raw SQL, wenn du es brauchst

import { sql } from 'kysely';

const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);

Der sql tag bindet interpolierte Werte als Parameter, ist damit bei User-Input sicher. Nutze sql.raw() nur für vertrauenswürdige Fragmente.

Community-Plugin verwenden

Falls du auf @capacitor-community/sqlite, behalte Dialekt, Adapter, Compiler und Mutex bei und ersetze die Verbindung:

import type { SQLiteDBConnection } from '@capacitor-community/sqlite';

class CommunityConnection implements DatabaseConnection {
  constructor(readonly db: SQLiteDBConnection) {}

  async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
    const params = compiled.parameters.map(toSqlValue);
    if (/^\s*(select|pragma|with)\b/i.test(compiled.sql)) {
      const res = await this.db.query(compiled.sql, params);
      return { rows: (res.values ?? []) as R[] };
    }
    const res = await this.db.run(compiled.sql, params, false, 'all');
    return {
      rows: (res.changes?.values ?? []) as R[],
      numAffectedRows: BigInt(res.changes?.changes ?? 0),
      insertId: res.changes?.lastId != null ? BigInt(res.changes.lastId) : undefined,
    };
  }

  async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
    throw new Error('Streaming is not supported');
  }
}

Übergeben false als drittes Argument an run(). Standardmäßig wird jede Anweisung in eine eigene Transaktion verpackt, was Kysely-Transaktionen bricht. Verwende das beginTransaction(), commitTransaction() und rollbackTransaction() im Driver.

Fehlerbehebung

Cannot find module 'kysely/migration'. Du nutzt Kysely älter als 0.29, wo Migrator wird exportiert aus kysely direkt. Import aktualisieren oder ändern.

Queries hängen ewig. Du hast db innerhalb db.transaction().execute(). Verwende das trx -Argument.

done = true trifft auf Android nie zu. Ein Boolean erreichte das Plugin ohne Konvertierung. Stelle sicher, dass jeder Wert durch toSqlValueläuft, einschließlich Werte in sql Template-Literals.

no such table: kysely_migration Fehler beim ersten Start. Der Migrator erstellt seine Tabelle selbst, daher bedeutet dies in der Regel, dass init() vorher fehlgeschlagen ist. Prüfe das native Setup (iOS Local Networking, Android Cleartext-Localhost-Regel) und logge den ersten Fehler.

Die Typen sagen, eine Spalte existiert, aber SQLite sagt, sie existiert nicht. Der Database Das Interface und die Migrationen sind auseinandergelaufen. Das Interface wird nicht aus der Datenbank generiert, also aktualisiere beide im selben Commit.

Zusammenfassung

Kysely braucht rund 80 Zeilen Glue-Code, um auf Capacitor zu laufen: eine Verbindung, die aufruft, execute()einen Mutex und Transaction-Hooks. Dafür bekommst du voll typisiertes SQL auf Basis von Fast SQLs nativem SQLite auf iOS und Android sowie SQLite Wasm im Web.

Weiterführende Artikel: Drizzle ORM mit Capacitor falls du Schema-First-Definitionen bevorzugst, TypeORM mit Capacitor für Decorators und Repositories, sowie die Fast SQL Plugin-Docs für jede Verbindungsoption.

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.