Aller au contenu principal

Comment utiliser Kysely avec Capacitor et SQLite

Utilisez Kysely avec Capacitor et SQLite : écrivez un petit dialecte Kysely pour Fast SQL, typez vos tables, exécutez des requêtes, des transactions et des migrations in-app.

Logo Capacitor pour un guide sur l'utilisation de Kysely avec Capacitor et SQLite

Kysely fonctionne bien avec Capacitor et SQLite car il n'a besoin que d'une seule chose de la plateforme : un driver qui envoie une chaîne SQL avec des paramètres et retourne des lignes sous forme d'objets. Ce guide construit ce driver pour @capgo/capacitor-fast-sql, puis couvre les tables typées, le CRUD, les joins, les transactions et les migrations s'exécutant dans l'app sur iOS, Android et le web.

Vous obtenez du SQL vérifié à la compilation, sans génération code et sans runtime lourd.

Pourquoi Kysely est idéal pour SQLite sur mobile

Kysely est un query builder, pas un ORM. Vous écrivez du code qui ressemble à du SQL, et TypeScript vérifie chaque nom de table et de colonne et infère le type du résultat.

  • Pas de décorateurs ni de réflexion, il fonctionne donc avec Vite et esbuild sans configuration.
  • Aucune étape de génération code. Votre Database interface est le contrat du schéma.
  • Runtime léger qui se tree-shake bien, ce qui compte pour la taille du bundle de l'app et pour live update taille du téléchargement.
  • L’interface du driver est minuscule. Kysely contient déjà le compilateur de requêtes SQLite, l’adaptateur et l’introspecteur, il vous suffit donc d’écrire le transport.

Si vous n’avez pas encore configuré SQLite dans votre application, commencez par Comment utiliser SQLite dans une application Capacitor. Il couvre la configuration native dont Fast SQL a besoin (NSAllowsLocalNetworking sur iOS et une règle de texte en clair sur localhost sur Android).

Installer

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

Ce guide utilise Kysely 0.29 et Fast SQL 8.2. Dans Kysely 0.29, les classes de migration ont été déplacées vers le kysely/migration point d’entrée.

Écrivez le dialecte Fast SQL

Un Kysely Dialect dispose de quatre fabriques. Trois proviennent directement de la prise en charge SQLite de Kysely. La quatrième est notre 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);
  }
}

Rôle de chaque partie :

  • toSqlValue convertit les booléens en 1/0, les dates en chaînes ISO et les objets simples en JSON. SQLite ne possède ni type booléen ni type date, et sur Android le plugin lie les paramètres des requêtes de lecture en tant que texte, donc true arriverait sous forme de chaîne 'true' et ne correspondent jamais à un 1.
  • FastSqlConnection.executeQuery appelle execute() sur la connexion Fast SQL, qui passe par le canal HTTP local du plugin sur iOS et Android, et via SQLite Wasm sur le web. Les lignes reviennent déjà sous forme d'objets indexés par le nom de colonne, ce qui est exactement ce que Kysely attend.
  • ConnectionMutex est crucial. Fast SQL maintient une connexion native par base de données. Sans le verrou, deux transactions Kysely simultanées entrelaceraient leurs instructions sur cette unique connexion. Le driver SQLite de Kysely lui-même utilise le même schéma.
  • Transactions passent par la couche native du plugin beginTransaction, commit et rollback au lieu du begin SQL, ainsi le plugin suit correctement l'état des transactions sur les deux plateformes.
  • Savepoints ne sont pas implémentés. Si vous appelez savepoint() sur une transaction contrôlée, Kysely lève une The savepoint method is not supported by this driver. Ajoutez savepoint, rollbackToSavepoint et releaseSavepoint méthodes qui exécutent le SQL compilé du savepoint si vous en avez besoin.

Décrivez vos tables

Le Database L’interface associe les noms de tables aux types de lignes. Utilisez les types utilitaires de Kysely pour décrire ce que la base de données remplit.

// 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> permet id optionnel à l'insertion. ColumnType<Select, Insert, Update> permet à une colonne d'être lue comme un type et d'en accepter un autre, ici done se lit comme un nombre mais accepte des booléens à l'écriture, que le driver convertit en 1/0. never on update signifie que la colonne ne peut pas être modifiée.

Créez l'instance Kysely

// 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 appelle le pilote init() lazily on the first query, so constructing the instance is cheap.

Migrations dans l'app

de Kysely FileMigrationProvider lit un dossier avec fs, qui n'existe pas dans un WebView. Fournissez les migrations dans code à la place. Le Migrator stocke les noms appliqués dans un kysely_migration table et exécute les requêtes en attente dans l’ordre.

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

Notes :

  • Les noms sont triés alphabétiquement, utilisez donc un préfixe avec des zéros.
  • migrateToLatest() ne lève pas d'exception. Il renvoie { error, results }, vérifiez donc error vous-même ou votre application se retrouve avec un schéma à moitié migré.
  • L'appeler à chaque lancement coûte peu. Si rien n'est en attente, il lit la table de migration et retourne.
  • SQLite prend en charge le DDL transactionnel, donc le Migrator enveloppe l'exécution dans une transaction. Cela fonctionne avec le driver ci-dessus.
  • Écrivez vos migrations en fonction de Kysely<any>, et non de votre Database type actuel. Les anciennes migrations doivent continuer à compiler après l'évolution du schéma.

Requêtes

Insertion

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 est un bigint dans Kysely. Convertissez avec Number() pour les IDs de ligne classiques.

Évitez .returning() si vous ciblez Android avec la version actuelle de Fast SQL : Android ne renvoie des lignes que pour les requêtes commençant par SELECT, PRAGMA ou EXPLAIN, donc INSERT ... RETURNING s'exécute mais ne renvoie rien. iOS et le web renvoient les lignes. La lecture insertId fonctionne partout.

Sélectionnez

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

Jointures et agrégations

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 sait quelles colonnes existent après la jointure, et les alias (task_count, done_count) se retrouvent dans le type de résultat. Comme les lignes sont renvoyées sous forme d'objets dont les clés sont les noms de colonnes, donnez un nom unique à chaque colonne sélectionnée. Deux colonnes nommées id s'écraseraient l'une l'autre dans l'objet ligne, ce qui vaut pour n'importe quel driver SQLite qui renvoie des objets.

Résultats imbriqués avec les helpers JSON

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

Cela repose sur les fonctions JSON de SQLite. Elles sont intégrées sur iOS, dans le build web et dans les versions récentes d'Android, mais si votre minSdkVersion is old, run SELECT json('{}') on your oldest test device first.

Mettre à jour et supprimer

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

Transactions

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

Si le callback lève une exception, le driver effectue un rollback. Utilisez trx, et non db, dans le callback. Les requêtes sur db attendent le mutex, détenu par la transaction, ce qui provoquerait un deadlock jusqu'à la fin de la transaction.

SQL brut quand vous en avez besoin

import { sql } from 'kysely';

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

Le sql tag lie les valeurs interpolées comme paramètres, donc il est sûr avec les entrées utilisateur. Utilisez sql.raw() uniquement pour les fragments de confiance.

Utiliser plutôt le plugin communautaire

Si vous êtes sur @capacitor-community/sqlite, conservez le dialecte, l’adaptateur, le compilateur et le mutex, et remplacez la connexion :

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

Passer false comme troisième argument de run(). Par défaut, il enveloppe chaque instruction dans sa propre transaction, ce qui casse les transactions Kysely. Utilisez le beginTransaction(), commitTransaction() et rollbackTransaction() dans le driver.

Troubleshooting

Cannot find module 'kysely/migration'. Vous utilisez une version de Kysely antérieure à 0.29, où Migrator est exporté depuis kysely directement. Mettez à jour ou modifiez l’import.

Les requêtes restent bloquées indéfiniment. You used db à l'intérieur db.transaction().execute(). Utilisez trx argument.

done = true correspond jamais sur Android. Un booléen a atteint le plugin sans conversion. Assurez-vous que chaque valeur passe par toSqlValue, y compris les valeurs dans sql les template literals.

no such table: kysely_migration des erreurs au premier lancement. Le Migrator crée sa table lui-même, ce qui signifie généralement que init() a échoué plus tôt. Vérifiez la configuration native (réseau local iOS, règle localhost en clair Android) et journalisez la première erreur.

Les types indiquent qu'une colonne existe, mais SQLite dit le contraire. Le Database l'interface et les migrations ont divergé. L'interface n'étant pas générée depuis la base de données, mettez les deux à jour dans le même commit.

Résumé

Kysely nécessite environ 80 lignes de code pour fonctionner sur Capacitor : une connexion qui appelle execute(), un mutex et des hooks de transaction. En échange, vous obtenez du SQL entièrement typé par-dessus le SQLite natif de Fast SQL sur iOS et Android, et SQLite Wasm sur le web.

À lire aussi : Drizzle ORM avec Capacitor si vous préférez les définitions schema-first, TypeORM avec Capacitor pour les décorateurs et les repositories, et la doc du plugin Fast SQL pour chaque option de connexion.

Live updates pour les apps Capacitor

Lorsqu'un bug de la couche web est en ligne, déployez le correctif via Capgo au lieu d'attendre des jours l'approbation des stores. Les utilisateurs reçoivent la mise à jour en arrière-plan, tandis que les changements natifs suivent le circuit de validation habituel.

Les derniers articles du blog

Capgo vous offre les meilleures informations pour créer une application mobile vraiment professionnelle.