← Semua pembelajaran / Astro Nol → Portal Berita
Fase 2 · MySQL dengan Kysely

Membaca skema CI3 dengan kysely-codegen

Kamu tidak menulis tipe skema dengan tangan. Kamu membacanya dari database yang sudah ada — lalu bersiap untuk apa yang sebenarnya ada di sana.

Sumber asli kysely.dev Resmi Rangkuman ~9 menit baca

Intisari

  • kysely-codegen menyambung ke database dan menulis db-types.ts. Berkas itu tidak pernah disunting tangan.
  • Jalankan lagi setiap skema berubah, dan commit hasilnya — CI tidak boleh perlu akses ke database produksi untuk membangun.
  • tinyint(1) jadi number, bukan boolean. MySQL tidak punya boolean sungguhan.
  • Kolom int berisi timestamp Unix — pola khas CI3 — akan bertipe number, dan compiler tidak akan menolongmu.
  • Skema besar menghasilkan berkas besar; saring dengan --include-pattern supaya editor tetap responsif.

Menjalankannya

pnpm add -D kysely-codegen

# Jalankan terhadap SALINAN, bukan produksi
DATABASE_URL="mysql://user:sandi@localhost:3306/portal_dev" \
  pnpm kysely-codegen --dialect mysql --out-file src/lib/db-types.ts
{
  "scripts": {
    "db:types": "kysely-codegen --dialect mysql --out-file src/lib/db-types.ts"
  }
}

Jalankan terhadap salinan skema, bukan produksi. Introspeksi memang hanya membaca, tapi kredensial produksi tidak seharusnya ada di laptop siapa pun. Ambil skemanya sekali dengan mysqldump --no-data, muat ke MySQL lokal, dan jalankan codegen di sana. Bonusnya: kamu punya skema yang bisa di-commit dan bisa direview.

mysqldump --no-data --single-transaction --routines=false \
  -h $PROD_HOST -u readonly -p portal > db/skema.sql

mysql -h localhost -u root -p portal_dev < db/skema.sql

Yang dihasilkannya

// src/lib/db-types.ts — DIGENERATE. Jangan disunting.
import type { ColumnType } from "kysely";

export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
  ? ColumnType<S, I | undefined, U>
  : ColumnType<T, T | undefined, T>;

export interface Artikel {
  id: Generated<number>;
  judul: string;
  slug: string;
  isi: string;
  ringkasan: string | null;
  kategori_id: number | null;
  penulis_id: number;
  status: string;
  premium: number;              // tinyint(1) — BUKAN boolean
  hits: Generated<number>;
  terbit_pada: Date | null;
  created_at: number;           // int(11) berisi timestamp Unix — khas CI3
}

export interface DB {
  artikel: Artikel;
  kategori: Kategori;
  member: Member;
  // …
}

Generated<T> menandai kolom yang diisi database sendiri — AUTO_INCREMENT atau kolom ber-DEFAULT. Kysely memakainya untuk membuat kolom itu opsional saat insert tapi tetap ada saat select. Ini yang membuat insertInto tidak menuntutmu mengisi id.

Lima kejutan di skema CI3

1. tinyint(1) bukan boolean

// Yang dihasilkan codegen:
premium: number;   // 0 atau 1

// Ini SELALU true — 0 adalah number, dan objeknya truthy
if (artikel.premium) { … }        // BENAR sebenarnya, 0 falsy
if (artikel.premium !== null) { … }  // SALAH — 0 lolos

Bungkus di batas: fungsi di src/lib/ mengembalikan bentuk domain yang sudah bersih, bukan baris mentah.

// src/lib/artikel.ts
function keDomain(baris: Selectable<Artikel>) {
  return {
    id: baris.id,
    judul: baris.judul,
    premium: baris.premium === 1,                    // jadi boolean sungguhan
    dibuatPada: new Date(baris.created_at * 1000),   // Unix → Date
    terbitPada: baris.terbit_pada,
  };
}

Inilah alasan aturan "hanya src/lib/ yang menyentuh db" itu ada. Konversi seperti ini terjadi di satu tempat. Kalau halaman menyusun kueri sendiri, tiap halaman harus ingat mengalikan created_at dengan 1000 — dan yang lupa akan menampilkan tanggal di tahun 1970 tanpa siapa pun menyadarinya sampai pembaca melapor.

2. int berisi timestamp Unix

CI3 sering menyimpan waktu sebagai integer detik. Codegen menghasilkan number, dan compiler tidak tahu bedanya dengan angka lain. Satu-satunya pertahanan adalah lapisan konversi di atas — dan menamai fieldnya dengan jelas di tipe domainmu.

3. Kolom DATETIME tanpa zona waktu

// mysql2 menafsirkan DATETIME memakai zona waktu koneksi.
// Kalau tidak diset, ia memakai zona waktu SERVER — yang berbeda
// antara laptopmu (WIB) dan container ECS (UTC).
createPool({ timezone: "Z" });   // paksa UTC di mana pun

Tanpa ini, artikel yang terbit jam 07.00 WIB akan tampil jam 07.00 di laptopmu dan jam 14.00 di produksi — atau sebaliknya. Untuk portal berita yang menampilkan "2 jam lalu", ini bug yang langsung terlihat pembaca.

4. Kolom enum jadi union, dan itu bagus

status: "draf" | "tinjau" | "terbit" | "arsip";

Kalau kolom statusmu di MySQL bertipe ENUM, kamu mendapat union literal gratis — persis konstruksi yang dibahas di Fase 0. Kalau bertipe VARCHAR, kamu hanya mendapat string. Kalau ada kesempatan mengubah satu kolom saja di skema warisanmu, jadikan ini kandidat pertama.

5. Skema besar bikin editor lambat

kysely-codegen --dialect mysql \
  --include-pattern "(artikel|kategori|member|langganan|perusahaan|tag)%" \
  --out-file src/lib/db-types.ts

Portal berita berumur biasanya punya tabel yang tidak lagi dipakai: ci_sessions, tabel cache lama, tabel log, sisa fitur yang mati. Menyaringnya membuat berkas tipe lebih kecil, editor lebih responsif, dan — yang lebih penting — tabel yang tidak seharusnya disentuh Astro jadi tidak bisa disentuh sama sekali. Itu pagar yang bagus.

Menjaganya tetap benar

# .github/workflows/ci.yml — potongan
- name: Pastikan tipe database mutakhir
  run: |
    mysql -h 127.0.0.1 -u root portal_dev < db/skema.sql
    pnpm db:types
    git diff --exit-code src/lib/db-types.ts

Kalau seseorang mengubah db/skema.sql tanpa menjalankan ulang codegen, CI gagal. Tanpa pagar ini, tipe dan skema akan menyimpang perlahan sampai tidak ada yang mempercayai tipenya lagi — dan pada titik itu seluruh keuntungan Kysely hilang.

Selectable, Insertable, Updateable

import type { Selectable, Insertable, Updateable } from "kysely";
import type { Artikel } from "./db-types";

type BarisArtikel = Selectable<Artikel>;   // hasil SELECT: id ada, tipe nyata
type ArtikelBaru = Insertable<Artikel>;    // untuk INSERT: id opsional
type UbahArtikel = Updateable<Artikel>;    // untuk UPDATE: semua opsional

Tiga tipe dari satu definisi tabel. Ini yang membuatmu tidak perlu menulis DTO terpisah untuk tiap operasi — dan yang membuat fungsi simpanArtikel(data: Insertable<Artikel>) menolak objek yang kelebihan atau kekurangan field.

Latihan: ambil skema database CI3-mu dengan mysqldump --no-data, muat ke MySQL lokal, jalankan kysely-codegen, dan buka hasilnya. Cari dan catat: berapa kolom tinyint(1) yang sebenarnya boolean, berapa kolom int yang sebenarnya waktu, dan berapa tabel yang sudah tidak dipakai lagi. Tiga daftar itu adalah pekerjaan rumah migrasimu yang sesungguhnya.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.