Kysely funciona muy bien con Capacitor y SQLite porque solo necesita una cosa de la plataforma: un driver que envía un string SQL con parámetros y devuelve filas como objetos. Esta guía crea ese driver para @capgo/capacitor-fast-sql, y luego cubre tablas tipadas, CRUD, joins, transacciones y migraciones que se ejecutan dentro de la app en iOS, Android y la web.
Obtienes SQL verificado en tiempo de compilación sin generación de code y sin un runtime pesado.
Por qué Kysely encaja con SQLite móvil
Kysely es un query builder, no un ORM. Escribes algo que se lee como SQL, y TypeScript verifica cada nombre de tabla y columna e infiere el tipo de resultado.
- Sin decoradores ni reflexión, así que funciona con Vite y esbuild sin configuración.
- Sin paso de generación de code. Tu
Databaseinterface es el contrato del esquema. - Runtime pequeño que se tree-shakea bien, clave para el tamaño del bundle de la app y para live update tamaño de descarga.
- La interfaz del driver es mínima. Kysely ya incluye el compilador de consultas SQLite, el adapter y el introspector, así que solo tienes que escribir el transporte.
Si aún no has configurado SQLite en tu app, empieza con Cómo usar SQLite en una app de Capacitor. Cubre la configuración nativa que requiere Fast SQL (NSAllowsLocalNetworking en iOS y una regla de cleartext para localhost en Android).
Instalar
bun add kysely @capgo/capacitor-fast-sql
bunx cap sync
Esta guía usa Kysely 0.29 y Fast SQL 8.2. En Kysely 0.29, las clases de migración se movieron al kysely/migration punto de entrada.
Escribe el dialecto de Fast SQL
Un Kysely Dialect tiene cuatro factories. Tres vienen directamente del soporte SQLite de Kysely. El cuarto es nuestro 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);
}
}
Qué hace cada parte:
toSqlValueconvierte booleanos a1/0, fechas a strings ISO y objetos planos a JSON. SQLite no tiene tipo boolean ni date, y en Android el plugin vincula los parámetros de las consultas de lectura como texto, por lo quetruellegarían como el string'true'y nunca coincida con1.FastSqlConnection.executeQueryllamadasexecute()en la conexión de Fast SQL, que pasa por el canal HTTP local del plugin en iOS y Android y a través de SQLite Wasm en la web. Las filas ya vuelven como objetos con claves por nombre de columna, que es exactamente lo que Kysely espera.ConnectionMuteximporta. Fast SQL mantiene una conexión nativa por base de datos. Sin el lock, dos transacciones Kysely concurrentes entrelazarían sus sentencias en esa única conexión. El propio driver de SQLite de Kysely usa el mismo patrón.- Transacciones pasan por el nativo del plugin
beginTransaction,commityrollbacken lugar de SQL crudobeginSQL, para que el plugin rastree el estado de la transacción correctamente en ambas plataformas. - Los savepoints no están implementados. Si llamas a
savepoint()en una transacción controlada, Kysely lanzaThe savepoint method is not supported by this driver. Añadesavepoint,rollbackToSavepointyreleaseSavepointmétodos que ejecutan el SQL compilado del savepoint si los necesitas.
Describe tus tablas
El Database La interfaz asigna nombres de tablas a tipos de fila. Usa los tipos auxiliares de Kysely para describir qué rellena la base de datos.
// 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> hace que id sea opcional al insertar. ColumnType<Select, Insert, Update> permite que una columna se lea como un tipo y acepte otro, aquí done se lee como un número pero acepta booleanos al escribir, que el driver convierte en 1/0. never al actualizar significa que la columna no se puede cambiar.
Crea la instancia de 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 llama al driver de forma init() perezosa en la primera consulta, por lo que construir la instancia es barato.
Migraciones dentro de la app
de Kysely FileMigrationProvider lee una carpeta con Node's fs, que no existe en un WebView. Proporciona las migraciones en code en su lugar. El Migrator guarda los nombres aplicados en un kysely_migration tabla y ejecuta las pendientes en orden.
// 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;
}
Notas:
- Los nombres se ordenan alfabéticamente, así que usa un prefijo con ceros.
migrateToLatest()no lanza errores. Devuelve{ error, results }, so checkerrortú mismo o tu app arrancará con un esquema migrado a medias.- Llamarlo en cada arranque es barato. Si no hay nada pendiente, lee la tabla de migraciones y devuelve.
- SQLite soporta DDL transaccional, así que el Migrator envuelve la ejecución en una transacción. Eso funciona con el driver anterior.
- Escribe las migraciones para
Kysely<any>, no el actualDatabasetype. Las migraciones antiguas deben seguir compilando a medida que evoluciona el esquema.
Consultas
Insertar
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 es un bigint en Kysely. Convierte con Number() para ids de fila normales.
Evita .returning() si tu objetivo es Android con la versión actual de Fast SQL: Android solo devuelve filas para sentencias que comienzan con SELECT, PRAGMA o EXPLAIN, así que INSERT ... RETURNING se ejecuta pero no devuelve nada. iOS y web sí devuelven las filas. Leer insertId funciona en todas partes.
Selecciona
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 y agregados
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 sabe qué columnas existen después del join, y los alias (task_count, done_count) terminan en el tipo de resultado. Como las filas se devuelven como objetos con claves por nombre de columna, asigna un nombre único a cada columna seleccionada. Dos columnas llamadas id se sobrescribirían entre sí en el objeto de fila, algo que ocurre con cualquier driver de SQLite que devuelve objetos.
Resultados anidados con 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();
Esto depende de las funciones JSON de SQLite. Están integradas en iOS, en el build web y en las versiones recientes de Android, pero si tu minSdkVersion es antiguo, ejecuta SELECT json('{}') primero en tu dispositivo de prueba más antiguo.
Actualizar y eliminar
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));
Transacciones
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 el callback lanza un error, el driver hace rollback. Usa trx, no db, dentro del callback. Las consultas en db esperan al mutex, que está retenido por la transacción, así que se bloquearían hasta que esta termine.
SQL puro cuando lo necesites
import { sql } from 'kysely';
const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);
El sql tag vincula los valores interpolados como parámetros, así que es seguro con inputs de usuario. Usa sql.raw() solo para fragmentos de confianza.
Usar el plugin de la comunidad
Si estás en @capacitor-community/sqlite, mantén el dialecto, el adaptador, el compilador y el mutex, y reemplaza la conexión:
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');
}
}
Pasa false como tercer argumento de run(). Su valor predeterminado envuelve cada sentencia en su propia transacción, lo que rompe las transacciones de Kysely. Usa el plugin’s beginTransaction(), commitTransaction() y rollbackTransaction() en el driver.
Solución de problemas
Cannot find module 'kysely/migration'. Estás usando una versión de Kysely anterior a la 0.29, donde Migrator se exporta desde kysely directamente. Actualiza o cambia el import.
Las queries se quedan colgadas para siempre. Usaste db dentro del db.transaction().execute(). Usa el trx argumento.
done = true nunca coincide en Android. Un booleano llegó al plugin sin conversión. Asegúrate de que cada valor pase por toSqlValue, incluidos los valores en sql las literales de plantilla.
no such table: kysely_migration errores en el primer arranque. El Migrator crea su propia tabla, por lo que esto suele significar init() falló antes. Revisa la configuración nativa (red local en iOS, regla de localhost en texto claro en Android) y registra el primer error.
Los tipos dicen que existe una columna, pero SQLite dice que no. El Database la interfaz y las migraciones se desincronizaron. La interfaz no se genera desde la base de datos, así que actualiza ambas en el mismo commit.
Resumen
Kysely necesita unas 80 líneas de glue para funcionar en Capacitor: una conexión que llama a execute()un mutex y hooks de transacción. A cambio, obtienes SQL totalmente tipado sobre el SQLite nativo de Fast SQL en iOS y Android, y SQLite Wasm en la web.
Lecturas relacionadas: Drizzle ORM con Capacitor si prefieres definiciones schema-first, TypeORM con Capacitor para decoradores y repositorios, y el docs del plugin Fast SQL para cada opción de conexión.