refactor(opencode): remove legacy database wrapper

This commit is contained in:
Dax Raad
2026-05-25 19:26:05 -04:00
parent c3743b26e8
commit 876ce31fef
50 changed files with 502 additions and 1966 deletions
+1 -4
View File
@@ -3,7 +3,6 @@ export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer as sqliteLayer } from "#sqlite"
import { Context, Effect, Layer } from "effect"
import { Sqlite } from "./sqlite"
import { Global } from "../global"
import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
@@ -15,7 +14,6 @@ type DatabaseShape = Effect.Success<typeof makeDatabase>
export interface Interface {
db: DatabaseShape
drizzle: Sqlite.DrizzleClient
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
@@ -23,7 +21,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const drizzle = yield* Sqlite.Drizzle
const db = yield* makeDatabase
yield* db.run("PRAGMA journal_mode = WAL")
@@ -35,7 +32,7 @@ export const layer = Layer.effect(
yield* Effect.log("Applying database migrations")
yield* DatabaseMigration.apply(db)
return { db, drizzle }
return { db }
}).pipe(Effect.orDie),
)
+26 -84
View File
@@ -1,17 +1,14 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "@/storage/db"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { JsonMigration } from "@/storage/json-migration"
import { EOL } from "os"
import { errorMessage } from "../../util/error"
import { Database } from "@opencode-ai/core/database/database"
import { Effect } from "effect"
import { sql } from "drizzle-orm"
import { effectCmd } from "../effect-cmd"
const QueryCommand = cmd({
const QueryCommand = effectCmd({
command: "$0 [query]",
describe: "open an interactive sqlite3 shell or run a query",
instance: false,
builder: (yargs: Argv) => {
return yargs
.positional("query", {
@@ -25,96 +22,41 @@ const QueryCommand = cmd({
describe: "Output format",
})
},
handler: async (args: { query?: string; format: string }) => {
handler: Effect.fn("Cli.db.query")(function* (args: { query?: string; format: string }) {
const query = args.query as string | undefined
if (query) {
const db = new BunDatabase(Database.getPath(), { readonly: true })
try {
const result = db.query(query).all() as Record<string, unknown>[]
if (args.format === "json") {
console.log(JSON.stringify(result, null, 2))
} else if (result.length > 0) {
const keys = Object.keys(result[0])
console.log(keys.join("\t"))
for (const row of result) {
console.log(keys.map((k) => row[k]).join("\t"))
}
}
} catch (err) {
UI.error(errorMessage(err))
process.exit(1)
const { db } = yield* Database.Service
const result = yield* db.all<Record<string, unknown>>(sql.raw(query)).pipe(Effect.orDie)
if (args.format === "json") console.log(JSON.stringify(result, null, 2))
else if (result.length > 0) {
const keys = Object.keys(result[0])
console.log(keys.join("\t"))
for (const row of result) console.log(keys.map((key) => row[key]).join("\t"))
}
db.close()
return
}
const child = spawn("sqlite3", [Database.getPath()], {
const child = spawn("sqlite3", [Database.path()], {
stdio: "inherit",
})
await new Promise((resolve) => child.on("close", resolve))
},
yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve)))
}),
})
const PathCommand = cmd({
const PathCommand = effectCmd({
command: "path",
describe: "print the database path",
handler: () => {
console.log(Database.getPath())
},
instance: false,
handler: Effect.fn("Cli.db.path")(function* () {
console.log(Database.path())
}),
})
const MigrateCommand = cmd({
command: "migrate",
describe: "migrate JSON data to SQLite (merges with existing data)",
handler: async () => {
const sqlite = new BunDatabase(Database.getPath())
const tty = process.stderr.isTTY
const width = 36
const orange = "\x1b[38;5;214m"
const muted = "\x1b[0;2m"
const reset = "\x1b[0m"
let last = -1
if (tty) process.stderr.write("\x1b[?25l")
try {
const stats = await JsonMigration.run(drizzle({ client: sqlite }), {
progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100)
if (percent === last) return
last = percent
if (tty) {
const fill = Math.round((percent / 100) * width)
const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}`
process.stderr.write(
`\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.current}/${event.total}${reset} `,
)
} else {
process.stderr.write(`sqlite-migration:${percent}${EOL}`)
}
},
})
if (tty) process.stderr.write("\n")
if (tty) process.stderr.write("\x1b[?25h")
else process.stderr.write(`sqlite-migration:done${EOL}`)
UI.println(
`Migration complete: ${stats.projects} projects, ${stats.sessions} sessions, ${stats.messages} messages`,
)
if (stats.errors.length > 0) {
UI.println(`${stats.errors.length} errors occurred during migration`)
}
} catch (err) {
if (tty) process.stderr.write("\x1b[?25h")
UI.error(`Migration failed: ${errorMessage(err)}`)
process.exit(1)
} finally {
sqlite.close()
}
},
})
export const DbCommand = cmd({
export const DbCommand = effectCmd({
command: "db",
describe: "database tools",
instance: false,
builder: (yargs: Argv) => {
return yargs.command(QueryCommand).command(PathCommand).command(MigrateCommand).demandCommand()
return yargs.command(QueryCommand).command(PathCommand).demandCommand()
},
handler: () => {},
handler: Effect.fn("Cli.db")(function* () {}),
})
+33 -35
View File
@@ -3,7 +3,7 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Session } from "@/session/session"
import { MessageV2 } from "../../session/message-v2"
import { CliError, effectCmd } from "../effect-cmd"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql"
import { InstanceRef } from "@/effect/instance-ref"
import { ShareNext } from "@/share/share-next"
@@ -99,6 +99,7 @@ export const ImportCommand = effectCmd({
const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) {
const share = yield* ShareNext.Service
const fs = yield* AppFileSystem.Service
const { db } = yield* Database.Service
let exportData: ExportData | undefined
@@ -176,48 +177,45 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: Ins
path: path.relative(path.resolve(ctx.worktree), ctx.directory).replaceAll("\\", "/"),
}) as Session.Info
const row = Session.toRow(info)
Database.use((db) =>
db
.insert(SessionTable)
.values(row)
.onConflictDoUpdate({
target: SessionTable.id,
set: { project_id: row.project_id, directory: row.directory, path: row.path },
})
.run(),
)
yield* db
.insert(SessionTable)
.values(row)
.onConflictDoUpdate({
target: SessionTable.id,
set: { project_id: row.project_id, directory: row.directory, path: row.path },
})
.run()
.pipe(Effect.orDie)
for (const msg of exportData.messages) {
const msgInfo = decodeMessageInfo(msg.info) as SessionLegacy.Info
const { id, sessionID: _, ...msgData } = msgInfo
Database.use((db) =>
db
.insert(MessageTable)
.values({
id,
session_id: row.id,
time_created: msgInfo.time?.created ?? Date.now(),
data: msgData as never,
})
.onConflictDoNothing()
.run(),
)
yield* db
.insert(MessageTable)
.values({
id,
session_id: row.id,
time_created: msgInfo.time?.created ?? Date.now(),
data: msgData as never,
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
for (const part of msg.parts) {
const partInfo = decodePart(part) as SessionLegacy.Part
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
Database.use((db) =>
db
.insert(PartTable)
.values({
id: partId,
message_id: messageID,
session_id: row.id,
data: partData,
})
.onConflictDoNothing()
.run(),
)
yield* db
.insert(PartTable)
.values({
id: partId,
message_id: messageID,
session_id: row.id,
data: partData,
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
}
}
+6 -5
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
import { Session } from "@/session/session"
import { NotFoundError } from "@/storage/storage"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Project } from "@/project/project"
import { InstanceRef } from "@/effect/instance-ref"
@@ -80,9 +80,10 @@ export const StatsCommand = effectCmd({
}),
})
const getAllSessions = Effect.sync(() =>
Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)),
)
const getAllSessions = Effect.fnUntraced(function* () {
const { db } = yield* Database.Service
return (yield* db.select().from(SessionTable).all().pipe(Effect.orDie)).map((row) => Session.fromRow(row))
})
const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
days?: number,
@@ -90,7 +91,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
currentProject?: Project.Info,
) {
const svc = yield* Session.Service
const sessions = yield* getAllSessions
const sessions = yield* getAllSessions()
const MS_IN_DAY = 24 * 60 * 60 * 1000
const cutoffTime = (() => {
@@ -1,7 +1,10 @@
import { Database } from "bun:sqlite"
import os from "node:os"
import path from "node:path"
import { Option, Schema } from "effect"
import { Effect, Option, Schema } from "effect"
import { sql } from "drizzle-orm"
import { Sqlite } from "@opencode-ai/core/database/sqlite"
import { layer as sqliteLayer } from "@opencode-ai/core/database/sqlite.bun"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem"
import type { EditorSelection } from "./editor"
@@ -88,12 +91,10 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
}
function queryZedActiveEditor(dbPath: string, cwd: string) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const raw = db
.query(
`select
const raw = zedDatabase(dbPath).runSync((db) =>
Effect.succeed(
db.all<ZedEditorRow>(sql.raw(`select
i.kind as item_kind,
e.item_id as editor_id,
i.workspace_id as workspace_id,
@@ -105,9 +106,9 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
join workspaces w on w.workspace_id = i.workspace_id
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
where i.active = 1 and p.active = 1
order by w.timestamp desc`,
)
.all()
order by w.timestamp desc`)),
),
)
const rows = raw.flatMap((row) => {
const parsed = decodeZedEditorRow(row)
@@ -126,24 +127,22 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
return { type: "row" as const, row }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const raw = db
.query(
`select
const raw = zedDatabase(dbPath).runSync((db) =>
Effect.succeed(
db.all<Schema.Schema.Type<typeof ZedSelectionRowSchema>>(
sql`select
start as selection_start,
end as selection_end
from editor_selections
where editor_id = $editorID and workspace_id = $workspaceID`,
)
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
where editor_id = ${row.editor_id} and workspace_id = ${row.workspace_id}`,
),
),
)
const selections = raw.flatMap((selection) => {
const parsed = decodeZedSelectionRow(selection)
@@ -154,33 +153,33 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
return { type: "selections" as const, selections }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const parsed = decodeZedEditorContents(
db
.query(
`select contents
zedDatabase(dbPath).runSync((db) =>
Effect.succeed(
db.get(
sql`select contents
from editors
where item_id = $editorID and workspace_id = $workspaceID`,
)
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
where item_id = ${row.editor_id} and workspace_id = ${row.workspace_id}`,
),
),
),
)
if (Option.isNone(parsed)) return { type: "unavailable" as const }
return { type: "contents" as const, contents: parsed.value.contents }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function zedDatabase(dbPath: string) {
return makeRuntime(Sqlite.Drizzle, sqliteLayer({ filename: dbPath, readonly: true, create: false }))
}
function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
return row.item_kind === "Editor" && row.editor_id != null
}
@@ -9,7 +9,8 @@ import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
import { SyncEvent } from "@/sync"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
@@ -174,7 +175,7 @@ export const layer = Layer.effect(
const session = yield* Session.Service
const prompt = yield* SessionPrompt.Service
const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const events = yield* EventV2Bridge.Service
const vcs = yield* Vcs.Service
const flags = yield* RuntimeFlags.Service
const fs = yield* AppFileSystem.Service
@@ -372,20 +373,20 @@ export const layer = Layer.effect(
})
}
const events = (yield* response.json) as HistoryEvent[]
const history = (yield* response.json) as HistoryEvent[]
log.info("workspace history synced", {
workspaceID: space.id,
events: events.length,
events: history.length,
})
yield* Effect.forEach(
events,
history,
(event) =>
sync
events
.replay(
{
id: event.id,
id: EventV2.ID.make(event.id),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@@ -432,11 +433,11 @@ export const layer = Layer.effect(
yield* parseSSE(stream, (evt) =>
Effect.gen(function* () {
if (!evt || typeof evt !== "object" || !("payload" in evt)) return
const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent }
const payload = evt.payload as { type?: string; syncEvent?: EventV2.SerializedEvent }
if (payload.type === "server.heartbeat") return
if (payload.type === "sync" && payload.syncEvent) {
const failed = yield* sync.replay(payload.syncEvent).pipe(
const failed = yield* events.replay(payload.syncEvent, { publish: true }).pipe(
Effect.as(false),
Effect.catchCause((error) =>
Effect.sync(() => {
@@ -634,7 +635,7 @@ export const layer = Layer.effect(
// "claim" this session so any future events coming from
// the old workspace are ignored
yield* sync.claim(input.sessionID, input.workspaceID ?? previous.projectID)
yield* events.claim(input.sessionID, input.workspaceID ?? previous.projectID)
}
}
@@ -1002,12 +1003,12 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)
-161
View File
@@ -1,161 +0,0 @@
import { Context, Effect, Layer } from "effect"
import { Database } from "./storage/db"
import { DataMigrationTable } from "@opencode-ai/core/data-migration.sql"
import * as Log from "@opencode-ai/core/util/log"
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
import { MessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import type { SessionID } from "./session/schema"
export type Migration<R = never> = {
name: string
run: Effect.Effect<void, unknown, R>
}
const log = Log.create({ service: "data-migration" })
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/DataMigration") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const migrations: Migration[] = [
{
name: "session_usage_from_messages",
run: Effect.gen(function* () {
type Usage = {
cost: number
tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
}
for (let cursor: SessionID | undefined, page = 1; ; page++) {
const next = yield* Effect.gen(function* () {
const sessions = yield* Effect.sync(() =>
Database.use((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(cursor ? gt(SessionTable.id, cursor) : undefined)
.orderBy(asc(SessionTable.id))
.limit(100)
.all(),
),
)
if (sessions.length === 0) return
yield* Effect.sync(() =>
Database.transaction((db) => {
const usageBySession = new Map<SessionID, Usage>(
sessions.map((session) => [
session.id,
{ cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } },
]),
)
for (const row of db
.select({
session_id: MessageTable.session_id,
cost: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.cost'), 0)), 0)`,
tokens_input: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.input'), 0)), 0)`,
tokens_output: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.output'), 0)), 0)`,
tokens_reasoning: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.reasoning'), 0)), 0)`,
tokens_cache_read: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.read'), 0)), 0)`,
tokens_cache_write: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.write'), 0)), 0)`,
})
.from(MessageTable)
.where(
and(
inArray(
MessageTable.session_id,
sessions.map((session) => session.id),
),
sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`,
),
)
.groupBy(MessageTable.session_id)
.all()) {
const current = usageBySession.get(row.session_id)
if (!current) continue
current.cost = row.cost
current.tokens.input = row.tokens_input
current.tokens.output = row.tokens_output
current.tokens.reasoning = row.tokens_reasoning
current.tokens.cache.read = row.tokens_cache_read
current.tokens.cache.write = row.tokens_cache_write
}
for (const [sessionID, value] of usageBySession) {
db.update(SessionTable)
.set({
cost: value.cost,
tokens_input: value.tokens.input,
tokens_output: value.tokens.output,
tokens_reasoning: value.tokens.reasoning,
tokens_cache_read: value.tokens.cache.read,
tokens_cache_write: value.tokens.cache.write,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.run()
}
}),
)
return sessions.at(-1)?.id
}).pipe(
Effect.withSpan("DataMigration.sessionUsage.page", {
attributes: {
"data_migration.name": "session_usage_from_messages",
"data_migration.page": page,
"data_migration.cursor": cursor ?? "",
},
}),
)
if (!next) return
cursor = next
yield* Effect.sleep("10 millis")
}
}),
},
]
yield* Effect.gen(function* () {
if (migrations.length === 0) return
// Migrations run in a background fiber, so they must be resumable until
// their completion row is written.
for (const migration of migrations) {
const completed = Database.use((db) =>
db
.select({ name: DataMigrationTable.name })
.from(DataMigrationTable)
.where(eq(DataMigrationTable.name, migration.name))
.get(),
)
if (completed) continue
log.info("running data migration", { name: migration.name })
yield* migration.run.pipe(Effect.withSpan("DataMigration", { attributes: { name: migration.name } }))
Database.use((db) =>
db
.insert(DataMigrationTable)
.values({ name: migration.name, time_completed: Date.now() })
.onConflictDoNothing()
.run(),
)
}
}).pipe(
Effect.tapCause((cause) =>
Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause)),
),
Effect.ignore,
Effect.forkScoped,
)
return Service.of({})
}),
)
export const defaultLayer = layer
export * as DataMigration from "./data-migration"
+2 -6
View File
@@ -3,6 +3,7 @@ import { attach } from "./run-service"
import * as Observability from "@opencode-ai/core/effect/observability"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@/bus"
import { Auth } from "@/auth"
import { Account } from "@/account/account"
@@ -51,17 +52,15 @@ import { PtyTicket } from "@/pty/ticket"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { SyncEvent } from "@/sync"
import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { DataMigration } from "@/data-migration"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
AppFileSystem.defaultLayer,
Database.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
@@ -111,9 +110,6 @@ export const AppLayer = Layer.mergeAll(
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
SyncEvent.defaultLayer,
EventV2Bridge.defaultLayer,
DataMigration.defaultLayer,
).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
const rt = ManagedRuntime.make(AppLayer, { memoMap })
+4 -4
View File
@@ -1,5 +1,5 @@
// Temporary V2 bridge: core events are the publish path, but the rest of
// opencode and the HTTP event stream still expect legacy bus/sync payloads.
// opencode and the HTTP event stream still expect legacy bus payloads.
// This layer goes away once consumers subscribe to core EventV2 directly.
import { Bus as ProjectBus } from "@/bus"
import { GlobalBus } from "@/bus/global"
@@ -31,8 +31,8 @@ export const layer = Layer.effect(
})
})
const provideEventLocation = <E, R>(event: EventV2.Payload, effect: Effect.Effect<void, E, R>) => {
return Effect.gen(function* () {
const provideEventLocation = <E, R>(event: EventV2.Payload, effect: Effect.Effect<void, E, R>) =>
Effect.gen(function* () {
const ctx = yield* InstanceRef
if (ctx) return yield* effect
const store = Option.getOrUndefined(yield* Effect.serviceOption(InstanceStore.Service))
@@ -45,7 +45,6 @@ export const layer = Layer.effect(
}),
)
})
}
yield* events.all().pipe(
Stream.runForEach((event) => {
@@ -58,6 +57,7 @@ export const layer = Layer.effect(
}),
Effect.forkScoped,
)
return Service.of(events)
}),
)
+5 -4
View File
@@ -30,10 +30,9 @@ import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { JsonMigration } from "@/storage/json-migration"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
@@ -116,7 +115,7 @@ const cli = yargs(args)
run_id: processMetadata.runID,
})
const marker = Database.getPath()
const marker = Database.path()
if (!(await Filesystem.exists(marker))) {
const tty = process.stderr.isTTY
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
@@ -126,8 +125,9 @@ const cli = yargs(args)
const reset = "\x1b[0m"
let last = -1
if (tty) process.stderr.write("\x1b[?25l")
const sqlite = new (await import("bun:sqlite")).Database(marker)
try {
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
await JsonMigration.run(drizzle({ client: sqlite }), {
progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100)
if (percent === last && event.current !== event.total) return
@@ -145,6 +145,7 @@ const cli = yargs(args)
},
})
} finally {
sqlite.close()
if (tty) process.stderr.write("\x1b[?25h")
else {
process.stderr.write(`sqlite-migration:done${EOL}`)
+1 -1
View File
@@ -2,5 +2,5 @@ export { Config } from "@/config/config"
export { Server } from "./server/server"
export { bootstrap } from "./cli/bootstrap"
export * as Log from "@opencode-ai/core/util/log"
export { Database } from "@/storage/db"
export { Database } from "@opencode-ai/core/database/database"
export { JsonMigration } from "@/storage/json-migration"
+4 -5
View File
@@ -5,7 +5,7 @@ import { InstanceState } from "@/effect/instance-state"
import { ProjectV2 } from "@opencode-ai/core/project"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@opencode-ai/core/session/sql"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Wildcard } from "@opencode-ai/core/util/wildcard"
@@ -145,11 +145,10 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const { db } = yield* Database.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* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get().pipe(Effect.orDie)
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: [...(row?.data ?? [])],
@@ -307,6 +306,6 @@ export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return PermissionV2.disabled(tools, ruleset)
}
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(Bus.layer))
export * as Permission from "."
@@ -1,7 +1,6 @@
import { Schema } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { ConfigApi } from "./groups/config"
import { ControlApi } from "./groups/control"
import { EventApi } from "./groups/event"
@@ -23,9 +22,8 @@ import { V2Api } from "./groups/v2"
import { Authorization } from "./middleware/authorization"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
// SSE event schemas built from the BusEvent/SyncEvent registries.
// SSE event schemas built from the BusEvent and EventV2 registries.
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
const SyncEventSchemas = SyncEvent.effectPayloads()
export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi)
@@ -56,7 +54,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas])
.annotate(HttpApi.AdditionalSchemas, [EventSchema])
export type RootHttpApiType = typeof RootHttpApi
export type InstanceHttpApiType = typeof InstanceHttpApi
@@ -1,6 +1,5 @@
import { Config } from "@/config/config"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import "@/server/event"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -15,7 +14,7 @@ const GlobalEventSchema = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Union([...BusEvent.effectPayloads(), ...SyncEvent.effectPayloads()]),
payload: Schema.Union(BusEvent.effectPayloads()),
}).annotate({ identifier: "GlobalEvent" })
export const GlobalUpgradeInput = Schema.Struct({
@@ -1,8 +1,9 @@
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Session } from "@/session/session"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventTable } from "@opencode-ai/core/event/sql"
import { asc } from "drizzle-orm"
import { and } from "drizzle-orm"
@@ -23,7 +24,8 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
const workspace = yield* Workspace.Service
const session = yield* Session.Service
const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const events = yield* EventV2Bridge.Service
const { db } = yield* Database.Service
const start = Effect.fn("SyncHttpApi.start")(function* () {
yield* workspace
@@ -33,27 +35,27 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
})
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: event.id,
const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: EventV2.ID.make(event.id),
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,
data: { ...event.data },
}))
const source = events[0].aggregateID
const source = payload[0].aggregateID
log.info("sync replay requested", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
events: payload.length,
first: payload[0]?.seq,
last: payload.at(-1)?.seq,
directory: ctx.payload.directory,
})
yield* sync.replayAll(events)
yield* events.replayAll(payload)
log.info("sync replay complete", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
events: payload.length,
first: payload[0]?.seq,
last: payload.at(-1)?.seq,
})
return { sessionID: source }
})
@@ -74,18 +76,17 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all(),
)
return yield* db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
})
return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history)
@@ -49,7 +49,6 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { Skill } from "@/skill"
import { Snapshot } from "@/snapshot"
import { SyncEvent } from "@/sync"
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
@@ -192,12 +191,12 @@ export function createRoutes(
corsVaryFix,
fenceLayer,
cors(corsOptions),
Database.defaultLayer,
Account.defaultLayer,
Agent.defaultLayer,
Auth.defaultLayer,
Command.defaultLayer,
Config.defaultLayer,
Database.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Format.defaultLayer,
@@ -225,7 +224,6 @@ export function createRoutes(
SessionSummary.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
SyncEvent.defaultLayer,
EventV2Bridge.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
@@ -233,7 +231,7 @@ export function createRoutes(
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
Bus.layer,
Bus.defaultLayer,
AppFileSystem.defaultLayer,
FetchHttpClient.layer,
HttpServer.layerServices,
+12 -9
View File
@@ -1,25 +1,28 @@
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { inArray } from "drizzle-orm"
import { EventSequenceTable } from "@opencode-ai/core/event/sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { makeRuntime } from "@/effect/run-service"
export const HEADER = "x-opencode-sync"
export type State = Record<string, number>
const log = Log.create({ service: "fence" })
const runtime = makeRuntime(Database.Service, Database.defaultLayer)
export function load(ids?: string[]) {
const rows = Database.use((db) => {
if (!ids?.length) {
return db.select().from(EventSequenceTable).all()
}
return runtime.runSync(({ db }) =>
Effect.gen(function* () {
const rows = yield* (ids?.length
? db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
: db.select().from(EventSequenceTable).all()
).pipe(Effect.orDie)
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
})
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq]))
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq]))
}),
)
}
export function diff(prev: State, next: State) {
+1 -1
View File
@@ -1652,7 +1652,6 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Image.defaultLayer),
Layer.provide(
Layer.mergeAll(
EventV2Bridge.defaultLayer,
Agent.defaultLayer,
Database.defaultLayer,
SystemPrompt.defaultLayer,
@@ -1661,6 +1660,7 @@ export const defaultLayer = Layer.suspend(() =>
Bus.layer,
CrossSpawnSpawner.defaultLayer,
RuntimeFlags.defaultLayer,
EventV2Bridge.defaultLayer,
),
),
),
+4 -4
View File
@@ -10,7 +10,7 @@ import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Database } from "@opencode-ai/core/database/database"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionV2 } from "@opencode-ai/core/session"
import { NotFoundError } from "@/storage/storage"
@@ -510,7 +510,7 @@ export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert"
export const layer: Layer.Layer<
Service,
never,
BackgroundJob.Service | Bus.Service | Storage.Service | RuntimeFlags.Service | Database.Service | EventV2.Service
BackgroundJob.Service | Bus.Service | Storage.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -518,7 +518,7 @@ export const layer: Layer.Layer<
const database = yield* Database.Service
const background = yield* BackgroundJob.Service
const bus = yield* Bus.Service
const events = yield* EventV2.Service
const events = yield* EventV2Bridge.Service
const storage = yield* Storage.Service
const flags = yield* RuntimeFlags.Service
@@ -935,7 +935,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(SessionV2.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
-12
View File
@@ -1,12 +0,0 @@
import { Database } from "bun:sqlite"
import { Sqlite } from "@opencode-ai/core/database/sqlite"
import { layer } from "@opencode-ai/core/database/sqlite.bun"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Effect } from "effect"
export function init(path: string) {
const runtime = makeRuntime(Sqlite.Native, layer({ filename: path }))
const native = runtime.runSync((native) => Effect.succeed(native)) as Database
return drizzle({ client: native })
}
-12
View File
@@ -1,12 +0,0 @@
import { DatabaseSync } from "node:sqlite"
import { Sqlite } from "@opencode-ai/core/database/sqlite"
import { layer } from "@opencode-ai/core/database/sqlite.node"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { drizzle } from "drizzle-orm/node-sqlite"
import { Effect } from "effect"
export function init(path: string) {
const runtime = makeRuntime(Sqlite.Native, layer({ filename: path }))
const native = runtime.runSync((native) => Effect.succeed(native)) as DatabaseSync
return drizzle({ client: native })
}
-115
View File
@@ -1,115 +0,0 @@
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
import type { TablesRelationalConfig } from "drizzle-orm/relations"
export * from "drizzle-orm"
import { LocalContext } from "@/util/local-context"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { EffectBridge } from "@/effect/bridge"
import { Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
export const NotFoundError = NamedError.create("NotFoundError", {
message: Schema.String,
})
const log = Log.create({ service: "db" })
const runtime = makeRuntime(Database.Service, Database.defaultLayer)
const database = await runtime.runPromise((db) => Effect.succeed(db))
export const getPath = () => Database.path()
export type Transaction = SQLiteTransaction<"sync", void, Record<string, unknown>, TablesRelationalConfig>
type Client = Database.Interface["drizzle"]
let client: Client | undefined
let loaded = false
export const Client = Object.assign(
(): Client => {
if (loaded) return client as Client
const dbPath = getPath()
log.info("opening database", { path: dbPath })
const db = database.drizzle
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL")
db.run("PRAGMA busy_timeout = 5000")
db.run("PRAGMA cache_size = -64000")
db.run("PRAGMA foreign_keys = ON")
db.run("PRAGMA wal_checkpoint(PASSIVE)")
client = db
loaded = true
return db
},
{
reset: () => {
loaded = false
client = undefined
},
loaded: () => loaded,
},
)
export function close() {
if (!Client.loaded()) return
Client.reset()
}
export type TxOrDb = Transaction | Client
const ctx = LocalContext.create<{
tx: TxOrDb
effects: (() => void | Promise<void>)[]
}>("database")
export function use<T>(callback: (trx: TxOrDb) => T): T {
try {
return callback(ctx.use().tx)
} catch (err) {
if (err instanceof LocalContext.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
for (const effect of effects) effect()
return result
}
throw err
}
}
export function effect(fn: () => any | Promise<any>) {
const bound = EffectBridge.bind(fn)
try {
ctx.use().effects.push(bound)
} catch {
bound()
}
}
type NotPromise<T> = T extends Promise<any> ? never : T
export function transaction<T>(
callback: (tx: TxOrDb) => NotPromise<T>,
options?: {
behavior?: "deferred" | "immediate" | "exclusive"
},
): NotPromise<T> {
try {
return callback(ctx.use().tx)
} catch (err) {
if (err instanceof LocalContext.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const txCallback = EffectBridge.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
const result = Client().transaction(txCallback, { behavior: options?.behavior })
for (const effect of effects) effect()
return result as NotPromise<T>
}
throw err
}
}
export * as Database from "./db"
-411
View File
@@ -1,411 +0,0 @@
// Legacy sync event system. It should stay unaware of core EventV2 execution;
// the only temporary V2 coupling here is exposing versioned core event schemas
// in effectPayloads() so existing HTTP/SDK schema generation remains stable.
// Remove that registry read when event schemas are generated from core directly.
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { EventID } from "./schema"
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
import type { DeepMutable } from "@opencode-ai/core/schema"
import { EventV2 } from "@opencode-ai/core/event"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EffectBridge } from "@/effect/bridge"
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
// when writing to the database. Bus payloads (`Properties`) stay readonly —
// subscribers only read.
export type Definition<
Type extends string = string,
Schema extends EffectSchema.Top = EffectSchema.Top,
BusSchema extends EffectSchema.Top = Schema,
> = {
type: Type
version: number
aggregate: string
schema: Schema
// Bus event payload schema. Defaults to `schema` unless `busSchema` was
// passed at definition time (see `session.updated`, whose projector
// expands the persisted data to a `{ sessionID, info }` bus payload).
properties: BusSchema
}
export type Event<Def extends Definition = Definition> = {
id: string
seq: number
aggregateID: string
data: DeepMutable<EffectSchema.Schema.Type<Def["schema"]>>
}
export type Properties<Def extends Definition = Definition> = EffectSchema.Schema.Type<Def["properties"]>
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
export interface Interface {
readonly run: <Def extends Definition>(
def: Def,
data: Event<Def>["data"],
options?: { publish?: boolean },
) => Effect.Effect<void>
readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect<void>
readonly replayAll: (
events: SerializedEvent[],
options?: { publish: boolean; ownerID?: string },
) => Effect.Effect<string | undefined>
readonly remove: (aggregateID: string) => Effect.Effect<void>
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SyncEvent") {}
export const layer = Layer.effect(Service)(
Effect.gen(function* () {
const flags = yield* RuntimeFlags.Service
const bus = yield* ProjectBus.Service
const replay: Interface["replay"] = Effect.fn("SyncEvent.replay")(function* (event, options) {
const def = registry.get(event.type)
if (!def) {
throw new Error(`Unknown event type: ${event.type}`)
}
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
.get(),
)
const latest = row?.seq ?? -1
if (event.seq <= latest) return
if (row?.ownerID && row.ownerID !== options?.ownerID) {
return
}
const expected = latest + 1
if (event.seq !== expected) {
throw new Error(
`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`,
)
}
const publish = !!options?.publish
// Bridge captures handler-fiber refs (InstanceRef/WorkspaceRef) and the
// full Effect context, so the forked publish + GlobalBus emit run with
// the right state without a per-call attachWith.
const bridge = yield* EffectBridge.make()
process(def, event, {
bus,
bridge,
publish,
ownerID: options?.ownerID,
experimentalWorkspaces: flags.experimentalWorkspaces,
})
})
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
const source = events[0]?.aggregateID
if (!source) return undefined
if (events.some((item) => item.aggregateID !== source)) {
throw new Error("Replay events must belong to the same session")
}
const start = events[0].seq
for (const [i, item] of events.entries()) {
const seq = start + i
if (item.seq !== seq) {
throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`)
}
}
for (const item of events) {
yield* replay(item, options)
}
return source
})
const run: Interface["run"] = Effect.fn("SyncEvent.run")(function* (def, data, options) {
const agg = (data as Record<string, string>)[def.aggregate]
// This should never happen: we've enforced it via typescript in
// the definition
if (agg == null) {
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
}
if (def.version !== versions.get(def.type)) {
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
}
const { publish = true } = options || {}
const bridge = yield* EffectBridge.make()
// Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing
// else changing the data from under us
Database.transaction(
(tx) => {
const id = EventID.ascending()
const row = tx
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, agg))
.get()
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event, { bus, bridge, publish, experimentalWorkspaces: flags.experimentalWorkspaces })
},
{
behavior: "immediate",
},
)
})
const remove: Interface["remove"] = Effect.fn("SyncEvent.remove")(function* (aggregateID) {
Database.transaction((tx) => {
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
})
})
const claim: Interface["claim"] = Effect.fn("SyncEvent.claim")((aggregateID, ownerID) =>
Effect.sync(() =>
Database.use((db) =>
db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run(),
),
),
)
return Service.of({
run,
replay,
replayAll,
remove,
claim,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide([ProjectBus.defaultLayer, RuntimeFlags.defaultLayer]))
export const use = serviceUse(Service)
export const registry = new Map<string, Definition>()
let projectors: Map<string, ProjectorFunc> | undefined
const versions = new Map<string, number>()
let frozen = false
let convertEvent: ConvertEvent
export function reset() {
frozen = false
projectors = undefined
convertEvent = (_, data) => data
}
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: ConvertEvent }) {
projectors = new Map(input.projectors.map(([def, func]) => [versionedType(def.type, def.version), func]))
for (let entry of EventV2.registry.values()) {
if (!entry.sync) continue
register({
type: entry.type,
version: entry.sync.version,
aggregate: entry.sync.aggregate,
properties: entry.data,
schema: entry.data,
})
}
// Install all the latest event defs to the bus. We only ever emit
// latest versions from code, and keep around old versions for
// replaying. Replaying does not go through the bus, and it
// simplifies the bus to only use unversioned latest events
for (let [type, version] of versions.entries()) {
let def = registry.get(versionedType(type, version))!
BusEvent.define(def.type, def.properties)
}
// Freeze the system so it clearly errors if events are defined
// after `init` which would cause bugs
frozen = true
convertEvent = input.convertEvent ?? ((_, data) => data)
}
export function versionedType<A extends string>(type: A): A
export function versionedType<A extends string, B extends number>(type: A, version: B): `${A}/${B}`
export function versionedType(type: string, version?: number) {
return version ? `${type}.${version}` : type
}
export function define<
Type extends string,
Agg extends string,
Schema extends EffectSchema.Top,
BusSchema extends EffectSchema.Top = Schema,
>(input: {
type: Type
version: number
aggregate: Agg
schema: Schema
busSchema?: BusSchema
}): Definition<Type, Schema, BusSchema> {
if (frozen) {
throw new Error("Error defining sync event: sync system has been frozen")
}
const def = {
type: input.type,
version: input.version,
aggregate: input.aggregate,
schema: input.schema,
properties: (input.busSchema ?? input.schema) as BusSchema,
}
register(def)
return def
}
export function project<Def extends Definition>(
def: Def,
func: (db: Database.TxOrDb, data: Event<Def>["data"], event: Event<Def>) => void,
): [Definition, ProjectorFunc] {
return [def, func as ProjectorFunc]
}
function register(def: Definition) {
versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0))
registry.set(versionedType(def.type, def.version), def)
}
function process<Def extends Definition>(
def: Def,
event: Event<Def>,
options: {
bus: ProjectBus.Interface
bridge: EffectBridge.Shape
publish: boolean
ownerID?: string
experimentalWorkspaces: boolean
},
) {
if (projectors == null) {
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
}
const projector = projectors.get(versionedType(def.type, def.version))
if (!projector) {
if (!def.type.includes("next")) throw new Error(`Projector not found for event: ${def.type}`)
return
}
Database.transaction((tx) => {
projector(tx, event.data, event)
if (options.experimentalWorkspaces) {
tx.insert(EventSequenceTable)
.values({
aggregate_id: event.aggregateID,
seq: event.seq,
owner_id: options?.ownerID,
})
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: event.seq },
})
.run()
tx.insert(EventTable)
.values({
id: event.id as never,
seq: event.seq,
aggregate_id: event.aggregateID,
type: versionedType(def.type, def.version),
data: event.data as never,
})
.run()
}
Database.effect(() => {
if (!options.publish) return
const result = convertEvent(def.type, event.data)
// The bridge was built inside the caller's fiber so it already carries
// InstanceRef/WorkspaceRef and the full Effect context. Both the bus
// publish and the GlobalBus emit run inside the forked Effect so they
// share the same instance/workspace lookup.
const publish = (data: unknown) =>
options.bridge.fork(
Effect.gen(function* () {
yield* options.bus.publish(def, data as Properties<Def>, { id: event.id })
const instance = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: instance.directory,
project: instance.project.id,
workspace,
payload: {
type: "sync",
syncEvent: {
type: versionedType(def.type, def.version),
...event,
},
},
})
}),
)
if (result instanceof Promise) {
void result.then(publish)
} else {
publish(result)
}
})
})
}
export function effectPayloads() {
return [
...registry
.entries()
.map(([type, def]) =>
EffectSchema.Struct({
type: EffectSchema.Literal("sync"),
name: EffectSchema.Literal(type),
id: EffectSchema.String,
seq: EffectSchema.Finite,
aggregateID: EffectSchema.Literal(def.aggregate),
data: def.schema,
}).annotate({ identifier: `SyncEvent.${type}` }),
)
.toArray(),
...EventV2.registry
.values()
.filter(
(definition) =>
definition.sync !== undefined && !registry.has(versionedType(definition.type, definition.sync.version)),
)
.map((definition) =>
EffectSchema.Struct({
type: EffectSchema.Literal("sync"),
name: EffectSchema.Literal(versionedType(definition.type, definition.sync!.version)),
id: EffectSchema.String,
seq: EffectSchema.Finite,
aggregateID: EffectSchema.Literal(definition.sync!.aggregate),
data: definition.data,
}).annotate({ identifier: `SyncEvent.${definition.type}` }),
)
.toArray(),
]
}
export * as SyncEvent from "."
+5 -5
View File
@@ -2,7 +2,7 @@ import { Global } from "@opencode-ai/core/global"
import { InstanceLayer } from "@/project/instance-layer"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import type { ProjectV2 } from "@opencode-ai/core/project"
@@ -149,7 +149,7 @@ type GitResult = { code: number; text: string; stderr: string }
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | AppProcess.Service | Git.Service | Project.Service | InstanceStore.Service
AppFileSystem.Service | Path.Path | AppProcess.Service | Git.Service | Project.Service | InstanceStore.Service | Database.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -157,6 +157,7 @@ export const layer: Layer.Layer<
const fs = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const appProcess = yield* AppProcess.Service
const { db } = yield* Database.Service
const gitSvc = yield* Git.Service
const project = yield* Project.Service
const store = yield* InstanceStore.Service
@@ -478,9 +479,7 @@ export const layer: Layer.Layer<
directory: string,
input: { projectID: ProjectV2.ID; extra?: string },
) {
const row = yield* Effect.sync(() =>
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()),
)
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get().pipe(Effect.orDie)
const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? ""
const ok = yield* runStartScript(directory, startup, "project")
@@ -611,6 +610,7 @@ export const appLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
+7 -6
View File
@@ -1,18 +1,19 @@
import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { sql } from "drizzle-orm"
import { AccountRepo } from "../../src/account/repo"
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
const db = Database.Client()
db.run(/*sql*/ `DELETE FROM account_state`)
db.run(/*sql*/ `DELETE FROM account`)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`DELETE FROM account_state`)
yield* db.run(sql`DELETE FROM account`)
}),
)
).pipe(Layer.provide(Database.defaultLayer))
const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate))
@@ -1,5 +1,6 @@
import { expect } from "bun:test"
import { Duration, Effect, Layer, Option, Schema } from "effect"
import { sql } from "drizzle-orm"
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
@@ -15,16 +16,16 @@ import {
RefreshToken,
UserCode,
} from "../../src/account/schema"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
const db = Database.Client()
db.run(/*sql*/ `DELETE FROM account_state`)
db.run(/*sql*/ `DELETE FROM account`)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`DELETE FROM account_state`)
yield* db.run(sql`DELETE FROM account`)
}),
)
).pipe(Layer.provide(Database.defaultLayer))
const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate))
@@ -10,14 +10,12 @@ import { eq } from "drizzle-orm"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Database } from "@/storage/db"
import { Database as CoreDatabase } from "@opencode-ai/core/database/database"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Session as SessionNs } from "@/session/session"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SyncEvent } from "@/sync"
import { EventSequenceTable } from "@opencode-ai/core/event/sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideTmpdirInstance, requireInstance, TestInstance } from "../fixture/fixture"
@@ -34,6 +32,7 @@ import { SessionPrompt } from "@/session/prompt"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
void Log.init({ print: false })
@@ -49,11 +48,11 @@ const workspaceLayer = (experimentalWorkspaces: boolean) =>
Workspace.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(CoreDatabase.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
@@ -64,6 +63,7 @@ const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
workspaceLayer(true),
SessionNs.defaultLayer,
Database.defaultLayer,
)
const it = testEffect(testServerLayer)
@@ -107,7 +107,6 @@ function restoreEnv() {
}
beforeEach(() => {
Database.close()
restoreEnv()
process.env.OPENCODE_EXPERIMENTAL_WORKSPACES = "true"
})
@@ -281,7 +280,7 @@ function workspaceInfo(projectID: ProjectV2.ID, type: string, input?: Partial<Wo
}
function insertWorkspace(info: Workspace.Info) {
Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.insert(WorkspaceTable)
.values({
@@ -294,12 +293,13 @@ function insertWorkspace(info: Workspace.Info) {
project_id: info.projectID,
time_used: info.timeUsed,
})
.run(),
.run()
.pipe(Effect.orDie),
)
}
function insertProject(id: ProjectV2.ID, worktree: string) {
Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.insert(ProjectTable)
.values({
@@ -311,34 +311,37 @@ function insertProject(id: ProjectV2.ID, worktree: string) {
time_updated: Date.now(),
sandboxes: [],
})
.run(),
.run()
.pipe(Effect.orDie),
)
}
function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceV2.ID) {
Database.use((db) =>
db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run(),
return Database.Service.use(({ db }) =>
db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie),
)
}
function sessionSequence(sessionID: SessionID) {
return Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.get(),
)?.seq
.get()
.pipe(Effect.orDie, Effect.map((row) => row?.seq)),
)
}
function sessionSequenceOwner(sessionID: SessionID) {
return Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.select({ ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.get(),
)?.ownerID
.get()
.pipe(Effect.orDie, Effect.map((row) => row?.ownerID)),
)
}
describe("workspace schemas and exports", () => {
@@ -382,7 +385,7 @@ describe("workspace CRUD", () => {
const instance = yield* requireInstance
const workspace = yield* Workspace.Service
const otherProjectID = ProjectV2.ID.make("project-other")
insertProject(otherProjectID, "/tmp/other")
yield* insertProject(otherProjectID, "/tmp/other")
const a = workspaceInfo(instance.project.id, "manual", {
id: WorkspaceV2.ID.ascending("wrk_a_list"),
branch: "a",
@@ -396,9 +399,9 @@ describe("workspace CRUD", () => {
extra: ["b"],
})
const other = workspaceInfo(otherProjectID, "manual", { id: WorkspaceV2.ID.ascending("wrk_c_list") })
insertWorkspace(b)
insertWorkspace(other)
insertWorkspace(a)
yield* insertWorkspace(b)
yield* insertWorkspace(other)
yield* insertWorkspace(a)
expect(yield* workspace.list(instance.project)).toEqual([a, b])
}),
@@ -580,7 +583,7 @@ describe("workspace CRUD", () => {
name: "existing",
directory: path.join(instance.directory, "existing"),
})
insertWorkspace(existing)
yield* insertWorkspace(existing)
const discovered = {
type,
@@ -765,8 +768,8 @@ describe("workspace CRUD", () => {
const info = yield* workspace.create({ type, branch: null, projectID: instance.project.id, extra: null })
const one = yield* sessionSvc.create({})
const two = yield* sessionSvc.create({})
attachSessionToWorkspace(one.id, info.id)
attachSessionToWorkspace(two.id, info.id)
yield* attachSessionToWorkspace(one.id, info.id)
yield* attachSessionToWorkspace(two.id, info.id)
const removed = yield* workspace.remove(info.id)
@@ -774,10 +777,14 @@ describe("workspace CRUD", () => {
expect(yield* workspace.get(info.id)).toBeUndefined()
expect(recorded.calls.remove).toEqual([info])
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
const { db } = yield* Database.Service
expect(
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(),
),
yield* db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, info.id))
.all()
.pipe(Effect.orDie),
).toEqual([])
})
},
@@ -804,7 +811,7 @@ describe("workspace CRUD", () => {
},
}).adapter,
)
insertWorkspace(info)
yield* insertWorkspace(info)
expect(yield* workspace.remove(info.id)).toEqual(info)
expect(yield* workspace.get(info.id)).toBeUndefined()
@@ -824,25 +831,27 @@ describe("workspace CRUD", () => {
const targetType = unique("warp-target-local")
const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
yield* insertWorkspace(previous)
yield* insertWorkspace(target)
registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
registerAdapter(instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
yield* attachSessionToWorkspace(session.id, previous.id)
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
const { db } = yield* Database.Service
expect(
Database.use((db) =>
db
(
yield* db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
.get()
.pipe(Effect.orDie)
)?.workspaceID,
).toBe(target.id)
expect(sessionSequenceOwner(session.id)).toBe(target.id)
expect(yield* sessionSequenceOwner(session.id)).toBe(target.id)
})
},
{ git: true },
@@ -867,12 +876,12 @@ describe("workspace CRUD", () => {
const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
yield* insertWorkspace(previous)
yield* insertWorkspace(target)
registerAdapter(instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
registerAdapter(instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
yield* attachSessionToWorkspace(session.id, previous.id)
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
@@ -893,23 +902,25 @@ describe("workspace CRUD", () => {
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-detach-local")
const previous = workspaceInfo(instance.project.id, previousType)
insertWorkspace(previous)
yield* insertWorkspace(previous)
registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
yield* attachSessionToWorkspace(session.id, previous.id)
yield* workspace.sessionWarp({ workspaceID: null, sessionID: session.id })
const { db } = yield* Database.Service
expect(
Database.use((db) =>
db
(
yield* db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
.get()
.pipe(Effect.orDie)
)?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(instance.project.id)
expect(yield* sessionSequenceOwner(session.id)).toBe(instance.project.id)
})
},
{ git: true },
@@ -926,9 +937,9 @@ describe("workspace CRUD", () => {
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-detach-workspace-instance")
const previous = workspaceInfo(projectID, previousType)
insertWorkspace(previous)
yield* insertWorkspace(previous)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
yield* attachSessionToWorkspace(session.id, previous.id)
const workspaceProjectID = yield* provideTmpdirInstance(
(workspaceDir) =>
@@ -942,17 +953,19 @@ describe("workspace CRUD", () => {
{ git: true },
)
const { db } = yield* Database.Service
expect(
Database.use((db) =>
db
(
yield* db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
.get()
.pipe(Effect.orDie)
)?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(projectID)
expect(sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID)
expect(yield* sessionSequenceOwner(session.id)).toBe(projectID)
expect(yield* sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID)
}),
{ git: true },
)
@@ -1005,14 +1018,14 @@ describe("workspace CRUD", () => {
const targetType = unique("warp-remote-target")
const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(instance.project.id, targetType, { directory: "remote-target-dir" })
insertWorkspace(previous)
insertWorkspace(target)
yield* insertWorkspace(previous)
yield* insertWorkspace(target)
registerAdapter(instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
registerAdapter(instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
yield* attachSessionToWorkspace(session.id, previous.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
@@ -1042,7 +1055,7 @@ describe("workspace CRUD", () => {
})
expect(calls[4].json).toEqual({ sessionID: session.id })
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
expect(sessionSequenceOwner(session.id)).toBe(target.id)
expect(yield* sessionSequenceOwner(session.id)).toBe(target.id)
}),
{ git: true },
)
@@ -1062,8 +1075,8 @@ describe("workspace sync state", () => {
const type = unique("flag-disabled")
const info = workspaceInfo(instance.project.id, type)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, info.id)
insertWorkspace(info)
yield* attachSessionToWorkspace(session.id, info.id)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter)
yield* Effect.promise(() => startWorkspaceSyncingWithFlag(instance.project.id, false))
@@ -1088,12 +1101,10 @@ describe("workspace sync state", () => {
const second = workspaceInfo(projectID, secondType)
yield* Effect.promise(() => fs.mkdir(path.join(dir, "first"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(dir, "second"), { recursive: true }))
yield* Effect.sync(() => {
insertWorkspace(first)
insertWorkspace(second)
registerAdapter(projectID, firstType, localAdapter(path.join(dir, "first")).adapter)
registerAdapter(projectID, secondType, localAdapter(path.join(dir, "second")).adapter)
})
yield* insertWorkspace(first)
yield* insertWorkspace(second)
registerAdapter(projectID, firstType, localAdapter(path.join(dir, "first")).adapter)
registerAdapter(projectID, secondType, localAdapter(path.join(dir, "second")).adapter)
yield* Effect.addFinalizer(() =>
Effect.all([workspace.remove(first.id), workspace.remove(second.id)], { discard: true }).pipe(Effect.ignore),
)
@@ -1121,13 +1132,13 @@ describe("workspace sync state", () => {
const sessionSvc = yield* SessionNs.Service
const type = unique("missing-local")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(
instance.project.id,
type,
localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter,
)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1157,9 +1168,9 @@ describe("workspace sync state", () => {
const info = workspaceInfo(instance.project.id, type)
const target = path.join(dir, "dedupe-local")
yield* Effect.promise(() => fs.mkdir(target, { recursive: true }))
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, localAdapter(target).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1211,9 +1222,9 @@ describe("workspace sync state", () => {
try {
const type = unique("remote-start")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sync`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* eventuallyEffect(
@@ -1265,9 +1276,9 @@ describe("workspace sync state", () => {
const instance = yield* requireInstance
const type = unique("remote-connect-fail")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/failed`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1306,9 +1317,9 @@ describe("workspace sync state", () => {
const instance = yield* requireInstance
const type = unique("remote-history-fail")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1364,12 +1375,12 @@ describe("workspace sync state", () => {
try {
const type = unique("history-replay")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/history`).adapter)
const session = yield* sessionSvc.create({ title: "before history" })
attachSessionToWorkspace(session.id, info.id)
yield* attachSessionToWorkspace(session.id, info.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1432,9 +1443,9 @@ describe("workspace sync state", () => {
try {
const type = unique("sse-forward")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1514,12 +1525,12 @@ describe("workspace sync state", () => {
try {
const type = unique("sse-sync")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter)
const session = yield* sessionSvc.create({ title: "before sse" })
attachSessionToWorkspace(session.id, info.id)
yield* attachSessionToWorkspace(session.id, info.id)
sseSessionID = session.id
sseNextSeq = (sessionSequence(session.id) ?? -1) + 1
sseNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1564,7 +1575,8 @@ describe("workspace waitForSync", () => {
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const sessionID = SessionID.descending("ses_wait_done")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run())
const { db } = yield* Database.Service
yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run().pipe(Effect.orDie)
expect(yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done"), { [sessionID]: 4 })).toBeUndefined()
expect(
@@ -1581,20 +1593,20 @@ describe("workspace waitForSync", () => {
const workspace = yield* Workspace.Service
const workspaceID = WorkspaceV2.ID.ascending("wrk_wait_event")
const sessionID = SessionID.descending("ses_wait_event")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run())
const { db } = yield* Database.Service
yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run().pipe(Effect.orDie)
yield* Effect.all(
[
workspace.waitForSync(workspaceID, { [sessionID]: 2 }),
Effect.gen(function* () {
yield* Effect.sleep("10 millis")
Database.use((db) =>
db
.update(EventSequenceTable)
.set({ seq: 2 })
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.run(),
)
yield* db
.update(EventSequenceTable)
.set({ seq: 2 })
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.run()
.pipe(Effect.orDie)
GlobalBus.emit("event", { workspace: workspaceID, payload: { type: "anything" } })
}),
],
@@ -1611,20 +1623,20 @@ describe("workspace waitForSync", () => {
const workspace = yield* Workspace.Service
const workspaceID = WorkspaceV2.ID.ascending("wrk_wait_sync_any")
const sessionID = SessionID.descending("ses_wait_sync_any")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run())
const { db } = yield* Database.Service
yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run().pipe(Effect.orDie)
yield* Effect.all(
[
workspace.waitForSync(workspaceID, { [sessionID]: 1 }),
Effect.gen(function* () {
yield* Effect.sleep("10 millis")
Database.use((db) =>
db
.update(EventSequenceTable)
.set({ seq: 1 })
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.run(),
)
yield* db
.update(EventSequenceTable)
.set({ seq: 1 })
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.run()
.pipe(Effect.orDie)
GlobalBus.emit("event", {
workspace: WorkspaceV2.ID.ascending("wrk_other_workspace"),
payload: { type: "sync" },
+2 -3
View File
@@ -1,11 +1,10 @@
import { rm } from "fs/promises"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { disposeAllInstances } from "./fixture"
export async function resetDatabase() {
await disposeAllInstances().catch(() => undefined)
Database.close()
const dbPath = Database.getPath()
const dbPath = Database.path()
await rm(dbPath, { force: true }).catch(() => undefined)
await rm(`${dbPath}-wal`, { force: true }).catch(() => undefined)
await rm(`${dbPath}-shm`, { force: true }).catch(() => undefined)
+2 -2
View File
@@ -11,17 +11,17 @@ import { Project } from "../../src/project/project"
import { Vcs } from "../../src/project/vcs"
import { Session } from "../../src/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SyncEvent } from "../../src/sync"
import { EventV2Bridge } from "../../src/event-v2-bridge"
export const workspaceLayerWithRuntimeFlags = (overrides: Partial<RuntimeFlags.Info>) =>
Workspace.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(RuntimeFlags.layer(overrides)),
@@ -3,6 +3,7 @@ import os from "os"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Bus } from "../../src/bus"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Database } from "@opencode-ai/core/database/database"
import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
@@ -14,7 +15,7 @@ import { MessageID, SessionID } from "../../src/session/schema"
const bus = Bus.layer
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus)),
Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(bus)),
bus,
CrossSpawnSpawner.defaultLayer,
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
@@ -21,12 +21,12 @@ import { Vcs } from "../../src/project/vcs"
import { InstanceState } from "../../src/effect/instance-state"
import { Session } from "../../src/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SyncEvent } from "../../src/sync"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { EventV2Bridge } from "../../src/event-v2-bridge"
const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
@@ -46,12 +46,12 @@ const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBoot
const workspaceLayer = Workspace.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
-2
View File
@@ -10,8 +10,6 @@ import { afterAll } from "bun:test"
const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid)
await fs.mkdir(dir, { recursive: true })
afterAll(async () => {
const { Database } = await import("../src/storage/db")
Database.close()
const busy = (error: unknown) =>
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
const rm = async (left: number): Promise<void> => {
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Project } from "@/project/project"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -15,7 +15,7 @@ import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer))
const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, Database.defaultLayer))
function legacySessionID() {
// Global-session migration covers persisted IDs from before prefixed session IDs.
@@ -24,7 +24,7 @@ function legacySessionID() {
function seed(opts: { id: SessionID; dir: string; project: ProjectV2.ID }) {
const now = Date.now()
Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.insert(SessionTable)
.values({
@@ -37,12 +37,13 @@ function seed(opts: { id: SessionID; dir: string; project: ProjectV2.ID }) {
time_created: now,
time_updated: now,
})
.run(),
.run()
.pipe(Effect.orDie),
)
}
function ensureGlobal() {
Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.insert(ProjectTable)
.values({
@@ -53,7 +54,8 @@ function ensureGlobal() {
sandboxes: [],
})
.onConflictDoNothing()
.run(),
.run()
.pipe(Effect.orDie),
)
}
@@ -72,7 +74,7 @@ describe("migrateFromGlobal", () => {
// 2. Seed a session under "global" with matching directory
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectV2.ID.global }))
yield* seed({ id, dir: tmp, project: ProjectV2.ID.global })
// 3. Make a commit so the project gets a real ID
yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet())
@@ -81,7 +83,9 @@ describe("migrateFromGlobal", () => {
expect(real.id).not.toBe(ProjectV2.ID.global)
// 4. The session should have been migrated to the real project ID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
const row = yield* Database.Service.use(({ db }) =>
db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
)
expect(row).toBeDefined()
expect(row!.project_id).toBe(real.id)
}),
@@ -96,19 +100,21 @@ describe("migrateFromGlobal", () => {
expect(project.id).not.toBe(ProjectV2.ID.global)
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
yield* Effect.sync(() => ensureGlobal())
yield* ensureGlobal()
// 3. Seed a session under "global" with matching directory.
// This simulates a session created before git init that wasn't
// present when the real project row was first created.
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectV2.ID.global }))
yield* seed({ id, dir: tmp, project: ProjectV2.ID.global })
// 4. Call fromDirectory again — project row already exists,
// so the current code skips migration entirely. This is the bug.
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
const row = yield* Database.Service.use(({ db }) =>
db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
)
expect(row).toBeDefined()
expect(row!.project_id).toBe(project.id)
}),
@@ -121,16 +127,18 @@ describe("migrateFromGlobal", () => {
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectV2.ID.global)
yield* Effect.sync(() => ensureGlobal())
yield* ensureGlobal()
// Legacy sessions may lack a directory value.
// Without a matching origin directory, they should remain global.
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: "", project: ProjectV2.ID.global }))
yield* seed({ id, dir: "", project: ProjectV2.ID.global })
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
const row = yield* Database.Service.use(({ db }) =>
db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
)
expect(row).toBeDefined()
expect(row!.project_id).toBe(ProjectV2.ID.global)
}),
@@ -143,14 +151,16 @@ describe("migrateFromGlobal", () => {
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectV2.ID.global)
yield* Effect.sync(() => ensureGlobal())
yield* ensureGlobal()
// Seed a session under "global" but for a DIFFERENT directory
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectV2.ID.global }))
yield* seed({ id, dir: "/some/other/dir", project: ProjectV2.ID.global })
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
const row = yield* Database.Service.use(({ db }) =>
db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
)
expect(row).toBeDefined()
// Should remain under "global" — not stolen
expect(row!.project_id).toBe(ProjectV2.ID.global)
@@ -1,279 +0,0 @@
// Diagnostic suite for /event SSE delivery.
//
// Each test isolates ONE variable in the publisher chain while keeping the
// subscriber path constant (in-process HttpApi via Server.Default reading the
// SSE body). The pass/fail pattern across tests tells us where the bug lives:
//
// D1 (baseline): publish via Bus.use.publish — mirror of httpapi-event.test.ts
// test 3. Confirms /event SSE delivery works for SOME publish path.
//
// D2: publish N times in quick succession via Bus.use.publish. If the bus
// subscription is acquired correctly there should be no message loss.
//
// D3: publish via SyncEvent.use.run — exercises the same path the HTTP
// handlers use (Session.updatePart → sync.run → bus.publish) without
// the HTTP roundtrip. Tells us whether the sync path itself can deliver
// in-process.
//
// D4: publish via SyncEvent.use.run; subscriber is an in-process Bus
// callback. Confirms pub/sub identity end-to-end without /event SSE.
//
// D5: in-process Bus callback subscriber AND raw /event SSE subscriber
// receive the same publish. If both receive: no bug. If only the
// callback receives: the /event handler has an acquisition race.
//
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
// subscription is established. Order-of-setup variable.
import { afterEach, describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Schema } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { Bus } from "../../src/bus"
import { Event as ServerEvent } from "../../src/server/event"
import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SyncEvent } from "../../src/sync"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffectShared } from "../lib/effect"
void Log.init({ print: false })
const SseEvent = Schema.Struct({
id: Schema.optional(Schema.String),
type: Schema.String,
properties: Schema.Record(Schema.String, Schema.Any),
})
type SseEvent = Schema.Schema.Type<typeof SseEvent>
type BusEvent = { type: string; properties: unknown }
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const it = testEffectShared(Layer.mergeAll(Bus.defaultLayer, SyncEvent.defaultLayer))
const publishConnected = Bus.use.publish(ServerEvent.Connected, {})
const publishPartUpdated = (partID: ReturnType<typeof PartID.ascending>) => {
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
return Bus.use.publish(MessageV2.Event.PartUpdated, {
sessionID,
part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
time: Date.now(),
})
}
const subscribeAllCallback = (handler: (event: BusEvent) => void) =>
Effect.acquireRelease(Bus.use.subscribeAllCallback(handler), (dispose) => Effect.sync(() => dispose()))
const openEventStream = (directory: string) =>
Effect.gen(function* () {
const response = yield* Effect.promise(async () =>
Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }),
)
if (!response.body) return yield* Effect.die("missing SSE response body")
const reader = response.body.getReader()
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined)))
return reader
})
const decoder = new TextDecoder()
function decodeFrame(value: Uint8Array): SseEvent[] {
return decoder
.decode(value)
.split(/\n\n+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
.map((part) => Schema.decodeUnknownSync(SseEvent)(JSON.parse(part.replace(/^data: /, ""))))
}
const readNextEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
Effect.promise(() => reader.read()).pipe(
Effect.timeoutOrElse({
duration: "3 seconds",
orElse: () => Effect.fail(new Error("timed out reading SSE chunk")),
}),
Effect.flatMap((result) => {
if (result.done || !result.value) return Effect.fail(new Error("event stream closed"))
const frames = decodeFrame(result.value)
if (frames.length === 0) return Effect.fail(new Error("empty SSE frame"))
return Effect.succeed(frames[0]!)
}),
)
const collectUntilEvent = (reader: ReadableStreamDefaultReader<Uint8Array>, predicate: (event: SseEvent) => boolean) =>
Effect.gen(function* () {
const events: SseEvent[] = []
while (true) {
const event = yield* readNextEvent(reader)
events.push(event)
if (predicate(event)) return events
}
}).pipe(
Effect.timeoutOrElse({
duration: "4 seconds",
orElse: () => Effect.fail(new Error("collectUntil deadline exceeded")),
}),
)
const isPartUpdated = (event: { type: string }) => event.type === MessageV2.Event.PartUpdated.type
describe("/event SSE delivery diagnostics", () => {
// Sanity: baseline same as httpapi-event.test.ts test 3 (already known to pass)
// but explicit about timing — publish happens with NO wait after reading
// server.connected. If this fails we have a deeper problem than just sync.
it.instance(
"D1: delivers a single bus event published right after server.connected",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
yield* publishConnected
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// If D1 passes but D2 fails, we have a queue-drain or partial-loss issue.
it.instance(
"D2: delivers all N bus events published in rapid succession",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const N = 5
yield* Effect.replicateEffect(publishConnected, N)
const received = yield* Effect.replicateEffect(readNextEvent(reader), N)
expect(received).toHaveLength(N)
for (const event of received) expect(event.type).toBe("server.connected")
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// The critical test. If D1 passes but this fails, the bus-identity fix is
// incomplete OR the sync.run publish path doesn't reach the same bus
// /event subscribes to, even when both share the memoMap.
it.instance(
"D3: delivers a SyncEvent published via SyncEvent.use.run after server.connected",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const partID = PartID.ascending()
yield* publishPartUpdated(partID)
const collected = yield* collectUntilEvent(reader, isPartUpdated)
const updated = collected.find(isPartUpdated)
expect(updated?.properties.part.id).toBe(partID)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// If D3 passes but D5 (the SDK E2E in httpapi-sdk.test.ts) fails, then the
// bug is specifically in the cross-request / cross-fiber HTTP path, not in
// the publish itself. If D3 also fails, the publish chain is broken.
//
// D4: ensure the publish reaches an in-process Bus subscriber too. Confirms
// pub/sub identity end-to-end without involving /event SSE.
it.instance(
"D4: SyncEvent.use.run publish reaches an in-process Bus callback",
() =>
Effect.gen(function* () {
const received = yield* Deferred.make<BusEvent>()
yield* subscribeAllCallback((event) => {
if (isPartUpdated(event)) Deferred.doneUnsafe(received, Effect.succeed(event))
})
const partID = PartID.ascending()
yield* publishPartUpdated(partID)
const event = yield* Deferred.await(received).pipe(
Effect.timeoutOrElse({
duration: "3 seconds",
orElse: () => Effect.fail(new Error("D4 timed out waiting for callback")),
}),
)
expect(event.type).toBe(MessageV2.Event.PartUpdated.type)
expect(event.properties).toMatchObject({ part: { id: partID } })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// D5: BOTH subscribers attached simultaneously. Trigger ONE publish via
// SyncEvent.use.run. Both subscribers should receive it. If only one does
// we know exactly which side of the chain is failing.
it.instance(
"D5: same SyncEvent.use.run publish reaches BOTH /event SSE and in-process callback",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const callbackReceived = yield* Deferred.make<BusEvent>()
yield* subscribeAllCallback((event) => {
if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event))
})
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const partID = PartID.ascending()
yield* publishPartUpdated(partID)
const sseSaw = yield* collectUntilEvent(reader, isPartUpdated).pipe(
Effect.map((events) => events.some(isPartUpdated)),
Effect.catch(() => Effect.succeed(false)),
)
const callbackSaw = yield* Deferred.await(callbackReceived).pipe(
Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(undefined) }),
Effect.map((event) => event !== undefined),
)
// Single assert with the boolean pair so the failure message tells us
// exactly which side broke.
expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
// subscription is established. If D5 fails and D6 passes, the order of
// subscriber setup is the determining factor.
it.instance(
"D6: /event SSE receives sync.run publish when callback is attached AFTER /event opens",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const callbackReceived = yield* Deferred.make<BusEvent>()
yield* subscribeAllCallback((event) => {
if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event))
})
const partID = PartID.ascending()
yield* publishPartUpdated(partID)
const sseSaw = yield* collectUntilEvent(reader, isPartUpdated).pipe(
Effect.map((events) => events.some(isPartUpdated)),
Effect.catch(() => Effect.succeed(false)),
)
const callbackSaw = yield* Deferred.await(callbackReceived).pipe(
Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(undefined) }),
Effect.map((event) => event !== undefined),
)
expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})
@@ -6,7 +6,9 @@ import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { Session } from "@/session/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { AccountV2 } from "@opencode-ai/core/account"
import { AccountTable } from "@opencode-ai/core/account/sql"
import * as Log from "@opencode-ai/core/util/log"
import { Worktree } from "../../src/worktree"
import { resetDatabase } from "../fixture/db"
@@ -15,7 +17,7 @@ import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const it = testEffect(Layer.mergeAll(Session.defaultLayer))
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer))
const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance
function app() {
@@ -62,34 +64,34 @@ function waitReady(input: { directory?: string; name?: string }) {
function insertAccount() {
return Effect.acquireRelease(
Effect.sync(() => {
Database.Client()
.$client.prepare(
"INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
"account-test",
"test@example.com",
"https://console.example.com",
"access",
"refresh",
Date.now(),
Date.now(),
)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(AccountTable)
.values({
id: AccountV2.ID.make("account-test"),
email: "test@example.com",
url: "https://console.example.com",
access_token: AccountV2.AccessToken.make("access"),
refresh_token: AccountV2.RefreshToken.make("refresh"),
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
.pipe(Effect.orDie)
return "account-test"
}),
(id) =>
Effect.sync(() => {
Database.Client().$client.prepare("DELETE FROM account WHERE id = ?").run(id)
}),
Database.Service.use(({ db }) =>
db.delete(AccountTable).where(eq(AccountTable.id, AccountV2.ID.make(id))).run().pipe(Effect.orDie),
),
)
}
function setSessionUpdated(session: Session.Info, updated: number) {
return Effect.sync(() => {
Database.use((db) =>
db.update(SessionTable).set({ time_updated: updated }).where(eq(SessionTable.id, session.id)).run(),
)
return Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ time_updated: updated }).where(eq(SessionTable.id, session.id)).run().pipe(Effect.orDie)
})
}
@@ -17,7 +17,7 @@ import { resetDatabase } from "../fixture/db"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
// Flip the experimental workspaces flag so SyncEvent.run actually writes to
// Flip the experimental workspaces flag so EventV2.run actually writes to
// EventSequenceTable (the source of truth the fence middleware reads). Reset
// the database around the test so per-instance state does not leak between
// runs. resetDatabase() already calls disposeAllInstances(), so we don't
@@ -1,7 +1,7 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { eq } from "drizzle-orm"
import * as Database from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { Server } from "../../src/server/server"
import { Session } from "@/session/session"
@@ -14,7 +14,7 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const it = testEffect(Session.defaultLayer)
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer))
afterEach(async () => {
await disposeAllInstances()
@@ -44,22 +44,20 @@ const seedCorruptStepFinishPart = Effect.gen(function* () {
})
// Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
// that broke the user's session in the OMO/Windows bug.
yield* Effect.sync(() =>
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
} as never, // drizzle's .set() can't narrow the discriminated union
})
.where(eq(PartTable.id, partID))
.run(),
),
)
const { db } = yield* Database.Service
yield* db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
} as never, // drizzle's .set() can't narrow the discriminated union
})
.where(eq(PartTable.id, partID))
.run()
.pipe(Effect.orDie)
return info.id
})
@@ -642,7 +642,7 @@ describe("HttpApi SDK", () => {
),
)
// Regression: SyncEvent must publish on the same ProjectBus the /event handler
// Regression: EventV2 must publish on the same ProjectBus the /event handler
// subscribes to, AND the /event stream must forward handler ALS/context into the
// body-pump fiber. Drives the full SDK → /event → Session.updatePart → sync.run →
// bus.publish → SDK subscriber path. Goes red if either the publisher uses a
@@ -19,7 +19,7 @@ import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/se
import { Session } from "@/session/session"
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -43,7 +43,9 @@ const instanceStoreLayer = InstanceStore.defaultLayer.pipe(
Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })),
),
)
const it = testEffect(Layer.mergeAll(instanceStoreLayer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
const it = testEffect(
Layer.mergeAll(instanceStoreLayer, Project.defaultLayer, Session.defaultLayer, workspaceLayer, Database.defaultLayer),
)
function app() {
return Server.Default().app
@@ -107,7 +109,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
)
const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
Effect.sync(() => {
Effect.gen(function* () {
const message = new SessionMessage.Assistant({
id: SessionMessage.ID.create(),
type: "assistant",
@@ -120,76 +122,72 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
time: { created: DateTime.makeUnsafe(time) },
content: [],
})
Database.use((db) =>
db
.insert(SessionMessageTable)
.values([
{
id: message.id,
session_id: sessionID,
type: message.type,
time_created: time,
data: {
time: { created: time },
agent: message.agent,
model: message.model,
content: message.content,
} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
},
])
.run(),
)
const { db } = yield* Database.Service
yield* db
.insert(SessionMessageTable)
.values([
{
id: message.id,
session_id: sessionID,
type: message.type,
time_created: time,
data: {
time: { created: time },
agent: message.agent,
model: message.model,
content: message.content,
} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
},
])
.run()
.pipe(Effect.orDie)
})
const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) =>
Effect.sync(() =>
Database.use((db) =>
db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.create(),
session_id: sessionID,
type: "assistant",
time_created: time,
data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
},
])
.run(),
),
)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.create(),
session_id: sessionID,
type: "assistant",
time_created: time,
data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
},
])
.run()
.pipe(Effect.orDie)
})
const setLegacySummaryDiff = (sessionID: SessionIDType) =>
Effect.sync(() =>
Database.use((db) =>
db
.update(SessionTable)
.set({
summary_additions: 1,
summary_deletions: 0,
summary_files: 1,
summary_diffs: [{ additions: 1, deletions: 0 }],
})
.where(eq(SessionTable.id, sessionID))
.run(),
),
)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.update(SessionTable)
.set({
summary_additions: 1,
summary_deletions: 0,
summary_files: 1,
summary_diffs: [{ additions: 1, deletions: 0 }],
})
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
})
const getWorkspaceID = (sessionID: SessionIDType) =>
Effect.sync(() =>
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
),
)
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db.select({ workspaceID: SessionTable.workspace_id }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
})
const clearSessionPath = (sessionID: SessionIDType) =>
Effect.sync(() =>
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run()),
)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
})
function request(path: string, init?: RequestInit) {
return Effect.promise(async () => app().request(path, init))
@@ -19,6 +19,7 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { Database } from "@opencode-ai/core/database/database"
import { Project } from "../../src/project/project"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
@@ -26,7 +27,6 @@ import {
workspaceRouterMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
import { Database } from "../../src/storage/db"
import { resetDatabase } from "../fixture/db"
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
import { tmpdirScoped } from "../fixture/fixture"
@@ -50,6 +50,7 @@ const it = testEffect(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
Database.defaultLayer,
Project.defaultLayer,
workspaceLayer,
Socket.layerWebSocketConstructorGlobal,
@@ -160,10 +161,11 @@ const insertRemoteWorkspaceWithoutSync = (input: {
type: string
url: string
}) =>
Effect.sync(() => {
Effect.gen(function* () {
const id = WorkspaceV2.ID.ascending()
registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url))
Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
const { db } = yield* Database.Service
yield* db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run().pipe(Effect.orDie)
return id
})
@@ -6,21 +6,21 @@
// strict `NonNegativeInt` schema then made every load of the message list
// fail to encode, killing Desktop boot for every user with such a row.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { eq } from "drizzle-orm"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID } from "../../src/session/schema"
import * as Database from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { PartTable } from "@opencode-ai/core/session/sql"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const it = testEffect(Session.defaultLayer)
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer))
function seedNegativeTokenSession() {
return Effect.gen(function* () {
@@ -47,20 +47,20 @@ function seedNegativeTokenSession() {
// Bypass the schema with a direct SQL update to install the
// negative `output` value we want to test loading.
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
} as never,
})
.where(eq(PartTable.id, partID))
.run(),
)
const { db } = yield* Database.Service
yield* db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
} as never,
})
.where(eq(PartTable.id, partID))
.run()
.pipe(Effect.orDie)
return info.id
})
@@ -1,7 +1,6 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Session as SessionNs } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
@@ -13,9 +12,9 @@ import { eq } from "drizzle-orm"
import { testEffect } from "../lib/effect"
import { Bus } from "@/bus"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
void Log.init({ print: false })
const it = testEffect(
@@ -24,9 +23,8 @@ const it = testEffect(
SessionNs.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
Layer.provide(BackgroundJob.defaultLayer),
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2Bridge } from "@/event-v2-bridge"
import { APICallError } from "ai"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
import * as Stream from "effect/Stream"
@@ -29,9 +30,7 @@ import { ProviderTest } from "../fake/provider"
import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { TestConfig } from "../fixture/config"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { LLMEvent, Usage } from "@opencode-ai/llm"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -234,22 +233,22 @@ const deps = Layer.mergeAll(
Plugin.defaultLayer,
Bus.layer,
Config.defaultLayer,
SyncEvent.defaultLayer,
RuntimeFlags.layer({ experimentalEventSystem: true }),
EventV2Bridge.defaultLayer,
Database.defaultLayer,
EventV2Bridge.defaultLayer,
)
const env = Layer.mergeAll(
SessionNs.defaultLayer,
Database.defaultLayer,
EventV2Bridge.defaultLayer,
CrossSpawnSpawner.defaultLayer,
SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)),
)
const it = testEffect(env)
const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer)
const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, Database.defaultLayer, EventV2Bridge.defaultLayer, CrossSpawnSpawner.defaultLayer)
const itCompaction = testEffect(compactionEnv)
type CompactionProcessOptions = {
@@ -286,7 +285,6 @@ function compactionProcessLayer(options?: CompactionProcessOptions) {
Layer.provide(status),
Layer.provide(bus),
Layer.provide(options?.config ?? Config.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
Layer.provide(EventV2Bridge.defaultLayer),
)
@@ -1,6 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2Bridge } from "@/event-v2-bridge"
import { expect } from "bun:test"
import { tool } from "ai"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
@@ -28,9 +29,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { raw, reply, TestLLMServer } from "../lib/llm-server"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
void Log.init({ print: false })
@@ -185,9 +184,8 @@ const deps = Layer.mergeAll(
LLM.defaultLayer,
Provider.defaultLayer,
status,
SyncEvent.defaultLayer,
EventV2Bridge.defaultLayer,
Database.defaultLayer,
EventV2Bridge.defaultLayer,
).pipe(Layer.provideMerge(infra))
const env = Layer.mergeAll(
TestLLMServer.layer,
@@ -1,6 +1,8 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Database as CoreDatabase } from "@opencode-ai/core/database/database"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { EventV2Bridge } from "@/event-v2-bridge"
import { FetchHttpClient } from "effect/unstable/http"
import { expect } from "bun:test"
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
@@ -46,7 +48,6 @@ import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
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"
import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
import { Reference } from "../../src/reference/reference"
@@ -54,9 +55,7 @@ import { RepositoryCache } from "../../src/reference/repository-cache"
import { TestInstance } from "../fixture/fixture"
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
void Log.init({ print: false })
@@ -184,9 +183,8 @@ function makePrompt(input?: { processor?: "blocking" }) {
AppFileSystem.defaultLayer,
BackgroundJob.defaultLayer,
status,
SyncEvent.defaultLayer,
Database.defaultLayer,
EventV2Bridge.defaultLayer,
CoreDatabase.defaultLayer,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
@@ -512,9 +510,8 @@ noLLMServer.instance(
const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
Effect.provide(SessionV2.defaultLayer),
)
const row = Database.use((db) =>
db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(),
)
const { db } = yield* Database.Service
const row = yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.session_id, chat.id)).get().pipe(Effect.orDie)
expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" })
expect(typeof row?.data.time.created).toBe("number")
expect(messages).toEqual(
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Deferred, Effect, Exit, Layer } from "effect"
import { Session as SessionNs } from "@/session/session"
@@ -14,9 +13,9 @@ import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Bus } from "@/bus"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
void Log.init({ print: false })
@@ -25,9 +24,8 @@ const it = testEffect(
SessionNs.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
Layer.provide(BackgroundJob.defaultLayer),
@@ -31,6 +31,7 @@ import { TestLLMServer } from "../lib/llm-server"
// Same layer setup as prompt-effect.test.ts
import { NodeFileSystem } from "@effect/platform-node"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Git } from "../../src/git"
@@ -62,9 +63,7 @@ import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
void Log.init({ print: false })
@@ -132,9 +131,8 @@ function makeHttp() {
AppFileSystem.defaultLayer,
BackgroundJob.defaultLayer,
status,
SyncEvent.defaultLayer,
EventV2Bridge.defaultLayer,
Database.defaultLayer,
EventV2Bridge.defaultLayer,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
@@ -1,9 +0,0 @@
import { describe, expect, it } from "bun:test"
import { Database as CoreDatabase } from "@opencode-ai/core/database/database"
import { Database } from "@/storage/db"
describe("Database.getPath", () => {
it("delegates to the core database path", () => {
expect(Database.getPath()).toBe(CoreDatabase.path())
})
})
-391
View File
@@ -1,391 +0,0 @@
import { describe, expect, beforeEach, afterAll } from "bun:test"
import { provideTmpdirInstance } from "../fixture/fixture"
import { Deferred, Effect, Layer, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { SyncEvent } from "../../src/sync"
import { Database, eq } from "@/storage/db"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { EventV2 } from "@opencode-ai/core/event"
import { MessageID } from "../../src/session/schema"
import { initProjectors } from "../../src/server/projectors"
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
const it = testEffect(
Layer.mergeAll(
SyncEvent.layer.pipe(
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
Layer.provideMerge(Bus.layer),
),
CrossSpawnSpawner.defaultLayer,
),
)
beforeEach(() => {
Database.close()
})
describe("SyncEvent", () => {
function setup() {
SyncEvent.reset()
const Created = SyncEvent.define({
type: "item.created",
version: 1,
aggregate: "id",
schema: Schema.Struct({ id: Schema.String, name: Schema.String }),
})
const Sent = SyncEvent.define({
type: "item.sent",
version: 1,
aggregate: "item_id",
schema: Schema.Struct({ item_id: Schema.String, to: Schema.String }),
})
SyncEvent.init({
projectors: [SyncEvent.project(Created, () => {}), SyncEvent.project(Sent, () => {})],
})
return { Created, Sent }
}
function expectDefect<A, E, R>(effect: Effect.Effect<A, E, R>, pattern: RegExp) {
return Effect.gen(function* () {
const exit = yield* Effect.exit(effect)
if (exit._tag === "Success") throw new Error("Expected effect to fail")
expect(String(exit.cause)).toMatch(pattern)
})
}
afterAll(() => {
SyncEvent.reset()
initProjectors()
})
describe("run", () => {
it.live(
"inserts event row",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].type).toBe("item.created.1")
expect(rows[0].aggregate_id).toBe("evt_1")
}),
),
)
it.live(
"increments seq per aggregate",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" })
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "second" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(2)
expect(rows[1].seq).toBe(rows[0].seq + 1)
}),
),
)
it.live(
"uses custom aggregate field from agg()",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Sent } = setup()
yield* SyncEvent.use.run(Sent, { item_id: "evt_1", to: "james" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe("evt_1")
}),
),
)
it.live(
"emits events",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const events: Array<{
type: string
properties: { id: string; name: string }
}> = []
let resolve = () => {}
const received = new Promise<void>((done) => {
resolve = done
})
const bus = yield* Bus.Service
const dispose = yield* bus.subscribeAllCallback((event) => {
events.push(event)
resolve()
})
try {
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" })
yield* Effect.promise(() => received)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
type: "item.created",
properties: {
id: "evt_1",
name: "test",
},
})
} finally {
dispose()
}
}),
),
)
// Regression for the EffectBridge migration. GlobalBus.emit used to fire
// synchronously inside the Database.effect post-commit callback. After the
// migration it fires inside the forked publish Effect, AFTER bus.publish
// completes. Consumers don't care about microsecond-level ordering, but
// we still need to prove the emit actually fires.
it.live(
"emits sync events to GlobalBus after publishing to ProjectBus",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
// Filter for OUR specific event in the handler so we ignore any
// stray sync events from other tests' lingering forks.
const received = yield* Deferred.make<GlobalEvent>()
const handler = (evt: GlobalEvent) => {
if (evt.payload?.type === "sync" && evt.payload?.syncEvent?.type === "item.created.1") {
Deferred.doneUnsafe(received, Effect.succeed(evt))
}
}
GlobalBus.on("event", handler)
try {
yield* SyncEvent.use.run(Created, { id: "evt_global_1", name: "global" })
const event = yield* awaitWithTimeout(
Deferred.await(received),
"timed out waiting for sync event on GlobalBus",
"2 seconds",
)
expect(event.payload).toMatchObject({
type: "sync",
syncEvent: { type: "item.created.1", data: { id: "evt_global_1", name: "global" } },
})
} finally {
GlobalBus.off("event", handler)
}
}),
),
)
})
describe("replay", () => {
it.live(
"inserts event from external payload",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const id = MessageID.ascending()
yield* SyncEvent.use.replay({
id: "evt_1",
type: "item.created.1",
seq: 0,
aggregateID: id,
data: { id, name: "replayed" },
})
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe(id)
}),
),
)
it.live(
"throws on sequence mismatch",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const id = MessageID.ascending()
yield* SyncEvent.use.replay({
id: "evt_1",
type: "item.created.1",
seq: 0,
aggregateID: id,
data: { id, name: "first" },
})
yield* expectDefect(
SyncEvent.use.replay({
id: "evt_1",
type: "item.created.1",
seq: 5,
aggregateID: id,
data: { id, name: "bad" },
}),
/Sequence mismatch/,
)
}),
),
)
it.live(
"throws on unknown event type",
provideTmpdirInstance(() =>
Effect.gen(function* () {
yield* expectDefect(
SyncEvent.use.replay({
id: "evt_1",
type: "unknown.event.1",
seq: 0,
aggregateID: "x",
data: {},
}),
/Unknown event type/,
)
}),
),
)
it.live(
"replayAll accepts later chunks after the first batch",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
const one = yield* SyncEvent.use.replayAll([
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "second" },
},
])
const two = yield* SyncEvent.use.replayAll([
{
id: "evt_3",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 2,
aggregateID: id,
data: { id, name: "third" },
},
{
id: "evt_4",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 3,
aggregateID: id,
data: { id, name: "fourth" },
},
])
expect(one).toBe(id)
expect(two).toBe(id)
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
}),
),
)
it.live(
"claims unowned event sequence on replay with ownerID",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
yield* SyncEvent.use.replay(
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "owned" },
},
{ publish: false, ownerID: "owner-1" },
)
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.get(),
)
expect(row).toEqual({ seq: 0, ownerID: "owner-1" })
}),
),
)
it.live(
"ignores replay from a different owner after sequence is claimed",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
yield* SyncEvent.use.replay(
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{ publish: false, ownerID: "owner-1" },
)
yield* SyncEvent.use.replay(
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "ignored" },
},
{ publish: false, ownerID: "owner-2" },
)
const events = Database.use((db) => db.select().from(EventTable).all())
const sequence = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.get(),
)
expect(events).toHaveLength(1)
expect(events[0]?.id).toBe(EventV2.ID.make("evt_1"))
expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
}),
),
)
it.live(
"claim updates the event sequence owner",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
yield* SyncEvent.use.run(Created, { id, name: "claimed" }, { publish: false })
yield* SyncEvent.use.claim(id, "owner-1")
yield* SyncEvent.use.claim(id, "owner-2")
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, id))
.get(),
)
expect(row).toEqual({ seq: 0, ownerID: "owner-2" })
}),
),
)
})
})
+17 -33
View File
@@ -25,7 +25,6 @@ Production imports from `packages/opencode/src/storage/db.ts` are concentrated i
- `packages/opencode/src/cli/cmd/import.ts`
- `packages/opencode/src/cli/cmd/stats.ts`
- `packages/opencode/src/control-plane/workspace.ts`
- `packages/opencode/src/data-migration.ts`
- `packages/opencode/src/index.ts`
- `packages/opencode/src/node.ts`
- `packages/opencode/src/permission/index.ts`
@@ -47,6 +46,8 @@ There are 65 direct API/type references in those files. The references fall into
## Group 1: Database Runtime And Startup
Status: Completed. Startup, the public node export, and database CLI tooling no longer import the legacy opencode database wrapper; `packages/opencode/src/storage/db.ts` has been deleted.
Files:
- `packages/opencode/src/storage/db.ts`
@@ -75,6 +76,8 @@ Target shape:
## Group 2: Sync Event Transaction Boundary
Status: Completed. `SyncEvent` and the opencode projector boundary were removed; session/message event projection now lives in core EventV2/projector infrastructure.
Files:
- `packages/opencode/src/sync/index.ts`
@@ -106,6 +109,8 @@ Suggested first step:
## Group 3: Domain Repositories Already Behind Services
Status: Completed. These services no longer import the legacy opencode database wrapper.
Files:
- `packages/opencode/src/account/repo.ts`
@@ -139,6 +144,8 @@ Suggested order:
## Group 4: Session And Message Read Models
Status: Completed. Session/message reads and projector writes have moved off the legacy opencode database wrapper.
Files:
- `packages/opencode/src/session/session.ts`
@@ -176,6 +183,8 @@ Suggested order:
## Group 5: Legacy CLI And One-Off Admin Reads
Status: Completed. Remaining one-off CLI/admin reads and writes now use core database services or domain services instead of the legacy opencode database wrapper.
Files:
- `packages/opencode/src/cli/cmd/import.ts`
@@ -206,46 +215,21 @@ Target shape:
- For HTTP sync reads, move the event log query behind the sync event module.
- For permission and worktree reads, call the permission/project services if available; otherwise add narrow repository methods.
## Group 6: Data Migrations
Files:
- `packages/opencode/src/data-migration.ts`
Current usage:
- Checks `DataMigrationTable` with `Database.use`.
- Runs resumable data migrations with `Database.use` and `Database.transaction`.
- Writes completion rows with `Database.use`.
Why this group is separate:
- Data migrations are database-native by definition and may reasonably stay close to SQL.
- They should not depend on the legacy opencode wrapper, but they do need a stable transaction API and migration completion store.
Target shape:
- Run data migrations through the same Effect database service used by startup/migrations.
- Keep SQL-heavy migration logic local to `data-migration.ts`, but remove callback-style legacy access.
- Ensure migrations still run in a scoped/background fiber and remain resumable.
## Recommended Migration Sequence
1. Replace the legacy runtime seam from Group 1 with an Effect-native database module that exposes path, client/query access, transaction, and after-commit behavior.
2. Port Group 2 sync event transaction semantics to the new module before touching projector bodies.
3. Migrate Group 3 repositories that already hide database access behind service interfaces.
4. Migrate Group 4 session/message reads, then projector write helpers once the new projector transaction type exists.
5. Migrate Group 6 data migrations onto the new database service.
6. Clean up Group 5 one-off reads and CLI/admin commands.
7. Remove drizzle helper re-exports from `@/storage/db` imports by importing operators directly from `drizzle-orm` during each file migration.
8. Delete `packages/opencode/src/storage/db.ts` once `rg "@/storage/db|./storage/db|Database\." packages/opencode/src` no longer finds legacy usages.
All migration groups are complete or superseded. `packages/opencode/src/storage/db.ts` has been deleted.
## Superseded: Data Migrations
Status: Superseded. No opencode data-migration group remains.
The previous opencode `data-migration.ts` service only backfilled session usage from message rows. That work is now covered by core database migration `packages/core/src/database/migration/20260510033149_session_usage.ts`, so there is no separate opencode data-migration group.
## Invariants To Preserve
- Nested reads inside a transaction must use the active transaction, not the root client.
- `SyncEvent.run` sequence allocation must keep immediate transaction behavior.
- Post-commit publish effects must not run before the transaction commits.
- Data migrations must remain resumable and record completion only after successful migration work.
- Existing schema ownership remains in `packages/core/src/**/*.sql.ts`; do not move table definitions back into `packages/opencode`.
## Verification Commands