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

Realtime: Broadcast and Presence

Broadcast และ Presence เป็นเลเยอร์ pub/sub และติดตามสถานะออนไลน์แบบทั่วไปที่อยู่บน Realtime channel และต่างจาก Postgres Changes ตรงที่ เปิดใช้งานอยู่แล้วโดย default ไม่ต้องตั้งค่าตารางหรือ replication ใด ๆ เลย — แต่ channel หนึ่งก็ตั้งเป็น private ได้เช่นกัน ซึ่งเมื่อนั้นการเข้าถึงจะถูก authorize ผ่าน Row Level Security policy บน realtime.messages เหมือนตารางอื่น ๆ ทุกประการ

Postgres Changes (บทที่แล้ว) ทำงานก็ต่อเมื่อมีแถวเปลี่ยนแปลงจริงในฐานข้อมูลเท่านั้น ฟีเจอร์ real-time จำนวนมากไม่ต้องการ — และไม่ควรต้องจ่ายต้นทุนของ — การเขียนฐานข้อมูลเลย: ตำแหน่ง cursor แบบ live, flag “กำลังพิมพ์อยู่”, หรือข้อความแชทที่แค่ต้องไปถึงคนที่อยู่ในห้องตอนนั้น Broadcast ครอบคลุมเรื่องนี้พอดี: client ที่ subscribe เข้า channel หนึ่งส่ง JSON payload ได้ และผู้ subscribe คนอื่นทุกคนบน channel นั้นได้รับมันโดยตรง ไม่มีตาราง public ไม่มี publication และไม่มี WAL เกี่ยวข้องเลย

การส่งข้อความ broadcast จาก supabase-js:

src/rt-broadcast-basic.ts
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
const channel = supabase.channel('rt:room1');
channel.on('broadcast', { event: 'cursor-pos' }, (payload) => {
console.log('Cursor moved:', payload.payload);
});
channel.subscribe();
channel.send({
type: 'broadcast',
event: 'cursor-pos',
payload: { x: 120, y: 340 },
});

ผู้ subscribe ทุกคนที่ลงทะเบียน .on('broadcast', { event: 'cursor-pos' }, ...) บน rt:room1 จะได้รับ payload โดยปกติภายในไม่กี่มิลลิวินาที — ไม่มีการไป-กลับฐานข้อมูลบน hot path เลย

self กับ ack: สองตัวเลือกระดับ channel ที่ควรรู้ให้ชัด

หัวข้อที่มีชื่อว่า “self กับ ack: สองตัวเลือกระดับ channel ที่ควรรู้ให้ชัด”

โดย default ผู้ส่ง broadcast จะ ไม่ ได้รับข้อความของตัวเองกลับมา — self: true ใน config broadcast ของ channel เปลี่ยนพฤติกรรมนี้ได้ แยกกันไป send() โดย default จะ resolve ทันทีที่ข้อความออกจาก client โดยไม่รอ server ยืนยันการส่งถึง — ack: true ทำให้มันรอการไป-กลับจริง พิสูจน์ทั้งสองเรื่องจากการรันครั้งเดียวกับ local stack:

default (self:false) — own message received: false send() resolved: ok in 1 ms
self:true — own message received: true send() resolved: ok
ack:false send() resolve times (ms): [ '0.2', '0.1', '0.1', '0.1', '0.1' ]
ack:true send() resolve times (ms): [ '0.7', '0.6', '0.6', '0.6', '0.5' ]
ack:true average: 0.60 ms vs ack:false average: 0.09 ms

พิสูจน์สองเรื่องในการรันเดียว: ถ้าไม่มี self: true handler ของ broadcast ของ channel ตัวเองจะไม่ยิงสำหรับ send() ของตัวเองเลย — มีประโยชน์ เพราะ UI ส่วนใหญ่ (cursor, ตัวบอกว่ากำลังพิมพ์) มักจะ render การกระทำของตัวเองแบบ optimistic อยู่แล้ว และจะ render ซ้ำซ้อนถ้าได้รับ broadcast ของตัวเองด้วย พอเปิด self: true บน channel ที่สอง handler ตัวเดียวกันก็ยิงให้ผู้ส่งด้วย แยกกันไป ack: true ทำให้ promise ของ send() resolve ช้าลงอย่างวัดผลได้ (ประมาณ 6 เท่าที่นี่ บน localhost ที่ไม่มีอย่างอื่นทำงานพร้อมกัน) เพราะตอนนี้มันรอการยืนยันจาก server แทนที่จะ resolve ทันทีที่ payload ออกจาก client — ตัวเลือกที่ควรใช้เมื่อคุณต้องรู้ว่าข้อความถูกรับจริง ไม่ใช่แค่ถูกส่งเข้า socket

src/rt-broadcast-self-ack.ts
// @run
import { 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 c1 = createClient(API_URL, ANON_KEY);
let gotOwnDefault = false;
const ch1 = c1.channel('rt:self-check-default');
ch1.on('broadcast', { event: 'ping' }, () => { gotOwnDefault = true; });
await new Promise<void>((resolve) => ch1.subscribe((status) => { if (status === 'SUBSCRIBED') resolve(); }));
await ch1.send({ type: 'broadcast', event: 'ping', payload: { n: 1 } });
await new Promise((r) => setTimeout(r, 1500));
console.log('self:false — own message received:', gotOwnDefault);
await c1.removeChannel(ch1);
const c2 = createClient(API_URL, ANON_KEY);
let gotOwnSelf = false;
const ch2 = c2.channel('rt:self-check-true', { config: { broadcast: { self: true } } });
ch2.on('broadcast', { event: 'ping' }, () => { gotOwnSelf = true; });
await new Promise<void>((resolve) => ch2.subscribe((status) => { if (status === 'SUBSCRIBED') resolve(); }));
await ch2.send({ type: 'broadcast', event: 'ping', payload: { n: 2 } });
await new Promise((r) => setTimeout(r, 1500));
console.log('self:true — own message received:', gotOwnSelf);
await c2.removeChannel(ch2);
process.exit(!gotOwnDefault && gotOwnSelf ? 0 : 1);

Presence ต่อยอดจากกลไก channel เดียวกันเพื่อแก้ปัญหาอีกแบบ: รู้ว่า client ไหนกำลังเชื่อมต่อ channel อยู่ตอนนี้ และแต่ละคนแชร์สถานะอะไรบ้าง (ชื่อผู้ใช้ ตำแหน่ง cursor สถานะ “online” หรือ “typing”) client ประกาศ presence ของตัวเองด้วย track และ handler sync/join/leave ของผู้ subscribe คนอื่นจะยิงพร้อม payload จริง:

src/rt-presence.ts
// @run
import { 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 watcher = createClient(API_URL, ANON_KEY);
const events: unknown[] = [];
const watchChannel = watcher.channel('rt:presence-room');
watchChannel
.on('presence', { event: 'sync' }, () => events.push({ type: 'sync', state: watchChannel.presenceState() }))
.on('presence', { event: 'join' }, ({ key, newPresences }) => events.push({ type: 'join', key, newPresences }))
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => events.push({ type: 'leave', key, leftPresences }))
.subscribe();
await new Promise((r) => setTimeout(r, 1500));
const alice = createClient(API_URL, ANON_KEY);
const aliceChannel = alice.channel('rt:presence-room');
aliceChannel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') await aliceChannel.track({ user: 'alice', status: 'online' });
});
await new Promise((r) => setTimeout(r, 2500));
await alice.removeChannel(aliceChannel);
await new Promise((r) => setTimeout(r, 2000));
const types = events.map((e: any) => e.type).join(',');
console.log('event sequence:', types);
console.log('full log:', JSON.stringify(events));
await watcher.removeChannel(watchChannel);
process.exit(types === 'sync,join,sync,leave,sync' ? 0 : 1);
event sequence: sync,join,sync,leave,sync
full log: [{"type":"sync","state":{}},{"type":"join","key":"3d962bf0-...","newPresences":[{"status":"online","user":"alice","presence_ref":"GNmxCHBCOp3_atcB"}]},{"type":"sync","state":{"3d962bf0-...":[{"status":"online","user":"alice","presence_ref":"GNmxCHBCOp3_atcB"}]}},{"type":"leave","key":"3d962bf0-...","leftPresences":[{"status":"online","user":"alice","presence_ref":"GNmxCHBCOp3_atcB"}]},{"type":"leave","key":"3d962bf0-...","leftPresences":[{"status":"online","user":"alice","presence_ref":"GNmxCHBCOp3_atcB"}]}]

ลำดับจริง เฝ้าดูจาก client ตัวที่สองตลอดเวลา: sync ว่าง ๆ ยิงทันทีที่ watcher subscribe (ยังไม่มีใครอยู่เลยตอนนั้น) จากนั้น join ยิงทันทีที่ alice track สถานะของเธอ (พร้อม payload จริงที่เธอ track ไว้ใน newPresences โดย key เป็น presence key ที่ server กำหนดให้ — ไม่ใช่ user id ของเธอ) จากนั้น sync อีกครั้งสะท้อนสถานะเต็มใหม่ จากนั้น leave (พร้อม payload เดียวกันใน leftPresences) ทันทีที่ channel ของเธอถูกลบออก แล้วจบด้วย sync ว่าง ๆ อีกครั้ง presenceState() เก็บภาพเต็มปัจจุบันตลอดเวลา; sync/join/leave คือส่วนต่างที่เปลี่ยนไป

นี่คือความต่างที่ควรจำไว้หลังจากบทที่แล้ว: Postgres Changes ทำงานได้ก็ต่อเมื่อคุณรัน alter publication supabase_realtime add table ... สำหรับตารางนั้น เพราะโดยพื้นฐานแล้วมันคือการสตรีมออกจาก replication log ของ Postgres เอง Broadcast กับ Presence ไม่เกี่ยวอะไรกับเรื่องนั้นเลย — มันเป็นเลเยอร์ messaging และ state-sync ที่อยู่ในตัว Realtime server ทั้งหมด เปิดใช้งานทันทีที่คุณเชื่อมต่อ channel โดยไม่มีอะไรต้องเปิดในฐานข้อมูลเลย — เว้นแต่ คุณจะตั้งให้ channel เป็น private ซึ่งจะพูดถึงต่อไป

Channel หนึ่งตั้งเป็น private ได้ — channel(name, { config: { private: true } }) — ซึ่งจำกัดให้เฉพาะ client ที่ authenticated เท่านั้น และส่งการ authorize ไปผ่าน Row Level Security policy บนตาราง realtime.messages กลไก using/with check เดียวกับโมดูล RLS เพียงแต่ใช้กับ “ใครมีสิทธิ์เข้า channel” แทนที่จะเป็นแถวในตารางของคุณเอง realtime.messages มาพร้อม RLS เปิดอยู่แล้วแต่ไม่มี policy เลย ดังนั้น private channel จะเข้าไม่ได้เลยโดย default พิสูจน์ — ผู้ใช้จริงที่สมัครไว้ realtime.setAuth(token) (จำเป็นสำหรับ private channel; ต่างจาก postgres_changes ตรงที่ไม่อัตโนมัติ) subscribe เข้า private channel ที่ยังไม่มี policy เลย:

subscribe result with NO policy on realtime.messages: {"status":"CHANNEL_ERROR","message":"Unauthorized: You do not have permissions to read from this Channel topic: rt:private-room-1"}

CHANNEL_ERROR จริง พร้อม error สิทธิ์ที่ตรงเป๊ะ ตรงจาก log ของ container supabase_realtime_probe เองในช่วงเวลาเดียวกัน: error_code=Unauthorized ... [error] Unauthorized: You do not have permissions to read from this Channel topic: rt:private-room-1 เพิ่ม policy select (และ policy insert ที่จำเป็นสำหรับให้ client ส่งอะไรกลับบน topic เดียวกันได้) จำกัดเฉพาะ topic นี้ ใช้ realtime.topic() — ฟังก์ชันที่ Realtime เปิดให้ policy อ้างอิงชื่อ channel ที่กำลังถูก authorize:

-- supabase/migrations/6003_rt_broadcast_presence.sql
create policy "rt_private_room_read"
on realtime.messages
for select
to authenticated
using (
realtime.topic() = 'rt:private-room-1'
);
create policy "rt_private_room_write"
on realtime.messages
for insert
to authenticated
with check (
realtime.topic() = 'rt:private-room-1'
);

การ subscribe แบบเดียวกันเป๊ะ ผู้ใช้คนเดียวกัน ชื่อ channel เดียวกัน ตอนนี้สำเร็จ:

subscribe result with NO policy on realtime.messages: {"status":"SUBSCRIBED"}
src/rt-private-channel.ts
// @run
import { 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 client = createClient(API_URL, ANON_KEY);
const { data: sessionData, error } = await client.auth.signInWithPassword({
password: 'rt-pw-alice-12345',
});
if (error || !sessionData.session) throw error ?? new Error('no session');
client.realtime.setAuth(sessionData.session.access_token);
const channel = client.channel('rt:private-room-1', { config: { private: true } });
const result = await new Promise<{ status: string; message?: string }>((resolve) => {
channel.subscribe((status, err) => resolve({ status, message: err?.message }));
});
console.log('subscribe result with the policy in place:', JSON.stringify(result));
await client.removeChannel(channel);
process.exit(result.status === 'SUBSCRIBED' ? 0 : 1);

Postgres Changes ไม่ใช่วิธีเดียวที่เปลี่ยนการแก้ไขแถวให้กลายเป็น event — trigger เรียก realtime.broadcast_changes() เพื่อส่ง payload ที่มีรูปร่างแบบ Postgres-Changes ผ่าน Broadcast แทนได้ ซึ่งเป็นสิ่งที่เอกสารทางการของ Supabase แนะนำเมื่อคุณโตเกินกว่าการประเมิน RLS ต่อผู้ subscribe (บทที่แล้ว) มีข้อเท็จจริงจริงที่ไม่ชัดเจนนักที่ควรรู้ก่อนจะสร้างต่อยอด: realtime.broadcast_changes() เขียนข้อความของมันเป็น private = true เสมอ ดังนั้นมันต้องการการตั้งค่า RLS แบบ private-channel เป๊ะ ๆ ด้านบน — ไม่มีทางใช้เป็น public broadcast ได้เลย

-- supabase/migrations/6003_rt_broadcast_presence.sql
create table public.rt_posts (
id bigint generated always as identity primary key,
title text not null,
created_at timestamptz not null default now()
);
create or replace function public.rt_posts_broadcast()
returns trigger
security definer set search_path = ''
language plpgsql
as $$
begin
perform realtime.broadcast_changes(
'rt:posts-broadcast', -- topic
tg_op, -- event
tg_op, -- operation
tg_table_name, -- table
tg_table_schema, -- schema
new, -- new record
old -- old record
);
return coalesce(new, old);
end;
$$;
create trigger rt_posts_broadcast_trigger
after insert or update or delete on public.rt_posts
for each row execute function public.rt_posts_broadcast();
create policy "rt_posts_broadcast_read"
on realtime.messages
for select
to authenticated
using (
realtime.topic() = 'rt:posts-broadcast'
);

พิสูจน์: การ insert ด้วย service_role เข้า rt_posts ถูกรับโดยผู้ subscribe ที่ channel ของเขาลงทะเบียนแค่ .on('broadcast', ...) เท่านั้น — ไม่มี postgres_changes listener อยู่ในสคริปต์นี้เลย และตารางนี้ไม่ต้อง และไม่จำเป็นต้อง อยู่ใน publication supabase_realtime เลย:

src/rt-broadcast-from-db.ts
// @run
import { 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!);
const client = createClient(API_URL, ANON_KEY);
const { data: sessionData } = await client.auth.signInWithPassword({
password: 'rt-pw-alice-12345',
});
client.realtime.setAuth(sessionData.session!.access_token);
const received: any[] = [];
const channel = client.channel('rt:posts-broadcast', { config: { private: true } });
channel.on('broadcast', { event: 'INSERT' }, (payload) => received.push(payload));
await new Promise<void>((resolve) => channel.subscribe((status) => { if (status === 'SUBSCRIBED') resolve(); }));
await new Promise((r) => setTimeout(r, 3000));
const { data: post } = await admin.from('rt_posts').insert({ title: 'hello from the database' }).select().single();
console.log('inserted via service_role, id =', post.id, '— no postgres_changes listener exists in this script');
await new Promise((r) => setTimeout(r, 5000));
console.log('broadcast events received:', received.length);
console.log('payload.record:', JSON.stringify(received[0]?.payload?.record));
await client.removeChannel(channel);
process.exit(received.length === 1 ? 0 : 1);
inserted via service_role, id = 4 — no postgres_changes listener exists in this script
broadcast events received: 1
payload.record: {"id":4,"title":"hello from the database, private this time","created_at":"2026-09-29T04:45:42.706501+00:00"}

payload.payload ตรงนี้มี { id, table, schema, operation, record, old_record } — ตั้งใจให้มีรูปร่างเหมือน event ของ postgres_changes ดังนั้นการย้าย listener จากตัวหนึ่งไปอีกตัวส่วนใหญ่ก็แค่เปลี่ยนไปอ่าน payload.payload.record แทน payload.new การเช็ค authorization ที่รันเพื่อส่ง event นี้คือ select ครั้งเดียวกับ realtime.messages ตาม topic — ไม่ใช่การเช็คหนึ่งครั้งต่อผู้ subscribe กับ rt_posts เอง ซึ่งเป็นจุดที่ scale ได้ดีกว่า postgres_changes ตามที่อธิบายไว้ในบทที่แล้วทั้งหมด

realtime.broadcast_changes() สร้างขึ้นจากฟังก์ชันพื้นฐานกว่า คือ realtime.send(payload, event, topic, private) ซึ่ง broadcast JSON payload อะไรก็ได้โดยไม่ต้องมีรูปร่างแบบ row-change เลย — มีประโยชน์สำหรับการแจ้งเตือน ค่า aggregate ที่คำนวณมา หรืออะไรก็ตามที่ไม่ใช่ “แถวหนึ่งเปลี่ยนไป” โดยธรรมชาติ argument ตัวที่สี่ควบคุมความต่างของ private/public เหมือนกัน: false ไม่ต้องการ RLS policy เลย (ส่งถึงผู้ subscribe คนไหนก็ได้บน public topic นั้น) ส่วน true ต้องการการตั้งค่า policy บน realtime.messages แบบเดียวกับด้านบน พิสูจน์ด้วย false (public) ไม่มี session ไม่มี policy ไม่มี private: true config บน channel ของผู้ subscribe:

-- sql/rt_adhoc_send.sql
select realtime.send(
'{"note": "ad hoc message, no table involved"}'::jsonb,
'custom-event',
'rt:adhoc-send-2',
false
);
subscribed, waiting for realtime.send()...
RECEIVED: {"id":"a0e798ba-45c1-4b0f-b403-a461505f4b3c","note":"ad hoc message, no table involved"}

anon subscriber ธรรมดา บน channel ธรรมดา (ไม่ private) ได้รับ payload ทันทีที่คำสั่ง select realtime.send(...) รันจาก psql — ไม่มี trigger ไม่มีตาราง ไม่มี postgres_changes และ (เพราะ argument ตัวที่สี่เป็น false) ไม่ต้องมี RLS policy เลยด้วย

ทุกการพิสูจน์ด้านบนใช้ topic ที่ตั้ง prefix และจำกัดขอบเขตไว้อย่างตั้งใจ — rt:private-room-1, rt:posts-broadcast — แทนที่จะใช้ชื่อเดียวร่วมกันทั้งหมด เพราะทั้งการ authorize ของ Realtime (private channel) และการ subscribe postgres_changes แบบไม่มี filter ต่างก็ยึดจาก topic/channel string ที่ตรงเป๊ะ ธรรมเนียมของเอกสารทางการเองก็จำกัด topic ให้ตรงกับกลุ่มเป้าหมายที่ควรแชร์มันพอดี:

// This only broadcasts to users in a specific room
const channel = supabase.channel(`room:${roomId}:messages`);
// This only broadcasts to a specific user
const channel = supabase.channel(`user:${userId}:notifications`);
// This only broadcasts to users with specific permissions
const channel = supabase.channel(`admin:${orgId}:alerts`);

(อ้างอิงจาก supabase.com/docs, Realtime prompt examples) private channel กับ public channel ที่บังเอิญใช้ topic string เดียวกัน ถูก Realtime มองเป็นสอง channel ที่แยกจากกันโดยสิ้นเชิง — ข้อความที่ส่งบนอันหนึ่งไม่มีวันไปถึงผู้ subscribe ของอีกอันเลย แม้ชื่อจะเหมือนกันเป๊ะก็ตาม

flowchart LR
  subgraph channel["Realtime channel"]
    c1["Client A"]
    c2["Client B"]
  end
  c1 -->|"channel.send broadcast"| c2
  c1 -->|"channel.track presence"| sync["Synced presence state"]
  c2 -->|"channel.track presence"| sync
  trigger["Table trigger: realtime.broadcast_changes()"] -->|"always private=true"| messages[("realtime.messages")]
  send["realtime.send(payload, event, topic, private)"] --> messages
  messages --> rls{"private channel? check RLS select policy on realtime.messages"}
  rls -->|allowed or public channel| c1
  rls -->|denied| reject["CHANNEL_ERROR: Unauthorized"]
Private channel และ Broadcast from database ต่างก็ authorize ผ่าน RLS บน realtime.messages

AI assistant ถูกขอให้ “ทำให้ channel ของ admin dashboard เป็น private เพื่อไม่ให้คนแปลกหน้าแอบฟังได้” แล้วเขียนโค้ดนี้มา:

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

const channel = supabase.channel('admin:alerts', {
config: { private: true },
});
channel
.on('broadcast', { event: 'new-alert' }, (payload) => {
console.log('Alert:', payload.payload);
})
.subscribe((status) => {
console.log('status:', status);
});
เฉลย

การตั้ง private: true ใน config ของ channel จำเป็นก็จริง แต่ยังห่างไกลจากเพียงพอ — มันแค่เปิดการเช็ค authorization เท่านั้น ไม่ได้ให้สิทธิ์ใครเข้าถึงเลย realtime.messages มาพร้อม Row Level Security ที่เปิดอยู่แล้วแต่ไม่มี policy เลยโดย default ดังนั้นโค้ดนี้รันตามที่เขียนไว้ จะ subscribe แล้วได้ CHANNEL_ERROR ทันทีพร้อม “Unauthorized: You do not have permissions to read from this Channel topic: admin:alerts” — พิสูจน์แล้วก่อนหน้านี้ในบทนี้ด้วย error text เดียวกันเป๊ะ ก็ไม่มีการเรียก client.realtime.setAuth(token) ด้วย ซึ่ง private channel ต้องการอย่างชัดเจน (ต่างจาก postgres_changes) ทางแก้ต้องมีทั้งสองส่วน: client.realtime.setAuth(session.access_token) หลัง sign in และ policy select จริงบน realtime.messages ที่จำกัดเฉพาะ topic นี้ (เช่น using (realtime.topic() = 'admin:alerts' and (select is_admin from profiles where id = auth.uid()))) — “private” คือการตัดสินใจเรื่อง routing ไม่ใช่การตัดสินใจเรื่อง access control เอง

Broadcast กับ Presence อยู่ในตัว Realtime server ทั้งหมดในฐานะเลเยอร์ messaging ที่แยกออกจาก Postgres — จนกว่าคุณจะขอ private ซึ่งเมื่อนั้น Realtime จะส่งคำถามเรื่อง authorization กลับไปให้ Postgres แบบตรงไปตรงมาที่สุด: ทุกการ subscribe/send บน private topic กลายเป็น select/insert จริงกับตารางจริง realtime.messages ที่ถูกเช็คโดย RLS engine จริง ใช้ policy จริงที่คุณเขียนโดยมี realtime.topic() แทนความหมาย “ชื่อ channel ที่ connection นี้กำลังพยายามใช้” realtime.broadcast_changes() กับ realtime.send() คือสองประตูเข้าสู่กลไกเดียวกันเป๊ะจากฝั่ง ฐานข้อมูล — trigger หรือการเรียก SQL ดิบ ๆ insert เข้า realtime.messages แล้ว replication slot ของ Realtime เองบนตารางนั้น (อันที่สอง แยกจาก supabase_realtime เป็นภายใน) จะหยิบมันไปกระจายต่อ — นี่คือเหตุผลที่ broadcast_changes() hardcode private = true ไม่ใช่ข้อจำกัด แต่เป็นการที่ฟังก์ชันยืนกรานให้คุณผ่านประตู RLS เดียวกับที่ private-channel client ทุกตัวต้องผ่าน

  • Broadcast/Presence ไม่ต้องตั้งค่าอะไรเลยสำหรับกรณีทั่วไป (public) — เชื่อมต่อ channel แล้วเริ่มส่งได้เลย — แลกมาด้วยระบบ authorization แยกต่างหากที่ชัดเจน (RLS ของ realtime.messages) ทันทีที่ต้องการความเป็นส่วนตัว ซึ่งแยกทั้งจาก RLS ของตารางคุณเองและจากการเช็คอัตโนมัติต่อผู้ subscribe ของ postgres_changes
  • realtime.broadcast_changes() ให้ event รูปร่างแบบ Postgres-Changes โดยไม่มีต้นทุน authorization ต่อผู้ subscribe — แลกมาด้วยการเป็น private เสมอ หมายความว่าผู้บริโภคทุกคนต้องมี setAuth บวก RLS policy จริงก่อนจะสร้างได้ แม้จะเป็นข้อมูลที่ public ก็ไม่มีปัญหาอะไรก็ตาม
  • ack: true พิสูจน์ว่าข้อความถูกรับจริงโดย server — แลกมาด้วย latency การไป-กลับเพิ่มในทุกการส่ง วัดได้ด้านบนว่าช้ากว่า default แบบ fire-and-forget ประมาณ 6 เท่า ใช้เมื่อการสูญเสียข้อความแบบเงียบ ๆ จะเป็นปัญหาจริง ๆ เท่านั้น
  • “ตั้ง channel เป็น private ก็เท่ากับปลอดภัยแล้ว” พิสูจน์แล้วด้านบน: config: { private: true } เพียงอย่างเดียวทำให้เกิด CHANNEL_ERROR จริงทันทีสำหรับผู้ subscribe ทุกคน เพราะมันแค่เปิดการเช็ค RLS เท่านั้น — ไม่ได้ให้สิทธิ์อะไรเองจนกว่าจะมี policy จริง
  • “postgres_changes กับการ authorize ของ private channel ใช้ auth wiring เดียวกัน” ไม่ใช่: postgres_changes รับ session ที่ sign in แล้วมาใช้อัตโนมัติ; private channel ต้องเรียก client.realtime.setAuth(token) เองอย่างชัดเจน พิสูจน์แล้วด้านบนด้วยผู้ใช้ที่ sign in คนเดียวกันเป๊ะที่ล้มเหลวเมื่อไม่ได้ตั้งค่าบน client instance ที่ถูกต้อง และสำเร็จเมื่อตั้งค่าแล้ว
  • “realtime.broadcast_changes() ใช้กับ feed แบบ public เพื่อประหยัดต้นทุน RLS ต่อผู้ subscribe ได้” ไม่ได้ — พิสูจน์แล้วด้านบน ทุกข้อความที่มันเขียนมี private = true ไม่ว่า security context ของ trigger เองจะเป็นอย่างไร ดังนั้นมันต้องการ policy ของ realtime.messages เสมอ ข้อโต้แย้งเรื่อง “ประหยัด RLS” มีผลแค่เทียบกับการเช็คต่อผู้ subscribe ของ postgres_changes เท่านั้น ไม่ใช่ “ไม่ต้อง auth เลย”
  • “Presence ติดตามสิ่งเดียวกับตาราง online-users ในฐานข้อมูลจะทำ” มันไม่ได้อิงตารางเลย — presenceState() คือมุมมองแบบ in-memory ที่ Realtime sync เองว่าใครกำลังเชื่อมต่ออยู่และล่าสุด track() อะไรไว้ หายไปทันทีที่ socket ตัดการเชื่อมต่อ (พิสูจน์แล้วด้านบนเป็น event leave อัตโนมัติ) ไม่ใช่ record ที่ query ได้แบบถาวร
Broadcast กับ Presence ต้องเพิ่มตารางเข้า publication supabase_realtime เหมือน Postgres Changes หรือไม่
self: true เปลี่ยนอะไรเกี่ยวกับการเรียก channel.send() ของ channel เอง
channel ถูกสร้างด้วย config: { private: true } แต่ยังไม่มี policy บน realtime.messages เลย เกิดอะไรขึ้นตอน subscribe
อะไรที่โดดเด่นเกี่ยวกับทุกข้อความที่ realtime.broadcast_changes() เขียนเข้า realtime.messages
ความต่างเชิงโครงสร้างที่สำคัญระหว่างวิธี scale ของ postgres_changes กับการ authorize ของ private channel คืออะไร
เกิดอะไรขึ้นเมื่อ events_per_second ของ channel หนึ่งเกินขีดจำกัด ตามเอกสารทางการ