メインコンテンツへスキップ
ブログ一覧

Drizzle ORM + SQLite(WALモード)で構築するローカルファーストアプリ実践ガイド

(更新: 2026年03月09日)
Drizzle ORMSQLiteTypeScriptローカルファーストデータベース

近年、クラウドに依存しない「ローカルファースト」なアプリケーション設計が注目を集めている。ネットワーク遅延ゼロ、オフライン動作、データの完全な所有権。これらを実現する最もシンプルな選択肢が、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アプリ、デーモンプロセスなど幅広い用途にマッチする。

セットアップ

bash
npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3

drizzle.config.ts を作成する。

typescript
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で設定する。

typescript
// 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 で宣言する。

typescript
// 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: textenum オプションで型レベルの制約をかけられる(DBレベルのCHECK制約ではない点に注意)

スキーマをDBに反映するには以下を実行する。

bash
npx drizzle-kit push

マイグレーションファイルを生成して適用する方式もあるが、開発初期は push のほうが手軽でよい。

CRUDの基本操作

Insert

typescript
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

typescript
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

typescript
await db
  .update(tasks)
  .set({
    status: "done",
    updatedAt: new Date(),
  })
  .where(eq(tasks.id, targetId));

Delete

typescript
await db.delete(tasks).where(eq(tasks.id, targetId));

集計クエリ

sql テンプレートリテラルを使えば、生SQLの表現力をそのまま活かせる。

typescript
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の性能を最大限引き出せる。

typescript
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" });

トランザクション

複数の書き込みをまとめる場合、トランザクションで囲むと整合性とパフォーマンスの両方が改善する。

typescript
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」に変える第一歩になるだろう。

もっと読む他の技術記事も読む