กาแล็กซี
วงโคจร I · กลุ่มดาว

Builders

ส่งมอบผลิตภัณฑ์

เปลี่ยนไอเดียให้เป็นสิ่งที่โหลดได้ ดูดี และ deploy ได้จริง ทั้ง Astro, คอมโพเนนต์, multi-tenant, white-label และ i18n นี่คือฝีมือเบื้องหลัง Espejo และเว็บไซต์ต่างๆ ของ XNLAB

ขับเคลื่อน EspejoXNLAB
6 โมดูล · 6 บทเรียน
โมดูลของกลุ่มดาว เปิดแต่ละโมดูลเพื่อดูบทเรียนเต็ม
BD-01

Astro และ Vite ที่โหลดไว

บทเรียน

ติดตั้งและทำความเข้าใจเว็บไซต์ Astro ที่สร้าง HTML แบบ static ตามแต่ละ route และแต่ละ locale รวมถึงโปรเจกต์ Vite SPA โดยรู้อย่างชัดเจนว่า JS ส่วนใดถูกส่งไปยังเบราว์เซอร์และเหตุใดจึงโหลดได้รวดเร็ว

ความล่าช้าของเว็บสมัยใหม่กว่า 90% เกิดจาก JavaScript ที่ไม่จำเป็นต้องส่งออกไปเลย Astro และ Vite มอบความเร็วให้ตั้งแต่ค่าเริ่มต้น แต่จะได้ผลก็ต่อเมื่อคุณเข้าใจว่าโค้ดแต่ละส่วนทำงานที่ใด นั่นคือช่วง build-time เทียบกับ runtime การสับสนสองสิ่งนี้คือต้นตอที่ทำให้เว็บ static ซึ่งควรเบา กลับมีน้ำหนัก JS สูงถึง 400KB อย่างกะทันหัน

บทเรียน

เริ่มจากความแตกต่างที่ควบคุมทุกสิ่ง: ใน Astro โค้ดของคอมโพเนนต์ .astro ทำงานในช่วง BUILD (บนเครื่องของคุณหรือบน CI) ไม่ใช่ในเบราว์เซอร์ ผลลัพธ์ที่ได้คือ HTML ธรรมดา MOONKEY เป็นเช่นนี้ทุกประการ: ไฟล์ `astro.config.mjs` ประกาศ `i18n` พร้อม `fallbackType: 'rewrite'` และไม่ใช้ SSR เมื่อคุณรัน `npm run build` Astro จะไล่ไปทั่ว `src/pages/` รันไฟล์ .astro แต่ละไฟล์หนึ่งครั้งต่อหนึ่ง locale แล้วเขียน HTML ลงใน `dist/` ไม่มีเซิร์ฟเวอร์ในโปรดักชัน Cloudflare Pages เพียงเสิร์ฟไฟล์เท่านั้น นี่คือเหตุผลที่ทำให้ `moonkeylab.pages.dev` โหลดได้ภายในเสี้ยววินาที

สร้างโครงสร้างพื้นฐานขึ้นมาจากศูนย์เพื่อให้เห็นโดยไม่มีเวทมนตร์: `npm create astro@latest mi-sitio -- --template minimal --no-install` จากนั้น `cd mi-sitio && npm install && npm run dev` แล้วเปิด `http://localhost:4321` แก้ไข `src/pages/index.astro` แล้วสังเกตว่า `const hoy = new Date()` ที่พิมพ์ออกมาด้วย `{hoy}` จะถูกตรึงไว้ ณ ช่วงเวลาที่ build เมื่อคุณรัน `npm run build` นี่คือช่วงเวลาแห่งความกระจ่าง: JS ใน frontmatter (ส่วนที่อยู่ระหว่าง `---`) ทำงานเพียงครั้งเดียว ไม่ได้ทำงานในทุกครั้งที่มีผู้เข้าชม

ทีนี้มาถึงการควบคุมอย่างละเอียด: นั่นคือ islands โดยค่าเริ่มต้น คอมโพเนนต์จากเฟรมเวิร์ก (React/Svelte) ใน Astro จะถูก render เป็น HTML แบบ static โดยไม่มี JS ของมันมาด้วย จะถูก hydrate ก็ต่อเมื่อคุณใส่ directive `client:*` เท่านั้น โดย `client:load` hydrate เมื่อหน้าโหลด, `client:visible` เมื่อเข้าสู่ viewport, และ `client:idle` เมื่อ thread ว่าง กฎของช่างฝีมือคือ: เริ่มต้นโดยไม่ใส่ directive เพิ่ม `client:visible` เฉพาะเมื่อคอมโพเนนต์นั้นจำเป็นต้องมีการโต้ตอบจริงเท่านั้น ใน MOONKEY ตัว `IntersectionObserver` ของ reveal อยู่ใน `Layout.astro` ในรูปของ `<script>` ระดับ global ไม่ใช่ island แยกต่อหน้า นั่นคือการตัดสินใจที่ถูกต้อง: script ความยาว 15 บรรทัด แทนที่จะ hydrate ทั้งเฟรมเวิร์ก

Vite คือเครื่องยนต์ที่อยู่เบื้องล่าง Astro และยังเป็นเครื่องยนต์ของ Espejo (ซึ่งเป็น SPA React ล้วน: `vite.config.ts`, `@vitejs/plugin-react`) ความแตกต่างคือ Espejo ส่ง React ไปยังเบราว์เซอร์จริง เพราะมันต้องการ state แบบโต้ตอบสดๆ (การอ่านค่าจากกล้องด้วย MediaPipe, การนำทางด้วย react-router) นี่คือเส้นแบ่งที่แท้จริงของวิชาชีพนี้: เนื้อหานั้นเป็นการอ่านเป็นหลัก (เว็บของ XNLAB, MOONKEY) หรือเป็นแอปที่มี state หนาแน่น (Espejo) อย่างแรกต้องใช้ Astro/SSG อย่างหลังใช้ Vite+SPA อย่าผสมความเชื่อสองสาย: อย่ายัด SPA ทั้งตัวเข้าไปใน Astro เมื่อ 95% ของงานเป็นเนื้อหา

จงวัด อย่าเดา รัน `npm run build` แล้วดูขนาดของ `dist/` ใน MOONKEY การ build หน้าเนื้อหาควรมี JS ของหน้าราว ~0KB (มีเพียง script ของ reveal ที่ใช้ร่วมกัน) เปิด DevTools → Network แล้ว reload โดยล้าง cache และกรองเฉพาะ JS ถ้าคุณเห็น bundle ของเฟรมเวิร์กในหน้าที่มีแต่ข้อความ แสดงว่าคุณใส่ directive `client:*` เกินมาหนึ่งตัว หรือมี import ที่ลากไลบรารีหนักไปยังฝั่ง client คำสั่ง `npx astro build --verbose` จะแสดงรายการว่าอะไรถูก prerender บ้าง

failure mode คลาสสิก: import ไลบรารีวันที่/markdown/ไอคอนใน frontmatter โดยคิดว่าเป็น build-only แต่กลับนำไปใช้ภายใน `<script>` ของ client หรือส่งต่อให้ island แล้วทันใดนั้นมันก็ไปอยู่ใน bundle อีกกรณีหนึ่ง: ลืมว่า `import.meta.env` ใน Astro แยกระหว่าง `PUBLIC_*` (ไปฝั่ง client) กับตัวอื่นๆ (อยู่ใน build) ถ้าตัวแปรที่คุณคิดว่าเป็นความลับไปปรากฏใน `dist/` แสดงว่าคุณใส่ prefix ผิด และกับดักของ Vite: ทุกสิ่งที่อยู่ภายใต้ `public/` จะถูกคัดลอกแบบ verbatim โดยไม่ประมวลผล อย่าวางความลับไว้ตรงนั้น และอย่าคาดหวังว่า Vite จะ optimize asset เหล่านั้น

แบบฝึกหัด

สร้างเว็บไซต์ Astro ขั้นต่ำที่มีสองหน้า: `/` (มีแต่ HTML ไม่มี JS เลย) และ `/contador` ที่มีคอมโพเนนต์ React `<Counter client:visible />` รัน `npm run build` เปิด `dist/` แล้วพิสูจน์ด้วย DevTools Network ว่า `/` ดาวน์โหลด JS 0KB และ `/contador` hydrate เฉพาะตอนที่เลื่อนลงไปถึงปุ่ม จงบันทึกขนาด bundle ที่แท้จริงของทั้งสองหน้า

สิ่งที่ส่งมอบ

โฟลเดอร์ `astro-islas/` ที่มี repo พร้อมไฟล์ `MEDICIONES.md` ซึ่งระบุ: ขนาดของ `dist/`, จำนวน KB ของ JS ต่อหน้า (ภาพแคปจาก Network) และหนึ่งประโยคอธิบายว่าเหตุใด `/` จึงไม่ส่งอะไรเลย ในขณะที่ `/contador` ส่ง

ข้อคิดสำคัญ

คำถามที่ถูกต้องไม่เคยเป็น 'ฉันจะใช้เฟรมเวิร์กไหน' แต่เป็น 'JS ส่วนใดที่จำเป็นต้องมีอยู่ในเบราว์เซอร์จริงๆ' Astro พลิกค่าเริ่มต้นของเว็บกลับด้าน: เริ่มจาก JS เป็นศูนย์ และบังคับให้คุณต้องให้เหตุผลกับทุกไบต์ของการโต้ตอบด้วย directive ที่ชัดเจน วันที่คุณซึมซับว่า frontmatter ทำงานในช่วง build ไม่ใช่ runtime คือวันที่คุณเลิกส่งขยะ

ข้อผิดพลาดที่ควรเลี่ยง

  • ×ใส่ `client:load` ไว้ทุกที่ 'เผื่อไว้ก่อน' คุณจะ hydrate ทั้งเว็บและสูญเสียข้อได้เปรียบของ Astro จงเริ่มโดยไม่ใส่ directive แล้วเพิ่ม `client:visible` เฉพาะตรงที่มีการโต้ตอบจริงเท่านั้น
  • ×เชื่อว่า JS ใน frontmatter (ระหว่าง `---`) ทำงานในเบราว์เซอร์ ความจริงคือมันทำงานในช่วง build เพียงครั้งเดียว และผลลัพธ์ถูกตรึงไว้ใน HTML
  • ×ใส่ตัวแปรลับไว้ใน `import.meta.env.PUBLIC_*` prefix PUBLIC จะเปิดเผยมันใน `dist/` ส่วนตัวแปรที่ไม่ใช่ public จะอยู่ในช่วง build
  • ×สับสนว่าควรใช้ Astro หรือ Vite-SPA เมื่อใด: เนื้อหาแบบอ่าน → Astro/SSG; แอปที่มี state หนาแน่นและทำงานสดอย่าง Espejo → Vite+React
  • ×คิดว่า `public/` ถูก optimize ความจริงคือมันถูกคัดลอกแบบ verbatim โดยไม่มีการ hash หรือ minify จึงไม่ใช่ที่สำหรับ asset ที่คุณต้องการให้ Vite ประมวลผล
BD-02

คอมโพเนนต์และ design token

บทเรียน

สร้างระบบภาพที่สอดคล้องกันด้วย design tokens (CSS custom properties) และคอมโพเนนต์ที่นำกลับมาใช้ซ้ำได้ แทนที่จะเขียน CSS กระจัดกระจายซ้ำๆ เพื่อให้การเปลี่ยนแบรนด์เป็นเรื่องของการแก้หนึ่งบรรทัด ไม่ใช่การออกล่าหา

CSS ที่กระจัดกระจายไม่สามารถสเกลได้: ในวันที่ลูกค้าขอให้ 'สีม่วงเข้มขึ้นอีกนิด' คุณจะลงเอยด้วยการไล่หา `#7c3aed` ใน 40 ไฟล์ และพลาดไปสามที่ tokens เปลี่ยนงานนั้นให้เหลือเพียงการแก้ตัวแปรเดียว นี่คือความแตกต่างระหว่างผลิตภัณฑ์ที่ดูแลรักษาได้กับหนี้เชิงภาพ

บทเรียน

design token คือตัวแปรที่มีชื่อเชิงความหมาย ไม่ใช่ค่าดิบที่ถูกเขียนซ้ำ MOONKEY กำหนด token ของตัวเองไว้ใน `src/styles/global.css`: `--surface-base: #f8fafc`, `--text-primary: #0f172a` พร้อมพาเลตต์ emerald/violet/amber กฎทองคือ: คอมโพเนนต์จะไม่ใช้ค่า hex โดยตรงเด็ดขาด แต่ใช้ token ดังนั้น `--surface-base` จึงปรากฏเพียงครั้งเดียว ถ้าพาเลตต์เปลี่ยน ก็เปลี่ยนที่เดียว antipattern คือการเขียน `color: #0f172a` ในทุก card คุณได้ผูก 30 ไฟล์เข้ากับค่าหนึ่งที่ควรมีชื่อ

กรณีที่ให้บทเรียนมากที่สุดของวิชาชีพอยู่ที่ Espejo เพราะ token ตรงนั้นเป็นแบบ DYNAMIC ต่อ tenant ลองดู `src/brand/BrandProvider.tsx`: ฟังก์ชัน `applyTheme(brand)` ทำ `root.style.setProperty('--brand', brand.accent)` แล้วได้มาซึ่ง `--brand-soft`, `--brand-line`, `--brand-glow` ผ่านฟังก์ชัน `withAlpha(hex, a)` ที่แปลง hex ของแบรนด์เป็น rgba พร้อมความโปร่งใส UI สาธารณะทั้งหมดของ Espejo วาดด้วย `var(--brand)` ผลลัพธ์คือ: โค้ดชุดเดียวกันรองรับ Luna Rosa (สีชมพู `#e58fb0`) และ influencer คนใดก็ได้ เพียงเปลี่ยนฟิลด์ `accent` เดียว นั่นคือ design system ที่เป็นเครื่องยนต์ของ white-label ไปในตัว

คอมโพเนนต์ เทียบกับ คลาสกระจัดกระจาย: คอมโพเนนต์ห่อหุ้มโครงสร้าง + tokens + พฤติกรรมไว้ภายใต้ชื่อเดียว MOONKEY มี `PromptBlock`, `AnimalIcon`, `MoonkeyLogo`, `FlowDiagram` ใน `src/components/` สัญญาณที่บอกว่าคุณต้องการคอมโพเนนต์คือ: คุณคัดลอกบล็อก markup+คลาสชุดเดิมเป็นครั้งที่สาม สองครั้งแรกให้ทำซ้ำไป พอครั้งที่สามจึงค่อยแยกออกมา อย่า abstract ก่อนหน้านั้น เพราะการ abstract คอมโพเนนต์ที่มี 8 props 'เผื่อไว้' ก่อนเวลาอันควรนั้นแย่กว่าการทำซ้ำอย่างซื่อสัตย์

กับ Tailwind มีกับดักเฉพาะตัวที่ MOONKEY ระบุไว้อย่างชัดเจน: อย่า interpolate คลาสอย่าง `bg-${theme}` เด็ดขาด Tailwind ทำ tree-shaking โดยสแกนหา string ของคลาสที่สมบูรณ์ในช่วง build คลาสที่ถูกสร้างขึ้นในช่วง runtime จะไม่มีอยู่ใน CSS ที่ถูก generate และจะถูก render โดยไม่มีสไตล์ ด้วยเหตุนี้ `galaxy.ts` จึงมี object `THEME` ที่ map แต่ละ constellation ไปยังคลาสที่เขียนตรงตัว (`violet`, `amber`, `emerald`…) ไว้แล้ว บทเรียนคือ: คลาสแบบ dynamic แก้ได้ด้วย MAP ของ string ที่เขียนตรงตัว ไม่ใช่ด้วย interpolation

typography ในฐานะ token ของระบบ: MOONKEY กำหนด `font-display` = Space Grotesk สำหรับ h1–h3, Inter สำหรับ body, JetBrains Mono สำหรับ labels (`.label` = mono uppercase tracking-widest emerald-600) นั่นไม่ใช่การตกแต่งกระจัดกระจาย แต่เป็นการตัดสินใจที่ถูกเข้ารหัสไว้ครั้งเดียวซึ่งทั้งแอปสืบทอดมา ถ้าทุก heading เลือกฟอนต์ของตัวเอง คุณก็จะไม่มีแบรนด์ มีแต่ความวุ่นวาย ระบบอยู่ที่ชั้นของ token/utility ไม่ใช่ที่ `<h1>` แต่ละตัว

วิธีตรวจสอบความสอดคล้อง: ทำ grep หาค่าดิบที่ไม่ควรมีอยู่นอกไฟล์ token `grep -rn '#[0-9a-fA-F]\{6\}' src/components/` ใน MOONKEY ควรคืนค่าแทบไม่มีอะไรเลย ถ้ามี hex ปรากฏในคอมโพเนนต์ นั่นคือ token ที่รั่วออกมาและต้องแทนที่ด้วย `var(--token)` grep นี้คือ linter ความสอดคล้องเชิงภาพที่คุณทำขึ้นเอง และควรรันก่อนทุกครั้งที่ merge

แบบฝึกหัด

นำ landing ที่มี 3 ส่วนซึ่งใช้ hex ซ้ำๆ มา จงดึงสีและรัศมีทั้งหมดออกมาเป็น token ใน `:root` (`--surface`, `--text`, `--accent`, `--radius`) จากนั้นจำลองรูปแบบของ Espejo: เพิ่มฟังก์ชัน `applyTheme(accent)` ที่เปลี่ยน `--accent` ในช่วง runtime แล้วสาธิตว่าด้วยการเรียกเพียงครั้งเดียว ทั้งหน้าก็เปลี่ยนแบรนด์ได้ ตรวจสอบด้วย `grep` ว่าไม่มี hex หลงเหลืออยู่นอกบล็อก token

สิ่งที่ส่งมอบ

โฟลเดอร์ `tokens-tenant/` ที่มี `styles/tokens.css` (ตัวแปรทั้งหมด), คอมโพเนนต์ที่ใช้ `var(--token)` และปุ่มที่เรียก `applyTheme()` ด้วยสีต่างกัน 3 สี พร้อม log ของ `grep` ที่พิสูจน์ว่ามี hex กระจัดกระจาย 0 รายการในคอมโพเนนต์

ข้อคิดสำคัญ

design system ไม่ใช่ไลบรารีของคอมโพเนนต์สวยๆ แต่เป็นชั้นกลางที่แยก 'ค่าอะไร' ออกจาก 'ใช้ที่ไหน' บททดสอบขั้นสุดท้ายคือบททดสอบของ Espejo: ถ้าคุณสามารถเปลี่ยนแบรนด์ทั้งหมดของผลิตภัณฑ์ white-label ได้ด้วย `setProperty` เพียงครั้งเดียว แสดงว่าคุณมีระบบ แต่ถ้าต้องไปแก้หลายไฟล์ แสดงว่าคุณมีแค่ CSS ที่แต่งตัวปลอม

ข้อผิดพลาดที่ควรเลี่ยง

  • ×interpolate คลาส Tailwind (`bg-${theme}`) JIT มองไม่เห็นในช่วง build และมันจะถูก render โดยไม่มีสไตล์ จงใช้ map ของ string ที่เขียนตรงตัวอย่าง `THEME` ใน galaxy.ts
  • ×เขียน hex ดิบในคอมโพเนนต์แทน `var(--token)` คุณกำลังผูก N ไฟล์เข้ากับค่าหนึ่งที่ควรมีจุดเปลี่ยนเพียงจุดเดียว
  • ×abstract คอมโพเนนต์ที่มี 8 props 'เผื่อไว้' ก่อนจะมีการใช้งานจริง 3 ครั้ง การทำซ้ำอย่างซื่อสัตย์ดีกว่าการ abstract ก่อนเวลาอันควร
  • ×ปล่อยให้ทุก heading เลือกฟอนต์ของตัวเอง แทนที่จะกำหนดลำดับชั้นทาง typography ไว้ครั้งเดียวที่ชั้นของ token
  • ×ลืม derive สถานะต่างๆ ของสีแบรนด์ (soft/line/glow): accent เพียงสีเดียวโดยไม่มี variant ที่มี alpha จะสร้างเงาและขอบที่ไม่สอดคล้องกัน จงลอกรูปแบบ `withAlpha` ของ Espejo
BD-03

Multi-tenant และ white-label

บทเรียน

ออกแบบผลิตภัณฑ์ multi-tenant white-label ที่ใช้งานได้จริง: code base ชุดเดียวที่ให้บริการลูกค้าจำนวนมากพร้อมแบรนด์ ข้อมูล และการตั้งค่าของตนเอง โดยใช้รูปแบบรอยต่อ (seam) ของ Store ที่แยก UI ออกจากที่จัดเก็บข้อมูล

white-label คือโมเดลธุรกิจที่ทวีคูณ build เดียวให้ครอบคลุมลูกค้า N รายโดยไม่ต้องเขียนใหม่ แต่มันได้ผลก็ต่อเมื่อการแยก tenant ออกจากกันเป็นของจริง การรั่วไหลของข้อมูลจากลูกค้ารายหนึ่งไปอีกรายจะฆ่าผลิตภัณฑ์ seam แบบ Store ของ Espejo คือสถาปัตยกรรมที่ทำให้สิ่งนี้เป็นไปได้ และในเวลาเดียวกันก็เตรียมพร้อมสำหรับการย้ายไป backend

บทเรียน

multi-tenant หมายถึง: ซอฟต์แวร์หนึ่ง instance ลูกค้า (tenant) จำนวนมากที่รับรู้ว่ามันเป็นของตน white-label เพิ่มเข้ามาว่า: tenant แต่ละรายเห็นมันเป็นแบรนด์ของตน โดยไม่มีร่องรอยของแบรนด์คุณเลย Espejo คือกรณีตัวอย่างมาตรฐาน `src/brand/types.ts` ระบุไว้ในคอมเมนต์ว่า 'influencer แต่ละรายคือ tenant ที่มีแบรนด์ของตน' tenant ถูกระบุด้วย `slug` ใน URL (`/r/<slug>`) และทุกสิ่ง ทั้งชื่อ, accent, glyph, น้ำเสียง, ว่าจะเสนอการอ่านแบบใด, CTA ของการ upsell ล้วนมาจาก object `Brand` แอปสาธารณะทั้งตัวถูกวาดขึ้นจาก object นั้น

หัวใจเชิงสถาปัตยกรรมคือ SEAM ของข้อมูล ลองดู `src/brand/store.ts`: มันนิยาม `interface Store` ด้วย signature แบบ async (`getBrand`, `listBrands`, `saveBrand`, `addSubscriber`, `listSubscribers`) และ implementation ชื่อ `LocalStore` บน localStorage คอมเมนต์ระบุชัดว่า: 'วันนี้: localStorage พรุ่งนี้: Supabase (signature เดียวกัน) UI จะไม่แตะที่จัดเก็บข้อมูลโดยตรง มีแต่ผ่าน interface นี้เท่านั้น' interface นั้นคือรอยต่อ: UI พึ่งพา ABSTRACTION ไม่ใช่ implementation การเปลี่ยน backend = เขียนคลาสใหม่ที่ implement `Store` โดยไม่ต้องแตะคอมโพเนนต์แม้แต่ตัวเดียว

tenant เดินทางไปยัง UI ได้อย่างไร: `BrandProvider.tsx` รับ `slug` เข้ามา เรียก `store.getBrand(slug)` และถ้ามีอยู่ก็ทำ `applyTheme(brand)` แล้วใส่ brand ลงใน React Context (`useBrand()`) ถ้าไม่มี ก็ทำเครื่องหมาย `notFound` รูปแบบที่สะอาด: จุดเข้าหนึ่งจุดแก้ปัญหา tenant นำ theme ของมันมาใช้ แล้วส่งต่อไปทั้งต้นไม้ คอมโพเนนต์ใดก็ทำ `const brand = useBrand()` แล้ววาดสิ่งที่เป็นของตน ไม่มีใครอื่นแตะ storage หรือตัดสินใจเรื่อง theme

การแยก tenant ออกจากกันคือเส้นแดงของ white-label และนี่คือความเสี่ยงที่แท้จริง: `listSubscribers(slug)` กรองด้วย `brandSlug` ใน localStorage นั่นเป็นเรื่องง่าย แต่ FRAGILE เพราะทุกอย่างอยู่ในเบราว์เซอร์เดียวกัน ไม่มีพรมแดนความปลอดภัยที่แท้จริง ช่วงเวลาวิกฤตคือการย้ายไป Supabase ตรงนั้นการแยกข้อมูล MUST ย้ายไปอยู่ที่ Postgres RLS (แถวของ subscriber มองเห็นได้เฉพาะ tenant ของตน) ซึ่งเป็นวินัยเดียวกับที่ MOONKEY ใช้กับ `is_admin()` และ policy แบบ self-or-admin ใน multi-tenant ที่แท้จริง การกรอง `WHERE slug = ...` ที่ฝั่ง client ไม่ใช่ความปลอดภัย ความปลอดภัยคือ RLS ที่ฐานข้อมูล

การตั้งค่าต่อ tenant โดยไม่แตกแขนงโค้ด: สังเกตว่า Espejo ไม่มี `if (tenant === 'lunarosa')` อยู่ที่ใดเลย ความหลากหลายคือ DATA (`Brand.readings`, `Brand.voice`, `Brand.offer`) ไม่ใช่ตรรกะแบบมีเงื่อนไข นั่นคือกฎของ white-label ที่ดี: ความแตกต่างระหว่างลูกค้าอยู่ในการตั้งค่า (ข้อมูล) ไม่อยู่ในการแตกแขนงของโค้ดเลย ทันทีที่คุณเขียน `if (ลูกค้า X)` ตัวแรก คุณก็เริ่ม fork ผลิตภัณฑ์และเริ่มสูญเสียเศรษฐศาสตร์ของโมเดลนี้

มี guardrail เชิงจริยธรรมเฉพาะตัวใน Espejo ที่ไม่ใช่ทางเลือก: หน่วยความจำของโปรเจกต์ระบุว่าจะไม่สร้างกลไกดูดข้อมูล lead อย่างลับๆ หรือขายข้อมูลส่วนบุคคล/ข้อมูลชีวภาพ มีแต่ข้อมูล first-party ที่ได้รับความยินยอมและข้อมูลรวมแบบนิรนามเท่านั้น ในผลิตภัณฑ์ multi-tenant ที่จัดการข้อมูลของผู้ใช้ปลายทางของลูกค้าคุณ โมเดลความยินยอม (`types.ts` มีเวอร์ชันของข้อความความยินยอมที่ถูกตอบรับ และ `Brand.legal.responsible`) เป็นส่วนหนึ่งของสถาปัตยกรรม ไม่ใช่ส่วนเสริมทางกฎหมายที่เพิ่มมาทีหลัง ผู้ควบคุมการประมวลผลข้อมูลคือ influencer-tenant และสิ่งนั้นต้องถูกออกแบบไว้ในข้อมูล

แบบฝึกหัด

สร้างผลิตภัณฑ์ white-label ขนาดย่อ: `interface Store` ที่มี `getTenant(slug)`/`listItems(slug)`, `LocalStore` บน localStorage ที่มี tenant seed 2 ราย (accent ต่างกัน ชื่อต่างกัน) `TenantProvider` ที่แก้ slug จาก URL นำ theme มาใช้ แล้วส่งต่อผ่าน Context จงสาธิตว่าเพียงเปลี่ยน slug ใน URL ทั้งแอปก็เปลี่ยนแบรนด์ได้ โดยไม่มี `if` ต่อลูกค้าแม้แต่ตัวเดียว

สิ่งที่ส่งมอบ

โฟลเดอร์ `white-label-seam/` ที่มี `store.ts` (interface + LocalStore), `TenantProvider` และสอง route `/t/<slug>` ที่ render แบรนด์สองแบบที่แตกต่างกันจากโค้ดชุดเดียวกัน พร้อม `README` ที่ชี้ว่ารอยต่ออยู่ตรงไหน และต้องเปลี่ยนอะไรเพื่อย้ายไป backend

ข้อคิดสำคัญ

seam แบบ Store ของ Espejo คือชิ้นงานวิศวกรรมที่มีค่าที่สุดของผลิตภัณฑ์: มันแยก 'แอปทำอะไร' ออกจาก 'ข้อมูลอยู่ที่ไหน' ไว้หลัง interface ที่มี 5 signature ชั้นกลางนั้นคือสิ่งที่เปลี่ยน demo บน localStorage ให้กลายเป็น SaaS multi-tenant และเป็นสิ่งที่ทำให้การกรองด้วย slug ที่ฝั่ง client ไม่อาจถูกเข้าใจผิดว่าเป็นความปลอดภัยที่แท้จริง (ซึ่งอยู่ใน RLS)

ข้อผิดพลาดที่ควรเลี่ยง

  • ×กรองด้วย tenant ที่ฝั่ง client เท่านั้น (`WHERE slug=...` ใน JS) แล้วเชื่อว่านั่นแยกข้อมูลได้ ที่ backend การแยกข้อมูล MUST เป็น RLS ใน Postgres ไม่ใช่ตัวกรองระดับแอปพลิเคชัน
  • ×ใส่ `if (ลูกค้า === 'X')` ในโค้ด: ทันทีที่คุณแตกแขนงตามลูกค้า คุณก็ fork ผลิตภัณฑ์ไปแล้ว ความหลากหลายอยู่ในข้อมูล/การตั้งค่า ไม่ใช่ในตรรกะ
  • ×ผูก UI เข้ากับ localStorage/Supabase โดยตรงแทนที่จะผูกกับ interface `Store` คุณจะสูญเสียรอยต่อ และการย้ายระบบจะกลายเป็นการเขียนใหม่
  • ×ลืมโมเดลความยินยอมและผู้ควบคุมการประมวลผลข้อมูลต่อ tenant: ใน multi-tenant ที่มีข้อมูลผู้ใช้ปลายทาง นั่นคือสถาปัตยกรรม ไม่ใช่ขั้นตอนทางการ (ขอบเขตจริยธรรมของ Espejo: ห้ามดูดข้อมูลอย่างลับๆ)
  • ×ไม่นิยามกรณี `tenant ไม่พบ`: หากไม่มี `notFound` ที่ชัดเจน slug ที่ไม่ถูกต้องจะทำให้แอปพังแทนที่จะแสดงสถานะที่สะอาด
BD-04

i18n: กาแล็กซีเดียวใน 6 ภาษา

บทเรียน

ออกแบบสถาปัตยกรรม internationalization สำหรับ 6 ภาษาโดยไม่ทำหน้าซ้ำ เข้าใจรูปแบบของการแปล กลไก fallback และเหตุผลที่ href ต้องเป็นแบบ locale-aware

i18n ที่ทำแบบฝืน (หนึ่งโฟลเดอร์หน้าต่อหนึ่งภาษา) ทวีคูณงานบำรุงรักษาเป็น 6 เท่าและรับประกันว่าคำแปลจะหลุดออกจากกัน สถาปัตยกรรมที่ถูกต้องมีเทมเพลตเดียว และภาษาคือข้อมูล ไม่ใช่สำเนา นี่คือความแตกต่างระหว่างการสเกลไป 6 locale ได้ กับการจมอยู่กับมัน

บทเรียน

MOONKEY รัน 6 locale `['es','en','th','fr','it','ch']` (config อยู่ใน `src/i18n/config.ts`) ES อยู่ที่ราก ส่วนที่เหลืออยู่ใต้ prefix (`/en/`, `/th/`, `/fr/`, `/it/`, `/ch/`) ชิ้นส่วนที่หลีกเลี่ยงการทำหน้าซ้ำคือ Astro i18n พร้อม `fallbackType: 'rewrite'` ใน `astro.config.mjs` ในช่วง build Astro จะ REWRITE แต่ละหน้าด้วยบริบทของแต่ละ locale ดังนั้น `index.astro` ไฟล์เดียวจึง generate HTML ที่แปลแล้ว 6 ไฟล์ ไม่มี `pages/en/index.astro`, `pages/fr/index.astro`… มีเพียงไฟล์เดียว นั่นคือกฎหลัก: เทมเพลตเดียว ผลลัพธ์ N รายการ

fallback คือสิ่งที่ทำให้ระบบทนทานโดยไม่ต้องแปลทุกอย่างพร้อมกัน: `astro.config.mjs` ประกาศ `fallback: { en:'es', th:'es', fr:'es', it:'es', ch:'es' }` และฟังก์ชัน `useTranslations` ใน `src/i18n/utils.ts` ก็ implement ห่วงโซ่เดียวกัน: `dict[key] ?? base[key] ?? key` กล่าวคือ: ถ้าฝรั่งเศสขาด key ก็ตกไปที่สเปน ถ้าสเปนก็ไม่มี ก็คืน key นั้นเอง (มองเห็นได้ ตามจับง่าย) สิ่งนี้ทำให้คุณ LAUNCH ได้พร้อมคำแปลที่ไม่สมบูรณ์โดยไม่พังอะไร ภาษาฝรั่งเศสจะแสดงสเปนตรงที่ยังไม่ได้แปล ไม่ใช่หน้าที่พัง

MOONKEY ใช้ i18n สามรูปแบบ และ CLAUDE.md เตือนถึงความไม่สอดคล้องนี้ ซึ่งในตัวมันเองก็เป็นบทเรียนของวิชาชีพ: (1) dictionary `src/i18n/ui.ts` ที่มี `t('key')` สำหรับ chrome/nav สั้นๆ (~240 keys); (2) object เนื้อหาแบบ inline ที่มี `cLocale = (es|th) ? locale : 'en'` ซึ่งยุบ FR/IT/CH ให้เป็น EN สำหรับเนื้อหาหนักอย่างหลักสูตร; (3) หน้า `src/pages/en/*.astro` ที่ override เฉพาะภาษาอังกฤษ รูปแบบที่ 3 คือหนี้: มี 5 หน้าระดับรากที่ hardcode เป็นภาษาสเปน ดังนั้น `/th/fr/it/ch` จึงแสดง ES ในหน้าเหล่านั้น บทเรียน: เลือกรูปแบบหลักเพียงรูปแบบเดียว (dictionary พร้อม fallback) แล้วถือว่ารูปแบบอื่นเป็นหนี้ที่ต้องจ่าย ไม่ใช่ความหลากหลายที่ดีต่อสุขภาพ

บั๊กที่แพงที่สุดของ i18n ที่ใช้ prefix คือ href ดิบ ถ้าคุณเขียน `<a href='/cuenta'>` จากหน้าที่อยู่ใต้ `/fr/` คุณก็ส่งผู้ใช้ออกไปนอกภาษาของเขา ด้วยเหตุนี้ MOONKEY จึงมี `localizedPath(pathname, target)` ใน `utils.ts`: มันลบ prefix ของ locale ปัจจุบันออก สร้าง path ใหม่ แล้วเติม target นำหน้า (`/fr/cuenta`) โดยปล่อย ES ไว้ที่ราก CLAUDE.md ระบุว่านี่เป็นข้อบังคับ: 'ใช้ `localizedPath('/ruta', locale)` ไม่ใช่ `/ruta` ดิบ' จงซึมซับสิ่งนี้: ในเว็บที่มี prefix ภาษา ทุกลิงก์ต้องผ่านฟังก์ชัน localization โดยไม่มีข้อยกเว้น

การตรวจหา locale ที่ใช้งานอยู่มีความละเอียดอ่อนที่ `utils.ts` แก้ไว้: `resolveLocale(currentLocale, pathname)` เลือก `Astro.currentLocale` มากกว่าการตรวจจาก URL เพราะในช่วง rewrite ของ fallback URL อาจสูญเสีย prefix ไป และมีเพียงบริบทของ Astro เท่านั้นที่รู้ locale ที่แท้จริง ถ้าคุณตรวจหา locale ด้วย `pathname.split('/')` อย่างเดียว คุณจะผิดพลาดตรงหน้าที่ใช้ fallback พอดี กฎคือ: เชื่อบริบทของเฟรมเวิร์กก่อน เชื่อ URL เป็นตัวสำรอง

ตัวเลือกภาษาต้องการ dictionary แยกกันสองชุด: `LOCALE_LABELS` (ES, EN, TH…) สำหรับ switcher แบบกระชับ และ `LOCALE_NAMES` (Español, ไทย, Italiano…) สำหรับ dropdown แบบเต็ม และระวังกรณีแปลก: 'ch' ตรงนี้คือ Deutsch (CH) เยอรมันสวิส ไม่ใช่ภาษาจีน คอมเมนต์ใน `config.ts` ชี้แจงไว้ ความกำกวมประเภทนี้ (รหัสที่ดูเหมือนภาษาอื่น) คือสิ่งที่คุณควรบันทึกไว้ในโค้ดเพื่อไม่ให้ใครแปลผิดเป็นภาษาที่ไม่ถูกต้องในอีกหกเดือนข้างหน้า

แบบฝึกหัด

สร้างเว็บไซต์ Astro ที่มี 3 locale (es ที่ราก, en/fr มี prefix) สร้าง `index.astro` ไฟล์เดียวที่ใช้ `t('hero.title')` จาก dictionary `ui.ts` implement `useTranslations` พร้อม fallback `dict[key] ?? base[key] ?? key` และ `localizedPath` ทิ้ง key หนึ่งไว้โดยไม่แปลในภาษา FR แล้วสาธิตว่ามันตกไปที่ ES (ไม่พัง) วาง selector ภาษาที่ใช้ `localizedPath` เพื่อไม่ให้ผู้ใช้หลุดออกจาก locale ของตน

สิ่งที่ส่งมอบ

โฟลเดอร์ `i18n-3locales/` ที่มี `i18n/config.ts`, `ui.ts`, `utils.ts` (useTranslations + localizedPath), `index.astro` ไฟล์เดียวที่ generate HTML 3 ไฟล์ และภาพแคปที่แสดง FR ตกไปที่ ES ใน key ที่ยังไม่แปล

ข้อคิดสำคัญ

i18n ที่ทำดีจะเปลี่ยนภาษาให้เป็นพารามิเตอร์ของข้อมูลบนเทมเพลตเดียว ไม่ใช่สำเนาของหน้า fallback แบบห่วงโซ่ (`dict ?? base ?? key`) คือสิ่งที่ทำให้คุณ launch ได้พร้อมคำแปลที่ทำไปครึ่งทางโดยไม่มีหน้าพัง และ `localizedPath` ในทุก href คือความแตกต่างที่มองไม่เห็นระหว่างผู้ใช้ที่อยู่ในภาษาของตน กับผู้ใช้ที่ตกกลับไปที่รากตั้งแต่คลิกแรก

ข้อผิดพลาดที่ควรเลี่ยง

  • ×ทำหน้าซ้ำตามภาษา (`pages/fr/index.astro`, `pages/en/index.astro`…) แทนที่จะใช้เทมเพลตเดียวพร้อม rewrite คุณทวีคูณงานบำรุงรักษาเป็น 6 เท่าและคำแปลจะหลุดออกจากกัน
  • ×เขียน href ดิบ (`/cuenta`) ใต้ prefix ของ locale: คุณส่งผู้ใช้ออกจากภาษาของเขา ทุกลิงก์ต้องผ่าน `localizedPath`
  • ×ตรวจหา locale จาก URL เพียงอย่างเดียว: ในช่วง rewrite ของ fallback URL จะสูญเสีย prefix จงเลือก `Astro.currentLocale` (`resolveLocale`)
  • ×ผสม i18n สามรูปแบบโดยไม่มีรูปแบบหลัก MOONKEY ก็แบกหนี้จากเรื่องนี้อยู่แล้ว (5 หน้าที่ hardcode เป็น ES ซึ่งแสดงต่อ th/fr/it/ch)
  • ×สมมติความหมายของรหัส locale: 'ch' ตรงนี้คือเยอรมันสวิส ไม่ใช่ภาษาจีน จงบันทึกตัวที่กำกวมไว้ใน config มิฉะนั้นจะมีคนแปลผิด
BD-05

Deploy: Cloudflare, โดเมน, env

บทเรียน

นำเว็บไซต์จาก localhost ไปสู่ URL จริงบน Cloudflare Pages พร้อมโดเมนของตัวเองและตัวแปร environment ที่จัดการอย่างปลอดภัย โดยแยกแยะว่า env ใดไปยัง client และตัวใดต้องไม่ไปเด็ดขาด

โปรเจกต์ที่ยังไม่ได้ deploy ก็เท่ากับไม่มีอยู่สำหรับใครเลย และ deploy คือจุดที่ความลับรั่วไหลมากที่สุด เส้นแบ่งระหว่างตัวแปร public กับ private นั้นมองไม่เห็นจนกว่า service_role key ของคุณจะไปปรากฏใน bundle ของโปรดักชัน การรู้วิธี deploy ที่ดีคือครึ่งหนึ่งของการ ship

บทเรียน

MOONKEY ถูก deploy บน Cloudflare Pages (`moonkeylab.pages.dev`) จาก repo `garciafradepablo-pixel/xop` ขั้นตอนพื้นฐาน: เชื่อม repo ของ GitHub เข้ากับ Cloudflare Pages กำหนด build command (`npm run build`) และ output directory (`dist/` ใน Astro) การ push ไป main แต่ละครั้งจะ trigger build บน CI แล้วเผยแพร่ คุณไม่ได้อัปโหลดไฟล์ด้วยมือ deploy เป็นผลพวงของ git push นั่นให้ความสามารถในการทำซ้ำได้แก่คุณ: สิ่งที่อยู่ในโปรดักชันคือสิ่งที่อยู่ใน commit เป๊ะ

สำหรับ SSG ล้วนอย่าง MOONKEY Cloudflare เพียงเสิร์ฟ HTML แบบ static จาก build เท่านั้น ไม่มี runtime ของเซิร์ฟเวอร์ สิ่งนี้สำคัญต่อโมเดลความปลอดภัย (คุณจะได้เห็นใน Ops): เนื่องจากไม่มี handler ฝั่งเซิร์ฟเวอร์ env เดียวที่สำคัญในโปรดักชันคือสิ่งที่ถูก inject ในช่วง BUILD ถ้าแอปของคุณต้องการตรรกะฝั่งเซิร์ฟเวอร์ (ซึ่งไม่ใช่กรณีของ MOONKEY หรือ XNLAB ที่เป็นเนื้อหา) ตรงนั้น Cloudflare Functions/Workers จึงเข้ามามีบทบาท แต่อย่าใส่มันถ้าคุณไม่ต้องการ

พรมแดนของความลับคือบทเรียนหลักของโมดูลนี้ ใน Astro/Vite ตัวแปร `PUBLIC_*` (Astro) หรือ `VITE_*` (Vite ล้วนอย่าง Espejo) ถูก inject เข้าไปใน bundle ของ client มันมองเห็นได้สำหรับทุกคนที่เปิด DevTools ส่วนที่เหลืออยู่ในช่วง build MOONKEY ตั้ง Supabase anon key เป็น public โดยตั้งใจ: CLAUDE.md ระบุ 'เฉพาะ anon key' ใน `src/lib/supabase.ts` เพราะ anon key ถูกออกแบบมาให้เป็น public (พลังของมันถูกจำกัดด้วย RLS ไม่ใช่ด้วยความลับ) กฎเด็ดขาด: `service_role` key ไม่มีวันมี prefix public และไม่มีวันไปอยู่ในเว็บ static ถ้าคุณวางมันลงไป มันก็อยู่ใน bundle และนั่นคือจบเกม

ใน Cloudflare คุณตั้งค่า env vars ใน dashboard (Settings → Environment variables) โดยแยก Production จาก Preview ตัวที่ build ของคุณต้องเปิดเผยให้ client จะมี prefix ที่ถูกต้อง ส่วนตัวที่ใช้เฉพาะใน process ของ build (token ของ API บุคคลที่สามเพื่อ generate เนื้อหา) จะไม่มี prefix และไม่ไปลงเอยใน `dist/` การตรวจสอบที่บังคับหลัง deploy: ดาวน์โหลด JS ของโปรดักชันของคุณ แล้วทำ `grep -ri 'service_role\|secret\|sk_'` บน `dist/` ถ้ามีอะไรปรากฏ คุณมีการรั่วไหลที่ต้อง rotate ทันที

โดเมนของตัวเอง: ใน Cloudflare Pages ไปที่ Custom domains → เพิ่มโดเมนของคุณ Cloudflare จะสร้าง DNS records ให้ (ถ้าโดเมนอยู่ใน Cloudflare ก็อัตโนมัติ ถ้าไม่ ก็เพิ่ม CNAME ชี้ไปที่ `*.pages.dev`) HTTPS เป็นอัตโนมัติ `.pages.dev` ยังคงทำงานเป็น URL สำรอง สำหรับ preview แต่ละ PR/branch จะ generate URL ชั่วคราวของตัวเอง ใช้มันเพื่อตรวจสอบการเปลี่ยนแปลงก่อน merge เข้า main โดยไม่ต้องแตะโปรดักชัน

failure mode ของ deploy ที่เกิดขึ้นจริง: (1) build ผ่านในเครื่องแต่ล้มเหลวบน CI เพราะ dependency อยู่ใน `node_modules` ในเครื่องแต่ไม่อยู่ใน `package.json` ให้รัน `npm ci` (ไม่ใช่ `install`) ในสภาพแวดล้อมที่สะอาดเพื่อจำลอง CI (2) ตัวแปรที่มีอยู่ในเครื่อง (`.env`) แต่คุณไม่ได้ตั้งค่าใน dashboard → build ผ่านแต่แอปล้มเหลวใน runtime ด้วยค่า undefined (3) คำเตือนเชิงรูปลักษณ์ของ Astro เกี่ยวกับ `Astro.request.headers` ใน build แบบ static ที่มี i18n rewrite `astro.config.mjs` บันทึกไว้ว่าไม่เป็นอันตราย build ผ่าน อย่าไล่ตามคำเตือนที่ config ได้ระบุไว้แล้วว่าเป็นสิ่งที่คาดหมาย

แบบฝึกหัด

deploy เว็บไซต์ Astro ไปยัง Cloudflare Pages จาก repo ของ GitHub: ตั้งค่า build command `npm run build`, output `dist/` และตัวแปร `PUBLIC_API_BASE` หนึ่งตัวกับตัวแปรที่ไม่ public อีกตัวชื่อ `BUILD_TOKEN` หลัง deploy ให้ดาวน์โหลด JS ของโปรดักชันแล้วพิสูจน์ด้วย `grep` ว่า `PUBLIC_API_BASE` ปรากฏใน bundle ส่วน `BUILD_TOKEN` ไม่ปรากฏ เพิ่มโดเมน (หรือ subdomain) และตรวจสอบ HTTPS

สิ่งที่ส่งมอบ

URL สาธารณะที่ทำงานได้จริงหนึ่งรายการ พร้อม `DEPLOY.md` ที่มี: build config, ตารางว่าตัวแปรใดเป็น public และเพราะอะไร และ log ของ `grep` ที่พิสูจน์ว่า build token ไม่รั่วไปยัง client

ข้อคิดสำคัญ

deploy ไม่ใช่ 'การอัปโหลดไฟล์' แต่คือการตรึง commit หนึ่งลงบน URL ด้วย CI ที่ทำซ้ำได้ และข้อผิดพลาดเดียวที่แก้ไม่ได้คือเรื่องความลับ: เนื่องจากเว็บ static ไม่มีเซิร์ฟเวอร์ ทุกสิ่งที่มันต้องใช้ในโปรดักชันจะถูก inject ในช่วง build ด้วยเหตุนี้ anon key (ที่ถูกจำกัดด้วย RLS) จึงเป็น public ได้ ในขณะที่ service_role ไม่มีวัน `grep` บน `dist/` คือตาข่ายสุดท้ายของคุณก่อนเกิดการรั่วไหล

ข้อผิดพลาดที่ควรเลี่ยง

  • ×วางคีย์ private (service_role, secret ของ API) ด้วย prefix `PUBLIC_`/`VITE_` หรือในเว็บ static มันจะอยู่ใน bundle ที่มองเห็นได้ เฉพาะคีย์ที่ออกแบบมาให้เป็น public (anon key ที่ถูกจำกัดด้วย RLS) เท่านั้นที่ไปยัง client
  • ×อัปโหลด build ด้วยมือแทนการเชื่อม repo: คุณสูญเสียความสามารถในการทำซ้ำและไม่รู้ว่า commit ใดอยู่ในโปรดักชัน
  • ×ตั้งค่า env vars ใน `.env` ในเครื่องแต่ลืมในdashboard ของ Cloudflare → build ผ่านและแอปพังใน runtime
  • ×ใช้ `npm install` เพื่อจำลอง CI จงใช้ `npm ci` ในสภาพแวดล้อมที่สะอาด ซึ่งเคารพ lockfile และจำลองความล้มเหลวของ dependency ที่แท้จริง
  • ×ไม่ตรวจสอบด้วย `grep` บน `dist/` หลัง deploy: การรั่วไหลของความลับนั้นเงียบงันจนกว่าจะมีใครค้นพบมัน
BD-06

จาก localStorage สู่ backend

บทเรียน

ย้ายชั้นข้อมูลจาก localStorage ไปยัง backend (Supabase) โดยไม่ต้องเขียนแอปพลิเคชันใหม่ ใช้ประโยชน์จากรอยต่อ Store เพื่อให้การเปลี่ยนแปลงเป็นการ swap implementation ไม่ใช่การ refactor

ทุกแอปเริ่มต้นด้วยข้อมูลในเครื่องเพื่อความรวดเร็ว แต่ localStorage ไม่แชร์ข้ามอุปกรณ์ ไม่คงอยู่อย่างแท้จริง และไม่มีความปลอดภัยที่แท้จริง การกระโดดไป backend เป็นสิ่งที่หลีกเลี่ยงไม่ได้ และถ้าสถาปัตยกรรมของคุณไม่ได้เตรียมไว้ การกระโดดนั้นคือการเขียนใหม่ที่เจ็บปวด แต่ด้วยรอยต่อที่ถูกต้อง มันคืองานหนึ่งบ่าย

บทเรียน

ทบทวนรอยต่อจาก BD-03: ใน Espejo `src/brand/store.ts` นิยาม `interface Store` ด้วย signature แบบ async และ `LocalStore` implement มันบน localStorage คอมเมนต์คือวิทยานิพนธ์ทั้งหมดของโมดูลนี้: 'วันนี้: localStorage พรุ่งนี้: Supabase (signature เดียวกัน)' เพราะ interface เป็น async อยู่แล้ว (`Promise<Brand | null>`) UI จึงไม่สังเกตความแตกต่างระหว่างการอ่านจากหน่วยความจำกับการอ่านจากเครือข่าย เพราะ `await` อยู่ตรงนั้นแล้ว API แบบ synchronous จะทำให้การย้ายระบบเป็นไปไม่ได้โดยไม่แตะทุกการเรียก การออกแบบรอยต่อเป็น async ตั้งแต่วันแรกคือการตัดสินใจที่ให้ผลตอบแทนตรงนี้

การ swap ที่เป็นรูปธรรม: คุณเขียนคลาส `SupabaseStore implements Store` `getBrand(slug)` เปลี่ยนจากการอ่าน localStorage ไปเป็น `await supabase.from('brands').select().eq('slug', slug).single()` `addSubscriber` เปลี่ยนจาก `write()` ไปเป็น `.insert()` signature เหมือนกันทุกประการ สุดท้าย หนึ่งบรรทัดเปลี่ยนทั้งโลก: `export const store: Store = new SupabaseStore()` แทน `new LocalStore()` ไม่แตะคอมโพเนนต์แม้แต่ตัวเดียว นั่นคือความหมายของ 'การเปลี่ยนชั้นข้อมูลโดยไม่ต้องเขียนแอปใหม่': โค้ดที่เหลือพึ่งพา `Store` ไม่ใช่ localStorage

แต่การย้ายที่จัดเก็บข้อมูลคือครึ่งที่ง่าย ครึ่งที่จริงจังคือ SECURITY และตรงนี้โมเดลทั้งหมดเปลี่ยน ใน localStorage ไม่มีพรมแดน ทุกอย่างอยู่ในเบราว์เซอร์ของผู้ใช้ ใน Supabase เนื่องจาก client พก anon key เท่านั้น (MOONKEY: `src/lib/supabase.ts`, 'เฉพาะ anon key') อำนาจไม่ได้อยู่ที่ client แต่อยู่ที่ Postgres RLS MOONKEY กำหนดไว้อย่างไม่กำกวม: 'อำนาจ = RLS ไม่ใช่ client' เมื่อ Espejo ย้าย subscribers ตัวกรอง `WHERE brandSlug = slug` ที่วันนี้ทำใน JS MUST กลายเป็น policy RLS ที่รับประกันว่า tenant อ่านได้เฉพาะ subscribers ของตน ตัวกรองที่ฝั่ง client เป็นเพียงความสะดวก RLS คือความปลอดภัย

รูปแบบอ้างอิงของ RLS ใน MOONKEY: ตาราง `profiles/progress/feedback/proofs` มี SELECT แบบ 'self-or-admin' ผ่าน `is_admin()` (ฟังก์ชัน SECURITY DEFINER) ตรวจสอบแล้วโดยการสวมรอย ผู้ที่ไม่ใช่ admin อ่านแถวของผู้อื่นไม่ได้ `proofs` INSERT ต้องมี session และผูก `user_id = auth.uid()` (policy `proofs_insert_self`) คอลัมน์ที่อ่อนไหว (`role`, `founder_badge`) ไม่สามารถแก้ไขได้สำหรับผู้ที่ไม่ใช่ admin ผ่าน trigger guard ดังนั้นไม่มีใคร self-escalate ได้ นั่นคือเทมเพลตทางความคิดสำหรับการย้ายระบบใดๆ: ทุก operation (SELECT/INSERT/UPDATE) ต้องการ policy ที่บังคับใช้ได้แม้ผู้โจมตีจะควบคุม client ก็ตาม

สุขอนามัยของการย้ายข้อมูล: คุณต้องการ schema (ตาราง `brands`, ตาราง `subscribers` ที่มี `brand_slug`) ที่ apply ในรูป MIGRATION ที่มีเวอร์ชัน (ไม่ใช่ SQL กระจัดกระจายใน console MOONKEY: 'การเปลี่ยนแปลง RLS/ความปลอดภัย: migration ที่มีเวอร์ชัน + `get_advisors` หลังจากนั้น') จากนั้น script ที่รันครั้งเดียวซึ่งอ่าน localStorage ที่มีอยู่แล้วทำ `insert` จำนวนมากไปยัง backend แบบ idempotent (ที่คุณรันซ้ำได้โดยไม่ทำซ้ำข้อมูล) และช่วงเวลาเขียนแบบ double-write ทางเลือก ถ้าคุณหยุดแอปไม่ได้: เขียนไปทั้งสองที่จนกว่าจะยืนยันความเท่าเทียม แล้วจึงตัด localStorage

ระวังพรมแดนจริยธรรมที่ Espejo แบกอยู่: หน่วยความจำของโปรเจกต์ห้ามดูดข้อมูลส่วนบุคคล/ชีวภาพอย่างลับๆ มีแต่ข้อมูล first-party ที่ได้รับความยินยอมและข้อมูลรวมแบบนิรนามเท่านั้น การย้ายไป backend เพิ่มพลังของข้อมูล (ตอนนี้มันคงอยู่ ถูกนำมาเชื่อมโยง ถูก export ได้) ดังนั้นโมเดลความยินยอมที่ใน localStorage แทบจะเป็นเพียงทฤษฎี กลับกลายเป็นของจริงและตรวจสอบได้ การย้ายเชิงเทคนิคและความรับผิดชอบต่อข้อมูลขึ้นไปพร้อมกัน อย่าย้ายที่จัดเก็บข้อมูลโดยไม่ย้ายหลักประกันความเป็นส่วนตัวไปด้วย

แบบฝึกหัด

นำ `white-label-seam` จาก BD-03 มา (interface Store + LocalStore) เขียน `SupabaseStore implements Store` ด้วย signature เดียวกันต่อตารางจริงของ Supabase ย้ายระบบโดยเปลี่ยนเพียงหนึ่งบรรทัด (`new LocalStore()` → `new SupabaseStore()`) โดยไม่แตะคอมโพเนนต์ apply policy RLS ที่รับประกันว่า `listItems(slug)` คืนเฉพาะแถวของ tenant ที่ถูกต้อง แล้วตรวจสอบโดยการสวมรอยเป็นผู้ใช้อีกคน

สิ่งที่ส่งมอบ

โฟลเดอร์ `store-migration/` ที่มี `LocalStore` และ `SupabaseStore` วางคู่กัน, diff หนึ่งบรรทัดที่ทำการ swap, migration SQL ที่มีเวอร์ชันพร้อม policy RLS และ log ที่พิสูจน์ว่า tenant หนึ่งอ่านข้อมูลของอีก tenant ไม่ได้ (การทดสอบสวมรอย)

ข้อคิดสำคัญ

รอยต่อ Store เปลี่ยนการย้าย backend จากการเขียนใหม่ให้เป็นการ swap หนึ่งบรรทัด แต่จะได้ก็ต่อเมื่อ interface เป็น async ตั้งแต่ต้น และส่วนที่สำคัญจริงๆ ไม่ใช่การย้ายไบต์ แต่คือเมื่อเปลี่ยนไปใช้ client ที่มี anon key อำนาจจะย้ายจากแอปไปสู่ Postgres RLS ตัวกรองด้วย slug ที่คุณเคยมีใน JS ไม่ใช่ความปลอดภัย ตอนนี้มันต้องเป็น และต้องเป็นที่ฐานข้อมูล

ข้อผิดพลาดที่ควรเลี่ยง

  • ×ออกแบบ interface Store เป็น synchronous แล้วมาค้นพบในตอนย้ายระบบว่าทุกการเรียกต้องการ `await` จงทำให้มันเป็น async (Promise) ตั้งแต่วันแรก แม้ localStorage จะไม่ต้องการก็ตาม
  • ×เชื่อว่าการย้ายที่จัดเก็บข้อมูลคืองานทั้งหมด ส่วนที่จริงจังคือการย้ายการแยกข้อมูลจากตัวกรอง JS ที่ฝั่ง client ไปสู่ policy RLS ใน Postgres ('อำนาจ = RLS ไม่ใช่ client')
  • ×apply schema และ policy ในรูป SQL กระจัดกระจายใน console แทนที่จะเป็น migration ที่มีเวอร์ชัน + `get_advisors` หลังจากนั้น
  • ×script ย้ายข้อมูลที่ไม่ idempotent: ถ้าคุณรันซ้ำไม่ได้โดยไม่ทำซ้ำข้อมูล การถูกขัดจังหวะจะทำให้ข้อมูลเสียหาย
  • ×ย้ายไป backend โดยไม่เสริมความยินยอม: การคงอยู่และการเชื่อมโยงข้อมูลส่วนบุคคลยกระดับความเสี่ยง ใน Espejo ขอบเขตคือข้อมูล first-party ที่ได้รับความยินยอม ห้ามดูดข้อมูลอย่างลับๆ

กลุ่มดาวถัดไป

Signal

Quant และ research