The Op Log
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”server apps/sync — backend ทั้งหมดของ OfflineNotes บางอย่างตั้งใจ: เก็บและ relay CRDT op ของแต่ละ note แล้วไม่ทำอะไรอย่างอื่นเลย ไม่เคย merge, ไม่เคย validate เนื้อหา note, ไม่เคยเป็นเจ้าของความจริง client ยังคงเป็นแหล่งความจริง (ดู Wiring the WASM Core →); server ตัวนี้เป็นแค่ตู้จดหมายที่ device หย่อน op ลงไปและหยิบ op ออกมา
ในบทนี้เราตั้ง Hono app บน port 8787 พร้อม CORS และสร้าง op log ที่ server relay: สำหรับแต่ละ note คือ list ของ op แบบ append-only ที่แต่ละตัวถูกประทับด้วย server sequence number ที่เพิ่มขึ้นทางเดียว (seq) บทถัดไปเราจะแขวน push และ pull endpoint ไว้บน log นี้
CRDT converge ไม่ว่าจะเห็น op ใน ลำดับ ไหนก็ตาม — งานของ server จึงหดลงเหลือแทบไม่มี server ไม่ต้องเข้าใจ op; แค่ต้องเก็บทุก op และส่งให้แต่ละ device เฉพาะตัวที่ยังไม่เห็น “ยังไม่เห็น” นั้นคือ bookkeeping ชิ้นเดียวที่ server เป็นเจ้าของ: sequence number ต่อ note แต่ละ op ที่ append เข้ามาจะได้ seq ตัวถัดไป; device pull ด้วย ?since=<seq ตัวสุดท้ายที่เห็น>
การเก็บ op ให้ opaque บน server คือหัวใจ server เก็บ { seq, op } โดยไม่เคยตรวจดู op เลย นั่นคือสิ่งที่ทำให้ server ตัวเดียวกัน relay การเปลี่ยน title, การ insert text, หรือการ delete ได้โดยไม่มี merge logic สักบรรทัด — และเป็นสิ่งที่ทำให้เราสลับไปเป็น Cloudflare KV, Durable Object, หรือ peer-to-peer transport ทีหลังได้โดยไม่แตะ data model ของ client
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”in-memory Map เทียบกับ database
- Pros: setup เป็นศูนย์ อ่านง่ายมากตอนเรียนรู้ และทำให้ protocol เป็นพระเอกแทน storage log เป็นแค่
Map<noteId, {seq, op}[]> - Cons: op ทุกตัวหายตอน restart และ scale ไม่ได้เกินหนึ่ง process เรายอมรับข้อนี้สำหรับ dev แล้วเปลี่ยนไปใช้ durable store ใน Deployment →
relay แบบบาง เทียบกับ server ที่ merge
- Pros: server ไม่เคยมี merge bug เพราะไม่เคย merge จึงคงถูกต้องต่อไปขณะที่ CRDT โตขึ้น และ transport ใดที่รักษา op ไว้ก็ใช้ได้
- Cons: server ตอบไม่ได้ว่า “note นี้พูดว่าอะไร?” — มีแค่ client พร้อม WASM engine เท่านั้นที่ตอบได้ op ยังสะสมโดยไม่มี compaction (ข้อควรระวังเรื่อง tombstone โตจาก CRDT module ใช้กับ log ด้วย)
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. apps/sync/package.json
หัวข้อที่มีชื่อว่า “1. apps/sync/package.json”{ "name": "sync", "private": true, "type": "module", "scripts": { "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts" }, "dependencies": { "hono": "^4.6.0", "@hono/node-server": "^1.13.0" }, "devDependencies": { "tsx": "^4.19.0", "typescript": "^5.6.0" }}2. apps/sync/src/log.ts
หัวข้อที่มีชื่อว่า “2. apps/sync/src/log.ts”ตัว store op คือสิ่งที่ opaque ต่อ server — เก็บและ relay เท่านั้น ไม่เคย merge — เราจึง type เป็น unknown และไม่เคยมองข้างใน
// An op is opaque to the server: it stores and relays, never inspects.export type Op = unknown;
export interface StoredOp { seq: number; op: Op;}
// noteId -> append-only log. Dev-only: in-memory, lost on restart.const logs = new Map<string, StoredOp[]>();
// Append each op with the next server seq. Returns the new head.export function append(noteId: string, ops: Op[]): number { const log = logs.get(noteId) ?? []; for (const op of ops) { log.push({ seq: log.length + 1, op }); } logs.set(noteId, log); return head(noteId);}
// Every op stored after `seq` (what a device hasn't pulled yet).export function since(noteId: string, seq: number): StoredOp[] { const log = logs.get(noteId) ?? []; return log.filter((entry) => entry.seq > seq);}
// The highest seq assigned for this note (0 if none).export function head(noteId: string): number { return logs.get(noteId)?.length ?? 0;}เพราะ log เป็น append-only seq จึงเป็นแค่ตำแหน่งแบบเริ่มนับ 1 และ head คือความยาว ไม่มีช่องว่าง เพิ่มขึ้นทางเดียวเสมอ — ตรงกับที่ pull cursor ต้องการพอดี
3. apps/sync/src/index.ts
หัวข้อที่มีชื่อว่า “3. apps/sync/src/index.ts”Hono app: CORS บน sync route, health check, และ Node server บน 8787 push/pull route จะมาลงที่นี่บทถัดไป
import { Hono } from 'hono';import { cors } from 'hono/cors';import { serve } from '@hono/node-server';
const app = new Hono();
// The PWA is served from another origin (dev 4321, prod the Pages domain),// so the browser sends cross-origin requests to this server. Allow them.app.use('/docs/*', cors());
app.get('/health', (c) => c.json({ ok: true }));
const port = 8787;serve({ fetch: app.fetch, port }, (info) => { console.log(`sync server on http://localhost:${info.port}`);});
export default app;CORS สำคัญตรงนี้: Service Worker ปฏิบัติต่อ sync API แบบ network-only (ดู Service Worker (Offline) →) และ fetch เหล่านั้นมาจาก origin ที่ต่างจาก 8787 ดังนั้นถ้าไม่มี cors() browser จะ block response
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”install แล้ว start server จาก workspace root:
pnpm installpnpm --filter sync devคาดหวัง — server ประกาศ port ของตัวเอง:
sync server on http://localhost:8787ยิงไปที่ health route:
curl -s http://localhost:8787/healthคาดหวัง:
{"ok":true}ยืนยันว่า CORS ทำงานอยู่บน doc route ด้วย preflight ควร return 204 พร้อม allow-origin header:
curl -s -i -X OPTIONS http://localhost:8787/docs/n1/ops \ -H 'Origin: http://localhost:4321' \ -H 'Access-Control-Request-Method: POST' | grep -i 'access-control-allow-origin'คาดหวัง:
access-control-allow-origin: *Run check — ปล่อย pnpm --filter sync dev ให้รันไว้; tsx watch ควรรายงานว่าไม่มี type error และ reload สะอาดเมื่อ save server ยังไม่มี endpoint เลย ดังนั้น curl http://localhost:8787/docs/n1/ops จะ return 404 ในตอนนี้ — เราจะเพิ่มในบทถัดไป
ตรวจสอบความเข้าใจ:
- ทำไม server ถึง type op เป็น
unknownได้และยัง relay ได้อย่างถูกต้อง? seqบนแต่ละ op ที่เก็บไว้ให้ device ทำอะไรได้ที่ list ธรรมดาทำไม่ได้?- ทำไม
head(noteId)ถึงเท่ากับความยาวของ log ในดีไซน์นี้? - ทำไม server ถึงต้องใช้ CORS ในเมื่อ CRDT engine และ IndexedDB ไม่เคยแตะ network เลย?
เราสร้าง op log และ Hono shell แล้ว: list แบบ append-only ต่อ note ที่แต่ละ op ได้ server seq ที่เพิ่มขึ้นทางเดียว เก็บไว้ใน in-memory Map เสิร์ฟบน port 8787 พร้อม CORS server ยังคงเป็น relay แบบบาง — เก็บและส่ง op ที่ opaque กลับมาโดยไม่เคย merge
ต่อไป เรา expose log ผ่าน HTTP: Push & Pull Endpoints →