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
Databaseinterface 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 :
toSqlValueconvertit les booléens en1/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, donctruearriverait sous forme de chaîne'true'et ne correspondent jamais à un1.FastSqlConnection.executeQueryappelleexecute()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.ConnectionMutexest 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,commitetrollbackau lieu dubeginSQL, 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 uneThe savepoint method is not supported by this driver. Ajoutezsavepoint,rollbackToSavepointetreleaseSavepointmé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 doncerrorvous-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 votreDatabasetype 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.