1import type { RepositoryIdentity } from "@t3tools/contracts";
2import * as Cache from "effect/Cache";
3import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as PubSub from "effect/PubSub";
11import * as Queue from "effect/Queue";
12import * as Ref from "effect/Ref";
13import type * as Scope from "effect/Scope";
14
15import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
16import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
17
18const DEFAULT_CACHE_CAPACITY = 512;
19const DEFAULT_MAX_PENDING = 512;
20const DEFAULT_CONCURRENCY = 4;
21const DEFAULT_SUCCESS_TTL = Duration.minutes(1);
22// A favicon scan checks about 20 files. Shell reads (and PR sync through them) ask for every
23// linked project about once a minute, and icons rarely change, so a resolved favicon is kept
24// longer than the identity, which is only a hit on RepositoryIdentityResolver's own cache.
25// Moving a project's workspace root, and deleting it, invalidate both at once.
26const DEFAULT_FAVICON_TTL = Duration.minutes(15);
27const DEFAULT_FAILURE_TTL = Duration.seconds(5);
28
29export interface ProjectEnrichment {
30 readonly repositoryIdentity: RepositoryIdentity | null;
31 readonly faviconPath: string | null;
32 /** True when identity resolution completed successfully, including cached null. */
33 readonly repositoryIdentityResolved: boolean;
34}
35
36export interface ProjectEnrichmentChange {
37 readonly workspaceRoot: string;
38 readonly enrichment: ProjectEnrichment;
39 readonly repositoryIdentityResolved: boolean;
40}
41
42export interface ProjectEnrichmentServiceOptions {
43 readonly cacheCapacity?: number;
44 /** Maximum active and queued roots for each enrichment field. */
45 readonly maxPending?: number;
46 /** Worker concurrency for each enrichment field. */
47 readonly concurrency?: number;
48 readonly successTtl?: Duration.Input;
49 readonly faviconTtl?: Duration.Input;
50 readonly failureTtl?: Duration.Input;
51}
52
53export class ProjectEnrichmentService extends Context.Service<
54 ProjectEnrichmentService,
55 {
56 /** Read resolved metadata without starting or awaiting filesystem work. */
57 readonly peek: (workspaceRoot: string) => Effect.Effect<ProjectEnrichment>;
58 /** Schedule missing metadata for bounded background resolution. */
59 readonly request: (workspaceRoot: string) => Effect.Effect<void>;
60 /** Read immediately available metadata and schedule anything missing. */
61 readonly getAvailable: (workspaceRoot: string) => Effect.Effect<ProjectEnrichment>;
62 /** Invalidate workspace-derived metadata. */
63 readonly invalidate: (workspaceRoots: Iterable<string>) => Effect.Effect<void>;
64 /** Subscribe to ephemeral completion notifications. */
65 readonly subscribeChanges: Effect.Effect<
66 PubSub.Subscription<ProjectEnrichmentChange>,
67 never,
68 Scope.Scope
69 >;
70 }
71>()("t3/project/ProjectEnrichmentService") {}
72
73function availableValue<A, E>(cached: Option.Option<Exit.Exit<A, E>>): A | null {
74 return Option.match(cached, {
75 onNone: () => null,
76 onSome: (exit) =>
77 Exit.match(exit, {
78 onFailure: () => null,
79 onSuccess: (value) => value,
80 }),
81 });
82}
83
84function isSuccessfullyResolved<A, E>(cached: Option.Option<Exit.Exit<A, E>>): boolean {
85 return Option.match(cached, {
86 onNone: () => false,
87 onSome: (exit) => Exit.isSuccess(exit),
88 });
89}
90
91interface EnrichmentWorkLane {
92 readonly pendingRoots: Ref.Ref<ReadonlySet<string>>;
93 readonly queue: Queue.Queue<string>;
94}
95
96export const make = Effect.fn("ProjectEnrichmentService.make")(function* (
97 options: ProjectEnrichmentServiceOptions = {},
98) {
99 const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
100 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
101 const cacheCapacity = Math.max(1, options.cacheCapacity ?? DEFAULT_CACHE_CAPACITY);
102 const maxPending = Math.max(1, options.maxPending ?? DEFAULT_MAX_PENDING);
103 const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
104 const successTtl = options.successTtl ?? DEFAULT_SUCCESS_TTL;
105 const failureTtl = options.failureTtl ?? DEFAULT_FAILURE_TTL;
106 const faviconTtl = options.faviconTtl ?? DEFAULT_FAVICON_TTL;
107
108 const repositoryIdentityCache = yield* Cache.makeWith(
109 (workspaceRoot: string) => Effect.exit(repositoryIdentityResolver.resolve(workspaceRoot)),
110 {
111 capacity: cacheCapacity,
112 timeToLive: Exit.match({
113 onFailure: () => failureTtl,
114 onSuccess: (result) => (Exit.isSuccess(result) ? successTtl : failureTtl),
115 }),
116 },
117 );
118 const faviconCache = yield* Cache.makeWith(
119 (workspaceRoot: string) => Effect.exit(faviconResolver.resolvePath(workspaceRoot)),
120 {
121 capacity: cacheCapacity,
122 timeToLive: Exit.match({
123 onFailure: () => failureTtl,
124 onSuccess: (result) => (Exit.isSuccess(result) ? faviconTtl : failureTtl),
125 }),
126 },
127 );
128 const makeWorkLane = Effect.gen(function* () {
129 const pendingRoots = yield* Ref.make<ReadonlySet<string>>(new Set());
130 const queue = yield* Effect.acquireRelease(Queue.dropping<string>(maxPending), (queue) =>
131 Queue.shutdown(queue),
132 );
133 return { pendingRoots, queue } satisfies EnrichmentWorkLane;
134 });
135 const repositoryIdentityLane = yield* makeWorkLane;
136 const faviconLane = yield* makeWorkLane;
137 const changes = yield* Effect.acquireRelease(
138 PubSub.sliding<ProjectEnrichmentChange>(256),
139 (pubsub) => PubSub.shutdown(pubsub),
140 );
141
142 const removePending = (lane: EnrichmentWorkLane, workspaceRoot: string) =>
143 Ref.update(lane.pendingRoots, (current) => {
144 if (!current.has(workspaceRoot)) return current;
145 const next = new Set(current);
146 next.delete(workspaceRoot);
147 return next;
148 });
149
150 const reservePending = (lane: EnrichmentWorkLane, workspaceRoot: string) =>
151 Ref.modify(lane.pendingRoots, (current) => {
152 if (current.has(workspaceRoot) || current.size >= maxPending) {
153 return [false, current] as const;
154 }
155 const next = new Set(current);
156 next.add(workspaceRoot);
157 return [true, next] as const;
158 });
159
160 const logFailure = <A, E>(
161 workspaceRoot: string,
162 field: "repositoryIdentity" | "faviconPath",
163 result: Exit.Exit<A, E>,
164 ) =>
165 Exit.isFailure(result)
166 ? Effect.logWarning("Failed to enrich optional project metadata", {
167 workspaceRoot,
168 field,
169 cause: Cause.pretty(result.cause),
170 })
171 : Effect.void;
172
173 const resolveRepositoryIdentity = Effect.fn("ProjectEnrichmentService.resolveRepositoryIdentity")(
174 function* (workspaceRoot: string) {
175 const repositoryIdentity = yield* Cache.get(repositoryIdentityCache, workspaceRoot);
176 yield* logFailure(workspaceRoot, "repositoryIdentity", repositoryIdentity);
177 const faviconPath = yield* Cache.getSuccess(faviconCache, workspaceRoot);
178 const repositoryIdentityResolved = Exit.isSuccess(repositoryIdentity);
179 yield* PubSub.publish(changes, {
180 workspaceRoot,
181 repositoryIdentityResolved,
182 enrichment: {
183 repositoryIdentity: availableValue(Option.some(repositoryIdentity)),
184 faviconPath: availableValue(faviconPath),
185 repositoryIdentityResolved,
186 },
187 });
188 },
189 );
190
191 const resolveFavicon = Effect.fn("ProjectEnrichmentService.resolveFavicon")(function* (
192 workspaceRoot: string,
193 ) {
194 const faviconPath = yield* Cache.get(faviconCache, workspaceRoot);
195 const isMissingWorkspaceRoot = Option.exists(
196 Exit.findErrorOption(faviconPath),
197 ProjectFaviconResolver.isMissingWorkspaceRoot,
198 );
199 if (isMissingWorkspaceRoot) {
200 return;
201 }
202 yield* logFailure(workspaceRoot, "faviconPath", faviconPath);
203 });
204
205 const startWorkers = (
206 lane: EnrichmentWorkLane,
207 resolve: (workspaceRoot: string) => Effect.Effect<void>,
208 ) => {
209 const worker = Queue.take(lane.queue).pipe(
210 Effect.flatMap((workspaceRoot) =>
211 resolve(workspaceRoot).pipe(Effect.ensuring(removePending(lane, workspaceRoot))),
212 ),
213 Effect.forever,
214 );
215 return Effect.forEach(Array.from({ length: concurrency }), () => Effect.forkScoped(worker), {
216 discard: true,
217 });
218 };
219 yield* Effect.all(
220 [
221 startWorkers(repositoryIdentityLane, resolveRepositoryIdentity),
222 startWorkers(faviconLane, resolveFavicon),
223 ],
224 { concurrency: "unbounded", discard: true },
225 );
226
227 const requestLane = Effect.fn("ProjectEnrichmentService.requestLane")(function* (
228 lane: EnrichmentWorkLane,
229 workspaceRoot: string,
230 field: "repositoryIdentity" | "faviconPath",
231 ) {
232 if (!(yield* reservePending(lane, workspaceRoot))) return;
233 if (!(yield* Queue.offer(lane.queue, workspaceRoot))) {
234 yield* removePending(lane, workspaceRoot);
235 yield* Effect.logWarning("Project metadata enrichment queue is full", {
236 workspaceRoot,
237 field,
238 });
239 }
240 });
241
242 const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fn(
243 "ProjectEnrichmentService.peek",
244 )(function* (workspaceRoot) {
245 const [repositoryIdentity, faviconPath] = yield* Effect.all(
246 [
247 Cache.getSuccess(repositoryIdentityCache, workspaceRoot),
248 Cache.getSuccess(faviconCache, workspaceRoot),
249 ] as const,
250 { concurrency: "unbounded" },
251 );
252 return {
253 repositoryIdentity: availableValue(repositoryIdentity),
254 faviconPath: availableValue(faviconPath),
255 repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity),
256 };
257 });
258
259 const request: ProjectEnrichmentService["Service"]["request"] = Effect.fn(
260 "ProjectEnrichmentService.request",
261 )(function* (workspaceRoot) {
262 const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all(
263 [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)],
264 { concurrency: "unbounded" },
265 );
266 yield* Effect.all(
267 [
268 hasRepositoryIdentity
269 ? Effect.void
270 : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"),
271 hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"),
272 ],
273 { concurrency: "unbounded", discard: true },
274 );
275 });
276
277 const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fn(
278 "ProjectEnrichmentService.getAvailable",
279 )(function* (workspaceRoot) {
280 const available = yield* peek(workspaceRoot);
281 yield* request(workspaceRoot);
282 return available;
283 });
284
285 const invalidate: ProjectEnrichmentService["Service"]["invalidate"] = Effect.fn(
286 "ProjectEnrichmentService.invalidate",
287 )(function* (workspaceRoots) {
288 yield* Effect.forEach(
289 new Set(workspaceRoots),
290 (workspaceRoot) =>
291 Effect.all(
292 [
293 Cache.invalidate(repositoryIdentityCache, workspaceRoot),
294 Cache.invalidate(faviconCache, workspaceRoot),
295 ],
296 { concurrency: "unbounded", discard: true },
297 ),
298 { discard: true },
299 );
300 });
301
302 return ProjectEnrichmentService.of({
303 peek,
304 request,
305 getAvailable,
306 invalidate,
307 subscribeChanges: PubSub.subscribe(changes),
308 });
309});
310
311export const layer = Layer.effect(ProjectEnrichmentService, make());