Persisting ops
ตอนนี้ op ไหลออกจากทุก edit แล้ว แต่หายไปตอน reload บทนี้ทำให้ document คงทน: snapshot ของ CRDT ทั้งก้อนไปที่ docs store, แต่ละ op ถูก queue ไว้ใน outbox, และการเปิด note ซ้ำจะสร้างขึ้นใหม่ด้วย NoteDoc.from_snapshot
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”write path สองเส้นและ read path หนึ่งเส้น ทั้งหมดผ่าน typed db.ts wrapper จาก IndexedDB module:
- ทุกครั้งที่ edit — persist snapshot ใหม่ไปที่
docs(state ที่เป็นทางการ) และ append op ที่ return มาไปที่outbox(queue ที่ยังไม่ sync) แล้ว refresh projection ของnoteslist - ตอนเปิด — อ่าน record ของ note จาก
docsแล้วสร้าง document ที่มีชีวิตขึ้นใหม่ด้วยNoteDoc.from_snapshot(actorId, snapshot)
editor ยังคง render จาก title() และ text() เหมือนเดิมเป๊ะ; ไม่รู้เลยว่ามีการ persist เกิดขึ้น
flowchart TD Edit["edit → Op[]"] --> Snap["doc.snapshot()"] Edit --> Ops["each op"] Snap -->|"putDoc"| Docs[("docs store<br/>{id, actorId, snapshot}")] Ops -->|"enqueueOp"| Outbox[("outbox store<br/>{seq, noteId, op}")] Docs -->|"getDoc + from_snapshot"| Reload["reopen note"]เราเก็บ สอง representation อย่างตั้งใจ เพราะทั้งสองตอบคำถามที่ต่างกัน snapshot ตอบว่า “ตอนนี้ note นี้พูดว่าอะไร เดี๋ยวนี้?” — นี่คือสิ่งที่เรา load เพื่อแสดง note ทันทีโดยไม่ต้อง replay ประวัติ ส่วน outbox ตอบว่า “ฉันแก้อะไรไปบ้างที่ server ยังไม่เห็น?” — นั่นคือ op stream เป๊ะ ๆ ที่เราจะ push ใน sync module
เราเก็บแค่ op log แล้ว replay ได้ไหม? ได้ และดีไซน์แบบ pure event-sourced ก็ทำแบบนั้น แต่การ replay ทุก op ตั้งแต่ตอนสร้างเพื่อเปิด note จะช้าลงเรื่อย ๆ ทุกครั้งที่ edit และหัวใจของ local-first คือการเปิด note ต้องทันทีและ offline ได้ snapshot คือ cache ของ state ที่พับเก็บไว้แล้ว; outbox คือส่วนหางที่เรายังติดค้าง network อยู่ การเก็บทั้งสองคือทางสายกลางที่ practical: อ่านเร็ว และ มี sync record ที่แม่นยำ
from_snapshot คือสิ่งที่ทำให้ snapshot เชื่อถือได้ เพราะสร้าง NoteDoc ขึ้นใหม่ด้วย actor id ตัวเดียวกัน และ counter ภายในเดิม edit ที่ทำหลัง reload จึงผลิต op ที่เข้าล็อกถูกที่ใน causal history เดียวกัน — note ที่กู้กลับมาแยกไม่ออกจาก note ที่ไม่เคยถูกปิดเลย
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”persist snapshot ทุกครั้งที่ edit เทียบกับ persist แค่ op log
- Pros: การเปิด note คือ
getDocหนึ่งครั้งและfrom_snapshotหนึ่งครั้ง — เวลาคงที่ ไม่มี replay อยู่รอด reload และการ restart browser - Cons: snapshot พก CRDT metadata มาด้วย (tombstone จาก text ที่ลบไปแล้วจะไม่หดลงถ้าไม่มี compaction — ข้อควรระวังที่ตรงไปตรงมาจาก CRDT module) ดังนั้น blob ที่เก็บไว้จึงโตขึ้นตามประวัติการ edit
outbox queue แยกต่างหาก เทียบกับ derive op ที่ยังไม่ sync ออกจาก snapshot ตอน push
- Pros: outbox เป็น record แบบ explicit เรียงลำดับ และ append-only ของสิ่งที่ต้องส่งเป๊ะ ๆ; การ drain ทำได้เรียบง่ายและเป็นมิตรกับ idempotent
- Cons: เป็น store ตัวที่สองที่ต้องรักษาให้ consistent กับ
docs— write ทั้งสองควรเกิดพร้อมกัน ไม่งั้น crash ระหว่างสองอันอาจทำ op หลุดจาก queue ทั้งที่ยังคงอยู่ใน state
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. apps/web/src/db.ts — the accessors we rely on
หัวข้อที่มีชื่อว่า “1. apps/web/src/db.ts — the accessors we rely on”พวกนี้มาจาก IndexedDB Foundation → module; shape ต้องตรงตาม contract เป๊ะ ๆ key seq ของ outbox autoincrement เอง ดังนั้น enqueueOp จึงไม่ต้องส่ง key มา
import { openDB, type DBSchema } from 'idb';import type { Op } from './store';
interface NotesDB extends DBSchema { notes: { key: string; value: { id: string; title: string; updatedAt: number } }; docs: { key: string; value: { id: string; actorId: string; snapshot: unknown } }; outbox: { key: number; value: { seq: number; noteId: string; op: Op }; autoIncrement: true; }; meta: { key: string; value: { key: string; value: unknown } };}
const dbp = openDB<NotesDB>('offlinenotes', 1, { upgrade(db) { db.createObjectStore('notes', { keyPath: 'id' }); db.createObjectStore('docs', { keyPath: 'id' }); db.createObjectStore('outbox', { keyPath: 'seq', autoIncrement: true }); db.createObjectStore('meta', { keyPath: 'key' }); },});
export async function putDoc(rec: { id: string; actorId: string; snapshot: unknown }) { return (await dbp).put('docs', rec);}export async function getDoc(id: string) { return (await dbp).get('docs', id);}export async function putNote(rec: { id: string; title: string; updatedAt: number }) { return (await dbp).put('notes', rec);}export async function enqueueOp(noteId: string, op: Op) { // seq is autoIncremented by IndexedDB; omit it from the value. return (await dbp).add('outbox', { noteId, op } as never);}2. crates/crdt/src/lib.rs — snapshot และ from_snapshot
หัวข้อที่มีชื่อว่า “2. crates/crdt/src/lib.rs — snapshot และ from_snapshot”บท CRDT ไม่เคยให้ NoteDoc มีวิธี serialize หรือสร้างตัวเองขึ้นใหม่เลย — เพิ่มตอนนี้ เพราะนี่คือบทแรกที่ต้องใช้มัน docs เก็บสิ่งที่ snapshot() คืนมาแล้วส่งกลับให้ from_snapshot โดยไม่แก้อะไร ดังนั้น wire shape แค่ต้องสมเหตุสมผลสำหรับ Rust เท่านั้น การสร้าง Lamport counter ขึ้นใหม่เป็น timestamp สูงสุดที่เจอทั้งใน title หรือ body ทำให้ local edit ตัวถัดไปหลัง reload ยังคงสูงกว่าทุกอย่างที่มีอยู่แล้วใน document
#[derive(Serialize, Deserialize, Clone, Debug)]struct Snapshot { title: Lww<String>, body: Rga,}
#[wasm_bindgen]impl NoteDoc { /// Serialize the whole document — title register and body — for storage. pub fn snapshot(&self) -> JsValue { let snap = Snapshot { title: self.title.clone(), body: self.body.clone() }; serde_wasm_bindgen::to_value(&snap).unwrap() }
/// Rebuild a document from a stored snapshot, with the Lamport clock /// seeded past every timestamp it contains. pub fn from_snapshot(actor_id: String, snapshot: JsValue) -> Result<NoteDoc, JsValue> { let snap: Snapshot = serde_wasm_bindgen::from_value(snapshot)?; let counter = snap.title.ts_counter().max(snap.body.max_counter()); Ok(NoteDoc { actor_id, counter, title: snap.title, body: snap.body }) }}accessor เล็ก ๆ สองตัวป้อนเลขให้ from_snapshot — เพิ่มไว้ข้าง ๆ Lww::merge และ Rga::tombstone ตามลำดับ:
impl<T: Clone> Lww<T> { fn ts_counter(&self) -> u32 { self.ts.0 }}impl Rga { fn max_counter(&self) -> u32 { self.elems.iter().map(|e| e.id.0).max().unwrap_or(0) }}Build WASM package ใหม่ให้ snapshot/from_snapshot ไปอยู่ใน pkg/:
cd crates/crdtwasm-pack build --target web3. apps/web/src/store.ts — persist on change
หัวข้อที่มีชื่อว่า “3. apps/web/src/store.ts — persist on change”ขยาย store ให้ mutator save ก่อนที่จะ return helper ตัวเดียวพับ write สามอย่างเข้าด้วยกัน: snapshot ไป docs, op ไป outbox, projection ไป notes การห่อ edit และ persist ไว้ใน method เดียวช่วยให้ docs และ outbox เดินตรง step กัน
import init, { NoteDoc } from '../../../crates/crdt/pkg/crdt.js';import { getDoc, putDoc, putNote, enqueueOp } from './db';
async function persist(id: string, actorId: string, doc: NoteDoc, ops: Op[]) { // snapshot() serialises the whole CRDT via serde-wasm-bindgen. await putDoc({ id, actorId, snapshot: doc.snapshot() }); for (const op of ops) await enqueueOp(id, op); await putNote({ id, title: doc.title(), updatedAt: Date.now() });}4. apps/web/src/store.ts — open from snapshot
หัวข้อที่มีชื่อว่า “4. apps/web/src/store.ts — open from snapshot”openNote อ่าน docs; ถ้ามี record อยู่ ก็สร้างใหม่ด้วย from_snapshot ไม่งั้นเริ่ม NoteDoc ใหม่สด ๆ ตอนนี้ edit method จะ await persist(...) ก่อน fire onChange
export async function openNote(id: string, onChange: () => void): Promise<OpenNote> { await ensureWasm(); const actorId = await getActorId();
const stored = await getDoc(id); const doc = stored ? NoteDoc.from_snapshot(stored.actorId, stored.snapshot) : new NoteDoc(actorId); const docActor = stored?.actorId ?? actorId;
const applied = async (ops: Op[]) => { await persist(id, docActor, doc, ops); onChange(); return ops; };
return { id, setTitle: (t) => applied(toOps(doc.set_title(t))) as unknown as Op[], insertText: (i, s) => applied(toOps(doc.insert_text(i, s))) as unknown as Op[], deleteText: (i, n) => applied(toOps(doc.delete_text(i, n))) as unknown as Op[], title: () => doc.title(), text: () => doc.text(), };}ตอนนี้ edit method เป็น async แล้ว (เพราะ await IndexedDB) ให้ editor
await note.insertText(...)เพื่อไม่ให้การกดคีย์หายถ้าสอง edit แข่งกัน; IndexedDB write ถูก queue ไว้ แต่docใน memory ของคุณต้องคงเป็นผู้มีอำนาจตัดสินลำดับ
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”build WASM package และ app แล้วพิสูจน์ความคงทนด้วยมือ ใน DevTools console:
const note = await window.__store.openNote('n1', () => {});await note.insertText(0, 'buy milk');await note.setTitle('Groceries');// Reload the page (Cmd-R), then:const again = await window.__store.openNote('n1', () => {});console.log(again.title(), '/', again.text());// Groceries / buy milkตรวจดู store ใน DevTools → Application → IndexedDB → offlinenotes:
docs → { id: "n1", actorId: "…", snapshot: {…} }outbox → { seq: 1, noteId: "n1", op: {…} }, { seq: 2, … }, …notes → { id: "n1", title: "Groceries", updatedAt: 173… }จากนั้นรัน production build เพื่อยืนยันว่าทุกอย่าง type-check ผ่านและ bundle ได้:
pnpm --filter web build# ✓ built in <time>ตรวจสอบความเข้าใจ:
- ทำไมถึงเก็บทั้ง snapshot ใน
docsและ op ในoutboxแทนที่จะเก็บแค่อย่างใดอย่างหนึ่ง? NoteDoc.from_snapshot(actorId, snapshot)สร้างอะไรขึ้นใหม่ที่new NoteDoc(actorId)สด ๆ จะไม่มี?- ทำไม snapshot write และ outbox append ควรเกิดพร้อมกันสำหรับ edit ครั้งเดียว?
- อะไรใน snapshot ที่เก็บไว้โตแบบไม่มีขอบเขต และเทคนิคใดในภายหลัง (ที่ตั้งชื่อไว้ใน CRDT module) จะจำกัดขอบเขตได้?
ตอนนี้ note คงทนแล้ว: ทุก edit save snapshot ไป docs queue op ไว้ใน outbox และ refresh projection ของ notes; การเปิดซ้ำสร้าง document เดิมขึ้นใหม่เป๊ะ ๆ ด้วย from_snapshot outbox คือ record ที่แม่นยำของสิ่งที่เรายังติดค้าง network — แต่ยังไม่มีอะไรอ่าน และ app ยังต้องมี server ที่ live ถึงจะทำงานได้ ต่อไป Service Worker (Offline) → ทำให้ app shell เองโหลดได้โดยไม่มี network เพื่อให้ OfflineNotes รัน offline ได้จริง