supabase-js の条件付き UPDATE は error を見ないと処理済み判定を誤る
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
supabase-js の条件付き UPDATE は、DB 側の書込エラーでも例外を投げず data:null と error を返すため、error を確認せず data の有無だけで判定すると、一時的な書込失敗を「対象0件=既に処理済み」と区別できずに取りこぼす。
結論
supabase-js の条件付き UPDATE は、書込エラーでも例外を投げません。 data:null と error を返すだけです。冪等化のために「対象0件=既に処理済み」という判定を data の有無だけで行っていると、一時的な書込失敗まで「処理済み」に含めてしまいます。
Stripe の webhook 処理では、これが「本物のカード入金を検知したのに、領収書が永久に発行されない」という形で表面化しました。直し方は、error を見て投げる、それだけです。
症状
- カード決済の webhook は届いている(Stripe 側のログに送信記録がある)
- 契約の入金ステータスが更新されていない
- 領収書メールが送られていない
- Stripe 管理画面を見ても、配信は成功扱いになっている(再配信されない)
エラーは記録されていません。処理は成功したことになっているのに、結果だけが起きていないという食い違いでした。
原因
入金確定の処理は、条件付き UPDATE で冪等に実装されています。
const { data: claimed, error } = await admin
.from("contracts")
.update(patch)
.eq("id", contractId)
.in("payment_status", ["未請求", "請求済"])
.select("id")
.maybeSingle();
if (!claimed) return { claimed: false }; // ← error を見ずに 0件=既処理と判定していた
payment_status が対象の状態でなければ、UPDATE は0件を更新して終わります。これは同じイベントが2度届いたときの正しい挙動で、2回目は claimed が null になり、何もせず抜けます。意図した冪等 no-op です。
問題は、error が入っているときも data は null になることです。supabase-js は DB 側のエラーで例外を投げず、{data:null, error} という同じ形の値を返します。呼び出し側が data の有無だけで「既に処理済みかどうか」を判定していると、
対象0件(意図した冪等 no-op) → data:null, error:null
一時的な書込失敗(pooler timeout 等) → data:null, error:{...}
この2つが、data だけを見ている限り同じ形に見えます。 後者を「既に処理済み」と誤認すると、本物のカード入金を握りつぶしたまま処理が正常終了します。handler が例外を投げないため、呼び出し元の webhook ルートも成功として扱い、200 を返します。Stripe は成功した配信を再送しないため、この入金は記録上どこにも残らないまま確定します。
直す
error を明示的に見て、あれば投げます。
if (error) {
throw new Error(`markContractPaid update failed: ${error.message}`);
}
if (!claimed) return { claimed: false }; // 既に入金済=冪等 no-op
error のチェックを data の判定より先に置くのが要点です。これで「書込失敗」と「意図した冪等 no-op」が明確に分かれます。
投げた例外は、呼び出し元の webhook ルートで拾われます。
try {
const { contractId } = await handleStripeEvent(admin, stripe, event);
await admin.from("stripe_events")
.update({ processed_at: new Date().toISOString(), contract_id: contractId })
.eq("id", event.id);
return Response.json({ received: true });
} catch (e) {
// 台帳行を削除し 500 → Stripe の再配信で再処理させる(at-least-once)。
await admin.from("stripe_events").delete().eq("id", event.id);
return new Response(`error: ${e instanceof Error ? e.message : "error"}`, { status: 500 });
}
冪等台帳(stripe_events)への INSERT は event.id の一意制約で守られているため、Stripe が再配信しても二重処理にはなりません。throw して 500 を返すことは「失敗した」という信号であると同時に、Stripe の再配信という回復手段を起動するスイッチでもあります。 ここを黙って 200 にしてしまうと、その回復手段自体が使われません。
一般化できる形
この形は入金確定に限りません。「対象0件」に複数の意味がありうる条件付き操作すべてに当てはまります。
- 意図した0件(重複・既処理・対象外)
- 意図しない0件(一時的な書込失敗・接続断・タイムアウト)
supabase-js に限らず、SDK が例外を投げずに {data, error} のようなタプルでエラーを返す設計では、data の中身だけで分岐する前に、必ず error を見る必要があります。この実装では冪等台帳への INSERT 側でも同じ形の分岐をしています。
if (insErr) {
if (String(insErr.code ?? "").includes("23505")) {
return Response.json({ received: true, duplicate: true }); // 意図した重複
}
return new Response("ledger insert failed", { status: 500 }); // 意図しない失敗
}
エラーコードで「意図した重複」と「意図しない失敗」を明示的に分けているのがポイントです。片方を握りつぶすためのコードが、もう片方まで一緒に握りつぶしてしまわないよう、分岐の条件を具体的に書いています。
よくある質問
Q1なぜ「0件」と「書込失敗」を区別する必要があるのですか?
条件付き UPDATE で対象が0件になる理由は2つあります。1つは意図どおりの冪等 no-op(既に処理済みで対象行がもう条件に合わない)。もう1つは pooler のタイムアウトや直列化衝突・接続断による一時的な書込失敗です。supabase-js はどちらの場合も data は null か空になり得ますが、後者では error にエラー情報が入ります。error を見ずに data の有無だけで判定すると、この2つが同じ「0件」として扱われます。
Q2誤認すると具体的に何が起きますか?
本物のカード入金を「既に入金済」と誤認し、領収書発行と紹介手数料の確定を永久にスキップします。さらに handler が例外を投げないため、webhook のルートは処理成功として 200 を返し、Stripe はイベントを再配信しません。入金自体はカード会社側では成立しているのに、記録側の反映だけが失われた状態が固定されます。
Q3throw するとどう回復するのですか?
呼び出し元の webhook ルートは、処理中に例外が出ると冪等台帳(stripe_events)の該当行を削除してから 500 を返します。Stripe は 500 応答を失敗とみなし、同じイベントを再配信します。台帳への INSERT は event.id の一意制約で守られているため、再配信されても二重処理にはなりません。error を握りつぶさずに throw することで、この再配信による自動回復の経路に載ります。
Q4領収書発行や紹介手数料の失敗は、なぜ握りつぶしてよいのですか?
入金確定(UPDATE)とその後続の副作用(領収書発行・紹介手数料確定)とで、失敗時に必要な挙動が違うためです。UPDATE の失敗は入金の記録自体が欠けるので必ず再試行させますが、後続処理まで例外にすると、入金確定は成功しているのに webhook 全体が 500 になり無駄な再配信を招きます。後続は個別に catch し、入金確定だけを壊さず優先しています。