รายชื่อติดต่อ 20 รายควรจะถูก archive ใน CRM แต่ UI กลับแสดงแถบแจ้งเตือนสีเขียวว่าสำเร็จ ทั้งที่การดำเนินการนั้นไม่ได้ทำอะไรเลยแม้แต่นิดเดียว ตัวช่วย (helper) ใน TypeScript ที่ทำให้ฟิลด์ error กลายเป็นฟิลด์ที่จำเป็น (mandatory) ในการทำ mutation ของ Supabase-JS จะช่วยบังคับให้นักพัฒนาต้องเผชิญหน้ากับความล้มเหลวนั้น แทนที่จะปล่อยผ่านไปเฉยๆ
ทำไมรูปแบบการคืนค่า (return shape) ของ Supabase-JS ถึงเป็นกับดัก
Supabase JavaScript client (@supabase/supabase-js) จะไม่ทำการ throw exception เมื่อการเขียนข้อมูลลงฐานข้อมูลถูกปฏิเสธ แต่จะใช้วิธี resolve promise ด้วย object { data, error } แทน หากนโยบาย Row-level security (RLS), การละเมิด unique-key หรือข้อจำกัด (constraint) อื่นๆ บล็อกการ query ไว้ ค่า data จะคืนกลับมาเป็น null และ error จะบรรจุข้อความจากฐานข้อมูล ตัว client ตั้งสมมติฐานว่าผู้เรียกใช้งานจะตรวจสอบ error เอง และมันจะไม่ยกเลิก (abort) การเรียกใช้งานนั้นๆ
ในทางปฏิบัติ โค้ดจำนวนมากมักจะปฏิบัติกับการเรียกใช้งานแบบ fire-and-forget (ส่งคำสั่งไปแล้วจบกัน):
await supabase
.from('contacts')
.update({ statut: 'ancien', archived_at: now })
.eq('id', id)
return { ok: true }
เมื่อการ update ถูกบล็อกโดยกฎ RLS ตัว promise ก็ยังคง resolve อยู่ และเนื่องจากนักพัฒนาไม่ได้ทำการ destructure { error } ออกมา ความล้มเหลวที่เกิดขึ้นจึงมองไม่เห็น ฟังก์ชันจะคืนค่า { ok: true } UI แสดงผลว่าสำเร็จ และข้อมูลก็ยังคงไม่เปลี่ยนแปลง ไม่มีการบันทึก log ไม่มีการแจ้งเตือนจาก Sentry และบั๊กนี้อาจค้างอยู่ในระบบได้นานหลายวัน
ลินเตอร์ (Linter) อย่างเดียวไม่พอ
เครื่องมือวิเคราะห์แบบ Static (Static analysis tools) สามารถเตือนได้เมื่อมีการละเลย property error แต่เครื่องมือเหล่านี้ไม่สามารถบังคับใช้ข้อตกลงในระดับ runtime (runtime contract) ได้ นักพัฒนายังคงสามารถเขียน const _ = await … เพื่อปิดการแจ้งเตือน หรือเพิ่ม comment เพื่อระงับกฎนั้นได้ ปัญหาที่แท้จริงคือระบบ Type อนุญาตให้การเรียกใช้งานสำเร็จได้โดยที่ไม่ต้องกล่าวถึง error เลย
ตัวช่วย mutate(): บังคับให้ต้องจัดการกับ error
ผู้เขียนได้สร้าง wrapper ขนาดเล็กที่เรียกว่า mutate() ซึ่งเปลี่ยนรูปแบบของ return type แทนที่จะเป็น { data, error } ตัวช่วยนี้จะคืนค่าเป็น tuple [data, error] โดยที่ error เป็นฟิลด์ที่จำเป็น (required) จากนั้น TypeScript จะปฏิเสธการ compile การเรียกใช้งานใดๆ ที่ทิ้ง element ตัวที่สองไป
async function mutate<T>(promise: Promise<{ data: T | null; error: any }>) {
const { data, error } = await promise
// Send error to Sentry immediately
if (error) Sentry.captureException(error)
return [data, error] as const
}
การใช้งานจะมีความชัดเจนมากขึ้น:
const [result, err] = await mutate(
supabase
.from('contacts')
.update({ statut: 'ancien', archived_at: now })
.eq('id', id)
)
if (err) {
// Handle or rethrow
return { ok: false, message: err.message }
}
return { ok: true, data: result }
หากนักพัฒนาลืมดักจับ err ตัว TypeScript จะแจ้ง error ว่า: “Tuple type [T, any] of length 2 has no element at index 1.” โค้ดจะไม่สามารถ compile ได้จนกว่าจะมีการจัดการกับ error นั้น นอกจากนี้ ตัวช่วยนี้ยังมีการใส่ Sentry instrumentation เข้าไปด้วย เพื่อรับประกันว่าทุกการปฏิเสธจากฐานข้อมูลจะถูกบันทึก log ไว้ แม้ว่าผู้เรียกใช้งานจะละเลยมันในภายหลังก็ตาม
ความเสี่ยงที่อาจเกิดขึ้น
- ความถูกต้องสมบูรณ์ของข้อมูล (Data integrity) – ความล้มเหลวที่เงียบเชียบ (Silent failures) ปล่อยให้สถานะที่ไม่ถูกต้องหลุดเข้าไปในระบบ production รายชื่อติดต่อที่ควรจะถูก archive แต่กลับไม่ถูกย้ายออกจากรายการที่ใช้งานอยู่ อาจทำให้รายงานในขั้นตอนถัดไปผิดพลาดได้
- ความเชื่อมั่นของผู้ใช้ (User trust) – UI ที่แจ้งว่าสำเร็จทั้งที่ไม่มีอะไรเปลี่ยนแปลงจะทำลายความเชื่อมั่น ลูกค้าเห็นสถานะว่า “archived” แต่ยังคงค้นหารายชื่อนั้นเจอ
- เวลาของนักพัฒนา (Developer time) – การไล่ตามบั๊กที่หาสาเหตุไม่ได้ (phantom bugs) สิ้นเปลืองเวลาหลายชั่วโมง การจัดการ error อย่างชัดเจนจะช่วยให้พบปัญหา ณ จุดที่เกิดความล้มเหลว ช่วยลดระยะเวลาในการ debug
- ต้นทุนการดำเนินงาน (Operational cost) – การเพิ่มโค้ดเพียงไม่กี่บรรทัดและ wrapper เล็กๆ นั้นถือว่าน้อยมากเมื่อเทียบกับต้นทุนที่ต้องจ่ายเมื่อเกิดเหตุการณ์ข้อมูลสูญหายแบบเงียบเชียบ
ข้อแลกเปลี่ยน
ตัวช่วยนี้ทำให้โค้ดมีความเยิ่นเย้อ (verbosity) มากขึ้น: ทุกการเรียก mutation จะคืนค่าเป็น tuple และผู้เรียกใช้งานต้องเขียน block if (err) บางทีมอาจมองว่านี่เป็นโค้ดส่วนเกิน (boilerplate noise) โดยเฉพาะสำหรับคำสั่ง CRUD ง่ายๆ ที่คาดหวังความสำเร็จอยู่แล้ว ข้อโต้แย้งคือโค้ดที่เพิ่มขึ้นมานี้คือเกราะป้องกัน ไม่ใช่ฟีเจอร์ทางเลือก ในสภาพแวดล้อมที่ความถูกต้องของข้อมูลเป็นเรื่องสำคัญที่สุด เช่น ระบบ CRM, การเงิน หรือสุขภาพ การบังคับให้มีการตรวจสอบจะคุ้มค่าในตัวมันเอง
สิ่งที่จะเกิดขึ้นต่อไป
ผู้เขียนสัญญาว่าจะเขียนบทความตอนที่สาม ซึ่งจะตรวจสอบเรื่องนโยบาย RLS ที่คืนค่าเป็นศูนย์แถวโดยไม่มีคำอธิบาย รูปแบบนั้นก็เหมือนกับกรณี silent-error ที่ซ่อนความล้มเหลวไว้เบื้องหลังการ query ที่ดูเหมือนจะสำเร็จ บทความชุดนี้มีเป้าหมายเพื่อเปิดโปงด้านที่เป็น "ข่าวลือ" (rumor) ของ Supabase API และมอบเครื่องมือที่เป็นรูปธรรมให้นักพัฒนาสามารถเรียกหาข้อเท็จจริงได้
บทสรุป: การออกแบบของ Supabase-JS ทำให้การเขียนข้อมูลที่ล้มเหลวดูเหมือนความสำเร็จ เว้นแต่คุณจะจำได้ว่าต้องอ่านฟิลด์ error การใช้ wrapper ที่บังคับด้วย TypeScript เพื่อทำให้ error เป็นสิ่งจำเป็นและบันทึกลง Sentry จะเปลี่ยนความล้มเหลวที่เงียบเชียบให้กลายเป็นเหตุการณ์ที่มองเห็นได้และจัดการได้ การเพิ่มขนาดโค้ดเพียงเล็กน้อยจะช่วยแลกกับความน่าเชื่อถือของข้อมูลและความเชื่อมั่นของผู้ใช้ ซึ่งเป็นสองสิ่งที่ความสำเร็จที่เงียบเชียบไม่สามารถมอบให้ได้เลย
