Langsung ke konten utama

Cara Menggunakan Kysely dengan Capacitor dan SQLite

Gunakan Kysely dengan Capacitor dan SQLite: tulis dialect Kysely kecil untuk Fast SQL, beri tipe pada tabel Anda, jalankan query, transaksi, dan migrasi in-app.

logo Capacitor untuk panduan penggunaan Kysely dengan Capacitor dan SQLite

Kysely bekerja dengan baik bersama Capacitor dan SQLite karena hanya membutuhkan satu hal dari platform: driver yang mengirim string SQL dengan parameter dan mengembalikan baris sebagai objek. Panduan ini membuat driver tersebut untuk @capgo/capacitor-fast-sql, lalu membahas tabel bertipe, CRUD, join, transaksi, dan migrasi yang berjalan di dalam aplikasi di iOS, Android, dan web.

Anda dapat SQL yang dicek saat compile-time tanpa code generation dan tanpa runtime yang berat.

Mengapa Kysely cocok untuk SQLite di mobile

Kysely adalah query builder, bukan ORM. Anda menulis kode yang terbaca seperti SQL, dan TypeScript mengecek setiap nama tabel dan kolom serta menyimpulkan tipe hasilnya.

  • Tanpa decorator atau reflection, jadi bisa langsung jalan dengan Vite dan esbuild.
  • Tanpa langkah code generation. code Database interface Anda adalah kontrak skemanya.
  • Runtime kecil yang tree-shakes dengan baik, ini penting untuk ukuran app bundle dan live update ukuran download.
  • Interface driver-nya sangat ringkas. Kysely sudah menyertakan SQLite query compiler, adapter, dan introspector, jadi Anda hanya perlu menulis bagian transport-nya.

Jika Anda belum mengatur SQLite di aplikasi Anda, mulailah dengan Cara menggunakan SQLite di aplikasi Capacitor. Ini mencakup konfigurasi native yang dibutuhkan Fast SQL (NSAllowsLocalNetworking pada iOS dan aturan cleartext localhost di Android).

Instal

bun add kysely @capgo/capacitor-fast-sql
bunx cap sync

Panduan ini menggunakan Kysely 0.29 dan Fast SQL 8.2. Di Kysely 0.29, class migrasi dipindahkan ke kysely/migration titik masuk.

Tulis dialect Fast SQL

Kysely Dialect memiliki empat factory. Tiga di antaranya berasal langsung dari dukungan SQLite Kysely. Yang keempat adalah driver kami.

// 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);
  }
}

Fungsi setiap bagian:

  • toSqlValue mengonversi boolean menjadi 1/0, tanggal ke ISO string, dan plain object ke JSON. SQLite tidak punya tipe boolean atau date, dan di Android plugin mengikat parameter read-query sebagai text, jadi true akan diterima sebagai string 'true' dan tidak pernah cocok dengan 1.
  • FastSqlConnection.executeQuery panggilan execute() pada koneksi Fast SQL, yang berjalan melalui kanal HTTP lokal plugin di iOS dan Android, serta melalui SQLite Wasm di web. Baris sudah dikembalikan sebagai objek yang di-key berdasarkan nama kolom, yang persis seperti yang diharapkan Kysely.
  • ConnectionMutex penting. Fast SQL mempertahankan satu koneksi native per database. Tanpa lock, dua transaksi Kysely yang berjalan bersamaan akan saling menyisipkan statement mereka pada satu koneksi tersebut. Driver SQLite milik Kysely sendiri menggunakan pola yang sama.
  • Transaksi melalui native plugin beginTransaction, commit dan rollback bukan mentah begin SQL mentah, sehingga plugin melacak status transaksi dengan benar di kedua platform.
  • Savepoints tidak diimplementasikan. Jika Anda memanggil savepoint() pada transaksi terkontrol, Kysely akan melempar The savepoint method is not supported by this driver. Tambahkan savepoint, rollbackToSavepoint dan releaseSavepoint metode untuk menjalankan SQL savepoint yang telah dikompilasi jika diperlukan.

Deskripsikan tabel Anda

Panduan Database interface ini memetakan nama tabel ke tipe baris. Gunakan helper types Kysely untuk mendeskripsikan data yang diisi 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> membuat id opsional saat insert. ColumnType<Select, Insert, Update> memungkinkan sebuah kolom dibaca sebagai satu tipe dan menerima tipe lain, di sini done dibaca sebagai angka tetapi menerima boolean saat write, yang kemudian diubah driver menjadi 1/0. never pada update berarti kolom tersebut tidak dapat diubah.

Buat instance 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 memanggil driver secara init() lazy pada query pertama, sehingga membuat instansnya sangat ringan.

Migrations di dalam app

Kysely FileMigrationProvider membaca folder dengan Node fs, yang tidak ada di WebView. Sediakan migrations dalam code saja. Migrator menyimpan nama yang sudah diterapkan di kysely_migration tabel dan menjalankan yang tertunda secara berurutan.

// 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;
}

Catatan:

  • Nama diurutkan secara alfabetis, jadi gunakan awalan dengan nol di depan.
  • migrateToLatest() tidak melempar error. Ia mengembalikan { error, results }, jadi periksa error Anda sendiri atau aplikasi Anda memulai dengan skema yang setengah termigrasi.
  • Memanggilnya setiap kali launch sangat ringan. Jika tidak ada yang tertunda, ia membaca tabel migrasi lalu return.
  • SQLite mendukung transactional DDL, jadi Migrator membungkus run dalam sebuah transaksi. Ini berfungsi dengan driver di atas.
  • Tulis migrasi terhadap Kysely<any>, bukan yang saat ini Database type Anda saat ini. Migrasi lama harus tetap bisa compile setelah skema berkembang.

Query

Sisipkan

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 adalah bigint di Kysely. Konversi dengan Number() untuk row id normal.

Hindari .returning() jika Anda menargetkan Android dengan versi Fast SQL saat ini: Android hanya mengembalikan baris untuk statement yang diawali dengan SELECT, PRAGMA atau EXPLAIN, jadi INSERT ... RETURNING berjalan tetapi tidak mengembalikan apa pun. iOS dan web mengembalikan barisnya. Membaca insertId berfungsi di mana saja.

Pilih

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 dan agregasi

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 tahu kolom mana yang ada setelah join, dan alias (task_count, done_count) ikut masuk ke tipe hasil. Karena baris dikembalikan sebagai objek dengan key berupa nama kolom, beri nama unik untuk setiap kolom yang dipilih. Dua kolom yang sama-sama bernama id akan saling menimpa dalam objek baris, dan ini berlaku untuk driver SQLite mana pun yang mengembalikan objek.

Hasil bersarang dengan 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();

Ini bergantung pada fungsi JSON SQLite. Fungsi ini sudah bawaan di iOS, pada build web, dan di rilis Android terbaru, tetapi jika aplikasi minSdkVersion is old, run SELECT json('{}') di perangkat uji tertua Anda terlebih dahulu.

Update dan delete

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));

Transaksi

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();
});

Jika callback melempar error, driver akan melakukan rollback. Gunakan trx, bukan db, di dalam callback. Query pada db menunggu mutex yang dipegang oleh transaksi, sehingga akan deadlock sampai transaksi selesai.

Raw SQL saat Anda membutuhkannya

import { sql } from 'kysely';

const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);

Panduan sql tag mengikat nilai yang diinterpolasi sebagai parameter, sehingga aman untuk input pengguna. Gunakan sql.raw() hanya untuk fragmen yang tepercaya.

Menggunakan plugin komunitas sebagai gantinya

Jika Anda menggunakan @capacitor-community/sqlite, pertahankan dialect, adapter, compiler, dan mutex, lalu ganti koneksi:

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');
  }
}

Gunakan false sebagai argumen ketiga untuk run(). Default-nya membungkus setiap statement dalam transaksinya sendiri, yang merusak transaksi Kysely. Gunakan plugin’s beginTransaction(), commitTransaction() dan rollbackTransaction() di driver.

Pemecahan Masalah

Cannot find module 'kysely/migration'. Anda menggunakan Kysely versi lebih lama dari 0.29, di mana Migrator diekspor dari kysely secara langsung. Perbarui atau ubah import-nya.

Query menggantung selamanya. Anda menggunakan db di dalam db.transaction().execute(). Gunakan trx argumen.

done = true tidak pernah cocok di Android. Nilai boolean mencapai plugin tanpa konversi. Pastikan setiap nilai melewati toSqlValue, termasuk nilai dalam sql template literals.

no such table: kysely_migration error saat peluncuran pertama. Migrator membuat tabelnya sendiri, jadi ini biasanya berarti init() gagal sebelumnya. Periksa setup native (local networking iOS, aturan cleartext localhost Android) dan catat error pertama.

Types mengatakan kolom ada, tetapi SQLite mengatakan tidak. Panduan Database interface dan migrations sudah menyimpang. Interface tidak dihasilkan dari database, jadi perbarui keduanya dalam commit yang sama.

Ringkasan

Kysely membutuhkan sekitar 80 baris glue untuk berjalan di Capacitor: koneksi yang memanggil execute(), mutex, dan hook transaksi. Sebagai gantinya, Anda mendapatkan SQL yang sepenuhnya bertipe di atas SQLite native Fast SQL di iOS dan Android, serta SQLite Wasm di web.

Bacaan terkait: Drizzle ORM dengan Capacitor jika Anda lebih suka definisi schema-first, TypeORM dengan Capacitor untuk decorator dan repository, serta Dokumentasi plugin Fast SQL untuk setiap opsi koneksi.

Live updates untuk aplikasi Capacitor

Saat bug web-layer sudah live, kirim perbaikannya lewat Capgo alih-alih menunggu berhari-hari untuk persetujuan app store. Pengguna mendapat update di background, sementara perubahan native tetap mengikuti jalur review normal.

Terbaru dari Blog kami

Capgo memberikan insight terbaik yang Anda butuhkan untuk membuat aplikasi mobile yang benar-benar profesional.