Setiap aplikasi yang menyimpan data berawal dari satu pertanyaan: informasi apa yang perlu disimpan, dan bagaimana hubungan antar informasi tersebut. Blog API kita menyimpan dua entitas: artikel yang kita tulis dan komentar yang ditinggalkan pembaca. Pada bagian ini kita merancang skema SQLite untuk kedua entitas tersebut, lalu membungkusnya menjadi modul src/db.js dengan fungsi pabrik buatDb. Seluruh akses data pada proyek ini melewati modul ini, sehingga endpoint yang kita bangun pada bagian berikutnya tinggal memanggil fungsi-fungsi yang sudah tersedia.
Merancang Dua Tabel yang Berelasi #
Relasi antara artikel dan komentar adalah one-to-many: satu artikel bisa memiliki banyak komentar, tetapi satu komentar hanya milik satu artikel. Untuk memodelkan relasi tersebut, tabel komentar menyimpan kolom artikel_id yang merujuk ke id pada tabel artikel. Berikut skema lengkap yang tersimpan di src/db.js:
const SKEMA = `
CREATE TABLE IF NOT EXISTS artikel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
judul TEXT NOT NULL,
isi TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS komentar (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artikel_id INTEGER NOT NULL,
nama TEXT NOT NULL,
isi TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (artikel_id) REFERENCES artikel(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_komentar_artikel ON komentar(artikel_id);
`;Mari kita telaah bagian-bagian skema di atas:
id INTEGER PRIMARY KEY AUTOINCREMENT: setiap baris mendapat id unik yang naik terus.AUTOINCREMENTmemastikan id yang pernah dipakai tidak dipakai ulang setelah baris dihapus, sehingga rujukan lama tidak berpindah ke data baru.judul TEXT NOT NULLdanisi TEXT NOT NULL: kolom wajib diisi. SQLite tetap menerima string kosong, jadi validasi “tidak kosong” tetap kita lakukan di lapisan HTTP pada Bagian 2.created_at TEXT NOT NULL: SQLite tidak punya tipe khusus untuk tanggal. Kita menyimpan waktu UTC dalam format ISO 8601 (misalnya2026-01-15T08:00:00.000Z) karena format ini mudah diurutkan sebagai teks dan langsung siap dikirim sebagai JSON.FOREIGN KEY (artikel_id) REFERENCES artikel(id): database menolak komentar yang merujuk artikel yang tidak ada. Inilah penjaga relasi paling dasar — tidak bergantung pada kedisiplinan kode aplikasi.ON DELETE CASCADE: saat artikel dihapus, seluruh komentarnya ikut terhapus otomatis. Tanpa opsi ini, komentar akan tertinggal sebagai baris yang merujuk ke id artikel yang sudah tidak ada.CREATE INDEX ... ON komentar(artikel_id): indeks mempercepat pencarian komentar milik satu artikel. Query pagination pada Bagian 3 menyaring kolom ini di klausaWHERE, jadi indeksnya langsung terpakai.
Pabrik Koneksi buatDb #
Modul src/db.js mengekspor fungsi buatDb(jalur, opsi) alih-alih membuka koneksi di level teratas. Pola pabrik ini memberi kita dua kebebasan: memilih antara database di memori dan di berkas, serta menyuntikkan sumber waktu. Berikut pembukaan fungsinya:
export function buatDb(jalur, opsi = {}) {
const db = new DatabaseSync(jalur);
const waktu = opsi.waktu ?? (() => new Date().toISOString());
// SQLite asli menonaktifkan foreign key secara bawaan. node:sqlite pada
// Node 24 ternyata mengaktifkannya, tetapi dokumennya tidak menjamin
// perilaku tersebut; pragma ini ditulis eksplisit agar niat kode jelas.
db.exec('PRAGMA foreign_keys = ON');
db.exec(SKEMA);Mari kita bedah baris per baris:
new DatabaseSync(jalur): membuka database padajalur. String':memory:'membuat database di RAM yang hilang saat ditutup — pas untuk test yang butuh kondisi awal bersih. Sebaliknya,'data/blog.db'menyimpan data pada berkas yang bertahan lintas proses.const waktu = opsi.waktu ?? ...:created_atdiisi oleh kode aplikasi, bukan default SQL. Secara bawaanwaktu()memanggilnew Date().toISOString(), tetapi demo dan test menyuntikkan jam beku2026-01-15T08:00:00.000Zagar output selalu identik antar-jalankan.db.exec(...): menjalankan SQL tanpa parameter — tepat untuk DDL. Satu panggilanexecboleh memuat beberapa statement sekaligus, jadi seluruh skema dieksekusi dalam dua baris.
Query Parameterized untuk Artikel #
Semua query data memakai parameter terikat ? seperti pada Chapter Query Parameterized — nilai dari pengguna tidak pernah dirangkai ke string SQL. Tiga fungsi berikut menangani tambah, daftar, dan ubah artikel:
function tambahArtikel(judul, isi) {
const hasil = db
.prepare('INSERT INTO artikel (judul, isi, created_at) VALUES (?, ?, ?)')
.run(judul, isi, waktu());
return barisArtikel(Number(hasil.lastInsertRowid));
} function daftarArtikel(kataKunci) {
if (!kataKunci) {
return db
.prepare('SELECT id, judul, isi, created_at FROM artikel ORDER BY id')
.all();
}
const pola = `%${kataKunci}%`;
return db
.prepare(
`SELECT id, judul, isi, created_at FROM artikel
WHERE judul LIKE ? OR isi LIKE ?
ORDER BY id`
)
.all(pola, pola);
} function ubahArtikel(id, perubahan) {
const kolom = Object.keys(perubahan);
if (kolom.length > 0) {
// Nama kolom tidak bisa diikat sebagai parameter, jadi daftarnya
// dibangun dari allow-list judul/isi yang disusun oleh handler.
const setSql = kolom.map((k) => `${k} = ?`).join(', ');
const nilai = kolom.map((k) => perubahan[k]);
const hasil = db
.prepare(`UPDATE artikel SET ${setSql} WHERE id = ?`)
.run(...nilai, id);
if (hasil.changes === 0) return undefined;
}
return barisArtikel(id);
}Tiga keputusan pada potongan di atas layak dicermati:
Number(hasil.lastInsertRowid):lastInsertRowidbisa berupaBigIntuntuk id sangat besar. PembungkusanNumber()membuat hasilnya konsisten berupa number untuk id kecil sekaligus baris yang bisa langsung di-JSON.stringify.- Pencarian
LIKE ?dengan pola%kata%: tanda persen dicetak di sisi aplikasi, sedangkan kata kunci tetap lewat parameter terikat. Handler cukup meneruskan nilai?q=dari URL tanpa perakitan string SQL. - Nama kolom pada
ubahArtikeltidak diikat sebagai parameter karena SQL hanya mengizinkan parameter untuk nilai, bukan identifier. Karena itu handler HTTP menyusun objectperubahanhanya dari kuncijudul/isiyang dikenal — pola allow-list yang sama dengan Chapter SQL Injection pada Section Security. Nilai kolom tetap lewat parameter terikat.
Mencoba Lapisan Data #
Berkas coba-db.mjs pada folder bagian ini mengimpor ../../src/db.js lalu menjalankan operasi dasar tanpa HTTP sama sekali. Jalankan dengan node coba-db.mjs dari folder bagian ini:
// Mencoba lapisan data Blog API secara langsung, tanpa HTTP.
import { buatDb, WAKTU_DEMO } from '../../src/db.js';
const db = buatDb(':memory:', { waktu: () => WAKTU_DEMO });
const a1 = db.tambahArtikel(
'Belajar node:sqlite',
'SQLite kini dibawa langsung oleh Node.js.'
);
const a2 = db.tambahArtikel(
'REST API tanpa framework',
'Untuk API kecil, node:http sudah cukup.'
);
console.log('tambahArtikel:', JSON.stringify(a1));
console.log('tambahArtikel:', JSON.stringify(a2));
console.log('prototype baris hasil query:', Object.getPrototypeOf(a1));
console.log('hasil spread {...a1}:', JSON.stringify({ ...a1 }));
console.log('daftarArtikel():', JSON.stringify(db.daftarArtikel()));
console.log(
"daftarArtikel('sqlite'):",
JSON.stringify(db.daftarArtikel('sqlite'))
);
console.log(
'ubahArtikel(2, judul):',
JSON.stringify(db.ubahArtikel(2, { judul: 'REST API dengan node:http' }))
);
console.log('hapusArtikel(1):', db.hapusArtikel(1));
console.log('hapusArtikel(1) lagi:', db.hapusArtikel(1));
console.log('cariArtikel(1) setelah dihapus:', db.cariArtikel(1));
db.tutup();Output yang Dihasilkan:
tambahArtikel: {"id":1,"judul":"Belajar node:sqlite","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z"}
tambahArtikel: {"id":2,"judul":"REST API tanpa framework","isi":"Untuk API kecil, node:http sudah cukup.","created_at":"2026-01-15T08:00:00.000Z"}
prototype baris hasil query: null
hasil spread {...a1}: {"id":1,"judul":"Belajar node:sqlite","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z"}
daftarArtikel(): [{"id":1,"judul":"Belajar node:sqlite","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z"},{"id":2,"judul":"REST API tanpa framework","isi":"Untuk API kecil, node:http sudah cukup.","created_at":"2026-01-15T08:00:00.000Z"}]
daftarArtikel('sqlite'): [{"id":1,"judul":"Belajar node:sqlite","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z"}]
ubahArtikel(2, judul): {"id":2,"judul":"REST API dengan node:http","isi":"Untuk API kecil, node:http sudah cukup.","created_at":"2026-01-15T08:00:00.000Z"}
hapusArtikel(1): true
hapusArtikel(1) lagi: false
cariArtikel(1) setelah dihapus: undefinedPerhatikan tiga hal pada hasil di atas: created_at selalu berisi jam beku yang kita suntikkan, prototype baris hasil query tercetak null sesuai catatan sebelumnya, dan cariArtikel(1) mengembalikan undefined setelah barisnya dihapus — sinyal yang nanti diterjemahkan handler menjadi respons 404.
Skema dan lapisan data kini berdiri sendiri dan teruji. Tantangan berikutnya adalah menghadapkannya ke dunia luar: kita perlu menerima permintaan HTTP, mengurai JSON, memilih status code, dan menjawab dengan rapi — semuanya di atas node:http tanpa framework. Itu pekerjaan Bagian 2.
Referensi resmi: node:sqlite , SQLite foreign keys , dan bahasa query SQLite .