なぜ私たちはまだデータベースを使っていないのか
iPad向け手書きアプリで Turso/libSQL を評価し、すべてを計測したうえで、フラットなスナップショットファイルを選んだ。私たちのワークロードにデータベースは必要ない。各段階では、すでに存在する問題にだけコストを払うべきだからだ。
Lulucat Notes は iPad 向けの手書きアプリだ。先週まで、キャンバスは1つだけで、2つ目のノートという概念はなかった。複数のドキュメントを扱い、それぞれに複数のページを持つノートライブラリを追加しようとしていた。そのとき最初に出てきたアーキテクチャ上の問いが、ストレージだった。
データベースは当然の答えに思えた。ノートアプリは構造化データを保存する。構造化データはデータベースに入れる。私たちは Turso とその Swift SDK を評価し、iOS シミュレーターで動かし、実際のストロークデータをベンチマークした。そして、使わないことにした。
代わりにフラットファイルを選んだ。何が分かり、なぜそう判断したのかを説明する。

写真: Gabriel Cox(Unsplash)。Unsplash License。
アプリが実際にデータへ行っていること
手書きアプリのデータアクセスパターンは、狭く予測可能だ。読むときはページを開き、そのページにあるすべての要素 — すべてのストローク、すべての画像 — を一度にメモリへ読み込む。キャンバスはすべてを保持し、部分的なクエリを実行することはない。書くときはペンのストロークを終え、ページに1つの要素を追加する。まれにストロークの一部を消したり、選択範囲を動かしたり、何かを削除したりするが、それでも操作は1ページ、1要素単位だ。
同時アクセスはない。1人のユーザーが、1つのドキュメントの1ページに、一度に書き込む。ドキュメントをまたぐ検索もない。ノートライブラリが各ドキュメントについて必要とするのは、タイトル、タイムスタンプ、ページ数、表紙のサムネイルだけで、ページの内容を読む必要はない。
クエリ、インデックス、並行性の調整は、データベースが得意とするものだ。私たちのアプリは、その3つをどれも使わない。
Turso の評価
Turso の libSQL エンジン向け公式 Swift SDK、libsql-swift を評価した。
SDK は動く。 9つあるテストケースはすべてパスした。アプリのコピーに統合し、iOS シミュレーター向けにビルドして起動し、アプリのサンドボックスにローカルデータベースを作った。1回のトランザクションで、サンプリングポイント3,400個のストロークを100本書き込んだ — BLOB データは合計 4,080,000 バイトだ。私たちの開発用 Mac では約 0.019 秒かかった。
PRAGMA wal_checkpoint(TRUNCATE) を実行すると、WAL ファイルはゼロまで縮んだ。そこでメインの .db ファイルだけを別の場所へコピーし、開いて、すべてのデータを読み戻すことができた。エンジン自体は堅実だ。
SDK にはコストがある。 CLibsql.xcframework のサイズは 161 MB だ。リンク後、Debug シミュレーターのビルドは約 1.9 MB から約 8.2 MB になった。API は同期的でブロッキング方式であり、Swift Concurrency のラッパーはない。明示的な close() メソッドもない。Transaction.commit() は throw しない — 内部の C API が void を返すためだ。リポジトリの README はこの SDK を「technical preview」と記載しており、最新のコミットは私たちが評価する約1年前、2025年7月のものだった。
Turso のエコシステムには空白がある。 Turso は現在、新しいプロジェクトには新しい「Turso Database」エンジンと「Turso Sync」プロトコルを推奨している。Turso Sync には TypeScript、Python、Go、Rust 向けのクライアント SDK がある。しかし Swift 向けはない。古い Embedded Replica モードは libsql-swift に存在するが、その Swift のイニシャライザーは、完全にローカルファーストのモバイルアプリに必要な offline パラメーターを公開していない。いま libsql-swift を採用しても、手に入るのはローカルの SQLite フォークであり、Turso を特徴づける同期機能ではない。
今データベースを採用すると、何がコストになるか
SDK が成熟していたとしても、私たちのワークロードには何ももたらさないコストを払い続けることになる。
WAL サイドカーファイルの管理。 実行中のデータベースは -wal と -shm の付属ファイルを作る。ドキュメントをコピーするには、先にチェックポイントを実行するか、3つのファイルをすべてアトミックにコピーする必要がある。.lnote パッケージを Files や AirDrop に書き出すとき、ユーザーには見えず、開発者が忘れてはならない事前のエクスポート処理が必要になる。
アダプター層。 ストロークは BLOB にシリアライズし、またデシリアライズしなければならない。ページ要素には、キャンバスがそのまま描画できる自然な配列順がある。データベースを入れると、行の順序と z-index の列が必要になる。同じデータを2つの表現の間で変換する層を書き、スキーマが変わるたびに保守することになる。
161 MB の依存関係。 Debug ビルドが2 MB未満のアプリにとって、アプリそのものの 80× を超える依存関係は、気に留める価値のあるコストだ。しかも「technical preview」とされ、1年間更新されていないものならなおさらだ。
これらのコストは仮定の話ではない。依存関係をリンクした瞬間に始まる。そして得られるのは、クエリ、インデックス、同時書き込みという、私たちのアプリが使っていない機能だ。
出荷した解決策:スナップショットファイルのパッケージ
.lnote ドキュメントはディレクトリパッケージだ。
Documents/Notes/<UUID>.lnote/
manifest.json # library cache: title, time, page count, cover
document.json # source of truth: document metadata + page order
pages/
<page-uuid>.content # one snapshot per page
assets/ # document-level shared resources
<asset-uuid>.jpg
thumbnails/
<page-uuid>.jpg # per-page thumbnail; first page doubles as cover
document.json はドキュメント構造の正本だ。ID、タイトル、タイムスタンプ、そして各ページのキャンバスサイズ、タイムスタンプ、要素数を含む、順序付きのページ一覧を持つ。ページのコンテンツファイルは、アプリがすでに使っているものと同じ量子化された整数エンコーディングで要素配列を保存する — 座標と半径は 0.1 ポイント精度、筆圧は千分の一、タイムスタンプは相対ミリ秒だ。
ノートライブラリが読むのは manifest.json と表紙のサムネイルだけだ。document.json やページコンテンツを解析することはない。ページを開くと、1つの .content ファイルを読み込む。ストロークデータに触れるファイル読み込みは、それだけだ。
ページが write amplification を解決する
手書きアプリにはもともとページという概念がある。ユーザーが考える単位であり、スワイプして移動する対象だ。ページを永続化の単位にすれば、自動保存は変更されたページだけを書き換えればよい。
手書きの1ページ — たとえば1,000〜2,000ストローク — は、私たちの量子化フォーマットでおよそ 3〜5 MB になる。3,400個のサンプリングポイントを持つ21ストロークの記録1つは、量子化すると約55 KBだ。現代のハードウェアで、ページのスナップショット1つをフラッシュストレージへ書くのにかかる時間は 10〜20 ミリ秒。0.5秒のデバウンスを入れれば、保存はユーザーから見えない。
保存コストはドキュメントの全ページ数ではなく、現在のページに書かれた量に比例する。200ページのノートでも2ページのノートでも保存速度が同じなのは、変更されたページだけを書き換えるからだ。
すべての書き込みはアトミックなファイル操作を使う — 一時ファイルに書き込み、その後リネームする — ため、保存中のクラッシュでページが途中までの状態になることはない。バックグラウンドへ移ると、変更されたページをすべて即座にフラッシュする。これは、キャンバスが1つだけだったときからアプリが持っていた挙動と同じだ。
トランザクションなしの整合性
ファイルパッケージにトランザクションはない。しかし、同じ目的を果たす明確な所有権ルールがある。
参照より先にリソース。 ユーザーが画像を挿入すると、アセットファイルはすぐに assets/ へ書き込まれる。ID でアセットを参照するページスナップショットは、デバウンスされた自動保存によって後から書き込まれる。ページがディスク上に存在しないアセットを参照する瞬間はない。
正本が勝つ。 document.json と pages/ ディレクトリが正本だ。manifest.json はキャッシュである。両者が食い違えば、次の保存時にキャッシュを正本に合わせて整合させる。サムネイルは派生データなので、いつでも再生成できる。
ぶら下がった参照より孤児。 クラッシュで起きる最悪の結果は孤児アセットだ。assets/ にあるのに、どのページからも参照されていないファイルである。孤児はドキュメントを閉じるときに削除する。逆に、ページが存在しないファイルを参照することは起こり得ない。参照元のページスナップショットより先にアセットを書き込むからだ。
これらのルールは、WAL のチェックポイントやトランザクション分離よりも考えやすい。アプリの単一プロセス、単一ページというアクセスパターンにも、ぴったり合っている。
アップグレードの道筋は書き留めてある
いまフラットファイルを選ぶことは、永遠にフラットファイルを選ぶことではない。パッケージ構造は、パッケージ自体を変えずに、その中身だけをストレージエンジンのアップグレードに合わせて変えられるよう設計してある。
レベル1:スナップショット + 追記ジャーナル。 もし write amplification が目に見えるようになったら — たとえば、数千ストロークのページで連続して書き続けると保存の遅延が気になるようになったら — 各ページファイルをスナップショットと追記専用ジャーナルに分ける。新しい要素は [length][CRC][type][payload] フレームとして追加する。ジャーナルを再生するとき、CRC が一致しないフレームは破棄するため、クラッシュに対する安全性が得られる。ジャーナルがしきい値を超えるかページを閉じたとき、スナップショットへマージする。外部依存なしで、コードにするとおよそ 200 行だ。
ページ単位のスナップショットによってページをまたぐ write amplification はすでに解消されているため、このレベルが必要になるのはずっと先かもしれない。5 MB のページを 0.5秒ごとに書き換えても、フラッシュの書き込み予算には十分収まる。
レベル2:SQLite データベース。 アプリにノート全体の全文検索、要素単位の同期、ドキュメント横断のインデックスが必要になったら、SQLite が適切なツールになる。その時点で有力なエンジンは GRDB だろう。成熟した、ソースからコンパイルされる Swift ラッパーで、バイナリサイズのオーバーヘッドはほぼゼロだ。libsql-swift を再検討するのは、Turso のエコシステム — 特に Swift 向け Turso Sync — が現実のプロダクト要件になった場合だけだ。
移行手順は機械的だ。各ページの要素配列を strokes / images テーブルへマッピングし、ストロークごとに不変の BLOB を1つ置く(リトルエンディアンのバイナリで、サンプリングポイント1つあたり16バイト)。100ストロークで 0.019秒というベンチマークは、この方法が実用になることを確認している。出荷前には、古いパッケージと新しいデータベースの要素数とアセット数が一致すること、クラッシュからの復旧、WAL の上限、バックグラウンドフラッシュがすべて通ることを移行処理で検証する。
いつコストを払うか
この判断はデータベースへの評価ではない。SQLite は共有状態に対する同時書き込み、複雑なクエリ、クラッシュからの復旧を扱える。しかし、今の私たちのアプリにはそのどれも必要ない。アプリがまだ抱えていない問題を解決する能力に先回りしてお金を払うのは、純粋な損失だ。
データベースのコスト — 依存関係、WAL の管理、アダプター層、バイナリサイズ — はライブラリをリンクした瞬間に始まる。利益が始まるのは、アプリに実行するクエリ、維持するインデックス、調整する同時書き込みができてからだ。この段階で、アプリにはその3つがない。
ストレージ進化の各段階では、すでに現れた問題にだけコストを払う。ページ単位のスナップショットが解決するのは、今日ある問題、つまりファイル全体を書き換えずに複数ページのドキュメントを保存することだ。write amplification が測定できるようになったら、追記ジャーナルがその問題を解決する。検索や同期がプロダクト要件になったら、データベースがその問題を解決する。
パッケージ構造、manifest、ドキュメントスキーマは、どのストレージエンジンにも縛られていない。境界が正しい場所にあるため、切り替えコストは低い。データベースが本当に必要になる日が来たら、仮定の問題のためではなく、すでに計測された具体的な問題のために採用する。