mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
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:
@@ -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 =
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
39
packages/opencode/src/util/scoped-state.ts
Normal file
39
packages/opencode/src/util/scoped-state.ts
Normal 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)
|
||||
164
packages/opencode/test/util/scoped-state.test.ts
Normal file
164
packages/opencode/test/util/scoped-state.test.ts
Normal 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"])
|
||||
})
|
||||
Reference in New Issue
Block a user