import { api } from "@/client/lib/api"; import { MAX_PAGE_MENTION_BATCH } from "@/shared/constants"; import type { ResolvedPageMentionItem } from "@/shared/types"; import type { PageMentionCacheMode, PageMentionCachedPage } from "./types"; export type MentionEntryStatus = "pending" | "resolved"; export type MentionEntrySource = "server" | "cache" | null; export interface MentionEntry { status: MentionEntryStatus; source: MentionEntrySource; accessible: boolean; title: string | null; icon: string | null; } type Listener = () => void; interface ResolverOpts { workspaceId: string; shareToken: string | undefined; environment?: PageMentionResolverEnvironment; } export interface PageMentionResolverEnvironment { cacheMode: PageMentionCacheMode; networkEnabled: boolean; lookupCachedPage?: (pageId: string) => PageMentionCachedPage | null; } export interface PageMentionResolver { get(pageId: string | null): MentionEntry; request(pageId: string | null): void; subscribe(pageId: string | null, listener: Listener): () => void; setEnvironment(environment: PageMentionResolverEnvironment): void; dispose(): void; } const PENDING: MentionEntry = { status: "pending", source: null, accessible: false, title: null, icon: null }; const DEFAULT_ENVIRONMENT: PageMentionResolverEnvironment = { cacheMode: "live", networkEnabled: true, }; export function createPageMentionResolver(opts: ResolverOpts): PageMentionResolver { const entries = new Map(); const listeners = new Map>(); const pendingQueue = new Set(); const inflight = new Set(); let flushScheduled = false; let isFlushing = false; let epoch = 0; let disposed = false; let environment = opts.environment ?? DEFAULT_ENVIRONMENT; function canUseCache() { return environment.cacheMode === "cache"; } function canResolveNetwork() { return environment.networkEnabled; } function notify(pageId: string) { const set = listeners.get(pageId); if (!set) return; for (const fn of set) fn(); } function setEntry(pageId: string, entry: MentionEntry) { if (disposed) return; entries.set(pageId, entry); notify(pageId); } function getCachedEntry(pageId: string): MentionEntry | null { if (!canUseCache()) return null; const cached = environment.lookupCachedPage?.(pageId); if (!cached) return null; return { status: "resolved", source: "cache", accessible: true, title: cached.title, icon: cached.icon, }; } function takeNextBatch() { const batch: string[] = []; for (const id of pendingQueue) { if (inflight.has(id)) continue; batch.push(id); if (batch.length >= MAX_PAGE_MENTION_BATCH) break; } for (const id of batch) { pendingQueue.delete(id); } return batch; } function scheduleFlush() { if (disposed) return; if (!canResolveNetwork()) return; if (isFlushing) { flushScheduled = true; return; } if (flushScheduled) return; flushScheduled = true; queueMicrotask(() => { flushScheduled = false; void flush(); }); } // Batch-resolve failure policy: on any error we resolve the batch's entries // as inaccessible (title/icon hidden, chip shows restricted state). No // auto-retry — stale or transient failures recover on the next natural // request() trigger (mention re-render, cache-mode flip, resolver remount). async function flush() { if (disposed || isFlushing) return; isFlushing = true; try { while (true) { const batch = takeNextBatch(); if (batch.length === 0) break; const batchEpoch = epoch; for (const id of batch) inflight.add(id); try { const response = await api.pageMentions.resolve(opts.workspaceId, batch, opts.shareToken); if (disposed || batchEpoch !== epoch) { break; } const byId = new Map(); for (const item of response.mentions) byId.set(item.page_id, item); for (const id of batch) { const item = byId.get(id); if (!item) { setEntry(id, { status: "resolved", source: "server", accessible: false, title: null, icon: null }); } else { setEntry(id, { status: "resolved", source: "server", accessible: item.accessible, title: item.title, icon: item.icon, }); } } } catch { if (disposed || batchEpoch !== epoch) { break; } // Preserve a usable cache label on transport failure when the // resolver is in cache mode; otherwise the chip collapses to // restricted. Live-mode or no-cache falls through to restricted. for (const id of batch) { const cachedEntry = getCachedEntry(id); if (cachedEntry) { setEntry(id, cachedEntry); } else { setEntry(id, { status: "resolved", source: "server", accessible: false, title: null, icon: null }); } } break; } finally { for (const id of batch) inflight.delete(id); } } } finally { isFlushing = false; if (!disposed && (flushScheduled || pendingQueue.size > 0)) { flushScheduled = false; queueMicrotask(() => { void flush(); }); } } } function syncEnvironment() { if (disposed) return; let shouldFlush = false; for (const [pageId, entry] of entries) { if (entry.source === "server") continue; const cachedEntry = getCachedEntry(pageId); if (cachedEntry) { if ( entry.source !== "cache" || entry.title !== cachedEntry.title || entry.icon !== cachedEntry.icon || entry.accessible !== cachedEntry.accessible ) { setEntry(pageId, cachedEntry); } } if (canResolveNetwork() && !inflight.has(pageId)) { shouldFlush = true; pendingQueue.add(pageId); } } if (shouldFlush) { scheduleFlush(); } } return { get(pageId) { if (!pageId) return PENDING; return entries.get(pageId) ?? PENDING; }, request(pageId) { if (!pageId || disposed) return; const existing = entries.get(pageId); const cachedEntry = getCachedEntry(pageId); if (!existing) { if (cachedEntry) { setEntry(pageId, cachedEntry); } else { entries.set(pageId, PENDING); } } else if (existing.source !== "server" && cachedEntry) { setEntry(pageId, cachedEntry); } if (!canResolveNetwork()) return; if (inflight.has(pageId)) return; pendingQueue.add(pageId); scheduleFlush(); }, subscribe(pageId, listener) { if (!pageId || disposed) return () => {}; let set = listeners.get(pageId); if (!set) { set = new Set(); listeners.set(pageId, set); } set.add(listener); return () => { set?.delete(listener); if (set && set.size === 0) listeners.delete(pageId); }; }, setEnvironment(nextEnvironment) { if (disposed) return; environment = nextEnvironment; syncEnvironment(); }, dispose() { if (disposed) return; disposed = true; epoch += 1; pendingQueue.clear(); inflight.clear(); isFlushing = false; flushScheduled = false; listeners.clear(); }, }; }