1import * as Effect from "effect/Effect";
2import { identity } from "effect/Function";
3import * as Schedule from "effect/Schedule";
4import * as HttpClient from "effect/http/HttpClient";
5import * as HttpClientError from "effect/http/HttpClientError";
6import * as HttpClientRequest from "effect/http/HttpClientRequest";
7import * as HttpApiClient from "effect/http-api/HttpApiClient";
8import * as HttpApiError from "effect/http-api/HttpApiError";
9import { profileCommandHint } from "../Util/interactive.ts";
10import { StateApi } from "./HttpStateApi.ts";
11
12import type { ReplacedResourceState, ResourceState } from "./ResourceState.ts";
13import {
14 StateStoreError,
15 type PersistedState,
16 type StateService,
17} from "./State.ts";
18import { encodeState, reviveStateRecursive } from "./StateEncoding.ts";
19
20/**
21 * Persisted shape of an HTTP state store endpoint — `{ url, authToken }`.
22 * Stored in the credentials file alongside other per-profile secrets.
23 * Deliberately separate from {@link HttpStateStoreProps}: `id` and
24 * `transformClient` are deployment-time choices, not credentials.
25 */
26export interface HttpStateStoreCredentials {
27 url: string;
28 /** Bearer token used to authenticate every request. */
29 authToken: string;
30}
31
32export interface HttpStateStoreProps extends HttpStateStoreCredentials {
33 /**
34 * `StateService.id` slug for telemetry — e.g. `"http"` for a bare
35 * HTTP store, `"cloudflare-http"` for the Cloudflare-deployed
36 * variant. Required so every concrete deployment of this state-store
37 * shape shows up distinctly on the adoption dashboard.
38 */
39 id: string;
40 transformClient?: (
41 client: HttpClientRequest.HttpClientRequest,
42 ) => HttpClientRequest.HttpClientRequest;
43}
44
45export const checkHttpStateStoreAuth = ({
46 url,
47 authToken,
48}: {
49 url: string;
50 authToken: string;
51}) =>
52 Effect.gen(function* () {
53 const apiClient = yield* HttpApiClient.make(StateApi, {
54 baseUrl: url,
55 transformClient: HttpClient.mapRequest((req) =>
56 req.pipe(HttpClientRequest.bearerToken(authToken)),
57 ),
58 });
59 return yield* apiClient.state.listStacks().pipe(
60 Effect.map(() => true),
61 Effect.catchTag("Unauthorized", () => Effect.succeed(false)),
62 Effect.retry({
63 while: (error) =>
64 error._tag === "HttpClientError" &&
65 // transport-level failures (no response — DNS, TCP reset, TLS,
66 // "fetch failed") are as transient as the post-deploy 404s
67 (!error.response ||
68 // worker can 404 for a bit on first deploy
69 error.response.status === 404 ||
70 error.response.status >= 500),
71 // Bounded: a store that 404s/500s forever is a hard failure, not
72 // something to spin on until the process is killed.
73 schedule: Schedule.max([Schedule.fixed(200), Schedule.recurs(75)]),
74 }),
75 );
76 });
77
78export const makeHttpStateStore = ({
79 url,
80 authToken,
81 transformClient,
82 id,
83}: HttpStateStoreProps) =>
84 Effect.gen(function* () {
85 const apiClient = yield* HttpApiClient.make(StateApi, {
86 baseUrl: url,
87 transformClient: HttpClient.mapRequest((req) =>
88 req.pipe(
89 HttpClientRequest.bearerToken(authToken),
90 transformClient ?? identity,
91 ),
92 ),
93 });
94 const state = apiClient.state;
95
96 const service: StateService = {
97 id,
98 getVersion: () =>
99 apiClient.version.getVersion().pipe(
100 Effect.map((v) => v.version),
101 mapStateStoreError,
102 ),
103 listStacks: () =>
104 state.listStacks().pipe(
105 Effect.map((stacks) => [...stacks]),
106 mapStateStoreError,
107 ),
108 listStages: (stack) =>
109 state.listStages({ params: { stack } }).pipe(mapStateStoreError),
110 list: (request) =>
111 state.listResources({ params: request }).pipe(mapStateStoreError),
112 get: (request) =>
113 state
114 .getState({
115 params: {
116 stack: request.stack,
117 stage: request.stage,
118 fqn: encodeURIComponent(request.fqn),
119 },
120 })
121 .pipe(
122 Effect.map((s) =>
123 s == null
124 ? undefined
125 : (reviveStateRecursive(s) as ResourceState),
126 ),
127 mapStateStoreError,
128 ),
129 getReplacedResources: (request) =>
130 state.getReplacedResources({ params: request }).pipe(
131 Effect.map((resources) =>
132 resources.map(
133 (s) => reviveStateRecursive(s) as ReplacedResourceState,
134 ),
135 ),
136 mapStateStoreError,
137 ),
138 set: <V extends PersistedState>(request: {
139 stack: string;
140 stage: string;
141 fqn: string;
142 value: V;
143 }) =>
144 state
145 .setState({
146 params: {
147 stack: request.stack,
148 stage: request.stage,
149 fqn: encodeURIComponent(request.fqn),
150 },
151 payload: encodeState(request.value),
152 })
153 .pipe(
154 // Server echoes the stored value, but the client already
155 // has the canonical object (including any Redacted<T>
156 // instances); returning the input avoids a lossy round-trip.
157 Effect.map(() => request.value),
158 mapStateStoreError,
159 ),
160 delete: (request) =>
161 state
162 .deleteState({
163 params: {
164 stack: request.stack,
165 stage: request.stage,
166 fqn: encodeURIComponent(request.fqn),
167 },
168 })
169 .pipe(Effect.asVoid, mapStateStoreError),
170 deleteStack: (request) =>
171 state
172 .deleteStack({
173 params: { stack: request.stack },
174 query: request.stage === undefined ? {} : { stage: request.stage },
175 })
176 .pipe(Effect.asVoid, mapStateStoreError),
177 getOutput: (request) =>
178 state
179 .getStackOutput({
180 params: { stack: request.stack, stage: request.stage },
181 })
182 .pipe(
183 Effect.map((s) =>
184 s == null ? undefined : reviveStateRecursive(s),
185 ),
186 mapStateStoreError,
187 ),
188 setOutput: (request) =>
189 state
190 .setStackOutput({
191 params: { stack: request.stack, stage: request.stage },
192 payload: encodeState(request.value as any),
193 })
194 .pipe(
195 Effect.map(() => request.value),
196 mapStateStoreError,
197 ),
198 };
199 return service;
200 });
201
202/**
203 * Predicate over an `HttpClientError`-shaped failure that returns `true`
204 * for failures we expect to clear up on their own.
205 *
206 * We retry:
207 * - all transport-level errors (no `response` — DNS, TCP reset, TLS,
208 * abort, etc.)
209 * - 408 (request timeout) and 429 (rate limit)
210 * - 404, which is normal in the seconds after a worker is first
211 * deployed and the route hasn't propagated yet
212 * - every 5xx
213 *
214 * Anything else (400/401/403/etc.) is a real client-side problem and
215 * shouldn't be hidden behind retries.
216 */
217const isTransient = (e: any): boolean => {
218 if (e?._tag !== "HttpClientError") return false;
219 const status: number | undefined = e.response?.status;
220 if (status == null) return true;
221 if (status === 404 || status === 408 || status === 429) return true;
222 return status >= 500 && status < 600;
223};
224
225const retryTransient = <A, Err, Req>(eff: Effect.Effect<A, Err, Req>) =>
226 Effect.retry(eff, {
227 while: isTransient,
228 // Exponential backoff capped at 2s, max 5 attempts. Beyond that
229 // the issue isn't transient and we'd rather surface a hard
230 // failure than block the deploy indefinitely.
231 schedule: Schedule.max([
232 Schedule.min([Schedule.exponential(100), Schedule.spaced("2 seconds")]),
233 Schedule.recurs(5),
234 ]),
235 });
236
237/**
238 * Describe a state-store failure using only its known kind and HTTP status.
239 * Client/decoder messages and causes can contain serialized state, including
240 * secrets unwrapped by encodeState, so they must not become diagnostics.
241 */
242export const describeStateStoreFailure = (
243 e: unknown,
244 profileCommand = "alchemy profile edit",
245): string => {
246 if (e instanceof HttpApiError.Unauthorized) {
247 return (
248 "State store rejected the request as unauthorized. " +
249 `The stored state-store credentials may be stale. Run \`${profileCommand}\` to reconfigure them.`
250 );
251 }
252 if (HttpClientError.isHttpClientError(e)) {
253 const status = e.response?.status;
254 return `State store request failed (${e.reason._tag}${status === undefined ? "" : `, HTTP ${status}`}).`;
255 }
256 return "State store request failed.";
257};
258
259/** Collapse any client failure into a {@link StateStoreError}. */
260const mapStateStoreError = <A, E, R>(eff: Effect.Effect<A, E, R>) =>
261 eff.pipe(
262 retryTransient,
263 Effect.catch((e: E) =>
264 Effect.gen(function* () {
265 const command = yield* profileCommandHint("alchemy profile edit");
266 return yield* Effect.fail(
267 new StateStoreError({
268 message: describeStateStoreFailure(e, command),
269 http: HttpClientError.isHttpClientError(e)
270 ? { status: e.response?.status }
271 : e instanceof HttpApiError.Unauthorized
272 ? { status: 401 }
273 : undefined,
274 }),
275 );
276 }),
277 ),
278 ) as Effect.Effect<A, StateStoreError, R>;