6.4 KiB
opencode database guide
Database
- Schema: Drizzle schema lives in
src/**/*.sql.ts. - Naming: tables and columns use snake*case; join columns are
<entity>_id; indexes are<table>*<column>\_idx. - Migrations: generated by Drizzle Kit using
drizzle.config.ts(schema:./src/**/*.sql.ts, output:./migration). - Command:
bun run db generate --name <slug>. - Output: creates
migration/<timestamp>_<slug>/migration.sqlandsnapshot.json. - Tests: migration tests should read the per-folder layout (no
_journal.json).
Module shape
Do not use export namespace Foo { ... } for module organization. It is not
standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript
runner. Use flat top-level exports combined with a self-reexport at the bottom
of the file:
// src/foo/foo.ts
export interface Interface { ... }
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(Service, ...)
export const defaultLayer = layer.pipe(...)
export * as Foo from "./foo"
Consumers import the namespace projection:
import { Foo } from "@/foo/foo"
yield * Foo.Service
Foo.layer
Foo.defaultLayer
Namespace-private helpers stay as non-exported top-level declarations in the
same file — they remain inaccessible to consumers (they are not projected by
export * as) but are usable by the file's own code.
When the file is an index.ts
If the module is foo/index.ts (single-namespace directory), use "." for
the self-reexport source rather than "./index":
// src/foo/index.ts
export const thing = ...
export * as Foo from "."
Multi-sibling directories
For directories with several independent modules (e.g. src/session/,
src/config/), keep each sibling as its own file with its own self-reexport,
and do not add a barrel index.ts. Consumers import the specific sibling:
import { SessionRetry } from "@/session/retry"
import { SessionStatus } from "@/session/status"
Barrels in multi-sibling directories force every import through the barrel to evaluate every sibling, which defeats tree-shaking and slows module load.
opencode Effect rules
Use these rules when writing or migrating Effect code.
See specs/effect/migration.md for the compact pattern reference and examples.
Core
- Use
Effect.gen(function* () { ... })for composition. - Use
Effect.fn("Domain.method")for named/traced effects andEffect.fnUntracedfor internal helpers. Effect.fn/Effect.fnUntracedaccept pipeable operators as extra arguments, so avoid unnecessary outer.pipe()wrappers.- Use
Effect.callbackfor callback-based APIs. - Prefer
DateTime.nowAsDateovernew Date(yield* Clock.currentTimeMillis)when you need aDate.
Module conventions
- In
src/config, follow the existing self-export pattern at the top of the file (for exampleexport * as ConfigAgent from "./agent") when adding a new config module.
Schemas and errors
- Use
Schema.Classfor multi-field data. - Use branded schemas (
Schema.brand) for single-value types. - Use
Schema.TaggedErrorClassfor typed errors. - Use
Schema.Defectinstead ofunknownfor defect-like causes. - In
Effect.gen/Effect.fn, preferyield* new MyError(...)overyield* Effect.fail(new MyError(...))for direct early-failure branches.
Runtime vs InstanceState
- Use
makeRuntime(fromsrc/effect/run-service.ts) for all services. It returns{ runPromise, runFork, runCallback }backed by a sharedmemoMapthat deduplicates layers. - Use
InstanceState(fromsrc/effect/instance-state.ts) for per-directory or per-project state that needs per-instance cleanup. It usesScopedCachekeyed by directory — each open project gets its own state, automatically cleaned up on disposal. - If two open directories should not share one copy of the service, it needs
InstanceState. - Do the work directly in the
InstanceState.makeclosure —ScopedCachehandles run-once semantics. Don't add fibers,ensure()callbacks, orstartedflags on top. - Use
Effect.addFinalizerorEffect.acquireReleaseinside theInstanceState.makeclosure for cleanup (subscriptions, process teardown, etc.). - Use
Effect.forkScopedinside the closure for background stream consumers — the fiber is interrupted when the instance is disposed. - To make a service's
init()non-blocking, forkInstanceState.get(state)at theinit()call site (e.g.Effect.forkIn(scope)), not by forking work inside theInstanceState.makeclosure. Forking inside the closure leaves state incomplete for other methods that read it. src/project/bootstrap.tsalready wraps every serviceinit()inEffect.forkDetach, soinit()is fire-and-forget in production. Keepinit()methods synchronous internally; the caller controls concurrency.
Effect v4 beta API
Effect.forkandEffect.forkDaemondo not exist. UseEffect.forkIn(scope)to fork a fiber into a specific scope.
Preferred Effect services
- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
- Prefer
FileSystem.FileSysteminstead of rawfs/promisesfor effectful file I/O. - Prefer
ChildProcessSpawner.ChildProcessSpawnerwithChildProcess.make(...)instead of custom process wrappers. - Prefer
HttpClient.HttpClientinstead of rawfetch. - Prefer
Path.Path,Config,Clock, andDateTimewhen those concerns are already inside Effect code. - For background loops or scheduled tasks, use
Effect.repeatorEffect.schedulewithEffect.forkScopedin the layer definition.
Effect.cached for deduplication
Use Effect.cached when multiple concurrent callers should share a single in-flight computation rather than storing Fiber | undefined or Promise | undefined manually. See specs/effect/migration.md for the full pattern.
Instance.bind — ALS for native callbacks
Instance.bind(fn) captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
Use it for native addon callbacks (@parcel/watcher, node-pty, native fs.watch, etc.) that need to call Bus.publish or anything that reads Instance.directory.
You do not need it for setTimeout, Promise.then, EventEmitter.on, or Effect fibers.
const cb = Instance.bind((err, evts) => {
Bus.publish(MyEvent, { ... })
})
nativeAddon.subscribe(dir, cb)