TL;DR
3 lựa chọn viết query cho D1:
- SQL thô qua
env.DB.prepare(): không bundle, kiểm soát SQL hoàn toàn. - Drizzle ORM: ~10KB, schema TypeScript, query builder gần SQL, tích hợp migration.
- Prisma: ~500KB vì query engine WASM, đầy đủ tính năng, DSL schema riêng.
Luận điểm chính:
Workers là môi trường khác Node. Kích thước bundle và cold start quan trọng. Prisma ở D1 chưa thật ổn định (D1 adapter mới). Drizzle là điểm ngọt hiện nay cho hầu hết project. SQL thô vẫn hợp với project đơn giản hoặc team đã quen SQL.
Bài này đi qua quy trình từng lựa chọn, pattern query phức tạp (JOIN, phân trang, transaction), migration, và đánh đổi thực tế.
Dành cho ai
- Dev đã đọc Part 6 (D1 production), muốn thêm type safety.
- Người đang viết
.prepare()ở 20+ chỗ và muốn refactor. - Ai đã dùng Prisma ở project Node, cân nhắc trên Workers.
Nên đọc trước: Part 6 (phương thức query D1), Part 9 (Router).
Sau bài này bạn sẽ:
- Thiết lập Drizzle đầu-cuối (schema → migration → query).
- Hiểu đánh đổi Prisma ở Workers (bundle + độ chín adapter).
- Biết khi nào SQL thô tốt hơn ORM.
Bài này không nói về gì
- Bên trong / kiến trúc D1: Part 6 đã cover.
- Thiết kế schema + index: d1-schema-tips.
- Tuning tối ưu query: cần bài riêng.
So sánh 3 cách
SQL thô: không ORM
Pattern đã quen ở Part 6:
interface Post { id: string; title: string; body: string; published_at: number; }
async function getPost(env: Env, slug: string): Promise<Post | null> {
return await env.DB
.prepare("SELECT id, title, body, published_at FROM posts WHERE slug = ?")
.bind(slug)
.first<Post>();
}
async function createPost(env: Env, post: Post) {
await env.DB
.prepare("INSERT INTO posts (id, title, body, published_at) VALUES (?, ?, ?, ?)")
.bind(post.id, post.title, post.body, post.published_at)
.run();
}
Ưu
- 0 dependency: không cài thêm gì.
- SQL trong tầm mắt: biết chính xác query nào chạy, dễ tối ưu.
- Không magic: không
select().from()rồi tự hỏi sinh ra SQL gì. - Migration thủ công: hợp với team quen Postgres
psql.
Nhược
- Type safety giả tạo:
first<Post>()chỉ cast, D1 không verify schema thật. - Refactor tốn sức: đổi tên cột → grep + thay thế 20 file.
- Lặp code: mỗi bảng có
get,list,create,update,deletetương tự. - Rủi ro SQL injection nếu lỡ nối string thay vì
.bind().
Khi nào SQL thô là đúng
- Schema < 10 bảng, quan hệ đơn giản.
- Team đã quen SQL, không muốn đường cong học.
- Query phức tạp cần tối ưu từng byte.
- Blog này đang dùng SQL thô cho mọi truy cập D1. Lý do: 10 bảng, query đơn giản, không cần chi phí phụ của ORM.
Drizzle ORM: điểm ngọt cho Workers
Cài đặt
npm install drizzle-orm
npm install -D drizzle-kit
Schema TypeScript
// src/db/schema.ts
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const posts = sqliteTable("posts", {
id: text().primaryKey(),
slug: text().notNull().unique(),
title: text().notNull(),
body: text().notNull(),
authorId: text("author_id").notNull(),
publishedAt: integer("published_at", { mode: "timestamp_ms" }),
readingTime: integer("reading_time"),
});
export const tags = sqliteTable("tags", {
id: text().primaryKey(),
name: text().notNull().unique(),
});
export const postTags = sqliteTable("post_tags", {
postId: text("post_id").notNull().references(() => posts.id),
tagId: text("tag_id").notNull().references(() => tags.id),
}, (t) => ({
pk: primaryKey({ columns: [t.postId, t.tagId] }),
}));
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
Schema là TypeScript. $inferSelect / $inferInsert cho ra type từ schema.
drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./migrations",
dialect: "sqlite",
driver: "d1-http",
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.D1_DATABASE_ID!,
token: process.env.CLOUDFLARE_API_TOKEN!,
},
});
Sinh migration
npx drizzle-kit generate
Tạo migrations/0001_<name>.sql với diff SQL giữa schema hiện tại và DB:
CREATE TABLE `posts` (
`id` text PRIMARY KEY NOT NULL,
`slug` text NOT NULL,
`title` text NOT NULL,
...
);
CREATE UNIQUE INDEX `posts_slug_unique` ON `posts` (`slug`);
Áp dụng migration
Dùng Wrangler (giống Part 6):
wrangler d1 migrations apply my-db --local
wrangler d1 migrations apply my-db --remote
Hoặc Drizzle tự đẩy qua HTTP API (thay wrangler):
npx drizzle-kit push # không tạo file migration, đẩy thẳng schema
Khuyến nghị: luôn dùng generate + wrangler apply cho production. push chỉ dùng trong dev để dựng prototype nhanh.
Query type-safe
import { drizzle } from "drizzle-orm/d1";
import { eq, desc, and, or, like, inArray } from "drizzle-orm";
import { posts, tags, postTags } from "./db/schema";
async fetch(request: Request, env: Env) {
const db = drizzle(env.DB);
// SELECT đơn giản
const post = await db
.select()
.from(posts)
.where(eq(posts.slug, "hello"))
.limit(1)
.then(rows => rows[0]);
// post: Post | undefined, type suy luận đầy đủ
// INSERT
await db.insert(posts).values({
id: crypto.randomUUID(),
slug: "new-post",
title: "New Post",
body: "...",
authorId: "khavan",
publishedAt: new Date(),
});
// UPDATE
await db.update(posts)
.set({ readingTime: 10 })
.where(eq(posts.id, postId));
// DELETE
await db.delete(posts).where(eq(posts.id, postId));
// WHERE nhiều điều kiện
const recentCfPosts = await db
.select()
.from(posts)
.where(
and(
eq(posts.authorId, "khavan"),
or(like(posts.title, "%Cloudflare%"), like(posts.body, "%Cloudflare%"))
)
)
.orderBy(desc(posts.publishedAt))
.limit(20);
// JOIN
const postsWithTags = await db
.select({
post: posts,
tagName: tags.name,
})
.from(posts)
.leftJoin(postTags, eq(postTags.postId, posts.id))
.leftJoin(tags, eq(tags.id, postTags.tagId))
.where(eq(posts.authorId, "khavan"));
// Mệnh đề IN
const byIds = await db
.select()
.from(posts)
.where(inArray(posts.id, ids));
}
IDE tự gợi ý đầy đủ: posts.slug → string, posts.publishedAt → Date.
Transaction (D1 batch)
import { sql } from "drizzle-orm";
await db.batch([
db.insert(posts).values(newPost),
db.insert(postTags).values({ postId: newPost.id, tagId: "cf" }),
db.update(/* stats table */).set({ count: sql`count + 1` }),
]);
Atomic giống env.DB.batch([...]) của SQL thô.
Drizzle Queries API (query lồng)
import { relations } from "drizzle-orm";
// Định nghĩa relations
export const postsRelations = relations(posts, ({ many }) => ({
postTags: many(postTags),
}));
export const postTagsRelations = relations(postTags, ({ one }) => ({
post: one(posts, { fields: [postTags.postId], references: [posts.id] }),
tag: one(tags, { fields: [postTags.tagId], references: [tags.id] }),
}));
// Query với nested
const db = drizzle(env.DB, { schema: { posts, tags, postTags, postsRelations, postTagsRelations } });
const postWithTags = await db.query.posts.findFirst({
where: eq(posts.slug, "hello"),
with: {
postTags: {
with: { tag: true }
}
}
});
// post.postTags[0].tag.name
Giống include lồng của Prisma nhưng bundle nhỏ hơn nhiều.
Ưu Drizzle
- Bundle 10KB: thân thiện với Workers.
- Schema TS: 1 nguồn sự thật, type + runtime thống nhất.
- Query builder gần SQL: dễ ánh xạ từ SQL sang Drizzle.
- Không query engine: không WASM, không tác động cold start.
- D1 hạng nhất: driver chính thức, không hack.
Nhược Drizzle
- API vẫn thay đổi: breaking change giữa minor version thỉnh thoảng xảy ra.
- Docs chưa thật trau chuốt: cần đọc source code cho edge case.
- Escape hatch SQL thô: một số case phức tạp cần
sql\…“. - Diff migration đôi khi lẫn lộn: đổi tên cột bị hiểu nhầm là drop + add.
Prisma: nặng nhưng quen thuộc
Prisma là ORM lớn nhất trong hệ sinh thái Node. D1 adapter mới có.
DSL schema
// prisma/schema.prisma
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
model Post {
id String @id @default(uuid())
slug String @unique
title String
body String
authorId String @map("author_id")
publishedAt DateTime? @map("published_at")
postTags PostTag[]
}
model Tag {
id String @id @default(uuid())
name String @unique
postTags PostTag[]
}
model PostTag {
postId String @map("post_id")
tagId String @map("tag_id")
post Post @relation(fields: [postId], references: [id])
tag Tag @relation(fields: [tagId], references: [id])
@@id([postId, tagId])
@@map("post_tags")
}
Generate + migrate
npx prisma generate
npx prisma migrate dev --name init
Dùng với D1 adapter
import { PrismaClient } from "@prisma/client";
import { PrismaD1 } from "@prisma/adapter-d1";
async fetch(request: Request, env: Env) {
const adapter = new PrismaD1(env.DB);
const prisma = new PrismaClient({ adapter });
const post = await prisma.post.findUnique({
where: { slug: "hello" },
include: { postTags: { include: { tag: true } } },
});
await prisma.post.create({
data: {
slug: "new",
title: "New",
body: "...",
authorId: "khavan",
postTags: {
create: [{ tag: { connect: { name: "cf" } } }]
}
}
});
}
API quen thuộc với Prisma Node.
Cảnh báo kích thước bundle
$ wrangler deploy
...
Total Upload: 520.45 KiB / gzip: 180.22 KiB
~180KB gzip. Gấp 10-20 lần Drizzle. Cold start vẫn OK ~10-20ms nhưng đáng kể.
Ưu Prisma
- API chín: chín nhất trong các ORM TS.
- Chuyển từ project Node mượt: cùng API.
- Query lồng tốt: chuỗi
includedài OK. - Studio UI: dashboard xem/sửa dữ liệu.
Nhược Prisma trên Workers
- Bundle 500KB: vượt giới hạn Free (3MB) không sao, nhưng gần giới hạn Paid 10MB nếu ứng dụng khác lớn.
- D1 adapter còn preview: một số tính năng Prisma chưa hỗ trợ (migration, giới hạn introspection).
- Cold start tăng: 5ms → 20-30ms với Prisma.
- Luồng migration phức tạp: Prisma migrate cần Postgres proxy hoặc cách đi đường vòng.
Khi nào bỏ qua ORM hoàn toàn
ORM không miễn phí. Lý do bỏ qua:
Project đơn giản
Blog 10 bảng, query đơn giản — SQL thô là đủ. Thêm Drizzle = thêm dependency, thêm bước build, thêm việc học cho contributor mới.
Hot path cần tuning
// SQL thô: biết chính xác query, EXPLAIN QUERY PLAN rõ ràng
await env.DB.prepare(
"SELECT p.*, a.name as author_name " +
"FROM posts p INNER JOIN authors a ON a.id = p.author_id " +
"WHERE p.published_at > ? AND p.author_id = ? " +
"ORDER BY p.published_at DESC LIMIT 20"
).bind(since, authorId).all();
// ORM: query builder ghép lại, đôi khi sinh SQL không tối ưu
const db = drizzle(env.DB);
await db.select({...}).from(posts).innerJoin(authors, ...).where(...)...
Cho query hot path, SQL thô + EXPLAIN QUERY PLAN rõ ràng hơn.
Team không quen trừu tượng ORM
Tranh luận “Drizzle vs Prisma vs TypeORM” tốn nhiều thời gian hơn bỏ qua ORM. Nếu team đã giỏi SQL, SQL thô là lựa chọn trung lập.
Blog này: vì sao SQL thô
Blog này không dùng ORM. 7 bảng D1:
subscribers,post_views_daily,ai_summaries,webmentions_in,webmentions_out,contact_messages,newsletter_campaigns.
Query phần lớn là SELECT ... WHERE key = ? và INSERT. Không JOIN phức tạp, không include lồng. SQL thô + .first<T>() đủ dùng.
Nếu mở rộng lên 30 bảng với quan hệ chằng chịt, mình sẽ chuyển sang Drizzle.
Pattern thường gặp
① Phân trang với cursor
SQL thô:
async function getPostsPage(env: Env, cursor?: string, limit = 20) {
const sql = cursor
? "SELECT * FROM posts WHERE published_at < ? ORDER BY published_at DESC LIMIT ?"
: "SELECT * FROM posts ORDER BY published_at DESC LIMIT ?";
const stmt = cursor ? env.DB.prepare(sql).bind(cursor, limit) : env.DB.prepare(sql).bind(limit);
const { results } = await stmt.all<Post>();
const nextCursor = results.length === limit ? results[results.length - 1].published_at : null;
return { posts: results, nextCursor };
}
Drizzle:
async function getPostsPage(db: D, cursor?: Date, limit = 20) {
const query = db.select().from(posts).orderBy(desc(posts.publishedAt)).limit(limit);
if (cursor) query.where(lt(posts.publishedAt, cursor));
const results = await query;
const nextCursor = results.length === limit ? results[results.length - 1].publishedAt : null;
return { posts: results, nextCursor };
}
② Counter tăng atomic
// Thô
await env.DB.prepare("UPDATE stats SET post_count = post_count + 1 WHERE id = ?").bind(id).run();
// Drizzle
await db.update(stats).set({ postCount: sql`${stats.postCount} + 1` }).where(eq(stats.id, id));
③ Upsert
D1 hỗ trợ upsert của SQLite:
INSERT INTO subscribers (email, confirmed) VALUES (?, ?)
ON CONFLICT(email) DO UPDATE SET confirmed = excluded.confirmed;
Thô:
await env.DB.prepare(
"INSERT INTO subscribers (email, confirmed) VALUES (?, ?) " +
"ON CONFLICT(email) DO UPDATE SET confirmed = excluded.confirmed"
).bind(email, true).run();
Drizzle:
await db.insert(subscribers)
.values({ email, confirmed: true })
.onConflictDoUpdate({
target: subscribers.email,
set: { confirmed: true },
});
④ Query FTS
D1 hỗ trợ FTS5 (bảng ảo). Drizzle chưa hỗ trợ DSL FTS, phải dùng phương án dự phòng là SQL thô:
// Cả Drizzle + thô đều phải dùng SQL thô cho FTS
const { results } = await env.DB.prepare(
"SELECT p.* FROM posts p " +
"INNER JOIN posts_fts f ON f.rowid = p.rowid " +
"WHERE posts_fts MATCH ? ORDER BY rank LIMIT 20"
).bind(query).all();
// Escape hatch Drizzle
import { sql } from "drizzle-orm";
const results = await db.execute(
sql`SELECT p.* FROM ${posts} p INNER JOIN posts_fts f ON f.rowid = p.rowid WHERE posts_fts MATCH ${query} ORDER BY rank LIMIT 20`
);
Gotcha
① Schema Drizzle + D1 thực tế bị drift
schema.ts nói: cột `reading_time INTEGER`
D1 thực tế: cột không tồn tại vì migration chưa chạy
→ query fail khi chạy, type bảo OK
Fix: luôn chạy migration ngay sau khi thay schema. CI check drizzle-kit generate --check (không diff = OK).
② Giới hạn adapter Prisma
Adapter D1 của Prisma chưa hỗ trợ:
prisma migrate(dùng wrangler thay)- Introspection từ D1
- Một số pattern query thô
Kiểm tra changelog Prisma cho trạng thái hiện tại.
③ Batch Drizzle không retry một phần
Nếu 1 statement trong batch fail, toàn batch lùi phiên bản. Không commit một phần. Khác hành vi batch thô của D1.
④ sqliteTable vs pgTable
Nhập sai sqliteTable → chạy local được (SQLite), triển khai Postgres → fail. Ngược lại với D1: pgTable không chạy.
⑤ Ép kiểu datetime
// Drizzle mode "timestamp_ms" tự convert Date ↔ integer
publishedAt: integer("published_at", { mode: "timestamp_ms" })
// Lấy ra là Date
const post = await db.select().from(posts).limit(1).then(r => r[0]);
post.publishedAt; // Date
Nếu bỏ mode, lấy ra là number. Dễ nhầm khi serialize JSON.
⑥ Bundle Prisma trong free tier
Giới hạn bundle Worker Free 3MB. Prisma + code ứng dụng > 3MB phải nâng Paid. Kiểm tra kích thước bundle trước khi commit Prisma.
Chiến lược migration khi đổi
Từ thô → Drizzle
- Thiết lập schema TypeScript khớp với D1 hiện tại (
drizzle-kit introspect:sqlite). - Giữ SQL thô chạy song song. Thêm Drizzle cho query mới.
- Refactor query cũ từng module, không vội làm tất cả một lúc.
Từ Drizzle → Prisma (ngược)
Hiếm. Nếu cần, xuất schema Drizzle → DSL Prisma thủ công (không có công cụ tự động).
Từ Prisma → Drizzle
Phổ biến vì bundle. prisma-generator-drizzle giúp chuyển schema.prisma → schema TS Drizzle. Migration SQL cần viết tay.
Production checklist
- Schema ORM (hoặc file SQL) là nguồn sự thật, commit trong git.
- Luồng migration có bước test local trước khi áp dụng remote.
- Suy luận type từ schema hoạt động (IDE tự gợi ý).
- Query hot path test với
EXPLAIN QUERY PLAN. - Giám sát kích thước bundle sau mỗi lần triển khai.
- Escape hatch FTS / SQL thô được ghi lại khi ORM không đủ.
- Batch atomic cho ghi nhiều statement.
- Test transaction với ghi đồng thời.
Kết
SQL thô, Drizzle, Prisma — 3 đánh đổi khác nhau. Thô cho đơn giản + kiểm soát. Drizzle cho type safety + bundle nhẹ + native Workers. Prisma cho team quen Prisma + chấp nhận bundle lớn.
Không có “ORM tốt nhất”. Khớp với khối lượng công việc + kỹ năng team.
Part 11 tới: Astro, Remix, SvelteKit trên Workers — framework full-stack, SSR vs SSG, cấu hình adapter, và pattern triển khai.