這一課完成後:你的 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 的記憶體裡。只要:

Ctrl + C → process 結束 → RAM state 消失 → node server.js → 回到程式初始值

真正的 Web App 通常需要另一層專門保存重要資料。這就是 database 的工作之一。

這一課為什麼選 SQLite?

SQLite 是嵌入式 relational database。它不需要你另外啟動一個 database server;資料可以直接放在一個檔案裡,非常適合先學資料模型與 SQL。

這一課使用 Node 內建的 node:sqlite:

Node.js 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 會建立它;如果已經存在,就打開同一份資料。

Node process → DatabaseSync → data.sqlite file

這和 :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
  )
`);

可以先把它讀成:

tasks → table
id / title / done / created_at → columns
每一筆 task → row

CREATE TABLE 的工作就是定義 schema:有哪些 columns、資料型別、預設值與 constraints。SQLite 官方也把 primary key、NOT NULL、UNIQUE、CHECK、foreign key 等視為 table schema 的一部分。

SQLite CREATE TABLE 官方文件

Primary key:每一筆 row 要有穩定身份

這裡:

id INTEGER PRIMARY KEY

id 是每一筆 task 的 primary key。

Title 可能重複、done 可能一樣,但兩筆不同 task 不應該靠「第幾個 array index」來辨認。

Resource identity → primary key → /api/tasks/:id

這也讓你的 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);

資料流已經變成:

HTTP GET → SQL SELECT → SQLite rows → JavaScript objects → JSON response

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 結構和資料值分開。

SQL structure 固定
+ 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。

Process restart ≠ Data reset

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

Create → INSERT → POST
Read → SELECT → GET
Update → UPDATE → PATCH
Delete → DELETE → DELETE

CRUD 不是一個框架功能,而是一組很常見的資料操作模式。

Database constraint 和 request validation 是兩層不同防線

API 已經會檢查 title,但 table 還可以自己拒絕 null:

title TEXT NOT NULL

比較成熟的系統不會只相信某一層。

Request validation → 給 client 清楚錯誤
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。

BEGIN → 多個 SQL operations → COMMIT
中途出錯 → 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 生命週期裡一致。

最後收斂:資料現在到底放在哪裡?

Frontend / client
↓ 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 重啟後資料仍然存在。