開発book-knowledge-baseアクティブ

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フェーズで組んだ。

  1. Phase 1: Turso DBを作成し、既存のSQLiteデータをマイグレーション
  2. Phase 2: Embedded Replicaの設定とアプリケーション側の接続切り替え
  3. 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_URLTURSO_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: ~1000DBDB数は1つで済むが、RLSの実装コスト
安全側に倒れるかYES(何もしなくても安全)YES(設定が正しければ)

SQLite(Turso)にはRLSがないからDB-per-tenant。PostgreSQL(Supabase等)にはRLSがあるから共有DB。DBエンジンのセキュリティ機能が推奨アーキテクチャを決めている。

B2B vs B2C — 最初のDB選択で分ける

ユースケース推奨アーキテクチャ推奨サービス月額目安
B2B(テナント数 数百〜数千)DB-per-tenantTurso0〜5
B2C(ユーザー数 数万〜数百万)共有DB + RLSSupabase, 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で安全側に倒す
  • 議論内容をマークダウンに保存し、メールで自分宛に送信して記録を残した