ข้ามไปยังเนื้อหา

Table, Schema และ Migration

เพราะ Supabase คือ Postgres ธรรมดา table หนึ่งอันจึงถูกสร้างด้วย SQL ธรรมดา — ผ่าน Table Editor GUI ของ Studio หรือคำสั่ง create table ตรง ๆ — และ migration คือสิ่งที่แปลง schema change นั้นให้เป็นไฟล์ใน version control ที่ review ได้และทำซ้ำได้

ไม่มี 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 แล้ว

schema change ที่ทำผ่าน Studio GUI เป็นของจริงและมีผลทันที แต่ตัวเอง ไม่ได้ ถูกเก็บไว้เป็น code ที่ไหนเลย — ไม่มีใครได้ review และจะไม่โผล่ไปที่ environment อื่นเองอัตโนมัติ migration แก้ปัญหานี้

Terminal window
# 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 project
supabase db push
# Generate a migration from schema changes made through the Studio GUI
supabase db diff --schema public

supabase 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.sql
create table public.profiles (
id uuid primary key references auth.users (id),
username text,
created_at timestamptz default now()
);

มีวิธีเขียน 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 harness
create 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" --yes
Applying 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 reference
create 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_courses
Creating 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 only
CREATE 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"}
]}

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 warning
Linting schema: public
No schema errors found
{"results":[],"message":"db lint"}

local stack วันนี้สะอาด — --fail-on warning (หรือ error) เปลี่ยนสิ่งนี้ให้เป็น CI gate จริง แทนที่จะเป็นแค่ report ให้ดูเฉย ๆ ตรง pattern เดียวกับที่ production checklist ท้ายคอร์สนี้ใช้

supabase test db รันไฟล์ test pgTAP กับ database local test เล็ก ๆ จริง ที่เช็ค shape ของ table ที่สร้างข้างบน:

-- supabase/tests/database/fnd_widgets.test.sql
begin;
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/probe
Connecting to local database...
.../supabase/tests/database/fnd_widgets.test.sql .. ok
All tests successful.
Files=1, Tests=2, 0 wallclock secs
Result: PASS

CLI จะดึง image supabase/pg_prove เล็ก ๆ ที่สร้างมาเพื่อสิ่งนี้ครั้งแรกที่รัน (ใช้ครั้งเดียว จบแล้ว exit — ไม่ได้เพิ่ม container ถาวรให้ stack) เปิด extension pgtap ให้อัตโนมัติ และห่อทั้งไฟล์ด้วย begin/rollback เพื่อไม่ให้ test data ลง database จริง วินัยแบบ rollback-by-default เดียวกับที่ verification harness ของคอร์สนี้เองใช้

$ 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 เปลี่ยนจริง

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.sql
create 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
GUI edit และ migration file ไหลเข้า Postgres schema เดียวกัน

AI assistant ถูกขอให้ “add a migration for the new widgets table” แล้วสร้างสิ่งนี้:

🐛 หา bug ในโค้ดนี้

scripts/ai-migration-workflow.sh
# An AI assistant's suggested "quick" migration workflow
psql "$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 จริงที่พิสูจน์ไว้ข้างบน

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 บอกว่าเกิดขึ้น”

  • เขียน 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 เพิ่ม
table ใน Supabase ถูกสร้างขึ้นโดยพื้นฐานยังไง
supabase db push ทำอะไร
ทำไมการเก็บ schema change เป็น migration file ถึงสำคัญ แม้ว่าคุณจะแก้ผ่าน Studio GUI ก็ตาม
pattern ที่พบบ่อยสำหรับเชื่อม table profiles เข้ากับ Supabase Auth คืออะไร
supabase db push --db-url "postgresql://...127.0.0.1:55322/postgres" จริง (ไม่มี param เพิ่ม) กับ local stack ของคอร์สนี้พังด้วย TLS error อะไรที่แก้ได้ และทำไม error ถึงเกิดขึ้นตั้งแต่แรก