"use client"; /** * Realtime block-patch store: last-writer-wins overlay on top of the TanStack * Query block cache, fed by the page's realtime channel (lib/realtime.ts). * Query is the source of truth at rest; this store keeps the editor * sub-200 ms (PRODUCT-SPEC §26) without refetching on every remote edit. */ import { create } from "zustand"; import type { Block, BlockPatch, Presence } from "@/lib/types"; interface BlockPatchState { /** pageId → blockId → pending patch (remote or optimistic). */ patches: Record>; /** pageId → fully-upserted remote blocks (not yet in the query cache). */ upserts: Record>; /** pageId → remote-deleted block ids. */ deleted: Record; /** pageId → userId → current peer state (the fold of the per-peer * `presence` frames seen since subscribing, minus `presence-leave` — * TASK-0093). */ presence: Record>; upsert: (pageId: string, blocks: Block[]) => void; patch: (pageId: string, blockId: string, p: BlockPatch) => void; delete: (pageId: string, blockIds: string[]) => void; /** Apply one peer's state (a `presence` frame). */ upsertPresence: (pageId: string, peer: Presence) => void; /** Drop a peer (a `presence-leave` frame / the client's own prune). */ removePresence: (pageId: string, userId: string) => void; /** Merge all overlay state for a page into a flat block list. */ merged: (pageId: string, base: readonly Block[]) => Block[]; /** Drop the BLOCK overlay for a page (call after a refetch/invalidation). * Unlike `reset`, this keeps the page's PRESENCE (a block change must * not wipe who's online). */ clearBlocks: (pageId: string) => void; /** Drop the overlay for a page (call after a refetch/invalidation). */ reset: (pageId: string) => void; } function applyPatch(block: Block, p: BlockPatch): Block { return { ...block, ...p, properties: { ...block.properties, ...(p.properties ?? {}) } }; } export const useBlockPatchStore = create()((set, get) => ({ patches: {}, upserts: {}, deleted: {}, presence: {}, upsert: (pageId, blocks) => { const cur = get().upserts[pageId] ?? {}; const next: Record = { ...cur }; for (const b of blocks) next[b.id] = b; set({ upserts: { ...get().upserts, [pageId]: next } }); }, patch: (pageId, blockId, p) => { const page = get().patches[pageId] ?? {}; set({ patches: { ...get().patches, [pageId]: { ...page, [blockId]: { ...page[blockId], ...p } } }, }); }, delete: (pageId, blockIds) => { const cur = get().deleted[pageId] ?? []; const page = get().upserts[pageId] ?? {}; const nextPage: Record = { ...page }; for (const id of blockIds) delete nextPage[id]; set({ deleted: { ...get().deleted, [pageId]: [...new Set([...cur, ...blockIds])] }, upserts: { ...get().upserts, [pageId]: nextPage }, }); }, upsertPresence: (pageId, peer) => { const page = get().presence[pageId] ?? {}; set({ presence: { ...get().presence, [pageId]: { ...page, [peer.userId]: peer } } }); }, removePresence: (pageId, userId) => { const page = get().presence[pageId]; if (page === undefined || page[userId] === undefined) return; const next = { ...page }; delete next[userId]; set({ presence: { ...get().presence, [pageId]: next } }); }, merged: (pageId, base) => { const { patches, upserts, deleted } = get(); const removed = new Set(deleted[pageId] ?? []); const pagePatches = patches[pageId] ?? {}; const pageUpserts = upserts[pageId] ?? {}; const out: Block[] = []; for (const b of base) { if (removed.has(b.id)) continue; out.push(pagePatches[b.id] ? applyPatch(b, pagePatches[b.id] as BlockPatch) : b); } for (const b of Object.values(pageUpserts)) { if (removed.has(b.id)) continue; out.push(pagePatches[b.id] ? applyPatch(b, pagePatches[b.id] as BlockPatch) : b); } return out; }, clearBlocks: (pageId) => { set({ patches: { ...get().patches, [pageId]: {} }, upserts: { ...get().upserts, [pageId]: {} }, deleted: { ...get().deleted, [pageId]: [] }, }); }, reset: (pageId) => { set({ patches: { ...get().patches, [pageId]: {} }, upserts: { ...get().upserts, [pageId]: {} }, deleted: { ...get().deleted, [pageId]: [] }, presence: { ...get().presence, [pageId]: {} }, }); }, }));