Skip to content

Commit 5cd4c6e

Browse files
authored
refactor: destroy Storage facades (#21956)
1 parent 40358d6 commit 5cd4c6e

5 files changed

Lines changed: 265 additions & 257 deletions

File tree

packages/opencode/specs/effect-migration.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,32 @@ Current raw fs users that will convert during tool migration:
308308
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
309309
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
310310
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
311+
312+
## Destroying the facades
313+
314+
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
315+
316+
### Process
317+
318+
For each service, the migration is roughly:
319+
320+
1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself.
321+
2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`:
322+
- Add the service to the caller's layer R type (`Layer.Layer<Self, never, ... | Namespace.Service>`)
323+
- Yield it at the top of the layer: `const ns = yield* Namespace.Service`
324+
- Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case)
325+
- Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain
326+
3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency.
327+
4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`.
328+
5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`.
329+
330+
### Pitfalls
331+
332+
- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases.
333+
- **`Effect.tryPromise``yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong.
334+
- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition.
335+
- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean.
336+
337+
### Migration log
338+
339+
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.

packages/opencode/src/session/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -361,10 +361,11 @@ export namespace Session {
361361
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
362362
Effect.sync(() => Database.use(fn))
363363

364-
export const layer: Layer.Layer<Service, never, Bus.Service> = Layer.effect(
364+
export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = Layer.effect(
365365
Service,
366366
Effect.gen(function* () {
367367
const bus = yield* Bus.Service
368+
const storage = yield* Storage.Service
368369

369370
const createNext = Effect.fn("Session.createNext")(function* (input: {
370371
id?: SessionID
@@ -585,7 +586,7 @@ export namespace Session {
585586
})
586587

587588
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
588-
return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
589+
return yield* storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID]).pipe(
589590
Effect.orElseSucceed((): Snapshot.FileDiff[] => []),
590591
)
591592
})
@@ -660,7 +661,7 @@ export namespace Session {
660661
}),
661662
)
662663

663-
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
664+
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
664665

665666
const { runPromise } = makeRuntime(Service, defaultLayer)
666667

packages/opencode/src/storage/storage.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { Global } from "../global"
44
import { NamedError } from "@opencode-ai/util/error"
55
import z from "zod"
66
import { AppFileSystem } from "@/filesystem"
7-
import { makeRuntime } from "@/effect/run-service"
87
import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
98
import { Git } from "@/git"
109

@@ -331,26 +330,4 @@ export namespace Storage {
331330
)
332331

333332
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
334-
335-
const { runPromise } = makeRuntime(Service, defaultLayer)
336-
337-
export async function remove(key: string[]) {
338-
return runPromise((svc) => svc.remove(key))
339-
}
340-
341-
export async function read<T>(key: string[]) {
342-
return runPromise((svc) => svc.read<T>(key))
343-
}
344-
345-
export async function update<T>(key: string[], fn: (draft: T) => void) {
346-
return runPromise((svc) => svc.update<T>(key, fn))
347-
}
348-
349-
export async function write<T>(key: string[], content: T) {
350-
return runPromise((svc) => svc.write(key, content))
351-
}
352-
353-
export async function list(prefix: string[]) {
354-
return runPromise((svc) => svc.list(prefix))
355-
}
356333
}

packages/opencode/test/share/share-next.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Provider } from "../../src/provider/provider"
1313
import { Session } from "../../src/session"
1414
import type { SessionID } from "../../src/session/schema"
1515
import { ShareNext } from "../../src/share/share-next"
16+
import { Storage } from "../../src/storage/storage"
1617
import { SessionShareTable } from "../../src/share/share.sql"
1718
import { Database, eq } from "../../src/storage/db"
1819
import { provideTmpdirInstance } from "../fixture/fixture"
@@ -55,7 +56,7 @@ function wired(client: HttpClient.HttpClient) {
5556
return Layer.mergeAll(
5657
Bus.layer,
5758
ShareNext.layer,
58-
Session.layer,
59+
Session.defaultLayer,
5960
AccountRepo.layer,
6061
NodeFileSystem.layer,
6162
CrossSpawnSpawner.defaultLayer,

0 commit comments

Comments
 (0)