CASCADE Terlalu Setia: Saat Hapus Akun Membawa Konten Pergi
Guard 409 di aplikasi tidak pernah menyala karena CASCADE menghapus konten lebih dulu. Migration 000067 membaliknya: RESTRICT menolak di database, guard baru bicara.
Ringkasan
Hapus akun warga ternyata ikut menghabisin semua ulasan, izin, dan pengaduan tanpa peringatan, gara-gara guard aplikasi kalah cepat sama FK CASCADE di database. Solusinya ganti CASCADE jadi RESTRICT lewat migration, biar database yang nolak duluan. Sekarang hapus akun berkonten otomatis ditolak, dan guard ErrHasContent akhirnya bisa jalan dengan respons 409.
Superadmin menekan tombol hapus pada satu akun warga. Layar sukses, akun hilang. Beberapa detik kemudian barulah saya sadar: seluruh ulasan, pengajuan izin, dan pengaduan yang pernah dibuat akun itu lenyap tanpa satu pun peringatan. Guard di aplikasi yang seharusnya menolak hapus akun berkonten, kode ErrHasContent yang membalas 409, tidak pernah menyala. Log aplikasi pun bersih, padahal ada yang seharusnya dicegah.
Tebakan pertama saya salah total. Kira validasi di service layer rusak, atau ada jalur eksekusi yang mem-bypass pengecekan. Saya telusuri alur hapus dari controller sampai repository, dan semuanya benar: guard-nya ada, kondisinya ada, pemanggilannya ada. Masalahnya bukan di sana. Masalahnya di lapisan yang lebih dulu bicara: kontrak foreign key di database masih ON DELETE CASCADE. InnoDB menyelesaikan penghapusan baris anak sebelum logika aplikasi sempat melihat bahwa konten itu ada. Guard yang tidak pernah kebagian data bukan guard yang rusak; ia guard yang kehilangan bahan bakunya.
Database sudah menghapus bukti sebelum sidang dimulai.
Pindahkan Garis Pertahanan ke Kontrak FK
Solusinya bukan menambah validasi baru di aplikasi, melainkan membalik urutan kekuasaan: database harus menolak dulu, baru aplikasi menerjemahkan penolakan itu. Migration 000067 mengubah FK tiga tabel konten, yaitu reviews, registration_submissions, dan reports, dari CASCADE ke ON DELETE RESTRICT, dengan nama constraint dipertahankan identik (fk_reviews_user, fk_regsub_user, fk_report_user) supaya down migration persis kebalikannya [1]. Sejak itu, perintah hapus akun yang masih punya konten ditolak InnoDB dengan error 1451, Cannot delete or update a parent row: a foreign key constraint fails [3], dan di titik itulah ErrHasContent akhirnya menyala: penolakan diterjemahkan menjadi respons 409 yang jujur.
Yang bikin saya nyeri gigi setelah baca ulang dokumentasi: RESTRICT itu ternyata default MySQL. Pilihan amannya sudah disediakan server, dan saya pernah menolaknya. Dokumen resmi menulis bahwa untuk ON DELETE/ON UPDATE yang tidak dispesifikkan, "the default action is always RESTRICT" [1], dan NO ACTION pun diperlakukan sebagai RESTRICT karena MySQL tidak punya deferred constraint checking [2]. Artinya siapa pun yang menulis CASCADE sedang secara sadar keluar dari default yang aman. Di kasus ini, pilihan itu memang dibuat jauh di masa lalu: dua migration lama, 000032 dan 000054, menanam CASCADE di ketiga tabel ketika prototipe masih muda dan belum ada yang menanyakan soal riwayat. Guard aplikasinya malah lahir belakangan, jadi selama berbulan-bulan ada dua aturan yang tidak pernah bertemu: satu melarang hapus, satu mengabulkan hapus.
Kebijakan FK akhirnya saya pertegas per jenis data, bukan seragam. Konten warga memakai RESTRICT karena hilangnya berarti kehilangan riwayat. otp_codes tetap CASCADE karena isinya sementara, mati bersama akun memang wajar. Atribusi aktor memakai SET NULL mengikuti preseden 000063: jejak kejadian bertahan, identitas pelakunya dilepas, baris catatan tidak ikut rusak. Satu tabel, satu keputusan, satu alasan yang bisa dijelaskan ke siapa pun yang menanyakan kenapa datanya hilang. Kebijakan ini sekarang tertulis di aturan database proyek, bukan hanya di kepala orang-orangnya.
Buktikan Dua Arah, Bukan Satu Arah
Mengubah action di up migration itu setengah pekerjaan; setengahnya lagi adalah membuktikan jalan kembali. Karena nama constraint dipertahankan identik, down migration hanya membalik pasangan DROP FOREIGN KEY dan ADD CONSTRAINT ke CASCADE semula, dan kebenarannya saya periksa dua arah lewat information_schema: setelah up, ketiga constraint harus terbaca RESTRICT; setelah down, ketiganya kembali CASCADE. Lima integration test menutup sisanya, satu skenario per tabel konten: seed satu user berkonten, panggil delete, pastikan guard membalas ErrHasContent, lalu pastikan akun masih ada dan baris kontennya utuh.
Pola seperti ini murah ditulis tapi mahal akibatnya jika dilewat: guard reaktif di aplikasi tanpa penolakan di level database adalah cek yang hanya bekerja pada data yang belum disentuh database. Begitu CASCADE aktif, urutan eksekusi selalu memenangkan database, bukan validasi. Konten yang bernilai riwayat tidak boleh mati diam-diam bersama akunnya, dan kalau guard aplikasi adalah satu-satunya garis pertahanan, ia hanya sekuat data yang belum sempat dihapus.