Hai mươi liên hệ đáng lẽ phải được lưu trữ vào CRM, nhưng giao diện người dùng (UI) lại hiển thị thông báo thành công màu xanh lá cây trong khi thao tác thực tế chẳng có gì thay đổi. Một helper TypeScript giúp bắt buộc phải có trường error trong các mutation của Supabase-JS giờ đây sẽ buộc các lập trình viên phải đối mặt với thất bại đó thay vì lờ nó đi.
Tại sao cấu trúc trả về của Supabase-JS là một cái bẫy
Client JavaScript của Supabase (@supabase/supabase-js) không ném ra ngoại lệ (exceptions) khi một thao tác ghi vào cơ sở dữ liệu bị từ chối. Thay vào đó, nó resolve promise với một đối tượng { data, error }. Nếu một chính sách bảo mật cấp hàng (RLS), vi phạm khóa duy nhất (unique-key violation), hoặc bất kỳ ràng buộc nào khác chặn truy vấn, data sẽ trả về là null và error sẽ chứa thông báo từ cơ sở dữ liệu. Client giả định rằng người gọi sẽ kiểm tra error; nó không bao giờ hủy bỏ (abort) cuộc gọi.
Trong thực tế, nhiều mã nguồn coi cuộc gọi này là một thao tác "fire-and-forget" (gửi đi và quên luôn):
await supabase
.from('contacts')
.update({ statut: 'ancien', archived_at: now })
.eq('id', id)
return { ok: true }
Khi lệnh update bị chặn bởi một quy tắc RLS, promise vẫn được resolve. Vì lập trình viên không bao giờ destructure { error }, thất bại này trở nên vô hình. Hàm trả về { ok: true }, UI hiển thị thành công, và dữ liệu vẫn không thay đổi. Không có log nào xuất hiện, không có cảnh báo Sentry nào được kích hoạt, và lỗi có thể tồn tại trong nhiều ngày.
Một linter là không đủ
Các công cụ phân tích tĩnh có thể cảnh báo khi thuộc tính error bị bỏ qua, nhưng chúng không thể thực thi một hợp đồng thực thi (runtime contract). Một lập trình viên vẫn có thể viết const _ = await … để làm im lặng cảnh báo, hoặc họ có thể thêm một comment để tắt quy tắc đó. Vấn đề cốt lõi là hệ thống kiểu (type system) cho phép một cuộc gọi thành công mà không bao giờ đề cập đến error.
Helper mutate() : bắt buộc phải xử lý lỗi
Tác giả đã xây dựng một wrapper nhỏ gọi là mutate() nhằm thay đổi cấu trúc của kiểu trả về. Thay vì { data, error }, helper này trả về một tuple [data, error] trong đó error là một trường bắt buộc. TypeScript sau đó sẽ từ chối biên dịch bất kỳ cuộc gọi nào bỏ qua phần tử thứ hai.
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
}
Cách sử dụng trở nên rõ ràng:
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 }
Nếu lập trình viên quên bắt lấy err, TypeScript sẽ báo lỗi: “Tuple type [T, any] of length 2 has no element at index 1.” Mã sẽ không thể biên dịch cho đến khi lỗi được xử lý. Helper này cũng tích hợp sẵn công cụ đo lường (instrumentation) của Sentry, đảm bảo rằng mọi lần bị cơ sở dữ liệu từ chối đều được ghi log ngay cả khi người gọi sau đó lờ nó đi.
Những rủi ro tiềm ẩn
- Tính toàn vẹn dữ liệu – Các thất bại âm thầm cho phép trạng thái không hợp lệ len lỏi vào môi trường production. Một liên hệ đã lưu trữ nhưng không bao giờ rời khỏi danh sách hoạt động có thể khiến các báo cáo hạ nguồn bị sai lệch.
- Niềm tin của người dùng – Một giao diện tuyên bố thành công trong khi không có gì thay đổi sẽ làm xói mòn sự tin tưởng. Khách hàng thấy trạng thái “đã lưu trữ” nhưng vẫn tìm thấy liên hệ đó trong các tìm kiếm.
- Thời gian của lập trình viên – Việc săn lùng những lỗi "ma" tiêu tốn hàng giờ đồng hồ. Việc xử lý lỗi rõ ràng sẽ làm lộ ra vấn đề ngay tại điểm xảy ra lỗi, giúp rút ngắn vòng lặp debug.
- Chi phí vận hành – Việc thêm một vài dòng code và một wrapper nhỏ là không đáng kể so với chi phí của một sự cố mất dữ liệu âm thầm.
Sự đánh đổi
Helper này làm tăng tính rườm rà: mọi cuộc gọi mutation giờ đây đều trả về một tuple, và người gọi phải viết một khối if (err). Một số nhóm có thể coi đây là những đoạn mã thừa (boilerplate noise), đặc biệt là đối với các hành động CRUD đơn giản nơi họ kỳ vọng sự thành công. Lập luận ngược lại là đoạn mã bổ sung này là một chốt chặn an toàn, chứ không phải là một tính năng tùy chọn. Trong các môi trường mà tính chính xác của dữ liệu là tối quan trọng—như hệ thống CRM, tài chính, y tế—việc bắt buộc kiểm tra sẽ mang lại giá trị xứng đáng.
Tiếp theo là gì
Tác giả hứa hẹn một phần thứ ba xem xét các chính sách RLS trả về số hàng bằng không mà không có giải thích. Mô hình đó, giống như trường hợp lỗi âm thầm, che giấu các thất bại đằng sau một truy vấn có vẻ như thành công. Cùng với nhau, chuỗi bài viết nhằm mục đích vạch trần khía cạnh "tin đồn" của API Supabase và cung cấp cho các lập trình viên những công cụ cụ thể để đòi hỏi sự thật.
Bài học rút ra: Thiết kế của Supabase-JS cho phép một thao tác ghi thất bại trông như một thành công trừ khi bạn nhớ đọc trường error. Bằng cách bao bọc các cuộc gọi trong một helper được TypeScript thực thi để bắt buộc có error và ghi log vào Sentry, bạn biến các thất bại âm thầm thành các sự kiện có thể nhìn thấy và có thể xử lý được. Sự gia tăng khiêm tốn về kích thước mã nguồn sẽ mang lại cho bạn độ tin cậy của dữ liệu và niềm tin của người dùng—hai thứ mà không một "thành công âm thầm" nào có thể mang lại.
