File
Blob: src/client/components/page-mention/resolver.ts
| 1 | import { api } from "@/client/lib/api"; |
| 2 | import { MAX_PAGE_MENTION_BATCH } from "@/shared/constants"; |
| 3 | import type { ResolvedPageMentionItem } from "@/shared/types"; |
| 4 | import type { PageMentionCacheMode, PageMentionCachedPage } from "./types"; |
| 5 | |
| 6 | export type MentionEntryStatus = "pending" | "resolved"; |
| 7 | export type MentionEntrySource = "server" | "cache" | null; |
| 8 | |
| 9 | export interface MentionEntry { |
| 10 | status: MentionEntryStatus; |
| 11 | source: MentionEntrySource; |
| 12 | accessible: boolean; |
| 13 | title: string | null; |
| 14 | icon: string | null; |
| 15 | } |
| 16 | |
| 17 | type Listener = () => void; |
| 18 | |
| 19 | interface ResolverOpts { |
| 20 | workspaceId: string; |
| 21 | shareToken: string | undefined; |
| 22 | environment?: PageMentionResolverEnvironment; |
| 23 | } |
| 24 | |
| 25 | export interface PageMentionResolverEnvironment { |
| 26 | cacheMode: PageMentionCacheMode; |
| 27 | networkEnabled: boolean; |
| 28 | lookupCachedPage?: (pageId: string) => PageMentionCachedPage | null; |
| 29 | } |
| 30 | |
| 31 | export interface PageMentionResolver { |
| 32 | get(pageId: string | null): MentionEntry; |
| 33 | request(pageId: string | null): void; |
| 34 | subscribe(pageId: string | null, listener: Listener): () => void; |
| 35 | setEnvironment(environment: PageMentionResolverEnvironment): void; |
| 36 | dispose(): void; |
| 37 | } |
| 38 | |
| 39 | const PENDING: MentionEntry = { status: "pending", source: null, accessible: false, title: null, icon: null }; |
| 40 | const DEFAULT_ENVIRONMENT: PageMentionResolverEnvironment = { |
| 41 | cacheMode: "live", |
| 42 | networkEnabled: true, |
| 43 | }; |
| 44 | |
| 45 | export function createPageMentionResolver(opts: ResolverOpts): PageMentionResolver { |
| 46 | const entries = new Map<string, MentionEntry>(); |
| 47 | const listeners = new Map<string, Set<Listener>>(); |
| 48 | const pendingQueue = new Set<string>(); |
| 49 | const inflight = new Set<string>(); |
| 50 | let flushScheduled = false; |
| 51 | let isFlushing = false; |
| 52 | let epoch = 0; |
| 53 | let disposed = false; |
| 54 | let environment = opts.environment ?? DEFAULT_ENVIRONMENT; |
| 55 | |
| 56 | function canUseCache() { |
| 57 | return environment.cacheMode === "cache"; |
| 58 | } |
| 59 | |
| 60 | function canResolveNetwork() { |
| 61 | return environment.networkEnabled; |
| 62 | } |
| 63 | |
| 64 | function notify(pageId: string) { |
| 65 | const set = listeners.get(pageId); |
| 66 | if (!set) return; |
| 67 | for (const fn of set) fn(); |
| 68 | } |
| 69 | |
| 70 | function setEntry(pageId: string, entry: MentionEntry) { |
| 71 | if (disposed) return; |
| 72 | entries.set(pageId, entry); |
| 73 | notify(pageId); |
| 74 | } |
| 75 | |
| 76 | function getCachedEntry(pageId: string): MentionEntry | null { |
| 77 | if (!canUseCache()) return null; |
| 78 | |
| 79 | const cached = environment.lookupCachedPage?.(pageId); |
| 80 | if (!cached) return null; |
| 81 | |
| 82 | return { |
| 83 | status: "resolved", |
| 84 | source: "cache", |
| 85 | accessible: true, |
| 86 | title: cached.title, |
| 87 | icon: cached.icon, |
| 88 | }; |
| 89 | } |
| 90 | |
| 91 | function takeNextBatch() { |
| 92 | const batch: string[] = []; |
| 93 | for (const id of pendingQueue) { |
| 94 | if (inflight.has(id)) continue; |
| 95 | batch.push(id); |
| 96 | if (batch.length >= MAX_PAGE_MENTION_BATCH) break; |
| 97 | } |
| 98 | for (const id of batch) { |
| 99 | pendingQueue.delete(id); |
| 100 | } |
| 101 | return batch; |
| 102 | } |
| 103 | |
| 104 | function scheduleFlush() { |
| 105 | if (disposed) return; |
| 106 | if (!canResolveNetwork()) return; |
| 107 | if (isFlushing) { |
| 108 | flushScheduled = true; |
| 109 | return; |
| 110 | } |
| 111 | if (flushScheduled) return; |
| 112 | flushScheduled = true; |
| 113 | queueMicrotask(() => { |
| 114 | flushScheduled = false; |
| 115 | void flush(); |
| 116 | }); |
| 117 | } |
| 118 | |
| 119 | // Batch-resolve failure policy: on any error we resolve the batch's entries |
| 120 | // as inaccessible (title/icon hidden, chip shows restricted state). No |
| 121 | // auto-retry — stale or transient failures recover on the next natural |
| 122 | // request() trigger (mention re-render, cache-mode flip, resolver remount). |
| 123 | async function flush() { |
| 124 | if (disposed || isFlushing) return; |
| 125 | isFlushing = true; |
| 126 | try { |
| 127 | while (true) { |
| 128 | const batch = takeNextBatch(); |
| 129 | if (batch.length === 0) break; |
| 130 | |
| 131 | const batchEpoch = epoch; |
| 132 | for (const id of batch) inflight.add(id); |
| 133 | |
| 134 | try { |
| 135 | const response = await api.pageMentions.resolve(opts.workspaceId, batch, opts.shareToken); |
| 136 | if (disposed || batchEpoch !== epoch) { |
| 137 | break; |
| 138 | } |
| 139 | |
| 140 | const byId = new Map<string, ResolvedPageMentionItem>(); |
| 141 | for (const item of response.mentions) byId.set(item.page_id, item); |
| 142 | |
| 143 | for (const id of batch) { |
| 144 | const item = byId.get(id); |
| 145 | if (!item) { |
| 146 | setEntry(id, { status: "resolved", source: "server", accessible: false, title: null, icon: null }); |
| 147 | } else { |
| 148 | setEntry(id, { |
| 149 | status: "resolved", |
| 150 | source: "server", |
| 151 | accessible: item.accessible, |
| 152 | title: item.title, |
| 153 | icon: item.icon, |
| 154 | }); |
| 155 | } |
| 156 | } |
| 157 | } catch { |
| 158 | if (disposed || batchEpoch !== epoch) { |
| 159 | break; |
| 160 | } |
| 161 | // Preserve a usable cache label on transport failure when the |
| 162 | // resolver is in cache mode; otherwise the chip collapses to |
| 163 | // restricted. Live-mode or no-cache falls through to restricted. |
| 164 | for (const id of batch) { |
| 165 | const cachedEntry = getCachedEntry(id); |
| 166 | if (cachedEntry) { |
| 167 | setEntry(id, cachedEntry); |
| 168 | } else { |
| 169 | setEntry(id, { status: "resolved", source: "server", accessible: false, title: null, icon: null }); |
| 170 | } |
| 171 | } |
| 172 | break; |
| 173 | } finally { |
| 174 | for (const id of batch) inflight.delete(id); |
| 175 | } |
| 176 | } |
| 177 | } finally { |
| 178 | isFlushing = false; |
| 179 | if (!disposed && (flushScheduled || pendingQueue.size > 0)) { |
| 180 | flushScheduled = false; |
| 181 | queueMicrotask(() => { |
| 182 | void flush(); |
| 183 | }); |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | function syncEnvironment() { |
| 189 | if (disposed) return; |
| 190 | |
| 191 | let shouldFlush = false; |
| 192 | |
| 193 | for (const [pageId, entry] of entries) { |
| 194 | if (entry.source === "server") continue; |
| 195 | |
| 196 | const cachedEntry = getCachedEntry(pageId); |
| 197 | if (cachedEntry) { |
| 198 | if ( |
| 199 | entry.source !== "cache" || |
| 200 | entry.title !== cachedEntry.title || |
| 201 | entry.icon !== cachedEntry.icon || |
| 202 | entry.accessible !== cachedEntry.accessible |
| 203 | ) { |
| 204 | setEntry(pageId, cachedEntry); |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | if (canResolveNetwork() && !inflight.has(pageId)) { |
| 209 | shouldFlush = true; |
| 210 | pendingQueue.add(pageId); |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | if (shouldFlush) { |
| 215 | scheduleFlush(); |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | return { |
| 220 | get(pageId) { |
| 221 | if (!pageId) return PENDING; |
| 222 | return entries.get(pageId) ?? PENDING; |
| 223 | }, |
| 224 | request(pageId) { |
| 225 | if (!pageId || disposed) return; |
| 226 | const existing = entries.get(pageId); |
| 227 | const cachedEntry = getCachedEntry(pageId); |
| 228 | |
| 229 | if (!existing) { |
| 230 | if (cachedEntry) { |
| 231 | setEntry(pageId, cachedEntry); |
| 232 | } else { |
| 233 | entries.set(pageId, PENDING); |
| 234 | } |
| 235 | } else if (existing.source !== "server" && cachedEntry) { |
| 236 | setEntry(pageId, cachedEntry); |
| 237 | } |
| 238 | |
| 239 | if (!canResolveNetwork()) return; |
| 240 | if (inflight.has(pageId)) return; |
| 241 | pendingQueue.add(pageId); |
| 242 | scheduleFlush(); |
| 243 | }, |
| 244 | subscribe(pageId, listener) { |
| 245 | if (!pageId || disposed) return () => {}; |
| 246 | let set = listeners.get(pageId); |
| 247 | if (!set) { |
| 248 | set = new Set(); |
| 249 | listeners.set(pageId, set); |
| 250 | } |
| 251 | set.add(listener); |
| 252 | return () => { |
| 253 | set?.delete(listener); |
| 254 | if (set && set.size === 0) listeners.delete(pageId); |
| 255 | }; |
| 256 | }, |
| 257 | setEnvironment(nextEnvironment) { |
| 258 | if (disposed) return; |
| 259 | environment = nextEnvironment; |
| 260 | syncEnvironment(); |
| 261 | }, |
| 262 | dispose() { |
| 263 | if (disposed) return; |
| 264 | disposed = true; |
| 265 | epoch += 1; |
| 266 | pendingQueue.clear(); |
| 267 | inflight.clear(); |
| 268 | isFlushing = false; |
| 269 | flushScheduled = false; |
| 270 | listeners.clear(); |
| 271 | }, |
| 272 | }; |
| 273 | } |