Compare commits

...

2 Commits

Author SHA1 Message Date
Dax Raad 34bade292d progress 2026-05-22 09:42:01 -04:00
Dax Raad 6bd47e1bce feat(core): add sqlite schema sync 2026-05-22 09:42:00 -04:00
31 changed files with 835 additions and 65 deletions
+3
View File
@@ -2,6 +2,9 @@
"$schema": "https://opencode.ai/config.json",
"provider": {},
"permission": {},
"reference": {
"effect": "github.com/Effect-TS/effect-smol",
},
"mcp": {},
"tools": {
"github-triage": false,
+3
View File
@@ -49,8 +49,10 @@
"@aws-sdk/credential-providers": "3.993.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
@@ -58,6 +60,7 @@
"@openrouter/ai-sdk-provider": "2.8.1",
"ai-gateway-provider": "3.1.2",
"cross-spawn": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"gitlab-ai-provider": "6.7.0",
"glob": "13.0.5",
+49
View File
@@ -0,0 +1,49 @@
export * as Database from "./database"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Context, Effect, Layer } from "effect"
import { Global } from "../global"
import { Flag } from "../flag/flag"
import path from "path"
import { DatabaseMigration } from "./migration"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
export class Service extends Context.Service<Service, DatabaseShape>()("@opencode/v2/storage/Database") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
console.log(DatabaseMigration.ensure
return db
}),
)
export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(SqliteClient.layer({ filename })))
}
export const defaultLayer = Layer.unwrap(
Effect.gen(function* () {
return layerFromPath(
!Flag.OPENCODE_DB
? path.join(Global.Path.data, "opencode.db")
: Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)
? Flag.OPENCODE_DB
: path.join(Global.Path.data, Flag.OPENCODE_DB),
)
}),
).pipe(Layer.provide(Global.defaultLayer))
+248
View File
@@ -0,0 +1,248 @@
export * as DatabaseMigration from "./migration"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect } from "effect"
import { getTableName, sql, type SQL, type Table } from "drizzle-orm"
import { getTableConfig, type AnySQLiteTable, type Index, type SQLiteColumn } from "drizzle-orm/sqlite-core"
export type SchemaAst = {
tables: Record<string, TableAst>
}
export type TableAst = {
name: string
columns: Record<string, ColumnAst>
indexes: Record<string, IndexAst>
}
export type ColumnAst = {
name: string
type: string
notNull: boolean
primaryKey: boolean
default?: string
}
export type IndexAst = {
name: string
table: string
columns: IndexColumnAst[]
unique: boolean
where?: string
}
export type IndexColumnAst = { type: "column"; name: string } | { type: "expression"; sql: string }
export type Operation =
| { type: "create_table"; table: TableAst }
| { type: "add_column"; table: string; column: ColumnAst }
| { type: "create_index"; index: IndexAst }
export function diff(db: EffectDrizzleSqlite.EffectSQLiteDatabase, tables: Table[]) {
return read(db).pipe(Effect.map((actual) => diffSchema(actual, fromTables(tables))))
}
export function apply(db: EffectDrizzleSqlite.EffectSQLiteDatabase, operations: Operation[]) {
return Effect.forEach(operations, (operation) => db.run(toSql(operation))).pipe(Effect.asVoid)
}
function fromTables(tables: Table[]): SchemaAst {
return {
tables: Object.fromEntries(tables.map((table) => {
const config = getTableConfig(table as AnySQLiteTable)
const name = getTableName(table)
return [name, tableFromConfig(name, config.columns, config.indexes)]
})),
}
}
function diffSchema(actual: SchemaAst, desired: SchemaAst): Operation[] {
return Object.values(desired.tables).flatMap<Operation>((table) => {
const current = actual.tables[table.name]
if (!current) {
return [createTableOperation(table), ...Object.values(table.indexes).map(createIndexOperation)]
}
return [
...Object.values(table.columns)
.filter((column) => current.columns[column.name] === undefined)
.map((column) => addColumnOperation(table.name, column)),
...Object.values(table.indexes)
.filter((index) => current.indexes[index.name] === undefined)
.map(createIndexOperation),
]
})
}
function createTableOperation(table: TableAst): Operation {
return { type: "create_table", table }
}
function addColumnOperation(table: string, column: ColumnAst): Operation {
return { type: "add_column", table, column }
}
function createIndexOperation(index: IndexAst): Operation {
return { type: "create_index", index }
}
function toSql(operation: Operation) {
if (operation.type === "create_table") {
return `CREATE TABLE ${quoteIdentifier(operation.table.name)} (${Object.values(operation.table.columns)
.map((column) => columnSql(column, true))
.join(", ")})`
}
if (operation.type === "add_column") {
return `ALTER TABLE ${quoteIdentifier(operation.table)} ADD COLUMN ${columnSql(operation.column, false)}`
}
return [
"CREATE",
operation.index.unique ? "UNIQUE" : undefined,
"INDEX",
quoteIdentifier(operation.index.name),
"ON",
quoteIdentifier(operation.index.table),
`(${operation.index.columns.map(indexColumnSql).join(", ")})`,
operation.index.where === undefined ? undefined : `WHERE ${operation.index.where}`,
]
.filter((part) => part !== undefined)
.join(" ")
}
function read(db: EffectDrizzleSqlite.EffectSQLiteDatabase) {
return Effect.gen(function* () {
const rows = yield* db.all<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
const tables = yield* Effect.forEach(rows, (row) => readTable(db, row.name))
return { tables: Object.fromEntries(tables.map((table) => [table.name, table])) }
})
}
function readTable(db: EffectDrizzleSqlite.EffectSQLiteDatabase, name: string) {
return Effect.gen(function* () {
const columns = yield* db.all<{
name: string
type: string
notnull: number
pk: number
dflt_value: string | null
}>(`PRAGMA table_info(${quoteIdentifier(name)})`)
const indexes = yield* db.all<{ name: string; unique: number }>(`PRAGMA index_list(${quoteIdentifier(name)})`)
const indexEntries = yield* Effect.forEach(indexes, (index) =>
Effect.gen(function* () {
const statement = yield* db.get<{ sql: string | null }>(sql`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ${index.name}`)
if (statement?.sql === null || statement?.sql === undefined) return undefined
const columns = yield* db.all<{ seqno: number; name: string | null }>(`PRAGMA index_info(${quoteIdentifier(index.name)})`)
return [
index.name,
{
name: index.name,
table: name,
columns: columns.map((column) =>
column.name === null
? ({ type: "expression", sql: "" } as const)
: ({ type: "column", name: column.name } as const),
),
unique: index.unique === 1,
},
] as const
}),
)
return {
name,
columns: Object.fromEntries(columns.map((column) => [
column.name,
{
name: column.name,
type: column.type,
notNull: column.notnull === 1,
primaryKey: column.pk > 0,
...(column.dflt_value === null ? {} : { default: column.dflt_value }),
},
])),
indexes: Object.fromEntries(indexEntries.filter((entry) => entry !== undefined)),
}
})
}
function tableFromConfig(name: string, columns: SQLiteColumn[], indexes: Index[]): TableAst {
return {
name,
columns: Object.fromEntries(columns.map((column) => [column.name, columnFromConfig(column)])),
indexes: Object.fromEntries(indexes.map((index) => [index.config.name, indexFromConfig(index)])),
}
}
function columnFromConfig(column: SQLiteColumn): ColumnAst {
return {
name: column.name,
type: column.getSQLType(),
notNull: column.notNull,
primaryKey: column.primary,
...defaultFromColumn(column),
}
}
function defaultFromColumn(column: SQLiteColumn) {
if (column.default !== undefined) return { default: literal(column.default) }
if (column.defaultFn !== undefined) return { default: literal(column.defaultFn()) }
return {}
}
function indexFromConfig(index: Index): IndexAst {
return {
name: index.config.name,
table: getTableName(index.config.table),
columns: index.config.columns.map(indexColumnName),
unique: index.config.unique,
...(index.config.where === undefined ? {} : { where: compileSql(index.config.where) }),
}
}
function indexColumnName(column: SQLiteColumn | SQL) {
if ("name" in column) return { type: "column", name: column.name } as const
return { type: "expression", sql: compileSql(column) } as const
}
function compileSql(value: SQL) {
return value.getSQL().toQuery(new SQLiteCompiler()).sql.replace(/"(?:""|[^"])*"\./g, "")
}
function indexColumnSql(column: IndexColumnAst) {
if (column.type === "column") return quoteIdentifier(column.name)
return column.sql
}
function columnSql(column: ColumnAst, includePrimaryKey: boolean) {
return [
quoteIdentifier(column.name),
column.type,
includePrimaryKey && column.primaryKey ? "PRIMARY KEY" : undefined,
column.notNull ? "NOT NULL" : undefined,
column.default === undefined ? undefined : `DEFAULT ${column.default}`,
]
.filter((part) => part !== undefined)
.join(" ")
}
class SQLiteCompiler {
inlineParams = true
escapeName = (name: string) => {
return quoteIdentifier(name)
}
escapeParam = () => {
return "?"
}
escapeString = (value: string) => {
return `'${value.replaceAll("'", "''")}'`
}
}
function literal(value: unknown) {
if (typeof value === "number") return String(value)
if (typeof value === "boolean") return value ? "1" : "0"
if (value === null) return "NULL"
return `'${String(value).replaceAll("'", "''")}'`
}
function quoteIdentifier(value: string) {
return `"${value.replaceAll('"', '""')}"`
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { Context, Schema } from "effect"
import { AbsolutePath } from "./schema"
export * as Location from "./location"
export const Ref = Schema.Struct({
directory: Schema.String,
directory: AbsolutePath,
workspaceID: Schema.optional(Schema.String),
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
+2
View File
@@ -3,6 +3,8 @@ import { AccountV2 } from "../account"
import { EventV2 } from "../event"
import { PluginV2 } from "../plugin"
// Depending on what account is active, enable matching providers for that
// service
export const AccountPlugin = PluginV2.define({
id: PluginV2.ID.make("account"),
effect: Effect.gen(function* () {
+1 -2
View File
@@ -56,7 +56,6 @@ export const ModelsDevPlugin = PluginV2.define({
const catalog = yield* Catalog.Service
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const load = yield* catalog.loader()
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
const data = yield* modelsDev.get()
@@ -114,7 +113,7 @@ export const ModelsDevPlugin = PluginV2.define({
yield* refresh()
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => refresh()),
Effect.forkIn(scope, { startImmediately: true }),
Effect.forkScoped({ startImmediately: true }),
)
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
})
+130
View File
@@ -0,0 +1,130 @@
export * as Project from "./project"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AppFileSystem } from "./filesystem"
import { AppProcess } from "./process"
import { AbsolutePath, withStatics } from "./schema"
import type { Location } from "./location"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({
global: schema.make("global"),
})),
)
export type ID = typeof ID.Type
export interface Interface {
readonly create: (input: AbsolutePath) => Promise<ID>
readonly locations: (projectID: ID) => Promise<Location.Ref[]>
// opencode -> ["~/dev/projects/anomalyco/opencode", "~/.gitworktrees/anomalyci/opencode"]
// global -> ["~/.config/nvim", "/etc/nixos"]
readonly resolve: (input: AbsolutePath) => Promise<ID>
// ~/dev/projects/anomalyco/opencode -> opencode
// ~/dev/projects/anomalyco/opencode/packages/core -> opencode
// ~/.gitworktrees/anomalyci/opencode -> opencode
// ~/.config/nvim -> global
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
interface GitResult {
readonly exitCode: number
readonly text: () => string
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const proc = yield* AppProcess.Service
const runGit = Effect.fn("Project.git")(
function* (args: string[], cwd: string) {
const result = yield* proc.run(
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
}),
)
return {
exitCode: result.exitCode,
text: () => result.stdout.toString("utf8"),
} satisfies GitResult
},
Effect.catch(() =>
Effect.succeed({
exitCode: 1,
text: () => "",
} satisfies GitResult),
),
)
const resolveGitPath = (cwd: string, value: string) => {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
const normalized = AppFileSystem.windowsPath(trimmed)
if (path.isAbsolute(normalized)) return path.normalize(normalized)
return path.resolve(cwd, normalized)
}
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map((x) => ID.make(x)),
Effect.catch(() => Effect.void),
)
})
const resolve = async (input: AbsolutePath) =>
Effect.runPromise(
Effect.gen(function* () {
const repoPath = yield* fs.up({ targets: [".git"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.void),
)
if (!repoPath) return ID.global
const cwd = path.dirname(repoPath)
const parsed = yield* runGit(["rev-parse", "--git-dir", "--git-common-dir"], cwd)
if (parsed.exitCode !== 0) return (yield* readCachedProjectId(repoPath)) ?? ID.global
const gitPaths = parsed
.text()
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
const commonDir = gitPaths[1] ? resolveGitPath(cwd, gitPaths[1]) : undefined
if (!commonDir) return (yield* readCachedProjectId(repoPath)) ?? ID.global
const cached = (yield* readCachedProjectId(repoPath)) ?? (yield* readCachedProjectId(commonDir))
if (cached) return cached
const id = (yield* runGit(["rev-list", "--max-parents=0", "HEAD"], cwd))
.text()
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
.toSorted()[0]
if (!id) return ID.global
yield* fs.writeFileString(path.join(commonDir, "opencode"), id).pipe(Effect.ignore)
return ID.make(id)
}),
)
return Service.of({
create: async () => {
throw new Error("Project.create is not implemented")
},
locations: async () => [],
resolve,
})
}),
)
+12
View File
@@ -10,6 +10,18 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
*/
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
/**
* Relative file path (e.g., `src/components/Button.tsx`).
*/
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
export type RelativePath = Schema.Schema.Type<typeof RelativePath>
/**
* Absolute file path (e.g., `/home/user/projects/myapp/src/main.ts`).
*/
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
export type AbsolutePath = Schema.Schema.Type<typeof AbsolutePath>
/**
* Optional public JSON field that can hold explicit `undefined` on the type
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.
-13
View File
@@ -1,13 +0,0 @@
export * as Session from "./session"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => ({
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
})),
)
export type ID = typeof ID.Type
@@ -1,11 +1,11 @@
import { Schema } from "effect"
import { EventV2 } from "./event"
import { ModelV2 } from "./model"
import { NonNegativeInt } from "./schema"
import { Session } from "./session"
import { FileAttachment, Prompt } from "./session-prompt"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./v2-schema"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { NonNegativeInt } from "../schema"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { Session } from "./index"
import { FileAttachment, Prompt } from "./prompt"
export { FileAttachment }
@@ -399,4 +399,4 @@ export const All = Schema.Union(
export type Event = typeof All.Type
export type Type = Event["type"]
export * as SessionEvent from "./session-event"
export * as SessionEvent from "./event"
+127
View File
@@ -0,0 +1,127 @@
export * as Session from "."
import { Effect, Schema } from "effect"
import { AbsolutePath, RelativePath, withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { Project } from "../project"
import { Workspace } from "../workspace"
import type { ModelV2 } from "../model"
import { Location } from "../location"
import type { SessionMessage } from "./message"
import type { Prompt } from "./prompt"
import type { EventV2 } from "../event"
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery",
})
export type Delivery = Schema.Schema.Type<typeof Delivery>
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => ({
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
})),
)
export type ID = typeof ID.Type
export const Info = Schema.Struct({
id: ID,
location: Location.Ref,
subpath: RelativePath, // derived from location
project: Project.ID, // derived from location
})
export type Info = typeof Info.Type
// get project -> project.locations
//
// get all sessions
//
// - by project
// - by subpath
// - by workspace (home is special)
type Cursor = {}
type ListInput = {
workspaceID?: Workspace.ID
search?: string
cursor?: Cursor
limit?: number
order?: "asc" | "desc"
} & (
| {
project: Project.ID
subpath?: RelativePath
}
| {
directory?: AbsolutePath
}
)
type CreateInput = {
id?: ID
agent?: string
model?: ModelV2.Ref
location: Location.Ref
}
type MoveInput = {
sessionID: ID
location: Location.Ref
}
type CompactInput = {
sessionID: ID
prompt?: Prompt
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
sessionID: ID,
}) {}
export type Error = NotFoundError
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError>
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
readonly messages: (input: {
sessionID: ID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
time: number
direction: "previous" | "next"
}
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
readonly context: (sessionID: ID) => Effect.Effect<SessionMessage.Message[], NotFoundError>
readonly switchAgent: (input: { sessionID: ID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: ID; model: ModelV2.Ref }) => Effect.Effect<void, never>
readonly prompt: (input: {
id?: EventV2.ID
sessionID: ID
prompt: Prompt
delivery?: Delivery
resume?: boolean
}) => Effect.Effect<void, NotFoundError>
readonly shell: (input: {
id?: EventV2.ID
sessionID: ID
command: string
delivery?: Delivery
resume?: boolean
}) => Effect.Effect<void, never>
readonly skill: (input: {
id?: EventV2.ID
sessionID: ID
skill: string
delivery?: Delivery
resume?: boolean
}) => Effect.Effect<void, never>
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError>
readonly wait: (id: ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: ID) => Effect.Effect<void>
}
@@ -1,6 +1,6 @@
import { produce, type WritableDraft } from "immer"
import { SessionEvent } from "./session-event"
import { SessionMessage } from "./session-message"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
export type MemoryState = {
messages: SessionMessage.Message[]
@@ -414,4 +414,4 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
return adapter.finish()
}
export * as SessionMessageUpdater from "./session-message-updater"
export * as SessionMessageUpdater from "./message-updater"
@@ -1,10 +1,10 @@
import { Schema } from "effect"
import { Prompt } from "./session-prompt"
import { SessionEvent } from "./session-event"
import { EventV2 } from "./event"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./v2-schema"
import { ModelV2 } from "./model"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event"
import { Prompt } from "./prompt"
export const ID = EventV2.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -170,4 +170,4 @@ export type Message = Schema.Schema.Type<typeof Message>
export type Type = Message["type"]
export * as SessionMessage from "./session-message"
export * as SessionMessage from "./message"
+49
View File
@@ -0,0 +1,49 @@
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Session } from "."
export const SessionTable = sqliteTable(
"session",
{
id: text().$type<Session.ID>().primaryKey(),
project_id: text().notNull(),
workspace_id: text(),
parent_id: text().$type<Session.ID>(),
slug: text().notNull(),
directory: text().notNull(),
path: text(),
title: text().notNull(),
version: text().notNull(),
share_url: text(),
summary_additions: integer(),
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
tokens_output: integer().notNull().default(0),
tokens_reasoning: integer().notNull().default(0),
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }),
permission: text({ mode: "json" }),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
providerID: string
variant?: string
}>(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
time_updated: integer()
.notNull()
.$onUpdate(() => Date.now()),
time_compacting: integer(),
time_archived: integer(),
},
(table) => [
index("session_project_idx").on(table.project_id),
index("session_workspace_idx").on(table.workspace_id),
index("session_parent_idx").on(table.parent_id),
],
)
+11
View File
@@ -0,0 +1,11 @@
export * as Workspace from "./workspace"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({ create: () => schema.make("wrk_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
@@ -0,0 +1,158 @@
import { describe, expect, test } from "bun:test"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
const rand = (seed: number) => () => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 0x100000000
}
describe("DatabaseMigration", () => {
test("diff creates missing tables before indexes", () => {
const table = makeTable(
"session",
[makeColumn("id", { primaryKey: true })],
[makeIndex("session_id_idx", "session", ["id"])],
)
expect(DatabaseMigration.diff(emptySchema(), schema(table))).toEqual([
{ type: "create_table", table },
{ type: "create_index", index: table.indexes.session_id_idx },
])
})
test("diff adds missing columns and indexes without recreating existing tables", () => {
const actual = makeTable("session", [makeColumn("id", { primaryKey: true })], [])
const desired = makeTable(
"session",
[makeColumn("id", { primaryKey: true }), makeColumn("title", { notNull: true })],
[makeIndex("session_title_idx", "session", ["title"])],
)
expect(DatabaseMigration.diff(schema(actual), schema(desired))).toEqual([
{ type: "add_column", table: "session", column: desired.columns.title },
{ type: "create_index", index: desired.indexes.session_title_idx },
])
})
test("diff is empty when actual already satisfies desired", () => {
const table = makeTable(
"session",
[makeColumn("id", { primaryKey: true }), makeColumn("title")],
[makeIndex("session_title_idx", "session", ["title"])],
)
expect(DatabaseMigration.diff(schema(table), schema(table))).toEqual([])
})
test("random desired schemas generate exactly missing additive operations", () => {
for (let seed = 1; seed <= 200; seed++) {
const random = rand(seed)
const desiredTables = Array.from({ length: 1 + Math.floor(random() * 5) }, (_, i) =>
makeRandomTable(random, `table_${i}`),
)
const actualTables = desiredTables
.filter(() => random() > 0.25)
.map((table) =>
makeTable(
table.name,
Object.values(table.columns).filter((column) => column.primaryKey || random() > 0.35),
Object.values(table.indexes).filter(() => random() > 0.5),
),
)
const operations = DatabaseMigration.diff(schema(...actualTables), schema(...desiredTables))
const expected = desiredTables.flatMap<DatabaseMigration.Operation>((table) => {
const actual = actualTables.find((item) => item.name === table.name)
if (!actual) {
return [createTableOperation(table), ...Object.values(table.indexes).map(createIndexOperation)]
}
return [
...Object.values(table.columns)
.filter((column) => actual.columns[column.name] === undefined)
.map((column) => addColumnOperation(table.name, column)),
...Object.values(table.indexes)
.filter((index) => actual.indexes[index.name] === undefined)
.map(createIndexOperation),
]
})
expect(operations).toEqual(expected)
}
})
test("random operations render quoted SQL", () => {
for (let seed = 1; seed <= 200; seed++) {
const random = rand(seed)
const table = makeRandomTable(random, `table_"${seed}`)
const operations = DatabaseMigration.diff(emptySchema(), schema(table))
for (const operation of operations) {
const rendered = DatabaseMigration.toSql(operation)
expect(rendered).not.toContain("undefined")
expect(rendered).toContain('"')
}
}
})
})
function emptySchema(): DatabaseMigration.SchemaAst {
return { tables: {} }
}
function schema(...tables: DatabaseMigration.TableAst[]): DatabaseMigration.SchemaAst {
return { tables: Object.fromEntries(tables.map((table) => [table.name, table])) }
}
function makeTable(
name: string,
columns: DatabaseMigration.ColumnAst[],
indexes: DatabaseMigration.IndexAst[],
): DatabaseMigration.TableAst {
return {
name,
columns: Object.fromEntries(columns.map((column) => [column.name, column])),
indexes: Object.fromEntries(indexes.map((index) => [index.name, index])),
}
}
function createTableOperation(table: DatabaseMigration.TableAst): DatabaseMigration.Operation {
return { type: "create_table", table }
}
function addColumnOperation(table: string, column: DatabaseMigration.ColumnAst): DatabaseMigration.Operation {
return { type: "add_column", table, column }
}
function createIndexOperation(index: DatabaseMigration.IndexAst): DatabaseMigration.Operation {
return { type: "create_index", index }
}
function makeColumn(
name: string,
options: Partial<Omit<DatabaseMigration.ColumnAst, "name" | "type">> = {},
): DatabaseMigration.ColumnAst {
return {
name,
type: "text",
notNull: options.notNull ?? false,
primaryKey: options.primaryKey ?? false,
...(options.default === undefined ? {} : { default: options.default }),
}
}
function makeIndex(name: string, table: string, columns: string[], unique = false): DatabaseMigration.IndexAst {
return { name, table, columns, unique }
}
function makeRandomTable(random: () => number, name: string) {
const columns = Array.from({ length: 1 + Math.floor(random() * 8) }, (_, i) =>
makeColumn(`column_${i}`, {
primaryKey: i === 0,
notNull: i === 0 || random() > 0.5,
default: random() > 0.7 ? String(Math.floor(random() * 100)) : undefined,
}),
)
const indexes = columns
.filter((column) => !column.primaryKey && random() > 0.5)
.map((column) => makeIndex(`${name}_${column.name}_idx`, name, [column.name], random() > 0.75))
return makeTable(name, columns, indexes)
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { SyncEvent } from "@/sync"
import { EventV2 } from "@opencode-ai/core/event"
import "@opencode-ai/core/account"
import "@opencode-ai/core/catalog"
import "@opencode-ai/core/session-event"
import "@opencode-ai/core/session/event"
import { Context, Effect, Layer, Option } from "effect"
export function toSyncDefinition<D extends EventV2.Definition>(definition: D) {
@@ -1,5 +1,5 @@
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
@@ -1,6 +1,6 @@
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { Prompt } from "@opencode-ai/core/session-prompt"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionV2 } from "@/v2/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
@@ -1,4 +1,4 @@
import { SessionMessage } from "@opencode-ai/core/session-message"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@/v2/session"
import { Effect, Schema } from "effect"
import * as DateTime from "effect/DateTime"
+1 -1
View File
@@ -19,7 +19,7 @@ import { isOverflow as overflow, usable } from "./overflow"
import { serviceUse } from "@/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session-event"
import { SessionEvent } from "@opencode-ai/core/session/event"
const log = Log.create({ service: "session.compaction" })
+1 -1
View File
@@ -22,7 +22,7 @@ import { errorMessage } from "@/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session-event"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
@@ -1,8 +1,8 @@
import { and, desc, eq } from "@/storage/db"
import type { Database } from "@/storage/db"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { SessionMessageUpdater } from "@opencode-ai/core/session-message-updater"
import { SessionEvent } from "@opencode-ai/core/session-event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionEvent } from "@opencode-ai/core/session/event"
import * as DateTime from "effect/DateTime"
import { SyncEvent } from "@/sync"
import { EventV2Bridge } from "@/event-v2-bridge"
+2 -2
View File
@@ -48,10 +48,10 @@ import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session-event"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session-prompt"
import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
import { Reference } from "@/reference/reference"
import * as DateTime from "effect/DateTime"
import { eq } from "@/storage/db"
+1 -1
View File
@@ -1,7 +1,7 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { Session as CoreSession } from "@opencode-ai/core/session"
import { Session as CoreSession } from "@opencode-ai/core/session/index"
import { withStatics } from "@opencode-ai/core/schema"
export const SessionID = CoreSession.ID
+1 -1
View File
@@ -1,7 +1,7 @@
import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2"
import type { SessionMessage } from "@opencode-ai/core/session-message"
import type { SessionMessage } from "@opencode-ai/core/session/message"
import type { Snapshot } from "../snapshot"
import type { Permission } from "../permission"
import type { ProjectID } from "../project/schema"
+5 -14
View File
@@ -3,11 +3,8 @@ import { SessionID } from "@/session/schema"
import { WorkspaceID } from "@/control-plane/schema"
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
import * as Database from "@/storage/db"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { SessionMessage } from "@opencode-ai/core/session-message"
import type { Prompt } from "@opencode-ai/core/session-prompt"
import { ProjectID } from "@/project/schema"
import { SessionEvent } from "@opencode-ai/core/session-event"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { V2Schema } from "@opencode-ai/core/v2-schema"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import { EventV2 } from "@opencode-ai/core/event"
@@ -88,9 +85,9 @@ export interface Interface {
readonly list: (input: {
limit?: number
order?: "asc" | "desc"
directory?: string
path?: string
projectID?: ProjectID
workspaceID?: WorkspaceID
path?: string
roots?: boolean
start?: number
search?: string
@@ -113,24 +110,18 @@ export interface Interface {
readonly context: (
sessionID: SessionID,
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
readonly prompt: (input: {
id?: EventV2.ID
sessionID: SessionID
prompt: Prompt
delivery?: Delivery
}) => Effect.Effect<SessionMessage.User, NotFoundError | OperationUnavailableError>
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
readonly subagent: (input: {
id?: EventV2.ID
parentID: SessionID
prompt: Prompt
agent: string
model?: ModelV2.Ref
resume?: boolean
}) => Effect.Effect<void, NotFoundError | OperationUnavailableError | MessageDecodeError>
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void, never>
readonly compact: (sessionID: SessionID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly resume: (sessionID: SessionID) => Effect.Effect<void>
readonly wait: (sessionID: SessionID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
}
@@ -20,7 +20,7 @@ import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from ".
import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db"
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
@@ -4,8 +4,8 @@ import { SessionID } from "../../src/session/schema"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionEvent } from "@opencode-ai/core/session-event"
import { SessionMessageUpdater } from "@opencode-ai/core/session-message-updater"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
test("step snapshots carry over to assistant messages", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }