feat(models): effectify ModelsDev as Service (#25434)
This commit is contained in:
@@ -26,8 +26,7 @@ export const ModelsCommand = effectCmd({
|
|||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.models")(function* (args) {
|
handler: Effect.fn("Cli.models")(function* (args) {
|
||||||
if (args.refresh) {
|
if (args.refresh) {
|
||||||
// followup: lift ModelsDev into an Effect Service so this drops the Effect.promise wrap.
|
yield* ModelsDev.Service.use((s) => s.refresh(true))
|
||||||
yield* Effect.promise(() => ModelsDev.refresh(true))
|
|
||||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
|
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher"
|
|||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
|
import { ModelsDev } from "@/provider/models"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ProviderAuth } from "@/provider/auth"
|
import { ProviderAuth } from "@/provider/auth"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
@@ -66,6 +67,7 @@ export const AppLayer = Layer.mergeAll(
|
|||||||
Storage.defaultLayer,
|
Storage.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
|
ModelsDev.defaultLayer,
|
||||||
Provider.defaultLayer,
|
Provider.defaultLayer,
|
||||||
ProviderAuth.defaultLayer,
|
ProviderAuth.defaultLayer,
|
||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Schema } from "effect"
|
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||||
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { lazy } from "@/util/lazy"
|
|
||||||
import { Filesystem } from "@/util/filesystem"
|
|
||||||
import { Flock } from "@opencode-ai/core/util/flock"
|
import { Flock } from "@opencode-ai/core/util/flock"
|
||||||
import { Hash } from "@opencode-ai/core/util/hash"
|
import { Hash } from "@opencode-ai/core/util/hash"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
// Try to import bundled snapshot (generated at build time)
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
// Falls back to undefined in dev mode when snapshot doesn't exist
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
/* @ts-ignore */
|
|
||||||
|
|
||||||
const log = Log.create({ service: "models.dev" })
|
|
||||||
const source = url()
|
|
||||||
const filepath = path.join(
|
|
||||||
Global.Path.cache,
|
|
||||||
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
|
|
||||||
)
|
|
||||||
const ttl = 5 * 60 * 1000
|
|
||||||
|
|
||||||
const Cost = Schema.Struct({
|
const Cost = Schema.Struct({
|
||||||
input: Schema.Finite,
|
input: Schema.Finite,
|
||||||
@@ -101,76 +90,119 @@ export const Provider = Schema.Struct({
|
|||||||
|
|
||||||
export type Provider = Schema.Schema.Type<typeof Provider>
|
export type Provider = Schema.Schema.Type<typeof Provider>
|
||||||
|
|
||||||
function url() {
|
export interface Interface {
|
||||||
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
|
readonly get: () => Effect.Effect<Record<string, Provider>>
|
||||||
|
readonly refresh: (force?: boolean) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
function fresh() {
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
|
||||||
return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
function skip(force: boolean) {
|
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | HttpClient.HttpClient> = Layer.effect(
|
||||||
return !force && fresh()
|
Service,
|
||||||
}
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||||
|
|
||||||
const fetchApi = async () => {
|
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
|
||||||
const result = await fetch(`${url()}/api.json`, {
|
const filepath = path.join(
|
||||||
headers: { "User-Agent": Installation.USER_AGENT },
|
Global.Path.cache,
|
||||||
signal: AbortSignal.timeout(10000),
|
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
|
||||||
})
|
)
|
||||||
return { ok: result.ok, text: await result.text() }
|
const ttl = Duration.minutes(5)
|
||||||
}
|
const lockKey = `models-dev:${filepath}`
|
||||||
|
|
||||||
export const Data = lazy(async () => {
|
const fresh = Effect.fnUntraced(function* () {
|
||||||
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
|
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (result) return result
|
if (!stat) return false
|
||||||
// @ts-ignore
|
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
|
||||||
const snapshot = await import("./models-snapshot.js")
|
return Date.now() - mtime < Duration.toMillis(ttl)
|
||||||
.then((m) => m.snapshot as Record<string, unknown>)
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (snapshot) return snapshot
|
|
||||||
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
|
|
||||||
return Flock.withLock(`models-dev:${filepath}`, async () => {
|
|
||||||
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
|
|
||||||
if (result) return result
|
|
||||||
const result2 = await fetchApi()
|
|
||||||
if (result2.ok) {
|
|
||||||
await Filesystem.write(filepath, result2.text).catch((e) => {
|
|
||||||
log.error("Failed to write models cache", { error: e })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return JSON.parse(result2.text)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function get() {
|
|
||||||
const result = await Data()
|
|
||||||
return result as Record<string, Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refresh(force = false) {
|
|
||||||
if (skip(force)) return Data.reset()
|
|
||||||
await Flock.withLock(`models-dev:${filepath}`, async () => {
|
|
||||||
if (skip(force)) return Data.reset()
|
|
||||||
const result = await fetchApi()
|
|
||||||
if (!result.ok) return
|
|
||||||
await Filesystem.write(filepath, result.text)
|
|
||||||
Data.reset()
|
|
||||||
}).catch((e) => {
|
|
||||||
log.error("Failed to fetch models.dev", {
|
|
||||||
error: e,
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
|
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
|
||||||
void refresh()
|
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
|
||||||
setInterval(
|
HttpClientRequest.setHeader("User-Agent", Installation.USER_AGENT),
|
||||||
async () => {
|
http.execute,
|
||||||
await refresh()
|
Effect.flatMap((res) => res.text),
|
||||||
},
|
Effect.timeout("10 seconds"),
|
||||||
60 * 1000 * 60,
|
)
|
||||||
).unref()
|
})
|
||||||
}
|
|
||||||
|
const loadFromDisk = fs
|
||||||
|
.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath)
|
||||||
|
.pipe(
|
||||||
|
Effect.catch(() => Effect.succeed(undefined)),
|
||||||
|
Effect.map((v) => v as Record<string, Provider> | undefined),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bundled at build time; absent in dev — `tryPromise` covers both.
|
||||||
|
const loadSnapshot = Effect.tryPromise({
|
||||||
|
// @ts-ignore — generated at build time, may not exist in dev
|
||||||
|
try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record<string, Provider> | undefined),
|
||||||
|
catch: () => undefined,
|
||||||
|
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
|
||||||
|
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||||
|
const text = yield* fetchApi()
|
||||||
|
yield* fs.writeWithDirs(filepath, text)
|
||||||
|
return text
|
||||||
|
})
|
||||||
|
|
||||||
|
const populate = Effect.gen(function* () {
|
||||||
|
const fromDisk = yield* loadFromDisk
|
||||||
|
if (fromDisk) return fromDisk
|
||||||
|
const snapshot = yield* loadSnapshot
|
||||||
|
if (snapshot) return snapshot
|
||||||
|
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
|
||||||
|
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
|
||||||
|
const text = yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Flock.effect(lockKey)
|
||||||
|
return yield* fetchAndWrite()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return JSON.parse(text) as Record<string, Provider>
|
||||||
|
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
|
||||||
|
|
||||||
|
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
|
||||||
|
|
||||||
|
const get = (): Effect.Effect<Record<string, Provider>> => cachedGet
|
||||||
|
|
||||||
|
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
|
||||||
|
if (!force && (yield* fresh())) return
|
||||||
|
yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Flock.effect(lockKey)
|
||||||
|
// Re-check under the lock: another process may have refreshed between
|
||||||
|
// our outer check and lock acquisition.
|
||||||
|
if (!force && (yield* fresh())) return
|
||||||
|
yield* fetchAndWrite()
|
||||||
|
yield* invalidate
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })),
|
||||||
|
Effect.ignore,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
|
||||||
|
// Schedule.spaced runs the effect once, then waits between completions.
|
||||||
|
yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Service.of({ get, refresh })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers).
|
||||||
|
// makeRuntime uses the shared memoMap so this runtime's Service instance is the same one
|
||||||
|
// AppRuntime sees — Effect callers and Promise callers operate on the same cache.
|
||||||
|
const runtime = makeRuntime(Service, defaultLayer)
|
||||||
|
export const get = () => runtime.runPromise((s) => s.get())
|
||||||
|
export const refresh = (force = false) => runtime.runPromise((s) => s.refresh(force))
|
||||||
|
|
||||||
export * as ModelsDev from "./models"
|
export * as ModelsDev from "./models"
|
||||||
|
|||||||
@@ -1074,7 +1074,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
|
|||||||
const layer: Layer.Layer<
|
const layer: Layer.Layer<
|
||||||
Service,
|
Service,
|
||||||
never,
|
never,
|
||||||
Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service
|
Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service | ModelsDev.Service
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -1083,13 +1083,14 @@ const layer: Layer.Layer<
|
|||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const env = yield* Env.Service
|
const env = yield* Env.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
|
const modelsDevSvc = yield* ModelsDev.Service
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(() =>
|
const state = yield* InstanceState.make<State>(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
using _ = log.time("state")
|
using _ = log.time("state")
|
||||||
const bridge = yield* EffectBridge.make()
|
const bridge = yield* EffectBridge.make()
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
|
const modelsDev = yield* modelsDevSvc.get()
|
||||||
const database = mapValues(modelsDev, fromModelsDevProvider)
|
const database = mapValues(modelsDev, fromModelsDevProvider)
|
||||||
|
|
||||||
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
|
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
|
||||||
@@ -1722,6 +1723,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(ModelsDev.defaultLayer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
|
|||||||
|
|
||||||
const list = Effect.fn("ProviderHttpApi.list")(function* () {
|
const list = Effect.fn("ProviderHttpApi.list")(function* () {
|
||||||
const config = yield* cfg.get()
|
const config = yield* cfg.get()
|
||||||
const all = yield* Effect.promise(() => ModelsDev.get())
|
const all = yield* ModelsDev.Service.use((s) => s.get())
|
||||||
const disabled = new Set(config.disabled_providers ?? [])
|
const disabled = new Set(config.disabled_providers ?? [])
|
||||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||||
const filtered: Record<string, (typeof all)[string]> = {}
|
const filtered: Record<string, (typeof all)[string]> = {}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { InstanceStore } from "@/project/instance-store"
|
|||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
import { ProviderAuth } from "@/provider/auth"
|
import { ProviderAuth } from "@/provider/auth"
|
||||||
|
import { ModelsDev } from "@/provider/models"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
import { Question } from "@/question"
|
import { Question } from "@/question"
|
||||||
@@ -155,6 +156,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
|||||||
InstanceBootstrap.defaultLayer,
|
InstanceBootstrap.defaultLayer,
|
||||||
InstanceStore.defaultLayer,
|
InstanceStore.defaultLayer,
|
||||||
MCP.defaultLayer,
|
MCP.defaultLayer,
|
||||||
|
ModelsDev.defaultLayer,
|
||||||
Permission.defaultLayer,
|
Permission.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const ProviderRoutes = lazy(() =>
|
|||||||
const svc = yield* Provider.Service
|
const svc = yield* Provider.Service
|
||||||
const cfg = yield* Config.Service
|
const cfg = yield* Config.Service
|
||||||
const config = yield* cfg.get()
|
const config = yield* cfg.get()
|
||||||
const all = yield* Effect.promise(() => ModelsDev.get())
|
const all = yield* ModelsDev.Service.use((s) => s.get())
|
||||||
const disabled = new Set(config.disabled_providers ?? [])
|
const disabled = new Set(config.disabled_providers ?? [])
|
||||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||||
const filtered: Record<string, (typeof all)[string]> = {}
|
const filtered: Record<string, (typeof all)[string]> = {}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
|
||||||
|
import { Effect, Layer, Ref } from "effect"
|
||||||
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { ModelsDev } from "../../src/provider/models"
|
||||||
|
import { it } from "../lib/effect"
|
||||||
|
import { rm, writeFile, utimes, mkdir } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
|
||||||
|
// resolve providers without network. These tests need to drive the on-disk
|
||||||
|
// cache themselves and silence the eager refresh fork. Save/restore around
|
||||||
|
// the suite — never leak the mutation to subsequent test files in the same
|
||||||
|
// bun process.
|
||||||
|
const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH
|
||||||
|
const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH
|
||||||
|
beforeAll(() => {
|
||||||
|
Flag.OPENCODE_MODELS_PATH = undefined
|
||||||
|
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
|
||||||
|
})
|
||||||
|
afterAll(() => {
|
||||||
|
Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH
|
||||||
|
Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH
|
||||||
|
})
|
||||||
|
|
||||||
|
const cacheFile = path.join(Global.Path.cache, "models.json")
|
||||||
|
|
||||||
|
const fixture: Record<string, ModelsDev.Provider> = {
|
||||||
|
acme: {
|
||||||
|
id: "acme",
|
||||||
|
name: "Acme",
|
||||||
|
env: ["ACME_API_KEY"],
|
||||||
|
models: {
|
||||||
|
"acme-1": {
|
||||||
|
id: "acme-1",
|
||||||
|
name: "Acme One",
|
||||||
|
release_date: "2026-01-01",
|
||||||
|
attachment: false,
|
||||||
|
reasoning: false,
|
||||||
|
temperature: true,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 128000, output: 8192 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixture2: Record<string, ModelsDev.Provider> = {
|
||||||
|
beta: {
|
||||||
|
id: "beta",
|
||||||
|
name: "Beta",
|
||||||
|
env: ["BETA_API_KEY"],
|
||||||
|
models: {
|
||||||
|
"beta-1": {
|
||||||
|
id: "beta-1",
|
||||||
|
name: "Beta One",
|
||||||
|
release_date: "2026-02-01",
|
||||||
|
attachment: false,
|
||||||
|
reasoning: true,
|
||||||
|
temperature: false,
|
||||||
|
tool_call: false,
|
||||||
|
limit: { context: 64000, output: 4096 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockState {
|
||||||
|
body: string
|
||||||
|
status: number
|
||||||
|
calls: Array<{ url: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeMockClient = (state: Ref.Ref<MockState>) =>
|
||||||
|
HttpClient.make((request) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] }))
|
||||||
|
const s = yield* Ref.get(state)
|
||||||
|
return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildLayer = (state: Ref.Ref<MockState>) =>
|
||||||
|
// Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
|
||||||
|
// and Effect.provide uses a process-global MemoMap by default — without fresh,
|
||||||
|
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||||
|
Layer.fresh(ModelsDev.layer).pipe(
|
||||||
|
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
const writeCache = (data: object, mtimeMs?: number) =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
await mkdir(Global.Path.cache, { recursive: true })
|
||||||
|
await writeFile(cacheFile, JSON.stringify(data))
|
||||||
|
if (mtimeMs !== undefined) {
|
||||||
|
const t = mtimeMs / 1000
|
||||||
|
await utimes(cacheFile, t, t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
|
||||||
|
eff.pipe(Effect.provide(buildLayer(state)))
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await rm(cacheFile, { force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(cacheFile, { force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const initialState: MockState = {
|
||||||
|
body: JSON.stringify(fixture),
|
||||||
|
status: 200,
|
||||||
|
calls: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ModelsDev Service", () => {
|
||||||
|
it.live("get() returns providers from disk when cache file exists", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* writeCache(fixture)
|
||||||
|
const state = yield* Ref.make(initialState)
|
||||||
|
const result = yield* provided(
|
||||||
|
state,
|
||||||
|
ModelsDev.Service.use((s) => s.get()),
|
||||||
|
)
|
||||||
|
expect(result).toEqual(fixture)
|
||||||
|
const final = yield* Ref.get(state)
|
||||||
|
expect(final.calls).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("get() returns {} when disk empty and fetch disabled", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* Ref.make(initialState)
|
||||||
|
const result = yield* provided(
|
||||||
|
state,
|
||||||
|
ModelsDev.Service.use((s) => s.get()),
|
||||||
|
)
|
||||||
|
expect(result).toEqual({})
|
||||||
|
const final = yield* Ref.get(state)
|
||||||
|
expect(final.calls).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("get() is single-flight under concurrent calls", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* writeCache(fixture)
|
||||||
|
const state = yield* Ref.make(initialState)
|
||||||
|
const results = yield* provided(
|
||||||
|
state,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* ModelsDev.Service
|
||||||
|
return yield* Effect.all(
|
||||||
|
[svc.get(), svc.get(), svc.get(), svc.get(), svc.get()],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
for (const result of results) expect(result).toEqual(fixture)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* writeCache(fixture)
|
||||||
|
const state = yield* Ref.make(initialState)
|
||||||
|
const first = yield* provided(
|
||||||
|
state,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* ModelsDev.Service
|
||||||
|
const a = yield* svc.get()
|
||||||
|
// mutate disk between calls — cache should mask the change
|
||||||
|
yield* writeCache(fixture2)
|
||||||
|
const b = yield* svc.get()
|
||||||
|
return { a, b }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(first.a).toEqual(fixture)
|
||||||
|
expect(first.b).toEqual(fixture)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* writeCache(fixture)
|
||||||
|
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||||
|
const result = yield* provided(
|
||||||
|
state,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* ModelsDev.Service
|
||||||
|
const before = yield* svc.get()
|
||||||
|
yield* svc.refresh(true)
|
||||||
|
const after = yield* svc.get()
|
||||||
|
return { before, after }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(result.before).toEqual(fixture)
|
||||||
|
expect(result.after).toEqual(fixture2)
|
||||||
|
const final = yield* Ref.get(state)
|
||||||
|
expect(final.calls.length).toBe(1)
|
||||||
|
expect(final.calls[0].url).toContain("/api.json")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
// Fresh: mtime within the 5-minute TTL.
|
||||||
|
yield* writeCache(fixture, Date.now() - 1000)
|
||||||
|
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||||
|
yield* provided(
|
||||||
|
state,
|
||||||
|
ModelsDev.Service.use((s) => s.refresh(false)),
|
||||||
|
)
|
||||||
|
const final = yield* Ref.get(state)
|
||||||
|
expect(final.calls).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("refresh(false) fetches when on-disk file is stale", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
|
||||||
|
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
|
||||||
|
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||||
|
const after = yield* provided(
|
||||||
|
state,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* ModelsDev.Service
|
||||||
|
yield* svc.refresh(false)
|
||||||
|
return yield* svc.get()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const final = yield* Ref.get(state)
|
||||||
|
expect(final.calls.length).toBe(1)
|
||||||
|
expect(after).toEqual(fixture2)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* writeCache(fixture)
|
||||||
|
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
|
||||||
|
const result = yield* provided(
|
||||||
|
state,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* ModelsDev.Service
|
||||||
|
yield* svc.refresh(true)
|
||||||
|
return yield* svc.get()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(result).toEqual(fixture)
|
||||||
|
// withTransientReadRetry retries 5xx, so calls may be > 1.
|
||||||
|
const final = yield* Ref.get(state)
|
||||||
|
expect(final.calls.length).toBeGreaterThanOrEqual(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user