1/** 2 * Pure page-realtime frame handling (the overlay's fold logic). 3 * 4 * Extracted from `usePageRealtime` so the frame → overlay actions mapping 5 * (including the TASK-0092 (b) `block:changed` refetch-hint) is unit-testable 6 * without a DOM, a socket, or the store. The hook wires the actions to the 7 * `block-patch-store` + TanStack Query; this file is domain logic only 8 * (AGENTS rule 23 — keep domain logic independent from the UI). 9 * 10 * Semantics (the overlay is REMOTE-only — the local save path never writes 11 * into it, so none of these can clobber the local user's edits): 12 * - `presence` → upsert ONE peer's state (the per-peer TASK-0093 13 * frame — the page roster is the fold of every `presence` frame seen 14 * since subscribing, minus the `presence-leave` frames). 15 * - `presence-leave`→ drop that peer (explicit leave / socket close / 16 * the server's 90 s prune). 17 * - `block:upsert` → merge remote blocks into the overlay (no refetch). 18 * - `block:delete` → mark remote-deleted ids (no refetch). 19 * - `block:move` → patch the moved block's parent/position + refetch 20 * (a move reorders siblings — the refetch is the authoritative state). 21 * - `block:changed` → the page's blocks changed (a REST write or a flush) 22 * → DROP the block overlay (keep presence) + refetch. This is the 23 * refetch-on-hint of (b): lossless (re-reads the DB, the source of truth 24 * at rest, with correct timestamps) and it prevents the refetched server 25 * blocks from duplicating stale overlay `upserts`. 26 * - `comment.event` / `notification.push` / `ack` → not block-overlay 27 * frames (the comment panel + the notification bell own those; `ack` is 28 * a no-op). 29 */ 30import type { Block, BlockPatch, Presence, RealtimeServerEvent } from "@/lib/types"; 31 32/** The overlay actions a frame handler may trigger (the hook implements 33 * these against the store + query client). */ 34export interface PageRealtimeActions { 35 upsert: (pageId: string, blocks: Block[]) => void; 36 delete: (pageId: string, blockIds: string[]) => void; 37 patch: (pageId: string, blockId: string, p: BlockPatch) => void; 38 /** Apply ONE peer's state (a `presence` frame). */ 39 upsertPresence: (pageId: string, peer: Presence) => void; 40 /** Drop a peer (a `presence-leave` frame). */ 41 removePresence: (pageId: string, userId: string) => void; 42 /** Drop the BLOCK overlay for a page (keeps presence). */ 43 clearBlocks: (pageId: string) => void; 44 /** Invalidate the page's blocks query (a refetch re-reads the server). */ 45 invalidateBlocks: (pageId: string) => void; 46} 47 48/** 49 * The presence frames' `pageId` is the server's ROOM KEY (`page:<id>` / 50 * `database:<id>` — slice 2's fan-out, asserted by the integration tests). 51 * The store keys presence by the BARE id (pageId / databaseId), so strip 52 * the prefix. (Block frames already carry the bare pageId.) 53 */ 54export function stripRoomKey(roomKey: string): string { 55 return roomKey.startsWith("page:") 56 ? roomKey.slice("page:".length) 57 : roomKey.startsWith("database:") 58 ? roomKey.slice("database:".length) 59 : roomKey; 60} 61 62/** Fold one server→client page frame into the overlay actions. */ 63export function handlePageRealtimeFrame( 64 event: RealtimeServerEvent, 65 actions: PageRealtimeActions, 66): void { 67 switch (event.type) { 68 case "presence": 69 // The per-peer frame → the web's Presence shape (the server stamps 70 // `user`; `cursor` is null when cleared or in avatars-only mode). 71 actions.upsertPresence(stripRoomKey(event.pageId), { 72 userId: event.user.id, 73 name: event.user.name, 74 color: event.user.color, 75 cursor: event.cursor ?? null, 76 viewing: event.viewing, 77 lastSeen: event.lastSeen, 78 }); 79 return; 80 case "presence-leave": 81 actions.removePresence(stripRoomKey(event.pageId), event.userId); 82 return; 83 case "block:upsert": 84 actions.upsert(event.pageId, event.blocks); 85 return; 86 case "block:delete": 87 actions.delete(event.pageId, event.blockIds); 88 return; 89 case "block:move": 90 actions.patch(event.pageId, event.blockId, { 91 parentId: event.parentId, 92 position: event.position, 93 }); 94 // A move can reorder siblings across parents — refetch both sides. 95 actions.invalidateBlocks(event.pageId); 96 return; 97 case "block:changed": 98 // (b) refetch-on-hint: the page changed (REST write or flush). Drop 99 // the overlay (keeps presence) and refetch — the refetch is 100 // authoritative and lossless (correct timestamps). 101 actions.clearBlocks(event.pageId); 102 actions.invalidateBlocks(event.pageId); 103 return; 104 case "comment.event": 105 case "notification.push": 106 case "ack": 107 // Not block-overlay frames — handled elsewhere (or a no-op). 108 return; 109 } 110} 111