Setiap layanan autentikasi berdiri di atas satu pertanyaan sederhana: di mana daftar pengguna disimpan, dan apa isi setiap barisnya. Pada bagian ini kita membangun fondasi itu dengan node:sqlite
— satu tabel pengguna yang bisa hidup di memori untuk demo dan test, atau di berkas untuk pemakaian nyata. Desainnya mengikuti pola repository dari Section Architecture dan Design: handler HTTP tidak pernah menulis SQL, hanya memanggil method penyimpanan. Satu keputusan paling penting sudah diambil sejak awal: kolom sandi tidak menyimpan sandi asli, melainkan rekaman hash yang cara pembuatannya dibahas tuntas di Bagian 2.
Tabel Pengguna dan Skemanya #
Empat kolom cukup untuk seluruh proyek ini. Berikut peta lengkapnya:
| Kolom | Isi | Alasan |
|---|---|---|
id | angka bertambah otomatis | pengenal stabil yang tidak berubah walau nama diganti |
nama | teks unik 3-30 karakter | kunci login sekaligus pengenal yang terlihat pengguna |
hash_sandi | string salt:hash | bukti sandi; sandi asli tidak pernah disimpan |
peran | pengguna atau admin | dasar keputusan izin pada Bagian 3 |
Skema ini dibuat oleh function buatDb yang selalu dipanggil sebelum penyimpanan dipakai:
export function buatDb(lokasi = ':memory:') {
const db = new DatabaseSync(lokasi);
db.exec(`
CREATE TABLE IF NOT EXISTS pengguna (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL UNIQUE,
hash_sandi TEXT NOT NULL,
peran TEXT NOT NULL DEFAULT 'pengguna'
)
`);
return db;
}Mari kita bedah baris-per-barisnya:
- Baris 5 (
lokasi = ':memory:'): string khusus':memory:'membuat database hidup di memori — tiap panggilan menghasilkan database baru yang hilang saat proses berakhir, tepat untuk test yang butuh kondisi awal bersih.src/cli.jsnanti mengirim path berkasdata/auth.dbagar data bertahan. - Baris 6 (
new DatabaseSync(lokasi)): classDatabaseSyncmembuka koneksi sinkron; operasi SQLite lokal selesai dalam mikrodetik sehingga pola sinkron ini justru menyederhanakan kode handler. - Baris 7-14 (
CREATE TABLE IF NOT EXISTS): penandaIF NOT EXISTSmembuat function ini aman dipanggil berulang — pada berkas yang sudah berisi tabel, perintah ini tidak melempar error.UNIQUEpada kolomnamamenempatkan janji “nama tidak boleh ganda” di database itu sendiri. - Baris 12 (
DEFAULT 'pengguna'): kolomperanpunya nilai bawaan, sehingga penyimpanan tetap sehat walau ada baris yang ditulis tanpa peran eksplisit.
Query Parameterized dalam Praktik #
Semua akses data terkumpul dalam buatPenyimpananPengguna — function yang menerima koneksi db dan mengembalikan tiga method. Chapter Query Parameterized menjelaskan mengapa nilai tidak pernah dirangkai ke string SQL; di sini pola itu menjadi satu-satunya jalan karena seluruh statement memakai placeholder ?:
export function buatPenyimpananPengguna(db) {
const tambahPengguna = db.prepare(
'INSERT INTO pengguna (nama, hash_sandi, peran) VALUES (?, ?, ?)');
const cariDenganNama = db.prepare(
'SELECT id, nama, hash_sandi, peran FROM pengguna WHERE nama = ?');
const daftarSemua = db.prepare(
'SELECT id, nama, peran FROM pengguna ORDER BY id');
return {
tambah(nama, hashSandi, peran) {
const hasil = tambahPengguna.run(nama, hashSandi, peran);
return { id: Number(hasil.lastInsertRowid), nama, peran };
},
cariNama(nama) {
return cariDenganNama.get(nama) ?? null;
},
daftar() {
return daftarSemua.all().map((baris) => ({
id: Number(baris.id),
nama: baris.nama,
peran: baris.peran,
}));
},
};
}Beberapa keputusan penting pada potongan di atas:
- Baris 19-24 (tiga
db.prepare): statement dikompilasi sekali saat penyimpanan dibuat, lalu dijalankan berkali-kali dengan nilai berbeda — pola prepare-then-run yang sudah kita kenal dari Chapter SQLite dengan node:sqlite. - Baris 27-30 (
tambah):run()mengikat tiga nilai ke tiga placeholder secara berurutan.hasil.lastInsertRowidberisi id yang baru dibuat;Number()menjaga nilainya tetap number untuk id berukuran wajar. - Baris 31-33 (
cariNama): method.get()mengembalikan baris pertama atauundefined; operator??mengubahnya menjadinullagar handler cukup memeriksa satu nilai kosong. Baris yang dikembalikan memuathash_sandikarena verifikasi login membutuhkannya — data ini tidak pernah keluar dari sisi server. - Baris 34-40 (
daftar): statement SQL-nya memilihid, nama, peransaja — kolomhash_sanditidak ikut terseleksi, sehingga endpoint/admindi Bagian 3 tidak mungkin membocorkan hash sekalipun lupa memfilternya. Pemetaanmapjuga menyalin baris ke object biasa dengan urutan field yang stabil.
Menolak Nama Ganda lewat Constraint #
Ketika insert melanggar UNIQUE, node:sqlite melempar error bertanda khusus. Function kecil ini mengenali tanda tersebut:
// Deteksi pelanggaran constraint UNIQUE pada kolom nama.
export function galatNamaTerpakai(err) {
return err.code === 'ERR_SQLITE_ERROR'
&& err.message.includes('UNIQUE constraint failed: pengguna.nama');
}Dua pemeriksaan berlapis pada baris 46-47: kode ERR_SQLITE_ERROR memastikan error berasal dari SQLite, dan isi pesan memastikan pelanggarannya memang kolom nama — bukan constraint lain yang mungkin ditambahkan kelak. Handler pendaftaran di Bagian 2 menangkap error ini dan memetakkannya ke status 409; error lain diteruskan sehingga tetap menjadi urusan penangan error terpusat.
Menguji Penyimpanan #
Empat test unit mengunci kontrak penyimpanan: roundtrip tambah-cari, nama asing menghasilkan null, nama ganda melempar error yang dikenali galatNamaTerpakai, dan daftar() tidak memuat hash_sandi:
node --test tests/penyimpanan.test.jsOutput yang Dihasilkan:
▶ penyimpanan pengguna
✔ tambah lalu cariNama mengembalikan pengguna lengkap
✔ cariNama untuk nama asing mengembalikan null
✔ nama sama ditolak constraint UNIQUE
✔ daftar menampilkan nama dan peran tanpa hash_sandi
✔ penyimpanan pengguna
ℹ tests 4
ℹ suites 1
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0Penyimpanan kini siap dipakai: tabel berdiri, query aman, dan nama ganda ditolak di lapis paling bawah. Yang belum ada adalah isi kolom hash_sandi — bagaimana sandi asli diubah menjadi rekaman yang aman disimpan, dan bagaimana pendaftaran serta login memakainya, itu pekerjaan Bagian 2.