mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
refactor(state): replace ScopedState with InstanceState
Replace the generic ScopedState (keyed by caller-provided root) with InstanceState that hardcodes Instance.directory and integrates with the instance dispose/reload lifecycle via a global task registry. - Parallelize State + InstanceState disposal in reload/dispose - Use Effect's Record.map and Struct.pick in auth-service - Flatten nested Effect.gen in OAuth callback flow - Add docstrings to ProviderAuthService interface - Add State and InstanceState tests
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { Effect } from "effect"
|
||||
import { Log } from "@/util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
@@ -5,6 +6,7 @@ import { State } from "./state"
|
||||
import { iife } from "@/util/iife"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import * as InstanceState from "@/util/instance-state"
|
||||
|
||||
interface Context {
|
||||
directory: string
|
||||
@@ -106,7 +108,7 @@ export const Instance = {
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
const directory = Filesystem.resolve(input.directory)
|
||||
Log.Default.info("reloading instance", { directory })
|
||||
await State.dispose(directory)
|
||||
await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))])
|
||||
cache.delete(directory)
|
||||
const next = track(directory, boot({ ...input, directory }))
|
||||
emit(directory)
|
||||
@@ -114,7 +116,7 @@ export const Instance = {
|
||||
},
|
||||
async dispose() {
|
||||
Log.Default.info("disposing instance", { directory: Instance.directory })
|
||||
await State.dispose(Instance.directory)
|
||||
await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))])
|
||||
cache.delete(Instance.directory)
|
||||
emit(Instance.directory)
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Plugin } from "../plugin"
|
||||
import { filter, fromEntries, map, mapValues, pipe } from "remeda"
|
||||
import { filter, fromEntries, map, 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 * as InstanceState from "@/util/instance-state"
|
||||
import { ProviderID } from "./schema"
|
||||
import z from "zod"
|
||||
|
||||
@@ -44,13 +44,20 @@ export type ProviderAuthError =
|
||||
|
||||
export namespace ProviderAuthService {
|
||||
export interface Service {
|
||||
/** Get available auth methods for each provider (e.g. OAuth, API key). */
|
||||
readonly methods: () => Effect.Effect<Record<string, Method[]>>
|
||||
|
||||
/** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */
|
||||
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
|
||||
|
||||
/** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */
|
||||
readonly callback: (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) => Effect.Effect<void, ProviderAuthError>
|
||||
|
||||
/** Set an API key directly for a provider (no OAuth flow). */
|
||||
readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect<void, Auth.AuthServiceError>
|
||||
}
|
||||
}
|
||||
@@ -62,8 +69,7 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
|
||||
ProviderAuthService,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.AuthService
|
||||
const state = yield* ScopedState.make({
|
||||
root: () => Instance.directory,
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: () =>
|
||||
Effect.promise(async () => {
|
||||
const methods = pipe(
|
||||
@@ -76,30 +82,22 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
|
||||
}),
|
||||
})
|
||||
|
||||
const methods = Effect.fn("ProviderAuthService.methods")(() =>
|
||||
ScopedState.get(state).pipe(
|
||||
Effect.map((x) =>
|
||||
mapValues(x.methods, (y) =>
|
||||
y.methods.map(
|
||||
(z): Method => ({
|
||||
type: z.type,
|
||||
label: z.label,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
|
||||
const x = yield* InstanceState.get(state)
|
||||
return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"])))
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
}) {
|
||||
const item = (yield* ScopedState.get(state)).methods[input.providerID]
|
||||
const method = item.methods[input.method]
|
||||
const authHook = (yield* InstanceState.get(state)).methods[input.providerID]
|
||||
const method = authHook.methods[input.method]
|
||||
if (method.type !== "oauth") return
|
||||
const result = yield* Effect.promise(() => method.authorize())
|
||||
;(yield* ScopedState.get(state)).pending[input.providerID] = result
|
||||
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.pending[input.providerID] = result
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
@@ -112,17 +110,15 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
|
||||
method: number
|
||||
code?: string
|
||||
}) {
|
||||
const match = (yield* ScopedState.get(state)).pending[input.providerID]
|
||||
const match = (yield* InstanceState.get(state)).pending[input.providerID]
|
||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||
|
||||
const result =
|
||||
match.method === "code"
|
||||
? yield* Effect.gen(function* () {
|
||||
const code = input.code
|
||||
if (!code) return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||
return yield* Effect.promise(() => match.callback(code))
|
||||
})
|
||||
: yield* Effect.promise(() => match.callback())
|
||||
if (match.method === "code" && !input.code)
|
||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||
)
|
||||
|
||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||
|
||||
|
||||
48
packages/opencode/src/util/instance-state.ts
Normal file
48
packages/opencode/src/util/instance-state.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Effect, ScopedCache, Scope } from "effect"
|
||||
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
const TypeId = Symbol.for("@opencode/InstanceState")
|
||||
|
||||
type Task = (key: string) => Effect.Effect<void>
|
||||
|
||||
const tasks = new Set<Task>()
|
||||
|
||||
export interface InstanceState<A, E = never, R = never> {
|
||||
readonly [TypeId]: typeof TypeId
|
||||
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
||||
}
|
||||
|
||||
export const make = <A, E = never, R = never>(input: {
|
||||
lookup: (key: string) => Effect.Effect<A, E, R>
|
||||
release?: (value: A, key: string) => Effect.Effect<void>
|
||||
}): Effect.Effect<InstanceState<A, E, R>, never, R | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* 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)),
|
||||
})
|
||||
|
||||
const task: Task = (key) => ScopedCache.invalidate(cache, key)
|
||||
tasks.add(task)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task)))
|
||||
|
||||
return {
|
||||
[TypeId]: TypeId,
|
||||
cache,
|
||||
}
|
||||
})
|
||||
|
||||
export const get = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
|
||||
|
||||
export const has = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
|
||||
|
||||
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
ScopedCache.invalidate(self.cache, Instance.directory)
|
||||
|
||||
export const dispose = (key: string) =>
|
||||
Effect.all(
|
||||
[...tasks].map((task) => task(key)),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
@@ -1,39 +0,0 @@
|
||||
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)
|
||||
87
packages/opencode/test/project/state.test.ts
Normal file
87
packages/opencode/test/project/state.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "bun:test"
|
||||
|
||||
import { State } from "../../src/project/state"
|
||||
|
||||
test("State.create caches values for the same key", async () => {
|
||||
let key = "a"
|
||||
let n = 0
|
||||
const state = State.create(
|
||||
() => key,
|
||||
() => ({ n: ++n }),
|
||||
)
|
||||
|
||||
const a = state()
|
||||
const b = state()
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
|
||||
await State.dispose("a")
|
||||
})
|
||||
|
||||
test("State.create isolates values by key", async () => {
|
||||
let key = "a"
|
||||
let n = 0
|
||||
const state = State.create(
|
||||
() => key,
|
||||
() => ({ n: ++n }),
|
||||
)
|
||||
|
||||
const a = state()
|
||||
key = "b"
|
||||
const b = state()
|
||||
key = "a"
|
||||
const c = state()
|
||||
|
||||
expect(a).toBe(c)
|
||||
expect(a).not.toBe(b)
|
||||
expect(n).toBe(2)
|
||||
|
||||
await State.dispose("a")
|
||||
await State.dispose("b")
|
||||
})
|
||||
|
||||
test("State.dispose clears a key and runs cleanup", async () => {
|
||||
const seen: string[] = []
|
||||
let key = "a"
|
||||
let n = 0
|
||||
const state = State.create(
|
||||
() => key,
|
||||
() => ({ n: ++n }),
|
||||
async (value) => {
|
||||
seen.push(String(value.n))
|
||||
},
|
||||
)
|
||||
|
||||
const a = state()
|
||||
await State.dispose("a")
|
||||
const b = state()
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
expect(seen).toEqual(["1"])
|
||||
|
||||
await State.dispose("a")
|
||||
})
|
||||
|
||||
test("State.create dedupes concurrent promise initialization", async () => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
let n = 0
|
||||
const state = State.create(
|
||||
() => "a",
|
||||
async () => {
|
||||
n += 1
|
||||
await gate.promise
|
||||
return { n }
|
||||
},
|
||||
)
|
||||
|
||||
const task = Promise.all([state(), state()])
|
||||
await Promise.resolve()
|
||||
expect(n).toBe(1)
|
||||
|
||||
gate.resolve()
|
||||
const [a, b] = await task
|
||||
expect(a).toBe(b)
|
||||
|
||||
await State.dispose("a")
|
||||
})
|
||||
139
packages/opencode/test/util/instance-state.test.ts
Normal file
139
packages/opencode/test/util/instance-state.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import * as InstanceState from "../../src/util/instance-state"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
async function access<A, E>(state: InstanceState.InstanceState<A, E>, dir: string) {
|
||||
return Instance.provide({
|
||||
directory: dir,
|
||||
fn: () => Effect.runPromise(InstanceState.get(state)),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("InstanceState caches values for the same instance", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: () => Effect.sync(() => ({ n: ++n })),
|
||||
})
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState isolates values by directory", async () => {
|
||||
await using a = await tmpdir()
|
||||
await using b = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: (dir) => Effect.sync(() => ({ dir, n: ++n })),
|
||||
})
|
||||
|
||||
const x = yield* Effect.promise(() => access(state, a.path))
|
||||
const y = yield* Effect.promise(() => access(state, b.path))
|
||||
const z = yield* Effect.promise(() => access(state, a.path))
|
||||
|
||||
expect(x).toBe(z)
|
||||
expect(x).not.toBe(y)
|
||||
expect(n).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState is disposed on instance reload", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const seen: string[] = []
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: () => Effect.sync(() => ({ n: ++n })),
|
||||
release: (value) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(String(value.n))
|
||||
}),
|
||||
})
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
|
||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
expect(seen).toEqual(["1"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState is disposed on disposeAll", async () => {
|
||||
await using a = await tmpdir()
|
||||
await using b = await tmpdir()
|
||||
const seen: string[] = []
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: (dir) => Effect.sync(() => ({ dir })),
|
||||
release: (value) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(value.dir)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => access(state, a.path))
|
||||
yield* Effect.promise(() => access(state, b.path))
|
||||
yield* Effect.promise(() => Instance.disposeAll())
|
||||
|
||||
expect(seen.sort()).toEqual([a.path, b.path].sort())
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState dedupes concurrent lookups for the same directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: () =>
|
||||
Effect.promise(async () => {
|
||||
n += 1
|
||||
await Bun.sleep(10)
|
||||
return { n }
|
||||
}),
|
||||
})
|
||||
|
||||
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
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