Pada 26 September 2026, dev server Payload saya menggantung di setiap request karena sebuah pertanyaan yang tidak terlihat. Pemicunya satu perubahan skema: sebuah group dibuang dari satu blok, lalu field baru ditambahkan. Catatan saya hari itu menjelaskan apa yang terjadi di baliknya. Drizzle push yang berjalan di dalam dev server sedang menunggu jawaban atas prompt rename, dan selama prompt itu menunggu, setiap request ikut tertahan.
Prompt itu bukan hal baru di proyek ini. Pada 15 September, push dan migrate:create sama-sama bertanya apakah sebuah kolom dibuat baru atau diganti nama, sehingga skema lokal hari itu diterapkan manual lewat psql dan pertanyaannya dijawab dengan expect. Catatan 17 September sudah menyebut gejala yang mirip: dev server menggantung di prompt drizzle interaktif ketika sebuah kolom dipindah selagi server itu berjalan.
Proyeknya memakai Payload 3.88.0 dan @payloadcms/db-postgres 3.88.0 di atas Next.js 16.3.0, dengan lockfile yang mengunci @payloadcms/drizzle 3.88.0, drizzle-kit 0.31.7, dan drizzle-orm 0.45.2. Menurut README repo-nya, database lokal menerima perubahan skema lewat push selama bekerja, sedangkan database hosted hanya menerima migrasi. Kapan Payload memutuskan untuk push, dan apa akibatnya kalau push itu sampai ke database hosted, sudah saya tulis terpisah. Di sini saya membahas sisi laptopnya: perubahan seperti apa yang memunculkan pertanyaan itu, kenapa satu pertanyaan bisa menahan seluruh dev server, dan apa yang saya kerjakan sesudahnya.
Satu group dibuang, tiga field lokal masuk
Blok yang diubah semula menyimpan sebuah group berisi tiga field lokal dan satu array. Perubahan 26 September membuang group itu dan menaruh tiga field lokal baru langsung di tingkat blok. Dengan nama yang sudah saya ganti, bentuknya kira-kira begini:
// disederhanakan, identifier netral, field lain di blok ini tidak ditampilkan
// sebelum: field lokal berada di dalam group (array di group ini juga tidak ditampilkan)
{
slug: 'featureGrid',
fields: [
{
name: 'callout',
type: 'group',
fields: [
{ name: 'kicker', localized: true, type: 'text' },
{ name: 'title', localized: true, type: 'text' },
{ name: 'body', localized: true, type: 'textarea' },
],
},
],
}
// kolom di pages_blocks_feature_grid_locales: callout_kicker, callout_title, callout_body
// sesudah: group dibuang, tiga field lokal baru di tingkat blok
{
slug: 'featureGrid',
fields: [
{ name: 'kicker', localized: true, type: 'text' },
{ name: 'title', localized: true, type: 'text' },
{ name: 'summary', localized: true, type: 'textarea' },
],
}
// kolom di pages_blocks_feature_grid_locales: kicker, title, summaryDi Postgres, field lokal milik sebuah blok tinggal di tabel locales blok itu, dan field di dalam group membawa nama group-nya sebagai awalan kolom. Dua migrasi yang dibuat untuk perubahan ini memperlihatkan kedua keadaan tabel tersebut. Bagian down migrasi pertama menunjukkan tabel locales lama berisi tiga kolom berawalan nama group, sedangkan migrasi kedua membuat tabel dengan nama yang persis sama, berisi tiga kolom tanpa awalan. Jadi dalam satu tabel yang namanya tidak berubah, tiga kolom hilang dan tiga kolom baru muncul pada saat yang sama.
Kapan drizzle-kit bertanya create atau rename
Pertanyaan itu tidak diajukan untuk setiap perubahan kolom. Di drizzle-kit 0.31.7 yang terpasang, fungsi yang menangani konflik kolom langsung pulang tanpa bertanya kalau sebuah tabel hanya punya kolom baru, atau hanya punya kolom yang hilang:
// drizzle-kit 0.31.7, api.mjs (dipotong)
promptColumnsConflicts = async (tableName, newColumns, missingColumns) => {
if (newColumns.length === 0 || missingColumns.length === 0) {
return { created: newColumns, renamed: [], deleted: missingColumns };
}
// ...
const promptData = [created, ...renames];
// ...
// state prompt pilihan, di berkas yang sama
var SelectState3 = class {
constructor(items) {
this.items = items;
this.selectedIdx = 0;
}
// ...Pertanyaan baru muncul kalau sebuah tabel punya keduanya sekaligus, dan tabel locales tadi persis seperti itu. Daftar pilihannya menaruh opsi create untuk kolom baru di urutan pertama, diikuti satu opsi rename untuk setiap kolom yang hilang. Dengan tiga kolom lama yang hilang, satu pertanyaan bisa memuat sampai empat pilihan, yaitu satu create ditambah tiga rename. Opsi yang tersorot ketika prompt pertama kali tampil adalah indeks 0, dan indeks 0 itu create.
Teks yang tampil di dev server hari itu tidak tercatat. Yang bisa saya tunjukkan adalah templatnya di kode drizzle-kit yang terpasang:
# templat di api.mjs drizzle-kit 0.31.7, bukan keluaran yang tertangkap
Is <kolom> column in <tabel> table created or renamed from another column?
+ <judul> create column
~ <judul> rename columnPotongan created or renamed dari templat inilah yang belakangan dipakai sebagai penanda di skrip expect, yang muncul lagi di bagian akhir tulisan ini.
Kenapa setiap request ikut menunggu
Prompt yang menunggu jawaban belum menjelaskan kenapa setiap request ikut macet, bukan hanya request yang menyentuh blok itu. Penjelasannya ada di cara Payload 3.88.0 memuat ulang dirinya saat development. Rangkaian di bawah ini saya susun dari kode yang terpasang, bukan dari jejak yang tertangkap waktu dev server macet.
Payload mendengarkan websocket HMR milik Next, dan begitu pesan serverComponentChanges datang, getPayload menandai instance-nya untuk dimuat ulang. Fungsi reload kemudian menyambungkan ulang database lewat payload.db.connect({ hotReload: true }). Selama reload itu berjalan, setiap panggilan getPayload berikutnya menunggu promise reload yang pertama:
// payload 3.88.0, dist/index.js (dipotong, disusun menurut alur)
// 1. pesan HMR dari Next menandai instance untuk dimuat ulang
if (// On Next.js 15, we need to check for data.action. On Next.js 16, we need to check for data.type.
data.type === 'serverComponentChanges' || data.action === 'serverComponentChanges') {
cached.reload = true;
}
// 2. di dalam reload, database disambung ulang
await payload.db.connect({
hotReload: true
});
// 3. panggilan getPayload lain menunggu reload yang pertama
if (cached.reload instanceof Promise) {
await cached.reload;
}Di adapter Postgres, connect menjalankan push skema dev selama tiga syarat terpenuhi: NODE_ENV bukan production, PAYLOAD_MIGRATING bukan true, dan opsi push tidak false. hotReload tidak termasuk di antaranya, jadi sambungan ulang saat reload ikut menjalankan push. Fungsi push di @payloadcms/drizzle sendiri berkomentar bahwa langkah itu akan memunculkan prompt kalau Drizzle butuh klarifikasi.
Kalau perubahan config sampai ke Payload lewat jalur HMR ini, rangkaiannya tertutup. Reload menyambung ulang database, sambungan itu menjalankan push, push berhenti di pertanyaan create atau rename yang tidak terlihat, dan promise reload tidak pernah selesai. Setiap request yang memanggil getPayload lalu menunggu promise yang sama. Rangkaian itu cocok dengan dua catatan saya: dev server menggantung ketika kolom dipindah selagi dia berjalan, dan pada 26 September setiap request ikut menggantung.
Jalan keluar hari itu
Jalan keluar yang dipakai tidak mencoba menjawab prompt itu. Dev server dihentikan, SQL dari dua migrasi untuk perubahan tersebut diterapkan ke database lokal dengan psql, lalu dev server dijalankan lagi. Resep yang sama sudah tercatat sembilan hari sebelumnya dalam bentuk lebih pendek: pindahkan kolom dengan psql lebih dulu, atau restart dev server.
Kalau dibaca dari kodenya, urutan ini masuk akal. Begitu SQL kedua migrasi masuk, tabel locales di database lokal sudah berbentuk baru, sehingga ketika push berjalan lagi sesudah restart, tabel itu tidak lagi kehilangan dan mendapat kolom sekaligus.
Pada 27 September ada cara lain untuk kasus yang berbeda. Beberapa kolom di database lokal dikonversi dengan menjalankan up migrasinya sekali memakai PAYLOAD_DB_PUSH=0. Di config proyek ini, push menyala kalau variabel itu bernilai 1, atau kalau nilainya bukan 0 dan database-nya lokal. Nilai 0 membuat opsi push bernilai false untuk run itu, dan syarat di connect tadi melewatkan push sepenuhnya.
Memecah migrasi supaya drizzle tidak bertanya
Untuk perubahan 26 September sendiri, migrasinya sengaja dipecah dua, satu membuang tabel group lama dan satu menambah field baru, supaya drizzle tidak pernah bertanya soal rename. Pesan commit-nya menulis alasan yang sama. Migrasi pertama membuang tiga tabel, termasuk tabel locales blok itu, dan migrasi kedua membuat tabel locales yang sama lagi dengan kolom barunya:
-- disederhanakan, identifier netral, kolom dan tabel lain tidak ditampilkan
-- migrasi 1: hanya membuang
DROP TABLE "pages_blocks_feature_grid_locales" CASCADE;
-- migrasi 2: hanya membuat
CREATE TABLE "pages_blocks_feature_grid_locales" (
"kicker" varchar,
"title" varchar,
"summary" varchar
);
-- pembanding dari 15 September, ketika isi kolom harus bertahan
ALTER TABLE "entries" RENAME COLUMN "legacy_ref" TO "ref";Dengan aturan drizzle-kit tadi, pembagian ini menutup jalan ke prompt. Langkah pertama hanya membuang dan langkah kedua hanya membuat, jadi di kedua langkah tidak ada tabel yang kehilangan dan mendapat kolom sekaligus.
Harganya perlu disebut. DROP TABLE membuang isi tabel lama, bukan memindahkannya, dan catatan sebelum push hari itu menyebut terang-terangan bahwa migrasi blok tersebut membuang tabel panel lama. Untuk perubahan ini memang begitu maksudnya. Untuk rename yang isinya harus bertahan, jalan ini tidak bisa dipakai. Pada 15 September, field ID yang khusus untuk satu penyedia diganti dengan field penyedia berisi dua pilihan dan field ID yang umum, dan migrasinya mengganti nama kolom lama supaya satu identifier asli yang sudah ada di staging tetap bertahan. Baris RENAME COLUMN di akhir blok di atas adalah versi netral dari pernyataan di berkas migrasi itu.
Prompt yang sama di luar dev server
migrate:create juga bertanya create atau rename, dan pada 29 September tercatat bahwa prompt rename di sana tidak bisa dijawab lewat pipa. Gantinya dipakai skrip expect yang menjalankan pnpm payload migrate:create <nama> dan mengirim Enter begitu teks created or renamed muncul:
# disederhanakan, nama migrasi netral
spawn pnpm payload migrate:create nama_migrasi
expect "created or renamed"
send "\r"
expect eofSehari kemudian aturannya ditetapkan untuk migrate:create dan seed lokal yang memunculkan prompt rename Drizzle: jawab Enter pada "created or renamed", dan y pada pertanyaan data loss.
Enter di situ bukan jawaban netral. Di prompt pilihan, Enter mengirim opsi yang sedang tersorot, dan seperti terlihat di kode tadi, opsi itu create. Untuk perubahan 26 September, ketika isi group lama memang dibuang, create adalah jawaban yang dimaksud. Untuk rename seperti 15 September, Enter yang sama akan membuat kolom baru alih-alih mengganti nama kolom lama.
Pipa punya jebakan lain. migrate:create pernah keluar diam-diam tanpa menghasilkan berkas ketika dijalankan dengan yes n |, sehingga catatan yang sama menyuruh memakai < /dev/null lalu memeriksa berkas migrasinya. Kalau tidak ada kolom yang terbaca sebagai rename, pnpm payload migrate:create <nama> berjalan tanpa pertanyaan create atau rename, sesuai aturan drizzle-kit di atas.
Seed lokal punya versinya sendiri. Catatan 29 September menyebut bahwa membuang kolom di skema, lewat seed maupun dev, memunculkan prompt Drizzle yang menahan dev server. Caranya, matikan dev, jalankan seed dengan yes y |, lalu nyalakan dev lagi.
Fungsi push Payload juga punya prompt kedua, terpisah dari pertanyaan rename milik drizzle-kit. Kalau drizzle-kit mengembalikan peringatan, pushDevSchema menanyakan Accept warnings and push schema to database? dengan jawaban bawaan tidak, dan konfirmasi itu memanggil process.exit(0) baik ketika dibatalkan maupun ketika tidak diterima. Catatan 27 September merekam akibatnya pada run non-interaktif: mode push memanggil process.exit(0) saat ada peringatan skema. Kode keluar 0 terbaca sebagai sukses oleh apa pun yang menjalankan proses itu.
Pada 6 Oktober tercatat satu variasi lagi: payload migrate:create menunggu prompt ketika skemanya tidak berubah.
Batas yang bisa saya klaim
Jalan keluar 26 September sudah dijalankan, tetapi catatan saya tidak memuat pemeriksaan eksplisit bahwa request kembali terlayani sesudah restart, dan tidak mencatat perbaikan permanen untuk perilaku prompt-nya. Pencegahan yang dipasang adalah pemecahan migrasi. Baris konfigurasi push lokal tidak ikut diubah, dan bunyinya sama di commit 15 September maupun di commit perubahan blok tanggal 26 September.
Commit perubahan blok itu ikut dalam push hari yang sama. Sebelum push itu, proyeknya di-build di lokal, 173 test dijalankan, lint melaporkan nol error, database staging dibaca, dan ke-48 migrasi digladikan di database kosong, dengan skema hasil yang sama dengan skema lokal bentukan push: 1275 kolom, 83 enum, dan 756 indeks. Gladi itu membuktikan bahwa migrasi dan skema lokal sepakat. Dia tidak membuktikan bahwa macetnya hilang, dan prompt sejenis memang muncul lagi pada 27 September, 29 September, dan 6 Oktober.
Satu lagi yang tidak saya pegang adalah teks prompt yang benar-benar tampil di dev server hari itu. Yang saya kutip di atas adalah templat dari kode drizzle-kit yang terpasang, bukan keluaran yang tertangkap.
Pertanyaan yang tidak bisa dijawab dari dalam server
Prompt interaktif mengandaikan ada orang di depan terminal. Push di dalam dev server tidak punya orang itu, dan karena setiap getPayload menunggu reload yang sama, satu pertanyaan yang tidak terlihat cukup untuk menahan semua request. Pertanyaannya sendiri baru muncul ketika satu tabel kehilangan dan mendapat kolom sekaligus. Itu sebabnya membuang sebuah group lalu menambah field pengganti layak diperlakukan sebagai perubahan database, bukan sekadar edit config.
Dari catatan-catatan itu ada beberapa kebiasaan yang bisa dipakai ulang. Pindahkan kolom dengan psql lebih dulu, atau hentikan dev server, terapkan SQL migrasinya, lalu jalankan lagi. Pecah migrasi menjadi langkah membuang dan langkah membuat kalau isi lama memang boleh hilang, dan pertahankan RENAME COLUMN kalau isinya harus bertahan. Kalau prompt rename tidak bisa dihindari, expect bisa menjawabnya, asal diingat bahwa Enter berarti create. Kalau migrate:create dijalankan tanpa terminal, periksa bahwa berkas migrasinya benar-benar ada.