Table, Schema และ Migration
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”เพราะ Supabase คือ Postgres ธรรมดา table หนึ่งอันจึงถูกสร้างด้วย SQL ธรรมดา — ผ่าน Table Editor GUI ของ Studio หรือคำสั่ง create table ตรง ๆ — และ migration คือสิ่งที่แปลง schema change นั้นให้เป็นไฟล์ใน version control ที่ review ได้และทำซ้ำได้
สร้าง table ด้วย SQL ธรรมดา
หัวข้อที่มีชื่อว่า “สร้าง table ด้วย SQL ธรรมดา”ไม่มี abstraction พิเศษของ Supabase สำหรับนิยาม table เลย ไม่ว่าคุณจะคลิกผ่าน Table Editor ของ Studio หรือพิมพ์ SQL ตรง ๆ ใน SQL Editor ผลลัพธ์คือ table Postgres ธรรมดา pattern ที่พบบ่อยมากคือ table profiles ที่ id ของตัวเองอ้างอิงไปยัง table auth.users ที่ Supabase มีให้ในตัว (module Auth จะพูดถึง auth.users แบบลงลึก)
create table public.profiles ( id uuid primary key references auth.users (id), username text, created_at timestamptz default now());auth.users คือ table ที่ service Auth ของ Supabase จัดการให้คุณ การเชื่อม profiles.id เข้ากับ auth.users ด้วย foreign key คือวิธีมาตรฐานในการผูกข้อมูลของแอปคุณเอง — username, avatar, preference — เข้ากับ user ที่ authenticate แล้ว
Migration ผ่าน CLI
หัวข้อที่มีชื่อว่า “Migration ผ่าน CLI”schema change ที่ทำผ่าน Studio GUI เป็นของจริงและมีผลทันที แต่ตัวเอง ไม่ได้ ถูกเก็บไว้เป็น code ที่ไหนเลย — ไม่มีใครได้ review และจะไม่โผล่ไปที่ environment อื่นเองอัตโนมัติ migration แก้ปัญหานี้
# Create a new, timestamped SQL migration file under supabase/migrations/supabase migration new create_profiles_table
# Apply all pending local migrations to the linked hosted projectsupabase db push
# Generate a migration from schema changes made through the Studio GUIsupabase db diff --schema publicsupabase migration new <name> สร้างไฟล์ .sql เปล่า ๆ ที่มี timestamp กำกับ — คุณเขียนคำสั่ง create table (หรือ alter table) ลงไปเอง หรือ generate ด้วย supabase db diff หลังจากแก้ผ่าน Studio GUI แล้ว supabase db push จะ apply ทุก migration ที่ยังไม่เคยรันเข้ากับ hosted project ที่ link ไว้
-- supabase/migrations/20250101000000_create_profiles_table.sqlcreate table public.profiles ( id uuid primary key references auth.users (id), username text, created_at timestamptz default now());สอง migration workflow จริง พิสูจน์กับ stack ที่รันอยู่จริง
หัวข้อที่มีชื่อว่า “สอง migration workflow จริง พิสูจน์กับ stack ที่รันอยู่จริง”มีวิธีเขียน migration file สองแบบที่ต่างกันจริง ๆ และทั้งคู่ถูกรันจริงที่นี่ กับ Postgres local เดียวกับที่ stack ของคอร์สนี้ใช้ จาก scratch project แบบทิ้งได้ (ไม่เคยรันกับ supabase/migrations/ ของคอร์สเอง — proof นี้อยู่ใน /private/tmp/.../supabase-fnd/ ซึ่งเป็น supabase init project แบบใช้แล้วทิ้ง ชี้ไปที่ database ที่แชร์กันด้วย --db-url แล้วลบทิ้งทีหลัง)
Workflow 1 — เขียน migration เองด้วยมือ supabase migration new สร้างแค่ไฟล์เปล่า ๆ ที่มี timestamp เท่านั้น คุณเขียน SQL เอง:
$ supabase migration new fnd_create_widgets{"path":".../supabase/migrations/20260929070840_fnd_create_widgets.sql","message":"Migration created"}-- already applied above via db push; shown here for reference, not re-collected by the harnesscreate table public.fnd_widgets ( id uuid primary key default gen_random_uuid(), name text not null, created_at timestamptz not null default now());$ supabase db push --db-url "postgresql://postgres:[email protected]:55322/postgres"Connecting to remote database...{"_tag":"Error","error":{"code":"LegacyDbConnectError","message":"failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: tls error (The server does not support SSL connections)"}}$ supabase db push --db-url "postgresql://postgres:[email protected]:55322/postgres?sslmode=disable" --yesApplying migration 20260929070840_fnd_create_widgets.sql...{"upToDate":false,"dryRun":false,"migrations":["20260929070840_fnd_create_widgets.sql"],"message":"Finished supabase db push."}ตอนนี้ fnd_widgets มีอยู่จริงบน database ที่แชร์กัน — ยืนยันด้วย \d ของ psql
Workflow 2 — capture change ที่มีคนแก้ด้วยมือไปแล้ว (แก้ผ่าน Studio GUI หรือใน proof นี้คือรัน create table ตรง ๆ กับ database เพื่อจำลองแบบนั้นเป๊ะ ๆ):
-- run directly against Postgres, simulating a Studio GUI edit — already applied, shown for referencecreate table public.fnd_courses ( id uuid primary key default gen_random_uuid(), title text not null, published boolean not null default false);$ supabase db diff --db-url "postgresql://postgres:[email protected]:55322/postgres?sslmode=disable" -f fnd_capture_coursesCreating shadow database...Diffing schemas...{"file":".../supabase/migrations/20260929070903_fnd_capture_courses.sql", "message":"Diff complete."}-- excerpt of the generated migration file (not the full diff output) — reference onlyCREATE TABLE "public"."fnd_courses" ( "id" uuid NOT NULL DEFAULT gen_random_uuid(), "title" text NOT NULL, "published" boolean NOT NULL DEFAULT false, CONSTRAINT "fnd_courses_pkey" PRIMARY KEY (id));สุดท้าย supabase migration list แสดง migration ทั้งสองตัวพร้อม state จริง — ตัวที่เขียนเองถูก apply แล้ว (remote มีค่า) ตัวที่ capture จาก diff ยังไม่ push (remote ยังว่าง เพราะ generate มาจาก state ที่มีอยู่แล้ว ไม่ได้ push ไปข้างหน้า):
$ supabase migration list --db-url "postgresql://postgres:[email protected]:55322/postgres?sslmode=disable"{"migrations":[ {"local":"20260929070840","remote":"20260929070840","time":"2026-09-29 07:08:40"}, {"local":"20260929070903","remote":"","time":"2026-09-29 07:09:03"}]}supabase db lint — เช็ค schema ก่อน review ไม่ใช่หลัง
หัวข้อที่มีชื่อว่า “supabase db lint — เช็ค schema ก่อน review ไม่ใช่หลัง”db lint รัน analysis สไตล์ plpgsql_check ของ Postgres เองกับ schema แล้ว report typing error:
$ supabase db lint --db-url "postgresql://postgres:[email protected]:55322/postgres?sslmode=disable" --schema public --level warningLinting schema: publicNo schema errors found{"results":[],"message":"db lint"}local stack วันนี้สะอาด — --fail-on warning (หรือ error) เปลี่ยนสิ่งนี้ให้เป็น CI gate จริง แทนที่จะเป็นแค่ report ให้ดูเฉย ๆ ตรง pattern เดียวกับที่ production checklist ท้ายคอร์สนี้ใช้
supabase test db — pgTAP รันจริง
หัวข้อที่มีชื่อว่า “supabase test db — pgTAP รันจริง”supabase test db รันไฟล์ test pgTAP กับ database local test เล็ก ๆ จริง ที่เช็ค shape ของ table ที่สร้างข้างบน:
-- supabase/tests/database/fnd_widgets.test.sqlbegin;select plan(2);
select has_table('public', 'fnd_widgets', 'fnd_widgets table should exist');select has_column('public', 'fnd_widgets', 'name', 'fnd_widgets should have a name column');
select * from finish();rollback;$ supabase test db --workdir tools/probeConnecting to local database....../supabase/tests/database/fnd_widgets.test.sql .. okAll tests successful.Files=1, Tests=2, 0 wallclock secsResult: PASSCLI จะดึง image supabase/pg_prove เล็ก ๆ ที่สร้างมาเพื่อสิ่งนี้ครั้งแรกที่รัน (ใช้ครั้งเดียว จบแล้ว exit — ไม่ได้เพิ่ม container ถาวรให้ stack) เปิด extension pgtap ให้อัตโนมัติ และห่อทั้งไฟล์ด้วย begin/rollback เพื่อไม่ให้ test data ลง database จริง วินัยแบบ rollback-by-default เดียวกับที่ verification harness ของคอร์สนี้เองใช้
supabase gen types — จาก schema เป็น TypeScript จริง
หัวข้อที่มีชื่อว่า “supabase gen types — จาก schema เป็น TypeScript จริง”$ supabase gen types typescript --db-url "postgresql://postgres:[email protected]:55322/postgres?sslmode=disable" --schema public// database.types.ts (excerpt, generated — not hand-written)fnd_widgets: { Row: { created_at: string id: string name: string } Insert: { created_at?: string id?: string name: string } Update: { created_at?: string id?: string name?: string } Relationships: []}ทุก column จาก migration ที่เขียนเองข้างบนถูก type ให้ถูกต้อง โดย variant Insert/Update จะ mark column ที่มี default ให้เป็น optional (created_at, id) ส่วน column ที่ต้องระบุจะเป็น required (name) ไฟล์นี้ควรถูก regenerate ทุกครั้งที่ schema เปลี่ยน แล้ว import เป็น Database เข้า createClient<Database>(...) — module Client Development พูดถึงว่าเกิดอะไรขึ้นถ้าข้าม step regenerate นี้หลัง schema เปลี่ยนจริง
Seeding กับ Branching — CLI เดียวกัน ส่วนใหญ่เป็นเอกสาร
หัวข้อที่มีชื่อว่า “Seeding กับ Branching — CLI เดียวกัน ส่วนใหญ่เป็นเอกสาร”supabase/seed.sql รันอัตโนมัติหลัง supabase db reset (และรันได้ด้วยถ้าใส่ db push --include-seed) — ชุด row ตายตัวสำหรับ local development และ CI ไม่ใช่สิ่งที่ migration ควรแบก fnd_widgets ด้านบนถูกสร้างใน scratch project แยกต่างหาก ไม่ใช่ supabase/migrations/ ของคอร์สนี้เอง บทเรียนนี้จึงสร้างตารางซ้ำที่นี่ (โครงสร้างเดียวกับ migration ที่เขียนด้วยมือก่อนหน้า) เพื่อให้ fence นี้ยืนได้ด้วยตัวเอง:
-- supabase/seed.sqlcreate table if not exists public.fnd_widgets ( id uuid primary key default gen_random_uuid(), name text not null, created_at timestamptz not null default now());insert into public.fnd_widgets (name) values ('seeded widget one'), ('seeded widget two');Branching ถูกพูดถึงแล้วในฐานะ CLI-level feature (supabase branches create) ในบท Branching and the Migrations Workflow ทีหลังในคอร์สนี้ — สรุปสั้น ๆ ตรงนี้: branch คือ hosted preview database แยกต่างหากจริง ที่ผูกกับ Git branch ไม่ใช่สิ่งที่ local CLI stack จำลองเองได้ (ต้องมี hosted project ที่ link ไว้) ดังนั้นบทนั้นจึงอิงเอกสารและ citation เป็นหลัก ไม่ได้พิสูจน์กับ local stack นี้
ทำไมเรื่องนี้ถึงสำคัญ นี่คือ IaC สำหรับ schema ของคุณ
หัวข้อที่มีชื่อว่า “ทำไมเรื่องนี้ถึงสำคัญ นี่คือ IaC สำหรับ schema ของคุณ”นี่คือไอเดียเดียวกับ “infrastructure as code เทียบกับ ClickOps” จากฝั่ง infrastructure tooling แค่เอามาใช้กับ database ของคุณ การแก้ไขด้วยการคลิกใน Studio GUI ใช้งานได้จริง แต่มองไม่เห็นตอน code review, มองไม่เห็นใน git log และต้อง reproduce มือเพื่อให้ไปถึง environment ที่สอง migration file ทำให้ change เดียวกันนี้ review ได้, ทำซ้ำได้ และ deploy ได้ — supabase db push apply เข้า staging, production หรือ local stack ของเพื่อนร่วมทีมได้เหมือนกันทุกที่
flowchart LR
gui["Studio GUI schema edit"] --> pg[("Postgres schema")]
diff["supabase db diff"] --> file["Migration SQL file (supabase/migrations/)"]
gui -.-> diff
file --> push["supabase db push"]
push --> hosted[("Hosted project Postgres")]
local["Local migration written by hand"] --> file อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”AI assistant ถูกขอให้ “add a migration for the new widgets table” แล้วสร้างสิ่งนี้:
🐛 หา bug ในโค้ดนี้
# An AI assistant's suggested "quick" migration workflowpsql "$DATABASE_URL" -c "create table public.widgets (id uuid primary key default gen_random_uuid(), name text not null);"echo "Done — table created."เฉลย
สิ่งนี้ “ใช้ได้” ในแง่ที่ table ถูกสร้างขึ้นจริง — และนั่นแหละคือปัญหาเป๊ะ ๆ change ตอนนี้มีอยู่แค่ในบรรทัด shell history และอะไรก็ตามที่ chat transcript ของ AI assistant เก็บไว้: ไม่มีอะไรใน supabase/migrations/ บันทึกไว้เลย supabase migration list จะไม่มีวันแสดงว่ามันถูก apply แล้ว supabase db push ของเพื่อนร่วมทีมจะไม่มีวัน reproduce มันได้ และ supabase db reset (ซึ่ง replay migration file ทุกไฟล์ตั้งแต่ต้น) จะสร้าง database ที่ ไม่มี table นี้แบบเงียบ ๆ นี่คือ failure mode “ClickOps ที่มีขั้นตอนเพิ่ม” แบบเดียวกับต้นบท แค่พิมพ์ลง terminal แทนที่จะคลิกผ่าน Studio ทางแก้ไม่ใช่ “รัน SQL อื่น” — แต่คือ route change ผ่าน supabase migration new, เขียน SQL ลงไฟล์ที่ generate มา แล้ว apply ด้วย db push เพื่อให้ statement เดียวกันที่เพิ่งรันกลายเป็น artifact ที่ version-controlled และ replay ได้จริง แบบเดียวกับสอง workflow จริงที่พิสูจน์ไว้ข้างบน
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”migration file ไม่ใช่พิธีกรรมเฉพาะของ Supabase — มันคือ SQL script ธรรมดาที่มี timestamp อยู่ในชื่อไฟล์ และคุณค่าทั้งหมดของ CLI คือการรันชุดไฟล์พวกนี้ตามลำดับกับ target แล้วจำว่าอันไหนรันไปแล้ว (คอลัมน์ remote ใน migration list ที่พิสูจน์ข้างบน) อะไรก็ตามที่แก้ schema โดยไม่ผ่านกลไกนี้ — คลิก Studio GUI, statement psql แบบ ad hoc — มีผลจริงทันที แต่ไม่ทิ้งร่องรอยที่ bookkeeping ของ CLI รู้จัก ซึ่งเป็นเหตุผลที่ db diff มีอยู่: เพื่อ reconcile “สิ่งที่เกิดขึ้นจริง” กลับเข้ากับ “สิ่งที่ migration file บอกว่าเกิดขึ้น”
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- เขียน migration เอง (
migration new+ SQL ที่พิมพ์เอง) ให้ control เต็มที่ว่าจะ ship อะไรออกไป — แลกมาด้วยต้องเขียน SQL ให้ถูกเอง ไม่มีอะไรเช็คกับ live schema ปัจจุบันจนกว่าdb pushจะรันจริง - Capture change ด้วย
db diff -fเร็วและไม่พิมพ์ชื่อ column ผิดเลย — แลกมาด้วยการ capture ทุกอย่างที่ต่าง พิสูจน์ข้างบนแล้ว: บน database ที่แชร์กัน นั่นรวม drift ที่คุณไม่ได้ตั้งใจแตะด้วย ต้อง trim มือก่อนถึงจะ commit ได้ปลอดภัย - test pgTAP ของ
supabase test dbรันในห่อbegin/rollbackเลยไม่มีทางปน data จริง — แลกมาด้วยไม่ได้ test อะไรที่ต้องมี row ที่ commit จริงข้ามหลาย statement (เช่น trigger ที่ fire หลัง commit จริงเท่านั้น) — verification harness ของคอร์สนี้เองก็มี trade-off เดียวกันกับ sql fence ของตัวเอง
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Migration คือ feature ของ Supabase” จริง ๆ คือไฟล์
.sqlธรรมดาที่มี timestamp กำกับ กับ table bookkeeping (supabase_migrations.schema_migrations) — pattern เดียวกับที่ migration tool จริงจังทุกตัว (Rails, Django, Flyway, Prisma Migrate) ใช้ CLI ของ Supabase คือ wrapper บาง ๆ ที่รู้จัก Postgres ห่อรอบมัน ไม่ใช่กลไกใหม่ - “
db push --db-urlทำงานเหมือนdb push --linkedแค่ชี้ไปที่อื่น” ถูกหักล้างข้างบนแล้ว:--db-urlถือว่า target เป็น remote โดยเฉพาะ และ default require SSL ซึ่ง Postgres local ธรรมดาไม่รองรับ — ปัญหาจริงที่ไม่มีเขียนไว้ใน quickstart ที่มีแค่?sslmode=disableแก้ได้ - “
db diffแสดงแค่ change ที่เพิ่งทำ” พิสูจน์ข้างบนแล้วบน database ที่แชร์กัน: มัน diff shadow schema ที่มาจาก migrations ทั้งหมดของคุณกับ target ทั้งก้อน ดังนั้นอะไรก็ตามที่เปลี่ยน target ก็โผล่มาด้วย ไม่ว่าจะเป็นของคุณหรือไม่ - “
supabase test dbต้องมี test database แยก” มันรันตรงกับ database ของ local stack เอง ในธุรกรรมที่ rollback เสมอ — ไม่ต้องมี database ที่สอง ไม่ต้องมี cleanup step เพิ่ม