# บทเรียน 08 — รันและตรวจ deployment path ของโปรเจกต์ Cloudflare นี้

> ถ้าทำเว็บจาก Lab สร้างเว็บไซต์หลักสูตรทีละขั้น ให้ใช้ [Lab Deploy เว็บตัวอย่างแบบ Static Assets](../labs/deploy-course-website-cloudflare.md) ซึ่งมีภาพและคำสั่งสำหรับเว็บนั้นโดยตรง และไม่ต้องสร้าง D1

ต้องการรู้ว่า Cloudflare มีบริการอะไรและควรเลือกตัวไหน ให้อ่าน [บท 11 — บริการ Cloudflare สำหรับเว็บไซต์](11-cloudflare-services.md) ก่อน ส่วนการให้ AI ทำ research → brandkit → สร้างเว็บ → deploy ต่อกันอยู่ใน [Lab RW Web Skills](../labs/rw-web-skills.md)

**เวลาศึกษาอิสระสำหรับ 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](https://developers.cloudflare.com/workers/static-assets/), [bindings and routing](https://developers.cloudflare.com/workers/static-assets/binding/)).

## สิ่งที่มีอยู่แล้วและไม่แก้ในบทเรียนนี้

| ไฟล์ | สิ่งที่เป็นความจริงใน 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](../labs/cloudflare-wrangler-auth.md) ก่อนขั้น 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 จริง

![แผนภาพ deployment ของ Cloudflare Worker เดียวซึ่งรับเส้นทาง API ก่อน เชื่อม D1 ผ่าน binding และส่งคำขอไฟล์หน้าเว็บไป Static Assets ใน dist โดยแยก local verification ออกจาก remote deployment](/diagrams/cloudflare-deployment.svg)

[เปิดแผนภาพขนาดเต็ม พร้อม prompt และแหล่งข้อมูลที่ใช้สร้าง](/diagrams/cloudflare-deployment.html)

ความสัมพันธ์ 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](https://developers.cloudflare.com/workers/static-assets/binding/)).

## ขั้นตอนปฏิบัติ: local path ที่รันได้

### 1. ตรวจ config และสร้าง type

ก่อนรัน ให้ดูเฉพาะเพื่อยืนยันว่า asset binding และ D1 binding มีจริง:

```bash
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

```bash
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](https://developers.cloudflare.com/d1/reference/migrations/)).

### 3. เริ่ม Worker พร้อม build

เปิด terminal แรก:

```bash
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 ที่สองและทดสอบ:

```bash
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 วัน:

```bash
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](https://developers.cloudflare.com/d1/worker-api/prepared-statements/)).

### 5. ส่ง mutation แบบ demo พร้อม Origin header

POST route ตรวจ `Origin` ให้ตรงกับ URL request. `curl` จึงต้องส่ง header นี้เสมอ; ถ้าไม่มีควรได้รับ `403 ORIGIN_REJECTED`. เริ่มจาก contact form ที่ใช้ข้อมูลสาธิตเท่านั้น:

```bash
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](../instructor/demo-data-policy.md).

### 6. ตรวจ deployment bundle ก่อนเขียน resource จริง

หยุด `npm run dev` เมื่อทำ smoke test เสร็จ แล้วรัน:

```bash
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 ที่มีชื่อเฉพาะของผู้เรียน:

```bash
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`.

```bash
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:

```bash
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](https://developers.cloudflare.com/d1/reference/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 โดยไม่แสดงหรือบันทึกค่า:

```bash
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.

```bash
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](../instructor/demo-data-policy.md). ถ้า `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 ต้องทำทั้งสองชิ้น:

1. สร้าง Turnstile widget สำหรับ hostname ที่ตั้งใจใช้ และใส่ **sitekey** ใน frontend widget
2. เก็บ **secret** ด้วย Workers Secret แล้วให้ browser ส่ง token เข้า POST route; Worker ตรวจ Siteverify ก่อน mutate D1

Turnstile บังคับ server-side Siteverify: token อายุ 300 วินาทีและใช้ได้ครั้งเดียว ([Cloudflare validation docs](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/)). 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](../instructor/demo-data-policy.md) ก่อนออกแบบ 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](https://developers.cloudflare.com/workers/wrangler/environments/)).

เมื่อต้องเพิ่ม staging ในอนาคต ให้ทำเป็นการเปลี่ยน config ที่ review ได้ ไม่ใช่เอา flag ไปต่อท้าย command. ตัวอย่างโครงสร้างที่ **ต้องเติม ID/ชื่อจริงและ validate กับ `npm run types` ก่อน deploy**:

```jsonc
{
  "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](https://developers.cloudflare.com/r2/api/workers/workers-api-usage/)).

สำหรับ direct browser upload ให้ Worker ที่ authorized ออก presigned URL อายุสั้น, จำกัด operation/content type และตั้ง CORS; URL เป็น bearer token ([R2 presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)). อย่าเพิ่ม R2 binding เพื่อให้ config “ดู production” หากไม่มี route/test ที่ใช้จริง.

## แบบฝึกหัดและหลักฐานส่ง

1. ส่ง output ย่อของ `npm run db:local`, `npm run types`, `npm run typecheck`, `npm test` และ `npm run deploy:check`.
2. ส่ง status/header/body ย่อของ `/api/health`, `/api/catalog`, static example และ availability request หนึ่งครั้ง.
3. ส่งหลักฐาน contact POST ครั้งแรก, idempotent replay และ POST ที่ไม่มี `Origin` ซึ่งถูกปฏิเสธ.
4. เขียนหนึ่งย่อหน้าว่าเหตุใด demo data ถูก redacted และอะไรที่ต้องเพิ่มก่อน `DEMO_MODE=false`.
5. ส่ง production URL, Worker version, remote migration evidence, remote health/catalog/example evidence และ synthetic POST ของ Step 7.
6. ระบุว่า 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](https://github.com/cloudflare/workers-sdk/issues/4727) การผ่าน local จึงไม่ทดแทน remote migration และ smoke test

เราเปลี่ยน trigger ให้ใช้เงื่อนไข `WHEN` ก่อน `BEGIN` แล้วเรียก `SELECT RAISE(ABORT, 'capacity_exceeded');` โดยตรง มีความหมายว่าหากยอดรวมเกิน stock ให้ยกเลิก insert ตาม [SQLite CREATE TRIGGER](https://www.sqlite.org/lang_createtrigger.html) ทดสอบแย่ง stock พร้อมกันอีกครั้งเพื่อยืนยันว่าการแก้รูปแบบ SQL ยังป้องกัน overbooking ได้

ไฟล์ `.gitattributes` บังคับ `migrations/*.sql text eol=lf` ด้วย เพราะมี [รายงาน upstream #14991](https://github.com/cloudflare/workers-sdk/issues/14991) เกี่ยวกับ CRLF ใน trigger migration ที่ทำงานบน local แต่ผิดพลาดบน remote กรณีนี้แก้ migration ก่อน release แรกและตรวจว่าฐานข้อมูล remote ยังไม่มีตารางแอปหลัง rollback หาก migration ถูกใช้ในระบบจริงแล้วให้สร้าง migration ใหม่แทนการแก้ไฟล์ย้อนหลัง
