Push & Pull Endpoints
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”สอง route บน Hono server จากบทที่แล้ว และ wire format ที่สอง route นี้พูด:
POST /docs/:id/ops— body{ ops: Op[] }→ append แต่ละ op ด้วย serverseqตัวใหม่ แล้ว return{ head }GET /docs/:id/ops?since=<seq>— return{ ops: [{ seq, op }], head }, ทุก op ที่เก็บไว้ หลังsince
นั่นคือ sync protocol ทั้งหมด ฝั่ง client — การ drain outbox เข้า POST และป้อน GET response ผ่าน CRDT — คือ module ถัดไป Push & Pull Sync →
สอง shape นี้ asymmetric อย่างตั้งใจ ตอน push client ถือ op อยู่แล้ว (op ออกมาจาก WASM engine และนั่งอยู่ใน outbox) จึงส่ง op เปล่า ๆ และแค่ต้องรู้ว่า server รับไปแล้ว — reply จึงเป็นแค่ { head }, ปลายใหม่ของ log ตอน pull client ต้องการทั้ง op และ cursor เพื่อจำว่าหยุดตรงไหน แต่ละ op จึงถูกห่อมาเป็น { seq, op } และ response ทวน head ซ้ำ device เก็บ seq ตัวมากที่สุดที่เห็นแล้วส่งกลับเป็น ?since= ครั้งถัดไป
since เป็นแบบ exclusive: ?since=5 return op ที่ seq > 5 device ที่เพิ่งเกิดใหม่ pull ด้วย ?since=0 แล้วได้ประวัติทั้งหมด นี่คือสิ่งที่ทำให้ pull resumable และ idempotent — pull สองครั้งด้วย cursor เดียวกันแล้วการเรียกครั้งที่สอง return op ชุดเดิม (หรือไม่มีเลย) และเพราะ CRDT merge เป็น idempotent การ re-merge จึงไม่เปลี่ยนอะไร
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”pull ด้วย seq-cursor เทียบกับการส่ง timestamp
- Pros: server
seqที่เพิ่มขึ้นทางเดียวไม่กำกวมและไม่มีช่องว่าง ดังนั้น “ทุกอย่างหลัง N” จึงเป็น filter ที่เรียบง่ายและถูกต้อง ไม่มี clock skew ระหว่าง device ให้ต้องคิด - Cons: cursor เป็นแบบต่อ note และต่อ server; ชี้ client ไปที่ server ใหม่แล้วต้อง re-pull ตั้งแต่
0ยอมรับได้สำหรับ relay ที่ store ทิ้งได้
return { seq, op } เทียบกับ return op เปล่าตอน pull
- Pros: client ได้ cursor มาแนบกับ data เลย จึงไม่ต้องเดาว่าหยุดตรงไหน — แค่ track
seqตัวมากที่สุด - Cons: เปลืองไม่กี่ byte ต่อ op เทียบกับ array เปล่าของ push เล็กน้อยเมื่อเทียบกับ payload ของ op และกำจัด bug ทั้งชนิด “ฉันพลาดไปตัวหนึ่งหรือเปล่า?” ออกไป
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. apps/sync/src/index.ts
หัวข้อที่มีชื่อว่า “1. apps/sync/src/index.ts”เพิ่มสอง route เข้า app เหนือ serve(...) ทั้งคู่พึ่ง append / since / head จาก log.ts ทั้งหมด — handler ไม่มี logic ของตัวเองเลย
import { Hono } from 'hono';import { cors } from 'hono/cors';import { serve } from '@hono/node-server';import { append, since, head, type Op } from './log.js';
const app = new Hono();
app.use('/docs/*', cors());
app.get('/health', (c) => c.json({ ok: true }));
// PUSH: append the client's ops, report the new head.app.post('/docs/:id/ops', async (c) => { const id = c.req.param('id'); const { ops } = await c.req.json<{ ops: Op[] }>(); const head = append(id, ops); return c.json({ head });});
// PULL: every op stored after `since`, plus the current head.app.get('/docs/:id/ops', (c) => { const id = c.req.param('id'); const since_ = Number(c.req.query('since') ?? 0); const ops = since(id, since_); return c.json({ ops, head: head(id) });});
const port = 8787;serve({ fetch: app.fetch, port }, (info) => { console.log(`sync server on http://localhost:${info.port}`);});
export default app;สังเกต c.req.param('id') สำหรับ path segment, c.req.query('since') สำหรับ query string (coerce ด้วย Number โดย default เป็น 0) และ await c.req.json<...>() สำหรับ body server ไม่เคยตรวจ op — แค่ส่ง array ops ทั้งก้อนให้ append โดยไม่แตะ
2. The wire format (the contract both sides obey)
หัวข้อที่มีชื่อว่า “2. The wire format (the contract both sides obey)”op หนึ่งตัวคือหนึ่งใน CRDT op ที่ NoteDoc ผลิตใน Wiring the WASM Core → — server relay พวกนี้ตามเดิมทุกตัวอักษร:
// a title change (LWW register){ "t": "title", "value": "Groceries", "ts": [7, "actor-a"] }// a text insert (RGA sequence){ "t": "ins", "id": [8, "actor-a"], "after": [7, "actor-a"], "ch": "H" }// a text delete (tombstone){ "t": "del", "id": [8, "actor-a"] }envelope ของ push และ pull:
// POST /docs/:id/ops — request body{ "ops": [ { "t": "ins", "id": [8, "actor-a"], "after": null, "ch": "H" } ] }// POST /docs/:id/ops — response{ "head": 12 }
// GET /docs/:id/ops?since=5 — response{ "ops": [ { "seq": 6, "op": { "t": "ins", "id": [8, "actor-a"], "after": null, "ch": "H" } } ], "head": 12 }ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”โดยที่ pnpm --filter sync dev รันอยู่ push สอง op ไปที่ note n1:
curl -s -X POST http://localhost:8787/docs/n1/ops \ -H 'content-type: application/json' \ -d '{"ops":[{"t":"title","value":"Groceries","ts":[1,"a"]},{"t":"ins","id":[2,"a"],"after":null,"ch":"H"}]}'คาดหวัง — head เลื่อนไปเป็น 2:
{"head":2}pull ทุกอย่างตั้งแต่ต้น:
curl -s 'http://localhost:8787/docs/n1/ops?since=0'คาดหวัง — ทั้งสอง op แต่ละตัวมี seq ของตัวเอง:
{"ops":[{"seq":1,"op":{"t":"title","value":"Groceries","ts":[1,"a"]}},{"seq":2,"op":{"t":"ins","id":[2,"a"],"after":null,"ch":"H"}}],"head":2}ตอนนี้ pull ด้วย cursor ที่อยู่ตรง head — device ที่ทันสมัยแล้วจะไม่ได้อะไรใหม่:
curl -s 'http://localhost:8787/docs/n1/ops?since=2'คาดหวัง:
{"ops":[],"head":2}Run check — การเรียก since=2 ที่ return array ops ว่างพร้อม head:2 พิสูจน์คณิตของ cursor: since เป็น exclusive และ device ตามทันแล้ว ปล่อย pnpm --filter sync dev ให้รันไว้โดยไม่มี type error ก่อนไปต่อ
ตรวจสอบความเข้าใจ:
- ทำไม push ถึง return แค่
{ head }ส่วน pull ห่อแต่ละ op เป็น{ seq, op }? ?since=0return อะไร และเมื่อไรที่ device จะส่งค่านี้?sinceเป็น exclusive อะไรจะพังถ้า device เก็บ cursor ผิดแล้ว re-pull op ที่มีอยู่แล้ว — และทำไม CRDT ถึงทำให้เรื่องนั้นไม่มีผลร้าย?- POST handler ไม่เคยมองเข้าไปใน op นั่นให้อะไรกับ server ขณะที่ CRDT โตขึ้น?
ตอนนี้ server พูด protocol เต็มรูปแบบแล้ว: POST /docs/:id/ops append แล้ว return head ตัวใหม่; GET /docs/:id/ops?since=<seq> return { ops: [{ seq, op }], head } สำหรับทุกอย่างหลัง cursor handler เป็น wrapper บาง ๆ ครอบ op log และทั้งสองฝั่งตกลงกันบน wire format เดียว
ฝั่ง server ของ sync เสร็จแล้ว ต่อไปเราสร้างฝั่ง client: Push & Pull Sync →