Turso DB移行計画とマルチテナントDBアーキテクチャ設計
book-knowledge-baseのSQLite(書籍OCRデータ16冊分、FTS5全文検索付き)をTurso(libSQL)に載せ替える計画を立てた。Codexにレビューを投げたら致命的な指摘が3つ返ってきて、計画を書き直すことになった。そこから「会計データもTursoに入れるか?」という話に流れ、クライアント単位のDB分割、RLS、シャーディングまで一気に掘り下げた。
移行計画の策定: SQLite → Turso
背景
book-knowledge-baseは書籍をOCRして構造化したデータを16冊分持っている。FTS5で全文検索できるようにしてあり、ローカルのSQLiteファイルで運用中。これをTursoに移すと、Embedded Replicaでローカル/クラウドの同期が手に入る。どの端末からでも同じデータにアクセスでき、ローカルのSQLiteファイルがレプリカとして残るのでオフラインでも読める。
最初の計画
3フェーズで組んだ。
- Phase 1: Turso DBを作成し、既存のSQLiteデータをマイグレーション
- Phase 2: Embedded Replicaの設定とアプリケーション側の接続切り替え
- Phase 3: CI/CDパイプラインの整備とモニタリング
Codexレビューで3つの致命的指摘
計画をCodex(GPT-5.4)に投げてレビューしてもらった。「瑣末な点は無視しろ、致命的な点だけ指摘しろ」と指示したところ、3つ返ってきた。
指摘1: FTS5の互換性が未検証
TursoはlibSQL(SQLiteフォーク)だが、FTS5拡張がそのまま動く保証がない。移行してからFTS5が使えないと判明したら全部巻き戻しになる。
対応: Phase 0(FTS5検証フェーズ)を計画の先頭に追加した。Turso上でFTS5テーブルを作成し、MATCH構文でクエリが通るかを確認する。ここで詰まったら移行自体を見送る判断ができる。
指摘2: データサイズとTursoの無料枠
16冊分のOCRデータがTursoの無料枠(9GB)に収まるか確認していなかった。
対応: 移行前にローカルのSQLiteファイルサイズを計測し、Turso側のストレージ使用量を見積もるステップを追加。
指摘3: ロールバック手順の欠如
移行後に問題が見つかった場合の切り戻し手順がなかった。
対応: 各フェーズにロールバック手順を明記。Embedded Replicaのローカルファイルが残るので、接続先をローカルに戻すだけで切り戻せる設計にした。
修正後の計画
Phase 0: FTS5互換性検証(Turso上でFTS5テーブル作成 → MATCHクエリ実行)
Phase 1: データサイズ見積もり → Turso DB作成 → マイグレーション
Phase 2: Embedded Replica設定 → アプリ接続切り替え
Phase 3: CI/CD整備 → モニタリング → ロールバック手順テスト
Phase 0に着手したが積み残し
Phase 0を開始して、Turso上でFTS5テーブルのCREATE文を流すところまで進めた。しかし、接続に必要な.envの設定(TURSO_DATABASE_URLとTURSO_AUTH_TOKEN)がまだ済んでおらず、実際のクエリ検証は翌日に持ち越しとなった。
クラウド会計データの格納先設計
移行計画と並行して、クラウド会計データ(会計ソフトA・会計ソフトBから取得した仕訳・財務データ)をどこに置くかの議論に入った。
3つの選択肢を比較
| 格納先 | 向いている用途 | 向かない用途 |
|---|---|---|
| スプレッドシート | 人が目で確認・手で修正する | プログラムが大量にCRUDする |
| ローカルSQLite | 単一マシンで完結する処理 | 複数端末からのアクセス |
| Turso | プログラムがCRUDし、複数端末で同期する | 人がGUIで直接いじりたい |
結論: 人が見るデータはスプレッドシートに残し、プログラムが扱うデータはTursoに入れる。両方に同じデータを持たせるのではなく、用途で分ける。
クライアント単位のDB分割をどうするか
会計データはクライアント(顧問先)ごとに存在する。2つの選択肢を検討した。
案A: クライアントごとにDBを分ける(DB-per-tenant)
client_tanaka.db → Turso DB 1つ目
client_suzuki.db → Turso DB 2つ目
案B: 1つのDBにclient_idカラムで分ける(共有DB)
SELECT * FROM journals WHERE client_id = 'tanaka' AND fiscal_year = 2025;
最初は「クライアント数が数十件だからDBを分けるのは管理コストが高い」と思ったが、後のマルチテナント設計の議論で考えが変わった。TursoはSQLiteベースでRLSがない。共有DBでWHEREを忘れると全クライアントのデータが丸見えになる。DB-per-tenantなら接続先のDBにそのクライアントのデータしか存在しないので、WHERE忘れの事故が構造的に起きない。
結論: DB-per-tenant方式を採用。TursoのDB作成コストはほぼゼロで、数十件のクライアントならFree枠(500DB)に十分収まる。スキーマ変更の手間は自動化スクリプトで対処できる。安全側に倒すならDB分離が正解。
マルチテナントDBアーキテクチャの深掘り
クライアント単位のDB分割の議論から、マルチテナントアーキテクチャ全般の話に発展した。
DB-per-tenant vs 共有DB+RLS — 「デフォルトの挙動」で選ぶ
この議論の核心は「何もしなかったとき、何が起きるか」に集約される。
DB-per-tenant: 書かないとデータに到達できない(デフォルト拒否)
共有DB(RLSあり): DBが自動でフィルタする(デフォルト拒否)
共有DB(RLSなし): 書かないと全部見える(デフォルト許可 = 危険)
DB-per-tenantでは、接続先のDBにそのテナントのデータしか物理的に存在しない。WHEREを書き忘れても他社のデータは返らない。守るべきポイントは「接続先のルーティング」1箇所だけ。一方、共有DBでWHEREを忘れると全テナントのデータが丸見えになる。RLSはこの穴をDB側で塞ぐ仕組みだ。
| 観点 | DB-per-tenant | 共有DB + RLS |
|---|---|---|
| データ分離 | 物理的に分離。WHERE不要 | 論理的に分離。RLSポリシーで制御 |
| スキーマ変更 | 全DBに個別適用が必要 | 1回のマイグレーションで済む |
| スケール | DB数が増えると管理が煩雑 | 1DBが巨大化するとシャーディングが必要 |
| コスト | Turso Free: 500DB、Developer: ~1000DB | DB数は1つで済むが、RLSの実装コスト |
| 安全側に倒れるか | YES(何もしなくても安全) | YES(設定が正しければ) |
SQLite(Turso)にはRLSがないからDB-per-tenant。PostgreSQL(Supabase等)にはRLSがあるから共有DB。DBエンジンのセキュリティ機能が推奨アーキテクチャを決めている。
B2B vs B2C — 最初のDB選択で分ける
| ユースケース | 推奨アーキテクチャ | 推奨サービス | 月額目安 |
|---|---|---|---|
| B2B(テナント数 数百〜数千) | DB-per-tenant | Turso | 0〜5 |
| B2C(ユーザー数 数万〜数百万) | 共有DB + RLS | Supabase, Neon, Railway | $5〜 |
B2Bなら最初からTurso。テナント数が限られるのでDB-per-tenantの無料枠(500DB)で十分回る。SQLiteベースでDB作成コストがほぼゼロだから、テナントが増えてもコストが跳ねない。企業顧客がデータ分離を契約で求める場面でも、物理的に別DBなので説明しやすい。
B2Cなら最初からPostgreSQL。ユーザー数が万単位に膨らむ見込みがある場合、Tursoで始めて後からPostgreSQLに移行するのは二度手間になる。最初からRLS付きの共有DBで設計しておけば、ユーザーが増えてもアーキテクチャを変える必要がない。
シャーディングとSalesforceの事例
共有DBが巨大化したときの対処がシャーディング。同じ構造のDBを複数作り、テナントを振り分ける。ルーティングテーブル(tenant_id → shard_id)で接続先を決める。Salesforceは数十万社を共有DBに入れつつ、シャード内はRLSでアクセス制御している。
スケール時の成長パスは「最初はDB-per-tenant、壁にぶつかったら移行」で十分。
10社: DB-per-tenant(Turso Free)→ $0
500社: DB-per-tenant(Turso Scaler)→ $29
5,000社: Enterprise交渉 or シャーディング検討
10,000社+: PostgreSQL系に移行 or 専任チームが再設計
Supabase RLSの設定漏れ事故 — 4つのパターン
Supabaseは「フロントエンドから直接DBを叩く」設計のため、RLSの設定ミスが即データ漏洩に直結する。従来型(Railway等)ではバックエンドがDBの前に立つのでRLSがなくても多層防御が効くが、Supabaseではバックエンドが存在しないためRLSが唯一の防壁になる。
パターン1: RLS有効化忘れ
SQLやマイグレーションで作ったテーブルはRLSがデフォルト無効。Dashboard経由なら有効だが、コードで作ると忘れやすい。2025年1月にノーコードツールLovableで170以上のアプリがRLS無効のまま公開されていた事故が発覚した。
CREATE TABLE invoices (id serial, client_id text, amount numeric);
-- ↓ これを忘れると anon key で全データが丸見え
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
パターン2: service_role キーのフロントエンド露出
Supabaseにはanon key(RLSに従う)とservice_role key(RLSをバイパス)の2種類がある。後者をフロントエンドに埋め込むと、RLSを設定してあっても全データにアクセスできてしまう。
パターン3: SELECTポリシーの条件が甘い
-- ダメ: 認証済みなら誰でも全行読める
CREATE POLICY "read all" ON posts
FOR SELECT USING (auth.role() = 'authenticated');
-- 正しい: 自分のデータだけ
CREATE POLICY "own data" ON posts
FOR SELECT USING (auth.uid() = user_id);
パターン4: Realtime + RLSの設定不足
リアルタイム更新(チャット等)でもSELECTポリシーが必要。加えて、更新前のデータ(old record)を受け取るにはREPLICA IDENTITY FULLの設定が要るが、RLS有効時はold recordに主キーしか含まれない制約がある。
RLSは設定さえ正しければデータを守れるが、「設定が正しいこと」を検証し続ける仕組み(CIでのポリシーテスト、Supabase Security Advisor等)がないと穴が開く。
今日の判断まとめ
- book-knowledge-base: Turso移行計画をCodexレビューで修正し、Phase 0(FTS5検証)から着手。
.env設定が残っており翌日に持ち越し - 会計データ格納: 人が見る→スプレッドシート、プログラムが扱う→Turso。クライアントごとにDBを分離(DB-per-tenant)
- マルチテナント設計: B2B(Turso/SQLite)→DB-per-tenant、B2C(PostgreSQL)→共有DB+RLS。自分の用途(会計顧問先数十件)はTurso + DB-per-tenantで安全側に倒す
- 議論内容をマークダウンに保存し、メールで自分宛に送信して記録を残した