1/**
2 * Memoizes "latest available version" lookups (npm registry, release
3 * channels) for the version advisory. One instance is shared by every driver
4 * and the maintenance runner, so a provider update can invalidate the entry
5 * the next snapshot refresh reads.
6 *
7 * @module provider-core/server/ProviderLatestVersions
8 */
9import * as Context from "effect/Context";
10import * as DateTime from "effect/DateTime";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Ref from "effect/Ref";
15
16/** A found version stays fresh this long. */
17const FOUND_TTL = Duration.hours(1);
18/** A failed lookup is retried sooner, so a flaky network does not hide an update for an hour. */
19const MISSING_TTL = Duration.minutes(1);
20
21interface Entry {
22 readonly expiresAt: number;
23 readonly version: string | null;
24}
25
26export class ProviderLatestVersions extends Context.Service<
27 ProviderLatestVersions,
28 {
29 /**
30 * Returns the cached version for `key`, running `lookup` when there is no
31 * fresh entry or `fresh` is set. `null` means the lookup found nothing.
32 */
33 readonly cached: <E, R>(
34 key: string,
35 lookup: Effect.Effect<string | null, E, R>,
36 options?: { readonly fresh?: boolean },
37 ) => Effect.Effect<string | null, E, R>;
38 readonly invalidate: (key: string) => Effect.Effect<void>;
39 }
40>()("@t3tools/provider-core/server/ProviderLatestVersions") {}
41
42/** Starts from `seed`, for tests that need a cached entry. */
43export const make = (seed: Iterable<readonly [key: string, version: string | null]> = []) =>
44 Effect.gen(function* () {
45 const entries = yield* Ref.make(
46 new Map<string, Entry>(
47 Array.from(seed, ([key, version]) => [
48 key,
49 { version, expiresAt: Number.MAX_SAFE_INTEGER },
50 ]),
51 ),
52 );
53 return ProviderLatestVersions.of({
54 cached: (key, lookup, options) =>
55 Effect.gen(function* () {
56 const now = DateTime.toEpochMillis(yield* DateTime.now);
57 const existing = (yield* Ref.get(entries)).get(key);
58 if (!options?.fresh && existing && existing.expiresAt > now) return existing.version;
59 const version = yield* lookup;
60 const ttl = version === null ? MISSING_TTL : FOUND_TTL;
61 yield* Ref.update(entries, (current) =>
62 new Map(current).set(key, { version, expiresAt: now + Duration.toMillis(ttl) }),
63 );
64 return version;
65 }),
66 invalidate: (key) =>
67 Ref.update(entries, (current) => {
68 const next = new Map(current);
69 next.delete(key);
70 return next;
71 }),
72 });
73 });
74
75export const layer = Layer.effect(ProviderLatestVersions, make());