IndexedDB を「まともに使えるレベル」まで抽象化した話
はじめに
こんにちは。柚須 佳(Kei Yusu)と申します。
現在、FraGament(フラガメント) という「完全ローカルのメモアプリ」を開発しています。
FraGament は「PWA + IndexedDB」で動作するアプリで
ユーザーのデータはすべてローカルに保存されます。
クラウドもサーバーもありません。
この構成は軽いのですが、実装していくうちに
「IndexedDB をこのまま使うのは無理だな……」
と何度も思いました。
この記事は、FraGament を作る過程で生まれた
必要最低限の抽象化レイヤー(ofc-indexeddb)
をどうやって作ったかという話です。
そして、IndexedDB を実際のアプリで使おうとすると
「勉強用のサンプル」では見えなかった壁にぶつかることが多いです。
ですから本記事では 「実際のアプリで使ううえで、何が必要だったのか」 を
できるだけ実体験ベースでまとめています。
1. IndexedDB のつらさは、実装者が一番よく知っている
まず最初に、IndexedDB を使ったことがある人なら
同じところでつまずくと思います。
- Promise じゃない
- transaction を毎回書くのがだるい
- index での検索が毎回複雑
- 型が弱い
- 一瞬でコードが“勉強用サンプル”っぽい匂いになる
「Web のローカルDB」でありながら、使い勝手は MySQL や SQLite とはだいぶ違います。
FraGament は 断片(fragment)を大量に保存し
画面遷移なしで即座に読み書きする構造になっているため
このつらさをすべて解消する必要がありました。
2. 求めたのは普通のアプリで普通に使える普通のDB
FraGament では、以下のようなデータを扱います。
- users
- settings
- titles
- sessions
- fragments
そして PWA をローカルなアプリとして使うには、
DBもすべてローカルで完結しなければなりません。
そこで自分の中で定義した目標はひとつだけでした:
普通のDBのように扱いたい
これは「技術的に高級な抽象化を作ろう」という話ではなく、
アプリを作るならこれくらい普通に書けないと進まないという最低条件でした。
ちなみに、今回の技術スタックは以下を想定しています。
- Next.js 16 / React
- TypeScript
- PWA(Service Worker + manifest)
- IndexedDB(完全ローカルDB)
- Electron(今後の予定)
3. そこで作ったのが「bindStore」という発想
IndexedDB は「DB を開いて store を指定し、transaction をして request を投げて…」
という操作が多いですが、アプリ開発者にとってはもっとシンプルで良いはずです。
そこで作ったのが bindStore という API です。
bindStore は「そのストア専用のミニORM」を返す関数
と考えるとイメージしやすいかもしれません。
const titlesStore = ofcIndexedDB.bindStore<iTitles>(db, "titles", {
genId: uuid,
now: getNowDateTimeString,
});
やっていることは単純で
- 型の指定(iTiles)
- DBインスタンスの指定(db)
- store名の指定(titles)
- id発行関数の指定(uuid)
- 日付生成関数の指定(getNowDateTimeString)
をまとめて 「そのストアの操作にだけ集中できるオブジェクト」 にします。
アプリ側は「タイトルを保存したい」だけなので、
DBの細かい操作を意識しない形を作りたかったのです。
4. upsert だけで良いという結論
最初は insert と update を分けようとしていました。
しかし FraGament の UI とワークフローを見ていると
「ユーザーの操作はすべて “保存” であり、insert と update をアプリ側で区別する理由がない」
と気づきました。
そこで潔く
titlesStore.upsert({user_id: "0001", title: "新規タイトル"});
だけにしました。
- 内部では主キーの id が指定されていれば更新
- 指定がなければ生成して新規保存
- created / updated のスタンプは自動管理
という流れになっています。
UI に合わせることで、DB の API が最小になった形です。
アプリが要求する振る舞いに「寄せた」だけで
特別な発明をしたつもりはありません。
5. list(index, from, to) は実務で使う最低限の検索API
FraGament では
「タイトルに紐づくセッションを取得」
「セッションに紐づく断片を取得」
という操作が大量に発生します。
そのたびに index と範囲条件を書きたくなかったので
const sessions = await sessionsStore.list("idx_title_id", fromTitleId, toTitleId);
のような「SQL に寄せた API」にしました。
この書き方だと、ちょっと分かりづらいですが
From と To に同じ ID を入れてやることで、下記の SQL と同じ意味合いになります。
select * from sessions where title_id = TitleId
これで、セッションストア内のタイトルに紐づくレコードを全て取得できます。
6. created / updated を自動管理にした理由
FraGament の全データには必ず
- inserted(created)
- updated
が必要でした。
しかし毎回アプリ側で管理するのは不毛です。
そこで
- genId
- now
を外から渡す設計にして、
upsert() 内で自動適用されるようにしました。
こうすることで、
- 実装側のミス防止
- 表示の一貫性
- “断片の更新順” というアプリの情報設計が安定
という副次効果が得られました。
7. PWA × Electron の両対応を見据えて
FraGament の今後の構想として
PWA と Electron の両対応があります。
IndexedDB は両方で使えるので、
そのまま共通のデータレイヤーにできます。
抽象化レイヤーを作ったことで、
Electron 版ではこのまま差し替えなしで動く予定です。
8. 実際に FraGament 内でどう使っているか
一部抜粋ですが、FraGament では以下のような使い方をしています。
import { v7 as uuid } from "uuid";
import { ofcIndexedDB, OfcRec } from "@kyusu0918/ofc-indexeddb";
import { getNowDateTimeString } from "@kyusu0918/ofc-datetime";
// IndexedDBストア作成
const createStoreV1 = (db: IDBDatabase) => {
// ユーザーストア作成
// ...省略
// タイトルストア作成
if (!db.objectStoreNames.contains("titles")) {
// ストアオブジェクト作成
const store: IDBObjectStore = db.createObjectStore("titles", {keyPath: 'id'});
// インデックス作成
store.createIndex("idx_user_id", "user_id", { unique: false });
}
// ...省略
}
// タイトルストアフィールド定義
interface iTitles extends OfcRec {
user_id: string;
title: string;
}
// DB接続(ストアがない場合は作成)
const db = await ofcIndexedDB.connect("fragament_db", 1, createStoreV1);
// ストアバインド
const titlesStore = ofcIndexedDB.bindStore<iTitles>(db, "titles", {
genId: uuid,
now: getNowDateTimeString,
});
// レコード追加
const insertedId = titlesStore.upsert({user_id: "0001", title: "新規タイトル"});
// 1レコードを取得
const titleRec = titlesStore.get(insertedId);
// 複数レコード取得(ユーザーIDが0001の全タイトル)
const titleRecs = titlesStore.list("idx_user_id", "0001", "0001");
9. 大層なライブラリを作ったわけではない
このライブラリは、偉い技術を使ったわけでもなく、
自分が「こうであってほしい」と思った動きを
そのままコードに落としただけです。
ただ結果的に
- 毎日使う UI
- 大量の読み書き
- オフライン動作
- PWA / Electron の両立
- 干渉しないAPI設計
という要件を満たすためには
この形が一番自然でした。
「汎用的な“すごい抽象化”を作った」ではなく、
「アプリのために必要な最小の抽象化を作った」
という言い方が一番しっくり来ます。
10. IndexedDB は難しいより雑に作られているのかもしれない
最後にひとつだけ思ったことを書きます。
IndexedDB は「難しい」とよく言われますが、実際に触るとそれよりも
アプリ開発者のための実用的なAPIが最初から用意されていない
という感覚の方が近いです。
だからこそ
アプリの UI にフィットした最小限の抽象化を与えるだけで
IndexedDB の印象は大きく変わります。
FraGament はこのデータレイヤーに支えられて
「ローカルで完結する快適なノートアプリ」になりました。
この記事が
IndexedDB を実戦投入したい人
の参考になれば嬉しいです。
おわりに
今回は、ザックリと紹介のような感じになってしまいましたが
興味がありましたら、以下のGitHubからもダウンロードできますので触れてみてください。
次回は、今回触れられなかった
TypeScript の型安全(ジェネリクス)と createStoreV1 の話
を掘り下げる予定です。
FraGament のデータレイヤーを支える裏側の仕組みを
実際のコードを交えて紹介します。
ここまで読んでいただき、ありがとうございました。
Discussion