Rebounder Tech Blog

Written by the people who actually run these systems in production.

maybeSingle() Throws PGRST116 on a Too-Loose Filter

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

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

結論

When a query's filter is coarser than the table's unique constraint, maybeSingle() throws PGRST116 the instant two rows match, and existing data vanishes as a 404.

Conclusion

If a table’s unique constraint is a composite key over 3 columns, and your query filters on only 2 of them before calling .maybeSingle(), PGRST116 fires the moment a row exists where the third column differs. PostgREST (the REST layer Supabase runs internally) errors out on .maybeSingle() or .single() as soon as 2 or more rows match — it has no way to decide which one to return. Data that genuinely existed was disappearing as a 404, purely because the filter was looser than the constraint it was meant to match.

Symptom

There’s an API that serves a PDF copy of the terms-of-service text a user agreed to at signature time. The copies live in a signature_evidence table, and the read side looked up a single row by entity (derived from the version) and entity_id (the contract ID) — 2 columns.

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

The record was confirmed to exist, yet hitting this endpoint sometimes returned ev as empty and a 404 (“no terms evidence found for this contract at signing time”). The write path was fine — this was a read-side problem.

Root cause

signature_evidence’s unique constraint is a composite key across 3 columns: entity, entity_id, and digest_sha256 (the sha256 of the frozen document bytes).

-- 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()
);
-- one row per (signed subject × content) — idempotent, re-hooks don't duplicate.
create unique index if not exists uq_signature_evidence_digest
  on public.signature_evidence (entity, entity_id, digest_sha256);

By design, the same entity/entity_id pair can legitimately have multiple rows as long as digest_sha256 differs. But the serving API’s query filtered on only entity and entity_id — it never looked at the third column the unique constraint actually depends on. As soon as a given contract and version accumulated more than one evidence row, .maybeSingle() hit 2+ matches, and PostgREST couldn’t collapse them to one, so it threw PGRST116 (multiple or zero rows matched). supabase-js surfaces this as an error value rather than throwing an exception, so code that only inspects data and treats its absence as “not found” folds a multi-row PGRST116 into the same 404 path as a genuine zero-row miss.

The mismatch between the constraint’s granularity (3 columns) and the query’s filter granularity (2 columns) let “multiple rows can exist” (true by design) collide with “there’s exactly one” (assumed by the read code).

The fix

Widening the filter to match all 3 constraint columns was one option, but what the serving API actually wants is “the latest evidence row for this version,” so the fix adds ordering and a row limit instead, guaranteeing a single result regardless of how many rows match.

// src/app/api/evidence/[id]/terms/route.ts (after)
const { data: ev } = await admin
  .from("signature_evidence")
  .select("frozen_path, mime_type, signer_snapshot")
  .eq("entity", termsEvidenceEntity(version))
  .eq("entity_id", id)
  // Unique constraint is (entity, entity_id, digest_sha256), so multiple rows
  // per version are allowed. maybeSingle() alone throws PGRST116 on a multi-row
  // match, turning real evidence into a permanent 404 — so pin to the latest row
  // (same convention as captureSignatureEvidence).
  .order("created_at", { ascending: false })
  .limit(1)
  .maybeSingle();

Inserting .order("created_at", { ascending: false }).limit(1) before .maybeSingle() guarantees exactly one row reaches PostgREST. Multiple rows can still exist by design, but the query now always resolves to “the newest one,” so 2+ matching rows no longer produce a 404.

Prevention

Whenever .maybeSingle() or .single() is used against a table whose unique constraint spans more than 2 columns, check whether the query’s .eq() chain covers every column in that constraint. If it doesn’t, rows differing only in the uncovered column can always slip through as a multi-row match — so either pin the result explicitly with .order() + .limit(1), or add the missing .eq() to match the constraint exactly. When adding a new table, it’s now standard practice to check that the number of columns in the unique constraint matches the number of columns any read query filters on.

よくある質問

Q1When does PGRST116 happen?

supabase-js's .maybeSingle() or .single() returns PGRST116 when 2 or more rows match the filter. With 0 rows, maybeSingle() just returns null — but with multiple rows it can't pick one, so PostgREST errors out instead.

Q2The table has a unique constraint — how did duplicates match?

The unique constraint was a composite key over 3 columns: entity, entity_id, and digest_sha256. The query filtered on only 2 of them (entity, entity_id), so rows differing only in digest_sha256 could both match — the filter was coarser than the constraint it was built against.

Q3What was the fix?

Add .order("created_at", { ascending: false }).limit(1) right before .maybeSingle(), so multiple matches always collapse to the newest row. The unique constraint and table design were left untouched.

Q4What was the real-world impact?

An already-stored PDF receipt became unreachable through the API and returned 404 — no data was lost. A single query fix resolved it; the bug was caught and fixed during code review.

確認した環境

  • @supabase/supabase-js ^2.106.2 / Next.js 16.2.7
  • Found and fixed in a 2026-07-24 code review

この記事の根拠

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

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