1import * as Cache from "effect/Cache";
2import * as Clock from "effect/Clock";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6
7/**
8 * Refresh expiring credentials this long before they actually expire so that
9 * in-flight requests never race the expiry deadline.
10 */
11export const CREDENTIAL_REFRESH_WINDOW = Duration.minutes(5);
12
13/** Compute how long credentials may remain cached. */
14export const credentialTimeToLive = (
15 expiresAt: number | undefined,
16 resolvedAt: number,
17): Duration.Duration => {
18 if (expiresAt === undefined) return Duration.infinity;
19 return Duration.millis(
20 Math.max(
21 0,
22 expiresAt - resolvedAt - Duration.toMillis(CREDENTIAL_REFRESH_WINDOW),
23 ),
24 );
25};
26
27/**
28 * Build a single-entry Effect cache whose TTL is derived from each resolved
29 * credential's expiry. Concurrent cache misses share one resolution.
30 */
31export const cacheUntilExpiry = <A, E>(
32 resolve: Effect.Effect<A, E>,
33 expiresAt: (credentials: A) => number | undefined,
34) =>
35 Effect.gen(function* () {
36 const cache = yield* Cache.makeWith(
37 (_: void) =>
38 Effect.gen(function* () {
39 const resolvedAt = yield* Clock.currentTimeMillis;
40 const credentials = yield* resolve;
41 return { credentials, resolvedAt };
42 }),
43 {
44 capacity: 1,
45 timeToLive: (exit) =>
46 Exit.isSuccess(exit)
47 ? credentialTimeToLive(
48 expiresAt(exit.value.credentials),
49 exit.value.resolvedAt,
50 )
51 : Duration.zero,
52 },
53 );
54 return Cache.get(cache, undefined).pipe(
55 Effect.map(({ credentials }) => credentials),
56 );
57 });