Compare commits

...

14 Commits

Author SHA1 Message Date
Kit Langton
68ff83a98d refactor(opencode): simplify database effect reset lifecycle 2026-05-02 23:23:32 -04:00
Kit Langton
fd4887d45d refactor(effect-drizzle-sqlite): simplify single-row reads 2026-05-02 23:23:32 -04:00
Kit Langton
25e546c837 refactor(effect-drizzle-sqlite): use Effectable prototype 2026-05-02 23:23:32 -04:00
Kit Langton
f948a1e3b0 test(opencode): pin lifecycle invariants for DatabaseEffect + managed-runtime
Adds regression tests for the two non-obvious invariants enforced by the
Effect-Drizzle integration:

- packages/opencode/test/storage/db-effect.test.ts pins that
  DatabaseEffect.layer rebuilds a fresh handle after Database.close + dispose,
  and demonstrates the shared-memoMap poisoning that resetDatabase prevents
  by disposing every DB-consuming runtime before closing the SQLite handle.

- packages/opencode/test/effect/managed-runtime.test.ts pins makeManagedRuntime
  dispose semantics and the lazy.resetIf compare-and-reset guard so a
  rebuilt instance is never clobbered by a stale dispose.
2026-05-02 23:23:32 -04:00
Kit Langton
46996e5a67 refactor(opencode): extract managed-runtime helper, prune adapter dead code
- Extract makeManagedRuntime() to src/effect/managed-runtime.ts so AppRuntime
  and BootstrapRuntime stop duplicating the lazy ManagedRuntime + dispose
  pattern, and document the shared-memoMap dispose ordering invariant.
- Add lazy.resetIf(expected) and use it in 3 compare-and-reset call sites
  (db.close, AppRuntime.dispose, disposeWebHandler).
- Drop dead `filename` option from EffectDrizzleSqlite MakeConfig.
- Drop redundant `patched` IIFE flag (patchClass is already idempotent).
- Add module-load assertion that Effect's protocol keys are present so a
  silent breakage on an Effect upgrade becomes a loud failure at import.
- Collapse share-next test `live()` into the wider `wired()` factory.
- Document lifecycle constraint in db-effect.ts and test/fixture/db.ts.
2026-05-02 23:23:32 -04:00
Kit Langton
322bb01257 refactor(opencode): unify drizzle client through effect adapter
The Effect adapter is now the only Drizzle wrapper over the bun:sqlite
handle. Database.Client owns the lifecycle, DatabaseEffect.Service just
exposes that handle. Removes acquire/release ref counting that was
ceremony around the same module-level singleton.
2026-05-02 23:23:32 -04:00
Kit Langton
aaa42cca07 fix(opencode): own effect sqlite lifecycle in layers 2026-05-02 23:23:32 -04:00
Kit Langton
8e2c15214e fix(opencode): refresh effect sqlite client after reset 2026-05-02 23:23:32 -04:00
Kit Langton
4faa6c64d6 feat(opencode): pilot effect sqlite database service 2026-05-02 23:23:32 -04:00
Kit Langton
f6f6cd0515 fix(effect-drizzle-sqlite): simplify sqlite adapter 2026-05-02 23:23:32 -04:00
Kit Langton
efcbc153ee test(effect-drizzle-sqlite): cover transaction edge cases 2026-05-02 23:23:32 -04:00
Kit Langton
e4ae265d8f fix(effect-drizzle-sqlite): support pipeable transactions 2026-05-02 23:23:32 -04:00
Kit Langton
89efce865d feat(effect-drizzle-sqlite): add sqlite adapter 2026-05-02 23:23:31 -04:00
Kit Langton
0e13279545 refactor(cli): convert agent / providers / mcp to effectCmd (#25525) 2026-05-02 23:22:44 -04:00
22 changed files with 823 additions and 182 deletions

View File

@@ -307,6 +307,19 @@
"@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",
@@ -396,6 +409,7 @@
"@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:*",
@@ -1572,6 +1586,8 @@
"@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

@@ -0,0 +1,24 @@
{
"$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

@@ -0,0 +1,246 @@
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

@@ -0,0 +1,246 @@
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

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

View File

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

View File

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

View File

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

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 { Database } from "@/storage/db"
import { DatabaseEffect } from "@/storage/db-effect"
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.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* 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* bus.publish(Event.Updated, input)
})
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
const rows = yield* Effect.sync(() =>
Database.use((db) =>
db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
),
)
const rows = yield* db
.select()
.from(TodoTable)
.where(eq(TodoTable.session_id, sessionID))
.orderBy(asc(TodoTable.position))
.pipe(Effect.orDie)
return rows.map((row) => ({
content: row.content,
status: row.status,
@@ -81,6 +81,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Bus.layer), Layer.provide(DatabaseEffect.layer))
export * as Todo from "./todo"

View File

@@ -9,7 +9,8 @@ import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import type { SessionID } from "@/session/schema"
import { Database } from "@/storage/db"
import { DatabaseEffect } from "@/storage/db-effect"
import { getOne } from "@opencode-ai/effect-drizzle-sqlite"
import { eq } from "drizzle-orm"
import { Config } from "@/config/config"
import * as Log from "@opencode-ai/core/util/log"
@@ -76,9 +77,6 @@ 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}`,
@@ -116,6 +114,7 @@ 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* () {
@@ -226,9 +225,9 @@ export const layer = Layer.effect(
})
const get = Effect.fnUntraced(function* (sessionID: SessionID) {
const row = yield* db((db) =>
db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
)
const row = yield* getOne(
db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)),
).pipe(Effect.orDie)
if (!row) return
return { id: row.id, secret: row.secret, url: row.url } satisfies Share
})
@@ -314,16 +313,13 @@ export const layer = Layer.effect(
Effect.flatMap((r) => httpOk.execute(r)),
Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)),
)
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(),
)
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 },
})
const s = yield* InstanceState.get(state)
s.shared.set(sessionID, result)
yield* full(sessionID).pipe(
@@ -355,7 +351,7 @@ export const layer = Layer.effect(
Effect.flatMap((r) => httpOk.execute(r)),
)
yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
yield* db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID))
s.shared.delete(sessionID)
s.queue.delete(sessionID)
})
@@ -364,13 +360,14 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
export const defaultLayer: Layer.Layer<Service> = 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

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

View File

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

View File

@@ -1,6 +1,4 @@
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"
@@ -14,6 +12,7 @@ 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
@@ -42,16 +41,16 @@ export const Path = iife(() => {
return getChannelPath()
})
export type Transaction = SQLiteTransaction<"sync", void>
export type Client = ReturnType<typeof open>
type Client = SQLiteBunDatabase
export type Transaction = Parameters<Parameters<Client["transaction"]>[0]>[0]
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: SQLiteBunDatabase, entries: Journal) => void
const migrateFromJournal = migrate as unknown as (db: Client, entries: Journal) => void
function applyMigrations(db: SQLiteBunDatabase, entries: Journal) {
function applyMigrations(db: Client, entries: Journal) {
migrateFromJournal(db, entries)
}
@@ -88,10 +87,10 @@ function migrations(dir: string): Journal {
return sql.sort((a, b) => a.timestamp - b.timestamp)
}
export const Client = lazy(() => {
export function open() {
log.info("opening database", { path: Path })
const db = init(Path)
const db = init(Path, StorageSchema)
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL")
@@ -119,7 +118,9 @@ export const Client = lazy(() => {
}
return db
})
}
export const Client = lazy(open)
export function close() {
if (!Client.loaded()) return
@@ -140,7 +141,8 @@ export function use<T>(callback: (trx: TxOrDb) => T): T {
} catch (err) {
if (err instanceof LocalContext.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
const client = 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,6 +8,7 @@ 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,
@@ -19,7 +20,11 @@ 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)), bus, CrossSpawnSpawner.defaultLayer)
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(DatabaseEffect.layer)),
bus,
CrossSpawnSpawner.defaultLayer,
)
const it = testEffect(env)
afterEach(async () => {

View File

@@ -39,6 +39,7 @@ 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"
@@ -170,6 +171,7 @@ function makeHttp() {
lsp,
mcp,
AppFileSystem.defaultLayer,
DatabaseEffect.layer,
status,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))

View File

@@ -51,6 +51,7 @@ 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"
@@ -120,6 +121,7 @@ function makeHttp() {
lsp,
mcp,
AppFileSystem.defaultLayer,
DatabaseEffect.layer,
status,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))

View File

@@ -15,6 +15,7 @@ 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"
@@ -39,18 +40,6 @@ 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(
@@ -66,6 +55,7 @@ function wired(client: HttpClient.HttpClient) {
Layer.provide(Config.defaultLayer),
Layer.provide(http),
Layer.provide(Provider.defaultLayer),
Layer.provide(DatabaseEffect.layer),
)
}
@@ -104,7 +94,7 @@ describe("ShareNext", () => {
expect(req.baseUrl).toBe("https://legacy-share.example.com")
expect(req.headers).toEqual({})
}),
).pipe(Effect.provide(live(none))),
).pipe(Effect.provide(wired(none))),
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
),
)
@@ -119,7 +109,7 @@ describe("ShareNext", () => {
expect(req.api.create).toBe("/api/share")
expect(req.headers).toEqual({})
}),
).pipe(Effect.provide(live(none))),
).pipe(Effect.provide(wired(none))),
),
)
@@ -128,7 +118,7 @@ describe("ShareNext", () => {
Effect.gen(function* () {
yield* seed("https://control.example.com", "org-1")
const req = yield* ShareNext.Service.use((svc) => svc.request()).pipe(Effect.provide(live(none)))
const req = yield* ShareNext.Service.use((svc) => svc.request())
expect(req.api.create).toBe("/api/shares")
expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync")
@@ -139,33 +129,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(
() =>
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 }))
})
() => {
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 }))
})
const result = yield* ShareNext.Service.use((svc) => svc.create(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" })
const result = yield* shareNext.create(session.id)
expect(result.id).toBe("shr_abc")
expect(result.url).toBe("https://legacy-share.example.com/share/abc")
@@ -179,60 +169,61 @@ 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(
() =>
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)))
() => {
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 })))
})
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(() =>
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)))
provideTmpdirInstance(() => {
const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500)))
const exit = yield* ShareNext.Service.use((svc) => Effect.exit(svc.create(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" })
const exit = yield* Effect.exit(shareNext.create(session.id))
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

@@ -0,0 +1,73 @@
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()
}
})
})