1241 文字
6 分
SQLite WASM + OPFS でブラウザにリレーショナル DB を持ち込む

はじめに#

前回の記事では Dexie.js で IndexedDB を実用レベルに引き上げる方法を紹介しました。IndexedDB は手軽ですが、キーバリュー型のストレージであり、複雑なクエリや JOIN は苦手です。

「ブラウザの中でも SQL を書きたい」——その要望に応えるのが SQLite WASM です。
SQLite の C ソースコードを WebAssembly にコンパイルし、ブラウザ上でフル機能のリレーショナル DB を動かします。
永続化には Origin Private File System(OPFS) を組み合わせることで、IndexedDB を超えるパフォーマンスが得られます。

IndexedDB との比較#

IndexedDBSQLite WASM + OPFS
データモデルキーバリュー(オブジェクトストア)リレーショナル(テーブル + SQL)
クエリ言語JavaScript API(カーソル、インデックス)SQL
JOIN不可(アプリ層で結合)可能
トランザクション暗黙コミット(リクエスト完了時)明示的(BEGIN / COMMIT)
書き込み性能中程度OPFS 経由で高速
ブラウザ対応全モダンブラウザChrome 102+, Firefox 111+, Safari 17.4+

単一テーブルの CRUD であれば IndexedDB(+ Dexie.js)で十分ですが、複数テーブルの結合や集計が頻繁に発生するなら SQLite の方が自然に書けます。

セットアップ#

公式の SQLite WASM ビルドを使う方法と、コミュニティのラッパーライブラリを使う方法があります。ここでは npm パッケージとして使いやすい @sqlite.org/sqlite-wasm を紹介します。

Terminal window
$ pnpm add @sqlite.org/sqlite-wasm

SQLite WASM は Web Worker 内で動かすのが推奨です。OPFS の同期アクセス API(createSyncAccessHandle)は Worker 専用のため、メインスレッドからは使えません。

sqlite-worker.ts
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
const sqlite3 = await sqlite3InitModule();
const db = new sqlite3.oo1.OpfsDb("/app.sqlite3");
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
`);

OpfsDb を使うことで、データは OPFS 上のファイルとして永続化されます。ブラウザを閉じてもデータは残り、次回アクセス時にそのまま読み込まれます。

CRUD 操作#

SQL をそのまま書けるのが最大の利点です。

// 作成
db.exec({
sql: "INSERT INTO tasks (title) VALUES (?)",
bind: ["記事を書く"],
});
// 読み取り
const rows = db.exec({
sql: "SELECT * FROM tasks WHERE done = 0 ORDER BY created_at DESC",
returnValue: "resultRows",
rowMode: "object",
});
// 更新
db.exec({
sql: "UPDATE tasks SET done = 1 WHERE id = ?",
bind: [1],
});
// 削除
db.exec({
sql: "DELETE FROM tasks WHERE id = ?",
bind: [1],
});

IndexedDB ではアプリ層で実装する必要があった集計やサブクエリも、SQL の表現力でそのまま書けます。

const stats = db.exec({
sql: `
SELECT
done,
COUNT(*) as count,
MIN(created_at) as oldest
FROM tasks
GROUP BY done
`,
returnValue: "resultRows",
rowMode: "object",
});

OPFS とは何か#

Origin Private File System は Web 標準のファイルシステム API です。通常のファイルシステムとは異なり、オリジン(ドメイン)ごとに隔離されたプライベートな領域を提供します。

SQLite WASM にとって OPFS が重要な理由は 同期的なファイル I/O が可能な点です。IndexedDB 経由での永続化は非同期のため、SQLite のような同期的なファイルアクセスを前提とする設計とは相性が悪く、パフォーマンスが劣化します。OPFS の createSyncAccessHandle は Worker 内限定ですが、バイト単位の同期読み書きを提供するため、SQLite のネイティブに近い I/O パターンを再現できます。

メインスレッドとの通信#

DB 操作は Worker 内で行うため、メインスレッドからは postMessage で通信します。

main.ts
const worker = new Worker(
new URL("./sqlite-worker.ts", import.meta.url),
{ type: "module" },
);
function query(sql: string, bind?: unknown[]): Promise<unknown[]> {
return new Promise((resolve) => {
const id = crypto.randomUUID();
worker.addEventListener(
"message",
(e) => {
if (e.data.id === id) resolve(e.data.rows);
},
{ once: true },
);
worker.postMessage({ id, sql, bind });
});
}
const tasks = await query("SELECT * FROM tasks WHERE done = 0");

実際のアプリケーションでは、この通信層を薄いラッパーとして整備し、コンポーネント側からは通常の非同期関数として呼び出せるようにしておくのがおすすめです。

制約と注意点#

  • Worker 必須
    OPFS の同期アクセスは Worker 内でしか使えません。メインスレッドで使う場合は IndexedDB バックエンドにフォールバックしますが、パフォーマンスは大幅に落ちます。

  • 同時アクセス
    同一オリジンの複数タブから同じ DB ファイルを開くと、ロック競合が発生します。SharedWorker で DB アクセスを一元化するか、Web Locks API で排他制御を設計する必要があります。

  • WASM のサイズ
    SQLite WASM のバイナリは gzip 圧縮後で約 400 KB です。初回ロードのコストとして許容できるかはアプリの性質次第です。

  • デバッグ
    OPFS のファイルは DevTools から直接参照しにくいのが現状です。Chrome の navigator.storage.getDirectory() で探索するか、アプリ側にエクスポート機能を用意しておくと開発が楽になります。

まとめ#

  • SQLite WASM はブラウザ上でフル機能の SQL を実行でき、OPFS と組み合わせると高速な永続化が可能
  • IndexedDB が苦手な JOIN や集計を SQL の表現力でカバーできる
  • Worker 内での実行が前提。メインスレッドとは postMessage で通信する設計が必要
  • 複数タブの同時アクセスや WASM サイズなど、導入前に検討すべき制約がある

単純な CRUD なら Dexie.js + IndexedDB、複雑なクエリが必要なら SQLite WASM + OPFS、というのが現時点での使い分けの目安です。

SQLite WASM + OPFS でブラウザにリレーショナル DB を持ち込む
https://blog.c12o.net/posts/sqlite-wasm-opfs/
作者
Seu (c12o)
公開日
2026-04-16
ライセンス
CC BY-NC-SA 4.0