0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

退会処理で「DBだけ消えて画像が残る」を防ぐ:コメントで指摘を受けて直した経緯(Supabase Edge Function)

0
Posted at

はじめに

音楽好きとミュージシャンのための交流アプリ OTOMO を個人で作っています(React + Vite + Supabase + Vercel)。

前回の記事「個人開発のSNSを「チームで運営できる状態」にする最初の3手」で、Edge Function delete-account による退会機能を紹介しました。その記事にいただいたコメントから実装の穴が見つかったので、直した経緯をまとめます。

いただいたコメント

2026-10-30 に、次のような趣旨のコメントをいただきました。

  • 画像の削除が失敗した場合は、アカウント削除を止めて再試行できるか
  • 「DB だけ消えて画像が残る」ケースも確認しているか

確認したところ、止まらずに進んでしまう作りになっていました。

元の実装と問題点

元の delete-account は、次の順番で処理していました。

  1. 自分の投稿に付いた「他の人の」コメント・通知を RPC で削除
  2. avatars バケットのアイコン画像を list → remove
  3. 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 だけ受け取っていると失敗を見落とす

コメントをくださった方、ありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?