Realtime: Postgres Changes
แนวคิดในประโยคเดียว
หัวข้อที่มีชื่อว่า “แนวคิดในประโยคเดียว”Postgres Changes สตรีม write-ahead log ของฐานข้อมูลตัวเองไปให้ client ที่ subscribe เป็น event INSERT/UPDATE/DELETE แต่กลไกนี้ ปิดอยู่โดย default สำหรับทุกตาราง จนกว่าคุณจะเพิ่มตารางนั้นเข้า publication เอง และทุก event จะถูก re-authorize ต่อผู้ subscribe แต่ละคนตาม Row Level Security
จากการเปลี่ยนแปลงของแถวหนึ่งไปสู่ callback ฝั่ง client
หัวข้อที่มีชื่อว่า “จากการเปลี่ยนแปลงของแถวหนึ่งไปสู่ callback ฝั่ง client”Postgres เก็บบันทึกการเขียนทุกครั้งไว้อย่างเป็นระเบียบและคงทนอยู่แล้วใน write-ahead log (WAL) — ใช้ภายในเพื่อ crash recovery และ replication Supabase Realtime อ่าน WAL เดียวกันนี้ผ่านฟีเจอร์ logical replication ของ Postgres แล้วแปลงทุกการเปลี่ยนแปลงที่ commit แล้วให้กลายเป็นข้อความที่ push ผ่าน WebSocket ได้ client subscribe เข้า channel หนึ่ง บอกว่าสนใจตารางไหนและ event ประเภทไหน แล้วจะได้ callback ทำงานทุกครั้งที่มีแถวเปลี่ยนแปลงตรงเงื่อนไข แบบ live โดยไม่ต้อง poll ตารางเป็นช่วง ๆ
ข้อเท็จจริงสำคัญที่ต้องจำให้ขึ้นใจก่อนเขียนโค้ดใด ๆ: pipeline นี้ปิดอยู่โดย default สำหรับทุกตารางที่คุณสร้าง Realtime ไม่ได้เฝ้าดู schema ทั้งหมดของคุณโดยอัตโนมัติ — การเขียนของตารางหนึ่งจะไปถึง WAL stream ที่ Realtime แตะอยู่ก็ต่อเมื่อตารางนั้นถูกเพิ่มเข้า publication ชื่อ supabase_realtime แล้วเท่านั้น จนกว่าจะทำแบบนั้น client จะ subscribe ทั้งวันก็ไม่ได้อะไรเลย
พิสูจน์: ตารางที่อยู่นอก publication เงียบสนิท
หัวข้อที่มีชื่อว่า “พิสูจน์: ตารางที่อยู่นอก publication เงียบสนิท”เริ่มจากตารางธรรมดา ๆ ที่ตั้งใจ ไม่ เพิ่มเข้า publication ใด ๆ เลย:
-- supabase/migrations/6001_rt_items_and_publication.sqlcreate table public.rt_items ( id bigint generated always as identity primary key, label text not null, created_at timestamptz not null default now());Subscribe ด้วย anon client รอสักครู่ แล้ว insert แถวด้วย service_role (การ bypass RLS ไม่เกี่ยวอะไรตรงนี้เลย — ประเด็นคือ Realtime ไม่เห็นการเขียนนี้เลยด้วยซ้ำ ไม่ว่าจะมี RLS หรือไม่):
channel status: SUBSCRIBEDinserted row via service_role, id = 1 (table is NOT in supabase_realtime publication yet)waited 5s after insert. event received: falsechannel status: CLOSEDผลลัพธ์จริงจาก Mac เครื่องนี้ container supabase_realtime_probe ใน tools/probe — ห้าวินาทีเต็ม ไม่มี event เลยสักครั้ง ไม่มี error ที่ไหนเลย นี่คือความล้มเหลวแบบเงียบ (silent failure) ที่พบบ่อยที่สุดในโค้ด Supabase Realtime ที่ AI สร้าง (โค้ด channel.on('postgres_changes', ...) ที่ดูถูกต้องสมบูรณ์แบบแต่ไม่เคยทำงานเลย): ตารางมีอยู่จริง คอลัมน์ถูกต้อง การ subscribe เชื่อมต่อสำเร็จและรายงาน SUBSCRIBED แต่ไม่มีอะไรมาถึงเลย เพราะไม่มีใครรันคำสั่ง SQL หนึ่งบรรทัดที่ทำให้ตารางถูกเชื่อมเข้ากับ replication จริง ๆ
เปิดใช้งานด้วย SQL ธรรมดา — ไฟล์ migration เดียวกัน ต่อจาก create table ทันที:
-- supabase/migrations/6001_rt_items_and_publication.sqlalter publication supabase_realtime add table public.rt_items;alter table public.rt_items replica identity full;สวิตช์แบบเดียวกันนี้มีอยู่ใน Studio ที่หน้า Database > Replication ด้วย ถ้าไม่อยากรัน SQL เอง — ทำสิ่งเดียวกันทุกประการภายใต้ฉากหลัง
Subscribe จากฝั่ง client
หัวข้อที่มีชื่อว่า “Subscribe จากฝั่ง client”เมื่อ rt_items อยู่ใน publication แล้ว supabase-js client ก็ subscribe รับการเปลี่ยนแปลงได้:
import { createClient } from '@supabase/supabase-js';import type { Database } from '../database.types';
declare const process: { env: Record<string, string | undefined>; exit: (code?: number) => never };
const supabase = createClient<Database>( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
const channel = supabase .channel('rt:items') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'rt_items' }, (payload) => { console.log('New item:', payload.new); } ) .subscribe();event รับค่า INSERT, UPDATE, DELETE หรือ * เพื่อรับทั้งสามแบบ schema กับ table จำกัดขอบเขตการ subscribe ไว้ที่ตารางเดียว คุณลงทะเบียน .on('postgres_changes', ...) หลายตัวบน channel เดียวกันเพื่อฟังหลาย event type หรือหลายตารางพร้อมกันได้
filter จำกัดการ subscribe ให้เหลือเฉพาะแถวที่ตรงเงื่อนไข
หัวข้อที่มีชื่อว่า “filter จำกัดการ subscribe ให้เหลือเฉพาะแถวที่ตรงเงื่อนไข”นอกจาก event/schema/table แล้ว ยังมีตัวเลือกที่สี่คือ filter ที่จำกัดการ subscribe ให้เหลือเฉพาะแถวที่ตรงเงื่อนไขเดียว — syntax แบบ column=operator.value เดียวกันกับที่ PostgREST ใช้ใน query string เช่น id=eq.1 พิสูจน์ด้วยการ seed สองแถวก่อน แล้ว subscribe ด้วย filter ที่ตรงกับแค่ id ของแถวเดียว:
// @runimport { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined>; exit: (code?: number) => never };
const anon = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);const admin = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const { data: target } = await admin.from('rt_items').insert({ label: 'filter-target' }).select().single();const { data: other } = await admin.from('rt_items').insert({ label: 'filter-other' }).select().single();
const matched: any[] = [];const channel = anon .channel('rt:filter-check') .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'rt_items', filter: `id=eq.${target.id}` }, (payload) => matched.push(payload) ) .subscribe();
await new Promise((r) => setTimeout(r, 6000));
await admin.from('rt_items').update({ label: 'other-updated' }).eq('id', other.id);await new Promise((r) => setTimeout(r, 3000));console.log('after updating the NON-matching row, matched events:', matched.length);
await admin.from('rt_items').update({ label: 'target-updated' }).eq('id', target.id);await new Promise((r) => setTimeout(r, 4000));console.log('after updating the matching row, matched events:', matched.length);console.log('matched payload.old:', JSON.stringify(matched[0]?.old));
await anon.removeChannel(channel);process.exit(matched.length === 1 && matched[0]?.old?.id === target.id ? 0 : 1);after updating the NON-matching row, matched events: 0after updating the matching row, matched events: 1matched payload.old: {"id":1,"label":"filter-target","created_at":"2026-09-29T04:33:05.011034+00:00"}การอัปเดตแถวของ other ไม่ทำให้เกิด callback เลยสักครั้ง — filter ถูกใช้งานก่อนที่ client จะเห็น event ด้วยซ้ำ ไม่ใช่การกรองฝั่ง client ภายหลัง — ส่วนการอัปเดตแถวของ target ทำให้ event ยิงมาพอดีหนึ่งครั้ง
replica identity full — ความต่างใน payload.old
หัวข้อที่มีชื่อว่า “replica identity full — ความต่างใน payload.old”โดย default Postgres จะใส่เฉพาะคอลัมน์ primary key ของแถวเก่าใน payload ของ UPDATE หรือ DELETE เท่านั้น — ไม่ใช่แถวเก่าเต็มรูปแบบ พิสูจน์กับตารางที่ยังใช้ replica identity แบบ default (relreplident = 'd' ก่อนที่ alter table ... replica identity full ด้านบนจะถูกใช้งาน):
EVENT: UPDATE old= {"id":4} new= {"id":4,"label":"debug-row-updated","created_at":"2026-09-29T04:31:04.807268+00:00"}EVENT: DELETE old= {"id":5} new= {}old มีแค่ id ในทั้งสอง event type — คอลัมน์อื่นหายไปหมด เพราะ WAL record ของแถวเก่าที่ Postgres เขียนไว้มีแค่ replica identity (primary key โดย default) เท่านั้น การพิสูจน์ filter ด้านบนรันขึ้น หลังจาก alter table public.rt_items replica identity full; แล้ว และ matched[0].old ของมันออกมาเป็น {"id":1,"label":"filter-target","created_at":"..."} — ครบทุกคอลัมน์ รวมถึงค่า label ก่อนหน้าที่จะถูกอัปเดต นั่นคือผลทั้งหมดของ replica identity full: มันบอก Postgres ให้เขียนแถวเก่าทั้งแถวลง WAL ไม่ใช่แค่คอลัมน์ identity เท่านั้น เพื่อให้ Realtime มีอะไรใส่ใน payload.old มากกว่าแค่ primary key ถ้า logic ของแอปคุณต้องเทียบค่าเก่ากับค่าใหม่ (เช่น เพื่อรู้ว่าฟิลด์หนึ่งเปลี่ยน จาก อะไร) คุณต้องตั้งค่านี้ — ถ้าไม่ตั้ง payload.old บน UPDATE/DELETE จะขาดทุกคอลัมน์ที่ไม่ใช่ key ไปแบบเงียบ ๆ
RLS มีผลกับ Postgres Changes ด้วย ต่อผู้ subscribe แต่ละคน
หัวข้อที่มีชื่อว่า “RLS มีผลกับ Postgres Changes ด้วย ต่อผู้ subscribe แต่ละคน”Postgres Changes ไม่ใช่ firehose ที่ข้ามระบบความปลอดภัยของตารางไปเฉย ๆ — Realtime เช็ค policy select ของ Row Level Security ของผู้ subscribe แต่ละคนซ้ำทุกครั้งสำหรับทุก event โดยใช้ session ที่ client นั้น authenticate ไว้ ผู้ใช้จริงสองคน ตารางที่มี policy จำกัดตาม owner และการ subscribe แบบไม่มี filter เลย:
-- supabase/migrations/6002_rt_notes_rls.sqlcreate table public.rt_notes ( id bigint generated always as identity primary key, user_id uuid not null, body text not null);
alter table public.rt_notes enable row level security;
create policy "rt_notes_own_rows_select"on public.rt_notesfor selectto authenticatedusing (auth.uid() = user_id);
create policy "rt_notes_own_rows_insert"on public.rt_notesfor insertto authenticatedwith check (auth.uid() = user_id);
alter publication supabase_realtime add table public.rt_notes;ผู้ใช้จริงสองคนที่สมัครผ่าน GoTrue ([email protected], [email protected]) แต่ละคนมี supabase-js client instance และ session เป็นของตัวเอง (signInWithPassword — ไม่ต้องเรียก realtime.setAuth() เพิ่มสำหรับ postgres_changes ต่างจากกรณี private channel ในบทถัดไป; การ sign in บน client จะรีเฟรช auth ของ Realtime connection ของ client นั้นให้อัตโนมัติ) Alice subscribe แบบไม่มี filter เลย จากนั้น bob insert แถวของตัวเองผ่าน client ของเขา และ alice insert แถวของตัวเองผ่าน client ของเธอ:
// @runimport { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined>; exit: (code?: number) => never };
const API_URL = process.env.SUPABASE_URL!;const ANON_KEY = process.env.SUPABASE_ANON_KEY!;const admin = createClient(API_URL, process.env.SUPABASE_SERVICE_ROLE_KEY!);
async function ensureUser(email: string, password: string) { const { data, error } = await admin.auth.admin.createUser({ email, password, email_confirm: true }); if (!error) return data.user; const { data: list } = await admin.auth.admin.listUsers(); const existing = list.users.find((u) => u.email === email); if (!existing) throw error; return existing;}
const aliceClient = createClient(API_URL, ANON_KEY);const bobClient = createClient(API_URL, ANON_KEY);await aliceClient.auth.signInWithPassword({ email: '[email protected]', password: 'rt-pw-alice-12345' });await bobClient.auth.signInWithPassword({ email: '[email protected]', password: 'rt-pw-bob-12345' });
const aliceEvents: any[] = [];const channel = aliceClient .channel('rt:notes-scoping') .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'rt_notes' }, (payload) => { aliceEvents.push(payload.new); }) .subscribe();
await new Promise((r) => setTimeout(r, 6000));
await bobClient.from('rt_notes').insert({ user_id: bob.id, body: 'bob private note' });await aliceClient.from('rt_notes').insert({ user_id: alice.id, body: 'alice private note' });
await new Promise((r) => setTimeout(r, 5000));
console.log('events alice received:', aliceEvents.length, JSON.stringify(aliceEvents.map((e) => e.body)));const onlyOwnRows = aliceEvents.every((e) => e.user_id === alice.id);const receivedHerOwn = aliceEvents.some((e) => e.user_id === alice.id);
await aliceClient.removeChannel(channel);process.exit(onlyOwnRows && receivedHerOwn ? 0 : 1);events alice received: 1 ["alice private note"]การ subscribe แบบไม่มี filter ของ alice เห็น INSERT แค่หนึ่งครั้งเท่านั้น — เป็นของเธอเอง — แม้ว่าการ insert ของ bob จะเกิดขึ้นบนตารางเดียวกัน channel type เดียวกันเป๊ะ แถวของ bob ถูกเขียนลง Postgres สำเร็จตามปกติ (insert ของเขาไม่ error) แต่ Realtime ไม่เคยส่ง event นั้นไปยัง socket ของ alice เลย เพราะ policy select ใน session ของเธอบนตาราง rt_notes ไม่อนุญาตให้เห็นแถวของเขา นี่คือกลไกจริง ไม่ใช่เรื่องบังเอิญ: ทุก event ของ Postgres Changes ถูก authorize ต่อผู้ subscribe แต่ละคนแยกกัน ซึ่งก็เป็นเหตุผลเดียวกับที่มันไม่ scale เกินไม่กี่พันผู้ subscribe พร้อมกัน (หัวข้อถัดไป)
flowchart LR
insert["Postgres INSERT/UPDATE/DELETE"] --> wal["Write-ahead log (WAL)"]
wal --> gate{"Is the table added to supabase_realtime publication?"}
gate -->|no| nothing["No event is ever sent"]
gate -->|yes| realtime["Realtime server reads WAL via logical replication"]
realtime --> perSub{"For EACH subscriber: does their RLS select policy admit this row?"}
perSub -->|no| skip["That subscriber gets nothing"]
perSub -->|yes| client["That subscriber's postgres_changes callback fires"] อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”AI assistant ถูกขอให้ “เพิ่ม live activity feed เมื่อมีแถวถูก insert เข้า orders” แล้วสร้าง migration นี้มา พร้อมคิดว่าฟีเจอร์เสร็จแล้ว:
🐛 หา bug ในโค้ดนี้
create table public.orders (id bigint generated always as identity primary key,customer_id uuid not null,total numeric not null,created_at timestamptz not null default now());
alter table public.orders enable row level security;
create policy "orders_select_own"on public.ordersfor selectto authenticatedusing (auth.uid() = customer_id);เฉลย
ตารางถูกสร้าง RLS ถูกเปิด และ policy ก็ถูกต้อง — แต่ไม่มีตรงไหนใน migration นี้เลยที่รัน alter publication supabase_realtime add table public.orders; โค้ดฝั่ง client channel.on('postgres_changes', { table: 'orders', ... }, callback) ที่ AI ตัวเดียวกันเขียนต่อมาจะเชื่อมต่อสำเร็จ รายงาน SUBSCRIBED แล้วก็ไม่เคยทำงานเลย — เหมือนที่พิสูจน์ไว้ตอนต้นบทนี้เป๊ะ เงียบสนิทห้าวินาทีเต็มโดยไม่มี error ที่ไหนใน stack เลย ไม่มี exception ให้ catch และไม่มี log บรรทัดไหนให้ grep เพราะในมุมมองของ Postgres ไม่มีอะไรผิดพลาดเลย — ตารางไม่เคยถูกเชื่อมเข้ากับ replication ตั้งแต่แรก ทางแก้คือคำสั่งเดียว เพิ่มต่อท้าย migration เดียวกัน: alter publication supabase_realtime add table public.orders; — และถ้า feed ต้องแสดงว่า total ของแถวเปลี่ยน จาก อะไร ให้เพิ่ม alter table public.orders replica identity full; ต่อท้ายด้วย
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Postgres Changes คือ Realtime ที่ tail write-ahead log ของ Postgres เอง ไม่ใช่ event bus แบบพิเศษที่ต่อเสริมเข้ากับ schema — และนั่นคือเหตุผลที่มันสืบทอดคุณสมบัติสองอย่างของ Postgres มาแทนที่จะประดิษฐ์ของใหม่ ข้อแรก WAL มีแค่สิ่งที่ Postgres ตัดสินใจเขียนลงไปเท่านั้น: ไม่มีอะไรสตรีมสำหรับตารางหนึ่งจนกว่า alter publication ... add table จะบอกให้ logical replication เริ่มรวมมันเข้าไป และแถวเก่าใน record ของ UPDATE/DELETE ก็ถูกจำกัดแค่ replica identity (primary key โดย default) เว้นแต่คุณจะขยายมันด้วย replica identity full ข้อสอง เพราะผู้ subscribe แต่ละคนเชื่อมต่อด้วย session ของตัวเอง Realtime จึงรัน policy select ของ RLS บนตารางของคุณซ้ำได้ต่อ event ต่อผู้ subscribe — policy เดียวกับที่ PostgREST จะรันสำหรับ select ธรรมดา เพียงแต่ถูกเรียกครั้งละหนึ่งคนที่กำลังเฝ้าดู แทนที่จะเป็นครั้งละหนึ่ง query filter คือการจำกัดขอบเขตแบบถูก ๆ ที่ใช้ก่อนการเช็ค RLS นั้น ไม่ใช่ตัวแทนของมัน: filter ลดจำนวนผู้ฟังของ event ได้ แต่มีแค่ RLS เท่านั้นที่ตัดสินว่าผู้ subscribe คนหนึ่งได้รับอนุญาตให้เห็นมันหรือไม่
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- Postgres Changes ไม่ต้องมี infrastructure เพิ่มเลยในการเริ่มใช้ — แค่คำสั่ง
alter publicationเดียว และทุกตารางที่คุณมี RLS policy อยู่แล้วก็สตรีมได้ทันที — แลกมาด้วยการเช็ค authorization ต่อผู้ subscribe ในทุก ๆ event ซึ่งเป็นสิ่งที่จำกัดมันไว้ที่ประมาณไม่กี่พันผู้ subscribe พร้อมกันสำหรับการเปลี่ยนแปลงเดียวกัน (เอกสาร อ้างอิงด้านบน) replica identity fullให้แถวเก่าเต็มรูปแบบในpayload.old— มีประโยชน์สำหรับการ diff หรือ undo — แลกมาด้วย WAL record ที่ใหญ่ขึ้นสำหรับทุกการเขียนบนตารางนั้น แม้จะไม่มีผู้ subscribe คนไหนสนใจเลยก็ตาม เพราะ Postgres ไม่มีทางรู้ล่วงหน้าว่าการเปลี่ยนแปลงไหนจะถูกเฝ้าดูfilterให้คุณจำกัดการ subscribe ให้เหลือเฉพาะแถวที่ client ต้องการจริง ๆ ลดทั้ง network traffic และจำนวนครั้งที่ RLS ต้องถูกประเมินซ้ำสำหรับ client นั้น — แลกมาด้วย filter expression หนึ่งอันต่อความสนใจหนึ่งเรื่อง: client ที่เฝ้าดูหลายส่วนของตารางเดียวกันต้องลงทะเบียน.on('postgres_changes', ...)หลายตัว หนึ่งตัวต่อหนึ่ง filter ไม่ใช่ subscription กว้าง ๆ ตัวเดียวแล้วกรองฝั่ง client
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ตารางใหม่สตรีม changes ให้อัตโนมัติ” พิสูจน์แล้วด้านบน: ตารางที่เพิ่งสร้างจะไม่มี event เลยไม่มีกำหนด จนกว่าจะถูกเพิ่มเข้า publication
supabase_realtimeอย่างชัดเจน — ไม่มี opt-in โดยนัย และไม่มี error บอกว่ามันขาดไป - “
payload.oldมีแถวเก่าเต็มรูปแบบเสมอ” มีเฉพาะเมื่อตั้งreplica identity fullบนตารางนั้นโดยเฉพาะ โดย default มันมีแค่ primary key — พิสูจน์แล้วด้านบนด้วยทั้งUPDATEและDELETEบนตารางเดียวกันก่อนที่จะตั้งค่านั้น - “RLS มีผลแค่กับ query ตรง ๆ เท่านั้น Realtime สตรีมทุกอย่างที่เปลี่ยนไปเฉย ๆ” พิสูจน์แล้วด้านบนด้วยผู้ใช้จริงสองคน: การ insert ของ bob ไม่เคยไปถึงการ subscribe แบบไม่มี filter ของ alice เลย เพราะ Realtime ประเมิน policy
selectของเธอกับทุก event ก่อนจะส่งให้ — policy เดียวกันเป๊ะกับที่จะ gateselect * from rt_notesธรรมดา - “
filterคือขอบเขตความปลอดภัย ใช้แทน RLS ได้เลย”filterแค่ตัดสินว่าแถวไหนคุ้มค่าที่จะเช็คกับ RLS เท่านั้น มันไม่เคยให้สิทธิ์เข้าถึงที่ RLS จะปฏิเสธอยู่แล้ว ผู้ subscribe ที่ policy RLS ของเขาไม่อนุญาตแถวหนึ่ง จะไม่ได้รับแถวนั้นไม่ว่าfilterจะเขียนว่าอะไรก็ตาม และตารางที่มี policy กว้างเกินไปก็ไม่ได้ปลอดภัยขึ้นแค่เพราะเพิ่มfilterแคบ ๆ เข้าไป