Kysely 与 Capacitor 和 SQLite 配合得很好,因为它只需要平台提供一样东西:一个能发送带参数的 SQL 字符串、并把结果行作为对象返回的驱动。本指南将构建这个驱动, @capgo/capacitor-fast-sql然后介绍类型化表、CRUD、JOIN、事务,以及在 iOS、Android 和 Web 应用内运行的迁移。
无需生成 code,也无需笨重的运行时,就能获得编译时校验的 SQL。
为什么 Kysely 适合移动端 SQLite
Kysely 是查询构建器,不是 ORM。你写的代码读起来像 SQL,TypeScript 会检查每个表名和列名,并推断结果类型。
- 无需装饰器或反射,因此可直接配合 Vite 和 esbuild 使用。
- 无需 code 生成步骤。你的
Databaseinterface 就是 schema 契约。 - 运行时小巧,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 有四个工厂。其中三个直接来自 Kysely 的 SQLite 支持,第四个是我们的驱动程序。
// 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);
}
}
各部分功能如下:
toSqlValue将布尔值转换为1/0,将日期转换为 ISO 字符串,并将普通对象转换为 JSON。SQLite 没有布尔或日期类型,而且在 Android 上,插件会把读取查询的参数绑定为文本,因此true会以字符串形式到达'true'且从不匹配1.FastSqlConnection.executeQuery调用execute()在 Fast SQL 连接上,该连接在 iOS 和 Android 上通过插件的本地 HTTP 通道传输,在 Web 上通过 SQLite Wasm 传输。行数据已经以列名为键返回为对象,这正是 Kysely 所期望的。ConnectionMutex至关重要。Fast SQL 为每个数据库保持一个原生连接。如果没有锁,两个并发的 Kysely 事务会在该单一连接上交错执行语句。Kysely 自身的 SQLite 驱动也使用相同的模式。- 事务 通过插件的原生
beginTransaction,commit以及rollback而不是原始beginSQL,以便插件在两个平台上正确跟踪事务状态。 - 保存点 尚未实现。如果你在受控事务中调用
savepoint(),Kysely 会抛出The savepoint method is not supported by this driver。添加savepoint,rollbackToSavepoint以及releaseSavepoint方法,如果你需要,可以执行编译后的保存点 SQL。
描述你的表
本指南中介绍的 Database 接口将表名映射到行类型。使用 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 插入时可选。 ColumnType<Select, Insert, Update> 让列以某种类型读取,但接受另一种类型,例如 done 读取为数字,但写入时接受布尔值,驱动会将其转换为 1/0. never 更新时意味着该列不可更改。
创建 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你自己或你的应用一开始就处于半迁移的 schema 状态。- 每次启动时调用它开销很低。如果没有待处理项,它只读取迁移表然后返回。
- SQLite 支持事务性 DDL,因此 Migrator 会把这次运行包在事务里。这适用于上面的驱动。
- 迁移要针对
Kysely<any>,而不是你当前的Database类型。schema 演进后,旧迁移必须仍能通过编译。
查询
插入
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) 最终会出现在结果类型中。由于返回的行是以列名为键的对象,请给每个选中的列起唯一名称。如果两列都叫 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('{}') 先在您最旧的测试设备上执行。
更新并删除
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 等待由事务持有的互斥锁,因此它们会死锁,直到事务结束。
需要时使用原生 SQL
import { sql } from 'kysely';
const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);
本指南中介绍的 sql tag 将插值绑定为参数,因此对用户输入是安全的。使用 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 作为第三个参数传给 run()。它默认会把每条语句包在各自的事务里,这会破坏 Kysely 事务。请在 driver 中使用插件的 beginTransaction(), commitTransaction() 以及 rollbackTransaction() 。
故障排除
Cannot find module 'kysely/migration'. 你使用的 Kysely 低于 0.29,此时 Migrator 是从 kysely 直接导出的。请更新或修改 import。
查询会一直挂起。 你使用了 db 内部 db.transaction().execute()。请使用 trx 参数。
done = true 在 Android 上永远匹配不上。 布尔值未经转换就传给了插件。请确保每个值都经过 toSqlValue,包括 sql 模板字符串中的值。
no such table: kysely_migration 首次启动时报错。 Migrator 会自行创建表,所以这通常意味着 init() 之前已失败。请检查原生配置(iOS 本地网络、Android cleartext localhost 规则)并记录第一个错误。
类型定义显示列存在,但 SQLite 却提示不存在。 本指南中介绍的 Database interface 和 migrations 脱节了。interface 不是从数据库生成的,所以要在同一个 commit 里同时更新两者。
总结
Kysely 要在 Capacitor 上运行,大约需要 80 行胶水代码:一个负责调用的连接 execute()、一个 mutex 和事务钩子。作为回报,你可以在 iOS 和 Android 上 Fast SQL 的原生 SQLite,以及 Web 上的 SQLite Wasm 之上,获得完全类型化的 SQL。
相关阅读: 在 Capacitor 中使用 Drizzle ORM 如果你更倾向于 schema-first 定义, 在 Capacitor 中使用 TypeORM 用于装饰器和 Repository,以及 Fast SQL 插件文档 适用于每个连接选项。