Worked review 3: dashboard แบบ realtime
ฟีเจอร์
หัวข้อที่มีชื่อว่า “ฟีเจอร์”prompt ที่ให้ไปคือ: “สร้าง live dashboard: table เก็บ event, feed realtime ของ event ใหม่, webhook ที่แจ้งระบบภายนอกทุก event และ background job ที่ rollup สถิติรายชั่วโมงโดยใช้ pooler” แต่ละชิ้นที่นี่เดี่ยว ๆ ดูเหมือนโค้ดที่ทำงานได้ที่ AI assistant มอบให้อย่างมั่นใจ — channel subscription ที่รายงาน SUBSCRIBED, trigger ที่ทำงาน, job ที่ query ได้ปกติสิบครั้งแรกที่ test มันมี บั๊กจริง 10 ตัว หลายตัวเป็นประเภทที่โผล่มาเฉพาะภายใต้เงื่อนไขที่ docs ของคอร์สนี้เองเตือนไว้พอดี (scale, contention, schedule string ที่ดูสมเหตุสมผล) ไม่ใช่ตอน manual test ครั้งแรก ดูที่ AI bug catalog สำหรับรูปแบบทั่วไปเบื้องหลังสองตัว (19, 21) — ที่เหลือเป็นเรื่องใหม่ในรีวิวนี้ เฉพาะกับโมเดล private-channel ของ Realtime, pg_net/pg_cron และ Supavisor ทุกข้อกล่าวอ้างด้านล่างถูกพิสูจน์บน local stack ของคอร์สนี้เอง — realtime subscription จริง, pg_net/pg_cron ที่ติดตั้งจริง, process supabase functions serve จริง (หยุดแล้วทันทีที่เก็บข้อพิสูจน์ครบ) และ connection ที่แย่งกันจริงกับ Supavisor pooler ของคอร์สนี้เอง
โค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “โค้ดที่ AI สร้าง”-- reading-supabase/worked-review-3/dashboard-migration.sql -- as generated, see review belowcreate table public.wr_dashboard_events ( id bigint generated always as identity primary key, kind text not null, payload jsonb not null default '{}'::jsonb, created_at timestamptz not null default now());alter table public.wr_dashboard_events enable row level security;create policy wr_dashboard_events_select on public.wr_dashboard_events for select to authenticated using (true);
-- AI comment: "realtime works out of the box once RLS is set up"
create or replace function public.wr_dashboard_webhook()returns triggerlanguage plpgsqlsecurity definerset search_path = ''as $$begin perform net.http_post( url := 'https://example-webhook-receiver.internal/wr3-dashboard', body := jsonb_build_object('id', new.id, 'kind', new.kind) ); return new;end;$$;
create trigger wr_dashboard_events_webhook after insert on public.wr_dashboard_events for each row execute function public.wr_dashboard_webhook();// reading-supabase/worked-review-3/dashboard-subscriber.ts -- as generated, see review belowimport { createClient } from '@supabase/supabase-js';
export function subscribeToDashboard(url: string, anonKey: string) { const client = createClient(url, anonKey); return client .channel('wr3:dashboard-events') .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'wr_dashboard_events' }, (payload) => { console.log('new event, what changed:', payload.old, '->', payload.new); }) .subscribe();}เดินรีวิวทีละจุด
หัวข้อที่มีชื่อว่า “เดินรีวิวทีละจุด”postgres_changes บน table ที่ไม่เคยถูกเพิ่มเข้า supabase_realtime
หัวข้อที่มีชื่อว่า “postgres_changes บน table ที่ไม่เคยถูกเพิ่มเข้า supabase_realtime”ไม่มีอะไรใน migration ที่ AI สร้างรัน alter publication supabase_realtime add table ... เลย พิสูจน์แล้ว: subscriber เชื่อมต่อ รายงานสำเร็จ และ insert จริงให้ความเงียบสนิท:
# subscribe, wait 1.5s, insert a row as service_role, wait 3sreceived event: false (table NOT in supabase_realtime publication yet)🐛 หา bug ในโค้ดนี้
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'wr_dashboard_events' }, ...).subscribe()เฉลย
ตรงกับ ai-bug-catalog entry 19 เป๊ะ: callback ของ subscribe() รายงาน SUBSCRIBED — การเชื่อมต่อ WebSocket และ channel เองโอเคจริง — ในขณะที่ table เองไม่เคยถูกต่อสายเข้า logical replication เลย Postgres เลยไม่มีอะไรจะ stream ออกมา ไม่มี error ตรงไหนใน chain นี้เลยเพราะ จากมุมมองของ Postgres เองไม่มีอะไรผิดพลาด วิธีแก้ พิสูจน์ตรงด้านล่าง คือ statement เดียว: alter publication supabase_realtime add table public.wr_dashboard_events; — หลังจากนั้น subscription เดียวกันเป๊ะได้รับ insert เดียวกันเป๊ะ
ALTER PUBLICATIONreceived event: truepostgres_changes เป็นกลไกที่ผิดสำหรับ table นี้ในระดับ scale
หัวข้อที่มีชื่อว่า “postgres_changes เป็นกลไกที่ผิดสำหรับ table นี้ในระดับ scale”แม้ต่อสายเข้า publication ถูกต้องแล้ว postgres_changes ก็ยัง re-authorize ทุก event กับ RLS select policy ของ subscriber แต่ละคนเอง — พิสูจน์ไว้แล้วใน Realtime: Postgres Changes สำหรับ dashboard ตาม docs ของ Supabase เอง (ดึงมาใหม่สำหรับรีวิวนี้ supabase.com/docs/guides/realtime/postgres-changes): “If you expect more than ~3,000 concurrent subscribers on the same changes, use Broadcast to stream database changes instead.” live dashboard คือฟีเจอร์ทรงที่มีแนวโน้มข้ามเส้นนั้นพอดี — ผู้ชมเยอะ ดู rollup เดียวกัน
🐛 หา bug ในโค้ดนี้
create policy wr_dashboard_events_select on public.wr_dashboard_eventsfor select to authenticated using (true);-- AI comment: "realtime works out of the box once RLS is set up"เฉลย
“ใช้ได้เลยไม่ต้องทำอะไร” เป็นจริงตอน subscriber น้อย และห่างจากความจริงลงหน้าผาที่มีเอกสารรองรับและอ้างอิงได้ในระดับ dashboard — ตาม docs เดียวกัน การเปลี่ยนแปลงครั้งเดียว re-run การเช็ค authorization ต่อ subscriber สำหรับทุกหนึ่งใน connection ที่เปิดอยู่หลายพัน แทนที่จะคำนวณครั้งเดียวแล้วกระจายออกไป วิธีแก้ ใช้ใน corrected code ของรีวิวนี้ด้านล่าง คือ realtime.broadcast_changes() — trigger เขียน event ครั้งเดียว และ private-channel policy ของ subscriber แต่ละคนถูกเช็คครั้งเดียวต่อconnection ไม่ใช่ต่อevent
broadcast_changes ทำงาน แต่ client subscribe channel แบบ public
หัวข้อที่มีชื่อว่า “broadcast_changes ทำงาน แต่ client subscribe channel แบบ public”การเปลี่ยนไปใช้ realtime.broadcast_changes() (trigger ยังไม่โชว์ — รูปทรงเดียวกับ corrected code ด้านล่าง) โดยไม่อัปเดต client ให้เป็น { config: { private: true } } ดูเหมือนน่าจะยังทำงานได้ — ชื่อ channel ตรงกัน trigger ทำงาน พิสูจน์ด้วย subscription จริงและ insert จริง:
received broadcast on PUBLIC channel subscription: false🐛 หา bug ในโค้ดนี้
client.channel('wr3:dashboard-events').on('broadcast', { event: 'INSERT' }, callback).subscribe()// no { config: { private: true } }เฉลย
realtime.broadcast_changes() เขียน message ของมันเป็น private = true เสมอ — ยืนยันไว้แล้วใน Realtime Broadcast and Presence — ดังนั้นไม่มีทางไปถึงมันได้จาก public-channel subscription เลย นี่ไม่ใช่ช่องโหว่ permission ที่ fail ดัง ๆ แต่คือ topic ที่ไม่ตรงกัน ให้ความเงียบสนิทเหมือนบั๊กก่อนหน้าเป๊ะ ด้วยเหตุผลที่ต่างกันโดยสิ้นเชิง วิธีแก้คือ { config: { private: true } } บน client พิสูจน์ด้านล่างพร้อมกับบั๊กถัดไป (private channel ที่ไม่มี policy ก็ fail เหมือนกัน แค่คนละแบบ)
private channel ที่ไม่มี policy บน realtime.messages
หัวข้อที่มีชื่อว่า “private channel ที่ไม่มี policy บน realtime.messages”การตั้ง channel เป็น private โดยไม่มี policy คู่กันบน realtime.messages เปลี่ยนจากความเงียบเป็นการปฏิเสธที่ดังและจริง พิสูจน์ด้วย subscriber ที่ authenticated จริง:
subscribe result with NO policy on realtime.messages: {"status":"CHANNEL_ERROR","message":"Unauthorized: You do not have permissions to read from this Channel topic: wr3:dashboard-events"}🐛 หา bug ในโค้ดนี้
-- realtime.broadcast_changes() trigger exists, but no policy anywhere-- grants select on realtime.messages for this topicเฉลย
นี่คือ failure mode เดียวในรีวิวนี้ที่อย่างน้อยก็ fail อย่างตรงไปตรงมาและทันที ต่างจากสองตัวเงียบ ๆ ข้างบน — แต่ก็ยังควรใส่ไว้เพราะ “private channel ก็ต้องการ RLS เหมือนกัน บน table ที่ต่างไปจาก table ที่คุณ broadcast จากโดยสิ้นเชิง” ไม่ใช่เรื่องที่เห็นชัดจาก trigger code อย่างเดียว วิธีแก้ พิสูจน์ด้านล่าง คือ policy บน realtime.messages ที่ scope ด้วย realtime.topic()
subscribe result with the policy in place: {"status":"SUBSCRIBED"}received on correctly-configured private channel: trueREPLICA IDENTITY ถูกปล่อยไว้ที่ default
หัวข้อที่มีชื่อว่า “REPLICA IDENTITY ถูกปล่อยไว้ที่ default”subscriber ที่ AI สร้าง log payload.old เพื่อแสดง “เปลี่ยนอะไรไป” — code ที่ดูสมเหตุสมผลแต่อ่านแล้วแย่ทันทีที่ REPLICA IDENTITY ถูกปล่อยไว้ที่ default ของ Postgres เอง พิสูจน์ด้วย UPDATE จริง แถวเดียวกัน subscription เดียวกัน:
REPLICA IDENTITY DEFAULT -- payload.old: {"id":5}🐛 หา bug ในโค้ดนี้
console.log('new event, what changed:', payload.old, '->', payload.new);เฉลย
ตรงกับ ai-bug-catalog entry 21 เป๊ะ: payload.old ไม่ใช่แค่ “ขาดบาง field” แต่ขาดทุกคอลัมน์ยกเว้น primary key ทำให้ payload.old ข้างบน log ออกมาเป็น {"id":5} — ไม่มีอะไรให้ code นี้ diff กับ payload.new เพื่อแสดง “เปลี่ยนอะไรไป” เลยจริง ๆ (หมายเหตุ: บั๊กเฉพาะนี้ใช้กับ path postgres_changes เท่านั้น — ตัวแปร OLD/NEW ของ trigger เอง ซึ่ง broadcast_changes ส่งตรง มีแถวเต็มเสมอไม่ว่า REPLICA IDENTITY จะเป็นอะไร; บั๊กนี้เกี่ยวกับ path ที่อิง WAL ที่บั๊กก่อนหน้าของรีวิวนี้ย้ายออกไปแล้ว ใส่ไว้เพราะการรีวิว codebase แบบผสมหรืออยู่ระหว่างเปลี่ยนผ่านหมายความว่าทั้งสองกลไกโผล่มาด้วยกัน) วิธีแก้ พิสูจน์ตรงด้านล่างด้วย update เดียวกันเป๊ะ คือ alter table ... replica identity full;
REPLICA IDENTITY DEFAULT -- payload.old: {"id":6,"kind":"ri-test","payload":{"note":"original"},"created_at":"2026-09-29T08:59:35.650623+00:00"}webhook trigger ที่ยิง pg_net แล้วไม่เคยเช็คผลลัพธ์เลย
หัวข้อที่มีชื่อว่า “webhook trigger ที่ยิง pg_net แล้วไม่เคยเช็คผลลัพธ์เลย”wr_dashboard_webhook() เรียก net.http_post แล้ว return — ไม่มีการจัดการ error และไม่มีอะไรใน migration นี้เลยที่อ่าน net._http_response พิสูจน์แล้ว: insert ที่ยิง trigger นี้ return ในเวลาจริงต่ำกว่า 3ms (ยืนยันว่า pg_net เป็น async จริง ตามที่บท Background Tasks and Webhooks พิสูจน์ไว้แล้ว) และการส่งจริง เช็คแยกต่างหาก fail ไปเลย:
Time: 2.498 ms id | status_code | error_msg | created----+-------------+----------------------------+------------------------------- 6 | | Couldn't connect to server | 2026-09-29 09:00:51.800611+00🐛 หา bug ในโค้ดนี้
perform net.http_post(url := 'https://example-webhook-receiver.internal/wr3-dashboard',body := jsonb_build_object('id', new.id, 'kind', new.kind));return new;เฉลย
คุณค่าทั้งหมดของ pg_net คือ webhook ไม่เคยบล็อกการเขียนที่ยิง trigger มันเลย — พิสูจน์ไว้ข้างบน และเป็นพฤติกรรมที่ถูกต้องจริง ไม่ใช่บั๊กในตัวมันเอง บั๊กคือไม่มีอะไรใน codebase นี้เคยดูผลลัพธ์เลย: webhook receiver ที่ล่ม ตั้งค่าผิด หรือแค่เข้าถึงไม่ได้ (พิสูจน์ข้างบนด้วย Couldn't connect to server จริง) fail ทุกครั้ง ตลอดไป โดยไม่มีใครเห็นเลยยกเว้น table ที่ไม่มีใคร query วิธีแก้ ใน corrected code ของรีวิวนี้ คือ monitoring function เล็ก ๆ เหนือ net._http_response — เช็คตามตารางเวลา ไม่ใช่ข้างใน trigger เอง เพราะจุดประสงค์ทั้งหมดของ pg_net คือ trigger ไม่ควรรอคำตอบ
EdgeRuntime.waitUntil ถูกเรียก แต่ก็ await อยู่ดี
หัวข้อที่มีชื่อว่า “EdgeRuntime.waitUntil ถูกเรียก แต่ก็ await อยู่ดี”Edge Function คู่หนึ่งตั้งใจจะแจ้ง service ที่เฝ้าดู dashboard ในเบื้องหลังหลังจาก response เร็ว ๆ พิสูจน์ด้วย response timing จริงที่เก็บมาสำหรับสอง function ที่เหมือนกันทุกอย่างยกเว้นจุดเดียว:
=== broken (awaits the waitUntil promise anyway) ===real 0m2.095s
=== fixed (does not await it) ===real 0m0.075s🐛 หา bug ในโค้ดนี้
const bg = slowFollowUp()EdgeRuntime.waitUntil(bg)await bgเฉลย
การเรียก EdgeRuntime.waitUntil(bg) แล้ว await bg บรรทัดถัดไปเลย ก็ยัง register promise เป็น background task จริง — แล้วก็บล็อก response บน promise ตัวเดียวกันเป๊ะทันทีอยู่ดี ทำลายจุดประสงค์ทั้งหมดไปเลย พิสูจน์ข้างบน: เวอร์ชันนี้ใช้เวลา response นานเท่ากับงาน “ช้า” นั้นเอง (2 วินาทีจริง ตรงกับ setTimeout ของ slowFollowUp เอง) เหมือนกับไม่เคยเรียก waitUntil เลย วิธีแก้ พิสูจน์ข้างบนว่าเร็วกว่า ~28 เท่า บน Mac เครื่องนี้ คือแค่ไม่ await promise ที่ส่งให้ waitUntil — ปล่อยให้มันรันจนจบด้วยตัวเอง
Prepared statement ผ่าน transaction-mode pooler
หัวข้อที่มีชื่อว่า “Prepared statement ผ่าน transaction-mode pooler”job rollup รายชั่วโมงเชื่อมผ่าน port transaction-mode ของ Supavisor เพื่อรัน aggregation query ซ้ำ ๆ PREPARE ครั้งเดียวแล้ว EXECUTE ตามตารางเวลา — การ optimize ที่ดูเป็นธรรมชาติสำหรับ query ที่รันรูปทรงเดียวกันทุกครั้ง reproduce ด้วย contention จริงกับ pooler ของคอร์สนี้เอง (pool_size = 20) กลไกเดียวกับที่บท Connection Pooling and Supavisor พิสูจน์ไว้ครบแล้ว:
$ docker exec -i supabase_db_probe psql "postgresql://postgres.pooler-dev:postgres@supabase_pooler_probe:6543/postgres" -c "PREPARE wr3_stmt AS select count(*) from public.wr_dashboard_events;"PREPARE# ...25 concurrent sessions competing for the same 20-connection pool...$ docker exec -i supabase_db_probe psql "postgresql://postgres.pooler-dev:postgres@supabase_pooler_probe:6543/postgres" -c "EXECUTE wr3_stmt;"ERROR: prepared statement "wr3_stmt" does not exist🐛 หา bug ในโค้ดนี้
PREPARE wr3_stmt AS select count(*) from public.wr_dashboard_events;-- ... later, on a schedule ...EXECUTE wr3_stmt;เฉลย
Transaction mode คืน backend Postgres connection กลับสู่ pool หลังทุก statement ที่ autocommit ดังนั้น PREPARE กับ EXECUTE — ที่ส่งผ่าน client connection เดียวกันไปที่ Supavisor — ตกไปที่ backend จริง คนละตัว ได้ภายใต้ contention จริง และ backend ที่สองไม่เคยเห็น statement แรกถูก prepare เลย สิ่งนี้ผ่าน test local ที่ concurrency ต่ำทุกครั้ง (pool ที่โหลดเบา ๆ มักตกที่ backend เดิมโดยบังเอิญ) และ fail เฉพาะตอนที่ job รันพร้อมโหลด concurrent จริง — ตรงกับเงื่อนไขที่ production rollup job เจอเป๊ะ และ local smoke test เร็ว ๆ ไม่เจอ วิธีแก้คือ { prepare: false } บน driver (option จริงที่ package npm postgres มีให้สำหรับ pooler mode นี้โดยเฉพาะ) หรือสำหรับ job ที่ต้องการ prepared statement หรือฟีเจอร์ระดับ session จริง ๆ ใช้ session mode แทน transaction mode
schedule string ของ pg_cron ที่ไม่ใช่ cron syntax ที่ถูกต้อง
หัวข้อที่มีชื่อว่า “schedule string ของ pg_cron ที่ไม่ใช่ cron syntax ที่ถูกต้อง”schedule ของ job rollup เองตั้งใจให้รันทุก 5 นาที เขียนเป็น '*/5 minutes' — ภาษาอังกฤษที่อ่านง่าย ไม่ใช่ pg_cron syntax ที่ถูกต้อง พิสูจน์ด้วยการลอง schedule จริง:
select cron.schedule('wr3-bad-schedule', '*/5 minutes', $$select 1;$$);ERROR: invalid schedule: */5 minutesHINT: Use cron format (e.g. 5 4 * * *), or interval format '[1-59] seconds'🐛 หา bug ในโค้ดนี้
select cron.schedule('wr3-rollup', '*/5 minutes', $$ ... $$);เฉลย
error ของ pg_cron เองทันทีและเฉพาะเจาะจง — นี่คือบั๊กเดียวในรีวิวนี้ที่ fail ดังพอ ๆ กับบั๊ก 8 ข้างบน ตอน migration รัน ไม่ใช่ทีหลังภายใต้โหลด ใส่ไว้เพราะ schedule string แบบ “อ่านง่ายแต่ผิด” เป็นนิสัยของ AI ที่พบบ่อยข้าม cron-like syntax ทั่วไป และทางแก้เล็กพอที่จะพลาดตอนอ่านเร็ว ๆ: cron syntax จริง (*/5 * * * *) หรือ format interval-เป็นวินาทีแยกต่างหากของ pg_cron เอง ไม่ใช่ภาษาธรรมชาติเด็ดขาด
client ตัวที่สอง ที่แยกออกไป ถือ token ที่ไม่เคย refresh
หัวข้อที่มีชื่อว่า “client ตัวที่สอง ที่แยกออกไป ถือ token ที่ไม่เคย refresh”module subscriber realtime ของ dashboard เขียนเป็นไฟล์แยกของตัวเอง สร้าง instance createClient() ของตัวเอง ที่ seed ครั้งเดียวด้วย token ที่ส่งให้ แยกออกจาก auth client จริงตัวเดียวของแอป พิสูจน์แบบสด ด้วยการบังคับ refresh token จริง แล้วเทียบ Realtime auth state ภายในของทั้งสอง client:
token actually changed on refresh: truecorrectly-wired client auto-updated its realtime token: trueseparate realtime-only client still on the STALE original token: true (should be true -- the bug)🐛 หา bug ในโค้ดนี้
const realtimeOnlyClient = createClient(url, anonKey);realtimeOnlyClient.realtime.setAuth(originalToken); // set once, never againเฉลย
นี่เป็นบั๊กที่ subtler กว่า “ลืมเรียก setAuth” — อ่าน source ของ @supabase/[email protected] ที่ติดตั้งอยู่ตรง ๆ แสดงว่า client instance เดียวที่แชร์กัน ต่อสายอัตโนมัติอยู่แล้ว: auth-state listener ภายในของมันเองเรียก this.realtime.setAuth(token) ทุก event TOKEN_REFRESHED/SIGNED_IN/INITIAL_SESSION พิสูจน์ข้างบนด้วย refreshSession() จริงที่ถูกบังคับ สะท้อนทันทีใน layer realtime ของ client ตัวนั้นเองโดยไม่มี code manual เลย client ตัวที่สอง ที่สร้างแยกต่างหากสำหรับ “แค่ส่วน realtime” ไม่เคยได้รับ auth event ของแอปนั้นเลย ดังนั้น token ที่มัน seed ไว้ตอนสร้างคือ token เดียวที่มันจะมีตลอดไป — ถูกต้องจนกว่า token เดิมจะหมดอายุจริง แล้วก็ไม่ authorized เงียบ ๆ จากจุดนั้นเป็นต้นไป ไม่มี error จนกว่าจะ resubscribe ครั้งถัดไป วิธีแก้คือ client instance เดียวที่แชร์กันสำหรับทั้ง auth และ realtime instance เดียวกันตลอดทั้งแอป เหมือนที่ทุกบทอื่นในคอร์สนี้ทำอยู่แล้ว
โค้ดที่แก้แล้ว
หัวข้อที่มีชื่อว่า “โค้ดที่แก้แล้ว”-- supabase/migrations/7403_wr_dashboard.sqlcreate table public.wr_dashboard_events ( id bigint generated always as identity primary key, kind text not null, payload jsonb not null default '{}'::jsonb, created_at timestamptz not null default now());alter table public.wr_dashboard_events enable row level security;create policy wr_dashboard_events_select on public.wr_dashboard_events for select to authenticated using (true);
create or replace function public.wr_broadcast_dashboard_event()returns triggerlanguage plpgsqlsecurity definerset search_path = ''as $$begin perform realtime.broadcast_changes( 'wr3:dashboard-events', tg_op, tg_op, tg_table_name, tg_table_schema, new, old ); return new;end;$$;
create trigger wr_dashboard_events_broadcast after insert on public.wr_dashboard_events for each row execute function public.wr_broadcast_dashboard_event();
create policy wr3_dashboard_events_read on realtime.messages for select to authenticated using ( realtime.topic() = 'wr3:dashboard-events' );
create extension if not exists pg_net with schema extensions;
create or replace function public.wr_dashboard_webhook()returns triggerlanguage plpgsqlsecurity definerset search_path = ''as $$begin perform net.http_post( url := 'https://example-webhook-receiver.internal/wr3-dashboard', body := jsonb_build_object('id', new.id, 'kind', new.kind) ); return new;end;$$;
create trigger wr_dashboard_events_webhook after insert on public.wr_dashboard_events for each row execute function public.wr_dashboard_webhook();
-- Checked on a schedule (a real deployment would wire this into-- `select cron.schedule('wr3-check-failed-webhooks', '*/5 * * * *', ...)`-- using real cron syntax, never left running on this shared local stack) ---- never inside the trigger itself, since pg_net's whole point is that the-- trigger does not wait for the answer.create or replace function public.wr_check_failed_webhooks()returns table(response_id bigint, error_msg text, created timestamptz)language sqlsecurity definerset search_path = ''stableas $$ select id, error_msg, created from net._http_response where created > now() - interval '5 minutes' and (status_code is null or status_code >= 400);$$;revoke execute on function public.wr_check_failed_webhooks() from public;grant execute on function public.wr_check_failed_webhooks() to authenticated;declare const EdgeRuntime: { waitUntil: (promise: Promise<unknown>) => void }
async function slowFollowUp() { await new Promise((r) => setTimeout(r, 2000)) console.log('slow follow-up finished at', new Date().toISOString())}
Deno.serve(async (_req) => { console.log('request received at', new Date().toISOString()) EdgeRuntime.waitUntil(slowFollowUp()) console.log('responding at', new Date().toISOString()) return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' }, })})// @runimport { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined>; exit: (code?: number) => never };
const url = process.env.SUPABASE_URL!;const anonKey = process.env.SUPABASE_ANON_KEY!;const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;const admin = createClient(url, serviceKey);const stamp = Date.now();const email = `wr+run-dashboard-${stamp}@example.com`;
async function main() { const { data: created, error: cErr } = await admin.auth.admin.createUser({ email, password: 'wr-Passw0rd!', email_confirm: true, }); if (cErr) throw cErr;
// ONE shared client for both auth and realtime -- the fix for the last bug above. const client = createClient(url, anonKey); const { data: signIn, error: sErr } = await client.auth.signInWithPassword({ email, password: 'wr-Passw0rd!', }); if (sErr) throw sErr; client.realtime.setAuth(signIn.session!.access_token);
let received: unknown = null; const channel = client .channel('wr3:dashboard-events', { config: { private: true } }) .on('broadcast', { event: 'INSERT' }, (payload) => { received = payload; }); const subResult = await new Promise<{ status: string }>((resolve) => { channel.subscribe((status) => resolve({ status })); }); if (subResult.status !== 'SUBSCRIBED') { throw new Error(`private channel subscribe failed: ${subResult.status}`); } console.log('private channel subscribed');
const { data: row, error: iErr } = await admin .from('wr_dashboard_events') .insert({ kind: 'dashboard-run', payload: { source: 'run-script' } }) .select() .single(); if (iErr) throw iErr;
await new Promise((r) => setTimeout(r, 3000)); if (!received) throw new Error('did not receive broadcast_changes event on private channel'); console.log('received broadcast_changes event for insert id', row.id);
const { data: failed, error: fErr } = await client.rpc('wr_check_failed_webhooks'); if (fErr) throw fErr; console.log('failed webhook deliveries visible to the monitoring function:', (failed as unknown[]).length >= 1);
await client.removeChannel(channel); await admin.auth.admin.deleteUser(created.user.id); await admin.from('wr_dashboard_events').delete().eq('id', row.id); console.log('dashboard realtime test passed'); process.exit(0); // the open Realtime WebSocket otherwise keeps the process alive}
main().catch((err) => { console.error(err); process.exit(1);});รันจริงกับ local stack ของคอร์สนี้เอง (--run ของ harness ทำซ้ำอันนี้ตอน gate):
private channel subscribedreceived broadcast_changes event for insert id 12failed webhook deliveries visible to the monitoring function: truedashboard realtime test passedสิ่งที่เปลี่ยนและทำไม
หัวข้อที่มีชื่อว่า “สิ่งที่เปลี่ยนและทำไม”| # | บั๊ก | ทางแก้ |
|---|---|---|
| 1 | table ไม่เคยถูกเพิ่มเข้า publication supabase_realtime | ไม่จำเป็นแล้วเมื่อใช้ broadcast — พิสูจน์ว่าเงียบสนิทถ้าใช้ postgres_changes แบบไม่แก้ |
| 2 | เลือก postgres_changes สำหรับ dashboard (subscriber เยอะ) | realtime.broadcast_changes() — docs อ้างเส้นตัดของ ~3,000 subscriber |
| 3 | broadcast_changes trigger ทำงาน client อยู่บน channel แบบ public | { config: { private: true } } — พิสูจน์ว่าเงียบสนิทถ้าไม่แก้ (topic ไม่ตรงกัน) |
| 4 | private channel ไม่มี policy realtime.messages | เพิ่มแล้ว scope ด้วย realtime.topic() — ไม่งั้นได้ CHANNEL_ERROR จริง |
| 5 | REPLICA IDENTITY ปล่อยไว้ที่ default | replica identity full สำหรับ path postgres_changes — ไม่งั้นได้ old: {"id":..} จริง |
| 6 | webhook pg_net ไม่มีการเช็คผลลัพธ์ที่ไหนเลย | monitoring function ตามตารางเวลาเหนือ net._http_response |
| 7 | EdgeRuntime.waitUntil(bg) แล้ว await bg อยู่ดี | ไม่ await มัน — พิสูจน์ว่า response เร็วขึ้นราว 28 เท่าถ้าแก้ |
| 8 | prepared statement ผ่าน transaction-mode pooler | { prepare: false } (หรือ session mode) — ไม่งั้นได้ prepared statement ... does not exist จริง ภายใต้ contention |
| 9 | schedule pg_cron '*/5 minutes' | cron syntax จริง '*/5 * * * *' — ไม่งั้นได้ error invalid schedule จริง |
| 10 | token ของ client ตัวที่สองที่แยกออกไปไม่เคย refresh | client เดียวที่แชร์กันสำหรับ auth + realtime — พิสูจน์แล้ว: client ที่ต่อสายถูกต้อง auto-update ตัวที่แยกยังคงเก่าเงียบ ๆ |
สิ่งที่ reviewer เขียนใน PR
หัวข้อที่มีชื่อว่า “สิ่งที่ reviewer เขียนใน PR”ใกล้แล้ว แต่มี 10 เรื่อง — ส่วนใหญ่เกี่ยวกับ “subscribe สำเร็จ” พิสูจน์อะไรจริง ๆ:
เงียบ ไม่ใช่พัง — พิสูจน์ด้วย insert จริงและ 3 วินาทีของความว่างเปล่า:
- table ไม่อยู่ใน
supabase_realtime— เปลี่ยนไปใช้ broadcast (ข้อถัดไป) เลี่ยงเรื่องนี้ไปเลยpostgres_changesบน table dashboard — docs บอกเส้น ~3,000 subscriber ใช้broadcast_changes- broadcast trigger ทำงาน client subscribe channel แบบ public —
broadcast_changesprivate เสมออย่างน้อยก็ fail ดัง ๆ: 4. private channel ไม่มี policy
realtime.messages—CHANNEL_ERRORจริง แก้ง่ายเมื่อรู้ว่าต้องดูตรงไหนข้อมูลผิด ไม่ใช่ข้อมูลหาย: 5. ไม่มี
replica identity full—payload.oldมีแค่{"id":...}ไม่มีอะไรให้ diffความล้มเหลวที่มองไม่เห็นใน production: 6. webhook
pg_netไม่มีใครเช็คnet._http_responseเลย — ผมพิสูจน์ว่ามี delivery failure จริงนั่งอยู่โดยไม่มีใครสังเกต 7.waitUntilถูกเรียก แต่ await อยู่ดี — response 2 วินาที แทนที่จะเป็น 75ms เหมือนไม่ได้ใช้มันเลยโผล่แค่ตอนโหลดจริงหรือ schedule ที่ไม่เคยรัน: 8. prepared statement ผ่าน transaction-mode pooler — ผ่าน local test เร็ว ๆ ทุกครั้ง fail ภายใต้ contention จริง (พิสูจน์แล้ว) 9. schedule
pg_cron'*/5 minutes'ไม่ใช่ cron syntax — fail ตอน migration จับได้ง่ายใน CI 10. client realtime-only ตัวที่สองไม่เคยได้ token refresh ที่ client หลักจัดการอัตโนมัติอยู่แล้ว — ใช้ client เดียว ไม่ใช่สองไม่มีอันไหนต้อง redesign เลย ส่วนใหญ่แค่เลือกกลไกที่สร้างมาสำหรับ scale นี้ และไม่แยก state ที่ควรอยู่ที่เดียวกัน
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”บั๊กของรีวิวนี้แบ่งตาม ความมั่นใจผิด ๆ ที่มันดูเป็น: สามตัว (1, 3 และ webhook ที่ไม่เช็ค type, 6) fail ด้วยความเงียบสนิท — รูปทรงเดิมที่คอร์สนี้วนกลับมาเจอ เพราะ Realtime กับ pg_net ทั้งคู่เป็นระบบ async แบบ fire-and-forget ที่ตั้งใจไว้ ที่ “ไม่มี error” กับ “ไม่มีอะไรเกิดขึ้น” แยกกันไม่ออกถ้าไม่เช็ค state ตรง ๆ สองตัว (4, 9) fail ดังและทันที ซึ่งย้อนแย้งตรงที่ทำให้มันเป็นบั๊กที่ง่ายที่สุดในนี้ ไม่ใช่ยากที่สุด — CHANNEL_ERROR จริงหรือ invalid schedule จริงตอน migration ถูกจับได้ในนาทีแรกของการ test ที่เหลืออีกห้าตัว (2, 5, 7, 8, 10) คือรูปทรงที่อันตรายจริง ๆ: ถูกต้องทุก manual test เร็ว ๆ ผิดแค่ภายใต้เงื่อนไขเฉพาะ — จำนวน subscriber, diff ที่ถูกอ่านจริง, งบเวลา response, โหลด concurrent จริง, token ที่หมดอายุจริง — ที่การอ่าน code รอบแรกไม่เคยสร้างขึ้นมา
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”broadcast_changesscale ผ่านต้นทุน authorization ต่อ subscriber ของpostgres_changesได้ — แลกมาด้วยการเสีย RLS re-evaluation อัตโนมัติต่อ event ของpostgres_changes; broadcast trigger ส่งแค่สิ่งที่คุณใส่ใน payload ตรง ๆ เท่านั้น ดังนั้น logic authorization ใด ๆ ที่มากกว่า “connection นี้อ่าน topic นี้ได้ไหม” ต้องสร้างเข้าไปใน trigger หรือ channel policy เอง- การเช็ค
net._http_responseตามตารางเวลา แทนที่จะเช็คทันทีใน trigger ทำให้ write path เร็วอยู่ — แลกมาด้วยความล่าช้าในการตรวจจับจริง (สูงสุดตามช่วงตารางเวลาเอง) ระหว่าง webhook fail กับที่มีใครสังเกตเห็น ซึ่งเป็นการแลกที่ถูกต้องสำหรับ write path แต่เป็นตัวเลขจริงที่ต้องใส่ใน SLA ไม่ใช่ศูนย์ - client เดียวที่แชร์กันสำหรับ auth และ realtime ง่ายกว่าและถูกต้องเป็น default — client ตัวที่สองที่สร้างมาเฉพาะทางยังสมเหตุสมผลได้สำหรับ concern ที่แยกจริง ๆ (เช่น admin client แบบ service-role) ตราบใดที่มันไม่เคยถูกขอให้ถือ identity ของ user จริงไว้นานแบบที่บั๊กของรีวิวนี้ทำ
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “channel ที่รายงาน
SUBSCRIBEDกำลังรับ event อยู่” พิสูจน์ไว้ข้างบนสองครั้ง ด้วยเหตุผลต่างกัน: table ที่หายไปจาก publication และ payload ของbroadcast_changesที่ตกไปบน public channel ทั้งคู่ให้สถานะSUBSCRIBEDสะอาด ๆ แล้วก็ไม่มีอะไรเลยจริง ๆ ตลอดไป - “การออกแบบ async ของ
pg_netหมายความว่า webhook ที่ fail เป็นปัญหาของคนอื่นในที่สุด” พิสูจน์ไว้ข้างบน: ไม่มีอะไรใน code ที่สร้าง — หรือในpg_netเอง — เปิดเผยความล้มเหลวเลยถ้าไม่มีอะไร querynet._http_responseตรง ๆ “ในที่สุด” จะเกิดขึ้นก็ต่อเมื่อ query นั้นมีอยู่เท่านั้น - “ถ้าทำงานตอน test local จะทำงานใน production ด้วย” พิสูจน์ไว้ข้างบนด้วยบั๊กเดียวในรีวิวนี้ที่ขึ้นกับโหลดทั้งหมด: prepared statement ผ่าน transaction-mode pooler ผ่าน local test ที่ concurrency ต่ำทุกครั้งด้วยความบังเอิญ และ fail เฉพาะตอนที่ traffic concurrent จริงมาแย่ง connection pool เดียวกัน
flowchart TD A["insert into wr_dashboard_events"] --> B["postgres_changes: table not in publication"] B -->|"1: silence"| B A --> C["broadcast_changes trigger fires"] C --> D["client on a PUBLIC channel"] D -->|"3: silence, topic mismatch"| D C --> E["private channel, no realtime.messages policy"] E -->|"4: CHANNEL_ERROR"| E A --> F["pg_net webhook, no result checked"] F -->|"6: fails forever, unseen"| F G["EdgeRuntime.waitUntil(bg); await bg"] -->|"7: response blocked anyway"| G H["PREPARE / EXECUTE over transaction pooler"] -->|"8: fails under real contention"| H I["pg_cron '*/5 minutes'"] -->|"9: invalid schedule"| I J["second realtime-only client"] -->|"10: token never refreshes"| J