โหมดมืด
บทที่ 9 — Database + REST API จริง: ประกอบทุกอย่างเข้าด้วยกัน
← บทที่ 8: Testing | สารบัญ | บทที่ 10: Performance + Scaling →
ถึงเวลาเอาทุกอย่าง (async บท 3, http/Express บท 5, error บท 6, config บท 7) มาประกอบเป็น REST API ที่ต่อฐานข้อมูลจริง — แบบที่ใช้ได้จริง ไม่ใช่ของเล่น เราจะทำ API จัดการ "tasks" ต่อ PostgreSQL พร้อม:
- ต่อ PostgreSQL ด้วย
pg+ ทำไมต้องมี connection pool - Parameterized query — กัน SQL injection (ช่องโหว่อันดับ 1 ของ backend)
- แยกชั้น route → service → repository (โครงที่ดูแลง่าย)
- Validate input ด้วย Zod + error format มาตรฐาน
- Transaction + ปิด pool ตอน shutdown
📋 บทนี้ใช้ความรู้ SQL พื้นฐาน (
SELECT/INSERT/UPDATE/DELETE/WHERE/ORDER BY) — ถ้ายังไม่คุ้น ลองดูสรุปสั้น ๆ ที่ PostgreSQL Tutorial หรือ Database บทที่ 1 ควบคู่ · แนวคิด connection pool ลึก ๆ มีใน Java บทที่ 17 (หลักการเดียวกันข้ามภาษา)🗺️ บทนี้สอน raw
pgเพื่อให้เข้าใจกลไกพื้นฐาน (pool, parameterized query, transaction) ในโลก production ปี 2026 ส่วนใหญ่ใช้ type-safe query builder/ORM (เครื่องมือช่วยเขียน query แบบเช็ก type ได้ ลดโอกาสพิมพ์ชื่อ column ผิด) บนpgอีกที — ตัวที่นิยม:
- Prisma — ORM แบบ full-stack, schema-first (ออกแบบโครงสร้างตาราง/schema ก่อน แล้วให้เครื่องมือ generate โค้ดออกมาให้)
- Drizzle — เบา (lightweight), เขียนคล้าย SQL ตรง ๆ, TS-first (ออกแบบมาให้ใช้กับ TypeScript เป็นหลักตั้งแต่ต้น)
- Kysely — type-safe query builder ที่เบาที่สุดในกลุ่มนี้
เมื่อเข้าใจบทนี้แล้วจะเลือก/ใช้ตัวไหนก็ไม่สับสน เพราะข้างใต้คือสิ่งเดียวกัน
🗺️ Migration (จัดการ schema เปลี่ยนแปลงข้ามเวอร์ชัน) ไม่ครอบคลุมในบทนี้ — เครื่องมือยอดนิยม:
node-pg-migrate(ใช้กับ rawpg), Drizzle Kit, Prisma Migrate — production ต้องมี อย่าลืม
1. ต่อ PostgreSQL + ทำไมต้องมี connection pool
ติดตั้ง driver pg (ตัวที่นิยมที่สุดสำหรับ PostgreSQL):
bash
npm install pgปัญหา: เปิด connection ใหม่ทุก query = ช้าและพัง
การเปิด connection ไป DB ใช้เวลา (TCP handshake + auth ~20-50ms) ถ้าทุก request เปิดใหม่:
javascript
// ❌ อย่าทำ: เปิด-ปิด connection ทุก query
import { Client } from "pg";
async function getTask(id) {
const client = new Client(/* ... */);
await client.connect(); // ช้า! 20-50ms ทุกครั้ง
const res = await client.query("SELECT ...");
await client.end();
return res.rows[0];
}ระบบที่มี 1000 req/s จะเปิด-ปิด connection 1000 ครั้ง/วิ → ช้า + DB มีลิมิตจำนวน connection (เปิดเกินจะ error)
วิธีถูก: Pool — บ่อ connection ที่ใช้ซ้ำ
connection pool = สร้าง connection ไว้จำนวนหนึ่งล่วงหน้า แล้ว "ยืม-คืน" ใช้ซ้ำ — ไม่ต้องเปิดใหม่ทุกครั้ง (เหมือนรถเช่า: มีรถพร้อมในลานให้ยืมไปใช้แล้วคืน ไม่ต้องซื้อรถใหม่ทุกครั้งที่จะขับ):
javascript
// db.mjs — สร้าง pool ครั้งเดียว แล้ว export ไปใช้ทั้งแอป
import pg from "pg";
import { config } from "./config.mjs"; // บทที่ 7
const { Pool } = pg;
export const pool = new Pool({
connectionString: config.databaseUrl, // จาก env (บทที่ 7)
max: 10, // จำนวน connection สูงสุดในบ่อ
idleTimeoutMillis: 30_000, // connection ที่ว่างเกิน 30 วิ ถูกปิด
connectionTimeoutMillis: 5_000, // รอยืม connection ได้นานสุด 5 วิ
statement_timeout: 10_000, // ⭐ ตัด query ที่รันนานเกิน 10 วิ (กัน query แย่ลาก pool หมด)
application_name: "tasks-api", // ⭐ ชื่อที่จะโชว์ใน pg_stat_activity (ตารางพิเศษใน PostgreSQL ที่โชว์ connection ที่กำลังทำงานอยู่ตอนนี้) ช่วย debug ว่า connection มาจากใคร
ssl: config.isProd // config.isProd จากบทที่ 7 (= process.env.NODE_ENV === "production")
? { rejectUnauthorized: true } // ⭐ production: ตรวจ certificate ของ DB จริง (ถ้ามี CA เฉพาะให้ใส่ ca: fs.readFileSync(...))
: false,
});
// ⚠️ ถ้า connect ไม่ผ่านตอน production ให้เช็คว่า DB provider ต้องการ CA bundle เฉพาะไหม
// (RDS/Supabase/Neon มักต้องการ ca: fs.readFileSync(...) เพราะ CA ของเขาไม่ได้อยู่ใน trust store เริ่มต้นของ Node)
// อย่าลด security เป็น rejectUnauthorized: false เพื่อความง่ายใน production
// pool เป็น EventEmitter (บทที่ 3) — ดัก error ของ connection ที่ idle
pool.on("error", (err) => {
console.error("DB pool error:", err); // อย่าให้ error ของ idle connection มา crash แอป
});pool.query() จะ "ยืม connection จากบ่อ → query → คืนอัตโนมัติ" ให้เราเอง — ใช้ง่าย:
javascript
import { pool } from "./db.mjs";
const res = await pool.query("SELECT NOW()");
console.log(res.rows[0]);2. Parameterized query — กัน SQL injection
นี่คือเรื่อง ความปลอดภัย ที่พลาดไม่ได้ อย่าเอา input ของ user มาต่อใส่ SQL string ตรง ๆ:
javascript
// ❌❌❌ ช่องโหว่ SQL injection — ห้ามเด็ดขาด
const id = req.params.id; // ถ้า user ส่ง "1 OR 1=1; DROP TABLE tasks;--"
const res = await pool.query(`SELECT * FROM tasks WHERE id = ${id}`);
// → SQL กลายเป็น: SELECT * FROM tasks WHERE id = 1 OR 1=1; DROP TABLE tasks;--
// → ดึงทุกแถว + ลบทั้งตาราง! 💀วิธีถูก: ใช้ placeholder $1, $2 แล้วส่งค่าแยกเป็น array — driver จะ escape ให้ ปลอดภัย 100% เพราะค่าถูกส่งแยกจากคำสั่ง SQL (DB รู้ว่าอันไหนคำสั่ง อันไหนข้อมูล):
javascript
// ✅ ปลอดภัย: ค่าถูกส่งแยก ไม่มีทางกลายเป็นคำสั่ง
const res = await pool.query(
"SELECT * FROM tasks WHERE id = $1", // $1 = placeholder
[id], // ค่าจริงส่งแยกเป็น array
);
// ต่อให้ user ส่งอะไรมา มันก็เป็นแค่ "ค่า" ของ id ไม่ใช่คำสั่ง⚠️ Pitfall ร้ายแรงที่สุดในบท: ไม่ว่ากรณีใดก็ตาม อย่าเอา input จาก user (params/body/query) มาต่อ string ใส่ SQL — ใช้
$1, $2เสมอนี่คือช่องโหว่ที่เรียกว่า OWASP A03: Injection — OWASP (Open Web Application Security Project) คือองค์กรที่จัดอันดับ Top 10 ช่องโหว่ความปลอดภัยเว็บที่พบบ่อยที่สุด ส่วน A03 คืออันดับ 3 ของ Top 10 ปี 2021 · อ่านเพิ่มที่ Security บทที่ 1 หรือ owasp.org/Top10
3. แยกชั้น: route → service → repository
API จริงไม่ยัดทุกอย่างใน route handler เดียว — แยกหน้าที่ให้ดูแลและทดสอบง่าย แนวคิดนี้เรียกว่า layered architecture (สถาปัตยกรรมแบบแบ่งชั้น): แต่ละชั้นรู้แค่ชั้นถัดไป ไม่ข้ามชั้น เปลี่ยน HTTP framework หรือเปลี่ยน DB ก็แก้แค่ชั้นเดียว
ลองนึกภาพร้านอาหาร: บริกร (route) รับออร์เดอร์จากลูกค้า ไม่ต้องรู้วิธีทำอาหาร แค่ส่งต่อ → เชฟ (service) ตัดสินใจว่าจะทำเมนูนี้ยังไงตามสูตร (business logic) ไม่ต้องรู้ว่าลูกค้านั่งโต๊ะไหน → คนคลังของ (repository) หยิบวัตถุดิบจากห้องเก็บของมาให้เชฟ ไม่ต้องรู้จักลูกค้าเลย — แต่ละคนทำงานเฉพาะหน้าที่ตัวเอง เปลี่ยนบริกรใหม่ก็ไม่กระทบเชฟ เปลี่ยนซัพพลายเออร์วัตถุดิบก็ไม่กระทบบริกร นี่คือเหตุผลที่การแยกชั้นทำให้โค้ดดูแลง่ายกว่าเขียนทุกอย่างปนกันในไฟล์เดียว (อ่านเพิ่มที่ Concepts บทที่ 6):
text
HTTP request
↓
route handler ← รับ req/res, validate input, แปลง error → HTTP status
↓
service ← business logic (กฎทางธุรกิจ) — ไม่รู้จัก HTTP, ไม่รู้จัก SQL โดยตรง
↓
repository ← คุยกับ DB (query) — ไม่รู้จัก HTTP
↓
PostgreSQLRepository — เฉพาะเรื่อง DB
javascript
// tasks.repository.mjs
import { pool } from "./db.mjs";
export const tasksRepo = {
async findAll({ limit = 50, offset = 0 } = {}) {
// SELECT = ดึงข้อมูล · * = ทุก column · FROM tasks = จากตาราง tasks · ORDER BY id = เรียงตาม id
// ⭐ LIMIT/OFFSET = pagination — กัน OOM ถ้าตารางใหญ่หลายล้านแถว (ห้าม SELECT * แบบไม่มี LIMIT ใน production)
const res = await pool.query(
"SELECT * FROM tasks ORDER BY id LIMIT $1 OFFSET $2",
[limit, offset],
);
return res.rows;
},
async findById(id) {
// WHERE id = $1 = เฉพาะแถวที่ id ตรงกับค่าที่ส่งมา
const res = await pool.query("SELECT * FROM tasks WHERE id = $1", [id]);
return res.rows[0] ?? null; // คืน null ถ้าไม่เจอ
},
async create({ title, done }) {
const res = await pool.query(
// INSERT INTO ... = เพิ่มแถวใหม่ · VALUES = ค่าที่จะใส่ · RETURNING * = ให้คืนแถวที่เพิ่งสร้างกลับมา
"INSERT INTO tasks (title, done) VALUES ($1, $2) RETURNING *",
[title, done],
);
return res.rows[0]; // RETURNING * คืนแถวที่เพิ่งสร้าง (รวม id ที่ DB gen ให้อัตโนมัติ)
},
};Service — business logic + แปลง error ของ domain
javascript
// errors.mjs — custom error classes (บทที่ 6 สอนทำไว้แล้ว สรุปสั้น ๆ ตรงนี้)
// export class NotFoundError extends Error {
// constructor(message) { super(message); this.name = "NotFoundError"; }
// }
// export class ValidationError extends Error { /* ... */ }
// tasks.service.mjs
import { tasksRepo } from "./tasks.repository.mjs";
import { NotFoundError } from "./errors.mjs"; // custom error (บทที่ 6)
export const tasksService = {
list: () => tasksRepo.findAll(),
async get(id) {
const task = await tasksRepo.findById(id);
if (!task) throw new NotFoundError(`ไม่เจอ task id ${id}`); // domain error
return task;
},
create: (data) => tasksRepo.create({ done: false, ...data }),
};4. Route + validation + error format มาตรฐาน
Validate input ด้วย Zod
อย่าเชื่อ input จาก client — validate ก่อนเสมอ (บทที่ 7 ใช้ Zod กับ config มาแล้ว):
javascript
// tasks.schema.mjs
import { z } from "zod";
export const createTaskSchema = z.object({
title: z.string().min(1, "title ห้ามว่าง").max(200),
done: z.boolean().optional(),
});Route handler
javascript
// tasks.routes.mjs
import { Router } from "express";
import { tasksService } from "./tasks.service.mjs";
import { createTaskSchema } from "./tasks.schema.mjs";
export const tasksRouter = Router();
// GET /tasks
tasksRouter.get("/", async (req, res, next) => {
try {
res.json(await tasksService.list());
} catch (err) {
next(err); // ส่งเข้า error middleware (บทที่ 6)
}
});
// GET /tasks/:id
tasksRouter.get("/:id", async (req, res, next) => {
try {
// ⚠️ ห้ามใช้แค่ Number(req.params.id) เฉย ๆ เพราะ Number("") ได้ 0 (ผ่านเช็ค isInteger ทั้งที่ params ว่างเปล่า!)
// เช็คด้วย regex ก่อนว่าเป็นตัวเลขล้วนแล้วค่อยแปลง + ต้องเป็นจำนวนบวก (id ไม่มีทาง 0 หรือติดลบ)
const id = /^\d+$/.test(req.params.id) ? Number(req.params.id) : NaN;
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: "id ต้องเป็นจำนวนเต็มบวก" });
res.json(await tasksService.get(id)); // throw NotFoundError ถ้าไม่เจอ → error middleware แปลงเป็น 404
} catch (err) {
next(err);
}
});
// POST /tasks
tasksRouter.post("/", async (req, res, next) => {
try {
// validate — ถ้าผิด safeParse คืน success: false พร้อมรายละเอียด
const parsed = createTaskSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: "ข้อมูลไม่ถูกต้อง",
details: parsed.error.flatten().fieldErrors, // บอกว่า field ไหนผิด
});
}
const task = await tasksService.create(parsed.data);
res.status(201).json(task);
} catch (err) {
next(err);
}
});ประกอบเป็น app + error middleware รวมศูนย์
javascript
// app.mjs
import express from "express";
import { tasksRouter } from "./tasks.routes.mjs";
import { NotFoundError } from "./errors.mjs";
export const app = express();
app.use(express.json());
app.use("/tasks", tasksRouter);
// error middleware รวมศูนย์ (บทที่ 6) — แปลง error → HTTP status + format เดียวกันทั้งแอป
app.use((err, req, res, next) => {
// ⭐ ใส่ requestId เพื่อให้ client report bug ได้ระบุได้ว่า request ไหนพัง (สมมติมี middleware gen req.id ก่อน)
const requestId = req.id ?? "unknown";
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, requestId });
}
console.error({ requestId, err }); // log error จริงไว้ฝั่ง server พร้อม id
res.status(500).json({ error: "เกิดข้อผิดพลาดภายใน", requestId }); // อย่ารั่ว stack ให้ client
});💡 สังเกต: route จับ NotFoundError ที่ service โยนมา แล้ว แปลงเป็น HTTP 404 ที่ชั้น error middleware ที่เดียว — service ไม่ต้องรู้จัก HTTP เลย นี่คือข้อดีของการแยกชั้น · ทั้งหมดนี้คือสิ่งที่ NestJS ทำให้เป็นระบบอัตโนมัติด้วย DI (Dependency Injection — ตัวจัดการ object ใช้แทน new เอง), Exception Filter (จับ error → HTTP), Validation Pipe (validate input อัตโนมัติ) — รายละเอียดใน NestJS บทที่ 2–4
📋 มาตรฐาน error format: ที่ทำในบทนี้คือ format ง่าย ๆ (
{ error, requestId }) ส่วนมาตรฐานสากลคือ RFC 7807 — Problem Details for HTTP APIs ({ type, title, status, detail, instance }) ใช้ถ้าทำ public API/ทำงานกับลูกค้าหลายเจ้า · สำหรับ rate-limit/retry ให้ใส่ headerRetry-Afterด้วย
5. Transaction + ปิด pool ตอน shutdown
Transaction — หลายคำสั่งต้องสำเร็จพร้อมกัน หรือพังพร้อมกัน
เช่นโอนเงิน: หักบัญชี A + เพิ่มบัญชี B ต้องเกิดทั้งคู่ ถ้าอันใดพังต้องย้อนทั้งหมด (atomic = สำเร็จทั้งหมดหรือล้มทั้งหมด ไม่มีครึ่ง ๆ กลาง ๆ) — ต้อง ยืม client ตัวเดียว จาก pool มาทำทั้ง transaction:
javascript
import { pool } from "./db.mjs";
async function transfer(fromId, toId, amount) {
if (fromId === toId) throw new Error("บัญชีต้นทางและปลายทางห้ามซ้ำกัน"); // กันเคส no-op ที่ล็อกแถวตัวเองซ้ำ
const client = await pool.connect(); // ยืม client เฉพาะตัว (ไม่ใช่ pool.query)
try {
await client.query("BEGIN"); // BEGIN = เริ่ม transaction (เปิดกลุ่มคำสั่งที่ต้องสำเร็จพร้อมกัน)
// ⭐ SELECT ... FOR UPDATE = ล็อกแถวที่อ่านไว้ก่อน (กัน transaction อื่นแก้พร้อมกัน → lost update)
// อ่านยอดปัจจุบันมาเช็คก่อน (กันยอดติดลบ)
const { rows } = await client.query(
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
[fromId],
);
if (rows[0].balance < amount) throw new Error("ยอดไม่พอ");
// UPDATE ... SET = แก้ค่า column ของแถวที่ตรงเงื่อนไข WHERE
await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, fromId]);
await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, toId]);
await client.query("COMMIT"); // COMMIT = ยืนยันทุกคำสั่งใน transaction (สำเร็จทั้งคู่ → บันทึกจริง)
} catch (err) {
await client.query("ROLLBACK"); // ROLLBACK = ย้อนทุกคำสั่งกลับ (พัง → เหมือนไม่เคยทำ)
throw err;
} finally {
client.release(); // ⭐ คืน client เข้า pool เสมอ (ไม่งั้น pool หมด!)
}
}🔴 Pitfall ร้ายแรง — pool exhaustion: ยืม client ด้วย
pool.connect()แล้ว ลืมclient.release()(โดยเฉพาะตอน error/throw กลางคัน หรือreturnก่อนถึงrelease()) → connection ค้าง ยืมไปเรื่อย ๆ จน pool หมด → ทุก request ใหม่ค้างรอ connection ที่ไม่มีวันคืน → ระบบตาย · กฎเหล็ก: ทุกpool.connect()ต้องมีclient.release()ในfinallyblock เสมอ ไม่มีข้อยกเว้น💡 (หัวข้อขั้นสูง ข้ามได้ถ้ายังไม่เจอปัญหานี้) Isolation level (ระดับการแยกกันของ transaction — กติกาว่า transaction หนึ่งจะเห็นการเปลี่ยนแปลงของอีก transaction ที่ยังไม่เสร็จมากแค่ไหน): PostgreSQL default คือ
READ COMMITTED(อ่านได้แค่ data ที่ commit แล้ว แต่ยัง race ได้) ถ้าเจอบั๊กพวก lost update (การอัปเดตของ transaction หนึ่งถูกทับหายโดยอีก transaction ที่ทำงานพร้อมกัน) หรือ phantom read (query เดิมซ้ำสองครั้งใน transaction เดียวกัน แต่ได้จำนวนแถวไม่เท่าเดิม เพราะมี transaction อื่นแทรกข้อมูลใหม่เข้ามาระหว่างนั้น) แบบที่SELECT FOR UPDATEแก้ไม่ครบ ลองเปลี่ยนเป็นSERIALIZABLE(BEGIN ISOLATION LEVEL SERIALIZABLE) แล้ว retry เมื่อเจอ serialization error · NestJS บทที่ 4 (TypeORM/Prisma transaction) ครอบคลุมเรื่องนี้ละเอียดกว่า
ปิด pool ตอน graceful shutdown (ต่อจากบทที่ 7)
javascript
// ใน shutdown handler (บทที่ 7) — สมมติ import { server } from "./server.mjs" และ pool จาก db.mjs
import { server } from "./server.mjs"; // http server ที่ app.listen() คืนมา (บทที่ 7)
import { pool } from "./db.mjs";
async function shutdown(signal) {
console.log(`รับ signal ${signal} — เริ่ม graceful shutdown`);
server.close(async () => { // หยุดรับ request ใหม่ + รอ request ที่ค้างเสร็จ
await pool.end(); // ปิดทุก connection ในบ่อให้เรียบร้อย
console.log("ปิด DB pool แล้ว");
process.exit(0);
});
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);🛠️ Checkpoint 9 — ลงมือก่อนไปบทถัดไป
ใบ้: ยังไม่มี PostgreSQL? รันเร็ว ๆ ด้วย Docker (เครื่องมือรันแอปใน container แยกจากเครื่องเรา ติดตั้งครั้งเดียวที่ docker.com):
docker run --name pg -e POSTGRES_PASSWORD=pass -p 5432:5432 -d postgres:16แล้วต่อด้วยpsql postgresql://postgres:pass@localhost:5432/postgres(หรือใช้ Docker บทที่ 0 ถ้ามีในเล่ม) แล้วสร้างตารางtasks(id serial primary key, title text, done boolean)— (serial= ชนิดข้อมูลที่ DB เพิ่มเลข id ให้อัตโนมัติทีละ 1 ·primary key= คีย์หลักที่ระบุแต่ละแถวไม่ซ้ำกัน)
- ตั้ง
db.mjsที่สร้างPoolจากDATABASE_URL(env, บทที่ 7) แล้วลองpool.query("SELECT NOW()")ให้สำเร็จ - เขียน
tasksRepoครบ CRUD (findAll/findById/create/update/delete) ด้วย parameterized query ทั้งหมด - พิสูจน์ SQL injection: ลองทำเวอร์ชันที่ต่อ string (ในเครื่อง dev เท่านั้น!) แล้วส่ง id เป็น
1 OR 1=1ดูว่ามันดึงทุกแถว — แล้วแก้เป็น$1 - ประกอบ route → service → repository + Zod validation + error middleware ให้ครบ ทดสอบด้วย
curl/supertest (บทที่ 8) - เขียน transaction ง่าย ๆ (เช่นสร้าง task + เขียน log ลงอีกตาราง) ที่ commit/rollback ถูก แล้วลองจงใจให้คำสั่งที่ 2 พัง ดูว่าคำสั่งแรกถูก rollback
- เพิ่ม
pool.end()ใน graceful shutdown แล้ว Ctrl+C ดูว่าปิด pool ก่อนตาย
เฉลยข้อ 2 (update):
javascriptasync update(id, { title, done }) { const res = await pool.query( "UPDATE tasks SET title = $1, done = $2 WHERE id = $3 RETURNING *", [title, done, id], ); return res.rows[0] ?? null; }
สรุปบทที่ 9
- ต่อ PostgreSQL ด้วย
pg+Pool(ยืม-คืน connection ใช้ซ้ำ) — สร้าง pool ครั้งเดียว export ไปทั้งแอป · อย่าเปิด connection ใหม่ทุก query - Parameterized query (
$1, $2) เสมอ — กัน SQL injection · ห้าม ต่อ user input ใส่ SQL string - แยกชั้น route → service → repository — service ไม่รู้จัก HTTP, repository ไม่รู้จัก HTTP → ทดสอบ/ดูแลง่าย
- Validate input (Zod) + error format มาตรฐาน รวมศูนย์ที่ error middleware
- Transaction: ยืม client เดียว
BEGIN/COMMIT/ROLLBACK+client.release()ใน finally เสมอ (ไม่งั้น pool หมด) - ปิด
pool.end()ตอน graceful shutdown - ทั้งหมดนี้คือสิ่งที่ NestJS + TypeORM/Prisma/Drizzle/Kysely ทำให้เป็นระบบ — ตอนนี้คุณรู้ว่าข้างใต้คืออะไร
- 🆕 Node 22+ มี built-in
node:sqlite(experimental) — ทดสอบ/prototype ได้โดยไม่ต้องลง dependency (Node 22.x เวอร์ชันต้น ๆ อาจต้องเปิดแฟล็ก--experimental-sqliteก่อนใช้ — เช็ก docs ของ patch version ที่ใช้จริง) · production จริงยังคงใช้ PostgreSQL/MySQL ผ่านpg/mysql2(+ Prisma/Drizzle/Kysely ทับอีกที)
บทต่อไป (ขั้นสูง): ความจริงของ single-thread — เมื่อไหร่ Node ช้า, worker_threads / cluster / child_process ช่วยยังไง, และวิธี profile หา bottleneck