REST API Tiket Kereta dengan Elysia JS, Supabase & Docker

Belajar membuat REST API tiket kereta menggunakan ElysiaJS, Supabase sebagai database, dan Docker untuk containerization. Tutorial ini menggunakan pendekatan MVP (Minimum Viable Product) lengkap dengan load testing menggunakan Bombardier untuk memastikan API siap production.

Bagian 1: Pengantar MVP & Tech Stack

Saya Angga Risky Setiawan, Founder dan CEO BuildWithAngga. Di tutorial ini kita akan bangun sistem booking tiket kereta dari nol — tapi dengan mindset MVP. Artinya, kita fokus ke fitur inti yang benar-benar dibutuhkan, bukan fitur "nice to have".

Apa itu MVP (Minimum Viable Product)?

MVP adalah versi paling sederhana dari produk yang masih bisa memberikan value ke user. Konsepnya:

Fokus ke Core Features Fitur yang tanpanya produk tidak bisa digunakan sama sekali.

Validate First, Scale Later Tujuan MVP adalah membuktikan ide works, bukan membangun produk sempurna.

Iterate Based on Feedback Setelah MVP live, kumpulkan feedback dan improve.

Banyak developer terjebak membangun fitur lengkap dari awal — payment gateway, seat selection, loyalty points, promo codes — lalu kehabisan waktu atau budget sebelum produk launch. MVP menghindari ini.

Fitur MVP Tiket Kereta

Untuk sistem tiket kereta, ini fitur yang benar-benar essential:

Yang Kita Bangun (MVP):

  • Stations — Data stasiun kereta
  • Schedules — Jadwal kereta (rute, waktu, harga, seat available)
  • Bookings — Pemesanan tiket dengan data penumpang

Yang TIDAK Kita Bangun (Future Features):

  • ❌ Payment gateway integration
  • ❌ Seat selection (pilih kursi spesifik)
  • ❌ User authentication & accounts
  • ❌ Loyalty points & membership
  • ❌ Promo codes & discounts
  • ❌ E-ticket dengan QR code
  • ❌ Notification (email/SMS)

Fitur-fitur di atas penting, tapi bukan untuk MVP. Kita bisa tambahkan setelah core system proven works.

Tech Stack

TechnologyPurposeWhy Choose
ElysiaJSBackend frameworkFast, type-safe, Bun-optimized
SupabaseDatabaseFree PostgreSQL, easy setup, realtime
DockerContainerizationConsistent environment across machines
BombardierLoad testingValidate performance before production
BunRuntimeFaster than Node.js

Kenapa kombinasi ini?

ElysiaJS memberikan developer experience yang excellent dengan built-in validation dan type safety. Performance-nya juga top tier karena optimized untuk Bun.

Supabase adalah PostgreSQL yang sudah di-manage. Tidak perlu setup database server sendiri, tinggal pakai. Free tier-nya generous — 500MB database, 1GB storage.

Docker memastikan "works on my machine" tidak jadi masalah. Environment di laptop kamu = environment di server production.

Bombardier untuk load testing. Sebelum deploy, kita perlu tahu API kita bisa handle berapa request per second.

Architecture Overview

┌─────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Client    │────▶│  ElysiaJS API   │────▶│    Supabase     │
│ (Postman/   │     │  (Docker)       │     │   PostgreSQL    │
│  Frontend)  │     └─────────────────┘     └─────────────────┘
└─────────────┘              │
                             ▼
                    ┌─────────────────┐
                    │   Bombardier    │
                    │  (Load Test)    │
                    └─────────────────┘

Flow sederhana:

  1. Client hit API endpoint
  2. ElysiaJS process request, validate input
  3. Query ke Supabase (PostgreSQL)
  4. Return response ke client

Docker membungkus ElysiaJS API supaya bisa dijalankan di mana saja dengan environment yang sama.

Endpoint yang Akan Dibuat

CategoryEndpointsDescription
Stations2List & detail stasiun
Schedules2Search & detail jadwal
Bookings4Create, view, confirm, cancel
Health1Health check

Total 9 endpoints untuk MVP yang fully functional.

Expected Output

Setelah selesai tutorial ini, kamu akan punya:

# API running di Docker
<http://localhost:3000>

# Swagger documentation
<http://localhost:3000/docs>

# Sample request
curl <http://localhost:3000/schedules?from=GMR&to=BD>

Response:

{
  "data": [
    {
      "id": "uuid-here",
      "train_name": "Argo Parahyangan",
      "departure_time": "06:00",
      "arrival_time": "09:00",
      "price": 150000,
      "available_seats": 50,
      "origin": { "code": "GMR", "name": "Gambir", "city": "Jakarta" },
      "destination": { "code": "BD", "name": "Bandung", "city": "Bandung" }
    }
  ]
}

💡 Mini Tips: MVP bukan tentang "quick and dirty code". Tetap tulis clean code dengan proper validation dan error handling — tapi batasi scope fitur. Technical debt di MVP akan sangat mahal dibayar nanti ketika scaling.

Di bagian selanjutnya, kita akan setup Supabase dan buat database schema.

Bagian 2: Setup Supabase & Database Schema

Supabase adalah "Firebase alternative" yang menggunakan PostgreSQL. Kita dapat database, authentication, storage, dan realtime subscriptions — semua gratis untuk tier awal.

Create Supabase Project

  1. Buka supabase.com dan sign up/login
  2. Create New Project
    • Organization: Pilih atau buat baru
    • Project name: tiket-kereta
    • Database password: Generate strong password (simpan!)
    • Region: Singapore (terdekat untuk Indonesia)
  3. Tunggu provisioning (~2 menit)
  4. Copy credentials dari Project Settings → API:
    • Project URL: https://xxxxx.supabase.co
    • Service Role Key: eyJhbGc... (secret, jangan expose ke frontend!)

Database Schema

Buka SQL Editor di Supabase dashboard dan jalankan query berikut:

-- =============================================
-- STATIONS TABLE
-- =============================================
CREATE TABLE stations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  code VARCHAR(5) UNIQUE NOT NULL,      -- "GMR", "BD", "YK"
  name VARCHAR(100) NOT NULL,            -- "Gambir", "Bandung"
  city VARCHAR(50) NOT NULL,             -- "Jakarta", "Bandung"
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index untuk pencarian by code
CREATE INDEX idx_stations_code ON stations(code);

-- =============================================
-- SCHEDULES TABLE
-- =============================================
CREATE TABLE schedules (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  train_name VARCHAR(100) NOT NULL,      -- "Argo Parahyangan"
  train_number VARCHAR(20) NOT NULL,     -- "77A"
  origin_id UUID NOT NULL REFERENCES stations(id),
  destination_id UUID NOT NULL REFERENCES stations(id),
  departure_time TIME NOT NULL,
  arrival_time TIME NOT NULL,
  price INTEGER NOT NULL,                -- dalam Rupiah
  available_seats INTEGER NOT NULL,
  class VARCHAR(20) NOT NULL,            -- "eksekutif", "bisnis", "ekonomi"
  created_at TIMESTAMPTZ DEFAULT NOW(),

  -- Constraint: origin != destination
  CONSTRAINT different_stations CHECK (origin_id != destination_id)
);

-- Indexes untuk filter
CREATE INDEX idx_schedules_origin ON schedules(origin_id);
CREATE INDEX idx_schedules_destination ON schedules(destination_id);
CREATE INDEX idx_schedules_class ON schedules(class);

-- =============================================
-- BOOKINGS TABLE
-- =============================================
CREATE TABLE bookings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  booking_code VARCHAR(10) UNIQUE NOT NULL,   -- "TKT-ABC123"
  schedule_id UUID NOT NULL REFERENCES schedules(id),
  passenger_name VARCHAR(100) NOT NULL,
  passenger_id_number VARCHAR(16) NOT NULL,   -- NIK (16 digit)
  passenger_phone VARCHAR(15) NOT NULL,
  seat_count INTEGER NOT NULL DEFAULT 1,
  total_price INTEGER NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),

  -- Constraint: valid status
  CONSTRAINT valid_status CHECK (status IN ('pending', 'confirmed', 'cancelled')),
  -- Constraint: seat count
  CONSTRAINT valid_seat_count CHECK (seat_count BETWEEN 1 AND 4)
);

-- Index untuk lookup by booking code
CREATE INDEX idx_bookings_code ON bookings(booking_code);
CREATE INDEX idx_bookings_status ON bookings(status);

Insert Sample Data

-- =============================================
-- SAMPLE STATIONS
-- =============================================
INSERT INTO stations (code, name, city) VALUES
  ('GMR', 'Gambir', 'Jakarta'),
  ('BD', 'Bandung', 'Bandung'),
  ('YK', 'Tugu Yogyakarta', 'Yogyakarta'),
  ('SB', 'Surabaya Gubeng', 'Surabaya'),
  ('SM', 'Semarang Tawang', 'Semarang'),
  ('CRB', 'Cirebon', 'Cirebon'),
  ('PSE', 'Pasar Senen', 'Jakarta'),
  ('MLG', 'Malang', 'Malang');

-- =============================================
-- SAMPLE SCHEDULES
-- =============================================
INSERT INTO schedules (train_name, train_number, origin_id, destination_id, departure_time, arrival_time, price, available_seats, class) VALUES
  -- Jakarta - Bandung
  ('Argo Parahyangan', '77A',
   (SELECT id FROM stations WHERE code='GMR'),
   (SELECT id FROM stations WHERE code='BD'),
   '06:00', '09:00', 150000, 50, 'eksekutif'),

  ('Argo Parahyangan', '79A',
   (SELECT id FROM stations WHERE code='GMR'),
   (SELECT id FROM stations WHERE code='BD'),
   '12:00', '15:00', 150000, 50, 'eksekutif'),

  ('Pangandaran', '157',
   (SELECT id FROM stations WHERE code='GMR'),
   (SELECT id FROM stations WHERE code='BD'),
   '08:00', '11:30', 80000, 80, 'ekonomi'),

  -- Jakarta - Yogyakarta
  ('Taksaka', '33',
   (SELECT id FROM stations WHERE code='GMR'),
   (SELECT id FROM stations WHERE code='YK'),
   '07:30', '15:30', 350000, 60, 'bisnis'),

  ('Taksaka', '35',
   (SELECT id FROM stations WHERE code='GMR'),
   (SELECT id FROM stations WHERE code='YK'),
   '20:00', '04:00', 350000, 60, 'bisnis'),

  -- Jakarta - Surabaya
  ('Argo Wilis', '15',
   (SELECT id FROM stations WHERE code='GMR'),
   (SELECT id FROM stations WHERE code='SB'),
   '08:00', '18:00', 450000, 40, 'eksekutif'),

  ('Sembrani', '85',
   (SELECT id FROM stations WHERE code='PSE'),
   (SELECT id FROM stations WHERE code='SB'),
   '16:00', '04:30', 280000, 100, 'ekonomi'),

  -- Bandung - Yogyakarta
  ('Lodaya', '17',
   (SELECT id FROM stations WHERE code='BD'),
   (SELECT id FROM stations WHERE code='YK'),
   '06:30', '14:00', 200000, 70, 'bisnis');

Entity Relationship

stations
  ├── id (PK)
  ├── code (unique)
  ├── name
  └── city
       │
       ▼
schedules
  ├── id (PK)
  ├── train_name
  ├── origin_id (FK → stations.id)
  ├── destination_id (FK → stations.id)
  ├── departure_time
  ├── arrival_time
  ├── price
  ├── available_seats
  └── class
       │
       ▼
bookings
  ├── id (PK)
  ├── booking_code (unique)
  ├── schedule_id (FK → schedules.id)
  ├── passenger_name
  ├── passenger_id_number
  ├── passenger_phone
  ├── seat_count
  ├── total_price
  └── status

Setup Project & Supabase Client

# Create project
bun create elysia tiket-kereta
cd tiket-kereta

# Install dependencies
bun add @supabase/supabase-js @elysiajs/cors @elysiajs/swagger

Buat file .env:

# .env
SUPABASE_URL=https://xxxxx.supabase.co
SUPABASE_SERVICE_ROLE=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Buat Supabase client:

// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

const { SUPABASE_URL, SUPABASE_SERVICE_ROLE } = process.env

if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE) {
  throw new Error('Missing Supabase environment variables')
}

export const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE)

Project Structure

tiket-kereta/
├── src/
│   ├── index.ts              # Entry point
│   ├── lib/
│   │   └── supabase.ts       # Supabase client
│   └── routes/
│       ├── stations.ts       # Station endpoints
│       ├── schedules.ts      # Schedule endpoints
│       └── bookings.ts       # Booking endpoints
├── Dockerfile
├── docker-compose.yml
├── .env
├── .dockerignore
├── package.json
└── tsconfig.json

Verify Database

Cek data sudah masuk dengan query di Supabase SQL Editor:

-- Check stations
SELECT * FROM stations ORDER BY name;

-- Check schedules with joins
SELECT
  s.train_name,
  s.train_number,
  o.name as origin,
  d.name as destination,
  s.departure_time,
  s.arrival_time,
  s.price,
  s.class
FROM schedules s
JOIN stations o ON s.origin_id = o.id
JOIN stations d ON s.destination_id = d.id
ORDER BY s.departure_time;

Harusnya ada 8 stasiun dan 8 jadwal kereta.

💡 Mini Tips: Gunakan service_role key untuk backend karena bypass Row Level Security (RLS). Untuk client-side apps (frontend), gunakan anon key dan enable RLS untuk security. Jangan pernah expose service_role ke frontend!

Di bagian selanjutnya, kita akan implementasi routes untuk Stations dan Schedules.

Bagian 3: Routes — Stations & Schedules

Sekarang kita implementasi routes untuk Stations dan Schedules. Kedua route ini read-only karena data stasiun dan jadwal biasanya dikelola oleh admin, bukan user biasa.

Stations Routes

// src/routes/stations.ts
import { Elysia, t } from 'elysia'
import { supabase } from '../lib/supabase'

export const stationRoutes = new Elysia({ prefix: '/stations' })

  // ============================================
  // GET /stations - List semua stasiun
  // ============================================
  .get('/', async ({ query }) => {
    let queryBuilder = supabase
      .from('stations')
      .select('*')

    // Search by name or city
    if (query.search) {
      queryBuilder = queryBuilder.or(
        `name.ilike.%${query.search}%,city.ilike.%${query.search}%`
      )
    }

    const { data, error } = await queryBuilder.order('name')

    if (error) {
      throw new Error(error.message)
    }

    return {
      data,
      total: data?.length || 0
    }
  }, {
    query: t.Object({
      search: t.Optional(t.String())
    }),
    detail: {
      tags: ['Stations'],
      summary: 'Get all stations',
      description: 'List semua stasiun. Bisa filter dengan search by name atau city.'
    }
  })

  // ============================================
  // GET /stations/:code - Detail stasiun by code
  // ============================================
  .get('/:code', async ({ params, set }) => {
    const { data, error } = await supabase
      .from('stations')
      .select('*')
      .eq('code', params.code.toUpperCase())
      .single()

    if (error || !data) {
      set.status = 404
      return {
        error: 'NOT_FOUND',
        message: `Stasiun dengan kode ${params.code.toUpperCase()} tidak ditemukan`
      }
    }

    return { data }
  }, {
    params: t.Object({
      code: t.String({ minLength: 2, maxLength: 5 })
    }),
    detail: {
      tags: ['Stations'],
      summary: 'Get station by code',
      description: 'Detail stasiun berdasarkan kode (contoh: GMR, BD, YK)'
    }
  })

Schedules Routes

// src/routes/schedules.ts
import { Elysia, t } from 'elysia'
import { supabase } from '../lib/supabase'

export const scheduleRoutes = new Elysia({ prefix: '/schedules' })

  // ============================================
  // GET /schedules - Search jadwal kereta
  // ============================================
  .get('/', async ({ query, set }) => {
    // Base query dengan JOIN ke stations
    let queryBuilder = supabase
      .from('schedules')
      .select(`
        id,
        train_name,
        train_number,
        departure_time,
        arrival_time,
        price,
        available_seats,
        class,
        origin:stations!origin_id(id, code, name, city),
        destination:stations!destination_id(id, code, name, city)
      `)

    // Filter by origin station code
    if (query.from) {
      const { data: originStation } = await supabase
        .from('stations')
        .select('id')
        .eq('code', query.from.toUpperCase())
        .single()

      if (!originStation) {
        set.status = 400
        return {
          error: 'INVALID_ORIGIN',
          message: `Stasiun asal ${query.from.toUpperCase()} tidak ditemukan`
        }
      }

      queryBuilder = queryBuilder.eq('origin_id', originStation.id)
    }

    // Filter by destination station code
    if (query.to) {
      const { data: destStation } = await supabase
        .from('stations')
        .select('id')
        .eq('code', query.to.toUpperCase())
        .single()

      if (!destStation) {
        set.status = 400
        return {
          error: 'INVALID_DESTINATION',
          message: `Stasiun tujuan ${query.to.toUpperCase()} tidak ditemukan`
        }
      }

      queryBuilder = queryBuilder.eq('destination_id', destStation.id)
    }

    // Filter by class
    if (query.class) {
      queryBuilder = queryBuilder.eq('class', query.class.toLowerCase())
    }

    // Filter by available seats
    if (query.minSeats) {
      queryBuilder = queryBuilder.gte('available_seats', Number(query.minSeats))
    }

    // Filter by max price
    if (query.maxPrice) {
      queryBuilder = queryBuilder.lte('price', Number(query.maxPrice))
    }

    const { data, error } = await queryBuilder.order('departure_time')

    if (error) {
      throw new Error(error.message)
    }

    return {
      data,
      total: data?.length || 0,
      filters: {
        from: query.from?.toUpperCase() || null,
        to: query.to?.toUpperCase() || null,
        class: query.class || null
      }
    }
  }, {
    query: t.Object({
      from: t.Optional(t.String()),
      to: t.Optional(t.String()),
      class: t.Optional(t.Union([
        t.Literal('eksekutif'),
        t.Literal('bisnis'),
        t.Literal('ekonomi')
      ])),
      minSeats: t.Optional(t.String()),
      maxPrice: t.Optional(t.String())
    }),
    detail: {
      tags: ['Schedules'],
      summary: 'Search train schedules',
      description: 'Cari jadwal kereta. Filter by: from (kode stasiun asal), to (kode stasiun tujuan), class, minSeats, maxPrice'
    }
  })

  // ============================================
  // GET /schedules/:id - Detail jadwal
  // ============================================
  .get('/:id', async ({ params, set }) => {
    const { data, error } = await supabase
      .from('schedules')
      .select(`
        id,
        train_name,
        train_number,
        departure_time,
        arrival_time,
        price,
        available_seats,
        class,
        created_at,
        origin:stations!origin_id(id, code, name, city),
        destination:stations!destination_id(id, code, name, city)
      `)
      .eq('id', params.id)
      .single()

    if (error || !data) {
      set.status = 404
      return {
        error: 'NOT_FOUND',
        message: 'Jadwal tidak ditemukan'
      }
    }

    // Calculate duration
    const [depHour, depMin] = data.departure_time.split(':').map(Number)
    const [arrHour, arrMin] = data.arrival_time.split(':').map(Number)

    let durationMinutes = (arrHour * 60 + arrMin) - (depHour * 60 + depMin)
    if (durationMinutes < 0) durationMinutes += 24 * 60 // Handle overnight

    const durationHours = Math.floor(durationMinutes / 60)
    const durationMins = durationMinutes % 60

    return {
      data: {
        ...data,
        duration: `${durationHours}j ${durationMins}m`,
        duration_minutes: durationMinutes
      }
    }
  }, {
    params: t.Object({
      id: t.String({ format: 'uuid' })
    }),
    detail: {
      tags: ['Schedules'],
      summary: 'Get schedule by ID',
      description: 'Detail jadwal kereta dengan informasi durasi perjalanan'
    }
  })

Endpoints Summary

MethodEndpointDescriptionFilters
GET/stationsList stasiunsearch
GET/stations/:codeDetail stasiun-
GET/schedulesSearch jadwalfrom, to, class, minSeats, maxPrice
GET/schedules/:idDetail jadwal-

Test dengan cURL

# List semua stasiun
curl <http://localhost:3000/stations> | jq

# Search stasiun
curl "<http://localhost:3000/stations?search=jakarta>" | jq

# Detail stasiun
curl <http://localhost:3000/stations/GMR> | jq

# Search jadwal Jakarta-Bandung
curl "<http://localhost:3000/schedules?from=GMR&to=BD>" | jq

# Search jadwal kelas eksekutif
curl "<http://localhost:3000/schedules?class=eksekutif>" | jq

# Search dengan multiple filter
curl "<http://localhost:3000/schedules?from=GMR&class=bisnis&maxPrice=400000>" | jq

# Detail jadwal (ganti dengan ID yang valid)
curl <http://localhost:3000/schedules/uuid-jadwal-disini> | jq

Response Examples

GET /stations:

{
  "data": [
    { "id": "uuid", "code": "BD", "name": "Bandung", "city": "Bandung" },
    { "id": "uuid", "code": "GMR", "name": "Gambir", "city": "Jakarta" }
  ],
  "total": 8
}

GET /schedules?from=GMR&to=BD:

{
  "data": [
    {
      "id": "uuid",
      "train_name": "Argo Parahyangan",
      "train_number": "77A",
      "departure_time": "06:00:00",
      "arrival_time": "09:00:00",
      "price": 150000,
      "available_seats": 50,
      "class": "eksekutif",
      "origin": { "code": "GMR", "name": "Gambir", "city": "Jakarta" },
      "destination": { "code": "BD", "name": "Bandung", "city": "Bandung" }
    }
  ],
  "total": 3,
  "filters": { "from": "GMR", "to": "BD", "class": null }
}

💡 Mini Tips: Supabase select() dengan relasi menggunakan syntax table!foreign_key(columns). Ini lebih powerful dari simple JOIN karena hasilnya sudah nested sebagai object, bukan flat columns.

Di bagian selanjutnya, kita implementasi Bookings routes — yang paling complex karena ada business logic validasi seat dan status management.

Bagian 4: Routes — Bookings & Business Logic

Bookings adalah jantung dari sistem tiket kereta. Di sini ada business logic penting: validasi seat availability, generate booking code, kalkulasi harga, dan status management.

Booking Routes Implementation

// src/routes/bookings.ts
import { Elysia, t } from 'elysia'
import { supabase } from '../lib/supabase'

// Generate unique booking code: TKT-XXXXXX
const generateBookingCode = (): string => {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
  let code = 'TKT-'
  for (let i = 0; i < 6; i++) {
    code += chars.charAt(Math.floor(Math.random() * chars.length))
  }
  return code
}

// Format price to Rupiah
const formatRupiah = (price: number): string => {
  return `Rp ${price.toLocaleString('id-ID')}`
}

export const bookingRoutes = new Elysia({ prefix: '/bookings' })

  // ============================================
  // POST /bookings - Buat booking baru
  // ============================================
  .post('/', async ({ body, set }) => {
    // 1. Get schedule details
    const { data: schedule, error: scheduleError } = await supabase
      .from('schedules')
      .select(`
        *,
        origin:stations!origin_id(code, name, city),
        destination:stations!destination_id(code, name, city)
      `)
      .eq('id', body.schedule_id)
      .single()

    if (scheduleError || !schedule) {
      set.status = 400
      return {
        error: 'SCHEDULE_NOT_FOUND',
        message: 'Jadwal kereta tidak ditemukan'
      }
    }

    // 2. Check seat availability
    if (schedule.available_seats < body.seat_count) {
      set.status = 400
      return {
        error: 'INSUFFICIENT_SEATS',
        message: `Kursi tidak cukup. Tersedia: ${schedule.available_seats}, diminta: ${body.seat_count}`,
        available_seats: schedule.available_seats
      }
    }

    // 3. Validate NIK (16 digits)
    if (!/^\\d{16}$/.test(body.passenger_id_number)) {
      set.status = 400
      return {
        error: 'INVALID_NIK',
        message: 'NIK harus 16 digit angka'
      }
    }

    // 4. Calculate total price
    const totalPrice = schedule.price * body.seat_count

    // 5. Generate unique booking code
    let bookingCode = generateBookingCode()
    let attempts = 0

    // Ensure unique code (retry if exists)
    while (attempts < 5) {
      const { data: existing } = await supabase
        .from('bookings')
        .select('id')
        .eq('booking_code', bookingCode)
        .single()

      if (!existing) break
      bookingCode = generateBookingCode()
      attempts++
    }

    // 6. Create booking
    const { data: booking, error: bookingError } = await supabase
      .from('bookings')
      .insert({
        booking_code: bookingCode,
        schedule_id: body.schedule_id,
        passenger_name: body.passenger_name.trim(),
        passenger_id_number: body.passenger_id_number,
        passenger_phone: body.passenger_phone,
        seat_count: body.seat_count,
        total_price: totalPrice,
        status: 'pending'
      })
      .select()
      .single()

    if (bookingError) {
      set.status = 500
      return {
        error: 'BOOKING_FAILED',
        message: 'Gagal membuat booking. Silakan coba lagi.'
      }
    }

    // 7. Update available seats
    const { error: updateError } = await supabase
      .from('schedules')
      .update({
        available_seats: schedule.available_seats - body.seat_count
      })
      .eq('id', body.schedule_id)

    if (updateError) {
      // Rollback: delete booking if seat update fails
      await supabase.from('bookings').delete().eq('id', booking.id)
      set.status = 500
      return {
        error: 'BOOKING_FAILED',
        message: 'Gagal mengupdate kursi. Booking dibatalkan.'
      }
    }

    set.status = 201
    return {
      message: 'Booking berhasil dibuat',
      data: {
        booking_code: bookingCode,
        status: 'pending',
        train: {
          name: schedule.train_name,
          number: schedule.train_number,
          class: schedule.class
        },
        route: {
          origin: schedule.origin,
          destination: schedule.destination
        },
        schedule: {
          departure: schedule.departure_time,
          arrival: schedule.arrival_time
        },
        passenger: {
          name: body.passenger_name,
          id_number: body.passenger_id_number,
          phone: body.passenger_phone
        },
        seats: body.seat_count,
        price_per_seat: schedule.price,
        total_price: totalPrice,
        total_price_formatted: formatRupiah(totalPrice),
        created_at: booking.created_at,
        note: 'Silakan konfirmasi pembayaran dalam 1 jam'
      }
    }
  }, {
    body: t.Object({
      schedule_id: t.String({ format: 'uuid' }),
      passenger_name: t.String({ minLength: 3, maxLength: 100 }),
      passenger_id_number: t.String({ minLength: 16, maxLength: 16 }),
      passenger_phone: t.String({ minLength: 10, maxLength: 15 }),
      seat_count: t.Number({ minimum: 1, maximum: 4 })
    }),
    detail: {
      tags: ['Bookings'],
      summary: 'Create new booking',
      description: 'Buat booking baru. Maksimal 4 kursi per booking.'
    }
  })

  // ============================================
  // GET /bookings/:code - Get booking by code
  // ============================================
  .get('/:code', async ({ params, set }) => {
    const { data, error } = await supabase
      .from('bookings')
      .select(`
        *,
        schedule:schedules(
          train_name,
          train_number,
          departure_time,
          arrival_time,
          price,
          class,
          origin:stations!origin_id(code, name, city),
          destination:stations!destination_id(code, name, city)
        )
      `)
      .eq('booking_code', params.code.toUpperCase())
      .single()

    if (error || !data) {
      set.status = 404
      return {
        error: 'NOT_FOUND',
        message: `Booking dengan kode ${params.code.toUpperCase()} tidak ditemukan`
      }
    }

    return {
      data: {
        ...data,
        total_price_formatted: formatRupiah(data.total_price)
      }
    }
  }, {
    params: t.Object({
      code: t.String({ minLength: 10, maxLength: 10 })
    }),
    detail: {
      tags: ['Bookings'],
      summary: 'Get booking by code',
      description: 'Cek status booking dengan kode (contoh: TKT-ABC123)'
    }
  })

  // ============================================
  // PATCH /bookings/:code/confirm - Konfirmasi booking
  // ============================================
  .patch('/:code/confirm', async ({ params, set }) => {
    // Check current status
    const { data: booking } = await supabase
      .from('bookings')
      .select('*')
      .eq('booking_code', params.code.toUpperCase())
      .single()

    if (!booking) {
      set.status = 404
      return {
        error: 'NOT_FOUND',
        message: 'Booking tidak ditemukan'
      }
    }

    if (booking.status === 'confirmed') {
      set.status = 400
      return {
        error: 'ALREADY_CONFIRMED',
        message: 'Booking sudah dikonfirmasi sebelumnya'
      }
    }

    if (booking.status === 'cancelled') {
      set.status = 400
      return {
        error: 'BOOKING_CANCELLED',
        message: 'Tidak bisa konfirmasi booking yang sudah dibatalkan'
      }
    }

    // Update status
    const { data, error } = await supabase
      .from('bookings')
      .update({
        status: 'confirmed',
        updated_at: new Date().toISOString()
      })
      .eq('booking_code', params.code.toUpperCase())
      .select()
      .single()

    if (error) {
      set.status = 500
      return {
        error: 'UPDATE_FAILED',
        message: 'Gagal mengkonfirmasi booking'
      }
    }

    return {
      message: 'Booking berhasil dikonfirmasi',
      data: {
        booking_code: data.booking_code,
        status: data.status,
        confirmed_at: data.updated_at
      }
    }
  }, {
    params: t.Object({ code: t.String() }),
    detail: {
      tags: ['Bookings'],
      summary: 'Confirm booking',
      description: 'Konfirmasi booking setelah pembayaran'
    }
  })

  // ============================================
  // DELETE /bookings/:code - Batalkan booking
  // ============================================
  .delete('/:code', async ({ params, set }) => {
    // Get booking with schedule
    const { data: booking } = await supabase
      .from('bookings')
      .select('*, schedule:schedules(*)')
      .eq('booking_code', params.code.toUpperCase())
      .single()

    if (!booking) {
      set.status = 404
      return {
        error: 'NOT_FOUND',
        message: 'Booking tidak ditemukan'
      }
    }

    if (booking.status === 'cancelled') {
      set.status = 400
      return {
        error: 'ALREADY_CANCELLED',
        message: 'Booking sudah dibatalkan sebelumnya'
      }
    }

    // Update status to cancelled
    const { error: updateError } = await supabase
      .from('bookings')
      .update({
        status: 'cancelled',
        updated_at: new Date().toISOString()
      })
      .eq('booking_code', params.code.toUpperCase())

    if (updateError) {
      set.status = 500
      return { error: 'CANCEL_FAILED', message: 'Gagal membatalkan booking' }
    }

    // Restore seats (only if was not cancelled before)
    if (booking.status !== 'cancelled') {
      await supabase
        .from('schedules')
        .update({
          available_seats: booking.schedule.available_seats + booking.seat_count
        })
        .eq('id', booking.schedule_id)
    }

    return {
      message: 'Booking berhasil dibatalkan',
      data: {
        booking_code: booking.booking_code,
        status: 'cancelled',
        seats_restored: booking.seat_count
      }
    }
  }, {
    params: t.Object({ code: t.String() }),
    detail: {
      tags: ['Bookings'],
      summary: 'Cancel booking',
      description: 'Batalkan booking. Kursi akan dikembalikan ke jadwal.'
    }
  })

Booking Status Flow

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   POST /bookings                                            │
│        │                                                    │
│        ▼                                                    │
│   ┌─────────┐    PATCH /confirm    ┌───────────┐           │
│   │ pending │ ──────────────────▶ │ confirmed │           │
│   └─────────┘                      └───────────┘           │
│        │                                                    │
│        │ DELETE /:code                                      │
│        ▼                                                    │
│   ┌───────────┐                                            │
│   │ cancelled │  ◀── (seats restored)                      │
│   └───────────┘                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Business Logic Summary

ValidationRule
Schedule existsJadwal harus ada di database
Seat availabilityKursi tersedia >= kursi diminta
NIK formatHarus 16 digit angka
Seat limitMaksimal 4 kursi per booking
Unique booking codeGenerate ulang jika sudah ada
Status transitionpending → confirmed / cancelled
Seat restorationKembalikan kursi saat cancel

Test dengan cURL

# 1. Cari jadwal dulu
curl "<http://localhost:3000/schedules?from=GMR&to=BD>" | jq
# Catat schedule ID

# 2. Buat booking
curl -X POST <http://localhost:3000/bookings> \\
  -H "Content-Type: application/json" \\
  -d '{
    "schedule_id": "uuid-schedule-disini",
    "passenger_name": "Budi Santoso",
    "passenger_id_number": "3201234567890001",
    "passenger_phone": "081234567890",
    "seat_count": 2
  }' | jq
# Catat booking_code dari response

# 3. Cek booking
curl <http://localhost:3000/bookings/TKT-XXXXXX> | jq

# 4. Konfirmasi booking
curl -X PATCH <http://localhost:3000/bookings/TKT-XXXXXX/confirm> | jq

# 5. Atau batalkan booking
curl -X DELETE <http://localhost:3000/bookings/TKT-XXXXXX> | jq

# 6. Verifikasi seat dikembalikan
curl "<http://localhost:3000/schedules?from=GMR&to=BD>" | jq

Response Example

POST /bookings:

{
  "message": "Booking berhasil dibuat",
  "data": {
    "booking_code": "TKT-A3B7X9",
    "status": "pending",
    "train": {
      "name": "Argo Parahyangan",
      "number": "77A",
      "class": "eksekutif"
    },
    "route": {
      "origin": { "code": "GMR", "name": "Gambir", "city": "Jakarta" },
      "destination": { "code": "BD", "name": "Bandung", "city": "Bandung" }
    },
    "seats": 2,
    "price_per_seat": 150000,
    "total_price": 300000,
    "total_price_formatted": "Rp 300.000",
    "note": "Silakan konfirmasi pembayaran dalam 1 jam"
  }
}

💡 Mini Tips: Untuk production, gunakan Supabase RPC (stored procedure) atau database transaction untuk operasi atomic. Contoh: create booking + update seats harus dalam satu transaction untuk mencegah race condition saat traffic tinggi.

Di bagian selanjutnya, kita akan mengenal Docker dan cara instalasi di berbagai sistem operasi.

Bagian 5: Mengenal Docker & Instalasi

Sebelum kita containerize API, mari pahami dulu apa itu Docker dan cara instalasinya di berbagai sistem operasi.

Apa itu Docker?

Docker adalah platform untuk membuat, menjalankan, dan mendistribusikan aplikasi dalam container. Bayangkan container seperti "kotak" yang berisi aplikasi beserta semua dependencies-nya.

Masalah yang Docker Selesaikan:

Developer: "Works on my machine!"
Ops: "But it doesn't work on the server..."

Dengan Docker, environment di laptop kamu = environment di server. Tidak ada lagi masalah "versi Node berbeda", "library tidak terinstall", atau "konfigurasi OS berbeda".

Docker vs Virtual Machine

AspectDocker ContainerVirtual Machine
SizeMegabytes (ringan)Gigabytes (berat)
StartupDetikMenit
OSShare kernel dengan hostFull OS sendiri
ResourceSangat efisienResource-heavy
IsolationProcess-levelHardware-level
PortabilitySangat portableKurang portable

Container jauh lebih ringan karena tidak perlu boot full OS — mereka share kernel dengan host machine.

Konsep Dasar Docker

Image Template read-only yang berisi OS, aplikasi, dan dependencies. Seperti "snapshot" atau "blueprint".

docker images  # List semua images

Container Instance yang berjalan dari sebuah image. Satu image bisa jadi banyak container.

docker ps      # List running containers
docker ps -a   # List semua containers

Dockerfile Script yang berisi instruksi untuk membuat image.

FROM oven/bun:1
COPY . .
CMD ["bun", "start"]

Registry Tempat menyimpan dan berbagi images. Docker Hub adalah registry publik terbesar.

docker pull oven/bun  # Download image dari registry

Volume Persistent storage untuk data yang perlu disimpan di luar container.

docker volume create mydata

Install Docker di Windows

Prasyarat:

  • Windows 10/11 64-bit (Pro, Enterprise, atau Education untuk Hyper-V)
  • Windows 10 Home bisa pakai WSL 2 backend
  • Minimal 4GB RAM
  • Virtualization enabled di BIOS

Step 1: Enable WSL 2

Buka PowerShell sebagai Administrator:

# Install WSL
wsl --install

# Set WSL 2 sebagai default
wsl --set-default-version 2

# Restart komputer

Step 2: Download Docker Desktop

Buka https://www.docker.com/products/docker-desktop/ dan download installer untuk Windows.

Step 3: Install Docker Desktop

  1. Double-click installer yang sudah didownload
  2. Pastikan "Use WSL 2 instead of Hyper-V" tercentang
  3. Ikuti wizard sampai selesai
  4. Restart komputer jika diminta

Step 4: Verify Installation

Buka PowerShell atau Command Prompt:

# Check version
docker --version
# Output: Docker version 24.x.x, build xxxxx

# Test dengan hello-world
docker run hello-world

Jika muncul pesan "Hello from Docker!", instalasi berhasil!

Install Docker di macOS

Option 1: Docker Desktop (Recommended)

  1. Buka https://www.docker.com/products/docker-desktop/
  2. Download untuk Mac (pilih Apple Silicon atau Intel sesuai Mac kamu)
  3. Double-click file .dmg
  4. Drag Docker ke folder Applications
  5. Buka Docker dari Applications
  6. Ikuti setup wizard

Option 2: Via Homebrew

# Install Docker Desktop via Homebrew
brew install --cask docker

# Buka Docker Desktop
open /Applications/Docker.app

Verify Installation:

docker --version
docker run hello-world

Install Docker di Linux (Ubuntu/Debian)

Step 1: Update & Install Prerequisites

# Update package index
sudo apt update

# Install prerequisites
sudo apt install -y \\
  apt-transport-https \\
  ca-certificates \\
  curl \\
  gnupg \\
  lsb-release

Step 2: Add Docker Repository

# Add Docker's official GPG key
curl -fsSL <https://download.docker.com/linux/ubuntu/gpg> | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

# Add repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] <https://download.docker.com/linux/ubuntu> $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Step 3: Install Docker Engine

# Update package index (again, with new repo)
sudo apt update

# Install Docker
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

Step 4: Post-Installation (Run Docker tanpa sudo)

# Add user ke docker group
sudo usermod -aG docker $USER

# Logout dan login kembali, atau jalankan:
newgrp docker

Step 5: Verify Installation

docker --version
docker run hello-world

Docker Commands Cheat Sheet

# === IMAGES ===
docker images                    # List images
docker pull <image>              # Download image
docker rmi <image>               # Remove image
docker build -t <name> .         # Build image dari Dockerfile

# === CONTAINERS ===
docker ps                        # List running containers
docker ps -a                     # List semua containers
docker run -d -p 3000:3000 <image>  # Run container
docker stop <container>          # Stop container
docker start <container>         # Start container
docker rm <container>            # Remove container
docker logs <container>          # View logs
docker logs -f <container>       # Follow logs (realtime)

# === EXEC ===
docker exec -it <container> /bin/sh  # Masuk ke container

# === CLEANUP ===
docker system prune              # Remove unused data
docker container prune           # Remove stopped containers
docker image prune               # Remove unused images

Troubleshooting

ProblemSolution
"permission denied"Run sudo usermod -aG docker $USER lalu logout/login
"Cannot connect to Docker daemon"Start Docker Desktop atau sudo systemctl start docker
"port already in use"Stop service yang pakai port atau ganti port mapping
"no space left on device"Run docker system prune untuk cleanup

💡 Mini Tips: Di Windows, pastikan WSL 2 sudah terinstall sebelum Docker Desktop. Docker Desktop menggunakan WSL 2 sebagai backend untuk performa optimal. Tanpa WSL 2, kamu harus pakai Hyper-V yang lebih berat.

Di bagian selanjutnya, kita akan buat Dockerfile dan docker-compose untuk API kita.

Bagian 6: Dockerfile & Docker Compose untuk ElysiaJS

Sekarang kita akan containerize API tiket kereta dengan Docker. Kita akan buat Dockerfile dan docker-compose untuk development dan production.

Memahami Dockerfile

Dockerfile adalah script yang berisi instruksi untuk membuat Docker image. Setiap instruksi membuat satu "layer".

FROM oven/bun:1       # Base image
WORKDIR /app          # Set working directory
COPY . .              # Copy files ke container
RUN bun install       # Execute command (build time)
CMD ["bun", "start"]  # Default command (run time)
EXPOSE 3000           # Document port yang digunakan

Instruksi Dockerfile yang Sering Dipakai:

InstructionPurpose
FROMBase image
WORKDIRSet working directory
COPYCopy files dari host ke container
RUNExecute command saat build
CMDDefault command saat container run
ENVSet environment variable
EXPOSEDocument port (tidak publish)
ARGBuild-time variable

Dockerfile Simple untuk ElysiaJS

# Dockerfile
FROM oven/bun:1

WORKDIR /app

# Copy package files dulu (layer caching)
COPY package.json bun.lockb ./

# Install dependencies
RUN bun install --frozen-lockfile --production

# Copy source code
COPY src ./src
COPY tsconfig.json ./

# Environment
ENV NODE_ENV=production

# Expose port
EXPOSE 3000

# Start server
CMD ["bun", "src/index.ts"]

Kenapa COPY package.json dulu?

Docker caching bekerja per-layer. Jika package.json tidak berubah, Docker akan pakai cache untuk layer bun install. Ini mempercepat build karena tidak perlu reinstall dependencies setiap kali code berubah.

Dockerfile Multi-Stage (Production)

Multi-stage build menghasilkan image yang lebih kecil dengan memisahkan build dan runtime.

# Dockerfile
# ================================
# Stage 1: Dependencies
# ================================
FROM oven/bun:1 AS deps

WORKDIR /app

COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production

# ================================
# Stage 2: Builder (optional)
# ================================
FROM oven/bun:1 AS builder

WORKDIR /app

# Copy dependencies dari stage sebelumnya
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Jika perlu build/compile TypeScript
# RUN bun run build

# ================================
# Stage 3: Runner (Final Image)
# ================================
FROM oven/bun:1-slim AS runner

WORKDIR /app

# Copy hanya yang dibutuhkan
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/src ./src
COPY --from=builder /app/tsconfig.json ./

# Environment
ENV NODE_ENV=production

# Security: run sebagai non-root user
USER bun

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\
  CMD curl -f <http://localhost:3000/health> || exit 1

# Start server
CMD ["bun", "src/index.ts"]

Keuntungan Multi-Stage:

  • Image size lebih kecil (hanya final stage yang di-ship)
  • Build tools tidak masuk production image
  • Lebih secure (less attack surface)
  • Faster deployment

docker-compose.yml

Docker Compose memudahkan running multiple containers atau container dengan konfigurasi kompleks.

# docker-compose.yml
version: '3.8'

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: tiket-kereta-api
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    env_file:
      - .env
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "<http://localhost:3000/health>"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s
    # Optional: resource limits
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M

docker-compose untuk Development

# docker-compose.dev.yml
version: '3.8'

services:
  api:
    image: oven/bun:1
    container_name: tiket-kereta-dev
    working_dir: /app
    ports:
      - "3000:3000"
    volumes:
      # Mount source code (hot reload)
      - ./src:/app/src
      - ./package.json:/app/package.json
      - ./bun.lockb:/app/bun.lockb
      - ./tsconfig.json:/app/tsconfig.json
    env_file:
      - .env
    command: sh -c "bun install && bun run --watch src/index.ts"

Keuntungan dev compose:

  • Hot reload dengan -watch
  • Source code di-mount (tidak perlu rebuild)
  • Perubahan langsung terlihat

.dockerignore

File ini mencegah file yang tidak perlu masuk ke image.

# .dockerignore
node_modules
.git
.gitignore

# Environment files
.env.local
.env.development
.env.*.local

# Logs
*.log
npm-debug.log*

# Docker files
Dockerfile*
docker-compose*
.dockerignore

# IDE
.vscode
.idea

# Test & coverage
coverage
*.test.ts

# Documentation
*.md
LICENSE

# OS files
.DS_Store
Thumbs.db

Main Entry Point dengan Health Check

Update src/index.ts untuk menambah health check endpoint:

// src/index.ts
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { swagger } from '@elysiajs/swagger'
import { stationRoutes } from './routes/stations'
import { scheduleRoutes } from './routes/schedules'
import { bookingRoutes } from './routes/bookings'

const app = new Elysia()
  .use(cors())
  .use(swagger({
    path: '/docs',
    documentation: {
      info: {
        title: 'Tiket Kereta API',
        version: '1.0.0',
        description: 'REST API untuk booking tiket kereta - MVP'
      },
      tags: [
        { name: 'Health', description: 'Health check' },
        { name: 'Stations', description: 'Data stasiun' },
        { name: 'Schedules', description: 'Jadwal kereta' },
        { name: 'Bookings', description: 'Pemesanan tiket' }
      ]
    }
  }))

  // Health check endpoint (untuk Docker healthcheck)
  .get('/health', () => ({
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  }), {
    detail: { tags: ['Health'], summary: 'Health check' }
  })

  // Mount routes
  .use(stationRoutes)
  .use(scheduleRoutes)
  .use(bookingRoutes)

  // Global error handler
  .onError(({ code, error, set }) => {
    console.error(`[${new Date().toISOString()}] ${code}:`, error.message)

    if (code === 'VALIDATION') {
      set.status = 400
      return { error: 'VALIDATION_ERROR', message: error.message }
    }

    if (code === 'NOT_FOUND') {
      set.status = 404
      return { error: 'NOT_FOUND', message: 'Endpoint tidak ditemukan' }
    }

    set.status = 500
    return { error: 'SERVER_ERROR', message: 'Terjadi kesalahan server' }
  })

  .listen(3000)

console.log('🚂 Tiket Kereta API')
console.log(`📍 Server: http://localhost:3000`)
console.log(`📚 Docs: <http://localhost:3000/docs`>)
console.log(`💚 Health: <http://localhost:3000/health`>)

Build & Run Commands

# === BUILD ===
# Build image
docker build -t tiket-kereta-api .

# Build dengan tag version
docker build -t tiket-kereta-api:1.0.0 .

# === RUN (tanpa compose) ===
docker run -d \\
  --name tiket-kereta \\
  -p 3000:3000 \\
  --env-file .env \\
  tiket-kereta-api

# === RUN (dengan compose) ===
# Production
docker-compose up -d

# Development (dengan hot reload)
docker-compose -f docker-compose.dev.yml up

# === LOGS ===
docker-compose logs -f

# === STOP ===
docker-compose down

# === REBUILD ===
docker-compose up -d --build

# === CLEANUP ===
docker-compose down --rmi all --volumes

Verify Container

# Check running containers
docker ps

# Check health
curl <http://localhost:3000/health>

# Check logs
docker logs -f tiket-kereta-api

# Masuk ke container
docker exec -it tiket-kereta-api /bin/sh

Common Issues & Solutions

IssueSolution
"port already in use"Ganti port di compose: "3001:3000"
"env file not found"Pastikan .env ada di root folder
".env not loaded"Check path di env_file compose
"image too large"Gunakan multi-stage build + .dockerignore
"permission denied"Add USER bun di Dockerfile
"health check failing"Pastikan /health endpoint exists

💡 Mini Tips: Selalu gunakan --frozen-lockfile saat install di Docker. Ini memastikan exact same dependencies seperti di development, mencegah "works on my machine" problems.

Di bagian terakhir, kita akan load test API dengan Bombardier untuk memastikan performa siap production.

Bagian 7: Load Testing dengan Bombardier & Penutup

Sebelum deploy ke production, kita perlu tahu seberapa kuat API kita. Load testing membantu menemukan bottleneck dan memastikan API bisa handle traffic yang diharapkan.

Apa itu Bombardier?

Bombardier adalah HTTP load testing tool yang ditulis dalam Go. Ringan, cepat, dan mudah digunakan.

Kenapa Bombardier?

  • Sangat cepat (ditulis dalam Go)
  • Simple CLI interface
  • Output yang informatif
  • Cross-platform

Install Bombardier

macOS:

brew install bombardier

Linux:

# Via Go
go install github.com/codesenberg/bombardier@latest

# Atau download binary
wget <https://github.com/codesenberg/bombardier/releases/download/v1.2.6/bombardier-linux-amd64>
chmod +x bombardier-linux-amd64
sudo mv bombardier-linux-amd64 /usr/local/bin/bombardier

Windows:

# Download dari GitHub releases
# <https://github.com/codesenberg/bombardier/releases>

# Atau via Scoop
scoop install bombardier

Verify:

bombardier --version

Bombardier Options

bombardier [options] <url>

# Common options:
-c, --connections   # Jumlah concurrent connections (default: 125)
-d, --duration      # Duration of test (contoh: 10s, 1m)
-n, --requests      # Total number of requests
-m, --method        # HTTP method (GET, POST, etc)
-H, --header        # HTTP header
-b, --body          # Request body
-f, --body-file     # Request body from file
-t, --timeout       # Request timeout (default: 2s)
-l, --latencies     # Print latency statistics

Test Scenarios

Pastikan container sudah running:

docker-compose up -d

Test 1: Health Check (Baseline)

bombardier -c 100 -d 10s <http://localhost:3000/health>

Expected output:

Statistics        Avg      Stdev        Max
  Reqs/sec     45231.12    2341.56   52341.89
  Latency        2.21ms     1.23ms    45.67ms
  HTTP codes:
    1xx - 0, 2xx - 452311, 3xx - 0, 4xx - 0, 5xx - 0
  Throughput:    12.34MB/s

Test 2: GET Stations (Read, Simple Query)

bombardier -c 100 -d 10s <http://localhost:3000/stations>

Test 3: GET Schedules dengan Filter (Read, JOIN Query)

bombardier -c 100 -d 10s "<http://localhost:3000/schedules?from=GMR&to=BD>"

Test 4: GET Schedule by ID (Read, Single Record)

# Get schedule ID dulu
SCHEDULE_ID=$(curl -s <http://localhost:3000/schedules> | jq -r '.data[0].id')

bombardier -c 100 -d 10s "<http://localhost:3000/schedules/$SCHEDULE_ID>"

Test 5: POST Booking (Write Operation)

# Get schedule ID
SCHEDULE_ID=$(curl -s <http://localhost:3000/schedules> | jq -r '.data[0].id')

# Test POST (gunakan -n untuk limit requests, bukan -d)
bombardier -c 20 -n 100 -m POST \\
  -H "Content-Type: application/json" \\
  -b "{
    \\"schedule_id\\": \\"$SCHEDULE_ID\\",
    \\"passenger_name\\": \\"Test User\\",
    \\"passenger_id_number\\": \\"1234567890123456\\",
    \\"passenger_phone\\": \\"081234567890\\",
    \\"seat_count\\": 1
  }" \\
  <http://localhost:3000/bookings>

Expected Performance

EndpointTypeConnectionsExpected RPSLatency (p99)
/healthRead10040,000+<10ms
/stationsRead1008,000+<30ms
/schedulesRead+JOIN1003,000+<50ms
/schedules/:idRead1005,000+<40ms
POST /bookingsWrite20500+<100ms

Catatan:

  • Angka di atas untuk local testing
  • Production akan berbeda tergantung network latency ke Supabase
  • Write operations lebih lambat karena ada multiple queries

Analyzing Results

Statistics        Avg      Stdev        Max
  Reqs/sec     12543.21    1234.56   15678.90
  Latency       7.89ms     2.34ms    45.67ms
  HTTP codes:
    1xx - 0, 2xx - 125432, 3xx - 0, 4xx - 0, 5xx - 0
  Throughput:    8.45MB/s

Key Metrics:

MetricMeaningTarget
Reqs/secThroughput (higher is better)Depends on use case
Latency AvgAverage response time<100ms
Latency p9999th percentile latency<500ms
StdevConsistency (lower is better)Low
Error rate4xx + 5xx responses0%

Optimization Tips

Jika performa tidak sesuai harapan:

1. Add Database Indexes

-- Supabase SQL Editor
CREATE INDEX idx_schedules_origin_dest
ON schedules(origin_id, destination_id);

2. Connection Pooling Supabase sudah handle ini, tapi untuk self-hosted PostgreSQL gunakan PgBouncer.

3. Caching Cache data yang jarang berubah (stations, schedules):

// Simple in-memory cache
const stationCache = new Map()

4. Limit Concurrent Bookings Prevent overselling dengan rate limiting atau queue.

5. Use Read Replicas Untuk high-traffic, pisahkan read dan write operations.

API Endpoints Summary

MethodEndpointDescriptionAuth
GET/healthHealth checkNo
GET/stationsList stationsNo
GET/stations/:codeStation detailNo
GET/schedulesSearch schedulesNo
GET/schedules/:idSchedule detailNo
POST/bookingsCreate bookingNo
GET/bookings/:codeGet bookingNo
PATCH/bookings/:code/confirmConfirm bookingNo
DELETE/bookings/:codeCancel bookingNo

Total: 9 endpoints

What We Built

✅ MVP Tiket Kereta dengan fitur essential ✅ 3 database tables dengan proper relations ✅ 9 REST API endpoints ✅ Input validation dengan TypeBox ✅ Business logic (seat management, status flow) ✅ Docker containerization ✅ Health check untuk monitoring ✅ Load tested dengan Bombardier

Next Steps

Untuk membuat API ini production-ready:

Authentication

bun add better-auth

Tambahkan login untuk staff dan customer.

Rate Limiting

import { rateLimit } from 'elysia-rate-limit'
app.use(rateLimit({ max: 100, duration: 60000 }))

Payment Integration Integrate dengan Midtrans atau Xendit untuk pembayaran.

Notification Email atau SMS konfirmasi booking.

Monitoring Setup logging dan alerting dengan tools seperti Grafana/Prometheus.

Penutup

Kamu sekarang punya REST API Tiket Kereta yang:

  • Type-safe dengan ElysiaJS dan TypeBox
  • Scalable dengan PostgreSQL via Supabase
  • Portable dengan Docker containerization
  • Tested dengan Bombardier load testing

Project ini bisa langsung kamu gunakan sebagai:

  • Portfolio untuk showcase backend skills
  • Template untuk project booking/reservation
  • Learning untuk eksperimen fitur baru

Resources

Untuk memperdalam skill backend development dengan database dan Docker, explore kelas gratis di BuildWithAngga. Ada track lengkap dari database design, API development, sampai deployment yang akan membantu kamu membangun aplikasi production-ready.

Butuh template UI untuk aplikasi tiket atau booking? Download HTML template gratis di shaynakit.com — tersedia berbagai template travel booking, ticket reservation, dan dashboard yang bisa langsung kamu integrate dengan API ini.

Documentation:

Happy coding! 🚂🐳

💡 Mini Tips: Load testing adalah skill yang sering diabaikan tapi sangat penting untuk backend developer. Knowing your API limits sebelum production launch bisa mencegah downtime saat traffic spike. Jadikan load testing sebagai bagian dari development workflow, bukan afterthought.