1import { ConfigError } from "@distilled.cloud/core/errors";
2import {
3 type Config as PlanetscaleClientConfig,
4 Credentials,
5 DEFAULT_API_BASE_URL,
6} from "@distilled.cloud/planetscale/Credentials";
7import * as Config from "effect/Config";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Redacted from "effect/Redacted";
11import * as CredentialsCache from "../Auth/CredentialsCache.ts";
12import {
13 deferUntilFirstUse,
14 orDieCredentialsUnavailable,
15 resolveProviderConfig,
16} from "../Auth/Resolve.ts";
17import {
18 PLANETSCALE_AUTH_PROVIDER_NAME,
19 type PlanetscaleAuthConfig,
20 type PlanetscaleResolvedCredentials,
21} from "./AuthProvider.ts";
22
23export {
24 Credentials,
25 CredentialsFromEnv,
26 DEFAULT_API_BASE_URL,
27} from "@distilled.cloud/planetscale/Credentials";
28
29/**
30 * Build a PlanetScale `Credentials` Layer from an explicit token. Useful for
31 * tests or when the caller already has credentials in hand.
32 *
33 * @example
34 * ```ts
35 * Effect.provide(
36 * Planetscale.fromToken({
37 * tokenId: "abcd1234",
38 * token: "api-token-secret",
39 * organization: "my-org",
40 * }),
41 * )
42 * ```
43 */
44export const fromToken = (input: {
45 tokenId: string | Redacted.Redacted<string>;
46 token: string | Redacted.Redacted<string>;
47 organization: string;
48 apiBaseUrl?: string;
49}) =>
50 Layer.succeed(
51 Credentials,
52 Effect.succeed({
53 tokenId:
54 typeof input.tokenId === "string"
55 ? Redacted.make(input.tokenId)
56 : input.tokenId,
57 token:
58 typeof input.token === "string"
59 ? Redacted.make(input.token)
60 : input.token,
61 organization: input.organization,
62 apiBaseUrl: input.apiBaseUrl ?? DEFAULT_API_BASE_URL,
63 }),
64 );
65
66/**
67 * Build a PlanetScale `Credentials` Layer that resolves credentials via the
68 * Alchemy AuthProvider using the configured profile (defaults to "default",
69 * selected by the current Alchemy profile).
70 */
71export const fromAuthProvider = () =>
72 Layer.effect(
73 Credentials,
74 Effect.gen(function* () {
75 const apiBaseUrl = yield* Config.String("PLANETSCALE_API_BASE_URL").pipe(
76 Config.withDefault(DEFAULT_API_BASE_URL),
77 );
78 // Defer profile lookup and credential resolution until first use, so
79 // building the provider layers never requires a configured profile.
80 const resolve = yield* resolveProviderConfig<
81 PlanetscaleAuthConfig,
82 PlanetscaleResolvedCredentials
83 >(PLANETSCALE_AUTH_PROVIDER_NAME).pipe(
84 Effect.flatMap(({ profileName, resolve }) =>
85 Effect.mapError(
86 resolve,
87 (e) =>
88 new ConfigError({
89 message: `Failed to resolve Planetscale credentials from ${profileName === undefined ? "the CI environment" : `profile '${profileName}'`}: ${e.message}`,
90 }),
91 ),
92 ),
93 deferUntilFirstUse,
94 );
95
96 // Cache until shortly before the OAuth token expires (service tokens
97 // never expire) so a long `alchemy dev` session re-resolves — and
98 // thereby refreshes — the token instead of keeping the first, by then
99 // dead, resolution forever. The cache wraps `resolve` (not the mapped
100 // client config) because the mapping drops the `expires` timestamp.
101 const cached = yield* CredentialsCache.cacheUntilExpiry(
102 resolve,
103 (creds) => (creds.type === "oauth" ? creds.expires : undefined),
104 );
105 return cached.pipe(
106 Effect.map((creds): PlanetscaleClientConfig =>
107 creds.type === "oauth"
108 ? {
109 type: "oauth",
110 accessToken: creds.accessToken,
111 organization: creds.organization,
112 apiBaseUrl,
113 }
114 : {
115 type: "serviceToken",
116 tokenId: creds.tokenId,
117 token: creds.token,
118 organization: creds.organization,
119 apiBaseUrl,
120 },
121 ),
122 orDieCredentialsUnavailable(PLANETSCALE_AUTH_PROVIDER_NAME),
123 );
124 }),
125 );