A Typed IndexedDB Store
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”ใน App Shell เรา mount <offline-notes-app> และ child component ทั้งหมด แต่ยังไม่มีที่เก็บข้อมูล module นี้เติมส่วนนั้นให้ OfflineNotes เป็น local-first — IndexedDB คือ source of truth ของ client ไม่ใช่ cache หน้า server — ดังนั้น storage layer จึงเป็นตัวรับน้ำหนัก และเราอยากให้ typed และน่าเบื่อ
ในบทนี้เรา wrap IndexedDB ด้วย library idb, อธิบาย database ของเราด้วย TypeScript schema และเปิด offlinenotes ที่ version 1 ผ่าน db.ts ที่ใช้ร่วมกันตัวเดียว เราสร้าง object store ตรงนี้; next lesson จะสร้าง typed accessor ต่อยอดขึ้นไป
IndexedDB ดิบ ๆ เป็น API แบบ event-driven, callback-based จากปี 2015 — IDBRequest.onsuccess, onupgradeneeded, transaction ที่ปิดตัวเองถ้าคุณ await ผิดจังหวะ idb เป็น promise wrapper บาง ๆ ครอบ API นั้นเป๊ะ ๆ (object เดียวกัน, semantic เดียวกัน) เราจึงเขียน await db.get(...) แทนการต่อสาย event handler และได้ compile-time check บนชื่อ store และรูปร่างของ value
- IndexedDB ไม่ใช่
localStoragelocalStorageเป็น synchronous, เก็บได้แค่ string และเพดานราว 5 MB note ที่มี body แบบ markdown บวก CRDT op log ที่โตขึ้นเรื่อย ๆ ต้องการ structured record, index และ transaction จริง IndexedDB เป็น browser store เดียวที่สร้างมาเพื่อสิ่งนั้น และเป็นฐานที่ทั้งแอปนั่งอยู่บนนั้น - typed wrapper ไม่ใช่ request ดิบ ๆ ส่วนของแอปที่แตะ storage — ตอนนี้คือ component ภายหลังคือ note store และ sync engine — ไม่ควรต่างคนต่าง re-derive วิธีเปิด transaction ให้ถูก
db.tsตัวเดียวเป็นเจ้าของ schema และ connection; ที่เหลือ import typed helper ไปใช้ - schema type คุ้มค่าทันที การประกาศ store เป็น
DBSchemaหมายความว่า typo ในชื่อ store หรือรูปร่าง value ที่ผิดจะเป็นเส้นหยักแดง ไม่ใช่ runtimeDataErrorที่คุณเจอหลังจาก note save ไม่สำเร็จ
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”idb เทียบกับ IndexedDB ดิบ ๆ
- Pros: promise แทน
onsuccess/onerror;async/awaitอ่านเป็นธรรมชาติ; genericDBSchematype ให้ทุกget/put; ~1 KB gzipped; เป็น API เดียวกันข้างใต้ จึงไม่มีอะไรถูกซ่อนจากคุณ - Cons: มี dependency เพิ่มอีกตัว; คุณยังต้องเข้าใจ transaction lifetime ของ IndexedDB (transaction ปิดตัวเมื่อ microtask queue ว่างโดยไม่มี request ค้าง) เพราะ
idbรักษาพฤติกรรมนั้นไว้ตามจริงแทนที่จะแปะทับ
shared connection promise เทียบกับการเปิดต่อ call
- Pros:
openDBรันครั้งเดียว; ทุก accessorawaitpromise เดียวกัน จึงมี upgrade path เดียวและไม่มี connection churn; ผู้เรียกที่ concurrent ตอน startup ได้ connection ตัวเดียวกันหมด - Cons: singleton ระดับ module เป็น global state โดยปริยาย; ใน test คุณ reset ด้วยการลบ database ระหว่าง case แทนการสร้าง instance ใหม่
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. apps/web — add the dependency
หัวข้อที่มีชื่อว่า “1. apps/web — add the dependency”pnpm --filter web add idbidb มาพร้อม TypeScript type ของตัวเอง จึงไม่มี @types/idb ให้ต้องเพิ่ม
2. apps/web/src/db.ts — the schema and the connection
หัวข้อที่มีชื่อว่า “2. apps/web/src/db.ts — the schema and the connection”อธิบาย store ทั้งสี่เป็น DBSchema แล้วเปิด database ครั้งเดียวและ cache promise ไว้ type ของ key และ value ของแต่ละ store ประกาศไว้ตรงนี้ และบังคับใช้ทุกที่ที่ใช้ connection
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
// One serialized CRDT operation. The CRDT crate (Module 6) defines the// real shape; here it's opaque JSON — the store never inspects it.export type Op = unknown;
// A full CRDT document snapshot, produced by NoteDoc.snapshot() (Module 7).export type SnapshotData = unknown;
export interface OfflineNotesDB extends DBSchema { // List projection for the note list UI: derived, cheap to read. notes: { key: string; value: { id: string; title: string; updatedAt: number }; }; // Authoritative per-note CRDT snapshot. docs: { key: string; value: { id: string; actorId: string; snapshot: SnapshotData }; }; // Local ops not yet pushed to the sync server. seq is auto-assigned. outbox: { key: number; value: { seq: number; noteId: string; op: Op }; }; // Small key/value bag: actorId, and pulled:<noteId> sync watermarks. meta: { key: string; value: { key: string; value: unknown }; };}
const DB_NAME = 'offlinenotes';const DB_VERSION = 1;
let dbPromise: Promise<IDBPDatabase<OfflineNotesDB>> | null = null;
export function getDb(): Promise<IDBPDatabase<OfflineNotesDB>> { if (!dbPromise) { dbPromise = openDB<OfflineNotesDB>(DB_NAME, DB_VERSION, { upgrade(db) { // Runs once per version bump. Create the stores for v1. db.createObjectStore('notes', { keyPath: 'id' }); db.createObjectStore('docs', { keyPath: 'id' }); db.createObjectStore('outbox', { keyPath: 'seq', autoIncrement: true }); db.createObjectStore('meta', { keyPath: 'key' }); }, }); } return dbPromise;}มีสองรายละเอียดที่ควรพูดถึง callback upgrade เป็น hook สำหรับ schema-migration เดียว ของ IndexedDB: รันเมื่อ browser ไม่มี database หรือมี version เก่ากว่า DB_VERSION และเป็นที่เดียวที่ createObjectStore ถูกกฎ พอเราต้องการ store ตัวที่ห้าหรือ index ภายหลัง เราจะ bump DB_VERSION เป็น 2 แล้ว branch ภายใน upgrade บน oldVersion — เลข version คือ cursor ของ migration ไม่ใช่ของประดับ
store outbox ใช้ autoIncrement: true กับ keyPath: 'seq' ดังนั้น IndexedDB assign ค่า seq แล้วเขียนกลับลงใน record ที่เก็บ นั่นให้ทุก op ที่ queue ไว้มี local sequence number แบบ monotonic ฟรี ๆ ซึ่ง enqueueOp/drainOutbox ของ next lesson พึ่งพา
3. apps/web/src/components/offline-notes-app.ts — open on boot
หัวข้อที่มีชื่อว่า “3. apps/web/src/components/offline-notes-app.ts — open on boot”เตะ connection ให้เริ่มตอน root element mount เพื่อให้ upgrade แรกเกิดขึ้นก่อนที่ component ใดจะอ่าน ไม่มีอะไร block รอ; accessor ภายหลัง await getDb() แล้วเข้าร่วม promise เดียวกัน
import { getDb } from '../db';
class OfflineNotesApp extends HTMLElement { connectedCallback() { // Fire-and-forget: opens (and upgrades) the DB once, early. void getDb(); this.innerHTML = `<note-list></note-list><note-editor></note-editor>`; }}
customElements.define('offline-notes-app', OfflineNotesApp);ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”type-check และ build web app:
pnpm --filter web exec tsc --noEmitpnpm --filter web buildทั้งคู่ควรจบโดยไม่มี error — schema type resolve ได้ และไม่มีอะไรอ้างถึง store ที่ไม่มีอยู่
จากนั้นรัน dev server แล้ว inspect database ใน browser:
pnpm --filter web devเปิดแอปที่ http://localhost:4321/offlinenotes/ แล้วไป DevTools → Application → IndexedDB → offlinenotes คุณควรเห็น version 1 พร้อม object store สี่ตัว — notes, docs, outbox, meta — ว่างทั้งหมด ใน Console ยืนยันว่า connection resolve:
const { getDb } = await import('/src/db.ts');const db = await getDb();console.log(db.name, db.version, [...db.objectStoreNames]);// offlinenotes 1 ['docs', 'meta', 'notes', 'outbox']objectStoreNames กลับมาแบบเรียงแล้ว ลำดับจึงต่างจากลำดับที่สร้าง — นั่นคือสิ่งที่คาดไว้
Check your understanding:
- ทำไม
createObjectStoreถึงทำงานได้เฉพาะภายใน callbackupgradeและคุณจะเปลี่ยนอะไรเพื่อเพิ่ม store ภายหลังโดยไม่ทำ database ของ user เดิมพัง? autoIncrement: trueกับkeyPath: 'seq'ทำอะไรกับ record ที่คุณเก็บในoutboxและทำไม monotonic sequence number ถึงมีประโยชน์กับ outbox โดยเฉพาะ?getDb()cache promise ไม่ใช่ connection ที่ resolve แล้ว ทำไมถึง cache promise และเกิดอะไรขึ้นถ้าสอง component เรียกgetDb()ใน tick เดียวกัน?- เราเลือก IndexedDB แทน
localStorageบอกคุณสมบัติสองอย่างของข้อมูลที่ OfflineNotes เก็บ ซึ่งlocalStorageรองรับไม่ได้
เรา wrap IndexedDB ด้วย idb, อธิบาย offlinenotes เป็น typed DBSchema และเปิดที่ version 1 ผ่าน db.ts ที่ใช้ร่วมกันตัวเดียว ซึ่งสร้าง store notes, docs, outbox และ meta connection เป็น cached promise ที่เปิดครั้งเดียวตอน boot และ callback upgrade คือรอยต่อ migration ของเราสำหรับ version ต่อ ๆ ไป
store มีอยู่แล้วแต่ยังไม่มีอะไรรู้วิธีอ่านหรือเขียนอย่างปลอดภัย ต่อไป Notes object stores → เดินผ่านรูปร่างของแต่ละ store แล้วเพิ่ม typed accessor — getNote, putNote, listNotes และตัวอื่น ๆ — ที่ Notes UI จะต่อยอด