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
Databaseinterface 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:
toSqlValuemengonversi boolean menjadi1/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, jaditrueakan diterima sebagai string'true'dan tidak pernah cocok dengan1.FastSqlConnection.executeQuerypanggilanexecute()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.ConnectionMutexpenting. 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,commitdanrollbackbukan mentahbeginSQL mentah, sehingga plugin melacak status transaksi dengan benar di kedua platform. - Savepoints tidak diimplementasikan. Jika Anda memanggil
savepoint()pada transaksi terkontrol, Kysely akan melemparThe savepoint method is not supported by this driver. Tambahkansavepoint,rollbackToSavepointdanreleaseSavepointmetode 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 periksaerrorAnda 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 iniDatabasetype 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.