| 1 | import type { RepositoryIdentity } from "@t3tools/contracts"; |
| 2 | import * as Cache from "effect/Cache"; |
| 3 | import * as Cause from "effect/Cause"; |
| 4 | import * as Context from "effect/Context"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Exit from "effect/Exit"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import * as PubSub from "effect/PubSub"; |
| 11 | import * as Queue from "effect/Queue"; |
| 12 | import * as Ref from "effect/Ref"; |
| 13 | import type * as Scope from "effect/Scope"; |
| 14 | |
| 15 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
| 16 | import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; |
| 17 | |
| 18 | const DEFAULT_CACHE_CAPACITY = 512; |
| 19 | const DEFAULT_MAX_PENDING = 512; |
| 20 | const DEFAULT_CONCURRENCY = 4; |
| 21 | const 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. |
| 26 | const DEFAULT_FAVICON_TTL = Duration.minutes(15); |
| 27 | const DEFAULT_FAILURE_TTL = Duration.seconds(5); |
| 28 | |
| 29 | export 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 | |
| 36 | export interface ProjectEnrichmentChange { |
| 37 | readonly workspaceRoot: string; |
| 38 | readonly enrichment: ProjectEnrichment; |
| 39 | readonly repositoryIdentityResolved: boolean; |
| 40 | } |
| 41 | |
| 42 | export 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 | |
| 53 | export 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 | |
| 73 | function 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 | |
| 84 | function 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 | |
| 91 | interface EnrichmentWorkLane { |
| 92 | readonly pendingRoots: Ref.Ref<ReadonlySet<string>>; |
| 93 | readonly queue: Queue.Queue<string>; |
| 94 | } |
| 95 | |
| 96 | export 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 | |
| 311 | export const layer = Layer.effect(ProjectEnrichmentService, make()); |