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行ログに足すだけで、あの手のバックフィルは全部見張れる。