Saltar al contenido principal

Cómo usar Kysely con Capacitor y SQLite

Usa Kysely con Capacitor y SQLite: escribe un pequeño dialecto de Kysely para Fast SQL, tipa tus tablas, ejecuta queries, transacciones y migraciones in-app.

Logo de Capacitor para una guía sobre cómo usar Kysely con Capacitor y SQLite

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 Database interface 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:

  • toSqlValue convierte booleanos a 1/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 que true llegarían como el string 'true' y nunca coincida con 1.
  • FastSqlConnection.executeQuery llamadas execute() 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.
  • ConnectionMutex importa. 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, commit y rollback en lugar de SQL crudo begin SQL, 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 lanza The savepoint method is not supported by this driver. Añade savepoint, rollbackToSavepoint y releaseSavepoint mé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 check error tú 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 actual Database type. 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.

Live updates para apps Capacitor

Cuando un bug de la capa web está en producción, publica el fix a través de Capgo en lugar de esperar días a la aprobación de la app store. Los usuarios reciben la actualización en segundo plano mientras los cambios nativos siguen el flujo de revisión habitual.

Lo último de nuestro blog

Capgo te da la información clave que necesitas para crear una app móvil realmente profesional.