refactor(state): add effectful ScopedState

Add a real effect-style scoped state data type built on ScopedCache and cover its caching, invalidation, concurrency, and scope-finalization semantics with focused tests. Move ProviderAuthService onto the new abstraction so the service no longer depends on Instance.state directly.
This commit is contained in:
Kit Langton
2026-03-12 15:16:07 -04:00
parent 3cfdb07fb8
commit fedaeea9da
4 changed files with 225 additions and 19 deletions
+19 -19
View File
@@ -5,19 +5,10 @@ import { filter, fromEntries, map, mapValues, pipe } from "remeda"
import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import * as Auth from "@/auth/service"
import * as ScopedState from "@/util/scoped-state"
import { ProviderID } from "./schema"
import z from "zod"
const state = Instance.state(async () => {
const methods = pipe(
await Plugin.list(),
filter((x) => x.auth?.provider !== undefined),
map((x) => [x.auth!.provider, x.auth!] as const),
fromEntries(),
)
return { methods, pending: {} as Record<string, AuthOuathResult> }
})
export type Method = {
type: "oauth" | "api"
label: string
@@ -71,10 +62,23 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
ProviderAuthService,
Effect.gen(function* () {
const auth = yield* Auth.AuthService
const state = yield* ScopedState.make({
root: () => Instance.directory,
lookup: () =>
Effect.promise(async () => {
const methods = pipe(
await Plugin.list(),
filter((x) => x.auth?.provider !== undefined),
map((x) => [x.auth!.provider, x.auth!] as const),
fromEntries(),
)
return { methods, pending: {} as Record<string, AuthOuathResult> }
}),
})
const methods = Effect.fn("ProviderAuthService.methods")(() =>
Effect.promise(() =>
state().then((x) =>
ScopedState.get(state).pipe(
Effect.map((x) =>
mapValues(x.methods, (y) =>
y.methods.map(
(z): Method => ({
@@ -91,15 +95,11 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
providerID: ProviderID
method: number
}) {
const item = yield* Effect.promise(() => state().then((x) => x.methods[input.providerID]))
const item = (yield* ScopedState.get(state)).methods[input.providerID]
const method = item.methods[input.method]
if (method.type !== "oauth") return
const result = yield* Effect.promise(() => method.authorize())
yield* Effect.promise(() =>
state().then((x) => {
x.pending[input.providerID] = result
}),
)
;(yield* ScopedState.get(state)).pending[input.providerID] = result
return {
url: result.url,
method: result.method,
@@ -112,7 +112,7 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
method: number
code?: string
}) {
const match = yield* Effect.promise(() => state().then((x) => x.pending[input.providerID]))
const match = (yield* ScopedState.get(state)).pending[input.providerID]
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
const result =
+3
View File
@@ -5,6 +5,9 @@ import { fn } from "@/util/fn"
import * as S from "./auth-service"
import { ProviderID } from "./schema"
// Separate runtime: ProviderAuthService can't join the shared runtime because
// runtime.ts → auth-service.ts → provider/auth.ts creates a circular import.
// AuthService is stateless file I/O so the duplicate instance is harmless.
const rt = ManagedRuntime.make(S.ProviderAuthService.defaultLayer)
function runPromise<A>(f: (service: S.ProviderAuthService.Service) => Effect.Effect<A, S.ProviderAuthError>) {
@@ -0,0 +1,39 @@
import { Effect, ScopedCache, Scope } from "effect"
const TypeId = Symbol.for("@opencode/ScopedState")
export interface ScopedState<A, E = never, R = never> {
readonly [TypeId]: typeof TypeId
readonly root: () => string
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export const make = <A, E = never, R = never>(input: {
root: () => string
lookup: (key: string) => Effect.Effect<A, E, R>
release?: (value: A, key: string) => Effect.Effect<void>
}): Effect.Effect<ScopedState<A, E, R>, never, Scope.Scope | R> =>
ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
lookup: (key) =>
Effect.acquireRelease(input.lookup(key), (value) => (input.release ? input.release(value, key) : Effect.void)),
}).pipe(
Effect.map((cache) => ({
[TypeId]: TypeId,
root: input.root,
cache,
})),
)
export const get = <A, E, R>(self: ScopedState<A, E, R>) => ScopedCache.get(self.cache, self.root())
export const getAt = <A, E, R>(self: ScopedState<A, E, R>, key: string) => ScopedCache.get(self.cache, key)
export const invalidate = <A, E, R>(self: ScopedState<A, E, R>) => ScopedCache.invalidate(self.cache, self.root())
export const invalidateAt = <A, E, R>(self: ScopedState<A, E, R>, key: string) =>
ScopedCache.invalidate(self.cache, key)
export const has = <A, E, R>(self: ScopedState<A, E, R>) => ScopedCache.has(self.cache, self.root())
export const hasAt = <A, E, R>(self: ScopedState<A, E, R>, key: string) => ScopedCache.has(self.cache, key)