Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton
98fef45553 fix(httpapi): 404 status + body shape for missing-session errors
Two related divergences from Hono are fixed in one move:

1. Status: many session handlers (todo, diff, summarize, fork, abort,
   init, deleteMessage, command, shell, revert, unrevert) didn't wrap
   with mapNotFound, so a thrown NotFoundError surfaced as a 500 defect
   instead of a 404. The fork/diff endpoints also lacked OpencodeNotFound
   in their declared error union, so handlers couldn't surface 404 even
   if they wanted to.

2. Body shape: the existing mapNotFound rebrand to HttpApiError.NotFound
   produced an empty 404 response. Hono returns the NamedError envelope
   `{ name: "NotFoundError", data: { message } }`. SDK consumers reading
   `error.data.message` got undefined.

The fix introduces OpencodeNotFound — a Schema.ErrorClass annotated with
`httpApiStatus: 404` and a body schema matching the legacy NamedError
shape. mapNotFound now rebrands NotFoundError to OpencodeNotFound,
preserving the underlying error message. All session endpoints that take
a sessionID now wrap their service calls with mapNotFound.

A TODO in the handler notes the long-term direction: services should
fail with typed errors directly (Effect<T, SessionNotFound>) and let
HttpApi auto-route status + body via schema annotations, eliminating
mapNotFound entirely. This PR is the pragmatic middle: small surface,
no service-layer changes, fixes the user-visible parity bug.

Unskips the two .todo parity reproducers in httpapi-parity.test.ts.
2026-05-02 23:33:14 -04:00
26 changed files with 308 additions and 909 deletions

View File

@@ -307,19 +307,6 @@
"@lydell/node-pty-win32-x64": "1.2.0-beta.10",
},
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "0.0.0",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.33",
@@ -409,7 +396,6 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -1586,8 +1572,6 @@
"@opencode-ai/desktop-electron": ["@opencode-ai/desktop-electron@workspace:packages/desktop-electron"],
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],

View File

@@ -1,24 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "0.0.0",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}

View File

@@ -1,246 +0,0 @@
import { Database } from "bun:sqlite"
import { drizzle as drizzleBun, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SQLiteCountBuilder } from "drizzle-orm/sqlite-core/query-builders/count"
import { SQLiteDeleteBase } from "drizzle-orm/sqlite-core/query-builders/delete"
import { SQLiteInsertBase } from "drizzle-orm/sqlite-core/query-builders/insert"
import { SQLiteRelationalQuery, SQLiteSyncRelationalQuery } from "drizzle-orm/sqlite-core/query-builders/_query"
import { SQLiteSelectBase } from "drizzle-orm/sqlite-core/query-builders/select"
import { SQLiteUpdateBase } from "drizzle-orm/sqlite-core/query-builders/update"
import type { PreparedQueryConfig, SQLiteSession, SQLiteTransaction, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
import { SQLitePreparedQuery } from "drizzle-orm/sqlite-core/session"
import type { DrizzleConfig } from "drizzle-orm/utils"
import { Cause, Effect, Exit, Schema } from "effect"
import * as Effectable from "effect/Effectable"
export class EffectDrizzleQueryError extends Schema.TaggedErrorClass<EffectDrizzleQueryError>()(
"EffectDrizzleQueryError",
{
query: Schema.String,
params: Schema.Array(Schema.Unknown),
cause: Schema.Unknown,
},
) {
override get message() {
return `Failed query: ${this.query}\nparams: ${this.params}`
}
constructor(params: { readonly query: string; readonly params: ReadonlyArray<unknown>; readonly cause: unknown }) {
super(params)
Error.captureStackTrace?.(this, EffectDrizzleQueryError)
}
}
export type EffectSQLiteDatabase<
TSchema extends Record<string, unknown> = Record<string, never>,
TRelations extends AnyRelations = EmptyRelations,
> = SQLiteBunDatabase<TSchema, TRelations> & {
readonly $client: Database
readonly withTransaction: <A, E, R>(
effect: Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
) => Effect.Effect<A, E, R>
}
export type MakeConfig<
TSchema extends Record<string, unknown> = Record<string, never>,
TRelations extends AnyRelations = EmptyRelations,
> = DrizzleConfig<TSchema, TRelations> & {
readonly client?: Database
}
type EffectLikeQuery<A = unknown> = {
readonly asEffect?: () => Effect.Effect<A, EffectDrizzleQueryError>
readonly toSQL?: () => { readonly sql: string; readonly params?: readonly unknown[] }
}
type PreparedLike<A = unknown> = EffectLikeQuery<A> & {
readonly execute: () => unknown
readonly getQuery?: () => { readonly sql: string; readonly params?: readonly unknown[] }
}
type SelectLike<A = unknown> = EffectLikeQuery<A> & {
readonly all: () => A
}
type GetLike<A = unknown> = EffectLikeQuery & {
readonly get: () => A
}
type MutationLike<A = unknown> = EffectLikeQuery<A> & {
readonly all: () => A
readonly run: () => A
readonly config?: { readonly returning?: unknown }
}
type CountLike = EffectLikeQuery<number> & {
readonly session: { readonly values: (sql: unknown) => unknown[][] }
readonly sql: unknown
}
class TransactionFailure extends Error {
constructor(readonly effectCause: Cause.Cause<unknown>) {
super("Effect transaction failed")
}
}
const queryInfo = (query: EffectLikeQuery | PreparedLike) => {
const info = "getQuery" in query && typeof query.getQuery === "function" ? query.getQuery() : query.toSQL?.()
return {
query: info?.sql ?? "<unknown>",
params: [...(info?.params ?? [])],
}
}
const queryError = (query: EffectLikeQuery | PreparedLike, cause: unknown) =>
new EffectDrizzleQueryError({
...queryInfo(query),
cause,
})
const fromSync = <A>(query: EffectLikeQuery, run: () => A) =>
Effect.try({
try: run,
catch: (cause) => queryError(query, cause),
})
const fromMutation = (query: MutationLike) => fromSync(query, () => (query.config?.returning ? query.all() : query.run()))
const fromCount = (query: CountLike) => fromSync(query, () => Number(query.session.values(query.sql)[0]?.[0] ?? 0))
export const getOne = <A>(query: GetLike<A>) => fromSync(query, () => query.get())
const fromExecuteResult = (result: unknown) => {
if (result && typeof result === "object" && "sync" in result && typeof result.sync === "function") {
return result.sync()
}
return result
}
const queryEffectProto = {
...Effectable.Prototype<Effect.Effect<unknown, EffectDrizzleQueryError> & EffectLikeQuery>({
label: "DrizzleSqliteQuery",
evaluate(this: EffectLikeQuery) {
return this.asEffect?.() ?? Effect.die("Drizzle SQLite query is missing asEffect()")
},
}),
commit(this: EffectLikeQuery) {
return this.asEffect?.() ?? Effect.die("Drizzle SQLite query is missing asEffect()")
},
}
const patchClass = <A>(ctor: { readonly prototype: object }, asEffect: (self: A) => Effect.Effect<unknown, EffectDrizzleQueryError>) => {
if (Object.prototype.hasOwnProperty.call(ctor.prototype, "asEffect")) return
Object.assign(ctor.prototype, queryEffectProto, {
asEffect(this: A) {
return asEffect(this)
},
})
}
// `patchClass` is idempotent via `hasOwnProperty` check, so calling this
// repeatedly is cheap. Patches are applied to Drizzle prototypes globally and
// survive any Database close/reopen cycle.
const patchQueryBuilders = () => {
patchClass(SQLitePreparedQuery, (query: PreparedLike) => fromSync(query, () => fromExecuteResult(query.execute())))
patchClass(SQLiteSelectBase, (query: SelectLike) => fromSync(query, () => query.all()))
patchClass(SQLiteInsertBase, fromMutation)
patchClass(SQLiteUpdateBase, fromMutation)
patchClass(SQLiteDeleteBase, fromMutation)
patchClass(SQLiteRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) =>
fromSync(query, () => query.executeRaw()),
)
patchClass(SQLiteSyncRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) =>
fromSync(query, () => query.executeRaw()),
)
patchClass(SQLiteCountBuilder, fromCount)
}
const attachTransaction = <
TSchema extends Record<string, unknown> = Record<string, never>,
TRelations extends AnyRelations = EmptyRelations,
>(db: SQLiteBunDatabase<TSchema, TRelations> & { readonly $client: Database }): EffectSQLiteDatabase<TSchema, TRelations> => {
const txStack: Array<SQLiteTransaction<"sync", void, TSchema, TRelations>> = []
const bound = new WeakMap<object, Map<PropertyKey, unknown>>()
const current = () => txStack.at(-1) ?? db
const runTransaction = (target: SQLiteBunDatabase<TSchema, TRelations> | SQLiteTransaction<"sync", void, TSchema, TRelations>) =>
target.transaction.bind(target) as (
transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => unknown,
config?: SQLiteTransactionConfig,
) => unknown
const withTransaction = <A, E, R>(
effect: Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
): Effect.Effect<A, E, R> =>
Effect.context<R>().pipe(
Effect.flatMap((context) =>
Effect.sync(
() =>
runTransaction(current())((tx) => {
txStack.push(tx)
try {
const exit = Effect.runSyncExit(Effect.provideContext(effect, context))
if (Exit.isSuccess(exit)) return exit.value
throw new TransactionFailure(exit.cause)
} finally {
txStack.pop()
}
}, config) as A,
).pipe(
Effect.catchDefect((defect) =>
defect instanceof TransactionFailure ? Effect.failCause(defect.effectCause as Cause.Cause<E>) : Effect.die(defect),
),
),
),
)
return new Proxy(db, {
get(_target, property) {
if (property === "withTransaction") return withTransaction
if (property === "$client") return db.$client
const target = current()
const value = Reflect.get(target, property)
if (typeof value !== "function") return value
const methods = bound.get(target) ?? new Map<PropertyKey, unknown>()
bound.set(target, methods)
if (!methods.has(property)) methods.set(property, value.bind(target))
return methods.get(property)
},
}) as EffectSQLiteDatabase<TSchema, TRelations>
}
export const make = <
TSchema extends Record<string, unknown> = Record<string, never>,
TRelations extends AnyRelations = EmptyRelations,
>(config: MakeConfig<TSchema, TRelations> = {}): EffectSQLiteDatabase<TSchema, TRelations> => {
patchQueryBuilders()
return attachTransaction(
drizzleBun({
...config,
client: config.client ?? new Database(":memory:"),
}),
)
}
export const drizzle = make
declare module "drizzle-orm/query-promise" {
interface QueryPromise<T> extends Effect.Effect<T, EffectDrizzleQueryError> {
asEffect(): Effect.Effect<T, EffectDrizzleQueryError>
}
}
declare module "drizzle-orm/sqlite-core/session" {
interface SQLitePreparedQuery<T extends PreparedQueryConfig> extends Effect.Effect<T["execute"], EffectDrizzleQueryError> {
asEffect(): Effect.Effect<T["execute"], EffectDrizzleQueryError>
}
}
declare module "drizzle-orm/sqlite-core/query-builders/count" {
interface SQLiteCountBuilder<TSession extends SQLiteSession<any, any, any, any>>
extends Effect.Effect<number, EffectDrizzleQueryError> {
asEffect(): Effect.Effect<number, EffectDrizzleQueryError>
}
}

View File

@@ -1,246 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { Database } from "bun:sqlite"
import { eq } from "drizzle-orm"
import { relations } from "drizzle-orm/_relations"
import { drizzle as drizzleBun } from "drizzle-orm/bun-sqlite"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Cause, Effect, Exit } from "effect"
import { EffectDrizzleQueryError, getOne, make, type EffectSQLiteDatabase } from "../src"
const users = sqliteTable("users", {
id: integer().primaryKey(),
name: text().notNull(),
})
const posts = sqliteTable("posts", {
id: integer().primaryKey(),
user_id: integer()
.notNull()
.references(() => users.id),
title: text().notNull(),
})
const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}))
const postsRelations = relations(posts, ({ one }) => ({
user: one(users, {
fields: [posts.user_id],
references: [users.id],
}),
}))
const schema = { users, posts, usersRelations, postsRelations }
let db: EffectSQLiteDatabase<typeof schema>
const testEffect = <A, E>(name: string, effect: () => Effect.Effect<A, E>) => test(name, () => Effect.runPromise(effect()))
beforeEach(() => {
db = make({ schema })
db.$client.run("PRAGMA foreign_keys = ON")
db.$client.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
db.$client.run(
"CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), title TEXT NOT NULL)",
)
})
afterEach(() => {
db.$client.close()
})
describe("effect drizzle sqlite", () => {
test("keeps normal Drizzle Bun SQLite clients usable after patching", async () => {
const sqlite = new Database(":memory:")
try {
const normal = drizzleBun({ client: sqlite })
sqlite.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
normal.insert(users).values({ id: 1, name: "Ada" }).run()
expect(normal.select().from(users).all()).toEqual([{ id: 1, name: "Ada" }])
expect(await normal.select().from(users)).toEqual([{ id: 1, name: "Ada" }])
} finally {
sqlite.close()
}
})
testEffect("makes select/insert/update/delete query builders yieldable Effects", () =>
Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
yield* db.insert(users).values({ id: 2, name: "Grace" })
const selected = yield* db.select().from(users).orderBy(users.id)
expect(selected).toEqual([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
])
const updated = yield* db.update(users).set({ name: "Lovelace" }).where(eq(users.id, 1)).returning()
expect(updated).toEqual([{ id: 1, name: "Lovelace" }])
const deleted = yield* db.delete(users).where(eq(users.id, 2)).returning({ id: users.id })
expect(deleted).toEqual([{ id: 2 }])
expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Lovelace" }])
}),
)
testEffect("supports direct Effect combinators on queries", () =>
Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
expect(
yield* (db.select().from(users) as Effect.Effect<Array<{ readonly name: string }>, EffectDrizzleQueryError>).pipe(
Effect.map((rows) => rows.map((row) => row.name)),
),
).toEqual(["Ada"])
}),
)
testEffect("supports relational query builders", () =>
Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
yield* db.insert(posts).values({ id: 1, user_id: 1, title: "Notes" })
expect(
yield* db._query.users.findMany({
with: {
posts: true,
},
}),
).toEqual([
{
id: 1,
name: "Ada",
posts: [{ id: 1, user_id: 1, title: "Notes" }],
},
])
}),
)
testEffect("runs synchronous Effect programs inside transactions", () =>
Effect.gen(function* () {
yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
return yield* db.select().from(users)
}).pipe(db.withTransaction)
expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Ada" }])
const exit = yield* Effect.exit(
Effect.gen(function* () {
yield* db.insert(users).values({ id: 2, name: "Grace" })
return yield* Effect.fail("rollback")
}).pipe(db.withTransaction),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* db.select().from(users).orderBy(users.id)).toEqual([{ id: 1, name: "Ada" }])
}),
)
testEffect("supports pipeable transactions using the same database service", () =>
Effect.gen(function* () {
const exit = yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
return yield* Effect.fail("rollback")
}).pipe(db.withTransaction, Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* db.select().from(users)).toEqual([])
yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 2, name: "Grace" })
expect(yield* db.$count(users)).toBe(1)
}).pipe(db.withTransaction)
expect(yield* db.select().from(users)).toEqual([{ id: 2, name: "Grace" }])
}),
)
testEffect("supports count builders and prepared queries", () =>
Effect.gen(function* () {
yield* db.insert(users).values([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
])
expect(yield* db.$count(users)).toBe(2)
const prepared = db.select().from(users).orderBy(users.id).prepare()
expect(yield* prepared).toEqual([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
])
}),
)
testEffect("supports single-row select effects", () =>
Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
expect(yield* getOne(db.select().from(users).where(eq(users.id, 1)))).toEqual({ id: 1, name: "Ada" })
expect(yield* getOne(db.select().from(users).where(eq(users.id, 2)))).toBeUndefined()
}),
)
testEffect("nested pipeable transactions commit or roll back with the outer transaction", () =>
Effect.gen(function* () {
yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 2, name: "Grace" })
}).pipe(db.withTransaction)
}).pipe(db.withTransaction)
expect(yield* db.select().from(users).orderBy(users.id)).toEqual([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
])
const exit = yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 3, name: "Katherine" })
yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 4, name: "Dorothy" })
return yield* Effect.fail("inner rollback")
}).pipe(db.withTransaction)
}).pipe(db.withTransaction, Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* db.select().from(users).orderBy(users.id)).toEqual([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
])
}),
)
testEffect("defects inside transactions roll back and stay defects", () =>
Effect.gen(function* () {
const exit = yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
return yield* Effect.die("boom")
}).pipe(db.withTransaction, Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(exit.cause.reasons.some(Cause.isDieReason)).toBe(true)
}
expect(yield* db.select().from(users)).toEqual([])
}),
)
testEffect("wraps query failures with query text and parameters", () =>
Effect.gen(function* () {
const exit = yield* Effect.exit(db.insert(posts).values({ id: 1, user_id: 404, title: "Missing" }))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = exit.cause.reasons.filter(Cause.isFailReason)[0]?.error
expect(error).toBeInstanceOf(EffectDrizzleQueryError)
expect((error as EffectDrizzleQueryError).query).toContain("insert into")
expect((error as EffectDrizzleQueryError).params).toEqual([1, 404, "Missing"])
}
}),
)
})

View File

@@ -1,15 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
}

View File

@@ -110,7 +110,6 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",

View File

@@ -9,7 +9,8 @@ import path from "path"
import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import matter from "gray-matter"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "../../project/instance"
import { WithInstance } from "../../project/with-instance"
import { EOL } from "os"
import type { Argv } from "yargs"
import { Effect } from "effect"
@@ -34,7 +35,7 @@ const AVAILABLE_PERMISSIONS = [
"skill",
]
const AgentCreateCommand = effectCmd({
const AgentCreateCommand = cmd({
command: "create",
describe: "create a new agent",
builder: (yargs: Argv) =>
@@ -62,11 +63,10 @@ const AgentCreateCommand = effectCmd({
alias: ["m"],
describe: "model to use in the format of provider/model",
}),
handler: Effect.fn("Cli.agent.create")(function* (args) {
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
yield* Effect.promise(async () => {
async handler(args) {
await WithInstance.provide({
directory: process.cwd(),
async fn() {
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
@@ -79,7 +79,7 @@ const AgentCreateCommand = effectCmd({
prompts.intro("Create agent")
}
const project = ctx.project
const project = Instance.project
// Determine scope/path
let targetPath: string
@@ -94,7 +94,7 @@ const AgentCreateCommand = effectCmd({
{
label: "Current project",
value: "project" as const,
hint: ctx.worktree,
hint: Instance.worktree,
},
{
label: "Global",
@@ -107,7 +107,7 @@ const AgentCreateCommand = effectCmd({
scope = scopeResult
}
targetPath = path.join(
scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"),
scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
"agent",
)
}
@@ -230,8 +230,9 @@ const AgentCreateCommand = effectCmd({
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
}
},
})
}),
},
})
const AgentListCommand = effectCmd({

View File

@@ -11,7 +11,8 @@ import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "@/config/config"
import { ConfigMCP } from "../../config/mcp"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "../../project/instance"
import { WithInstance } from "../../project/with-instance"
import { Installation } from "../../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "path"
@@ -432,22 +433,21 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat
return configPath
}
export const McpAddCommand = effectCmd({
export const McpAddCommand = cmd({
command: "add",
describe: "add an MCP server",
handler: Effect.fn("Cli.mcp.add")(function* () {
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
yield* Effect.promise(async () => {
async handler() {
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add MCP server")
const project = ctx.project
const project = Instance.project
// Resolve config paths eagerly for hints
const [projectConfigPath, globalConfigPath] = await Promise.all([
resolveConfigPath(ctx.worktree),
resolveConfigPath(Instance.worktree),
resolveConfigPath(Global.Path.config, true),
])
@@ -592,11 +592,12 @@ export const McpAddCommand = effectCmd({
}
prompts.outro("MCP server added successfully")
},
})
}),
},
})
export const McpDebugCommand = effectCmd({
export const McpDebugCommand = cmd({
command: "debug <name>",
describe: "debug OAuth connection for an MCP server",
builder: (yargs) =>
@@ -605,8 +606,10 @@ export const McpDebugCommand = effectCmd({
type: "string",
demandOption: true,
}),
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
yield* Effect.promise(async () => {
async handler(args) {
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("MCP OAuth Debug")
@@ -778,6 +781,7 @@ export const McpDebugCommand = effectCmd({
}
prompts.outro("Debug complete")
},
})
}),
},
})

View File

@@ -1,7 +1,6 @@
import { Auth } from "../../auth"
import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { ModelsDev } from "@/provider/models"
@@ -14,6 +13,7 @@ import os from "os"
import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global"
import { Plugin } from "../../plugin"
import { WithInstance } from "../../project/with-instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "@/util/process"
import { text } from "node:stream/consumers"
@@ -232,14 +232,11 @@ export const ProvidersCommand = cmd({
async handler() {},
})
export const ProvidersListCommand = effectCmd({
export const ProvidersListCommand = cmd({
command: "list",
aliases: ["ls"],
describe: "list providers and credentials",
// Lists global credentials + provider env vars; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.list")(function* (_args) {
yield* Effect.promise(async () => {
async handler(_args) {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
@@ -283,11 +280,10 @@ export const ProvidersListCommand = effectCmd({
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
})
}),
},
})
export const ProvidersLoginCommand = effectCmd({
export const ProvidersLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
@@ -306,8 +302,10 @@ export const ProvidersLoginCommand = effectCmd({
describe: "login method label (skips method selection)",
type: "string",
}),
handler: Effect.fn("Cli.providers.login")(function* (args) {
yield* Effect.promise(async () => {
async handler(args) {
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
@@ -489,17 +487,15 @@ export const ProvidersLoginCommand = effectCmd({
})
prompts.outro("Done")
},
})
}),
},
})
export const ProvidersLogoutCommand = effectCmd({
export const ProvidersLogoutCommand = cmd({
command: "logout",
describe: "log out from a configured provider",
// Removes a global auth credential; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
yield* Effect.promise(async () => {
async handler(_args) {
UI.empty()
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
Effect.gen(function* () {
@@ -529,6 +525,5 @@ export const ProvidersLogoutCommand = effectCmd({
}),
)
prompts.outro("Logout successful")
})
}),
},
})

View File

@@ -5,8 +5,7 @@ import { InstanceState } from "@/effect/instance-state"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { DatabaseEffect } from "@/storage/db-effect"
import { getOne } from "@opencode-ai/effect-drizzle-sqlite"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import { zod } from "@/util/effect-zod"
import * as Log from "@opencode-ai/core/util/log"
@@ -154,15 +153,11 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const db = yield* DatabaseEffect.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) {
const row = yield* getOne(
db
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, ctx.project.id)),
).pipe(Effect.orDie)
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
)
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: row?.data ?? [],
@@ -324,6 +319,6 @@ export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return result
}
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Bus.layer), Layer.provide(DatabaseEffect.layer))
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export * as Permission from "."

View File

@@ -0,0 +1,21 @@
import { Schema } from "effect"
/**
* 404 Not Found error matching the legacy Hono `NamedError` JSON shape:
* `{ name: "NotFoundError", data: { message } }`.
*
* `httpApiStatus: 404` annotation drives the response status; the schema
* fields drive the response body. Use this in place of
* `HttpApiError.NotFound` (which has an empty body) anywhere SDK clients
* may inspect `error.data.message`.
*/
export class OpencodeNotFound extends Schema.ErrorClass<OpencodeNotFound>("opencode/Error/NotFound")(
{
name: Schema.tag("NotFoundError"),
data: Schema.Struct({ message: Schema.String }),
},
{
description: "Not found",
httpApiStatus: 404,
},
) {}

View File

@@ -12,6 +12,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { OpencodeNotFound } from "../errors"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
@@ -123,7 +124,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: described(Session.Info, "Get session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
@@ -134,7 +135,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID },
success: described(Schema.Array(Session.Info), "List of children"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.children",
@@ -145,7 +146,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
success: described(Schema.Array(Todo.Info), "Todo list"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.todo",
@@ -157,6 +158,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: DiffQuery,
success: described(Schema.Array(Snapshot.FileDiff), "Successfully retrieved diff"),
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
@@ -168,7 +170,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: MessagesQuery,
success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
@@ -179,7 +181,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(MessageV2.WithParts, "Message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
@@ -201,7 +203,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Successfully deleted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
@@ -213,7 +215,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: UpdatePayload,
success: described(Session.Info, "Successfully updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
@@ -225,6 +227,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: ForkPayload,
success: described(Session.Info, "200"),
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
@@ -235,7 +238,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Aborted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.abort",
@@ -247,7 +250,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: InitPayload,
success: described(Schema.Boolean, "200"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.init",
@@ -259,7 +262,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully shared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
@@ -270,7 +273,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully unshared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
@@ -282,7 +285,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: SummarizePayload,
success: described(Schema.Boolean, "Summarized session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",
@@ -294,7 +297,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: PromptPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt",
@@ -306,7 +309,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: PromptPayload,
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt_async",
@@ -319,7 +322,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: CommandPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.command",
@@ -331,7 +334,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: ShellPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.shell",
@@ -343,7 +346,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: RevertPayload,
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.revert",
@@ -355,7 +358,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID },
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unrevert",
@@ -367,7 +370,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID, permissionID: PermissionID },
payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.respond",
@@ -379,7 +382,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(Schema.Boolean, "Successfully deleted message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.deleteMessage",
@@ -391,7 +394,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
success: described(Schema.Boolean, "Successfully deleted part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "part.delete",
@@ -402,7 +405,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
payload: MessageV2.Part,
success: described(MessageV2.Part, "Successfully updated part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "part.update",

View File

@@ -17,6 +17,7 @@ import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage"
import { OpencodeNotFound } from "../errors"
import { NamedError } from "@opencode-ai/core/util/error"
import { Cause, Effect, Option, Schema, Scope } from "effect"
import * as Stream from "effect/Stream"
@@ -38,11 +39,17 @@ import {
UpdatePayload,
} from "../groups/session"
// TODO: long-term, services like Session.Service should fail with typed errors
// directly (e.g. Effect<SessionInfo, SessionNotFound>) and let HttpApi auto-route
// status + body via the schema annotations. Until then, we catch the legacy
// thrown NotFoundError at the boundary and rebrand to OpencodeNotFound — which
// matches the Hono NamedError JSON shape SDK consumers already expect.
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
Effect.catchDefect((error) =>
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
NotFoundError.isInstance(error)
? Effect.fail(new OpencodeNotFound({ data: { message: error.message } }))
: Effect.die(error),
),
)
@@ -87,14 +94,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* todoSvc.get(ctx.params.sessionID)
return yield* mapNotFound(todoSvc.get(ctx.params.sessionID))
})
const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: {
params: { sessionID: SessionID }
query: typeof DiffQuery.Type
}) {
return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID })
return yield* mapNotFound(summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }))
})
const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: {
@@ -198,11 +205,11 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof ForkPayload.Type
}) {
return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })
return yield* mapNotFound(session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }))
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* promptSvc.cancel(ctx.params.sessionID)
yield* mapNotFound(promptSvc.cancel(ctx.params.sessionID))
return true
})
@@ -210,13 +217,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof InitPayload.Type
}) {
yield* promptSvc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
})
yield* mapNotFound(
promptSvc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
}),
)
return true
})
@@ -234,22 +243,26 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof SummarizePayload.Type
}) {
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agentSvc.defaultAgent()
const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
return yield* mapNotFound(
Effect.gen(function* () {
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agentSvc.defaultAgent()
const currentAgent = messages.findLast((m) => m.info.role === "user")?.info.agent ?? defaultAgent
yield* compactSvc.create({
sessionID: ctx.params.sessionID,
agent: currentAgent,
model: {
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
return true
yield* compactSvc.create({
sessionID: ctx.params.sessionID,
agent: currentAgent,
model: {
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
return true
}),
)
})
const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {
@@ -297,25 +310,25 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof CommandPayload.Type
}) {
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
return yield* mapNotFound(promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID }))
})
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ShellPayload.Type
}) {
return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID })
return yield* mapNotFound(promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID }))
})
const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof RevertPayload.Type
}) {
return yield* revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })
return yield* mapNotFound(revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload }))
})
const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* revertSvc.unrevert({ sessionID: ctx.params.sessionID })
return yield* mapNotFound(revertSvc.unrevert({ sessionID: ctx.params.sessionID }))
})
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
@@ -329,8 +342,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
yield* runState.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
yield* mapNotFound(
Effect.gen(function* () {
yield* runState.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
}),
)
return true
})

View File

@@ -5,7 +5,7 @@ import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { Effect, Layer, Context, Schema } from "effect"
import z from "zod"
import { DatabaseEffect } from "@/storage/db-effect"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import { asc } from "drizzle-orm"
import { TodoTable } from "./session.sql"
@@ -42,34 +42,34 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const db = yield* DatabaseEffect.Service
const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
yield* Effect.gen(function* () {
yield* db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID))
if (input.todos.length === 0) return
yield* db.insert(TodoTable).values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
}).pipe(db.withTransaction, Effect.orDie)
yield* Effect.sync(() =>
Database.transaction((db) => {
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
db.insert(TodoTable)
.values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
.run()
}),
)
yield* bus.publish(Event.Updated, input)
})
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
const rows = yield* db
.select()
.from(TodoTable)
.where(eq(TodoTable.session_id, sessionID))
.orderBy(asc(TodoTable.position))
.pipe(Effect.orDie)
const rows = yield* Effect.sync(() =>
Database.use((db) =>
db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
),
)
return rows.map((row) => ({
content: row.content,
status: row.status,
@@ -81,6 +81,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Bus.layer), Layer.provide(DatabaseEffect.layer))
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export * as Todo from "./todo"

View File

@@ -9,8 +9,7 @@ import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import type { SessionID } from "@/session/schema"
import { DatabaseEffect } from "@/storage/db-effect"
import { getOne } from "@opencode-ai/effect-drizzle-sqlite"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import { Config } from "@/config/config"
import * as Log from "@opencode-ai/core/util/log"
@@ -77,6 +76,9 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ShareNext") {}
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
function api(resource: string): Api {
return {
create: `/api/${resource}`,
@@ -114,7 +116,6 @@ export const layer = Layer.effect(
const httpOk = HttpClient.filterStatusOk(http)
const provider = yield* Provider.Service
const session = yield* Session.Service
const db = yield* DatabaseEffect.Service
function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
return Effect.gen(function* () {
@@ -225,9 +226,9 @@ export const layer = Layer.effect(
})
const get = Effect.fnUntraced(function* (sessionID: SessionID) {
const row = yield* getOne(
db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)),
).pipe(Effect.orDie)
const row = yield* db((db) =>
db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
)
if (!row) return
return { id: row.id, secret: row.secret, url: row.url } satisfies Share
})
@@ -313,13 +314,16 @@ export const layer = Layer.effect(
Effect.flatMap((r) => httpOk.execute(r)),
Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)),
)
yield* db
.insert(SessionShareTable)
.values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
.onConflictDoUpdate({
target: SessionShareTable.session_id,
set: { id: result.id, secret: result.secret, url: result.url },
})
yield* db((db) =>
db
.insert(SessionShareTable)
.values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
.onConflictDoUpdate({
target: SessionShareTable.session_id,
set: { id: result.id, secret: result.secret, url: result.url },
})
.run(),
)
const s = yield* InstanceState.get(state)
s.shared.set(sessionID, result)
yield* full(sessionID).pipe(
@@ -351,7 +355,7 @@ export const layer = Layer.effect(
Effect.flatMap((r) => httpOk.execute(r)),
)
yield* db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID))
yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
s.shared.delete(sessionID)
s.queue.delete(sessionID)
})
@@ -360,14 +364,13 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Account.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(DatabaseEffect.layer),
)
export * as ShareNext from "./share-next"

View File

@@ -1,20 +0,0 @@
import { Database } from "@/storage/db"
import { Context, Layer } from "effect"
import type { EffectSQLiteDatabase } from "@opencode-ai/effect-drizzle-sqlite"
import * as StorageSchema from "@/storage/schema"
export class Service extends Context.Service<Service, EffectSQLiteDatabase<typeof StorageSchema>>()(
"@opencode/DatabaseEffect",
) {}
const client = new Proxy({} as EffectSQLiteDatabase<typeof StorageSchema>, {
get(_target, property) {
const db = Database.Client()
const value = Reflect.get(db, property)
return typeof value === "function" ? value.bind(db) : value
},
})
export const layer = Layer.succeed(Service, client)
export * as DatabaseEffect from "./db-effect"

View File

@@ -1,6 +1,8 @@
import { Database } from "bun:sqlite"
import { drizzle } from "@opencode-ai/effect-drizzle-sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
export function init<TSchema extends Record<string, unknown>>(path: string, schema: TSchema) {
return drizzle({ client: new Database(path, { create: true }), schema })
export function init(path: string) {
const sqlite = new Database(path, { create: true })
const db = drizzle({ client: sqlite })
return db
}

View File

@@ -1,6 +1,8 @@
import { DatabaseSync } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
export function init<TSchema extends Record<string, unknown>>(path: string, schema: TSchema) {
return drizzle({ client: new DatabaseSync(path), schema })
export function init(path: string) {
const sqlite = new DatabaseSync(path)
const db = drizzle({ client: sqlite })
return db
}

View File

@@ -1,4 +1,6 @@
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm"
import { LocalContext } from "@/util/local-context"
import { lazy } from "../util/lazy"
@@ -12,7 +14,6 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { InstanceState } from "@/effect/instance-state"
import { iife } from "@/util/iife"
import * as StorageSchema from "@/storage/schema"
import { init } from "#db"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
@@ -41,16 +42,16 @@ export const Path = iife(() => {
return getChannelPath()
})
export type Client = ReturnType<typeof open>
export type Transaction = SQLiteTransaction<"sync", void>
export type Transaction = Parameters<Parameters<Client["transaction"]>[0]>[0]
type Client = SQLiteBunDatabase
type Journal = { sql: string; timestamp: number; name: string }[]
// Drizzle's migrate overloads trigger expensive variance checks here; narrow to the journal overload we actually use.
const migrateFromJournal = migrate as unknown as (db: Client, entries: Journal) => void
const migrateFromJournal = migrate as unknown as (db: SQLiteBunDatabase, entries: Journal) => void
function applyMigrations(db: Client, entries: Journal) {
function applyMigrations(db: SQLiteBunDatabase, entries: Journal) {
migrateFromJournal(db, entries)
}
@@ -87,10 +88,10 @@ function migrations(dir: string): Journal {
return sql.sort((a, b) => a.timestamp - b.timestamp)
}
export function open() {
export const Client = lazy(() => {
log.info("opening database", { path: Path })
const db = init(Path, StorageSchema)
const db = init(Path)
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL")
@@ -118,9 +119,7 @@ export function open() {
}
return db
}
export const Client = lazy(open)
})
export function close() {
if (!Client.loaded()) return
@@ -141,8 +140,7 @@ export function use<T>(callback: (trx: TxOrDb) => T): T {
} catch (err) {
if (err instanceof LocalContext.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const client = Client()
const result = ctx.provide({ effects, tx: client }, () => callback(client))
const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
for (const effect of effects) effect()
return result
}

View File

@@ -15,6 +15,6 @@ export function lazy<T>(fn: () => T) {
}
result.loaded = () => loaded
result.peek = () => (loaded ? value : undefined)
return result
}

View File

@@ -8,7 +8,6 @@ import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { DatabaseEffect } from "../../src/storage/db-effect"
import {
disposeAllInstances,
provideInstance,
@@ -20,11 +19,7 @@ import { testEffect } from "../lib/effect"
import { MessageID, SessionID } from "../../src/session/schema"
const bus = Bus.layer
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(DatabaseEffect.layer)),
bus,
CrossSpawnSpawner.defaultLayer,
)
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
const it = testEffect(env)
afterEach(async () => {

View File

@@ -32,12 +32,12 @@ function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
function createSessionWithMessages(directory: string, count: number) {
return WithInstance.provide({
directory,
fn: async () => {
const session = await runSession(Session.Service.use((svc) => svc.create({})))
for (let i = 0; i < count; i++) {
await runSession(
Effect.gen(function* () {
const svc = yield* Session.Service
fn: () =>
runSession(
Effect.gen(function* () {
const svc = yield* Session.Service
const session = yield* svc.create({})
for (let i = 0; i < count; i++) {
yield* svc.updateMessage({
id: MessageID.ascending(),
role: "user",
@@ -46,11 +46,10 @@ function createSessionWithMessages(directory: string, count: number) {
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
}),
)
}
return session.id
},
}
return session.id
}),
),
})
}
@@ -82,22 +81,23 @@ describe("Link header host", () => {
})
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500.
// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a
// `NotFoundError` from the service surfaces as a defect → 500. Hono's
// equivalent maps to 404 via `errors.notFound`.
//
// Affected endpoints (handlers without mapNotFound): todo, diff, summarize,
// fork, abort, init, deleteMessage, command, shell, revert, unrevert.
//
// FIXME: unskip when mapNotFound coverage is added (next PR).
// Reproducer 2: GET /session/{missing-id}/todo returns 404, not 500.
// Previously the session.todo handler didn't wrap with `mapNotFound`, so a
// thrown `NotFoundError` surfaced as a defect → 500. Hono's equivalent maps
// to 404 via `errors.notFound`. mapNotFound is now applied to all session
// endpoints that take a sessionID.
// ──────────────────────────────────────────────────────────────────────────────
describe("404 mapping for missing session", () => {
test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => {
test("HttpApi /session/{missing}/fork returns 404 not 500", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await app(true).request("/session/ses_does_not_exist/todo", {
headers: { "x-opencode-directory": tmp.path },
const response = await app(true).request("/session/ses_does_not_exist/fork", {
method: "POST",
headers: {
"x-opencode-directory": tmp.path,
"content-type": "application/json",
},
body: JSON.stringify({}),
})
expect(response.status).toBe(404)
@@ -105,15 +105,14 @@ describe("404 mapping for missing session", () => {
})
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 3: 404 response body shape should match Hono's NamedError
// envelope `{ name, data: { message } }`. HttpApi returns the typed-error
// shape `{ _tag }` instead. SDK consumers reading `error.data.message`
// see undefined.
//
// FIXME: unskip when error JSON shape policy is decided + applied (separate PR).
// Reproducer 3: 404 body matches Hono's NamedError envelope
// `{ name: "NotFoundError", data: { message } }`. HttpApi previously returned
// `{ _tag: "NotFound" }` (empty body via HttpApiError.NotFound). The new
// OpencodeNotFound class encodes the legacy shape via its schema fields and
// `httpApiStatus: 404` annotation.
// ──────────────────────────────────────────────────────────────────────────────
describe("Error JSON shape parity", () => {
test.todo("HttpApi 404 body matches NamedError shape", async () => {
test("HttpApi 404 body matches NamedError shape", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await app(true).request("/session/ses_does_not_exist", {

View File

@@ -39,7 +39,6 @@ import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { DatabaseEffect } from "@/storage/db-effect"
import * as Log from "@opencode-ai/core/util/log"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import * as Database from "../../src/storage/db"
@@ -171,7 +170,6 @@ function makeHttp() {
lsp,
mcp,
AppFileSystem.defaultLayer,
DatabaseEffect.layer,
status,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))

View File

@@ -51,7 +51,6 @@ import { SessionStatus } from "../../src/session/status"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { DatabaseEffect } from "@/storage/db-effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
@@ -121,7 +120,6 @@ function makeHttp() {
lsp,
mcp,
AppFileSystem.defaultLayer,
DatabaseEffect.layer,
status,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))

View File

@@ -15,7 +15,6 @@ import type { SessionID } from "../../src/session/schema"
import { ShareNext } from "@/share/share-next"
import { SessionShareTable } from "../../src/share/share.sql"
import { Database } from "@/storage/db"
import { DatabaseEffect } from "@/storage/db-effect"
import { eq } from "drizzle-orm"
import { provideTmpdirInstance } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
@@ -40,6 +39,18 @@ const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unkno
const none = HttpClient.make(() => Effect.die("unexpected http call"))
function live(client: HttpClient.HttpClient) {
const http = Layer.succeed(HttpClient.HttpClient, client)
return ShareNext.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(http))),
Layer.provide(Config.defaultLayer),
Layer.provide(http),
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
)
}
function wired(client: HttpClient.HttpClient) {
const http = Layer.succeed(HttpClient.HttpClient, client)
return Layer.mergeAll(
@@ -55,7 +66,6 @@ function wired(client: HttpClient.HttpClient) {
Layer.provide(Config.defaultLayer),
Layer.provide(http),
Layer.provide(Provider.defaultLayer),
Layer.provide(DatabaseEffect.layer),
)
}
@@ -94,7 +104,7 @@ describe("ShareNext", () => {
expect(req.baseUrl).toBe("https://legacy-share.example.com")
expect(req.headers).toEqual({})
}),
).pipe(Effect.provide(wired(none))),
).pipe(Effect.provide(live(none))),
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
),
)
@@ -109,7 +119,7 @@ describe("ShareNext", () => {
expect(req.api.create).toBe("/api/share")
expect(req.headers).toEqual({})
}),
).pipe(Effect.provide(wired(none))),
).pipe(Effect.provide(live(none))),
),
)
@@ -118,7 +128,7 @@ describe("ShareNext", () => {
Effect.gen(function* () {
yield* seed("https://control.example.com", "org-1")
const req = yield* ShareNext.Service.use((svc) => svc.request())
const req = yield* ShareNext.Service.use((svc) => svc.request()).pipe(Effect.provide(live(none)))
expect(req.api.create).toBe("/api/shares")
expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync")
@@ -129,33 +139,33 @@ describe("ShareNext", () => {
authorization: "Bearer st_test_token",
"x-org-id": "org-1",
})
}).pipe(Effect.provide(wired(none))),
}),
),
)
it.live("create posts share, persists it, and returns the result", () =>
provideTmpdirInstance(
() => {
const seen: HttpClientRequest.HttpClientRequest[] = []
const client = HttpClient.make((req) => {
seen.push(req)
if (req.url.endsWith("/api/share")) {
return Effect.succeed(
json(req, {
id: "shr_abc",
url: "https://legacy-share.example.com/share/abc",
secret: "sec_123",
}),
)
}
return Effect.succeed(json(req, { ok: true }))
})
() =>
Effect.gen(function* () {
const session = yield* Session.Service.use((svc) => svc.create({ title: "test" }))
const seen: HttpClientRequest.HttpClientRequest[] = []
const client = HttpClient.make((req) => {
seen.push(req)
if (req.url.endsWith("/api/share")) {
return Effect.succeed(
json(req, {
id: "shr_abc",
url: "https://legacy-share.example.com/share/abc",
secret: "sec_123",
}),
)
}
return Effect.succeed(json(req, { ok: true }))
})
return Effect.gen(function* () {
const sessions = yield* Session.Service
const shareNext = yield* ShareNext.Service
const session = yield* sessions.create({ title: "test" })
const result = yield* shareNext.create(session.id)
const result = yield* ShareNext.Service.use((svc) => svc.create(session.id)).pipe(
Effect.provide(live(client)),
)
expect(result.id).toBe("shr_abc")
expect(result.url).toBe("https://legacy-share.example.com/share/abc")
@@ -169,61 +179,60 @@ describe("ShareNext", () => {
expect(seen).toHaveLength(1)
expect(seen[0].method).toBe("POST")
expect(seen[0].url).toBe("https://legacy-share.example.com/api/share")
}).pipe(Effect.provide(wired(client)))
},
}),
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
),
)
it.live("remove deletes the persisted share and calls the delete endpoint", () =>
provideTmpdirInstance(
() => {
const seen: HttpClientRequest.HttpClientRequest[] = []
const client = HttpClient.make((req) => {
seen.push(req)
if (req.method === "POST") {
return Effect.succeed(
json(req, {
id: "shr_abc",
url: "https://legacy-share.example.com/share/abc",
secret: "sec_123",
}),
)
}
return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 })))
})
() =>
Effect.gen(function* () {
const session = yield* Session.Service.use((svc) => svc.create({ title: "test" }))
const seen: HttpClientRequest.HttpClientRequest[] = []
const client = HttpClient.make((req) => {
seen.push(req)
if (req.method === "POST") {
return Effect.succeed(
json(req, {
id: "shr_abc",
url: "https://legacy-share.example.com/share/abc",
secret: "sec_123",
}),
)
}
return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 })))
})
yield* Effect.gen(function* () {
yield* ShareNext.Service.use((svc) => svc.create(session.id))
yield* ShareNext.Service.use((svc) => svc.remove(session.id))
}).pipe(Effect.provide(live(client)))
return Effect.gen(function* () {
const sessions = yield* Session.Service
const shareNext = yield* ShareNext.Service
const session = yield* sessions.create({ title: "test" })
yield* shareNext.create(session.id)
yield* shareNext.remove(session.id)
expect(share(session.id)).toBeUndefined()
expect(seen.map((req) => [req.method, req.url])).toEqual([
["POST", "https://legacy-share.example.com/api/share"],
["DELETE", "https://legacy-share.example.com/api/share/shr_abc"],
])
}).pipe(Effect.provide(wired(client)))
},
}),
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
),
)
it.live("create fails on a non-ok response and does not persist a share", () =>
provideTmpdirInstance(() => {
const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500)))
provideTmpdirInstance(() =>
Effect.gen(function* () {
const session = yield* Session.Service.use((svc) => svc.create({ title: "test" }))
const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500)))
return Effect.gen(function* () {
const sessions = yield* Session.Service
const shareNext = yield* ShareNext.Service
const session = yield* sessions.create({ title: "test" })
const exit = yield* Effect.exit(shareNext.create(session.id))
const exit = yield* ShareNext.Service.use((svc) => Effect.exit(svc.create(session.id))).pipe(
Effect.provide(live(client)),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(share(session.id)).toBeUndefined()
}).pipe(Effect.provide(wired(client)))
}),
}),
),
)
it.live("ShareNext coalesces rapid diff events into one delayed sync with latest data", () =>

View File

@@ -1,73 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect, ManagedRuntime } from "effect"
import { Database } from "@/storage/db"
import { DatabaseEffect } from "@/storage/db-effect"
import { resetDatabase } from "../fixture/db"
afterEach(async () => {
await resetDatabase()
})
describe("DatabaseEffect.layer", () => {
test("yields a working Service that round-trips a query", async () => {
const rt = ManagedRuntime.make(DatabaseEffect.layer)
try {
const value = await rt.runPromise(
Effect.gen(function* () {
const db = yield* DatabaseEffect.Service
return db.$client.prepare("SELECT 42 as n").get() as { n: number }
}),
)
expect(value).toEqual({ n: 42 })
} finally {
await rt.dispose()
}
})
test("service resolves a fresh handle after Database.close", async () => {
const rt = ManagedRuntime.make(DatabaseEffect.layer)
const first = await rt.runPromise(Effect.sync(() => Database.Client().$client))
expect(first.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
Database.close()
try {
const second = await rt.runPromise(
Effect.gen(function* () {
const db = yield* DatabaseEffect.Service
return db.$client
}),
)
expect(second).not.toBe(first)
expect(second.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
} finally {
await rt.dispose()
}
})
test("a runtime kept alive over Database.close uses the refreshed handle", async () => {
const rt = ManagedRuntime.make(DatabaseEffect.layer)
const captured = await rt.runPromise(
Effect.gen(function* () {
const db = yield* DatabaseEffect.Service
return db.$client
}),
)
expect(captured.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
Database.close()
try {
const fresh = await rt.runPromise(
Effect.gen(function* () {
const db = yield* DatabaseEffect.Service
return db.$client
}),
)
expect(fresh).not.toBe(captured)
expect(fresh.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
} finally {
await rt.dispose()
}
})
})