Kysely が Capacitor と SQLite でうまく動くのは、プラットフォームに求めるものが1つだけだからです。それは、パラメータ付きの SQL 文字列を送り、行をオブジェクトとして返すドライバーです。本ガイドでは、そのドライバーを構築し、 @capgo/capacitor-fast-sql続いて、型付きテーブル、CRUD、JOIN、トランザクション、そして iOS、Android、Web 上で動作するアプリ内マイグレーションについて解説します。
code の生成も重いランタイムも不要で、コンパイル時にチェックされる SQL を利用できます。
KyselyがモバイルSQLiteに適している理由
Kysely は ORM ではなくクエリビルダーです。SQL のように読めるコードを書き、TypeScript がすべてのテーブル名とカラム名をチェックして結果の型を推論します。
- デコレータもリフレクションも不要なので、Vite や esbuild でそのまま動きます。
- code の生成ステップも不要です。作成した
Databaseinterface がスキーマの定義そのものになります。 - ランタイムが小さく tree-shaking も効くため、アプリの bundle サイズや live update を ダウンロードサイズにも影響します。
- ドライバーのインターフェースはごく小さいです。Kysely には SQLite のクエリコンパイラ、アダプター、イントロスペクターが既に含まれているので、トランスポート層を書くだけで済みます。
アプリでまだ SQLite をセットアップしていない場合は、まずはこちらから始めてください。 Capacitor アプリでSQLiteを使う方法. Fast SQL が必要とするネイティブ設定(NSAllowsLocalNetworking iOS の設定と Android の localhost cleartext ルール)をカバーします。
インストール
bun add kysely @capgo/capacitor-fast-sql
bunx cap sync
このガイドでは Kysely 0.29 と Fast SQL 8.2 を使用します。Kysely 0.29 では、マイグレーションクラスが kysely/migration エントリポイントに移動しました。
Fast SQLダイアレクトを書く
Kysely Dialect には 4 つのファクトリがあります。そのうち 3 つは Kysely の SQLite サポートから直接提供され、4 つ目が当社のドライバーです。
// 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);
}
}
各パーツの役割:
toSqlValueboolean を変換し1/0、date を ISO 文字列に、プレーンオブジェクトを JSON に変換します。SQLite には boolean 型や date 型がなく、Android ではプラグインが読み取りクエリのパラメータをテキストとしてバインドするため、true文字列として届くことになります。'true'そして決して一致しない1.FastSqlConnection.executeQuery呼び出しexecute()Fast SQL 接続では、iOS と Android ではプラグインのローカル HTTP チャネルを、Web では SQLite Wasm を通じて通信します。行はすでにカラム名をキーとするオブジェクトとして返ってくるため、Kysely が期待する形式と完全に一致します。ConnectionMutex重要です。Fast SQL はデータベースごとにネイティブ接続を1つだけ保持します。ロックなしでは、並行する2つの Kysely トランザクションがその1つの接続上でステートメントを交互に実行してしまいます。Kysely 自身の SQLite ドライバーも同じパターンを使用しています。- トランザクション プラグインのネイティブ経由で
beginTransaction,commitとrollback生のbeginSQLではなく、これによりプラグインは両プラットフォームでトランザクション状態を正しく追跡します。 - セーブポイント は実装されていません。制御されたトランザクションで
savepoint()呼び出すと、Kyselyは例外を投げますThe savepoint method is not supported by this driver. 追加savepoint,rollbackToSavepointとreleaseSavepoint必要に応じて、コンパイル済みの savepoint SQL を実行するメソッドを追加します。
テーブルの定義
この Database interface はテーブル名を行の型にマッピングします。データベースが埋める値を記述するには、Kysely のヘルパー型を使います。
// 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> を id insert 時に任意(optional)にします。 ColumnType<Select, Insert, Update> 列の読み取り型と書き込み型を分けることができます。ここでは、 done 読み取り時は数値ですが、書き込み時はbooleanを受け付け、ドライバーがそれを変換します 1/0. never on update を指定すると、その列は更新できなくなります。
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はドライバーの init() を最初のクエリ時に遅延呼び出しするため、インスタンス生成は低コストです。
アプリ内でのマイグレーション
Kyselyの FileMigrationProvider はNodeの fsでフォルダを読み込みますが、これはWebViewには存在しません。代わりにcodeでマイグレーションを提供してください。また、 Migrator は適用済みの名前を kysely_migration テーブルに保存し、未適用のものを順番に実行します。
// 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;
}
注意:
- 名前はアルファベット順にソートされるため、ゼロ埋めしたプレフィックスを使用してください。
migrateToLatest()はスローしません。返り値は{ error, results }なので、確認するのはerror自分自身やアプリが、中途半端にマイグレーションされたスキーマから始まってしまうこと。- 起動のたびに呼び出してもコストは低い。保留中のものがなければ、マイグレーションテーブルを読み取って戻るだけだ。
- SQLite はトランザクション DDL をサポートしているため、Migrator は実行をトランザクションでラップする。これは上記のドライバーでも動作する。
- マイグレーションは
Kysely<any>に対して書き、現在のDatabase型に対しては書かないこと。スキーマが進化しても、古いマイグレーションはコンパイルされ続けなければならない。
クエリ
挿入
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 は bigint Kysely では。変換には Number() 通常の行 ID 用。
避ける .returning() 現在の Fast SQL バージョンで Android をターゲットにする場合: Android は次で始まるステートメントの行のみを返します SELECT, PRAGMA または EXPLAINを使用しているため、 INSERT ... RETURNING は実行されますが何も返しません。iOS と Web は行を返します。読み取り insertId はどこでも動作します。
選択します
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と集計
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 は JOIN 後にどのカラムが存在するかを把握しており、エイリアス (task_count, done_count) が結果型に含まれます。行はカラム名をキーとするオブジェクトとして返されるため、選択する各カラムに一意の名前を付けてください。同じ名前のカラムが 2 つあると id 行オブジェクト内で上書きし合います。これはオブジェクトを返す SQLite ドライバー全般に言えることです。
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();
これは SQLite の JSON 関数に依存しています。iOS、Web ビルド、最近の Android リリースでは組み込まれていますが、お使いの minSdkVersion が古い場合は、まず SELECT json('{}') 最も古いテスト端末で実行してください。
Updateと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));
トランザクション
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();
});
コールバックが例外をスローした場合、ドライバーはロールバックします。次を trxではなく、 dbコールバック内で使用してください。クエリは db トランザクションが保持するミューテックスを待つため、トランザクションが終了するまでデッドロックします。
必要なときにRaw SQL
import { sql } from 'kysely';
const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);
この sql タグは補間値をパラメータとしてバインドするため、ユーザー入力に対しても安全です。次を sql.raw() 信頼できるフラグメントにのみ使用してください。
代わりにコミュニティプラグインを使う
を使用している場合は @capacitor-community/sqlite、dialect、adapter、compiler、mutex はそのままに、connection を置き換えてください:
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');
}
}
渡す false を第3引数として run()デフォルトでは各ステートメントが独立したトランザクションにラップされるため、Kysely のトランザクションが壊れます。プラグインの beginTransaction(), commitTransaction() と rollbackTransaction() ドライバー内です。
トラブルシューティング
Cannot find module 'kysely/migration'. 0.29 より前の Kysely を使用しており、 Migrator は kysely から直接エクスポートされています。インポートを更新するか変更してください。
クエリが永久にハングします。 使用したのは db 既存の db.transaction().execute()です。 trx 引数を使用してください。
done = true は Android では一致しません。 ブール値が変換されずにプラグインに渡されました。すべての値が次を経由することを確認してください: toSqlValue、テンプレートリテラル内の値を含む sql 。
no such table: kysely_migration 初回起動時のエラー。 Migrator は自身でテーブルを作成するので、通常これは init() 以前に失敗したことを意味します。ネイティブ設定(iOS のローカルネットワーク、Android の cleartext localhost ルール)を確認し、最初のエラーをログに出力してください。
型定義ではカラムが存在するのに、SQLite では存在しない。 この Database interface とマイグレーションがずれています。interface はデータベースから生成されないので、両方を同じコミットで更新してください。
まとめ
Kysely を Capacitor で実行するには、約 80 行のグルーコードが必要です:呼び出しを行う接続 execute()、ミューテックス、トランザクションフック。その代わり、iOS と Android では Fast SQL のネイティブ SQLite、Web では SQLite Wasm 上で、完全に型付けされた SQL を利用できます。
関連記事: Capacitor と Drizzle ORM スキーマファーストの定義を好む場合 Capacitor と TypeORM デコレーターとリポジトリ、および Fast SQL プラグインのドキュメント すべての接続オプションについて。