Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

maybeSingleで1件決め打ちすると、複数行ヒットでPGRST116の404になる

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

PostgRESTのmaybeSingle()は、絞り込み条件がテーブルの一意制約より粗いと、複数行がヒットした瞬間にPGRST116を返し、実在するデータが404として消える。

結論

テーブルの一意制約が3列を組み合わせた複合キーなのに、クエリ側はそのうち2列だけで絞り込んで.maybeSingle()に渡すと、残り1列の値が異なる行が複数存在した瞬間にPGRST116が返る。 PostgREST(Supabaseが内部で使うREST層)は.maybeSingle().single()に対して行が2件以上ヒットすると、どれを返すべきか決められずエラーにする。存在するはずのデータが、クエリの絞り込みが甘いというだけで404として消えていた。

症状

署名済みの契約に対して、同意した時点の規約本文をPDFの控えとして配信するAPIがある。控えはsignature_evidenceというテーブルに保存されており、取得側はバージョンから決まるentityと、契約IDであるentity_idの2列で1件を検索していた。

// src/app/api/evidence/[id]/terms/route.ts(修正前)
const { data: ev } = await admin
  .from("signature_evidence")
  .select("frozen_path, mime_type, signer_snapshot")
  .eq("entity", termsEvidenceEntity(version))
  .eq("entity_id", id)
  .maybeSingle();

控えは確かに保存されているはずなのに、このAPIを叩くとevが取れず、404(「この契約の同意時点の規約控えが見つかりません」)が返るケースが出た。保存処理自体は成功していたので、書き込みではなく読み出し側の問題だった。

原因

signature_evidenceテーブルの一意制約はentityentity_iddigest_sha256(凍結した文書バイトのsha256)の3列を組み合わせた複合キーで定義されている。

-- supabase/migrations/0082_signature_evidence.sql
create table if not exists public.signature_evidence (
  id              uuid primary key default gen_random_uuid(),
  entity          text not null default 'contracts',
  entity_id       uuid not null,
  document_id     uuid references public.documents(id) on delete set null,
  digest_sha256   text not null,
  hash_alg        text not null default 'sha256',
  frozen_path     text not null,
  mime_type       text,
  size_bytes      bigint,
  signer_snapshot jsonb not null default '{}'::jsonb,
  created_at      timestamptz not null default now()
);
-- 同一署名対象×同一内容は1行(冪等・再フックで重複させない)。
create unique index if not exists uq_signature_evidence_digest
  on public.signature_evidence (entity, entity_id, digest_sha256);

つまりこのテーブルの設計上、同じentityentity_idの組であってもdigest_sha256が異なれば別の行として複数存在できる。ところが配信APIのクエリはentityentity_idの2列だけで絞り込んでおり、一意制約が守っている3列目のdigest_sha256を見ていなかった。同じ契約・同じ版で控えが複数行になった時点で、.maybeSingle()に対して2件以上がヒットし、PostgRESTは1件に絞れないためPGRST116(複数行または0行がヒットしたことを示すエラーコード)を投げる。supabase-jsはこのerrorを返すだけで例外は投げないため、呼び出し側でdataだけを見て「なければ404」と組んでいると、複数行ヒットのPGRST116も0件と同じ404に丸められて表面化する。

一意制約の粒度(3列)とクエリの絞り込みの粒度(2列)がずれていたことが、複数行が「あり得る」設計と「1件のはず」という読み出しコードの前提を食い違わせていた。

直し方

クエリの絞り込み列を一意制約に合わせて増やす方法もあるが、配信APIが欲しいのは「その版の最新の控え1件」なので、並べ替えと件数制限を足して常に1件に絞り込む形にした。

// src/app/api/evidence/[id]/terms/route.ts(修正後)
const { data: ev } = await admin
  .from("signature_evidence")
  .select("frozen_path, mime_type, signer_snapshot")
  .eq("entity", termsEvidenceEntity(version))
  .eq("entity_id", id)
  // 一意制約は (entity, entity_id, digest_sha256) なので同一版で複数行は許容される。
  // maybeSingle 単独だと複数ヒットで PGRST116 になり、存在する控えが恒久 404 になるため
  // 最新1件に絞る(captureSignatureEvidence 側と同じ流儀)。
  .order("created_at", { ascending: false })
  .limit(1)
  .maybeSingle();

.order("created_at", { ascending: false }).limit(1).maybeSingle()の前に挟むことで、PostgRESTに渡る時点で必ず1件に絞られる。複数行がヒットする状況自体はテーブル設計上あり得るままだが、クエリが常に「最新の1件」を返すようになったため、行数が2件以上になってもAPIは404にならない。

再発防止

一意制約が2列より広い(3列以上の)複合キーになっているテーブルに対して.maybeSingle().single()を使うときは、クエリの.eq()が一意制約の全列をカバーしているかを確認する必要がある。カバーしていない列がある場合、その列の値違いで複数行が返る余地が常に残るため、.order().limit(1)で明示的に1件へ絞るか、.eq()をもう1列増やして一意制約と一致させるかのどちらかが要る。テーブルを新しく作る際は、一意制約の列数と、そのテーブルを読み出すクエリの絞り込み列数が一致しているかを合わせて確認する習慣にした。

よくある質問

Q1PGRST116エラーはどんな時に起きますか?

supabase-jsの.maybeSingle()や.single()に対して、絞り込み条件に一致する行が2件以上返ってきた時に発生する。0件ならmaybeSingle()はnullを返すだけだが、複数件だとどれを返せばよいか決められずPGRST116としてエラーになる。

Q2一意制約があるのに、なぜ複数行がヒットしたのですか?

テーブルの一意制約はentity・entity_id・digest_sha256の3列を組み合わせた複合キーだった。クエリ側はentityとentity_idの2列だけで絞り込んでおり、同じentity・entity_idの組でdigest_sha256が異なる行が複数存在しうる設計だったため、クエリの絞り込みが一意制約より粗かった。

Q3直し方はどう変えましたか?

.maybeSingle()の直前に.order(created_at, {ascending: false}).limit(1)を足し、複数行がヒットしても常に最新の1件だけを取るようにした。一意制約やテーブル設計そのものは変えていない。

Q4実害はどの程度でしたか?

既に保存されているはずの控え(PDF)がAPI経由で取得できず404を返す不具合で、データそのものが失われたわけではない。クエリを1本直すだけで解消し、レビューで発見・修正された。

確認した環境

  • @supabase/supabase-js ^2.106.2 / Next.js 16.2.7
  • 2026-07-24 のレビュー指摘で発覚・修正

この記事の根拠

  • TypeScriptファイル 50〜67行目コミット 54979c2
  • SQLファイル 37〜52行目コミット fad0b5c

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。