Error Handling and Typed Queries
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”ทุก call ของ supabase-js resolve เป็น { data, error } ไม่ throw ดังนั้น pattern มาตรฐานคือเช็ค error ก่อน — พอจัดการแล้ว TypeScript จะ narrow data ให้เอง และ Database type ที่ generate ไว้อธิบาย schema แค่ ณ ตอนที่ generate เท่านั้น ซึ่งบทนี้พิสูจน์ว่ามัน drift หลุด sync จาก database จริงได้เงียบ ๆ
เช็ค error ก่อนเชื่อ data
หัวข้อที่มีชื่อว่า “เช็ค error ก่อนเชื่อ data”ไม่มี call ไหนของ supabase-js throw ตอน request fail เลย มัน resolve ปกติเสมอ แยกผลลัพธ์เป็น data กับ error:
const { data, error } = await supabase .from('profiles') .select('id, username') .eq('id', userId) .single();
if (error) { console.error(error.message);} else { // TypeScript now knows data is not null here console.log(data.username);}เพราะไม่มีอะไร throw request ที่ fail ที่ลืมเช็คก็แค่ทำให้ data เป็น null เฉย ๆ — ไม่มี stack trace บังคับให้สังเกต การเช็ค error ก่อนไม่ใช่แค่ good practice แต่เป็นวิธีเดียวที่บอก success กับ failure ออกจากกันได้แน่นอน และยังเป็นสิ่งที่ทำให้ TypeScript narrow type ของ data ได้ด้วย
supabase gen types typescript --local ผลลัพธ์จริง
หัวข้อที่มีชื่อว่า “supabase gen types typescript --local ผลลัพธ์จริง”จาก schema local จริงของคอร์สนี้ ตัดให้เหลือแค่สอง table ที่บทนี้ใช้:
supabase gen types typescript --workdir tools/probe --local cli_widgets: { Row: { created_at: string id: string owner: string price: number title: string } Insert: { created_at?: string id?: string owner?: string price: number title: string } Update: { created_at?: string id?: string owner?: string price?: number title?: string } Relationships: [] } cli_widget_notes: { Row: { id: string note: string widget_id: string } ... Relationships: [ { foreignKeyName: "cli_widget_notes_widget_id_fkey" columns: ["widget_id"] referencedRelation: "cli_widgets" referencedColumns: ["id"] } ] }Generate จาก schema จริงเบื้องหลังบทนี้: create table public.cli_widgets (id uuid primary key default gen_random_uuid(), owner uuid not null default auth.uid() references auth.users(id), title text not null unique, price integer not null, created_at timestamptz not null default now()) บวก table ลูก cli_widget_notes ที่มี foreign key ย้อนกลับไป — array Relationships คือวิธีที่ generator บันทึก foreign key นั้น และมันคือสิ่งที่ทำให้ query embedded-resource ด้านล่าง type-safe
-- supabase/migrations/20260929000000_cli_widgets.sqlcreate table public.cli_widgets ( id uuid primary key default gen_random_uuid(), owner uuid not null default auth.uid() references auth.users(id), title text not null unique, price integer not null, created_at timestamptz not null default now());alter table public.cli_widgets enable row level security;create policy "cli_widgets_select_all" on public.cli_widgets for select to anon, authenticated using (true);create policy "cli_widgets_insert_own" on public.cli_widgets for insert to authenticated with check (owner = auth.uid());
create table public.cli_widget_notes ( id uuid primary key default gen_random_uuid(), widget_id uuid not null references public.cli_widgets(id) on delete cascade, note text not null);alter table public.cli_widget_notes enable row level security;create policy "cli_widget_notes_select_all" on public.cli_widget_notes for select to anon, authenticated using (true);create policy "cli_widget_notes_insert_authenticated" on public.cli_widget_notes for insert to authenticated with check (true);Query embedded-resource แบบ typed
หัวข้อที่มีชื่อว่า “Query embedded-resource แบบ typed”createClient<Database>() (บทที่แล้ว) เช็คทุก call .from() กับ Database type ที่ generate ไว้ — รวมถึง string select() ที่ embed table ที่เกี่ยวข้องด้วยชื่อ ซึ่ง PostgREST resolve ผ่าน foreign key ข้างบน แล้ว query builder ก็ type มันเป็น nested array ให้อัตโนมัติ:
// @runimport { 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 password = 'cli-pw-widgets-12345';let user = (await admin.auth.admin.listUsers()).data.users.find((u) => u.email === email);if (!user) user = (await admin.auth.admin.createUser({ email, password, email_confirm: true })).data.user!;
const client = createClient(API_URL, ANON_KEY);await client.auth.signInWithPassword({ email, password });await admin.from('cli_widgets').delete().eq('title', 'cli-widget-alpha'); // idempotent re-run
const { data: widget } = await client.from('cli_widgets').insert({ title: 'cli-widget-alpha', price_usd: 100 }).select().single();await client.from('cli_widget_notes').insert({ widget_id: widget!.id, note: 'first note' });await client.from('cli_widget_notes').insert({ widget_id: widget!.id, note: 'second note' });
const { data: embedded, error } = await client .from('cli_widgets') .select('title, price_usd, cli_widget_notes(note)') // Tables<'cli_widget_notes'>[] inferred via the foreign key .eq('id', widget!.id) .single();console.log('embedded select error =', error?.code ?? null);console.log('embedded select data =', JSON.stringify(embedded));
await admin.from('cli_widgets').delete().eq('title', 'cli-widget-alpha');embedded select error = nullembedded select data = {"title":"cli-widget-alpha","price_usd":100,"cli_widget_notes":[{"note":"first note"},{"note":"second note"}]}หมายเหตุเรื่องลำดับ: migration ของบทนี้เองรันตามลำดับในเอกสาร ดังนั้นตอนที่ script // @run ตัวไหนของบทนี้รันจริง การเปลี่ยนชื่อ column ใน section ถัดไปเกิดขึ้นจริงไปแล้ว — ทุกการพิสูจน์ที่รันได้จากตรงนี้ไปเล็งไปที่ชื่อ column จริงบน live database คือ price_usd section ถัดไป (“stale type”) ตั้งใจใช้ชื่อ price เดิมใน type ที่เขียนเองต่อไป เป็นตัวแทนของไฟล์ database.types.ts ที่ไม่มีใคร regenerate — ความไม่ตรงกันนั้น ไม่ใช่การตั้งชื่อใน section นี้ คือประเด็นทั้งหมดของสิ่งที่ตามมา
embedded.cli_widget_notes ถูก infer เป็น { note: string }[] — array เพราะ foreign key วิ่งไปอีกทาง (หลาย note อ้าง widget เดียว) — โดยไม่ต้องพิมพ์ type เองเลย กลับด้าน embed (select จาก cli_widget_notes แล้ว embed cli_widgets(...)) shape ที่ infer ได้จะกลายเป็น object เดี่ยว nested ไม่ใช่ array ตาม foreign key ทิศทางตรงข้าม
Type drift พิสูจน์ทั้งสองด้าน
หัวข้อที่มีชื่อว่า “Type drift พิสูจน์ทั้งสองด้าน”Database type ที่ generate ไว้เป็นแค่ snapshot ไม่มีอะไรมาเช็คมันกับ live schema อัตโนมัติเลย — พิสูจน์ทั้งสองด้านว่าเกิดอะไรขึ้นเมื่อเปลี่ยนชื่อ column แล้วไม่ regenerate type:
ด้านที่ 1: type เก่ายัง compile ผ่านสะอาด snapshot ที่เก็บไว้เองของ Database['public']['Tables']['cli_widgets'] ตรงเป๊ะกับที่ gen types สร้างข้างบน ก่อน การเปลี่ยนชื่อ column ด้านล่าง:
// @runimport { createClient } from '@supabase/supabase-js';
type StaleWidgetsRow = { id: string; owner: string; title: string; price: number; created_at: string };type StaleDatabase = { public: { Tables: { cli_widgets: { Row: StaleWidgetsRow; Insert: Partial<StaleWidgetsRow>; Update: Partial<StaleWidgetsRow> } } };};
const stale = createClient<StaleDatabase>(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);const { data, error } = await stale.from('cli_widgets').select('id, title, price');console.log('compiles clean, but runtime error.code =', error?.code, '| message =', error?.message);console.log('data =', data);compile ไฟล์นี้ด้วย tsc เดี่ยว ๆ (node_modules/.bin/tsc -p . — program เดียวกับที่ harness ของคอร์สนี้ type-check ทุก fence src/*.ts) รายงาน error ศูนย์ตัว แต่ migration ข้างล่างเปลี่ยนชื่อ price เป็น price_usd จริง:
-- supabase/migrations/20260929000001_cli_widgets_rename_price.sqlalter table public.cli_widgets rename column price to price_usd;รัน script ที่ compile สะอาดตัวเดิมเป๊ะ กับ table ที่เปลี่ยนชื่อไปแล้วจริง ๆ:
compiles clean, but runtime error.code = 42703 | message = column cli_widgets.price does not existdata = null42703 จริงของ Postgres (undefined_column) ทะลุผ่าน PostgREST มาตรง ๆ — tsc ไม่เจออะไรผิดเลย แต่ query ยังพังอยู่ดี type เก่าอธิบาย schema ที่ไม่มีอยู่แล้ว
ด้านที่ 2: regenerate type เปลี่ยนข้อผิดพลาดเดียวกันให้กลายเป็น compile error ก่อนที่อะไรจะรันด้วยซ้ำ รัน supabase gen types typescript --local ใหม่ ตอนนี้รายงาน price_usd ไม่ใช่ price แล้ว เขียน query และการอ่าน property เดิมเป๊ะกับ type ใหม่นี้:
// @expect-error TS2339: Property 'price' does not exist on type 'never' — the regenerated type catches the same stale-column mistake proved aboveimport { createClient } from '@supabase/supabase-js';
type FreshWidgetsRow = { id: string; owner: string; title: string; price_usd: number; created_at: string };type FreshDatabase = { public: { Tables: { cli_widgets: { Row: FreshWidgetsRow; Insert: Partial<FreshWidgetsRow>; Update: Partial<FreshWidgetsRow> } } };};
const fresh = createClient<FreshDatabase>('http://x', 'x');const { data, error } = await fresh.from('cli_widgets').select('id, title, price').single(); // same typo as beforeif (error) throw error;console.log(data.price);error TS2339: Property 'price' does not exist on type 'never'.diagnostic จริงจาก tsc — ควรพูดให้ชัดว่า อะไร ถูกเช็คจริง ๆ: string .select('...') ของ postgrest-js ไม่ถูก validate กับ schema ที่ระดับ type เองใน version นี้ แต่ shape ของข้อมูลที่มันคืนมา ถูก derive จากมัน และ shape นั้นแหละที่การอ่าน property ใด ๆ ถูกเช็คด้วย — price ไม่เคยเป็นส่วนหนึ่งของ Row ของ cli_widgets ภายใต้ type ใหม่ ดังนั้น data.price จึงกลายเป็น compile error (never เพราะ builder จับคู่ column ชื่อ price ใน select string กับ Row type ใหม่ไม่ได้เลย) แทนที่จะเป็น undefined เงียบ ๆ ตอน runtime นี่คือเวอร์ชันรูปธรรมของ “regenerate type หลังทุก migration” — ข้ามมันไปไม่ได้ทำให้ TypeScript จับ mistake นั้นได้ทีหลัง มันทำให้ TypeScript จับไม่ได้เลยต่างหาก และการพิสูจน์ก่อนหน้านี้โชว์แล้วว่า “ทีหลัง” หน้าตาเป็นแบบไหน: 42703 จริงบน production
error.code พิสูจน์กับ failure จริงสี่แบบ
หัวข้อที่มีชื่อว่า “error.code พิสูจน์กับ failure จริงสี่แบบ”error.code คือ identifier ที่เสถียร — เป็น Postgres error code จริงหรือ code เฉพาะของ PostgREST — และคือสิ่งที่โค้ดควร branch ไม่เคยใช้ text ของ error.message ค่าจริงสี่ค่า จาก request จริงสี่แบบกับ cli_widgets:
// @runimport { createClient } from '@supabase/supabase-js';
const API_URL = process.env.SUPABASE_URL!;const admin = createClient(API_URL, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const password = 'cli-pw-widgets-errors-12345';let user = (await admin.auth.admin.listUsers()).data.users.find((u) => u.email === email);if (!user) user = (await admin.auth.admin.createUser({ email, password, email_confirm: true })).data.user!;
const client = createClient(API_URL, process.env.SUPABASE_ANON_KEY!);await client.auth.signInWithPassword({ email, password });await admin.from('cli_widgets').delete().eq('title', 'cli-widget-errors-demo'); // idempotent re-run
await client.from('cli_widgets').insert({ title: 'cli-widget-errors-demo', price_usd: 50 });
const { error: dupErr } = await client.from('cli_widgets').insert({ title: 'cli-widget-errors-demo', price_usd: 1 });console.log('duplicate insert -> error.code =', dupErr?.code, '|', dupErr?.message);
const otherUid = crypto.randomUUID();const { error: rlsErr } = await client.from('cli_widgets').insert({ title: 'cli-widget-errors-spoof', price_usd: 1, owner: otherUid });console.log('spoofed-owner insert -> error.code =', rlsErr?.code, '|', rlsErr?.message);
const { error: zeroErr } = await client.from('cli_widgets').select().eq('title', 'cli-widget-does-not-exist').single();console.log('.single() with 0 rows -> error.code =', zeroErr?.code, '|', zeroErr?.message);
const { data: maybeData, error: maybeErr } = await client.from('cli_widgets').select().eq('title', 'cli-widget-does-not-exist').maybeSingle();console.log('.maybeSingle() with 0 rows -> error =', maybeErr, '| data =', maybeData);
await admin.from('cli_widgets').delete().eq('title', 'cli-widget-errors-demo');duplicate insert -> error.code = 23505 | duplicate key value violates unique constraint "cli_widgets_title_key"spoofed-owner insert -> error.code = 42501 | new row violates row-level security policy for table "cli_widgets".single() with 0 rows -> error.code = PGRST116 | Cannot coerce the result to a single JSON object.maybeSingle() with 0 rows -> error = null | data = null23505 (unique_violation) และ 42501 (insufficient_privilege โค้ดที่ Postgres ใช้เมื่อ RLS policy ปฏิเสธ) ทั้งคู่เป็น Postgres error code ธรรมดา ส่งผ่านมาไม่เปลี่ยน PGRST116 เป็น code เฉพาะของ PostgREST เอง เฉพาะ .single(): มันเรียกร้อง แค่แถวเดียวเป๊ะ แล้วเปลี่ยนอะไรก็ตามที่ไม่ใช่นั้น — ศูนย์แถวหรือมากกว่าหนึ่งแถว — เป็น error นี้ .maybeSingle() มีไว้เพื่อกรณี “ศูนย์แถวคือผลลัพธ์ที่ valid และคาดหวังได้” โดยเฉพาะ: คาดหวังแถวเดียวเหมือนกัน แต่ศูนย์แถว resolve เป็น { data: null, error: null } แทนที่จะเป็น error ในขณะที่มากกว่าหนึ่งแถวยังเป็น error อยู่ไม่ว่าจะใช้ method ไหน
if (error) { if (error.code === '23505') { showError('That title is taken.'); } else if (error.code === '42501') { showError('You do not have permission to do that.'); } else if (error.code === 'PGRST116') { showError('Not found.'); } else { showError('Something went wrong, please try again.'); }}AuthError, StorageError และ FunctionsHttpError: ชื่อ class จริง
หัวข้อที่มีชื่อว่า “AuthError, StorageError และ FunctionsHttpError: ชื่อ class จริง”sub-client สามตัวที่ต่างกัน throw หรือคืน error base class คนละตัว — ยืนยันที่นี่ด้วยการ import ตรงจาก package ที่ติดตั้งจริงแต่ละตัว (@supabase/[email protected], @supabase/[email protected], @supabase/[email protected]) ไม่ใช่เดาจากความจำ:
node -e "console.log(Object.keys(require('@supabase/auth-js')).filter(k => /Error/.test(k)))"[ 'AuthError', 'AuthApiError', 'AuthUnknownError', 'AuthSessionMissingError', 'AuthInvalidTokenResponseError', 'AuthInvalidCredentialsError', 'AuthWeakPasswordError', 'AuthInvalidJwtError', 'AuthRetryableFetchError', ... ]
node -e "console.log(Object.keys(require('@supabase/storage-js')).filter(k => /Error/.test(k)))"[ 'StorageApiError', 'StorageError', 'StorageUnknownError', 'StorageVectorsApiError', ... ]
node -e "console.log(Object.keys(require('@supabase/functions-js')).filter(k => /Error/.test(k)))"[ 'FunctionsError', 'FunctionsFetchError', 'FunctionsHttpError', 'FunctionsRelayError' ]call ของ supabase.auth.* reject/คืน AuthError (ในทางปฏิบัติมักเป็น AuthApiError); call ของ supabase.storage.* คืน StorageError (ในทางปฏิบัติมักเป็น StorageApiError); supabase.functions.invoke() reject ด้วย FunctionsHttpError เฉพาะตอน response ไม่ใช่ 2xx (แยกจาก FunctionsFetchError สำหรับ failure ระดับ network และ FunctionsRelayError สำหรับ gateway ของ Functions เองที่ relay request ไม่สำเร็จ) — สาม shape ที่ต่างกันจริงสำหรับสาม service ที่ต่างกันจริง ไม่มีตัวไหนเป็น PostgrestError ที่บทนี้เน้นเป็นหลัก branch ด้วย error instanceof FunctionsHttpError (แทนที่จะ duck-type บน .message) เป็นวิธีที่เชื่อถือได้ในการแยก “function รันแล้วคืน error response” จาก “function เข้าไม่ถึงเลย”
กลยุทธ์ retry: อะไร retry จริง อะไรไม่เคย
หัวข้อที่มีชื่อว่า “กลยุทธ์ retry: อะไร retry จริง อะไรไม่เคย”อ่าน source ของ @supabase/[email protected] ที่ติดตั้งจริงตรง ๆ แทนที่จะเดา: มี retry mechanism ในตัวจริง และมันแคบกว่าที่ชื่อบอก
# tools/probe/node_modules/@supabase/postgrest-js/dist/index.cjs (installed 2.117.2, read directly)const DEFAULT_MAX_RETRIES = 3;const getRetryDelay = (attemptIndex) => Math.min(1e3 * 2 ** attemptIndex, 3e4); // 1s, 2s, 4s, ... capped at 30sconst RETRYABLE_STATUS_CODES = [520, 503]; // 520 = Cloudflare transient; 503 = PostgREST schema cache not yet loadedconst RETRYABLE_METHODS = ["GET", "HEAD", "OPTIONS"];สามข้อที่ควรพูดให้ชัด อ่านตรงจาก source ทั้งหมด: retry ถูกจำกัดที่ 3 ครั้ง ด้วย exponential backoff (1s, 2s, 4s จำกัดที่ 30s); มีแค่ สอง status code ที่ transient เฉพาะเจาะจง ที่ trigger retry ไม่ใช่ทุก 5xx; และมีแค่ method อ่านที่ idempotent (GET/HEAD/OPTIONS) เท่านั้นที่ retry อัตโนมัติ — .insert()/.update()/.delete() (POST/PATCH/DELETE) ไม่เคย ถูก retry อัตโนมัติโดย layer นี้ เพราะ retry การเขียนที่อาจสำเร็จไปแล้วเสี่ยงต่อ side effect ซ้ำซ้อน service อีกสองตัวในคอร์สนี้จัดการ retry ต่างกันไปเลย: Realtime reconnect WebSocket อัตโนมัติเมื่อเจอ CHANNEL_ERROR (config ได้ผ่าน realtime: { params: { reconnectAfterMs } } } ตาม reference example ปัจจุบันของ SDK เอง) และ error-codes reference ของ Storage เองบันทึกไว้ว่า response 503 SlowDown เป็นสัญญาณให้ back off แล้ว retry เอง — ไม่มี retry อัตโนมัติสร้างไว้ใน Storage client เลยสำหรับกรณีนี้ กติกาข้อเดียวที่ใช้ได้ทุกที่: อย่าสร้าง retry loop ของตัวเองครอบ call เขียนโดยไม่มี idempotency key หรือ guard เทียบเท่า ด้วยเหตุผลเดียวกับที่ retry logic ในตัวของ REST layer เองกันการเขียนออกไปโดยตั้งใจ
flowchart LR
call[".from('cli_widgets').insert(row)"] --> result["{ data, error }"]
result --> check{"error present?"}
check -->|"yes"| code{"error.code"}
code -->|"23505"| unique["Unique violation"]
code -->|"42501"| rls["RLS rejection"]
code -->|"PGRST116"| single["single() got 0 or 2+ rows"]
check -->|"no"| typed["Use typed data safely"]
drift[("Schema changes")] -->|"gen types NOT re-run"| stale["tsc passes, runtime 42703"]
drift -->|"gen types re-run"| caught["tsc catches it, no runtime surprise"] อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”AI assistant ที่ถูกขอให้ “handle กรณีที่ widget ไม่มีอยู่จริง” สร้างโค้ดนี้:
🐛 หา bug ในโค้ดนี้
async function getWidgetByTitle(title: string) {const { data, error } = await supabase .from('cli_widgets') .select('id, title, price_usd') .eq('title', title) .single();
if (error) { // "not found" is the only realistic error here, right? return null;}
return data;}เฉลย
โค้ดนี้ปฏิบัติต่อ error ทุกแบบจาก .single() ว่าเป็น “not found” แล้วกลืนมันเงียบ ๆ ให้กลายเป็น null — แต่ error code จริงของ .single() สำหรับ “0 หรือ 2+ แถว” ที่พิสูจน์ข้างบน คือ PGRST116 เฉพาะเจาะจง และฟังก์ชันนี้ยุบมันรวมกับ RLS rejection จริง (42501 พิสูจน์ข้างบนเช่นกัน — row อื่น, owner ผิด หรือ select policy ที่ขาดไปเลย) หรือ failure ชั่วคราวจริง ๆ เข้าด้วยกัน caller ของ getWidgetByTitle บอกไม่ได้เลยว่า “title นี้ไม่มีอยู่จริง” ต่างจาก “คุณไม่มีสิทธิ์เห็นสิ่งนี้” หรือ “database เข้าไม่ถึงตอนนี้” — สามสถานการณ์ที่ต่างกันสิ้นเชิงที่ควรได้ UI response ต่างกันสิ้นเชิง (หน้า 404, error สิทธิ์, ปุ่ม retry) ถูกยุบรวมเป็น null เดียวกันหมด ทางแก้คือเช็ค error.code ตรง ๆ เหมือนที่บทนี้ทำ: if (error) { if (error.code === 'PGRST116') return null; throw error; } — ตอนนี้เฉพาะกรณี “ไม่มีแถวตรงกันจริง ๆ” เท่านั้นที่คืน null ส่วนที่เหลือ propagate เป็น error จริงแทนที่จะหายไปเงียบ ๆ นี่ยังเป็นกรณีที่ควรใช้ .maybeSingle() แทน .single() ตั้งแต่แรกด้วย: มันทำให้ “ศูนย์แถวคือเรื่องปกติ” เป็น path ที่คาดหวังไว้ ไม่มี error แล้วปล่อยให้ error มีความหมายตามที่ควร — คือมีอะไรผิดพลาดจริง ๆ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”{ data, error } ไม่ใช่สอง field ที่เป็นอิสระต่อกัน — มันคือ discriminated union ตัวเดียวที่ใส่เสื้อผ้าสองช่อง: มีแค่ตัวเดียวที่มีความหมายจริง (non-null) ในแต่ละ call และทุก bug ในบทนี้มาจากโค้ดที่ลืมสิ่งนั้นแล้วอ่าน data (หรือ property บนมัน) โดยไม่ได้ตัด error ออกไปก่อนจริง ๆ ไม่ว่าจะเป็นเพราะเช็คขาดไป (SpotTheBug) หรือ type ที่ไม่ตรงความจริงแล้ว (การพิสูจน์ drift) Database type ที่ generate ไว้ไม่ใช่ connection ที่เชื่อมกับ schema แบบสด ๆ — มันคือ snapshot ไฟล์ TypeScript ธรรมดาที่เกิดจากการ inspect schema ครั้งเดียว tsc ซื่อสัตย์เต็มที่กับไฟล์ที่มันได้รับ มันไม่มีทางรู้เลยว่าไฟล์นั้น stale ไปแล้ว ซึ่งเป็นเหตุผลเป๊ะ ๆ ที่การพิสูจน์ drift compile ผ่านสะอาดบน type เก่า แล้ว fail แค่ตอนที่ generation ใหม่เปลี่ยนสิ่งที่ tsc เช็คด้วยจริง ๆ ปฏิบัติต่อ gen types เหมือนที่ปฏิบัติต่อการรัน linter ใหม่หลังเปลี่ยน config ของมัน — เป็น step ที่ migration เองไม่ทำให้อัตโนมัติ
Trade-off
หัวข้อที่มีชื่อว่า “Trade-off”- Branch บน
error.codeเสถียรข้าม message wording ที่เปลี่ยนไปและข้าม locale — แลกกับต้องรู้จักและเปิดหา code เหล่านั้นจริง ๆ (23505,42501,PGRST116และตัวอื่น) แทนที่จะอ่านประโยคภาษาอังกฤษ Databasetype ที่ generate ไว้ให้ compile-time safety จริงสำหรับเกือบทั้งอายุของ schema — แลกกับช่องว่างจริงที่พิสูจน์ข้างบน ในช่วงเวลาระหว่าง migration ลงจริงกับตอนที่มีคน regenerate type ช่องว่างนั้นเงียบ ไม่ใช่ build failure- สัญญาแบบเข้มงวด “แค่แถวเดียวเป๊ะ” ของ
.single()ทำให้การ match หลายแถวโดยไม่ตั้งใจดังชัด (เป็นPGRST116จริง ไม่ใช่เลือกแถวแรกเงียบ ๆ) — แลกกับการทำให้กรณี “ศูนย์แถว” ที่ valid และคาดหวังได้ throw ไปด้วย ซึ่งเป็นกรณีที่.maybeSingle()มีไว้จัดการแทน - retry ในตัวของ
postgrest-js(พิสูจน์ข้างบน: 3 ครั้ง, exponential backoff, มีแค่520/503, อ่านอย่างเดียว) ไม่ต้อง setup อะไรเลยสำหรับ failure ชั่วคราวที่มันครอบคลุม — แลกกับการครอบคลุมแค่ส่วนแคบ ๆ ของ failure จริง failure อื่น ๆ (500, การเขียนที่ timeout) ยังเป็นความรับผิดชอบของแอปเองอยู่ดี
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ถ้า TypeScript compile ผ่าน แปลว่า query ถูกต้องกับ database จริง” พิสูจน์แล้วว่าผิด ทั้งสองทิศทาง:
Databasetype ที่ stale compile query ที่พังไปแล้วผ่านสะอาด แล้วมีแค่ request จริงตอน runtime เท่านั้นที่เผย42703regenerate type หลังทุก migration คือสิ่งที่ทำให้ความเห็นของtscตรงกับความจริง ไม่ใช่แค่คำแนะนำ - “branch บน error.message ก็โอเค ตราบใดที่ match wording ปัจจุบันเป๊ะ” เปราะบางโดยธรรมชาติ:
error.codeคือ contract ที่ documented และเสถียร (23505,42501,PGRST116พิสูจน์ข้างบน) ข้อความคือสำหรับคนอ่าน ไม่ใช่ API ที่ versioned - “error ที่จับได้จาก .single() แปลว่า row ไม่มีอยู่จริงเสมอ” พิสูจน์แล้วว่าผิดจาก
SpotTheBugของบทนี้: brancherrorเดียวกันจับ RLS rejection กับ failure อื่นด้วย นอกจากจะเช็คerror.codeเฉพาะเจาะจงว่าเป็นPGRST116 - “error ของ Auth, Storage และ Functions ก็เป็นแค่ PostgrestError เหมือนทุกอย่างในบทนี้” พิสูจน์แล้วว่าผิดจากการ import แต่ละ package ตรง ๆ:
AuthError/AuthApiError,StorageError/StorageApiErrorและFunctionsError/FunctionsHttpErrorเป็นสาม class hierarchy จริงที่ต่างกัน ไม่มีตัวไหนเป็นPostgrestErrorเลย