Saya sedang menambahkan panel kecil di Sanity Studio untuk sebuah situs konten klien: sebuah plugin "Link Suggestions" yang menampilkan artikel-artikel terkait saat editor sedang menulis. Idenya sederhana. Editor mengetik beberapa kata, panel menjalankan query GROQ ber-scoring, dan artikel yang paling relevan naik ke atas. Di lokal, dengan dataset kecil, semuanya jalan mulus. Saya kirim ke produksi, klik panel pertama kali di dataset asli, dan panelnya langsung 500.
Refleks pertama saya menuduh React: mungkin komponen panelnya yang error, mungkin ada state yang belum ke-handle. Tapi begitu saya buka Network tab, jelas bukan di frontend. Request ke data API balik HTTP 400 mentah, bukan 500 dari komponen. Payload errornya cuma satu baris, tapi baris itu yang membongkar semuanya:
score() function received unexpected expression
Kenapa ini terjadi
Saya kira score() dan boost() itu ekspresi GROQ biasa, sama seperti klausa lain di query. Ternyata tidak. Mesin scoring GROQ menjalankan parser yang jauh lebih terbatas dari parser GROQ utama. Di dalam boost(), kamu hanya boleh menaruh predikat sederhana: path field langsung ditambah operator match atau operator perbandingan. Selesai. Bukan pemanggilan fungsi.
Masalahnya, query saya penuh dengan pemanggilan fungsi karena kelihatannya masuk akal. Judul artikel bisa ada di field title atau name, jadi saya bungkus dengan coalesce(). Body-nya Portable Text, jadi untuk mencocokkan teksnya saya pakai pt::text(). Dua-duanya fungsi yang benar-benar valid di GROQ, dan dua-duanya jalan mulus di klausa projeksi. Yang tidak saya sadari: valid di projeksi bukan berarti valid di dalam boost().
// WRONG — coalesce() dan pt::text() ditolak di dalam boost()
*[_type == "article"] | score(
boost(coalesce(title, name) match $q, 2),
boost(pt::text(body) match $q, 1)
)Begitu query itu mengenai data API, parser scoring melihat sebuah pemanggilan fungsi di tempat yang cuma mengharapkan predikat sederhana, lalu menolak seluruh ekspresi dengan 400. Di lokal saya lolos karena datasetku kecil dan aku tidak pernah betul-betul memicu jalur scoring yang berat; produksi langsung memicunya di request pertama. Bukan bug React, bukan masalah deploy. Query saya memang tidak sah untuk mesin scoring sejak awal.
Perbaikannya
Perbaikannya bukan membuang scoring, tapi memecah setiap fungsi menjadi predikat sederhana yang bisa dicerna boost(). Untuk coalesce(title, name), saya berhenti mencoba menggabungkan dua field jadi satu ekspresi. Saya bikin dua predikat terpisah, masing-masing dibungkus boost() sendiri dengan bobot yang sama:
*[_type == "article"] | score(
boost(title match $q, 2),
boost(name match $q, 2)
)Untuk body Portable Text, pt::text(body) diganti dengan path traversal array eksplisit langsung ke node teksnya. Portable Text itu array of block, tiap block punya array children, tiap child punya field text. Jadi alih-alih memanggil fungsi untuk mengubah body jadi string, saya lompati fungsinya dan cocokkan langsung ke path field:
*[_type == "article"] | score(
boost(title match $q, 2),
boost(name match $q, 2),
boost(body[].children[].text match $q, 1)
)body[].children[].text adalah path field murni, jadi parser scoring senang. Yang penting dicatat: coalesce() sendiri tetap tidak salah, dia cuma salah tempat. Saya tetap pakai coalesce(title, name) di klausa projeksi untuk memilih judul mana yang ditampilkan di kartu hasil. Yang berubah cuma ini: fungsi dilarang keras di dalam boost(), tapi bebas dipakai di projeksi. Setelah dipecah begini, API balik 200, panelnya render, dan artikel terkait naik dengan bobot yang benar.
Pelajaran
score() dan boost() di GROQ bukan ekspresi serba bisa. Mereka jalan di parser terbatas yang cuma menerima predikat sederhana, jadi apa pun yang lebih dari path field plus match atau perbandingan bakal ditolak dengan score() function received unexpected expression. Kalau kamu terjebak, tanya satu hal: apakah aku memanggil fungsi di dalam boost()? Kalau iya, pecah. Gabungkan banyak field dengan beberapa boost() bukan satu coalesce(), dan gantikan pt::text() dengan path traversal array seperti body[].children[].text. Simpan pemanggilan fungsi untuk klausa projeksi, tempat mereka memang sah. Dan uji jalur scoring di dataset yang cukup besar untuk memicunya sebelum kirim ke produksi, karena dataset lokal yang mungil bisa menyembunyikan 400 yang menunggu di request pertama produksi.
