Kysely funziona bene con Capacitor e SQLite perché ha bisogno di una sola cosa dalla piattaforma: un driver che invii una stringa SQL con parametri e restituisca le righe come oggetti. Questa guida crea quel driver per @capgo/capacitor-fast-sql, poi copre tabelle tipizzate, CRUD, join, transazioni e migrazioni che vengono eseguite all'interno dell'app su iOS, Android e web.
Ottieni SQL verificato a compile-time, senza generazione di code e senza un runtime pesante.
Perché Kysely è ideale per SQLite mobile
Kysely è un query builder, non un ORM. Scrivi qualcosa che si legge come SQL e TypeScript controlla ogni nome di tabella e colonna e inferisce il tipo di risultato.
- Nessun decorator o reflection, quindi funziona subito con Vite ed esbuild.
- Nessuno step di generazione di code. La tua
Databaseinterface è il contratto dello schema. - Runtime leggero con un buon tree-shaking, importante per la dimensione del bundle dell'app e per live update la dimensione del download.
- L'interfaccia del driver è minima. Kysely include già il compilatore di query SQLite, l'adapter e l'introspector, quindi devi scrivere solo il transport.
Se non hai ancora configurato SQLite nella tua app, parti da Come usare SQLite in un'app Capacitor. Copre la configurazione nativa necessaria per Fast SQL (NSAllowsLocalNetworking su iOS e una regola cleartext per localhost su Android).
Installa
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 sono state spostate nel kysely/migration punto di ingresso.
Scrivi il dialetto Fast SQL
Un Kysely Dialect ha quattro factory. Tre provengono 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 booleani o di data, e su Android il plugin vincola i parametri delle query di lettura come testo, quinditruearriverebbe come la stringa'true'e non corrisponderebbe mai a un1.FastSqlConnection.executeQuerychiamaexecute()sulla connessione Fast SQL, che passa dal canale HTTP locale del plugin su iOS e Android e tramite SQLite Wasm sul web. Le righe tornano già come oggetti indicizzati per nome di colonna, esattamente ciò che Kysely si aspetta.ConnectionMutexè importante. Fast SQL mantiene una connessione nativa per database. Senza il lock, due transazioni Kysely concorrenti intreccerebbero le loro istruzioni su quella singola connessione. Anche il driver SQLite di Kysely usa lo stesso pattern.- Transazioni passano attraverso il layer nativo del plugin
beginTransaction,commiterollbackal posto del codice grezzobeginSQL, in modo che il plugin tracci correttamente lo stato delle transazioni su entrambe le piattaforme. - Savepoint non sono implementati. Se chiami
savepoint()su una transazione controllata, Kysely lanciaThe savepoint method is not supported by this driver. Aggiungisavepoint,rollbackToSavepointereleaseSavepointmetodi che eseguono il SQL del savepoint compilato, se ti servono.
Definisci le tue tabelle
Il Database l'interface mappa i nomi delle tabelle ai tipi di riga. Usa i tipi helper di Kysely per descrivere cosa il database compila.
// 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> rende id opzionale all'inserimento. ColumnType<Select, Insert, Update> permette a una colonna di essere letta come un tipo e di accettarne un altro, qui done viene letta come numero ma accetta booleani in scrittura, che il driver converte in 1/0. never on 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() in modo lazy alla prima query, quindi costruire l'istanza costa poco.
Le migrazioni all'interno dell'app
di Kysely FileMigrationProvider legge una cartella con l'API di Node fs, che non esiste in una WebView. Fornisci invece le migrazioni in code. Il Migrator salva i nomi applicati in una kysely_migration tabella ed esegue quelle in sospeso 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 zeri iniziali.
migrateToLatest()non lancia eccezioni. Restituisce{ error, results }, quindi controllaerrortu o la tua app parte con uno schema solo parzialmente migrato.- Chiamarlo a ogni avvio ha un costo minimo. Se non c'è nulla in coda, legge la tabella delle migrazioni e restituisce.
- SQLite supporta il DDL transazionale, quindi il Migrator avvolge l'esecuzione in una transazione. Funziona con il driver sopra.
- Scrivi le migrazioni contro
Kysely<any>, non contro il tuo attualeDatabasetype. Le vecchie migrazioni devono continuare a compilarsi 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 gli id delle righe normali.
Evitare .returning() se miri ad Android con la versione attuale di Fast SQL: Android restituisce righe solo per le istruzioni che iniziano con SELECT, PRAGMA oppure EXPLAIN, quindi INSERT ... RETURNING si esegue ma non restituisce nulla. iOS e web restituiscono le righe. La lettura insertId funziona ovunque.
Seleziona
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 e aggregazioni
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 il join, e gli alias (task_count, done_count) finiscono nel tipo del risultato. Poiché le righe tornano come oggetti con chiavi basate sul nome della colonna, assegna a ogni colonna selezionata un nome univoco. Due colonne chiamate entrambe id si sovrascriverebbero a vicenda nell'oggetto della riga, il che vale per 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, nel build web e nelle versioni recenti di Android, ma se il tuo minSdkVersion è vecchio, esegui SELECT json('{}') prima sul tuo dispositivo di test più vecchio.
Aggiorna ed elimina
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 il callback genera un errore, il driver esegue il rollback. Usa trx, non db, all'interno del callback. Le query su db attendono il mutex, che è detenuto dalla transazione, quindi andrebbero in deadlock fino alla fine della transazione.
SQL raw quando serve
import { sql } from 'kysely';
const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);
Il sql legge i valori interpolati come parametri, quindi è sicuro con input utente. Usa sql.raw() solo per frammenti fidati.
Usando il plugin della community invece
Se stai usando @capacitor-community/sqlite, mantieni il dialetto, l'adapter, il compilatore e il 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 di run(). Il suo comportamento predefinito avvolge ogni statement in una transazione propria, il che rompe le transazioni di Kysely. Usa il plugin beginTransaction(), commitTransaction() e rollbackTransaction() nel driver.
Risoluzione problemi
Cannot find module 'kysely/migration'. Sei su una versione di Kysely precedente alla 0.29, in cui Migrator viene esportato da kysely direttamente. Aggiorna o modifica l'import.
Le query restano in sospeso all'infinito. Hai usato db dentro db.transaction().execute(). Usa l'argomento trx .
done = true non corrisponde mai su Android. Un booleano ha raggiunto il plugin senza conversione. Assicurati che ogni valore passi attraverso toSqlValue, inclusi i valori nei sql template literals.
no such table: kysely_migration errori al primo avvio. Il Migrator crea la sua tabella da solo, quindi di solito significa che init() ha fallito prima. Controlla la configurazione nativa (rete locale iOS, regola cleartext localhost Android) e registra il primo errore.
I tipi indicano che una colonna esiste, ma SQLite dice di no. Il Database interfaccia e le migrazioni sono diverse. L'interfaccia non viene generata dal database, quindi aggiorna entrambi nello stesso commit.
Riassunto
Kysely richiede circa 80 righe di codice di collegamento per funzionare su Capacitor: una connessione che chiama execute(), un mutex e gli hook delle transazioni. In cambio ottieni SQL completamente tipizzato sopra il SQLite nativo di Fast SQL su iOS e Android e SQLite Wasm sul web.
Lettura correlata: 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.