Kysely funziona bene con Capacitor e SQLite perché richiede una sola cosa dalla piattaforma: un driver che invii una stringa SQL con parametri e restituisca righe come oggetti. Questa guida crea quel driver per @capgo/capacitor-fast-sql, per poi coprire tabelle tipizzate, CRUD, join, transazioni e migrazioni che girano all'interno dell'app su iOS, Android e web.
Ottieni SQL verificato in fase di compilazione, senza generazione di code e senza un runtime pesante.
Perché Kysely è perfetto per SQLite su mobile
Kysely è un query builder, non un ORM. Scrivi codice che sembra SQL, mentre TypeScript controlla ogni nome di tabella e di colonna e inferisce il tipo di risultato.
- Niente decoratori o reflection: funziona subito con Vite ed esbuild.
- Nessuno step di generazione di code. La tua
Databaseinterface è il contratto dello schema. - Runtime leggero con ottimo tree-shaking, fondamentale per la dimensione del bundle e per live update dimensione del download.
- L'interfaccia del driver è minima. Kysely include già il compilatore di query SQLite, l'adapter e l'introspector, quindi devi solo scrivere il trasporto.
Se non hai ancora configurato SQLite nella tua app, parti da Come usare SQLite in un'app Capacitor. Copre la configurazione nativa richiesta da Fast SQL (NSAllowsLocalNetworking su iOS e la regola cleartext su localhost per Android).
Installazione
bun add kysely @capgo/capacitor-fast-sql
bunx cap sync
Questa guida usa Kysely 0.29 e Fast SQL 8.2. In Kysely 0.29 le classi di migrazione si sono spostate nell' kysely/migration punto di ingresso.
Scrivi il dialetto Fast SQL
Un Kysely Dialect ha quattro factory. Tre derivano direttamente dal supporto SQLite di Kysely. La quarta è il nostro 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);
}
}
Cosa fa ogni parte:
toSqlValueconverte i booleani in1/0, le date in stringhe ISO e gli oggetti semplici in JSON. SQLite non ha tipi boolean o date e su Android il plugin lega i parametri di query in lettura come testo, quinditruearriverebbero come la stringa'true'e non corrisponderanno mai a un1.FastSqlConnection.executeQuerychiamateexecute()sulla connessione Fast SQL, che passa attraverso il canale HTTP locale del plugin su iOS e Android e tramite SQLite Wasm sul web. Le righe vengono restituite come oggetti con chiavi basate sul nome della colonna, esattamente ciò che si aspetta Kysely.ConnectionMutexè fondamentale. Fast SQL mantiene una connessione nativa per ogni database. Senza il lock, due transazioni Kysely concorrenti intreccerebbero le loro istruzioni su quell'unica connessione. Anche il driver SQLite di Kysely utilizza lo stesso pattern.- Transazioni esplora la parte native del plugin
beginTransaction,commiterollbackinvece di usare SQLbeginraw, così il plugin traccia correttamente lo stato della transazione su entrambe le piattaforme. - I Savepoint non sono implementati. Se chiami
savepoint()su una transazione controllata, Kysely lanciaThe savepoint method is not supported by this driver. Aggiungisavepoint,rollbackToSavepointereleaseSavepointmetodi per eseguire il SQL del savepoint compilato, se necessari.
Descrivi le tue tabelle
L'oggetto Database l'interfaccia mappa i nomi delle tabelle ai tipi di riga. Usa i tipi helper di Kysely per descrivere i dati inseriti dal database.
// 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> crea id opzionale in fase di insert. ColumnType<Select, Insert, Update> permette a una colonna di essere letta con un tipo e di accettarne un altro, qui done viene letta come un numero ma accetta booleani in scrittura, che il driver trasforma in 1/0. never in update significa che la colonna non può essere modificata.
Crea l'istanza di 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 chiama il driver init() lazy alla prima query, rendendo la creazione dell'istanza molto economica.
Le migrazioni all'interno dell'app
di Kysely FileMigrationProvider legge una cartella con il modulo Node fsche non esiste in una WebView. Fornisci le migrazioni in code invece. Il Migrator salva i nomi applicati in una kysely_migration tabella ed esegue quelle pendenti in ordine.
// 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;
}
Note:
- I nomi sono ordinati alfabeticamente, quindi usa un prefisso con zero iniziale.
migrateToLatest()non lancia eccezioni. Restituisce{ error, results }, quindi controllaerrorte stesso o la tua app con uno schema migrato a metà.- Chiamarlo a ogni avvio costa poco. Se non c'è nulla in sospeso, legge la tabella delle migrazioni e ritorna.
- SQLite supporta il DDL transazionale, quindi il Migrator racchiude l'esecuzione in una transazione. Funziona con il driver sopra.
- Scrivi le migrazioni su
Kysely<any>, non sul tuo attualeDatabasetype. Le vecchie migrazioni devono continuare a compilare dopo l'evoluzione dello schema.
Query
Inserisci
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 è un bigint in Kysely. Converti con Number() per i normali ID di riga.
Evita .returning() se hai come target Android con l'attuale versione Fast SQL: Android restituisce righe solo per le istruzioni che iniziano con SELECT, PRAGMA o EXPLAIN, quindi INSERT ... RETURNING viene eseguito ma non restituisce nulla. iOS e web restituiscono le righe. Leggere insertId funziona ovunque.
Select
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
Join e aggregati
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 sa quali colonne esistono dopo la join, e gli alias (task_count, done_count) finiscono nel tipo di risultato. Poiché le righe tornano come oggetti con chiavi basate sul nome della colonna, dai a ogni colonna selezionata un nome univoco. Due colonne chiamate entrambe id si sovrascriverebbero a vicenda nell'oggetto riga, il che accade con qualsiasi driver SQLite che restituisce oggetti.
Risultati annidati con helper 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();
Questo si basa sulle funzioni JSON di SQLite. Sono integrate in iOS, nella build web e nelle recenti release di Android, ma se il tuo minSdkVersion è vecchio, esegui SELECT json('{}') prima sul tuo dispositivo di test più datato.
Aggiorna e cancella
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));
Transazioni
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();
});
Se la callback lancia un errore, il driver effettua il rollback. Usa trx, invece di db all'interno della callback. Le query che db attendono il mutex, detenuto dalla transazione, andrebbero in deadlock fino alla fine della transazione stessa.
SQL Raw quando serve
import { sql } from 'kysely';
const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);
L'oggetto sql tag segna i valori interpolati come parametri, garantendo la sicurezza con input utente. Usa sql.raw() solo per frammenti fidati.
Usare invece il plugin della community
Se stai usando @capacitor-community/sqlite, mantieni dialect, adapter, compiler e mutex e sostituisci la connessione:
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');
}
}
Passa false come terzo argomento a run(). Il suo default avvolge ogni statement in una propria transazione, rompendo quelle di Kysely. Usa quello del plugin beginTransaction(), commitTransaction() e rollbackTransaction() nel driver.
Risoluzione problemi
Cannot find module 'kysely/migration'. Se usi una versione di Kysely precedente alla 0.29, dove Migrator viene esportato da kysely direttamente. Aggiorna o modifica l'import.
Le query si bloccano per sempre. Hai usato db dentro db.transaction().execute(). Usa l' trx argomento.
done = true non corrisponde mai su Android. Un booleano è arrivato al plugin senza conversione. Assicurati che ogni valore passi per toSqlValue, inclusi i valori in sql template literal.
no such table: kysely_migration errori al primo avvio. Il Migrator crea la propria tabella, quindi di solito significa che init() è fallito in precedenza. Controlla la configurazione nativa (iOS local networking, regola Android cleartext localhost) e logga il primo errore.
I tipi indicano che una colonna esiste, ma SQLite dice il contrario. L'oggetto Database l'interfaccia e le migrazioni sono sfasate. L'interfaccia non è generata dal database, quindi aggiornale entrambe nello stesso commit.
Riassunto
Kysely richiede circa 80 righe di codice di raccordo per girare su Capacitor: una connessione che chiama execute(), un mutex e gli hook delle transazioni. In cambio, ottieni SQL completamente tipizzato sopra il nativo SQLite di Fast SQL su iOS e Android e SQLite Wasm sul web.
Letture correlate: Drizzle ORM con Capacitor se preferisci definizioni schema-first, TypeORM con Capacitor per decorator e repository, e la documentazione del plugin Fast SQL per ogni opzione di connessione.