Salta al contenuto principale

Come usare Kysely con Capacitor e SQLite

Usa Kysely con Capacitor e SQLite: scrivi un piccolo dialetto Kysely per SQL veloce, tipizza le tue tabelle, esegui query, transazioni e migrazioni in-app.

Come usare Kysely con Capacitor e SQLite

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

  • toSqlValue converte i booleani in 1/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, quindi true arriverebbero come la stringa 'true' e non corrisponderanno mai a un 1.
  • FastSqlConnection.executeQuery chiamate execute() 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, commit e rollback invece di usare SQL begin raw, 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 lancia The savepoint method is not supported by this driver. Aggiungi savepoint, rollbackToSavepoint e releaseSavepoint metodi 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 controlla error te 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 attuale Database type. 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.

Live update per le app Capacitor

Quando un bug del web layer è live, rilascia il fix tramite Capgo invece di aspettare giorni per l'approvazione degli store. Gli utenti ricevono l'update in background, mentre le modifiche native restano nel normale percorso di review.

Le ultime dal nostro blog

Capgo ti offre i migliori insight per creare un'app mobile davvero professionale.