D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

"It looks like you've run Payload in dev mode" di Build Vercel: Baris `dev` Batch -1 yang Membuat `payload migrate` Jadi No-Op

· · 9 menit baca
"It looks like you've run Payload in dev mode" di Build Vercel: Baris `dev` Batch -1 yang Membuat `payload migrate` Jadi No-Op

Build command staging ini sengaja saya susun supaya migrasi jalan lebih dulu: pnpm payload migrate && pnpm build. Pesan commit yang memasangnya, dini hari 11 September 2026, menyebut alasannya: perubahan skema harus mendarat bersama deploy yang membutuhkannya. Catatan staging saya menambahkan satu aturan di sebelahnya, cukup satu kalimat: jangan pernah mengandalkan push skema di production.

Proyeknya memakai Payload CMS 3.88.0 dengan adapter @payloadcms/db-postgres versi yang sama, di atas Next.js 16.3.0. Staging-nya di Vercel, dan database-nya Postgres di Neon yang dipasang lewat Vercel Marketplace. Migrasi pertamanya 20260910_163813_initial.ts.

Dalam beberapa hari, build command itu gagal dengan dua cara yang bentuknya berlawanan. Satu deploy berhenti di sebuah pertanyaan dan menunggu jawaban dari terminal yang tidak ditunggui siapa pun. Deploy lain selesai hijau, tayang, lalu menjawab setiap request dengan 500. Keduanya berawal dari satu baris di tabel payload_migrations, dan baris itu ditulis Payload sendiri.

Push diputuskan oleh NODE_ENV, bukan oleh alamat database

Adapter Postgres Payload punya dua cara membuat database mengikuti config. Push menyelaraskan tabel langsung dari config tanpa berkas migrasi, dan memang dimaksudkan untuk pengembangan. Migrate menjalankan berkas migrasi berurutan dan mencatat setiap migrasi yang sudah jalan sebagai satu baris di payload_migrations. Database hosted semestinya hanya diubah lewat jalan yang kedua.

Di 3.88.0, keputusan untuk push diambil saat adapter connect:

// Only push schema if not in production
if (process.env.NODE_ENV !== 'production' && process.env.PAYLOAD_MIGRATING !== 'true' && this.push !== false) {
    await pushDevSchema(this);
}

Komentarnya bilang push hanya jalan di luar production, dan production di sini berarti nilai NODE_ENV. Alamat database yang sedang disambungkan tidak ikut ditimbang. Selama opsi push tidak diisi false dan PAYLOAD_MIGRATING bukan 'true', proses apa pun dengan NODE_ENV selain production akan mem-push, termasuk skrip yang dijalankan dari laptop ke database hosted.

Seed-nya persis skrip semacam itu. Seed dijalankan dari mesin saya ke Neon dengan push menyala, yang memang bawaannya di luar production, sehingga Payload mem-push skema dan menulis satu baris bernama dev dengan batch -1 ke payload_migrations. Soal tanggalnya, catatan saya sendiri tidak sepakat. Catatan staging menaruh seed itu di 11 September, sedangkan pesan commit guard pada 13 September menyebut baris itu ditinggalkan seed paling pertama pada 10 September. Baris yang disebut keduanya sama.

Baris yang ditulis Payload sendiri

pushDevSchema di @payloadcms/drizzle 3.88.0 tidak berhenti setelah skemanya diterapkan. Bagian ekornya begini:

await apply();
const migrationsTable = adapter.schemaName ? `"${adapter.schemaName}"."payload_migrations"` : '"payload_migrations"';
const drizzle = adapter.drizzle;
const result = await adapter.execute({
    drizzle,
    raw: `SELECT * FROM ${migrationsTable} WHERE batch = '-1'`
});
const devPush = result.rows;
if (!devPush.length) {
    // Use drizzle for insert so $defaultFn's are called
    await drizzle.insert(adapter.tables.payload_migrations).values({
        name: 'dev',
        batch: -1
    });
} else {
    await adapter.execute({
        drizzle,
        raw: `UPDATE ${migrationsTable} SET updated_at = CURRENT_TIMESTAMP WHERE batch = '-1'`
    });
}

Sesudah push diterapkan, fungsi itu mencari baris dengan batch -1. Kalau belum ada, dia menyisipkan baris dev di batch -1. Kalau sudah ada, yang diperbarui cuma updated_at-nya. Jadi baris itu bukan sisa satu run yang salah. Setiap connect dengan push menyala memastikan baris itu ada.

Di database lokal, baris ini normal dan tidak berbahaya. Di database yang dikelola migrasi, dia diam-diam mengubah payload migrate jadi no-op.

Satu prompt, dua kegagalan

payload migrate berikutnya di Vercel melihat baris itu dan berhenti di pertanyaan "It looks like you've run Payload in dev mode... Would you like to proceed?", tanpa ada orang di terminal. Cabang yang memunculkannya ada di migrate.js dari versi yang sama:

if (migrationsInDB.find((m)=>m.batch === -1)) {
    const { confirm: runMigrations } = await prompts({
        name: 'confirm',
        type: 'confirm',
        initial: false,
        message: "It looks like you've run Payload in dev mode, meaning you've dynamically pushed changes to your database.\n\n" + "If you'd like to run migrations, data loss will occur. Would you like to proceed?"
    }, {
        onCancel: ()=>{
            process.exit(0);
        }
    });
    if (!runMigrations) {
        process.exit(0);
    }

Satu baris dengan batch -1 sudah cukup untuk memunculkan prompt ini, dan jawaban awalnya false. Yang ditanyakan adalah apakah migrasi mau dijalankan. Prompt yang dibatalkan dan jawaban yang tidak mengonfirmasi sama-sama berakhir di process.exit(0), kode keluar yang dibaca shell sebagai sukses.

Deployment yang menggantung itu dihapus, dan build 11 September tetap tayang. Marker-nya ditemukan pada 12 September. Saat tabelnya dibaca hari itu, isinya 20260910_163813_initial di batch 1 dan dev di batch -1, dengan 73 tabel dari skema awal.

Kegagalan kedua lebih buruk karena tidak terlihat seperti kegagalan. Pada 13 September 2026 staging menjawab setiap request dengan 500 sementara build-nya hijau, dan penyebabnya baris dev di batch -1 itu. Sembilan migrasi tidak pernah sampai ke database, deployment tetap tayang, dan query pertama mati di tabel yang belum pernah dibuat. Tidak ada yang tahu sampai seseorang membuka halamannya. Catatan staging saya merangkumnya dalam satu kalimat: build hijau bukan berarti database sudah termigrasi.

Catatan saya tidak merekam bagaimana prompt itu berakhir di build tersebut, dan tidak juga menjelaskan kenapa deploy sebelumnya menggantung sementara yang ini lanjut. Warna hijaunya sendiri bisa dibaca dari build command. Waktu itu bunyinya masih pnpm payload migrate && pnpm build, dan && hanya menjalankan perintah kedua kalau perintah pertama keluar dengan 0. pnpm build jalan dan deployment-nya tayang, berarti migrate keluar dengan 0 tanpa menerapkan satu migrasi pun. Kode di atas menyediakan jalan untuk itu: jawaban apa pun selain ya berakhir di process.exit(0).

Marker dihapus, push mengikuti alamat database

Perbaikan pada 13 September sendiri tidak istimewa. Baris marker dihapus, sembilan migrasi yang tertunda diterapkan, dan staging hidup lagi. Satu migrasi awal yang sudah tercatat ditambah sembilan itu, 1 + 9 = 10, cocok dengan sepuluh dari sepuluh yang kemudian dilaporkan guard di staging.

Prompt tadi sendiri memperingatkan bahwa menjalankan migrasi akan menimbulkan kehilangan data. Di kasus ini tabelnya masih memuat skema awal saat dibaca pada 12 September, tapi kalau database-mu pernah di-push dengan skema yang sudah menyimpang dari berkas migrasimu, peringatan itu layak dibaca serius sebelum marker-nya dihapus.

Penyebab di hulunya sudah ditutup malam sebelumnya, 12 September pukul 23:41, lewat commit berjudul "fix(db): schema push only for a local database". Push sekarang mengikuti alamat database:

const databaseUrl = process.env.DATABASE_URL || ''
const localDatabase = /@(localhost|127\.0\.0\.1)[:/]/.test(databaseUrl)
 
    push: process.env.PAYLOAD_DB_PUSH === '1' || (process.env.PAYLOAD_DB_PUSH !== '0' && localDatabase),

Alamat yang memuat localhost atau 127.0.0.1 tetap di-push seperti biasa. Alamat lain mendapat push: false, dan syarat this.push !== false di adapter tadi melewatkan push sepenuhnya, apa pun nilai NODE_ENV. PAYLOAD_DB_PUSH yang diisi 0 atau 1 menimpa keputusan itu untuk satu run.

Commit itu mengatur kapan push boleh jalan, tapi tidak menghapus baris yang sudah telanjur tertulis. Marker di Neon tetap menjadi penyebab 500 keesokan harinya, dan baru hilang sebagai bagian dari perbaikan.

Guard di antara migrate dan build

Hari yang sama, build command mendapat satu langkah di tengahnya lewat commit berjudul "fix(deploy): a build whose database is behind its code now fails":

-  "buildCommand": "pnpm payload migrate && pnpm build",
+  "buildCommand": "pnpm payload migrate && PAYLOAD_DB_PUSH=0 pnpm payload run scripts/check-migrations.ts && pnpm build",

scripts/check-migrations.ts mengambil berkas di src/migrations yang namanya cocok dengan pola ^\d{8}_\d{6}_.+\.ts$, membaca baris payload-migrations lewat Local API, lalu mencetak jumlah migrasi di disk dan jumlah yang tercatat di database. Migrasi yang ada di disk tapi tidak tercatat disebut satu per satu, dengan keterangan bahwa deployment akan menjawab 500, lalu build gagal dengan kode 1. Baris dengan batch negatif diperlakukan sama kerasnya:

if (marker.length > 0) {
  console.error(`\nThe database carries ${marker.length} development marker row(s): ${marker.map((r) => r.name).join(', ')}.`)
  console.error('While one of these is present Payload treats the schema as push managed and applies no migrations.')
  console.error('Delete it, then deploy again:  delete from payload_migrations where batch < 0;')
  process.exit(1)
}

Pengujian guard ini juga yang memperlihatkan sifat marker tadi. Waktu diuji ke database scratch, guard menyala pada database yang sebenarnya sehat. Database yang dikelola push memang selalu membawa baris dev, jadi guard tidak mau menilai database seperti itu. Dia membaca saklar yang sama dengan config, PAYLOAD_DB_PUSH yang menyala secara bawaan untuk alamat lokal, dan kalau push menyala untuk database itu, dia keluar dengan 0 tanpa memeriksa apa pun. Build command mengunci PAYLOAD_DB_PUSH=0 supaya kasus deploy tidak pernah ambigu.

Pesan commit guard mencatat empat jalur yang dicoba ke database scratch berisi kesepuluh migrasi: database lengkap lolos, marker yang ditanam gagal dan disebut namanya, baris migrasi yang dihapus gagal dan disebut namanya, dan database yang dikelola push dibiarkan. Sesudah itu guard dijalankan ke staging dan melaporkan sepuluh dari sepuluh tercatat. Pada akhir 13 September semuanya sudah di origin/main, dengan guard migrasi sebagai ujungnya.

Push berikutnya, dengan Neon dibaca lebih dulu

Skrip seed untuk staging, tools/seed-staging.sh, mengekspor PAYLOAD_DB_PUSH=0, dan sebelum menjalankan perintahnya dia mencetak tiga baris terakhir payload_migrations di Neon dalam bentuk nama@batch.

Catatan saya merekam pembacaan Neon sebelum push pada 14, 18, 22, 24, dan 26 September. Pada 14 September malam tidak ada baris marker dev, lalu push 23 commit memicu deployment lewat integrasi GitHub yang Ready sekitar sepuluh menit kemudian. Build command menerapkan empat migrasi baru sebagai batch 3, guard meloloskannya, dan deployment lama tetap menjawab 200 sepanjang waktu itu.

Angka-angka di push berikutnya cocok satu sama lain. Pada 18 September Neon tercatat tanpa marker dengan 16 dari 27 migrasi, dan build menerapkan sebelas sisanya: 16 + 11 = 27. Pada 22 September tiga migrasi membawanya dari 27 ke 30, dan pada 24 September sembilan migrasi masuk sebagai batch 7, dari 30 ke 39. Di ketiga push itu, migrasinya lebih dulu diterapkan ke database scratch kosong, dan pembacaan pra-push siang 26 September juga tidak menemukan marker dev.

Di laptop perilakunya terbalik, dan itu memang benar. Pada 21 September payload migrate di lokal menggantung di prompt marker dev, sebagaimana mestinya, karena skema lokal berasal dari push.

Menjalankan migrasi sebelum build juga punya harga: database sudah berubah sebelum ada kepastian build-nya lolos. Pada push pagi 15 September, deployment pertama gagal di type check sesudah dua migrasi diterapkan sebagai batch 4, dan baru dua deployment berikutnya yang Ready. Aturan yang saya catat pada 26 September menjawab celah itu: sebelum push, jalankan pnpm build di lokal dengan dev server dimatikan, karena Vercel memigrasi Neon sebelum build, dan build yang gagal akan meninggalkan kode lama di atas skema baru. Push hari itu baru dilakukan sesudah build lokal, 173 tes, lint tanpa error, gladi ke-48 migrasi di database kosong yang skemanya sama dengan skema lokal hasil push (1275 kolom, 83 enum, 756 indeks), dan pembacaan Neon.

Ada satu hal yang belum bisa saya klaim. Catatan saya tidak memuat satu pun build sungguhan yang digagalkan guard ini sejak dipasang. Jalur gagalnya baru terbukti di database scratch, dan sejauh yang tercatat, dia baru meloloskan push yang migrasinya memang lengkap.

Build hijau bukan database yang termigrasi

Kalau build command-mu menjalankan payload migrate di depan build, kode keluar 0 dari langkah itu cuma berarti prosesnya berakhir tanpa melaporkan error. Di 3.88.0, prompt dev mode yang tidak dijawab ya juga keluar dengan 0. Yang benar-benar menjawab apakah database sudah mengikuti kode adalah perbandingan berkas migrasi dengan baris di payload_migrations, dan perbandingan itu harus diberi hak untuk menggagalkan build.

Baris dev di batch -1 pada database lokal bukan kerusakan, melainkan jejak bahwa sesuatu pernah connect dengan push menyala. Di database hosted, jejak yang sama menandakan ada proses yang pernah tersambung ke sana dengan push menyala. Membiarkan alamat database yang memutuskan push menutup jalan itu, tapi marker yang sudah telanjur tertulis tetap tinggal sampai dihapus, dan selama itu payload migrate di mesin build akan terus berhenti di pertanyaan yang sama.