Drizzle ORM + SQLite(WALモード)で構築するローカルファーストアプリ実践ガイド
近年、クラウドに依存しない「ローカルファースト」なアプリケーション設計が注目を集めている。ネットワーク遅延ゼロ、オフライン動作、データの完全な所有権。これらを実現する最もシンプルな選択肢が、SQLiteだ。
本記事では、TypeScriptプロジェクトにおいて Drizzle ORM + better-sqlite3 を使い、WALモードを活用したローカルファーストアプリの構築方法を実践的に解説する。
なぜDrizzle ORM + SQLiteなのか
Drizzle ORMは、SQLに近い記法で型安全なクエリを書けるTypeScript製の軽量ORMだ。Prismaと比較されることが多いが、DrizzleはクエリをほぼそのままSQLにコンパイルするため、ランタイムオーバーヘッドが極めて小さい。
SQLiteとの組み合わせでは、better-sqlite3ドライバを通じて同期的にクエリを実行できる。外部のDBサーバーが不要で、.dbファイル1つで完結する手軽さは、CLIツール、Electronアプリ、デーモンプロセスなど幅広い用途にマッチする。
セットアップ
npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3
drizzle.config.ts を作成する。
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: "./data/app.db",
},
});
WALモードの有効化
SQLiteのデフォルトはジャーナルモード(rollback journal)だが、本番運用ではWAL(Write-Ahead Logging)モードへの切り替えを強く推奨する。
WALモードの利点は明確だ。
- 読み取りと書き込みが同時に実行できる(デフォルトでは書き込み中に読み取りがブロックされる)
- 書き込みパフォーマンスが向上する
- クラッシュ耐性が高い
DB接続時にPRAGMAで設定する。
// src/db/index.ts
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "./schema.js";
const sqlite = new Database("./data/app.db");
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("busy_timeout = 5000");
export const db = drizzle(sqlite, { schema });
busy_timeout は、別のプロセスが書き込み中だった場合に最大5秒間リトライしてくれる設定だ。マルチプロセス環境では、これがないと SQLITE_BUSY エラーに悩まされることになる。正直、最初はこの設定を知らずにかなり苦労した。
スキーマ定義
Drizzleのスキーマ定義はテーブルごとに sqliteTable で宣言する。
// src/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const tasks = sqliteTable("tasks", {
id: text("id").primaryKey(),
title: text("title").notNull(),
description: text("description"),
status: text("status", {
enum: ["todo", "in_progress", "done"],
}).notNull().default("todo"),
priority: integer("priority").notNull().default(0),
metadata: text("metadata"), // JSONを文字列として格納
isArchived: integer("is_archived", { mode: "boolean" })
.notNull()
.default(false),
createdAt: integer("created_at", { mode: "timestamp" })
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.$defaultFn(() => new Date()),
});
// 型をエクスポートしておくと各所で便利
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;
ポイントをいくつか補足する。
- boolean型: SQLiteにはBOOL型がないため、
integerに{ mode: "boolean" }を指定すると0/1を自動変換してくれる - timestamp型: 同様に
{ mode: "timestamp" }でDateオブジェクトとの変換が自動化される - JSON格納: SQLiteのJSON関数を使う手もあるが、シンプルに
textカラムにJSON.stringify/JSON.parseするパターンが実用上は十分なことが多い - enum:
textのenumオプションで型レベルの制約をかけられる(DBレベルのCHECK制約ではない点に注意)
スキーマをDBに反映するには以下を実行する。
npx drizzle-kit push
マイグレーションファイルを生成して適用する方式もあるが、開発初期は push のほうが手軽でよい。
CRUDの基本操作
Insert
import { db } from "./db/index.js";
import { tasks } from "./db/schema.js";
await db.insert(tasks).values({
id: crypto.randomUUID(),
title: "Drizzleの記事を書く",
status: "in_progress",
priority: 1,
});
Select
import { eq, and, desc } from "drizzle-orm";
// 全件取得(降順)
const allTasks = await db
.select()
.from(tasks)
.orderBy(desc(tasks.createdAt));
// 条件付き取得
const activeTasks = await db
.select()
.from(tasks)
.where(
and(
eq(tasks.status, "todo"),
eq(tasks.isArchived, false)
)
);
// 特定カラムだけ取得
const titles = await db
.select({ id: tasks.id, title: tasks.title })
.from(tasks)
.limit(10);
Update
await db
.update(tasks)
.set({
status: "done",
updatedAt: new Date(),
})
.where(eq(tasks.id, targetId));
Delete
await db.delete(tasks).where(eq(tasks.id, targetId));
集計クエリ
sql テンプレートリテラルを使えば、生SQLの表現力をそのまま活かせる。
import { sql } from "drizzle-orm";
const stats = await db
.select({
status: tasks.status,
count: sql<number>`count(*)`,
})
.from(tasks)
.groupBy(tasks.status);
// => [{ status: "todo", count: 3 }, { status: "done", count: 12 }]
この sql<number> のジェネリクスで戻り値の型を指定できるのは地味に便利だ。
パフォーマンスを引き出すTips
Prepared Statements
同じクエリを繰り返し実行する場合、Prepared Statementsを使うとbetter-sqlite3の性能を最大限引き出せる。
const findByStatus = db
.select()
.from(tasks)
.where(eq(tasks.status, sql.placeholder("status")))
.prepare();
// 何度でも高速に実行可能
const todoTasks = findByStatus.all({ status: "todo" });
const doneTasks = findByStatus.all({ status: "done" });
トランザクション
複数の書き込みをまとめる場合、トランザクションで囲むと整合性とパフォーマンスの両方が改善する。
await db.transaction(async (tx) => {
await tx.insert(tasks).values({
id: crypto.randomUUID(),
title: "タスクA",
});
await tx.insert(tasks).values({
id: crypto.randomUUID(),
title: "タスクB",
});
});
SQLiteでは個々のINSERTが暗黙的にトランザクションを生成するため、100件のINSERTをトランザクションなしで実行すると100回のfsyncが走る。明示的なトランザクションで囲めば、fsyncは1回で済む。
よくあるハマりポイント
1. ESMでのimportパス
"type": "module" のプロジェクトでは、import文に .js 拡張子が必要になる。TypeScriptファイルであっても import { db } from "./db/index.js" と書く。
2. WALモードの永続性
PRAGMA journal_mode = WAL は一度実行すればDBファイルに永続化される。毎回実行しても害はないが、「設定したはずなのにWALになっていない」場合は、DBファイルのパスが異なっている可能性を疑うとよい。
3. $defaultFnのタイミング
$defaultFn はDrizzle側(アプリケーション層)で値を生成する。SQLのDEFAULT句とは異なり、DBを直接操作した場合にはデフォルト値が入らない。外部ツールからもDBを触る場合は sql`(strftime('%s', 'now'))` のようなSQL DEFAULTを使うほうが安全だ。
4. JSON型カラムの扱い
text カラムにJSONを入れる場合、insert時に JSON.stringify()、select後に JSON.parse() が必要になる。Drizzleの .$type<T>() を使えば型だけは付けられるが、自動的なシリアライズ/デシリアライズは行われない点に注意。
まとめ
Drizzle ORM + SQLite(WALモード)の組み合わせは、ローカルファーストアプリの構築において非常にバランスの良い選択肢だ。型安全なクエリ、最小限のランタイムコスト、サーバー不要のシンプルさ。これらが揃った環境は、CLIツールからElectronアプリ、常駐デーモンまで幅広く活用できる。
特にWALモードと busy_timeout の設定は、本番環境での安定稼働に直結する。この2行のPRAGMAを忘れずに設定しておくことが、SQLiteを「趣味のDB」から「実用のDB」に変える第一歩になるだろう。
