Salta al contenuto principale

Come usare Kysely con Capacitor e SQLite

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

Logo di Capacitor per una guida sull'uso di Kysely con Capacitor e SQLite

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

  • toSqlValue converte i booleani in 1/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, quindi true arriverebbe come la stringa 'true' e non corrisponderebbe mai a un 1.
  • FastSqlConnection.executeQuery chiama execute() 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, commit e rollback al posto del codice grezzo begin SQL, 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 lancia The savepoint method is not supported by this driver. Aggiungi savepoint, rollbackToSavepoint e releaseSavepoint metodi 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 controlla error tu 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 attuale Database type. 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.

Live updates per le app Capacitor

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

Ultimi articoli dal blog

Capgo ti offre gli insight migliori che ti servono per creare un'app mobile davvero professionale.