Notes object stores
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”previous lesson เปิด offlinenotes และสร้าง object store เปล่าสี่ตัว บทนี้ให้แต่ละ store มีงานที่ชัดเจน แล้วเพิ่ม typed accessor ที่ส่วนที่เหลือของแอปเรียกใช้ — ไม่มี component หรือ note store ในอนาคตตัวไหนเปิด transaction เอง
พอจบ db.ts export getNote, putNote, listNotes, getDoc, putDoc, enqueueOp, drainOutbox, getMeta และ setMeta module Notes UI ใช้ตรง ๆ; sync engine พึ่ง enqueueOp/drainOutbox ภายหลัง
- สี่ store สี่ความรับผิดชอบ local-first app มีมากกว่า “ข้อมูล”: ยังมีรูปแบบที่เชื่อถือได้, read model ราคาถูก, queue ขาออก และการจดบัญชีเล็กน้อย การแยกออกจากกันทำให้แต่ละการอ่านเร็วและแต่ละการเขียนชัดเจน
- accessor ไม่ใช่
db.getดิบ ๆ กระจายอยู่ทั่ว ถ้าทุก component เขียน transaction ของตัวเอง รูปร่าง value และกฎ “อัปเดตnotesทุกครั้งที่docsเปลี่ยน” จะเพี้ยน module ตัวเดียวเป็นเจ้าของ invariant เหล่านั้น - รูปร่างคือ contract รูปร่าง record เหล่านี้เป๊ะ ๆ ใช้ร่วมกับ CRDT engine, sync server และบทเรียนภาษาไทย การตรึงไว้ตรงนี้คือสิ่งที่ทำให้ module ต่อ ๆ ไป assume ได้เลย ไม่ต้อง re-derive ว่า record
docsมีอะไร
แต่ละ store และเหตุผลที่ต้องมี:
notes—{ id, title, updatedAt }เป็น list projection: ขั้นต่ำที่ note list ต้องใช้ render หนึ่งแถว derive มาจากเอกสารจริง การอ่าน list ไม่เคย deserialize CRDT snapshotdocs—{ id, actorId, snapshot }เป็น state ที่ เชื่อถือได้ ต่อ note: CRDT snapshot เต็มบวกactorIdของ replica ทุกอย่างที่เหลือ derive มาจากตัวนี้outbox—{ seq, noteId, op }local op ที่ ยังไม่ push ไปยัง sync server key ด้วยseqที่ assign อัตโนมัติ push drain ตามลำดับ; ไม่มีอะไรตรงนี้ถูกลบจนกว่า server จะ acknowledgemeta—{ key, value }ถุง key/value เล็ก ๆ:actorIdของเครื่องนี้ และ watermarkpulled:<noteId>ที่บันทึก server sequence ล่าสุดที่ pull มาต่อ note
ก่อน CRDT (module นี้กับบทถัดไป) UI เขียน note ธรรมดาลง notes ตรง ๆ docs และ outbox ว่างจนกว่าจะถึง Wiring the WASM Core — แต่เราสร้าง accessor ครบทั้งสี่กลุ่มตอนนี้ เพื่อให้ storage API เสถียรก่อนที่ CRDT จะมานั่งทับ
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”list projection notes แยกต่างหาก เทียบกับการอ่าน docs เพื่อทำ list
- Pros: query ของ list ยังเล็กและไม่เคยแตะ CRDT snapshot; render N แถวอ่าน N record เล็ก ๆ; projection เพิ่ม sort index ได้โดยไม่รบกวน store ที่เชื่อถือได้
- Cons: สอง store อธิบาย note เดียวกัน การเขียนจึงต้องอัปเดตทั้งคู่ และเพี้ยนได้ถ้า code path ไหนลืม เรากันเรื่องนั้นด้วยการบีบให้ทุก mutation ผ่าน
putNote/putDoc
store outbox เทียบกับการคำนวณ op ที่ยังไม่ sync แบบ on the fly
- Pros: “อะไรยังต้อง push?” เป็น list ถาวรที่รอดจากการ reload และช่วง offline; Background Sync (Module 11) แค่ drain ออกไป; ลำดับชัดเจนผ่าน
seq - Cons: duplicate op ที่อยู่ใน snapshot ด้วยจนกว่าจะได้รับ acknowledge และ outbox หดลงเฉพาะตอน push สำเร็จ — outbox ของเครื่องที่ offline โตขึ้นจนกว่าจะ reconnect
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. apps/web/src/db.ts — typed accessors
หัวข้อที่มีชื่อว่า “1. apps/web/src/db.ts — typed accessors”เพิ่มพวกนี้ต่อยอดจาก getDb() ของบทก่อน ทุก function await connection ที่ใช้ร่วมกัน จึงขี่ upgrade path เดียวกันหมด
import type { Op, SnapshotData } from './db';
// --- notes: the list projection ---------------------------------------
export type NoteMeta = OfflineNotesDB['notes']['value']; // { id, title, updatedAt }
export async function getNote(id: string): Promise<NoteMeta | undefined> { return (await getDb()).get('notes', id);}
export async function putNote(note: NoteMeta): Promise<void> { await (await getDb()).put('notes', note);}
export async function listNotes(): Promise<NoteMeta[]> { const all = await (await getDb()).getAll('notes'); // Most-recently edited first — the order the list UI wants. return all.sort((a, b) => b.updatedAt - a.updatedAt);}
export async function deleteNote(id: string): Promise<void> { const db = await getDb(); // Remove the projection and the authoritative doc together. const tx = db.transaction(['notes', 'docs'], 'readwrite'); await Promise.all([ tx.objectStore('notes').delete(id), tx.objectStore('docs').delete(id), tx.done, ]);}
// --- docs: the authoritative CRDT snapshot ----------------------------
export type NoteDocRecord = OfflineNotesDB['docs']['value']; // { id, actorId, snapshot }
export async function getDoc(id: string): Promise<NoteDocRecord | undefined> { return (await getDb()).get('docs', id);}
export async function putDoc(doc: NoteDocRecord): Promise<void> { await (await getDb()).put('docs', doc);}
// --- outbox: local ops awaiting push ----------------------------------
export async function enqueueOp(noteId: string, op: Op): Promise<number> { const db = await getDb(); // seq is auto-assigned; add() returns the new key. Cast: our value // type names seq, but the store fills it in, so we omit it here. return db.add('outbox', { noteId, op } as OfflineNotesDB['outbox']['value']);}
export async function drainOutbox(): Promise< OfflineNotesDB['outbox']['value'][]> { const db = await getDb(); // Read everything queued, in seq order (the store's natural key order). return db.getAll('outbox');}
export async function ackOutbox(throughSeq: number): Promise<void> { const db = await getDb(); const tx = db.transaction('outbox', 'readwrite'); // Delete acknowledged ops up to and including throughSeq. for await (const cursor of tx.store) { if (cursor.key <= throughSeq) await cursor.delete(); } await tx.done;}
// --- meta: small key/value bookkeeping --------------------------------
export async function getMeta<T = unknown>(key: string): Promise<T | undefined> { const rec = await (await getDb()).get('meta', key); return rec?.value as T | undefined;}
export async function setMeta(key: string, value: unknown): Promise<void> { await (await getDb()).put('meta', { key, value });}มีการตัดสินใจไม่กี่อย่างที่ควรพูดถึง deleteNote ครอบ notes และ docs ใน transaction readwrite เดียว ดังนั้น note จึงไม่มีทางรอดใน store หนึ่งหลังจากถูกลบจากอีก store — นั่นคือ invariant ที่ทำให้ projection ซื่อสัตย์ enqueueOp return seq ที่ถูก assign เพราะผู้เรียกต้องการ local sequence number ที่เพิ่งสร้าง และ getMeta เป็น generic เพื่อให้ getMeta<string>('actorId') และ watermark pulled:<noteId> แบบตัวเลขอ่านกลับมาโดย type ถูกต้อง เพราะ value ของ meta จงใจให้หลวม
2. apps/web/src/actor.ts — a stable device id
หัวข้อที่มีชื่อว่า “2. apps/web/src/actor.ts — a stable device id”actorId ระบุ replica นี้ให้ CRDT (Module 6) และต้องเสถียรข้ามการ reload เก็บไว้ใน meta ตอนรันครั้งแรก
import { getMeta, setMeta } from './db';
export async function getActorId(): Promise<string> { let id = await getMeta<string>('actorId'); if (!id) { id = crypto.randomUUID(); await setMeta('actorId', id); } return id;}crypto.randomUUID() มีให้ใช้ในทุก browser ที่รองรับ stack ที่เหลือนี้ และ UUID มี entropy มากพอที่จะทำให้ actor id ของสองเครื่องแตกต่างกัน
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”type-check accessor แล้ว build:
pnpm --filter web exec tsc --noEmitpnpm --filter web buildทั้งคู่ควรผ่าน จากนั้นลองวิ่ง round-trip จาก browser console (dev server รันอยู่, แอปเปิดอยู่):
const db = await import('/src/db.ts');
await db.putNote({ id: 'n1', title: 'First note', updatedAt: Date.now() });await db.putNote({ id: 'n2', title: 'Second', updatedAt: Date.now() + 1 });console.log((await db.listNotes()).map((n) => n.title)); // ['Second', 'First note']
const seq = await db.enqueueOp('n1', { t: 'title', value: 'First note' });console.log('queued seq', seq); // 1console.log(await db.drainOutbox()); // [{ seq: 1, noteId: 'n1', op: {...} }]
await db.deleteNote('n1');console.log(await db.getNote('n1')); // undefinedlistNotes return แบบใหม่สุดก่อน, enqueueOp ส่ง seq ที่ assign อัตโนมัติกลับมา และ deleteNote เคลียร์ note จากทั้งสอง store
Check your understanding:
- ทำไม
notesถึงเก็บแค่{ id, title, updatedAt }แทนที่จะเก็บ body เต็มของ note? แบบนี้ให้อะไรกับ list UI? deleteNoteครอบสองdeleteไว้ใน transaction เดียว invariant อะไรจะพังถ้าแยกเป็นสองawait?enqueueOpเก็บ{ noteId, op }แต่ value type ยังระบุseqด้วยseqมาจากไหน และทำไม function ถึง return ค่านั้นออกมา?getMetaเป็น generic (getMeta<T>) ทำไม value ของ storemetaถึง type แบบหลวม ๆ และ generic กู้อะไรคืนให้ผู้เรียก?
เราให้แต่ละ store มีบทบาท — notes (list projection), docs (snapshot ที่เชื่อถือได้), outbox (op ที่ยังไม่ push), meta (การจดบัญชี) — แล้วสร้าง typed accessor ที่ทั้งแอปอ่านและเขียนผ่าน: getNote/putNote/listNotes/deleteNote, getDoc/putDoc, enqueueOp/drainOutbox/ackOutbox, getMeta/setMeta บวก actorId ที่เสถียร storage API เสถียรแล้ว พร้อมให้ CRDT มานั่งทับภายหลัง
foundation เสร็จแล้ว ต่อไป The Notes UI → วาง editor และ list จริงไว้หน้า store เหล่านี้ — สร้าง, แก้ไข และลบ note ที่ persist ลง notes ยังก่อน CRDT ด้วย field ชื่อและ body ธรรมดา