/** * Pure page-realtime frame handling (the overlay's fold logic). * * Extracted from `usePageRealtime` so the frame → overlay actions mapping * (including the TASK-0092 (b) `block:changed` refetch-hint) is unit-testable * without a DOM, a socket, or the store. The hook wires the actions to the * `block-patch-store` + TanStack Query; this file is domain logic only * (AGENTS rule 23 — keep domain logic independent from the UI). * * Semantics (the overlay is REMOTE-only — the local save path never writes * into it, so none of these can clobber the local user's edits): * - `presence` → upsert ONE peer's state (the per-peer TASK-0093 * frame — the page roster is the fold of every `presence` frame seen * since subscribing, minus the `presence-leave` frames). * - `presence-leave`→ drop that peer (explicit leave / socket close / * the server's 90 s prune). * - `block:upsert` → merge remote blocks into the overlay (no refetch). * - `block:delete` → mark remote-deleted ids (no refetch). * - `block:move` → patch the moved block's parent/position + refetch * (a move reorders siblings — the refetch is the authoritative state). * - `block:changed` → the page's blocks changed (a REST write or a flush) * → DROP the block overlay (keep presence) + refetch. This is the * refetch-on-hint of (b): lossless (re-reads the DB, the source of truth * at rest, with correct timestamps) and it prevents the refetched server * blocks from duplicating stale overlay `upserts`. * - `comment.event` / `notification.push` / `ack` → not block-overlay * frames (the comment panel + the notification bell own those; `ack` is * a no-op). */ import type { Block, BlockPatch, Presence, RealtimeServerEvent } from "@/lib/types"; /** The overlay actions a frame handler may trigger (the hook implements * these against the store + query client). */ export interface PageRealtimeActions { upsert: (pageId: string, blocks: Block[]) => void; delete: (pageId: string, blockIds: string[]) => void; patch: (pageId: string, blockId: string, p: BlockPatch) => void; /** Apply ONE peer's state (a `presence` frame). */ upsertPresence: (pageId: string, peer: Presence) => void; /** Drop a peer (a `presence-leave` frame). */ removePresence: (pageId: string, userId: string) => void; /** Drop the BLOCK overlay for a page (keeps presence). */ clearBlocks: (pageId: string) => void; /** Invalidate the page's blocks query (a refetch re-reads the server). */ invalidateBlocks: (pageId: string) => void; } /** * The presence frames' `pageId` is the server's ROOM KEY (`page:` / * `database:` — slice 2's fan-out, asserted by the integration tests). * The store keys presence by the BARE id (pageId / databaseId), so strip * the prefix. (Block frames already carry the bare pageId.) */ export function stripRoomKey(roomKey: string): string { return roomKey.startsWith("page:") ? roomKey.slice("page:".length) : roomKey.startsWith("database:") ? roomKey.slice("database:".length) : roomKey; } /** Fold one server→client page frame into the overlay actions. */ export function handlePageRealtimeFrame( event: RealtimeServerEvent, actions: PageRealtimeActions, ): void { switch (event.type) { case "presence": // The per-peer frame → the web's Presence shape (the server stamps // `user`; `cursor` is null when cleared or in avatars-only mode). actions.upsertPresence(stripRoomKey(event.pageId), { userId: event.user.id, name: event.user.name, color: event.user.color, cursor: event.cursor ?? null, viewing: event.viewing, lastSeen: event.lastSeen, }); return; case "presence-leave": actions.removePresence(stripRoomKey(event.pageId), event.userId); return; case "block:upsert": actions.upsert(event.pageId, event.blocks); return; case "block:delete": actions.delete(event.pageId, event.blockIds); return; case "block:move": actions.patch(event.pageId, event.blockId, { parentId: event.parentId, position: event.position, }); // A move can reorder siblings across parents — refetch both sides. actions.invalidateBlocks(event.pageId); return; case "block:changed": // (b) refetch-on-hint: the page changed (REST write or flush). Drop // the overlay (keeps presence) and refetch — the refetch is // authoritative and lossless (correct timestamps). actions.clearBlocks(event.pageId); actions.invalidateBlocks(event.pageId); return; case "comment.event": case "notification.push": case "ack": // Not block-overlay frames — handled elsewhere (or a no-op). return; } }