這一課完成後:你的 tasks 不再是 JavaScript array,而會存在 data.sqlite。你會建立 table、用 primary key 找資料、寫 SELECT / INSERT / UPDATE / DELETE,使用 prepared statements 綁定參數,並驗證 server 重啟後資料仍然存在。
先重現問題:RAM 不是持久層
上一課有:
let tasks = [
{ id: 1, title: "Learn routes", done: true }
];
這份資料存在目前 Node process 的記憶體裡。只要:
真正的 Web App 通常需要另一層專門保存重要資料。這就是 database 的工作之一。
這一課為什麼選 SQLite?
SQLite 是嵌入式 relational database。它不需要你另外啟動一個 database server;資料可以直接放在一個檔案裡,非常適合先學資料模型與 SQL。
這一課使用 Node 內建的 node:sqlite:
版本提醒:node:sqlite 是較新的 Node API。如果 require("node:sqlite") 找不到,先更新到目前支援它的 Node LTS。這門課不會為了相容很舊的 Node 改教另一套第三方套件。
先確認你的 Node 能載入 SQLite
在 Terminal:
node -e "console.log(require('node:sqlite'))"
如果能看到 module 內容,就可以繼續。
現在把專案整理成:
web-app-playground/
├─ server.js
├─ data.sqlite ← 第一次啟動後才會出現
└─ .gitignore
Database file 通常不要直接 commit
在 .gitignore 加:
data.sqlite
data.sqlite-shm
data.sqlite-wal
程式碼與 schema 應該可以重建資料庫結構;實際 runtime data 不應該因為你 push source code 就一起被公開。
之後 deployment 會正式談 migration。現在先讓 schema initialization 放在程式裡。
打開第一個 SQLite database
在 server.js 最上方加入:
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync("data.sqlite");
如果檔案不存在,SQLite 會建立它;如果已經存在,就打開同一份資料。
這和 :memory: 不一樣。:memory: 只活在記憶體,這堂課故意使用實體檔案。
Table:先定義資料長什麼樣
我們需要一張 tasks table:
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
可以先把它讀成:
id / title / done / created_at → columns
每一筆 task → row
CREATE TABLE 的工作就是定義 schema:有哪些 columns、資料型別、預設值與 constraints。SQLite 官方也把 primary key、NOT NULL、UNIQUE、CHECK、foreign key 等視為 table schema 的一部分。
Primary key:每一筆 row 要有穩定身份
這裡:
id INTEGER PRIMARY KEY
id 是每一筆 task 的 primary key。
Title 可能重複、done 可能一樣,但兩筆不同 task 不應該靠「第幾個 array index」來辨認。
這也讓你的 API path 和 database row 開始接在一起。
先手動塞一筆資料
你可以暫時執行:
db.exec(`
INSERT INTO tasks (title, done)
VALUES ('First database task', 0)
`);
但不要把這段永久留在每次啟動都會跑的位置,不然 server 每重開一次就多一筆。
真正的 POST route 會用 prepared statement 寫入。
SELECT:把資料讀回 JavaScript
準備 statement:
const selectTasks = db.prepare(`
SELECT id, title, done, created_at
FROM tasks
ORDER BY id DESC
`);
讀取:
const rows = selectTasks.all();
console.log(rows);
.all() 會得到多筆 rows;每一 row 會變成 JavaScript object。
SQLite 裡我們用 0 / 1 存 boolean,因此回 API 前可以轉成真正的 boolean:
function toTask(row) {
return {
...row,
done: Boolean(row.done)
};
}
GET /api/tasks 不再讀 array
上一課:
sendJson(response, 200, tasks);
現在改成:
const rows = selectTasks.all();
const tasks = rows.map(toTask);
sendJson(response, 200, tasks);
資料流已經變成:
Prepared statement:不要把使用者輸入直接拼進 SQL
建立 INSERT statement:
const insertTask = db.prepare(`
INSERT INTO tasks (title, done)
VALUES (?, 0)
`);
執行時把資料另外綁進去:
const result = insertTask.run(title);
? 是 parameter placeholder。
不要寫成:
db.exec(`INSERT INTO tasks (title) VALUES ('${title}')`);
把不可信輸入直接拼進 SQL 會帶來 SQL injection 與 quoting 問題。Prepared statement 讓 SQL 結構和資料值分開。
+ bound parameters
→ database 執行
POST /api/tasks:從 request body 寫進 database
沿用上一課已經驗證過的 title:
const title = body.title.trim();
const result = insertTask.run(title);
const id = Number(result.lastInsertRowid);
再把剛建立的 row 查回來:
const selectTaskById = db.prepare(`
SELECT id, title, done, created_at
FROM tasks
WHERE id = ?
`);
const row = selectTaskById.get(id);
sendJson(response, 201, toTask(row));
lastInsertRowid 讓你知道剛剛 INSERT 出來的是哪一筆。
現在做最重要的測試:重開 server
先 POST 一筆新 task,再 GET 確認它存在。
然後:
Ctrl + C
node server.js
再 GET:
curl http://localhost:3000/api/tasks
如果剛才建立的 task 還在,表示你第一次真正完成 persistence。
UPDATE:讓 task 可以切換 done
準備:
const updateTaskDone = db.prepare(`
UPDATE tasks
SET done = ?
WHERE id = ?
`);
假設我們支援:
PATCH /api/tasks/3
{
"done": true
}
先驗證:
if (typeof body.done !== "boolean") {
sendJson(response, 400, {
error: "done must be a boolean"
});
return;
}
再更新:
const result = updateTaskDone.run(
body.done ? 1 : 0,
id
);
if (result.changes === 0) {
sendJson(response, 404, {
error: "Task not found"
});
return;
}
const row = selectTaskById.get(id);
sendJson(response, 200, toTask(row));
changes === 0 表示沒有任何 row 被更新,這時通常代表 id 不存在。
DELETE:刪掉一筆 row
準備:
const deleteTask = db.prepare(`
DELETE FROM tasks
WHERE id = ?
`);
Route:
const result = deleteTask.run(id);
if (result.changes === 0) {
sendJson(response, 404, {
error: "Task not found"
});
return;
}
response.writeHead(204);
response.end();
204 No Content 表示操作成功,但 response body 沒有內容。
到這裡,你已經走完 CRUD
Read → SELECT → GET
Update → UPDATE → PATCH
Delete → DELETE → DELETE
CRUD 不是一個框架功能,而是一組很常見的資料操作模式。
Database constraint 和 request validation 是兩層不同防線
API 已經會檢查 title,但 table 還可以自己拒絕 null:
title TEXT NOT NULL
比較成熟的系統不會只相信某一層。
Database constraints → 守住資料本身的一致性
之後還會加入 authentication / authorization,它又是另一層。
不要把 database error 原封不動丟給 client
如果 SQLite 丟出 exception,server 可以在內部 log:
console.error(error);
但 public response 不需要把檔案路徑、SQL 細節、stack trace 全部送出去。
sendJson(response, 500, {
error: "Internal server error"
});
對使用者提供可理解的錯誤,對開發者保留足夠的內部診斷資訊,這兩件事要分開。
同步 SQLite API 的邊界
DatabaseSync 的操作是同步執行。對這個小型本機練習,它能讓資料庫概念更容易看懂。
但不要因此得到「所有 production backend 都應該把所有 database 工作同步塞在主執行緒」的結論。資料量、併發、部署環境與 database 類型都會影響真正的架構選擇。
這條 Learning path 目前的目的,是先把 HTTP → database → HTTP 的資料流建立正確,再談 ORM、connection pool、distributed database 等更大的系統問題。
Transaction 先知道它存在
如果一次操作必須修改多筆資料,而且「要嘛全部成功、要嘛全部失敗」,你會需要 transaction。
中途出錯 → ROLLBACK
這一課的單筆 task CRUD 還不需要深入 transaction;先記住 database 不只有「一條 SQL 一條 SQL」的能力。
小挑戰:完成持久化 tasks API
需求 1:刪掉 JavaScript 裡原本的 let tasks = [...]。
需求 2:建立 data.sqlite 與 tasks table。
需求 3:GET /api/tasks 從 SELECT 取得資料。
需求 4:GET /api/tasks/:id 從 primary key 查單筆資料。
需求 5:POST /api/tasks 使用 prepared statement INSERT。
需求 6:PATCH /api/tasks/:id 可以更新 done。
需求 7:DELETE /api/tasks/:id 可以刪除資料。
需求 8:不存在的 id 要回 404。
需求 9:所有來自 client 的值都不能直接字串拼接進 SQL。
需求 10:重開 server 後,尚未刪除的 tasks 必須仍然存在。
再做一次真正的 persistence 驗收
至少依序測:
POST → GET → PATCH → GET → restart server → GET → DELETE → GET
不要只測「INSERT 成功」。你要確認 API 和 database 在整個 CRUD 生命週期裡一致。
最後收斂:資料現在到底放在哪裡?
↓ HTTP request
Backend route + validation
↓ SQL prepared statement
SQLite data.sqlite
↓ SQL result rows
Backend JSON response
↓ Client
你第一次真正把 Web App 的「持久資料層」接進來了。
下一課會處理另一個更難的問題:現在任何能呼叫 API 的人都可以讀、改、刪資料。系統需要知道「你是誰」,也需要決定「你能做什麼」。
完成條件:你的 tasks API 已完全移除記憶體 array 作為主要資料來源,使用 SQLite 完成 CRUD,使用 prepared statements 綁定 client input,而且 server process 重啟後資料仍然存在。