Day 21 那張 feedback 表單,現在收得到、驗得過,但送出後就沒了:handler 只驗證、只回傳,沒把資料留下來。這篇要把它接上資料層,讓每一筆 feedback 真的存進去、之後查得回來、算得出平均。
接資料庫之前,得先決定要用哪一個,因為這個選擇會影響後續部署。資料層如果只支援單一平台,日後換部署平台時,app 的資料存取方式也得跟著改。若要以 Cloudflare 為主要部署平台、同時保留轉到 Vercel 的可能,資料庫的連線方式也必須兩邊都能用。
如果你哪天可能換部署平台,就別用「只在那個平台裡才連得到」的資料庫。
資料庫接進 app,大致分兩種接法:
env.DB)去用它。它沒有對外的網址、沒有連線字串,只有跑在那個平台上的程式才叫得動。Cloudflare 有自家的 SQLite 資料庫 D1,走的是綁定;這篇要用的 Turso,走的是連線字串。放在一起對照:
| Cloudflare D1 | Turso / libSQL | |
|---|---|---|
| 存取方式 | Workers/Pages 的 binding(env.DB) |
連線字串 + token,走 HTTPS |
| 有沒有對外連線位址 | 沒有,資料庫只在平台內部 | 有(libsql://…turso.io) |
| 能從哪些 runtime 連 | 主要是 Cloudflare Workers/Pages | Node、容器、serverless、Workers、Vercel… |
| 換部署平台時 | 資料層要跟著換架構 | 同一份程式照連 |
(D1 也有一個管理用的 REST API 能跑查詢,但那是工具/管理用途、有流量限制,不是拿來當線上低延遲資料層的路徑。)
需要在 Cloudflare 與 Vercel 之間保留選擇,就用 Turso。若專案確定只部署在 Cloudflare,D1 的綁定能少一層連線設定。
Turso 是把 SQLite 託管起來、開一個 HTTPS 端點讓你連的資料庫服務。你不用自己顧一台資料庫機器,註冊後拿到一個 libsql://…turso.io 的網址和一組 token 就能連。
Turso 底層使用 libSQL,它是 SQLite 的分支。因此,原有的 SQLite/SQL 知識仍可沿用,不必另學一套資料庫。Turso 走 HTTP,Cloudflare Workers 這類無法開啟傳統資料庫連線的 edge 環境也能連線。
接下來會用到三個工具:
db.select().from(feedback),Drizzle 會將它轉成 SQL 送往資料庫,查詢結果自動帶型別。Drizzle 跑在 app 裡。(ORM 就是「讓你用寫程式的方式操作資料庫、不必手拼 SQL 字串」的工具;Drizzle 的特色是薄、貼近 SQL。)schema 只需寫一次:drizzle-kit 用它建立資料表,Drizzle 則在 app 裡用它查詢。
先裝套件。app 要用的是 drizzle-orm 和 @libsql/client,遷移工具 drizzle-kit 只在開發時用、裝成 devDependency:
npm install drizzle-orm @libsql/client
npm install -D drizzle-kit
版本基準(2026-07-21 查證):
drizzle-orm0.45.2、@libsql/client0.17.4、drizzle-kit0.31.10。資料層這類套件迭代快,實作時以官方文件當下版本為準。
接著定義 schema。這份定義與 runtime 無關,欄位對應 Day 21 那張表單收的資料(評分、留言、哪一篇):
// src/db/schema.ts
import { sql } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const feedback = sqliteTable('feedback', {
id: integer('id').primaryKey({ autoIncrement: true }),
slug: text('slug').notNull(), // 哪一篇文章的 feedback
rating: integer('rating').notNull(), // 1–5,範圍在 action 的 Zod 已擋
message: text('message').notNull(),
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
});
// 讓 insert / select 兩端都拿到型別,不用自己手寫 interface
export type InsertFeedback = typeof feedback.$inferInsert;
export type SelectFeedback = typeof feedback.$inferSelect;
然後設定 drizzle-kit,指定 schema 路徑與資料庫連線。連 Turso 時,dialect 要用 'turso':
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './migrations',
dialect: 'turso', // 連 Turso 用 'turso';'sqlite' 是給本機檔案型的
dbCredentials: {
url: process.env.TURSO_DATABASE_URL ?? 'file:local.db',
authToken: process.env.TURSO_AUTH_TOKEN,
},
});
drizzle-kit 跑在開發機的 Node 環境,因此這裡可以使用 process.env。app 的 runtime 有不同的 secret 讀取方式,連線時不能照搬這段設定。未設定 url 時,設定會退回本機的 file:local.db,不必先建立帳號也能在本機試跑。
最後產生並套用 migration。migration 是資料庫的「schema 版本控制」:修改 schema 後,產生的 migration 會記錄該次變更的 SQL;套用後,資料庫才會有對應的表:
npx drizzle-kit generate # 把 schema 變成 SQL migration 檔
npx drizzle-kit migrate # 套用(push 是開發期直推、跳過檔案,正式流程用 migrate)
generate 產生的 SQL 如下(migrations/0000_*.sql),欄位與上面的 schema 一一對應:
CREATE TABLE `feedback` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`slug` text NOT NULL,
`rating` integer NOT NULL,
`message` text NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);
套用 migration 後,資料庫裡就有了這張表。
app 要連 Turso,寫一個工廠函式,需要時才建連線:
// src/db/index.ts
import { drizzle } from 'drizzle-orm/libsql/web'; // 注意 /web
import { createClient } from '@libsql/client/web'; // 注意 /web
import * as schema from './schema';
export function createDb(url: string, authToken?: string) {
const client = createClient({ url, authToken });
return drizzle({ client, schema });
}
這段有兩個限制,都來自 app 執行於 Cloudflare Workers:
一、要用 /web 入口。 Cloudflare Workers 跑的是 workerd,不是 Node,因此沒有 Node 的原生模組。@libsql/client 的主入口帶了 Node 依賴,打包進 Worker 會失敗;/web 是純 fetch 版本,可以打包進 Worker。(drizzle-kit 和 seed 腳本跑在 Node,還要開啟本機檔案,因此使用主入口。兩者的差別在執行環境,不是資料庫。)
二、連線要在「請求當下」才建,不能在模組頂層。 若在檔案頂層用 const db = createDb(process.env.TURSO_DATABASE_URL) 建立共用 client,部署到 Cloudflare 時會失敗。Workers 在建置和模組載入期間拿不到 secret,此時建立的 client 會收到空網址。secret 要到「處理請求」時才能取得,因此連線要包成工廠函式,等請求進入 handler 後再呼叫。
連線需要 url 和 token,兩者都是 secret。Cloudflare runtime 不能沿用前面 drizzle-kit 的讀法。
舊教學仍可見 Astro.locals.runtime.env 的寫法。這個存取方式已在 @astrojs/cloudflare v13(對應 Astro 6)移除,v14/Astro 7 也沒有恢復,因此不應再使用。
Cloudflare 現在的官方做法是從一個虛擬模組拿:
import { env } from 'cloudflare:workers';
const url = env.TURSO_DATABASE_URL;
cloudflare:workers 可以使用,但它是 Cloudflare 專屬模組;部署到 Vercel 時,這段程式必須修改。為了保留跨平台部署能力,讀取 secret 也要避免平台專屬介面。Astro 內建的 astro:env 提供這種跨平台讀法。
在 astro.config.mjs 宣告一次有哪些環境變數、各是什麼性質:
import { defineConfig, envField } from 'astro/config';
export default defineConfig({
env: {
schema: {
// server + secret:只在伺服器可讀、不會進到瀏覽器
TURSO_DATABASE_URL: envField.string({ context: 'server', access: 'secret' }),
TURSO_AUTH_TOKEN: envField.string({ context: 'server', access: 'secret', optional: true }),
},
},
// …adapter 等其他設定
});
宣告完,app 裡就這樣拿:
import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from 'astro:env/server';
這一行 import 在 Cloudflare 和 Vercel 都能讀取 secret,各 adapter 會對接平台的 secret 機制。app 不必判斷目前部署在哪個平台。
至於 secret 實際放哪:Cloudflare 正式部署用 wrangler secret put TURSO_DATABASE_URL 設;本機開發放專案根目錄的 .dev.vars(記得加進 .gitignore,別上傳)。
回到 Day 21 只負責驗證的 handler,接上前面的資料庫連線,讓它寫入資料後再查回結果:
// src/actions/index.ts(handler 部分)
import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from 'astro:env/server';
import { avg, count, eq } from 'drizzle-orm';
import { createDb } from '../db';
import { feedback } from '../db/schema';
// …defineAction 的 input 驗證維持 Day 21 那套 Zod
handler: async ({ rating, message, slug }) => {
const articleSlug = slug ?? 'unknown';
const db = createDb(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN); // 請求當下才建連線
// 落地這一筆
await db.insert(feedback).values({ slug: articleSlug, rating, message });
// 查回這篇目前的平均分與則數,一起回給前端
const [stats] = await db
.select({ average: avg(feedback.rating), total: count() })
.from(feedback)
.where(eq(feedback.slug, articleSlug));
return { ok: true, slug: articleSlug, average: stats.average, total: stats.total };
},
Day 21 的 handler 只有驗證與回傳;這裡多了「存 + 查」兩步。使用者送出 feedback 後,回傳訊息會從「你剛送了 5 分」變成「這篇目前平均 X 分、共 Y 則」,這些數字來自資料庫的查詢結果。
和 Day 21 一樣,接資料庫的頁面(執行 action 或 endpoint)必須在收到請求時才執行,因此要設定 export const prerender = false。否則頁面會在 build 時被預先產生成靜態內容,無法接收請求。
先在本機用 file:local.db 寫入幾筆資料,再用 Drizzle 查回來:
=== day-21 的 feedback(新到舊)===
#1 [5★] Action 跟 Endpoint 的分界終於講清楚了。 (2026-07-20 22:59:55)
#2 [4★] 漸進增強那段很有用,沒 JS 也能送。 (2026-07-20 22:59:55)
=== 聚合 ===
平均 4.5 分、共 2 則
(時間戳是 UTC,這是 SQLite CURRENT_TIMESTAMP 的預設行為,不是 bug。)insert、select、avg、count 這套查詢邏輯確實跑得起來,回來的 average、total 就是前端要顯示的數字。
接著用 @astrojs/cloudflare adapter 執行 npm run build,檢查整套寫法能否在 Cloudflare 上成立。結果通過:/web client、astro:env 與接上資料庫的 action 都成功打包。
turso dev上面的 seed 使用 file:local.db 驗證查詢邏輯,但這和啟動 app、從瀏覽器送出表單是兩條不同路徑:app 無法直接連線到 local.db 檔案。
這項限制來自兩個入口的執行環境:app 使用 @libsql/client/web(/web 入口、純 fetch),只能走 HTTP,無法開啟本機檔案;drizzle-kit 和 seed 使用 Node 主入口,才能開啟 file:local.db。兩邊的能力如下:
| 用哪個入口 | 開得了 file:local.db? |
|
|---|---|---|
db:migrate、seed(Node 工具) |
@libsql/client 主入口 |
開得了,直接讀寫檔案 |
| app 的 action/endpoint(跑在 Workers) | @libsql/client/web |
開不了,只認 HTTP 端點 |
因此,只完成 local.db seed 就直接執行 npm run dev,表單仍然送不進去。app 需要 HTTP 網址,而 file:local.db 不是 HTTP 端點。可以用 Turso CLI 把同一個 local.db 檔在本機提供為 HTTP 服務:
npm run db:migrate # 建表:這步走 file: 入口,直接寫進 local.db
turso dev --db-file local.db # 把 local.db 服務成本機 HTTP 伺服器(預設 127.0.0.1:8080)
再把前面存 secret 的 .dev.vars 指到這個本機伺服器(本機不驗 token,留空即可),另開一個終端跑 app:
# .dev.vars
TURSO_DATABASE_URL=http://127.0.0.1:8080
TURSO_AUTH_TOKEN=
npm run dev
此時的資料鏈分成三層:
瀏覽器 ──▶ app(astro dev, :4321)──HTTP──▶ libsql 伺服器(turso dev, :8080)──開檔──▶ local.db
127.0.0.1:8080 不是資料庫,是資料庫的「HTTP 門口」——turso dev 起的伺服器進程,背後才是 local.db。這三層全在你這台機器上。那段 HTTP 走的是 loopback,不碰外部網路、離線也跑得動;用 HTTP 只是因為 app 端的 client 只會講這個協定,不代表資料送出去了。
turso dev 讓本機與 production 使用相同的連線方式。上線時不必改動程式,只要把 .dev.vars 裡的 http://127.0.0.1:8080 換成雲端的 libsql://…turso.io,再補上 token。
migrations/)要進,那是 schema 的歷史;本機的 local.db 和裝 secret 的 .dev.vars 不要進。astro:env 的 secret 是 build 時不檢查、runtime 才讀。 好處是本機沒設 secret 也 build 得過;代價是線上忘了設,會等到請求進來那一刻才抓不到值。部署前記得確認 secret 設了。dialect 別填錯。 連 Turso 用 'turso';'sqlite' 是給本機 better-sqlite3 那種檔案型資料庫的,填錯 drizzle-kit 會走錯連線方式。avg() 回的是字串。 SQL 聚合函式回來的平均值是字串型別,前端要顯示成「4.5」記得自己轉數字、控小數位。這裡選 Turso 而非 D1,並以 astro:env 取代 cloudflare:workers,是為了讓同一套資料存取程式能跨平台沿用。若不需要轉移部署平台,D1 仍是設定更少的選項。
資料存得進、查得回了,但還不知道這次請求是誰送的、他登入了沒。Day 23 會用 middleware 和 locals,在請求進到 handler 前辨認使用者;Day 24 再接上登入與收藏。
本日程式碼:step-22|只看這天的改動:step-21...step-22