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

Combining REST, Realtime, and Storage

feature จริงมักไม่แตะแค่ service เดียวของ Supabase — อัปโหลดรูป บันทึก row ที่อ้างถึงมัน แล้วให้ client อื่นเห็นมันโผล่ขึ้นมาแบบสด เป็น flow เดียวที่ผสม Storage, REST API ที่ป้องกันด้วย RLS และ Realtime ผ่าน client authenticated ตัวเดียว และบทนี้ พิสูจน์ ไม่ใช่แค่กล่าวอ้าง ว่าทั้งสามตัวแชร์ token เดียวกันและ row level security scope เดียวกันจริง ๆ

feed cli_posts: แต่ละ row เป็นของ user ที่สร้างมัน สามารถพก image path ได้ และ client อื่นที่ follow feed ควรเห็น post ใหม่ทันทีที่มันถูก insert

-- supabase/migrations/20260929000002_cli_posts.sql
create table public.cli_posts (
id uuid primary key default gen_random_uuid(),
owner uuid not null default auth.uid() references auth.users(id),
caption text not null,
image_path text,
created_at timestamptz not null default now()
);
alter table public.cli_posts enable row level security;
create policy "cli_posts_select_own" on public.cli_posts for select to authenticated using (owner = auth.uid());
create policy "cli_posts_insert_own" on public.cli_posts for insert to authenticated with check (owner = auth.uid());
alter publication supabase_realtime add table public.cli_posts;
insert into storage.buckets (id, name, public) values ('cli-posts-images', 'cli-posts-images', false)
on conflict (id) do nothing;
create policy "cli_posts_images_insert_own" on storage.objects for insert to authenticated with check (
bucket_id = 'cli-posts-images' and (storage.foldername(name))[1] = auth.uid()::text
);
create policy "cli_posts_images_select_own" on storage.objects for select to authenticated using (
bucket_id = 'cli-posts-images' and (storage.foldername(name))[1] = auth.uid()::text
);
create policy "cli_posts_images_update_own" on storage.objects for update to authenticated using (
bucket_id = 'cli-posts-images' and (storage.foldername(name))[1] = auth.uid()::text
);

Storage policy ทั้งสามตัว (insert, select, update) มีความหมายกับ call upload ด้านล่างทั้งหมด ไม่ใช่แค่ insert — บท storage-buckets-and-policies ของโมดูล Realtime and Storage พิสูจน์ไว้แล้วอย่างละเอียดว่าทำไม: upload(..., { upsert: true }) compile ออกมาเป็น INSERT ... ON CONFLICT (name, bucket_id) DO UPDATE ... RETURNING * ดังนั้นการ re-upload ไปที่ path เดิมที่ script นี้เคยใช้ในรอบก่อนหน้าต้องการ select (เพื่อ evaluate conflict) และ update (เพื่อทำมัน) นอกเหนือจาก insert — ลิงก์ ไม่ re-proof เพราะบทนั้นมี trace จริงของ 403/AccessDenied สำหรับกรณี policy ขาดอยู่แล้ว select policy ของ cli_posts ถูก scope ไว้อย่างตั้งใจเป็น owner = auth.uid() ไม่ใช่ using (true) — feed ส่วนตัว ไม่ใช่ public

Step 1 + 2: อัปโหลดรูป แล้วบันทึก row — พิสูจน์ว่าเป็น token ตัวเดียวกัน

หัวข้อที่มีชื่อว่า “Step 1 + 2: อัปโหลดรูป แล้วบันทึก row — พิสูจน์ว่าเป็น token ตัวเดียวกัน”

ทั้งสอง call ผ่าน aliceClient instance supabase-js ที่ authenticated ตัวเดียว — ไม่มีการ juggle header เองระหว่างสองตัวเลย:

src/cli-posts-same-token.ts
// @run
import { createClient } from '@supabase/supabase-js';
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 email = '[email protected]';
const password = 'cli-pw-alice-12345';
let alice = (await admin.auth.admin.listUsers()).data.users.find((u) => u.email === email);
if (!alice) alice = (await admin.auth.admin.createUser({ email, password, email_confirm: true })).data.user!;
await admin.from('cli_posts').delete().eq('owner', alice.id); // idempotent re-run
let restAuth: string | undefined;
let storageAuth: string | undefined;
const aliceClient = createClient(API_URL, ANON_KEY, {
global: {
fetch: (input, init) => {
const url = typeof input === 'string' ? input : (input as Request).url;
const auth = new Headers(init?.headers).get('authorization') ?? undefined;
if (url.includes('/rest/v1/')) restAuth = auth;
if (url.includes('/storage/v1/')) storageAuth = auth;
return fetch(input, init);
},
},
});
await aliceClient.auth.signInWithPassword({ email, password });
// Step 1: Storage upload
const imagePath = `${alice.id}/cli-post-image.txt`;
const { error: uploadError } = await aliceClient.storage
.from('cli-posts-images')
.upload(imagePath, new Blob(['fake-image-bytes']), { upsert: true });
console.log('upload error =', uploadError?.message ?? null);
// Step 2: REST insert referencing it, protected by the owner = auth.uid() policy above
const { data: post, error: insertError } = await aliceClient
.from('cli_posts')
.insert({ caption: 'alice real post', image_path: imagePath })
.select()
.single();
console.log('insert error =', insertError?.message ?? null);
console.log('REST Authorization:', restAuth?.slice(0, 24) + '...');
console.log('Storage Authorization:', storageAuth?.slice(0, 24) + '...');
console.log('same token on both requests =', restAuth === storageAuth);
upload error = null
insert error = null
REST Authorization: Bearer eyJhbGciOiJFUzI1N...
Storage Authorization: Bearer eyJhbGciOiJFUzI1N...
same token on both requests = true

ไม่ใช่ “น่าจะเหมือนกัน” — พิสูจน์ว่าเหมือนกันจริง ด้วยการ intercept Authorization header จริงที่ออกไปทั้งบน request /rest/v1/ และ /storage/v1/ จาก client instance เดียวกันเป๊ะ ถ้า uploadError มีค่า — network สะดุด หรือ Storage policy ปฏิเสธการอัปโหลด — REST insert ใน step 2 ไม่มีอะไรเช็คเรื่องนั้นนอกจาก error ของตัวเอง ข้ามการเช็ค error ของ upload แปลว่า insert row ที่ image_path ชี้ไปที่ไฟล์ที่ไม่เคยถูกบันทึกจริงได้อย่างเงียบ ๆ failure mode เดียวกับที่วินัย error.code ของบทที่แล้วมีไว้เพื่อป้องกัน

Step 3: Realtime บังคับ RLS scope เดียวกันเป๊ะ — พิสูจน์ด้วย user สองคน

หัวข้อที่มีชื่อว่า “Step 3: Realtime บังคับ RLS scope เดียวกันเป๊ะ — พิสูจน์ด้วย user สองคน”

aliceClient subscribe postgres_changes บน cli_posts สองครั้ง: ครั้งแรกในฐานะ anonymous (ยังไม่ sign-in) ครั้งที่สองตอน signed in — ขณะที่ bobClient user อิสระที่สองที่ authenticated เต็มตัว insert post ของตัวเองคั่นกลาง:

src/cli-posts-realtime-rls.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!);
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!;
return (await admin.auth.admin.listUsers()).data.users.find((u) => u.email === email)!;
}
const alice = await ensureUser('[email protected]', 'cli-pw-alice-12345');
const bob = await ensureUser('[email protected]', 'cli-pw-bob-12345');
await admin.from('cli_posts').delete().eq('owner', alice.id);
await admin.from('cli_posts').delete().eq('owner', bob.id);
const aliceClient = createClient(API_URL, ANON_KEY);
const bobClient = createClient(API_URL, ANON_KEY);
await bobClient.auth.signInWithPassword({ email: '[email protected]', password: 'cli-pw-bob-12345' });
// alice's channel is created BEFORE she signs in
const eventsBeforeSignIn: string[] = [];
const preChannel = aliceClient
.channel('cli-posts-feed-pre')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'cli_posts' }, (p) => eventsBeforeSignIn.push(p.new.caption))
.subscribe();
await new Promise((r) => setTimeout(r, 800));
await bobClient.from('cli_posts').insert({ caption: 'bob post while alice anon' });
await new Promise((r) => setTimeout(r, 800));
console.log('events seen by alice, still anonymous:', eventsBeforeSignIn.length);
await aliceClient.removeChannel(preChannel);
// alice signs in on the SAME client, then re-subscribes
await aliceClient.auth.signInWithPassword({ email: '[email protected]', password: 'cli-pw-alice-12345' });
const eventsAfterSignIn: string[] = [];
const postChannel = aliceClient
.channel('cli-posts-feed-post')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'cli_posts' }, (p) => eventsAfterSignIn.push(p.new.caption))
.subscribe();
await new Promise((r) => setTimeout(r, 1000));
await aliceClient.from('cli_posts').insert({ caption: 'alice own post' });
await bobClient.from('cli_posts').insert({ caption: 'bob post while alice signed in' });
await new Promise((r) => setTimeout(r, 1000));
console.log('events alice sees once signed in:', JSON.stringify(eventsAfterSignIn));
await aliceClient.removeChannel(postChannel);
await admin.from('cli_posts').delete().eq('owner', alice.id);
await admin.from('cli_posts').delete().eq('owner', bob.id);
process.exit(0); // Realtime's underlying socket otherwise keeps the process alive past removeChannel()
events seen by alice, still anonymous: 0
events alice sees once signed in: ["alice own post"]

สองการพิสูจน์จริงในการรันเดียว: ขณะเป็น anonymous subscription ของ alice ได้รับ event ศูนย์ ตัวสำหรับ insert ของ Bob — session แบบ anonymous ไม่ตรงกับ row ไหนใน owner = auth.uid() เลย ดังนั้น RLS จึงบล็อกการส่งมอบเหมือนกับที่จะบล็อก select เป๊ะ พอ signed in แล้ว subscription ของ alice เห็น insert ของตัวเอง แต่ไม่เห็น insert ที่สองของ Bob แม้ทั้งสองเกิดขึ้นขณะ channel ของเธอยังทำงานอยู่ และทั้งคู่เป็น INSERT จริงบน table เดียวกัน — RLS policy scope ด้วย owner ตัวเดียวกัน ถูก evaluate ต่อ subscriber นี่เป็นไปได้เพราะ SupabaseClient ของ supabase-js — อ่านตรงจาก source ของ @supabase/[email protected] ที่ติดตั้งจริง — subscribe onAuthStateChange ของตัวเองแล้ว re-push token ปัจจุบันเข้า Realtime อัตโนมัติ:

# tools/probe/node_modules/@supabase/supabase-js/dist/index.cjs (installed 2.117.2, read directly)
if ((event === "TOKEN_REFRESHED" || event === "SIGNED_IN" || event === "INITIAL_SESSION") && this.changedAccessToken !== token) {
this.changedAccessToken = token;
this.realtime.setAuth(token);
}

นี่คือ behavior แบบอัตโนมัติปัจจุบัน — token ของ Realtime ที่ stale หลัง sign-in หรือ token refresh ที่ต้องเรียก supabase.realtime.setAuth() เองเป็น pattern แบบ เก่ากว่า; supabase-js ปัจจุบัน wire สิ่งนี้ไว้ในตัวเอง ทุกครั้งที่ SIGNED_IN/TOKEN_REFRESHED/INITIAL_SESSION (ลำดับที่พิสูจน์แล้วของ onAuthStateChange จากบทที่แล้ว) realtime.setAuth(token) ด้วยมือยังจำเป็นอยู่เฉพาะ JWT แบบ custom ที่ไม่ใช่ session จริง ๆ — pattern ที่ docs ของโมดูล Realtime เองโชว์สำหรับ token ที่ออกด้วยมือ ไม่ใช่กรณี user signed-in ปกติที่บทนี้พิสูจน์

ทุก channel.subscribe() ข้างบนถูกจับคู่กับ client.removeChannel(channel) เมื่อไม่มีการ watch feed อีกแล้ว — proof script ทั้งสองข้างบนเรียกมันตรง ๆ และรันสะอาดทั้งคู่ channel ที่ไม่ถูก remove จะเก็บ WebSocket subscription (และ closure ที่มันจับไว้) ให้อยู่ต่อตลอดอายุของ client ซึ่งใน single-page app ที่อายุยืนเป็น leak จริงที่สะสมขึ้นทุกครั้งที่ user navigate ออกจากแล้วกลับมาที่ feed view โดยไม่เรียกมัน

flow ที่ผสมกันแบบนี้มี network round trip เยอะพอ (upload, แล้ว insert, แล้วรอ Realtime echo) จนการรอทั้งสามให้เสร็จก่อนแสดงอะไรเลยรู้สึกช้า pattern ทั่วไปคือ render post ใหม่ทันทีจากข้อมูล local ของ client เอง แล้ว reconcile เมื่อ row จริง (พร้อม id และ created_at ที่ server กำหนด) กลับมา:

async function createPost(caption: string, file: File) {
const tempId = crypto.randomUUID();
const optimisticPost = { id: tempId, caption, created_at: new Date().toISOString(), pending: true };
addPostToFeed(optimisticPost); // shows immediately, before any network call resolves
const { data: uploaded, error: uploadErr } = await supabase.storage.from('cli-posts-images').upload(`${userId}/${tempId}`, file);
if (uploadErr) return replacePostInFeed(tempId, { ...optimisticPost, pending: false, failed: true });
const { data: post, error: insertErr } = await supabase
.from('cli_posts')
.insert({ caption, image_path: uploaded.path })
.select()
.single();
if (insertErr) return replacePostInFeed(tempId, { ...optimisticPost, pending: false, failed: true });
replacePostInFeed(tempId, { ...post, pending: false }); // swap the temp row for the real one
}

flag pending/failed มีไว้เพื่อให้ UI แสดง spinner หรือปุ่ม retry บน card นั้นเฉพาะเจาะจง โดยไม่บล็อก feed ที่เหลือ และ tempId คือสิ่งที่ทำให้ replacePostInFeed หา entry ที่ optimistic ตัวที่ถูกต้องมา reconcile เพราะ id ของ row จริง (สร้างโดย gen_random_uuid() ฝั่ง server) ยังไม่รู้จนกว่า insert จะ return จริง

feed แทบไม่เคยเป็น “ทุก row สด” — มักเป็น query แบบแบ่งหน้า .range() (หน้า 1 ล่าสุดก่อน) พร้อม Realtime subscription ซ้อนทับสำหรับของใหม่ pitfall คือ: on('postgres_changes', { event: 'INSERT' }, (payload) => setPosts((prev) => [payload.new, ...prev])) แบบไร้เดียงสา prepend ทุก insert ที่มันเห็น รวมถึงตัวที่ optimistic update ของคุณเองเพิ่มไปแล้ว (ซ้ำ พอ row จริงมาถึง) และในมุมมองที่แบ่งหน้าด้วย .range() ที่ไม่ได้เรียงตามลำดับการ insert ล้วน ๆ ตัวที่ไม่ควรอยู่บนสุดของ list ที่ user กำลังดูอยู่จริง ๆ ด้วย สอง guard จัดการทั้งสองกรณี: dedupe ด้วย id ก่อน prepend (if (prev.some((p) => p.id === payload.new.id)) return prev;) — ซึ่งก็คือสิ่งที่ reconcile optimistic post กับ Realtime echo ของมันเองพอดี ถ้า echo มาถึงก่อน response ของ insert เอง — และ prepend เฉพาะตอนที่ subscriber กำลังดูหน้าแรกใน sort order ปัจจุบันของ feed จริง ๆ เท่านั้น โดย queue “มี post ใหม่” affordance แทนที่จะ reorder เนื้อหาเงียบ ๆ ใต้ user ที่ scroll ลงไปไกลแล้ว

flowchart LR
  upload["storage.from('cli-posts-images').upload(file)"] --> insert["from('cli_posts').insert({ image_path, caption })"]
  insert -->|"same Authorization header — proved"| commit[("Row committed, owner = auth.uid()")]
  commit -->|"replicated because cli_posts is in supabase_realtime"| notify["postgres_changes INSERT event"]
  notify -->|"gated by owner = auth.uid() — proved: 0 events while anonymous"| other["Subscriber's callback fires only for their own rows"]
Feature เดียว สาม service token เดียวที่พิสูจน์แล้ว RLS scope เดียวที่พิสูจน์แล้ว

AI assistant ที่ถูกขอให้ “โชว์ post ใหม่แบบสดที่บนสุดของ feed” สร้างโค้ดนี้:

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

function usePostsFeed() {
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
supabase.from('cli_posts').select().order('created_at', { ascending: false }).range(0, 19).then(({ data }) => {
setPosts(data ?? []);
});
const channel = supabase
.channel('cli-posts-feed')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'cli_posts' }, (payload) => {
setPosts((prev) => [payload.new as Post, ...prev]);
})
.subscribe();
}, []);
return posts;
}
เฉลย

สอง bug จริง ทั้งคู่พิสูจน์ไว้แล้วที่อื่นในคอร์สนี้ ข้อแรก channel ถูกสร้างในตัว effect แต่ supabase.removeChannel(channel) ไม่เคยถูกเรียกใน cleanup function เลย — ทุกครั้งที่ hook นี้ remount (เปลี่ยน route แล้วกลับมา, parent re-render ที่ reset state) ทิ้ง WebSocket listener ของ subscription ก่อนหน้าไว้ให้ทำงานตลอดกาล สะสมทุกครั้งที่ remount ตรงกับ leak ที่บทนี้เอ่ยชื่อไว้เป๊ะ ข้อสอง callback ของ Realtime prepend payload.new แบบไม่มีเงื่อนไขเลย ไม่มีการ de-duplication — ถ้าแอปเดียวกันนี้ทำ optimistic insert ด้วย (pattern ของบทนี้เอง) row จริงจะมาถึงสองครั้ง: ครั้งหนึ่งจาก optimistic update ครั้งหนึ่งจาก Realtime callback นี้ สร้าง card ซ้ำที่เห็นได้ทันทีที่ network ตามทัน ทางแก้ของทั้งสองข้อ: useEffect(() => { const channel = supabase.channel(...).on(...).subscribe(); return () => { supabase.removeChannel(channel); }; }, []) สำหรับ leak และ setPosts((prev) => prev.some((p) => p.id === payload.new.id) ? prev : [payload.new as Post, ...prev]) สำหรับตัวซ้ำ — ทั้งคู่เป็นการเปลี่ยนแค่บรรทัดเดียวบนโค้ดที่ดูสมเหตุสมผลสมบูรณ์

ไม่มีอะไรในบทนี้เป็นการ integrate พิเศษระหว่าง Storage, REST และ Realtime เลย — แต่ละ service ทำสิ่งที่มันทำในโมดูลของตัวเองเป๊ะ และทุกการพิสูจน์ในนี้คือข้อเท็จจริงเดียวกันที่มองจากสาม service ต่างกัน: JWT ตัวเดียว แนบวิธีเดียวกัน เช็ควิธีเดียวกัน ทุกที่ REST กับ Storage ทั้งคู่อ่านมันจาก Authorization header เดียวกัน (พิสูจน์แล้วว่าเหมือนกันเป๊ะ byte ต่อ byte); Realtime อ่าน header ต่อ message บน WebSocket ที่คงอยู่ตลอดไม่ได้ ดังนั้น supabase-js จึง push token เดียวกันเข้าไปตรง ๆ ทุกครั้งที่ token เปลี่ยน (พิสูจน์ผ่าน internal listener จริง) และ row level security ก็ไม่รู้และไม่สนใจว่า service ไหนถามมา — owner = auth.uid() ถูก evaluate เหมือนกันเป๊ะไม่ว่า request จะมาในรูป select, insert หรือการเช็ค subscription postgres_changes เพราะทั้งสามท้ายที่สุดถาม role Postgres เดียวกันคำถามเดียวกัน พอเห็นว่า “token เดียว, RLS check เดียว, สาม service ที่บังเอิญใช้มัน” แล้ว ที่เหลือของบทนี้ — optimistic UI, cleanup, pagination merge — ก็เป็น client-side state management ธรรมดา ไม่ใช่อะไรเฉพาะของ Supabase เลย

  • Client authenticated ตัวเดียวขับเคลื่อนทั้งสาม service แปลว่าไม่ต้องส่ง token เองด้วยมือระหว่างพวกมันเลย — แลกกับ Realtime ที่ต้องการการ push ภายในตรง ๆ (พิสูจน์ข้างบน) แทนที่จะอ่าน header สดต่อ message แบบที่ REST กับ Storage ทำ เพราะ WebSocket ไม่มี header ต่อ message ให้อ่าน
  • RLS ที่บังคับเหมือนกันข้าม REST และ Realtime แปลว่า policy เดียวเป็น single source of truth ว่าใครเห็นอะไร — แลกกับ owner-scoped postgres_changes subscription (พิสูจน์ข้างบน) ที่ทำงาน authorization จริงต่อ row ทุกครั้งที่มีการเปลี่ยนแปลง ไม่ใช่แค่เช็คครั้งเดียวตอน subscribe
  • Optimistic UI ทำให้ feature รู้สึกเร็วทันที — แลกกับต้องมี reconciliation strategy (การสลับ tempId ของบทนี้ และ dedupe-by-id ของ section pagination) สำหรับตอนที่การเดา optimistic กับ state จริงของ server ไม่ตรงกัน รวมถึงกรณีปกติที่ตรงกันแต่มาถึงสองครั้ง
  • Feed ที่แบ่งหน้าด้วย .range() ทำให้ initial load เร็วและมีขอบเขต — แลกกับ Realtime insert แบบสดที่ไม่รู้โดยธรรมชาติว่ามันควรอยู่บนหน้าที่ viewer กำลังดูอยู่ไหม ช่องว่างที่ guard “prepend เฉพาะหน้าแรก” ของ section pagination-merge มีไว้ปิด
  • “Storage, REST และ Realtime ต้อง authenticate แยกกัน” พิสูจน์แล้วว่าผิด: signInWithPassword() ครั้งเดียวบน client เดียว แล้ว bearer token เดียวกันเป๊ะไปถึงทั้ง request /rest/v1/ และ /storage/v1/ โดยไม่ต้อง wire อะไรเองเลย
  • “Realtime ไม่ได้บังคับ RLS จริง ๆ เหมือน REST — มันแค่ feed สดเฉย ๆ” พิสูจน์แล้วว่าผิดด้วย user จริงสองคน: subscriber แบบ anonymous ได้รับ postgres_changes event ศูนย์ตัวภายใต้ policy ที่ scope ด้วย owner และ subscriber ที่ signed in ไม่เคยเห็น insert ของ user อื่นเลย ตรงกับที่ select ภายใต้ policy เดียวกันจะคืนให้
  • “ต้องเรียก realtime.setAuth() เองเสมอหลัง sign-in ถึงจะให้ Realtime ทำงาน” จริงใน SDK version เก่ากว่า แต่พิสูจน์แล้วว่าไม่จริงสำหรับ 2.117.2 ที่ติดตั้งของคอร์สนี้: SupabaseClient re-push token อัตโนมัติตอน SIGNED_IN/TOKEN_REFRESHED/INITIAL_SESSION ยืนยันด้วยการอ่าน source ที่ติดตั้งจริง — setAuth() ด้วยมือยังเหลือไว้สำหรับ JWT แบบ custom จริง ๆ ไม่ใช่กรณีปกติ
  • “Prepend ทุก Realtime INSERT event เข้า list เป็นวิธีที่ปลอดภัยในการทำ feed สด” พิสูจน์แล้วว่าผิดจาก SpotTheBug ของบทนี้และ section pagination-merge: ถ้าไม่มีเช็ค de-duplication optimistic update กับ echo ของ Realtime เองสร้างตัวซ้ำ และถ้าไม่มีเช็คว่า viewer อยู่หน้าไหน insert สามารถถูก prepend เข้าไปในหน้าที่ viewer scroll ผ่านไปแล้วได้
ใน feature cli_posts การ intercept Authorization header จริงที่ออกไปทั้งบน Storage upload และ REST insert พิสูจน์อะไรจริง ๆ
ด้วย postgres_changes subscription บน cli_posts (select policy: owner = auth.uid()) การพิสูจน์ anonymous-vs-signed-in โชว์อะไร
ทำไม Realtime subscription ของ user ที่ signed in ยังทำงานถูกต้องหลัง token refresh โดยไม่ต้องเรียก realtime.setAuth() เองใน supabase-js version ที่ติดตั้งของคอร์สนี้
hook usePostsFeed ใน SpotTheBug พลาดอะไรเกี่ยวกับ lifecycle ของ Realtime subscription
feed หนึ่ง merge postgres_changes INSERT event แบบสดเข้า list ที่แบ่งหน้าด้วย .range() โดยไม่มี de-duplication เลย เกิดปัญหาจริงอะไรเมื่อแอปทำ optimistic UI update ด้วย