チームツールのDB設計パターン【未読管理・通知・権限をSupabaseで実装】


チームツールのDB設計とは、「未読管理」「通知」「権限」の3領域を、テーブル構造とアクセス制御ポリシーの両方で同時に設計することです。特にSupabase(PostgreSQL)では、テーブルを作っただけでは公開APIから誰でも読み書きできる状態のままであり、全テーブルでRLS(行レベルセキュリティ)を有効化して初めて権限設計が成立します。

チームで使うコミュニケーションツールを自作しようとすると、機能の実装より先にデータベース設計で詰まることが多いです。テーブルを並べるところまでは書けても、「誰がどの行を読めるのか」を決めきれずに走り出してしまい、後から作り直すことになりがちです。

この記事では、Next.js + Supabase でチームボードを構築する前提で、テーブル定義・RLSポリシー・検証手順までをひと続きで解説します。掲載しているSQLはPostgreSQL 16.14(Docker公式イメージ)で上から順に実行し、エラー0で通ることを確認済みです。権限の穴が実際にふさがるかどうかも、同じ環境で再現テストして確かめています。


チームツールに必要なテーブルと依存順

チームツールに必要なテーブルを整理すると、4つのグループに分けられます。

グループテーブル役割
コアaccountsユーザーアカウント。権限(role)もここに持つ
コアchannelsチャンネル・DM
コアchannel_members「誰がどのチャンネルに参加しているか」。権限判定の土台
メッセージmessagesメッセージ本体
メッセージreactionsリアクション(絵文字)
未読・通知read_messages既読管理
未読・通知notifications通知
未読・通知notification_settings通知設定
その他bookmarksブックマーク
その他online_statusオンラインステータス

10テーブル構成です。この記事で定義を示すのは、未読・通知・権限に直接関わる7つ(accounts / channels / channel_members / messages / read_messages / notifications / notification_settings)です。残りの3つは「所有者のIDと対象のIDを持つだけ」の素直な構造なので、同じ考え方をそのまま当てはめられます。

channel_members を最初に置く理由

チームツールの設計で最初に決めるべきなのは、メッセージの形ではなく「参加者の表」です。チャンネル参加者テーブルが無いと、「このユーザーはこのチャンネルを見てよいのか」を判定する根拠がどこにも無くなります。その結果、未読数は参加していないチャンネルまで数えてしまい、権限ポリシーも書きようがなくなります。

channel_members は行数こそ地味ですが、このあと出てくる未読数クエリとRLSポリシーの両方が参照する中心的なテーブルです。

コアの4テーブルを依存順に作る

外部キーは「参照される側のテーブルが先に存在していること」を要求します。したがって作成順は accountschannelschannel_membersmessages の一択です。この順序を崩すと ERROR: relation "accounts" does not exist で止まります。

-- コアの4テーブル。依存順(親 → 子)に作る
CREATE TABLE accounts (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email      TEXT NOT NULL UNIQUE,
  name       TEXT NOT NULL,
  role       TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE channels (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        TEXT NOT NULL,
  type        TEXT NOT NULL DEFAULT 'channel' CHECK (type IN ('channel', 'dm')),
  created_by  UUID REFERENCES accounts(id) ON DELETE SET NULL,
  is_archived BOOLEAN NOT NULL DEFAULT false,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE channel_members (
  channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
  account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  joined_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (channel_id, account_id)
);

CREATE TABLE messages (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
  account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  body       TEXT NOT NULL,
  deleted_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

created_by だけ ON DELETE SET NULL にしているのは、チャンネル作成者が退職してもチャンネル自体は残したいためです。逆に channel_idaccount_idON DELETE CASCADE で、親が消えたら子も消えるようにしています。削除時の挙動をFK側で宣言しておかないと、あとでアカウント1件を消すだけでも外部キー違反で止まります。この落とし穴は後述の「削除方針」で詳しく扱います。データベースとAPIの役割分担そのものを整理したい場合はバックエンドとは?サーバー・DB・APIの全体像もあわせてどうぞ。


未読管理の設計パターン

未読管理はチームツールの中で最も設計が難しい領域です。「誰が・どのメッセージを・読んだか」を記録し、そこから「まだ読んでいない件数」を逆算する必要があります。

既読レコードを積み上げる方式

メッセージが読まれるたびに「アカウント × メッセージ」の組み合わせを1行ずつ追加していく方式です。主キーをその2列にすることで、同じ組み合わせが二重に入ることを構造的に防げます。

CREATE TABLE read_messages (
  account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
  read_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (account_id, message_id)
);

未読数クエリで数え間違えやすい3点

未読数は「既読レコードが無いメッセージ」を数えれば求まる、と考えたくなります。しかしそれだけでは次の3つを全部拾ってしまいます。

  1. 自分が参加していないチャンネルのメッセージ
  2. 他人同士のDM
  3. 自分が投稿したメッセージ(自分の発言が自分の未読になる)

1と2は channel_members との結合で、3は投稿者の除外条件で落とします。論理削除済みのメッセージも数えないようにします。

-- チャンネルごとの未読数を取得
SELECT
  m.channel_id,
  COUNT(*) AS unread_count
FROM messages m
JOIN channel_members cm
  ON cm.channel_id = m.channel_id
 AND cm.account_id = (select auth.uid())     -- 参加しているチャンネルだけ
LEFT JOIN read_messages r
  ON r.message_id = m.id
 AND r.account_id = (select auth.uid())
WHERE r.message_id IS NULL                   -- 既読レコードが無い = 未読
  AND m.account_id <> (select auth.uid())    -- 自分の投稿は数えない
  AND m.deleted_at IS NULL                   -- 削除済みは数えない
GROUP BY m.channel_id;

この差は実際に数字として出ます。一般メンバーのAliceが general にだけ参加していて、役員限定 チャンネルには入っていない状態で両方のクエリを流すと、結果はこうなりました。

クエリgeneral役員限定問題
参加判定なし・投稿者の除外なし21未参加チャンネルと自分の投稿を数えている
上のクエリ(参加判定+投稿者除外)1なし

auth.uid()(select auth.uid()) と書いているのは書き癖ではありません。Supabase公式は、サブクエリで包むとPostgreSQLのinitPlan最適化が働き、行ごとではなくステートメントごとに1回だけ評価されると説明しています。公式のベンチマークでは179msから9msへ、94.97%の改善が報告されています(Supabase Docs: Row Level Security)。

テーブル肥大化への備え

この方式はシンプルですが、read_messages は「メンバー数 × メッセージ数」で増えます。10人のチームが1日300メッセージやり取りすると、1年で約110万行です。運用開始前に「一定期間より古い既読レコードは消す」という方針を決めておくと安全です。

もう一つの選択肢が「最後に読んだメッセージIDだけを記録する」方式です。行数はメンバー数×チャンネル数に抑えられますが、メッセージの削除・編集や、途中まで読んで戻る操作への対応が複雑になります。小規模チームなら既読レコード積み上げ方式のほうが実装がシンプルです。より小さな単一テーブル設計でログを扱う例としては、FAQチャットウィジェットをCloudflare D1で実装する方法が参考になります。


通知の設計パターン

通知テーブルと重複防止

通知は「誰に・何の・どのメッセージに関する通知か」を記録します。種類(メンション・DM・リアクションなど)はカラムで区別するのが基本です。

ここで見落としやすいのが重複防止の制約です。制約が無いと、通知生成処理がリトライされたときやWebhookが二重に届いたときに、まったく同じ通知が2件並びます。実際に制約の無いテーブルへ同じ通知を2回INSERTすると、どちらも成功して2行入りました。

CREATE TABLE notifications (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,  -- 受信者
  type       TEXT NOT NULL CHECK (type IN ('mention', 'dm', 'reaction', 'reply')),
  message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
  channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
  sender_id  UUID REFERENCES accounts(id) ON DELETE SET NULL,          -- 送信者
  is_read    BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (account_id, type, message_id)
);

注意点として、PostgreSQLの UNIQUE はNULL同士を「別の値」として扱います。したがって message_id がNULLになる通知(メッセージに紐づかないお知らせなど)はこの制約では重複を防げません。そうした通知を扱う場合は、種別ごとに専用の部分UNIQUEインデックスを足すか、通知の生成側で冪等性を担保します。

通知設定テーブルは主キーで縛らない

「このチャンネルの通知はオフにしたい」「メンションだけ受け取りたい」といった設定は別テーブルに持ちます。よくある設計は「channel_id がNULLならアカウント全体のデフォルト設定」というものですが、PRIMARY KEY (account_id, channel_id) にしてしまうとこれは実現できません。主キーの構成列は暗黙にNOT NULLになるためです。

実際に主キー版のテーブルへグローバル設定を入れようとすると ERROR: null value in column "channel_id" ... violates not-null constraint で弾かれます。代理キーを主キーにし、一意性は部分UNIQUEインデックス2本で表現するのが正解です。

CREATE TABLE notification_settings (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  account_id       UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  channel_id       UUID REFERENCES channels(id) ON DELETE CASCADE,  -- NULL = 全体のデフォルト
  mentions_enabled BOOLEAN NOT NULL DEFAULT true,
  dm_enabled       BOOLEAN NOT NULL DEFAULT true
);

-- channel_id ありの行は「アカウント × チャンネル」で一意
CREATE UNIQUE INDEX notification_settings_channel_key
  ON notification_settings (account_id, channel_id)
  WHERE channel_id IS NOT NULL;

-- channel_id が NULL の行(全体のデフォルト)はアカウントごとに1行だけ
CREATE UNIQUE INDEX notification_settings_global_key
  ON notification_settings (account_id)
  WHERE channel_id IS NULL;

Supabase Realtime と filter の役割

Supabase Realtimeとは、PostgreSQLのデータ変更をWebSocketでフロントエンドへ配信する機能です。通知テーブルに行が挿入された瞬間、該当ユーザーの画面に反映されます。WebSocketの接続管理やリトライをSupabaseが担うため、自前実装が不要です。

// 通知テーブルの変更をリアルタイム購読
supabase
  .channel('notifications')
  .on('postgres_changes', {
    event: 'INSERT',
    schema: 'public',
    table: 'notifications',
    filter: `account_id=eq.${session.user.id}`,
  }, (payload) => {
    // 新着通知をUIに反映する処理
  })
  .subscribe()

ここで最も誤解されやすいのが filter の役割です。filter は「このクライアントが受け取りたい範囲」を指定するもので、権限を決めるものではありません。Supabase公式は filter を「クライアントが受け取るデータをより細かく指定するためのもの」と説明する一方、認可については「Postgres Changesはすべてのイベントを購読者ごとに認可する(Postgres Changes authorizes every event against each subscriber)」と、RLSによる別レイヤーの仕組みとして記述しています(Supabase Docs: Postgres Changes)。

つまり notifications にRLSポリシーが無ければ、購読者ごとの認可で弾く根拠が存在しません。filter に他人のIDを書いたクライアントを止めるものは何も無いことになります。この行の filter は利便性のための指定であって、セキュリティ境界は次章のRLSが担います。


権限設計とRLS

RLS(Row Level Security・行レベルセキュリティ)とは、「このレコードは誰が読み書きできるか」をPostgreSQL側で定義する仕組みです。Supabaseの権限設計は、実質このRLSがすべてです。

RLSは全テーブルで有効化する

Supabase公式は「公開スキーマに置かれたテーブルでは、RLSを常に有効にしなければならない。既定ではこれは public スキーマである」と明記しています。有効化されていないテーブルは、publishableキーを持つ誰からでもAPI経由で読み書きできる状態です(Supabase Docs: Row Level Security)。

これは抽象的な警告ではありません。accounts だけRLSを有効にして残りを放置した状態を作り、一般メンバーのAliceとして接続したところ、次のことが実際にできました。

Aliceが試した操作結果
参加していない「役員限定」チャンネルのメッセージを読む読めた(全2件が返る)
他人(Bob)宛の通知を読む読めた
他人宛の通知を既読に書き換えるUPDATE 1
他人のメッセージを削除するDELETE 1

したがって、RLSの有効化はテーブルを1つ作るたびに書く定型作業として扱います。

ALTER TABLE accounts              ENABLE ROW LEVEL SECURITY;
ALTER TABLE channels              ENABLE ROW LEVEL SECURITY;
ALTER TABLE channel_members       ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages              ENABLE ROW LEVEL SECURITY;
ALTER TABLE read_messages         ENABLE ROW LEVEL SECURITY;
ALTER TABLE notifications         ENABLE ROW LEVEL SECURITY;
ALTER TABLE notification_settings ENABLE ROW LEVEL SECURITY;

有効化しただけではアプリが動かない

RLSを有効にすると、ポリシーが1本も無いテーブルはすべてのアクセスが拒否されます。UPDATEポリシーだけを書いてSELECTポリシーを書き忘れると、自分の行すら見えません。実際にUPDATEポリシー1本だけの状態でAliceとして自分の名前を更新すると、エラーにはならず UPDATE 0(0行更新)が返り、原因に気づきにくい形で失敗します。

ポリシーには TO authenticated のようにロールを明示します。Supabase公式は、対象外のロールに対する無駄なポリシー評価を避けられるとして常に付けることを推奨しており、anonauthenticated 限定のポリシーに触れるケースで99.78%の改善というベンチマークを示しています。

WITH CHECK を省くと自分を管理者に昇格できる

ここがこの記事で最も重要な箇所です。「自分のアカウントだけ更新できる」を素直に書くと、次のようになります。

-- 危険なポリシー(このままにしない)
CREATE POLICY "自分のアカウントのみ更新可" ON accounts FOR UPDATE
  USING (id = auth.uid());

問題は role カラムが同じ accounts の同じ行にあることです。このポリシーは「更新してよい行かどうか」しか見ておらず、「更新後の行がどうなるか」を見ていません。そのため一般メンバーが次の1文を投げるだけで管理者になれます。

UPDATE accounts SET role = 'admin' WHERE id = auth.uid();
-- → UPDATE 1(role が admin になる)

「チャンネルの削除・アーカイブは管理者のみ」といった設計は、この一文で丸ごと無効になります。なお、SELECTポリシーを書き忘れているうちは UPDATE 0 のままなので一見安全に見えますが、アプリを動かすためにSELECTポリシーを足した瞬間に昇格が通るようになります。この順番で気づくのが最も危険です。

ここでよくある誤解を1つ正しておきます。「WITH CHECK が無いと他人のデータを作れてしまう」という説明を見かけますが、このケースでは正しくありません。PostgreSQL公式は「WITH CHECK 式が定義されていない場合、USING 式が可視性の判定と新しい行の許可判定の両方に使われる」と述べています(PostgreSQL 16 Documentation: CREATE POLICY)。したがって id を他人のIDに書き換える攻撃は USING の流用で防がれ、実際に試すと ERROR: new row violates row-level security policy で止まります。

防げないのは id 以外の列、つまり role の自己書き換えです。USING の条件は id しか見ていないため、id が変わらない限り何を書き換えても条件を満たしてしまうからです。修正版では WITH CHECK に「更新後の role が更新前と同じであること」を明示します。

CREATE POLICY "アカウントは全員が閲覧可" ON accounts FOR SELECT
  TO authenticated
  USING ( true );

CREATE POLICY "自分のアカウントのみ更新可" ON accounts FOR UPDATE
  TO authenticated
  USING ( (select auth.uid()) = id )
  WITH CHECK ( (select auth.uid()) = id
               AND role = (SELECT a.role FROM accounts a WHERE a.id = (select auth.uid())) );

WITH CHECK の中のサブクエリは更新前のスナップショットを読むため、role を変えようとした瞬間に「更新前の値と違う」と判定されます。ロールの変更はサーバー側(RLSをバイパスする service_role)でのみ行う、という運用とセットで使います。認証情報の置き場所も含めてクライアント側の前提を確認したい場合は、localStorageにJWTを保存するリスクもあわせて読んでおくと判断しやすくなります。

チャンネルとメッセージのポリシー

「参加しているチャンネルだけが見える」は channel_members の存在チェックで表現します。ここが channel_members を最初に作った理由です。

CREATE POLICY "自分の参加行のみ閲覧可" ON channel_members FOR SELECT
  TO authenticated
  USING ( (select auth.uid()) = account_id );

CREATE POLICY "参加しているチャンネルのみ閲覧可" ON channels FOR SELECT
  TO authenticated
  USING ( EXISTS (SELECT 1 FROM channel_members cm
                   WHERE cm.channel_id = channels.id
                     AND cm.account_id = (select auth.uid())) );

CREATE POLICY "参加チャンネルのメッセージのみ閲覧可" ON messages FOR SELECT
  TO authenticated
  USING ( EXISTS (SELECT 1 FROM channel_members cm
                   WHERE cm.channel_id = messages.channel_id
                     AND cm.account_id = (select auth.uid())) );

CREATE POLICY "参加チャンネルへ自分名義でのみ投稿可" ON messages FOR INSERT
  TO authenticated
  WITH CHECK ( (select auth.uid()) = account_id
               AND EXISTS (SELECT 1 FROM channel_members cm
                            WHERE cm.channel_id = messages.channel_id
                              AND cm.account_id = (select auth.uid())) );

CREATE POLICY "自分のメッセージのみ更新可" ON messages FOR UPDATE
  TO authenticated
  USING ( (select auth.uid()) = account_id )
  WITH CHECK ( (select auth.uid()) = account_id );

CREATE POLICY "自分のメッセージのみ削除可" ON messages FOR DELETE
  TO authenticated
  USING ( (select auth.uid()) = account_id );

INSERTポリシーで account_id まで縛っているのは、他人になりすました投稿を防ぐためです。この2条件を入れた状態でAliceが役員限定チャンネルへ投稿を試みると ERROR: new row violates row-level security policy for table "messages" で止まります。

未読と通知のポリシー

既読・通知・通知設定はいずれも「自分の行だけ」です。通知にINSERTポリシーを置いていないのは、通知の生成をサーバー側の処理に限定するためです。service_role はRLSをバイパスするため、ポリシーが無くてもサーバーからは書き込めます。

CREATE POLICY "自分の既読のみ閲覧可" ON read_messages FOR SELECT
  TO authenticated
  USING ( (select auth.uid()) = account_id );

CREATE POLICY "自分の既読のみ登録可" ON read_messages FOR INSERT
  TO authenticated
  WITH CHECK ( (select auth.uid()) = account_id );

CREATE POLICY "自分宛の通知のみ閲覧可" ON notifications FOR SELECT
  TO authenticated
  USING ( (select auth.uid()) = account_id );

CREATE POLICY "自分宛の通知のみ既読化可" ON notifications FOR UPDATE
  TO authenticated
  USING ( (select auth.uid()) = account_id )
  WITH CHECK ( (select auth.uid()) = account_id );

CREATE POLICY "自分の通知設定のみ操作可" ON notification_settings FOR ALL
  TO authenticated
  USING ( (select auth.uid()) = account_id )
  WITH CHECK ( (select auth.uid()) = account_id );

ここまでを適用した状態でAliceとして接続すると、見えるチャンネルは general のみ、見える通知は0件、他人の通知への UPDATE と他人のメッセージへの DELETE はいずれも0行になりました。前掲の表と正反対の結果です。


設計で気をつけたこと

削除方針は ON DELETE まで決めて初めて完成する

論理削除とは、レコードを実際には消さず deleted_at カラムで「削除済み」を表す方式です。チームツールのメッセージは「削除されたメッセージです」と表示する仕様が多いため論理削除が向きます。一方、通知や既読レコードは物理削除で十分です。

ただし「物理削除で十分」と決めただけでは足りません。外部キーに ON DELETE を書いていないと、PostgreSQLの既定は NO ACTION になり、参照されている行は物理削除できないからです。実際に ON DELETE を書かないスキーマでアカウントを1件削除すると、次のエラーで止まります。

ERROR:  update or delete on table "accounts" violates foreign key constraint
        "channels_created_by_fkey" on table "channels"

この記事のスキーマでは、親と運命を共にする子には ON DELETE CASCADE、残しておきたい参照(チャンネルの作成者・通知の送信者)には ON DELETE SET NULL を指定しています。削除方針は「論理か物理か」ではなく、「テーブルごとにどちらを選び、FKにどう書くか」まで決めて初めて完成します。

インデックスは何を張り、何を張らないか

read_messagesaccount_idmessage_id に複合インデックスを張る」という説明を見かけますが、この表のPRIMARY KEYはすでに (account_id, message_id) なので、同じ並びのインデックスを追加しても完全に冗長です。必要なのは逆向き、つまり message_id 単独のインデックスです。メッセージ削除時のカスケードや、メッセージ側から既読者を引くときに効きます。

-- チャンネルの最新メッセージ取得(最も回数が多いクエリ)
CREATE INDEX messages_channel_created_idx
  ON messages (channel_id, created_at DESC);

-- PRIMARY KEY が (account_id, message_id) なので、必要なのは逆向きの1本だけ
CREATE INDEX read_messages_message_idx
  ON read_messages (message_id);

-- 未読の通知だけを引くので部分インデックスにする
CREATE INDEX notifications_unread_idx
  ON notifications (account_id, created_at DESC)
  WHERE is_read = false;

-- 「自分が参加しているチャンネル一覧」用(PKは (channel_id, account_id) のため)
CREATE INDEX channel_members_account_idx
  ON channel_members (account_id);

Supabase無料枠の制約

無料枠(Free Plan)の主な上限は次のとおりです。数値は公式の料金ページに掲載されているものです。

項目Free Plan
データベースサイズ500 MB(Shared CPU・500 MB RAM)
ファイルストレージ1 GB
Egress(転送量)5 GB + キャッシュEgress 5 GB
月間アクティブユーザー50,000
アクティブプロジェクト数2つまで
一時停止1週間アクセスが無いと一時停止

ファイルストレージはDBサイズとは別枠で1 GBです(Supabase Pricing)。実務で効いてくるのは「アクティブプロジェクト2つまで」「1週間アクセスが無いと一時停止」の2つで、開発用と本番用で1つずつ使うと空きが無くなります。5〜10人のチームなら容量面は当面問題になりませんが、有料プランへ移る条件は先に決めておくほうが安全です。同じ規模の個人開発でCloudflare D1を選んだ場合との比較は、Next.js × Cloudflare Workers × D1 でSaaSを作るにまとめています。


設計を検証する3つのチェック

ここまでのSQLを流し終えたら、必ず自分の手で結果を確認します。設計が正しいかどうかは、別のユーザーとして接続して叩いてみるまで分かりません。SupabaseのSQLエディタでそのまま実行できる3つのチェックを挙げます。

検証1:RLSを有効化し忘れたテーブルが無いか

SELECT relname AS テーブル名,
       relrowsecurity AS rls有効,
       (SELECT count(*) FROM pg_policy p WHERE p.polrelid = c.oid) AS ポリシー数
  FROM pg_class c
  JOIN pg_namespace n ON n.oid = c.relnamespace
 WHERE n.nspname = 'public' AND c.relkind = 'r'
 ORDER BY relname;

合格ライン:rls有効 が全テーブルで t、かつ ポリシー数 が1以上。f が1つでもあれば、そのテーブルはAPI経由で誰でも読み書きできる状態です。逆に ポリシー数 が0のまま t にすると全アクセスが遮断され、アプリが無言で動かなくなります。この記事のSQLを流した直後の実測は、7テーブルすべてが t で、ポリシー数は1〜4でした。

検証2:一般メンバーが自分を管理者に昇格できないか

一般メンバーになりすまして2本のUPDATEを投げます。トランザクションで囲んで最後に ROLLBACK するので、データは変わりません。

BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL "request.jwt.claims" TO '{"sub":"(一般メンバーのUUID)"}';

UPDATE accounts SET name = 'テスト' WHERE id = (select auth.uid());   -- 期待: 通る
UPDATE accounts SET role = 'admin' WHERE id = (select auth.uid());    -- 期待: 弾かれる
ROLLBACK;

合格ライン:1本目が UPDATE 1、2本目が ERROR: new row violates row-level security policy for table "accounts"PostgreSQL 16.14で実行した結果は、まさにこの2行でした。2本目も UPDATE 1 になるなら WITH CHECK が効いていません。逆に1本目まで UPDATE 0 になるならSELECTポリシーが不足しています。

検証3:インデックスが実際に使われているか

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, body FROM messages
 WHERE channel_id = '(チャンネルのUUID)'
 ORDER BY created_at DESC
 LIMIT 50;

合格ライン:Index Scan using ... が出ること。Seq Scan on messages なら不合格です。PostgreSQL 16.14に20万行・50チャンネルのデータを入れ、それぞれ5回ずつ計測した結果が次の表です。

状態実行計画Execution Time読んだ共有バッファ
インデックス無しParallel Seq Scan + top-N heapsort5.8〜9.1 ms2,895 ブロック
(channel_id, created_at DESC) 追加後Index Scan0.07〜0.13 ms40 ブロック

読み込むブロック数が約72分の1になっています。ただし判定はある程度データを入れてから行ってください。行数が少ないうちは、インデックスがあってもPostgreSQLが意図的にSeq Scanを選びます。同じテーブルを100行に絞って同じクエリを流したところ、インデックスが存在するにもかかわらず Seq Scan が選ばれました。テーブル全体を読んだほうが速いと判断されているだけで、これは異常ではありません。

この3つを通せば、テーブル定義とポリシーが噛み合っているかは確認できます。ここから先(CIへの組み込みや継続的な監視)は、実運用に入ってから必要に応じて足せば十分です。


Slackと比べた自作の判断基準

条件判断
チームが5〜10人以下自作のコストパフォーマンスが良い
必要な機能が限定的自作のほうがシンプル
データを自社管理したい自作が有力
GitHub・Google Drive等との外部連携が必要Slack/Teamsのほうが現実的
50人以上の大規模チーム既製品のほうが安全

Next.js + Supabaseなら、リアルタイム通信・認証・DBが追加コストほぼゼロで揃います。一方で、この記事で見たとおり権限まわりの正しさは自分で担保する必要があります。そのコストを引き受けられるかどうかが、自作するかどうかの実質的な分かれ目です。

作ったツールを公開ページとして運用する場合、サイト側のSEOや技術的な品質は Direbase(ディレベース) で診断できます。


まとめ

チームツールのDB設計で押さえておくべきポイントを整理します。

  • 参加者テーブルが先channel_members が無いと、未読数も権限ポリシーも書けない
  • 未読管理:既読レコードを積み上げる方式が扱いやすい。参加判定・自分の投稿・削除済みの3つを必ず除外する
  • 通知:重複防止のUNIQUE制約を最初から入れる。通知設定は主キーで縛らず部分UNIQUEインデックスで表現する
  • 権限:RLSは全テーブルで有効化し、SELECT・INSERT・UPDATEのポリシーを揃える。WITH CHECK を省くと role の自己書き換えを許してしまう
  • 削除:論理削除か物理削除かを決めたうえで、外部キーに ON DELETE を明示する
  • 検証:RLSの抜け・権限昇格・実行計画の3点を、別ユーザーとして接続して確認する

テーブルを並べる作業と、行単位のアクセス制御を書く作業は別物です。前者だけ終わった状態で動くように見えてしまうのが、この領域で最も危険な点だと考えています。


よくある質問

Q. SupabaseとFirebaseはどう使い分けますか?

Supabaseはリレーショナルデータベース(PostgreSQL)ベースなので、テーブル間の結合や集計が得意です。この記事の未読数クエリのように複数テーブルを結合して数えるロジックがある場合はSupabaseが向いています。FirebaseはNoSQLで、スキーマレスに高速に書き込みたい場合やモバイルアプリとの親和性を重視する場合に選ばれます。チームツールのように構造化されたデータと行単位の権限制御を扱う用途では、SQLでポリシーを書けるSupabaseのほうが設計しやすいです。

Q. RLSを有効にし忘れたテーブルは、どうやって見つけますか?

本記事の「検証1」のクエリを使うと、public スキーマのテーブル一覧とRLSの有効・無効、ポリシー数を一覧で確認できます。rls有効f のテーブルが1つでもあれば、そのテーブルはAPI経由で誰でも読み書きできる状態です。テーブルを追加するたびに実行する習慣にしておくと、有効化漏れが本番に出ることを防げます。Supabaseのダッシュボードにも警告は表示されますが、SQLで確認したほうが確実です。

Q. 既読テーブルが肥大化してきたら、どう対処しますか?

まず「どこまで遡って未読を表示するか」という仕様を決めます。90日より前のメッセージは常に既読扱いにする、といった割り切りができれば、それより古い既読レコードは定期的に削除できます。削除はSupabaseのスケジュール実行(Cron)から日次で流すのが簡単です。それでも足りない規模になったら、チャンネルごとに「最後に読んだ時刻」だけを持つ方式への移行を検討します。移行前に、メッセージの編集・削除がその方式でも破綻しないかを確認してください。

Q. Realtimeの同時購読数に上限はありますか?

あります。Supabase公式のRealtime上限によると、Free Planの既定値は同時接続数200・毎秒メッセージ数100・毎秒チャンネル参加数100で、Pro Planではそれぞれ500・500・500です。加えてPostgres Changesには構造的な特性があり、公式は「1つの変更に対して購読者の数だけ認可チェックが走る(100人が購読していれば100回)」「順序を保つため単一スレッドで処理されるので、コンピュートを大きくしても大きくは速くならない」と説明しています。数十人規模のチームツールなら問題になりませんが、購読者が増える設計では注意が必要です。

Q. RLSを使わずにアプリ側だけで権限管理することはできますか?

推奨しません。Supabaseはクライアントから直接データベースAPIを叩ける構成なので、アプリのコードを通らないリクエストが常に成立します。RLSが無いテーブルは、そのリクエストに対して無防備です。実際にRLS未設定のテーブルでは、一般メンバーの権限で他人宛の通知を読み、他人のメッセージを削除できることを確認しています。RLSはアプリの実装漏れをカバーする最後の砦ではなく、Supabase構成における一次的な防御線として設計してください。

Q. チームが増えてきたときにSupabaseからの移行は難しいですか?

データ本体の移行は比較的容易です。SupabaseはPostgreSQLなので、標準のダンプを取って別のマネージドPostgreSQLへ移せます。RLSポリシーもスキーマの一部として移動します。移行コストが集中するのはRealtimeと認証で、これらはSupabase固有のサービスのため代替実装が必要です。移行を視野に入れるなら、Realtimeへの依存箇所をアプリ内で1〜2ファイルに閉じ込めておくと、後の切り替えが楽になります。