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

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 =

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>) {

View File

@@ -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)

View File

@@ -0,0 +1,164 @@
import { expect, test } from "bun:test"
import { Effect, Fiber } from "effect"
import * as ScopedState from "../../src/util/scoped-state"
test("ScopedState caches values for the current root", async () => {
let key = "a"
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* ScopedState.make({
root: () => key,
lookup: () => Effect.sync(() => ({ n: ++n })),
})
const a = yield* ScopedState.get(state)
const b = yield* ScopedState.get(state)
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})
test("ScopedState isolates values by root", async () => {
let key = "a"
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* ScopedState.make({
root: () => key,
lookup: (root) => Effect.sync(() => ({ root, n: ++n })),
})
const a = yield* ScopedState.get(state)
key = "b"
const b = yield* ScopedState.get(state)
key = "a"
const a2 = yield* ScopedState.get(state)
expect(a).toBe(a2)
expect(a).not.toBe(b)
expect(n).toBe(2)
}),
),
)
})
test("ScopedState.invalidate refreshes the current root", async () => {
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* ScopedState.make({
root: () => "a",
lookup: () => Effect.sync(() => ({ n: ++n })),
})
const a = yield* ScopedState.get(state)
yield* ScopedState.invalidate(state)
const b = yield* ScopedState.get(state)
expect(a).not.toBe(b)
expect(n).toBe(2)
}),
),
)
})
test("ScopedState.invalidateAt only refreshes the targeted root", async () => {
let key = "a"
let n = 0
const seen: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* ScopedState.make({
root: () => key,
lookup: (root) => Effect.sync(() => ({ root, n: ++n })),
release: (value, root) =>
Effect.sync(() => {
seen.push(`${root}:${value.n}`)
}),
})
const a = yield* ScopedState.get(state)
key = "b"
const b = yield* ScopedState.get(state)
yield* ScopedState.invalidateAt(state, "a")
key = "a"
const a2 = yield* ScopedState.get(state)
key = "b"
const b2 = yield* ScopedState.get(state)
expect(a).not.toBe(a2)
expect(b).toBe(b2)
expect(seen).toEqual(["a:1"])
}),
),
)
})
test("ScopedState dedupes concurrent lookups for the same root", async () => {
const gate = Promise.withResolvers<void>()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* ScopedState.make({
root: () => "a",
lookup: () =>
Effect.promise(async () => {
n += 1
await gate.promise
return { n }
}),
})
const fiber = yield* Effect.all([ScopedState.get(state), ScopedState.get(state)], { concurrency: 2 }).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* Effect.promise(() => Promise.resolve())
expect(n).toBe(1)
gate.resolve()
const [a, b] = yield* Fiber.join(fiber)
expect(a).toBe(b)
}),
),
)
})
test("ScopedState runs release when the surrounding scope closes", async () => {
const seen: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* ScopedState.make({
root: () => "a",
lookup: (root) => Effect.sync(() => ({ root })),
release: (value, root) =>
Effect.sync(() => {
seen.push(`${root}:${value.root}`)
}),
})
yield* ScopedState.get(state)
yield* ScopedState.getAt(state, "b")
}),
),
)
expect(seen.sort()).toEqual(["a:a", "b:b"])
})