บทเรียน 08 — รันและตรวจ deployment path ของโปรเจกต์ Cloudflare นี้
ถ้าทำเว็บจาก Lab สร้างเว็บไซต์หลักสูตรทีละขั้น ให้ใช้ Lab Deploy เว็บตัวอย่างแบบ Static Assets ซึ่งมีภาพและคำสั่งสำหรับเว็บนั้นโดยตรง และไม่ต้องสร้าง D1
ต้องการรู้ว่า Cloudflare มีบริการอะไรและควรเลือกตัวไหน ให้อ่าน บท 11 — บริการ Cloudflare สำหรับเว็บไซต์ ก่อน ส่วนการให้ AI ทำ research → brandkit → สร้างเว็บ → deploy ต่อกันอยู่ใน Lab RW Web Skills
เวลาศึกษาอิสระสำหรับ full lab: ประมาณ 4 ชั่วโมง · ในชั้น 8 ชั่วโมง: เลือกทำ deploy/release rehearsal 40 นาที · ระดับ: ผู้เริ่มต้นที่ผ่าน HTML/CSS/TypeScript และบทเรียน data มาแล้ว
ฐานข้อเท็จจริง: ตรวจ source/config ของ repository และ Cloudflare docs เมื่อ 9 กันยายน 2026
เป้าหมาย
เมื่อจบบทเรียน ผู้เรียนสามารถรันเว็บไซต์นี้บน local Worker, apply D1 migration แบบ local, ตรวจ static site และ API ที่มีจริง, แล้ว deploy production demo ที่เข้าถึงได้จริง ไปยัง Cloudflare account ของตน. งานจบเมื่อ Step 7 ให้ URL, Worker version, remote health/catalog/page และ synthetic POST ที่ตรวจซ้ำได้—not merely when --dry-run passes. DEMO_MODE=true เป็น simulation ที่ redacts PII; มันปลอดภัยสำหรับ demo แต่ไม่ทำให้ deployment เป็นของปลอม.
โปรเจกต์นี้ใช้ Worker เดียว: wrangler.jsonc กำหนด assets.directory: "./dist", assets binding ชื่อ ASSETS, และ run_worker_first: ["/api/*"]. จึงให้ Worker รับ API ก่อน แล้วให้ static request ที่เหลือคืนจาก env.ASSETS.fetch(request). Cloudflare deploy Worker code และ static assets ใน operation เดียว และ assets binding คือทางที่ Worker ส่ง request กลับไปหา static assets (Static Assets, bindings and routing).
สิ่งที่มีอยู่แล้วและไม่แก้ในบทเรียนนี้
| ไฟล์ | สิ่งที่เป็นความจริงใน starter นี้ |
|---|---|
package.json |
มี script db:local, dev, types, typecheck, deploy:check, deploy |
wrangler.jsonc |
มี ASSETS, DB, DEMO_MODE: "true", TURNSTILE_HOSTNAME: ""; ผู้เรียนต้องแทน D1 resource ด้วยของ account ตนก่อน Step 7 |
src/worker.ts |
มี GET /api/health, GET /api/catalog, GET /api/availability และ POST /api/contact, /api/rentals, /api/quotes |
migrations/0001_initial.sql + 0002_demo_retention.sql |
สร้าง/seed ตาราง catalogue, form, rental, quote, idempotency, daily limit และ demo-retention policy สำหรับ D1 local/remote |
อย่าเพิ่ม interface Env ด้วยมือ: Worker ใช้ type ที่ Wrangler สร้างจาก config แล้ว. หลังปรับ config ให้รัน npm run types แล้วตามด้วย npm run typecheck. อย่าเพิ่ม R2 binding ใน lab หลัก เพราะ Worker ปัจจุบันไม่มี R2 route/authorization; R2 เป็น lab เสริมในหัวข้อท้ายเท่านั้น.
ก่อนเริ่ม
ทำ คู่มือ setup Wrangler, login และสร้าง Cloudflare API token ก่อนขั้น deploy บทนั้นมีวิธีติดตั้ง, OAuth ผ่าน browser/device, การเลือก account, token สำหรับ Workers/D1 และการตรวจสิทธิ์โดยไม่เปิดเผย credential ส่วนการรัน local ในบทนี้ยังไม่ต้องล็อกอิน
- ติดตั้ง dependencies ของ repository แล้ว และอยู่ที่ root ของ project
- ใช้ Node.js ที่ project รองรับ และมี
node_modules/.bin/wrangler - ไม่มี local process ใช้ port 3320
- เข้าใจว่า local D1 state อยู่ใน
.wranglerและสามารถลบทิ้งได้เฉพาะเมื่อเจตนา reset ข้อมูลสาธิต - สำหรับ Step 1–6 ใช้ได้โดยไม่ login; Step 7 ต้องมี Cloudflare account ที่ผู้เรียนมีสิทธิ์สร้าง D1 และ deploy Worker
แผนภาพ request จริง
เปิดแผนภาพขนาดเต็ม พร้อม prompt และแหล่งข้อมูลที่ใช้สร้าง
ความสัมพันธ์ request เดิมยังครบ: browser/curl ขอ GET /examples/... จาก static assets ใน dist; GET หรือ POST /api/* ไป src/worker.ts; Worker ใช้ prepared statements/batch กับ D1 และส่ง non-API path ต่อไปยัง assets binding จุดเน้นของภาพคือ artifact เดียวถูกตรวจใน local ก่อน deploy พร้อม bindings ไปยัง resource remote ที่ตั้งใจ
ฝึกอ่านภาพ: ชี้สิ่งที่ wrangler deploy --dry-run ตรวจได้และสิ่งที่ยังพิสูจน์ไม่ได้ แล้วระบุหลักฐานหลัง deploy อย่างน้อย URL, Worker version และ remote health
run_worker_first: ["/api/*"] สำคัญ: ถ้า asset กับ API ใช้ชื่อ path ชนกัน Worker API ต้องได้ควบคุม request ก่อน. Static Assets docs ระบุว่า run_worker_first รับ pattern array สำหรับ selective routing และ binding: "ASSETS" ทำให้ Worker เรียก env.ASSETS.fetch() ได้ (configuration).
ขั้นตอนปฏิบัติ: local path ที่รันได้
1. ตรวจ config และสร้าง type
ก่อนรัน ให้ดูเฉพาะเพื่อยืนยันว่า asset binding และ D1 binding มีจริง:
npm run types
npm run typecheck
ผลที่คาดหวัง: Wrangler สร้าง/อัปเดต type จาก wrangler.jsonc และ TypeScript ผ่าน. ถ้าเพิ่ง clone ให้ไม่แก้ database_id placeholder เพื่อทำ local lab—Wrangler local D1 ใช้ binding/config และ local state, ไม่ได้เขียน D1 cloud.
2. Apply migration เข้า local D1
npm run db:local
npm run db:local เริ่มจาก scripts/setup-local.mjs: สร้างค่า random สำหรับ DEMO_HMAC_KEY ใน .dev.vars หากยังไม่มี และตั้ง permission 0600; จากนั้นจึง apply migration 0001 และ 0002 เข้า local D1. ห้าม copy หรือ commit .dev.vars, และอย่าเขียน secret ด้วยมือใน wrangler.jsonc. คำสั่ง migration ใช้ wrangler d1 migrations apply DB --local; จึงไม่มี --remote และไม่ใช่การเปลี่ยน database ใน Cloudflare. Migration ของ D1 ถูก track ใน d1_migrations; ให้ treat SQL file ที่ apply แล้วเป็น immutable release artifact (D1 migrations).
3. เริ่ม Worker พร้อม build
เปิด terminal แรก:
npm run dev
script นี้ build dist/ ก่อน แล้วเรียก wrapper scripts/preview.mjs, ซึ่ง bind loopback preview ที่ 127.0.0.1:3320 และ Wrangler inspector ที่ 127.0.0.1:3322. รอจน wrapper รายงาน URL local แล้วคง process นี้ไว้. เปิด terminal ที่สองและทดสอบ:
curl -i http://localhost:3320/api/health
curl -i http://localhost:3320/api/catalog
curl -i http://localhost:3320/examples/company/
ผลที่คาดหวัง:
/api/healthตอบ JSON ที่มีok: true,storage: "d1"และdemo: true/api/catalogตอบequipmentและproductsที่อ่านจาก D1 (ข้อมูลเริ่มต้นมาจาก migration)- หน้า
/examples/company/ตอบ static HTML พร้อม security headers
ชื่อ endpoint ที่ถูกต้องคือ /api/catalog, ไม่ใช่ /api/products. เมื่อ DB binding พร้อม Worker อ่าน catalogue จาก D1; migration เดียวกัน seed data สำหรับ local และ remote. D1 ยังเป็น authority ของ mutation, availability, idempotency และ rate control.
4. ตรวจ D1 read และ availability
/api/availability ต้องมี query parameter ครบ. เลือกวันตั้งแต่วันนี้เป็นต้นไปและไม่เกิน 365 วัน เพราะ server validate ระยะ 1–30 วัน:
curl -i "http://localhost:3320/api/availability?equipmentId=camera&start=YYYY-MM-DD&end=YYYY-MM-DD&quantity=1"
แทน YYYY-MM-DD ด้วยวันจริง เช่น start พรุ่งนี้, end วันถัดไปอีกอย่างน้อยหนึ่งวัน. ผลที่คาดหวังคือ JSON available, remaining, totalPrice, currency และ days. ลอง parameter ที่ไม่รู้จักหรือ quantity เกิน stock เพื่อยืนยัน 400. Worker ใช้ prepare(...).bind(...); Cloudflare แนะนำ prepared statement เพื่อป้องกัน SQL injection (D1 prepared statements).
5. ส่ง mutation แบบ demo พร้อม Origin header
POST route ตรวจ Origin ให้ตรงกับ URL request. curl จึงต้องส่ง header นี้เสมอ; ถ้าไม่มีควรได้รับ 403 ORIGIN_REJECTED. เริ่มจาก contact form ที่ใช้ข้อมูลสาธิตเท่านั้น:
curl -i -X POST http://localhost:3320/api/contact \
-H 'Origin: http://localhost:3320' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: lesson08-contact-001' \
--data '{"name":"Demo Student","email":"demo@example.test","message":"ข้อความทดสอบสำหรับบทเรียนเท่านั้น","consent":true,"website":""}'
ผลที่คาดหวังคือ 201. ส่ง body และ Idempotency-Key เดิมซ้ำอีกครั้ง: ควรได้ 201 พร้อม Idempotent-Replayed: true, ไม่ใช่ record ใหม่. จากนั้นตัด Origin ออกเพื่อพิสูจน์ว่า worker ปฏิเสธ cross-origin mutation.
ความหมายของ simulation: config เริ่มต้นกำหนด DEMO_MODE: "true". ในโหมดนี้ Worker ข้าม Turnstile check และ redact name, email, message ก่อนเขียน D1 เป็น [demo-redacted]; rental/quote มีสถานะ reserved-demo/quote-demo และข้อความตอบกลับย้ำว่าไม่ใช่ธุรกรรมจริง. ใช้ HMAC ที่ day-scoped จาก CF-Connecting-IP เพื่อ rate control โดยไม่เก็บ IP ต้นฉบับหรือใช้ User-Agent เป็นตัวแยกสิทธิ์. ห้ามนำ PII หรือการจองจริงมาทดสอบในโหมดนี้. อ่านขอบเขตครบถ้วนที่ demo data policy.
6. ตรวจ deployment bundle ก่อนเขียน resource จริง
หยุด npm run dev เมื่อทำ smoke test เสร็จ แล้วรัน:
npm run build
npm run types
npm run typecheck
npm run deploy:check
deploy:check คือ npm run build && wrangler deploy --dry-run: ใช้ตรวจ build/bundle/config เท่านั้น และไม่พิสูจน์ว่า D1 cloud ID, secret หรือ custom domain พร้อม. ดำเนิน Step 7 ต่อทันที—--dry-run ไม่ใช่เกณฑ์จบหลักสูตร.
7. บังคับ: deploy production demo จริงบน Cloudflare
ขั้นนี้สร้าง resource และ deploy ใน Cloudflare account ของผู้เรียนเอง. ตรวจ account ก่อนเสมอ แล้วสร้าง D1 database ที่มีชื่อเฉพาะของผู้เรียน:
npx wrangler whoami
npx wrangler d1 create vibe-academy-<your-initials>-prod
คำสั่ง create แสดงชื่อและ database_id ที่ Cloudflare ออกให้. แทนที่ค่าของ d1_databases[0].database_name และ database_id ใน wrangler.jsonc ด้วยค่าของ resource นี้; ห้ามคัดลอก ID จากเพื่อนหรือใช้ database ทดสอบร่วมกัน. จากนั้น regenerate binding type และตรวจ local ทั้งหมดอีกครั้ง. Port preview และ test ถูกจองไว้: preview ใช้ 127.0.0.1:3320 กับ inspector 3322; npm test สร้าง Worker/Local D1 ที่แยกที่ 127.0.0.1:3321 กับ inspector 3323. ห้ามใช้ default 8787.
npm run db:local
npm run types
npm run typecheck
npm test
npm run deploy:check
เมื่อ local/test ผ่าน ให้ apply migration เข้า database remote ที่เพิ่งสร้าง โดยใช้ database name เดียวกัน แล้ว deploy:
npx wrangler d1 migrations apply vibe-academy-<your-initials>-prod --remote
npm run deploy
--remote เขียน D1 จริง จึงอ่านชื่อที่ command แสดงก่อนกด Enter. D1 migrations เป็น SQL ที่ versioned และ Cloudflare บันทึก migration ที่ apply แล้วใน d1_migrations (D1 migrations). npm run deploy จะ build แล้ว publish Worker/Static Assets; ให้คัดลอก production URL ที่ Wrangler แสดง (เช่น https://<worker>.<subdomain>.workers.dev) ลงในตัวแปรด้านล่าง. หาก account ใช้ custom domain ให้ใช้ URL ที่เสิร์ฟ Worker ตัวนี้จริง.
หลัง deploy ครั้งแรก Worker มีอยู่แล้ว แต่ mutation forms ต้อง fail closed ด้วย 503 จนตั้ง DEMO_HMAC_KEY. สร้างค่า 32 bytes ใหม่ในเครื่องแล้วส่งทาง stdin โดยไม่แสดงหรือบันทึกค่า:
node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64'))" \
| npx wrangler secret put DEMO_HMAC_KEY
คำสั่งนี้สร้าง Worker version ใหม่พร้อม secret. ห้ามแทนที่ด้วยค่าในเอกสาร, command history, vars, .dev.vars ของคนอื่น หรือ Git. ตรวจได้เฉพาะชื่อ secret ผ่าน npx wrangler secret list; ห้ามพิมพ์ secret value. จึงค่อยทำ synthetic POST ในขั้นถัดไป. หาก secret หาย/ตั้งไม่สำเร็จ ให้ถือว่า form fail-closed 503 เป็นผลที่ถูกต้องและแก้ provisioning ก่อน—not a reason to disable protection.
WORKER_URL='https://<URL-printed-by-wrangler>'
curl -fsS "$WORKER_URL/api/health"
curl -fsS "$WORKER_URL/api/catalog"
curl -fsSI "$WORKER_URL/examples/company/"
curl -fsSI "$WORKER_URL/examples/rental/"
curl -fsSI "$WORKER_URL/examples/store/"
curl -i -X POST "$WORKER_URL/api/contact" \
-H "Origin: $WORKER_URL" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: production-demo-contact-001' \
--data '{"name":"Demo Student","email":"demo@example.test","message":"ข้อความสาธิตบน deployment จริงเท่านั้น","consent":true,"website":""}'
npx wrangler versions list
ผลที่ต้องบันทึก: URL, เวลา deploy, Worker version ID หลัง secret provisioning, output ของ health/catalog, status ของหน้า examples ทั้งสาม และ status/body ของ synthetic POST. ด้วย DEMO_MODE="true" POST นี้ไม่เก็บ PII ที่ส่งลง D1 แต่ยังพิสูจน์ same-origin mutation, D1 binding และ production route ได้. Hold การเช่าเป็น UTC 30 นาที; contact, quote, idempotency และ rate metadata ถูก cleanup ทุกชั่วโมงหลัง 7 วันผ่าน cron 0 * * * *. รายละเอียดและข้อจำกัดอยู่ที่ demo data policy. ถ้า whoami, D1 create, remote migration, secret provisioning หรือ deploy ทำไม่ได้เพราะไม่มี credential/สิทธิ์ ให้บันทึก blocker ได้ แต่ หลักสูตรยังไม่เสร็จ จนเจ้าของ account ทำ Step 7 สำเร็จ.
Turnstile production: สิ่งที่ base lab ยังไม่มี
เมื่อตั้ง DEMO_MODE เป็น false, Worker ต้องมี TURNSTILE_SECRET และ request body ต้องมี turnstileToken; ไม่เช่นนั้น POST ถูก reject. ใน production ต้องทำทั้งสองชิ้น:
- สร้าง Turnstile widget สำหรับ hostname ที่ตั้งใจใช้ และใส่ sitekey ใน frontend widget
- เก็บ secret ด้วย Workers Secret แล้วให้ browser ส่ง token เข้า POST route; Worker ตรวจ Siteverify ก่อน mutate D1
Turnstile บังคับ server-side Siteverify: token อายุ 300 วินาทีและใช้ได้ครั้งเดียว (Cloudflare validation docs). Starter repository มี logic Worker ฝั่ง server แล้ว แต่ ยังไม่ได้เพิ่ม/ผูก frontend widget production ใน lab นี้; จึงห้ามเปลี่ยน DEMO_MODE=false แล้วประกาศว่า form พร้อม production จนกว่าจะทำ frontend, hostname, secret และ negative tests ครบ.
เมื่อ DEMO_MODE=false นโยบาย demo นี้ใช้ต่อไม่ได้โดยปริยาย: ต้องกำหนด purpose, access control, retention, deletion, incident response และข้อกำหนดกฎหมายของธุรกิจจริงใหม่. อย่านำ hold 30 นาที, cleanup 7 วัน, HMAC rate metadata หรือข้อความ redaction ของ demo ไปประกาศเป็น retention policy ของลูกค้าจริง; ดู demo data policy ก่อนออกแบบ real-mode policy.
Environment/staging เป็นหัวข้อขั้นสูง (ไม่ใช่ base command)
repository ปัจจุบันไม่มี env.staging; ดังนั้นอย่ารัน npm run dev -- --env staging หรือ wrangler deploy --env staging ใน lab หลัก. Named environment สร้าง Worker ชื่อแยก และ Cloudflare ระบุว่า bindings และ vars ไม่ inherit; ต้องกำหนดครบสำหรับทุก environment (Wrangler environments).
เมื่อต้องเพิ่ม staging ในอนาคต ให้ทำเป็นการเปลี่ยน config ที่ review ได้ ไม่ใช่เอา flag ไปต่อท้าย command. ตัวอย่างโครงสร้างที่ ต้องเติม ID/ชื่อจริงและ validate กับ npm run types ก่อน deploy:
{
"env": {
"staging": {
"name": "vibe-to-production-academy-staging",
"vars": { "DEMO_MODE": "true", "TURNSTILE_HOSTNAME": "" },
"d1_databases": [{
"binding": "DB",
"database_name": "vibe-academy-staging-db",
"database_id": "<staging-d1-id>",
"migrations_dir": "migrations"
}]
}
}
}
ก่อน deploy named environment ให้ review every non-inheritable key ที่ project ใช้—อย่างน้อย vars, D1 และ R2 หากเพิ่ม—พร้อม secrets สำหรับ environment นั้น. Do not point staging at production D1. หลัง config ที่สมบูรณ์ผ่าน review จึงใช้ npx wrangler deploy --env staging; command นี้ไม่เป็นส่วน pass criteria ของ starter ที่ยังไม่มี config ดังกล่าว.
R2 optional lab: เพิ่มหลัง base ผ่านแล้ว
R2 ไม่ใช่ requirement ของ base pass. ให้เพิ่มเฉพาะเมื่อมี feature ที่ต้องเก็บ bytes (รูปสินค้าหรือเอกสาร) และออกแบบ schema/key/authorization ก่อน. ขั้นต่ำคือ create bucket, add r2_buckets binding, run npm run types, implement read/write route ที่ตรวจสิทธิ์ และทำ test. Cloudflare เตือนว่า Worker ที่เปิด bucket operations ให้ทุก incoming request จะ expose bucket; application ต้องกำหนด authorization เอง (R2 from Workers).
สำหรับ direct browser upload ให้ Worker ที่ authorized ออก presigned URL อายุสั้น, จำกัด operation/content type และตั้ง CORS; URL เป็น bearer token (R2 presigned URLs). อย่าเพิ่ม R2 binding เพื่อให้ config “ดู production” หากไม่มี route/test ที่ใช้จริง.
แบบฝึกหัดและหลักฐานส่ง
- ส่ง output ย่อของ
npm run db:local,npm run types,npm run typecheck,npm testและnpm run deploy:check. - ส่ง status/header/body ย่อของ
/api/health,/api/catalog, static example และ availability request หนึ่งครั้ง. - ส่งหลักฐาน contact POST ครั้งแรก, idempotent replay และ POST ที่ไม่มี
Originซึ่งถูกปฏิเสธ. - เขียนหนึ่งย่อหน้าว่าเหตุใด demo data ถูก redacted และอะไรที่ต้องเพิ่มก่อน
DEMO_MODE=false. - ส่ง production URL, Worker version, remote migration evidence, remote health/catalog/example evidence และ synthetic POST ของ Step 7.
- ระบุว่า R2 และ named environment เพิ่มเฉพาะเมื่อมี resource/config/route/authorization test ของตน; อย่าอ้างว่าทดสอบแล้วหากยังไม่ได้ทำ.
Troubleshooting
| อาการ | ตรวจ/แก้ |
|---|---|
DB ยังไม่มี table |
รัน npm run db:local แล้ว restart npm run dev; ตรวจว่าอยู่ root project |
Origin 403 |
ใช้ Origin: http://localhost:3320 ให้ตรง URL local; browser ปกติส่ง origin ให้ form same-origin |
TURNSTILE_REQUIRED ใน base lab |
ตรวจ DEMO_MODE ว่ายังเป็น string "true"; ถ้าตั้ง false ต้องมี widget+token+secret ครบ |
catalog 404 |
ใช้ /api/catalog, ไม่ใช่ /api/products |
| port 3320/3322 หรือ 3321/3323 ถูกใช้อยู่ | ตรวจ reservation registry; หยุดเฉพาะ process ที่เป็นของคุณ แล้วใช้ port ที่ project จองไว้—อย่าเปลี่ยนไป default 8787 ซึ่งมีเจ้าของอื่น |
| static page 404 | รัน npm run build หรือใช้ npm run dev ซึ่ง build ให้; ตรวจ path จริงใน dist |
| deploy dry-run ระบุ config/resource error | แก้ source/config ที่ review ได้; อย่าแทน placeholder ด้วย ID ของคนอื่นหรือข้ามไป deploy จริง |
เกณฑ์ผ่าน
ผ่านเมื่อ local D1 migration, dev server, health/catalog/static/availability smoke และ contact mutation ทั้ง success/replay/origin rejection ทำงานตามผลที่คาด; npm test, typecheck และ deploy dry-run ผ่าน; และ Step 7 มี production URL, Worker version, remote D1 migration, remote health/catalog/examples และ synthetic POST evidence. R2/named staging เป็น extension แยก แต่ production Worker + D1 demo เป็นข้อบังคับของหลักสูตร. ไม่มี credential หรือสิทธิ์ Cloudflare คือสถานะ incomplete—not a substitute for a deployed website.
กรณีจริงจากการ deploy หลักสูตร: local ผ่าน แต่ D1 remote ไม่ผ่าน
ตอน release นี้ wrangler d1 migrations apply DB --remote เคยตอบ incomplete input: SQLITE_ERROR แม้ migration ชุดเดียวกันผ่าน local ปัญหามาจาก trigger ที่มี SELECT CASE ... END; ภายใน BEGIN ... END; ซึ่งตรงกับ รายงาน upstream #4727 การผ่าน local จึงไม่ทดแทน remote migration และ smoke test
เราเปลี่ยน trigger ให้ใช้เงื่อนไข WHEN ก่อน BEGIN แล้วเรียก SELECT RAISE(ABORT, 'capacity_exceeded'); โดยตรง มีความหมายว่าหากยอดรวมเกิน stock ให้ยกเลิก insert ตาม SQLite CREATE TRIGGER ทดสอบแย่ง stock พร้อมกันอีกครั้งเพื่อยืนยันว่าการแก้รูปแบบ SQL ยังป้องกัน overbooking ได้
ไฟล์ .gitattributes บังคับ migrations/*.sql text eol=lf ด้วย เพราะมี รายงาน upstream #14991 เกี่ยวกับ CRLF ใน trigger migration ที่ทำงานบน local แต่ผิดพลาดบน remote กรณีนี้แก้ migration ก่อน release แรกและตรวจว่าฐานข้อมูล remote ยังไม่มีตารางแอปหลัง rollback หาก migration ถูกใช้ในระบบจริงแล้วให้สร้าง migration ใหม่แทนการแก้ไฟล์ย้อนหลัง