--- paths: - "hooks/**" - "app/api/**" - "lib/annotate-events.ts" --- # Realtime Rules (รายละเอียด: docs/PLAN.md §4) - Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/annotations/stream?fileId=")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount - `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง** - Event payload เป็น **DTO (camelCase + ISO string) จาก server ตรง ๆ** — ไม่มีการแปลง snake_case - Server Actions ต้อง **publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จทุกครั้ง** (create/update/delete ของ annotations, create/delete ของ replies, create/**update**/delete ของ bookmarks — update ของ bookmarks ใช้กับแก้ชื่อ/โน้ต/ย้ายหัวข้อ และลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ, client replace by id) — ลืม publish = client อื่นไม่เห็น; คู่กันในจุดเดียวกันต้อง **`revalidatePath` หน้า viewer+summary** (`revalidateFilePages`) มิฉะนั้นกลับเข้าหน้าใหม่ (back → คลิกไฟล์) ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - Event `update` (annotations): client **replace ตาม id** — idempotent (มาก่อน/หลัง optimistic ก็ได้ผลเดียวกัน) ห้ามเพิ่ม logic กัน duplicate เกินกว่า replace ด้วย id (PLAN-FEAT.md §5) — update row ของ F10 (`setAnnotationResolved`) ใช้ channel เดียวกันนี้ (row เต็มมี `resolved`/`resolvedAt`) - Event ของ `replies` (F9): create → append + dedupe ด้วย id, delete → filter ตาม id — และเมื่อ annotation แม่ถูกลบ **ต้องเคลียร์ replies ของแม่ใน state ฝั่ง client ด้วย** (CASCADE ใน DB ไม่ส่ง event ราย reply) - `restoreVersion` (F19) ก็ publish **create** event ต่อรายการที่ insert สำเร็จเหมือน action เขียนอื่น ๆ — แท็บของผู้กดเองก็ได้ event ผ่าน SSE ของตัวเอง จึงไม่ต้อง sync state ซ้ำใน client - SSE route: ต้อง cleanup (unsubscribe + close) เมื่อ client ตัดการเชื่อมต่อ (`req.signal` abort / `cancel`) - Dedupe ด้วย id: event ที่ id มีอยู่ใน state แล้ว ไม่ต้อง append ซ้ำ — ทำงานคู่กับ `useOptimistic` ใน PDFViewer: SSE event อาจมาถึงก่อน action เสร็จ — `createAnnotation` รับ `id` ที่ client สร้างของ optimistic row มาใช้ซ้ำ (validate เป็น UUID) เพื่อให้ dedupe ด้วย id เดียวกันตั้งแต่แรก **ไม่เกิด duplicate ชั่วขณะ** (row โผล่แล้วหาย) — **อย่าเพิ่ม logic กัน duplicate เกินกว่า dedupe ด้วย id** - pub/sub เป็น in-memory ของ process เดียว — ถ้ามีวันที่ต้องรันหลาย instance ให้เพิ่มตัวกลาง (Redis/LISTEN-NOTIFY) ก่อน อย่าแอบ sync ผ่าน DB polling - **Map ของ listener ใน `lib/annotate-events.ts` ต้องเก็บบน `globalThis`** — module-level state จะถูก duplicate เป็น 2 อินสแตนซ์ใน Next dev (bundle ของ "use server" กับ route handler แยกกัน) แล้ว event ไม่ไหลถึงกัน — **ห้ามเปลี่ยนกลับเป็น module-level Map**