ログオフ
開発2026-08-10

Supabase / PostgREST が「エラーを出さずに」壊れる4箇所

全件取得が1000行で切れる。一覧だけ0件になる。件数集約が落ちる。service_role でも permission denied になる。どれも例外が飛ばないか、飛んでも受け取り側が見ていないので、動いているように見えたまま腐る。

Supabase で時間を溶かしたのは、落ちるバグではなかった。レスポンスは 200 で返り、配列も返ってきて、ログにも「完了」と出る。中身だけが違う。権限とデフォルト値まわりで4回踏んだので、症状ごとに書く。

なお PostgREST は、Supabase がテーブルから自動で REST API を生やしている部分のこと。以下の挙動はだいたいそこの都合で起きる。

1. 全件取得は、黙って1000行で切れる

事象

「全レコードを取得して、未処理のものだけ埋める」という後追いの穴埋めスクリプト(途中で止まっても次回続きから走る型)を毎日回していた。ログはこう出る。

完了:更新 107 件

それなのに、未処理の件数が何日たっても1件も減らない。エラーは出ていない。

原因

PostgREST には返却行数の上限がある。Supabase の既定値は 1000行で、これを超えたぶんはエラーにも警告にもならず、単に配列が短くなる。

スクリプト側は「全件を見たうえで、対象はこれだけだった」と判断する。だから1000行より後ろのレコードは、何度実行しても永久に処理されない。しかも処理そのものは成功しているので、ログには毎回それらしい件数が出る。ログを見ている限り一生気づけない。

解決策

見つけ方は、処理した件数ではなく残り件数を毎回測ること。テーブルの総件数と、実際に返ってきた配列の長さを突き合わせれば一発でわかる。

const { data, count } = await supabase
  .from('items')
  .select('id, status', { count: 'exact' })

console.log(`総件数 ${count} / 取得 ${data.length}`)
// 総件数 1457 / 取得 1000  ← ここで確定した

直すのは .range() でページングするか、プロジェクト設定の上限そのものを上げるかのどちらか。数行で済む。

const all = []
for (let from = 0; ; from += 1000) {
  const { data, error } = await supabase
    .from('items')
    .select('id, status')
    .order('id')                       // ← これが無いとページ間で重複・欠落する
    .range(from, from + 999)
  if (error) throw error
  all.push(...data)
  if (data.length < 1000) break
}

.order() は省略できない。並び順を指定しないクエリは物理的な行の位置で並ぶので、ページをまたぐ間に行が更新されると順序が変わる。同じ行を2回拾ったり、1回も拾わなかったりする。無音で壊れる話を直すつもりで、別の無音の壊れ方を作ることになる。

より本筋なのは、フィルタを取得側に寄せること。「全件取ってからクライアントで絞る」設計は、暗黙のページングがある API に対しては上限に当たった瞬間に無音で腐る。.eq('status', 'pending') を投げれば、そもそも1000行に届かない。

2. select("*") は、列単位 GRANT と相性が悪い

事象

一覧ページと検索ページだけが 0 件になった。個別ページは動く。データは入っている。

原因

投稿者の IP アドレスのような列を匿名ロールから隠したくて、テーブル全体の SELECT を revoke して、安全な列だけ列単位で grant する構成にしていた。

revoke select on posts from anon;
grant select (id, title, created_at) on posts to anon;

この状態で .select('*') を投げると、PostgREST は未 grant の列まで要求する。返るのはこれだ。

permission denied for table posts   -- 42501

行フィルタ(RLS)は「行」を絞るが「列」は絞らない。列を隠すには列単位 GRANT が要る。そこまでは正しい。詰まるのは * のほうだ。この制約と正面衝突する。

そして supabase-js はエラーを例外で投げない。返すのは { data, error } のペアだけ。

// ❌ error を見ていないので、権限エラーが 0 件として素通りする
const { data } = await supabase.from('posts').select('*')
return data ?? []

明示的に列を書いているページは通るので、症状は「一覧と検索だけ空」になる。データ側の問題に見えない。

解決策

匿名から叩かれるクエリは明示列で書く。

// ✅
const { data, error } = await supabase
  .from('posts')
  .select('id, title, created_at')
if (error) throw error

error を握り潰さないことのほうが重要かもしれない。どちらか片方でもやっていれば気づけた。

grant 済みの列は information_schema.column_privileges で確認できる。切り分けは REST を直に叩くのが速い。select=id は通って select=* が 42501 なら、これで確定する。

この構成でいちばん危ないのは、後からの列追加だ。テーブル定義を変更するスクリプト(マイグレーション)で列を足しても、anon の grant は追随しない。追加した瞬間から * を使っているクエリが全部落ちる。落ちたことに気づくのは数週間後になる。

3. 列単位 GRANT にすると、件数の埋め込み集約も落ちる

事象

同じ構成で、一覧に出している「コメント◯件」の数字が消えた。500 が返る。

原因

PostgREST には、親のクエリのついでに関連テーブルの件数を数えてくれる書き方がある。

GET /posts?select=id,title,comments(count)

この comments(count) が、列単位の権限ではなくテーブル権限を要求する。行を数えるだけなのに、列を隠した側のテーブルでは通らない。

select('*') が壊れるのと同じ根っこだが、こちらは * を書いていなくても踏む。列単位 GRANT に切り替えた瞬間、意図していない場所まで巻き込まれる。

解決策

行だけ数えて列は隠したいなら、許可した列で別クエリを投げてクライアント側で集計する。外部キー列は普通 grant 済みなので、それを使う。

const { data } = await supabase
  .from('comments')
  .select('post_id')          // grant 済みの列だけ
  .in('post_id', postIds)

const counts = data.reduce((acc, r) => {
  acc[r.post_id] = (acc[r.post_id] ?? 0) + 1
  return acc
}, {})

切り分けの目印は単純で、列単位 GRANT に変えた直後に一覧やカウントが 0 か 500 になったら列権限を疑う。

4. 「Automatically expose new tables」を OFF にすると、service_role でも弾かれる

事象

新規プロジェクトを立ち上げ、マイグレーションでテーブルを作り、サーバー側から service_role キーで書き込んだ。落ちる。

permission denied for table items   -- 42501

service_role は行フィルタを迂回する側のロールなので、権限で落ちるはずがないと思っていた。

原因

プロジェクト作成時のセキュリティ設定に「Automatically expose new tables」がある。ここを安全側に倒して OFF にすると、新しく作ったテーブルへの API ロール自動 GRANT が一切走らない。

service_role が迂回するのは行フィルタ(RLS)だけで、テーブル GRANT は迂回しない。Postgres の BYPASSRLS は行フィルタを飛ばす属性であって、権限そのものを飛ばす属性ではない。自動 GRANT が止まっている以上、service_role にも SELECT や INSERT の権限は無い。

解決策

マイグレーションで明示的に付ける。付ける相手は service_role だけにして、anon と authenticated には渡さない。OFF にした意図がそれなので。

grant usage on schema public to service_role;
grant all on all tables in schema public to service_role;
grant all on all sequences in schema public to service_role;

alter default privileges in schema public
  grant all on tables to service_role;

最後の alter default privileges を忘れると、次に作るテーブルでまた同じところで詰まる。今あるテーブルに付けて終わりにしない。

まとめ

4つとも形が同じだった。PostgREST は権限とデフォルト値の都合を、例外ではなく「それらしいレスポンス」で返してくる。空の配列、途中で切れた配列、{ error } の中身。呼び出し側が見ていなければ、そのまま通過していく。

普段やるようにしたのは4つ。

  • 全件取得を書いたら、総件数と取得件数を突き合わせる
  • 匿名から叩かれるクエリに * を書かない
  • error を握り潰さない
  • 列単位 GRANT に切り替えたら、*count の両方を洗う

いちばん効いたのは最初のやつだった。総件数を1行ログに足すだけで、あの手のバックフィルは全部見張れる。

関連記事

開発 一覧へ