Slug Berisi Byte Kutip Miring: Perbaikan Lewat HEX, Bukan Regex
Tujuh slug publik berisi byte kutip miring hasil impor. Perbaikannya: migrasi SQL dengan guard HEX byte-exact, down migration UNHEX, tanpa regex.
Ringkasan
Jadi ada tujuh slug venue yang kebawa tanda kutip miring dari jalur impor lama, awalnya dikira kolomnya salah charset. Ternyata enggak, utf8mb3 sama utf8mb4 nyimpen karakter itu identik, berarti datanya emang rusak sebelum masuk DB. Fix-nya pakai migrasi golang-migrate dengan guard HEX biar byte-exact, rollback-nya juga balikin byte persis kayak semula.
Malam ini saya sedang mengeksekusi SELECT HEX(name) di database dev KotaPortal, niatnya cuma pastikan tidak ada karakter aneh yang tersisa dari impor data lama. Hasilnya justru menunjukkan sebaliknya: tujuh slug venue publik menyimpan byte E2 80 99 di tengah URL mereka. Byte itu bukan karakter acak; dia adalah U+2019, tanda kutip tunggal miring kanan, yang seharusnya tidak pernah sampai ke URL. Dua baris lain memakai E2 80 93, en dash.
Tebakan pertama saya: kolomnya yang salah charset. Dugaan itu wajar, kolom lama identik dengan utf8 tiga-byte yang sering dituduh memotong karakter. Kalau storage-nya memotong, wajar data jadi kacau.
Dugaan itu runtuh setelah membaca manual MySQL [2]: untuk karakter BMP seperti U+2019 dan U+2013, utf8mb3 dan utf8mb4 punya penyimpanan identik, nilai kode sama, encoding sama, panjang sama. Storage tidak pernah memotong apa pun. Byte kutip miring itu ditulis utuh ke database, dan artinya jalur imporlah yang menulis data yang sudah rusak sejak awal. Kolomnya tidak bersalah; yang salah adalah data yang masuk. Dan karena UTF-8 menjamin rentang ASCII penuh tetap apa adanya [1], slug ASCII jadi target perbaikan yang paling aman.
Regex Ditolak, Mapping Manual Dipilih
Di titik ini menggoda sekali menulis satu fungsi REPLACE berbasis regex: deteksi semua karakter non-ASCII di slug, ganti otomatis. Commit ini menolak jalur itu secara eksplisit. Baris yang terdampak cuma tujuh, dan untuk jumlah segitu, mapping transliterasi manual per baris jauh lebih bisa dipertanggungjawabkan: diff-nya bisa dibaca orang, efeknya terbatas pada baris yang disebut, dan tidak ada skrip yang "kira-kira cocok" menyentuh data lain.
Kunci mekanismenya ada di klausa WHERE. Slug lama tidak dicocokkan sebagai string biasa, tapi byte-per-byte lewat HEX(slug). Alasannya sederhana: koneksi database dengan setelan charset beda, atau file SQL yang lewat editor beda, bisa mengubah bentuk karakter kutip miring di perjalanan. Representasi hex kebal dari semua itu. Baris yang id-nya cocok tapi HEX-nya tidak, artinya sudah diperbaiki proses lain, langsung di-skip, dan justru itu yang membuat migrasi ini idempoten: dijalankan dua kali pun hasil akhirnya sama. Precek tabrakan slug baru juga dijalankan dulu di database dev, hasilnya nol baris bentrok.
Jalan Mundur yang Byte-Identik
Migrasinya jalan di atas konvensi golang-migrate [3]: file up dan down bernomor versi, dieksekusi berurutan. Yang bikin migrasi ini pantas dicontek adalah simetrinya sampai level byte. Naik: set slug baru dengan guard HEX di WHERE. Turun: kembalikan byte lama persis lewat CONVERT(UNHEX(...) USING utf8mb4), dicocokkan lagi terhadap slug baru. Tidak ada "kira-kira balik seperti semula"; rollback mengembalikan byte yang sama persis dengan kondisi pra-migrasi.
-- up: repair one slug, byte-exact guard
UPDATE entities SET slug = 'k-prima-futsal'
WHERE id = 336 AND HEX(slug) = '6BE280997072696D612D66757473616C';
-- down: restore the original bytes, byte-identical
UPDATE entities
SET slug = CONVERT(UNHEX('6BE280997072696D612D66757473616C') USING utf8mb4)
WHERE id = 336 AND slug = 'k-prima-futsal';
Dampak ke halaman publik juga langsung kelihatan: changelog modul mencatat URL lama yang berkutip merespons 404, jadi sesi QA ulang modul wisata dijadwalkan khusus untuk cek tautan publik tujuh venue ini setelah migrasi jalan.
Satu commit ini juga menutup dua lubang lain di sisi infrastruktur. db-push ke staging sekarang mengecualikan DATA tabel users: hash password dan akun dev tidak pernah lagi menimpa akun staging, tapi schema users tetap dibuat kosong lewat dump --no-data supaya API staging tidak rusak saat boot. Konfigurasi E2E diberi guard di saat load: kalau E2E_BASE_URL menunjuk ke selain mesin lokal tempat tes dijalankan, config langsung menolak jalan, karena tes E2E itu menulis data dan tidak boleh diarahkan ke server bersama.
Pelajaran yang saya bawa pulang: memperbaiki data yang salah itu pekerjaan presisi, bukan pekerjaan pola. Regex yang kira-kira cocok terlihat hemat sepuluh menit, tapi membuka pintu ke false positive yang tidak kelihatan. Guard byte-exact, mapping manual, precek tabrakan, dan jalan mundur yang byte-identik membuat perbaikan tujuh slug ini bisa dijelaskan baris per baris ke siapa pun yang menanyakan.
Sumber
[1] RFC 3629: UTF-8, a transformation format of ISO 10646
[2] MySQL 8.0 Manual: The utf8mb4 Character Set
[3] golang-migrate/migrate