124 lines · 5.5 KB · TypeScript
RawDownload
import { Clock, Duration, Effect, Predicate } from "effect";import type { Layer } from "effect";import type { HttpClient } from "effect/unstable/http"; import { sha256Hex, type StorageFailure } from "@executor-js/sdk/core"; import type { OpenApiParseError } from "./errors";import { fetchSpecDocument, fetchSpecText, type SpecFetchValidators } from "./parse";import type { OpenapiStore, SpecSourceEntry } from "./store"; // ---------------------------------------------------------------------------// Cached spec fetch — the URL-keyed index over the content-addressed blob// store. The blob store alone can't skip a download (its key is the SHA-256 of// the body, unknowable before fetching), so the `spec_source` index remembers// what each URL last resolved to plus the response's cache validators:////   fresh entry (within TTL)  -> serve the spec blob, no network//   stale entry w/ validators -> conditional GET; 304 revalidates the blob//   miss / changed upstream   -> full download, blob + index updated//// Spec fetches are unauthenticated by design (see fetchSpecDocument), so one// org-shared entry per tenant is safe. Preview persists through this path too,// which is what makes the add flow's preview -> addSpec sequence a single// download instead of two: an abandoned preview leaves only an unreferenced// content-addressed blob, the same accepted cost as an aborted addSpec.// --------------------------------------------------------------------------- /** How long a cached fetch keeps serving without touching the network. Sized *  for the add flow (debounced previews, preview -> add) — not a freshness *  contract for refresh, which revalidates unconditionally. */export const SPEC_FETCH_TTL = Duration.minutes(5); export type SpecFetchFreshness =  /** Serve a within-TTL cache entry without any network round trip. */  | "prefer-cache"  /** Always hit the network; validators still turn an unchanged spec into a   *  bodyless 304. The explicit-refresh (`updateSpec`) mode. */  | "revalidate"; export interface ResolvedSpecDocument {  readonly specText: string;  readonly specHash: string;  /** True when the spec blob is known to already be in the store (cache hit or   *  this call wrote it) — lets callers skip a redundant multi-MB re-put. */  readonly persisted: boolean;} /** * Fetch a spec URL through the tenant's `spec_source` cache. Storage failures * on the write-back are surfaced (a broken store should fail loudly, not * silently double-fetch forever). */export const fetchSpecTextCached = (input: {  readonly url: string;  readonly storage: OpenapiStore;  readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;  readonly freshness: SpecFetchFreshness;}): Effect.Effect<ResolvedSpecDocument, OpenApiParseError | StorageFailure> =>  Effect.gen(function* () {    const now = yield* Clock.currentTimeMillis;    const entry = yield* input.storage.getSpecSource(input.url);     if (      entry !== null &&      input.freshness === "prefer-cache" &&      now - entry.fetchedAt < Duration.toMillis(SPEC_FETCH_TTL)    ) {      const cached = yield* input.storage.getSpec(entry.specHash);      // A missing blob (pruned out-of-band) falls through to a full fetch.      if (cached !== null) {        return { specText: cached, specHash: entry.specHash, persisted: true };      }    }     const validators: SpecFetchValidators | undefined =      entry !== null && (entry.etag !== undefined || entry.lastModified !== undefined)        ? {            ...(entry.etag !== undefined ? { etag: entry.etag } : {}),            ...(entry.lastModified !== undefined ? { lastModified: entry.lastModified } : {}),          }        : undefined;     const conditional = yield* fetchSpecDocument(input.url, validators).pipe(      Effect.provide(input.httpClientLayer),    );     if (Predicate.isTagged(conditional, "NotModified") && entry !== null) {      const cached = yield* input.storage.getSpec(entry.specHash);      if (cached !== null) {        yield* putSpecSourceRaceSafe(input.storage, { ...entry, fetchedAt: now });        return { specText: cached, specHash: entry.specHash, persisted: true };      }    }     // 304 with the blob gone is a broken-index corner: refetch for real. The    // unconditional fetchSpecText fails typed on a (protocol-violating) bare 304.    const fetched: {      readonly text: string;      readonly etag?: string;      readonly lastModified?: string;    } = Predicate.isTagged(conditional, "Fetched")      ? conditional      : { text: yield* fetchSpecText(input.url).pipe(Effect.provide(input.httpClientLayer)) };     const specHash = yield* sha256Hex(fetched.text);    yield* input.storage.putSpec(specHash, fetched.text);    yield* putSpecSourceRaceSafe(input.storage, {      url: input.url,      specHash,      ...(fetched.etag !== undefined ? { etag: fetched.etag } : {}),      ...(fetched.lastModified !== undefined ? { lastModified: fetched.lastModified } : {}),      fetchedAt: now,    });    return { specText: fetched.text, specHash, persisted: true };  }).pipe(    Effect.withSpan("openapi.spec_cache.fetch", {      attributes: { "openapi.spec.url": input.url },    }),  ); // Two debounced previews of the same URL can race the first insert; the loser's// unique violation is harmless (the winner just wrote an equivalent entry).const putSpecSourceRaceSafe = (storage: OpenapiStore, entry: SpecSourceEntry) =>  storage.putSpecSource(entry).pipe(Effect.catchTag("UniqueViolationError", () => Effect.void));