はじめに
音楽好きとミュージシャンのための交流アプリ OTOMO を個人で作っています(React + Vite + Supabase + Vercel)。
前回の記事「個人開発のSNSを「チームで運営できる状態」にする最初の3手」で、Edge Function delete-account による退会機能を紹介しました。その記事にいただいたコメントから実装の穴が見つかったので、直した経緯をまとめます。
いただいたコメント
2026-10-30 に、次のような趣旨のコメントをいただきました。
- 画像の削除が失敗した場合は、アカウント削除を止めて再試行できるか
- 「DB だけ消えて画像が残る」ケースも確認しているか
確認したところ、止まらずに進んでしまう作りになっていました。
元の実装と問題点
元の delete-account は、次の順番で処理していました。
- 自分の投稿に付いた「他の人の」コメント・通知を RPC で削除
-
avatarsバケットのアイコン画像をlist→remove -
auth.admin.deleteUserでアカウントを削除(profiles以下のデータはon delete cascadeで連鎖して消える)
// 修正前の 2.
const { data: files } = await admin.storage.from('avatars').list(user.id)
if (files?.length) await admin.storage.from('avatars').remove(files.map((f) => `${user.id}/${f.name}`))
問題は3つありました。
-
listとremoveの戻り値のerrorを見ていないので、画像の取得や削除に失敗しても 3. に進み、アカウントだけ消えて画像が Storage に残る - Storage のファイルはデータベースの連鎖削除の対象外。アカウントが消えたあとは、アプリ側から「この画像はだれのもので、消し残しか」をたどれず、利用者が退会をやり直すこともできない
-
listは既定で最大100件までしか返さない(アイコンは通常1枚なので実害は小さいが、取りこぼしの余地がある)
直し方
方針は「取り消せる処理を先に、取り消せない処理(アカウント削除)を最後に」です。画像の削除をいちばん最初に移し、失敗したらその場で 500 を返して止めます。この時点ではアカウントもデータも残っているので、利用者はもう一度退会を試せます。
// 1. アイコン画像(Storage はデータベースの連鎖削除の対象外)。
// 最初に消し、失敗したらここで止める(アカウントとデータは残るので、もう一度退会を試せる)。
const avatars = admin.storage.from('avatars')
for (let round = 0; ; round++) {
const { data: files, error: listError } = await avatars.list(user.id, { limit: 100 })
if (listError) return json({ error: 'avatar list failed' }, 500)
if (!files?.length) break
// 消しても一覧から減らないときに回り続けないよう、回数で打ち切る
if (round >= 10) return json({ error: 'avatar delete failed' }, 500)
const { error: removeError } = await avatars.remove(files.map((f) => `${user.id}/${f.name}`))
if (removeError) return json({ error: 'avatar delete failed' }, 500)
}
// 2. 自分の投稿に付いた他の人のコメント・通知
const prep = await admin.rpc('prepare_account_deletion', { target_user: user.id })
if (prep.error) return json({ error: 'prepare failed' }, 500)
// 3. アカウント本体(profiles → 投稿・コメントなどが連鎖して消える)
const del = await admin.auth.admin.deleteUser(user.id)
if (del.error) return json({ error: 'delete failed' }, 500)
- 100件ずつ「一覧 → 削除」を繰り返し、一覧が空になったら次へ進む
- 削除しても一覧から減らない場合に無限ループにならないよう、10回で打ち切ってエラーにする
画面側はもともと、失敗すると「退会の処理に失敗しました。時間をおいてもう一度お試しください」と表示してボタンを押し直せる作りだったので、変更していません。
コミット:https://github.com/masatomoto-cmyk/otomo/commit/bf3f8c6
反映と確認
- 同日に Supabase へ反映(
delete-accountの version 3) - ログインせずに呼び出すと 401 が返り、関数が正常に起動していることを確認
- 画像の削除をわざと失敗させる試験はまだしていません。エラー時に止まることはコードで確認した段階です
残っている課題
- 2.(他の人のコメント・通知の削除)のあと、3.(アカウント削除)で失敗すると、コメントだけ消えた状態になります。もう一度退会すれば最後まで消えますが、全体を1つのトランザクションにはできていません
- Storage とデータベースをまたぐ処理なので、完全に「全部消えるか、何も消えないか」にするのは難しく、「失敗したら止まる・やり直せば最後まで進む」作りにとどめています
学び
- データベースの外(Storage など)にあるデータは、連鎖削除に頼れない。消す順番と失敗の検出を自分で設計する
- 取り消せない処理は最後に置き、その前の段階で失敗を見つけて止める
- Supabase の SDK は失敗しても例外を投げず、戻り値の
errorで知らせる。dataだけ受け取っていると失敗を見落とす
コメントをくださった方、ありがとうございました。