Kysely funciona bien con Capacitor y SQLite porque solo necesita una cosa del plataforma: un controlador que envíe una cadena SQL con parámetros y devuelva filas como objetos. Este guía construye ese controlador para @capgo/capacitor-fast-sqlEntonces cubre tablas tipadas, CRUD, joins, transacciones y migraciones que se ejecutan dentro de la aplicación en iOS, Android y la web.
Tiene SQL verificado en tiempo de compilación sin code generación y sin un tiempo de ejecución pesado.
Why Kysely se adapta a SQLite móvil
Kysely es un constructor de consultas, no un ORM. Escribe algo que se lee como SQL, y TypeScript verifica cada nombre de tabla y columna y infiere el tipo de resultado.
- No hay decoradores ni reflexión, por lo que funciona con Vite y esbuild de forma nativa.
- No hay paso de generación de code. Su
Databaseinterface es el contrato de esquema. - Pequeño runtime que elimina innecesariamente, lo que importa para el tamaño del paquete de la aplicación y live update el tamaño de descarga.
- La interfaz del conductor es muy pequeña. Kysely ya contiene el compilador, adaptador e introspectador de consultas SQLite, por lo que solo escribe el transporte.
Si no has configurado SQLite en tu aplicación aún, comienza con How to use SQLite en una Capacitor app. Cubre la configuración nativa que Fast SQL necesita (NSAllowsLocalNetworking en iOS y una regla de texto claro en localhost en Android).
Instalar
bun add kysely @capgo/capacitor-fast-sql
bunx cap sync
Esta guía utiliza Kysely 0.29 y Fast SQL 8.2. En Kysely 0.29 las clases de migración se mueven a la kysely/migration punto de entrada.
Escribir el dialecto de Fast SQL
Un Kysely Dialect tiene cuatro fábricas. Tres de ellas provienen directamente del soporte de SQLite de Kysely. La cuarta es nuestro controlador.
// 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 los booleanos a1/0las fechas a cadenas ISO y los objetos simples a JSON. SQLite no tiene tipo booleano ni de fecha, y en Android el plugin vincula los parámetros de consulta de lectura como texto, por lo quetruellegaría como la cadena'true'y nunca coincidiría con un1.FastSqlConnection.executeQueryllamadasexecute()en la conexión de SQL rápido, que se transfiere a través del canal HTTP local del plugin en iOS y Android y a través de SQLite Wasm en la web. Las filas ya vienen de vuelta como objetos claveados por el nombre de columna, que es exactamente lo que Kysely espera.ConnectionMutexmatters. Fast SQL keeps one native connection per database. Without the lock, two concurrent Kysely transactions would interleave their statements on that one connection. Kysely’s own SQLite driver uses the same pattern.- Transactions pasar por la extensión nativa
beginTransaction,commitandrollbacken lugar de brutobeginSQL, por lo tanto, el plugin sigue el estado de transacción correctamente en ambos sistemas. - Savepoints no están implementados. Si lo llamas
savepoint()En una transacción controlada, Kysely lanzaThe savepoint method is not supported by this driver. Addsavepoint,rollbackToSavepointyreleaseSavepointlos métodos que ejecutan el punto de salvamento SQL compilado si los necesitan.
Describe sus tablas
La Database interface mapea nombres de tablas a tipos de fila. Utilice los tipos de ayuda de Kysely para describir qué llena 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 id opcional en insertar. ColumnType<Select, Insert, Update> permite que una columna se lea como un tipo y se acepte otro, aquí done se lee como un número pero acepta booleanos en la escritura, que el controlador convierte en 1/0. never en la actualización significa que la columna no se puede cambiar.
Crear 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’s init() de manera relajada en la primera consulta, por lo que la construcción de la instancia es barata.
Migraciones dentro de la aplicación
Kysely’s FileMigrationProvider lee un carpeta con Node’s fsque no existe en una WebView. Proporciona migraciones en code en su lugar. El Migrator almacena nombres aplicados en una kysely_migration tabla y ejecuta los 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 están ordenados alfabéticamente, por lo que utilice un prefijo cero-puntuable.
migrateToLatest()no lanza una excepción. Devuelve{ error, results }, así que revisaerrorusted mismo o su aplicación comience con un esquema parcialmente migrado.- Llamarlo en cada arranque es barato. Si no hay nada pendiente, lee la tabla de migración y devuelve.
- SQLite admite transacciones DDL, por lo que el Migrador envuelve la ejecución en una transacción. Funciona con el controlador anterior.
- Escribe migraciones contra
Kysely<any>no tu esquema actualDatabasetipo. Las migraciones antiguas deben seguir compilando después de que el esquema evoluciona.
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 identificadores de fila normales.
Evita .returning() si tu objetivo es Android con la versión actual de Fast SQL: Android solo devuelve filas para las declaraciones que comienzan con SELECT, PRAGMA o EXPLAINo INSERT ... RETURNING funciona pero no devuelve nada. iOS y web devuelven las filas. Leer insertId funciona en todas partes.
Seleccionar
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
Uniones 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 de la unión, y los alias (task_count, done_count) acaban en el tipo de resultado. Porque las filas vuelven como objetos claveado por nombre de columna, dé a cada columna seleccionada un nombre único. Dos columnas ambas llamadas id sobrescribirían entre sí en el objeto de fila, lo cual es cierto para cualquier controlador SQLite que devuelve objetos.
Resultados anidados con ayudantes de 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();
Este se basa en las funciones JSON de SQLite. Están integradas en iOS, en la compilación web y en las últimas versiones de Android, pero si su minSdkVersion es viejo, corra SELECT json('{}') en primer dispositivo de prueba 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();
});
If the callback throws, the driver rolls back. Use trx, no dbdentro de la función de llamada. Consultas en db espere a la mutex, que está siendo mantenida por la transacción, por lo que se bloquearían hasta que la transacción termine.
Consulta SQL bruta cuando lo necesite
import { sql } from 'kysely';
const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);
El sql etiqueta vincula valores interpolados como parámetros, por lo que es seguro con la entrada del usuario. Utilice sql.raw() solo para fragmentos de confianza.
Usando el plugin de la comunidad en lugar
If estás en @capacitor-community/sqlitemantén el dialecto, adaptador, compilador y 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');
}
}
Pass false como tercer argumento a run()Su valor predeterminado envuelve cada instrucción en su propia transacción, lo que rompe las transacciones de Kysely. Utilice el plugin’s beginTransaction(), commitTransaction() y rollbackTransaction() en el controlador.
Solución de problemas
Cannot find module 'kysely/migration'. Estás en Kysely más antiguo que 0.29, donde Migrator es exportado desde kysely directamente. Actualiza o cambia la importación.
Las consultas se quedan congeladas. Usted utilizó db dentro de db.transaction().execute(). Utilice el trx argumento.
done = true Nunca se ajusta en Android. Una boolean alcanzó el plugin sin conversión. Asegúrese de que todos los valores pasen por toSqlValueincluyendo los valores en sql literals de plantilla.
no such table: kysely_migration los errores en la primera ejecución. La Migradora crea su propia tabla, por lo que esto suele significar init() Compruebe la configuración nativa (red local de iOS, regla de localhost sin cifrado de Android) y registre el primer error.
Los tipos dicen que una columna existe pero SQLite dice que no. La Database La interfaz y las migraciones se han desplazado. La interfaz no se genera a partir de la base de datos, actualiza ambos en el mismo commit.
Resumen
Kysely necesita aproximadamente 80 líneas de pegamento para ejecutarse en Capacitor: una conexión que realiza llamadas execute()Kysely necesita aproximadamente 80 líneas de pegamento para ejecutarse en __CAPGO_KEEP_0__: una conexión que llama
Lecturas relacionadas: ORM de Drizzle con Capacitor ORM Drizzle con __CAPGO_KEEP_0__ TypeORM con Capacitor para decoradores y repositorios, y el para decoradores y repositorios, y el documento del plugin Fast SQL