Skip to content
File

Blob: src/client/components/page-mention/resolver.ts

typescript274 lines
1import { api } from "@/client/lib/api";
2import { MAX_PAGE_MENTION_BATCH } from "@/shared/constants";
3import type { ResolvedPageMentionItem } from "@/shared/types";
4import type { PageMentionCacheMode, PageMentionCachedPage } from "./types";
5 
6export type MentionEntryStatus = "pending" | "resolved";
7export type MentionEntrySource = "server" | "cache" | null;
8 
9export interface MentionEntry {
10 status: MentionEntryStatus;
11 source: MentionEntrySource;
12 accessible: boolean;
13 title: string | null;
14 icon: string | null;
15}
16 
17type Listener = () => void;
18 
19interface ResolverOpts {
20 workspaceId: string;
21 shareToken: string | undefined;
22 environment?: PageMentionResolverEnvironment;
23}
24 
25export interface PageMentionResolverEnvironment {
26 cacheMode: PageMentionCacheMode;
27 networkEnabled: boolean;
28 lookupCachedPage?: (pageId: string) => PageMentionCachedPage | null;
29}
30 
31export 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 
39const PENDING: MentionEntry = { status: "pending", source: null, accessible: false, title: null, icon: null };
40const DEFAULT_ENVIRONMENT: PageMentionResolverEnvironment = {
41 cacheMode: "live",
42 networkEnabled: true,
43};
44 
45export 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}